Redis用于频率限制上踩过的坑
閱讀本文約花費: 5 (分鐘)
背景
今天分享下前段时间遇到的一个case,相信大家都有做过类似频率限制的东西,我们的也有类似的业务场景,某个接口或者功能需要限制用户一段时间内的访问量,我们的解决方案是通过Redis去做,一方面是由于Redis完全是内存访问性能比较高,另一方面系统是分布式的,如果是单机的或者说只需要限制单机访问的QPS那么可以采用Guava
的RateLimiter
。
现象
比如有这么一个场景,接口A限制用户30S内只能调用3次,但出现了一个诡异的现象是,已经过了这个时间还是不能调用,查看应用日志、外部依赖都没有发现异常。
问题定位
首先看一下应用最近有没有发布过,是不是新功能导致的,然而并没有。因为这段代码最近一直没有改动,而且一直没遇到过类似的问题,因此开始怀疑代码逻辑有漏洞,一层一层拨开迷雾,找到最核心的代码,伪码如下:
Jedis redis = getRedis(); try { redis.set(SafeEncoder.encode(key), SafeEncoder.encode(def + ""), "nx".getBytes(), "ex".getBytes(), exp); Long count = redis.incrBy(key.getBytes(), val); } finally { redis.close(); }
做的事情很简单,第一set命令就是说若key不存在则将值设置为def,并且设置过期时间,然后incrBy命令自增val,因此这里如果val传递了0则可以获取当前值,但是这里其实有一个问题,不是很容易复现,但是一旦出现用户就不能调用接口了。
问题
假设应用在调用这个方法,在时间点t1执行set命令,并发现key是存在的,那么就不会设置过期时间,也不会去设置默认值,然后再时间点t2调用incrBy命令,但是如果这里key刚好在t1和t2之间过期的话,那么这个key就会一直存在,也就会导致上述的问题。
- 客户端执行set命令,这个时候key还未过期,因此set命令不会设置value也不会设置过期时间
- set命令执行完毕,这个时候key过期
- 客户端执行incrBy命令,因为上一步中key已经过期,因此这里的incrBy命令相当于在一个新的key上自增,但这里的关键是没有设置过期时间,也就是说key会一直存在。
解决方案
这里提出一种解决方案,首先分析一下这段代码想做什么,传递一个key和默认值以及一个过期时间,需求就是自增并且能够过期。那么分析之后发现其实不需要set命令,下面给出一个解决方案:
try (Jedis redis = getRedis()) { Long count = redis.incrBy(key.getBytes(), val); if (count == val) { redis.expire(key, exp); } }
首先调用incrBy命令自增,如果incrBy返回的值等于val,那么说明这是第一次调用因此需要设置下过期时间。 但其实这里还是有个问题,如果incrBy和expire这两个命令执行之间发生了异常,比如连接断掉等,但是incrBy命令执行成功了,而expire没有得到执行,那么这个key也会永远存在,因为代码设置过期时间的条件是第一次自增的时候, 但这个概率一般来说非常小了,如果想避免类似的情况发生,最好改成lua脚本,我们知道lua脚本执行时原子的,而且之前的方案涉及到了两次网络调用,而改成lua脚本这样就只有一次网络调用,如果还想优化那么可以改成evalsha命令,避免每次都需要传递lua脚本避免额外的网络开销。当然这里其实还有很多其他的方案,这里只是给出一种方案。
经验教训
分布式、高并发系统是一个很复杂的领域,编写相关的代码也需要更好的意识,写完代码后,我们需要仔细分析下代码在各种case下的表现,比如其中一个服务超时了,这个时候如何处理,是重试还是直接往上层抛异常等,以及代码在高并发下会如何表现等等。 我的建议是多多阅读优秀的代码,多思考他们是如何处理各种case的,包括日志、异常的处理等等,多学习、多踩坑才能更快的成长。