blog
blog copied to clipboard
Redis用于频率限制上踩过的坑
背景
今天分享下前段时间遇到的一个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的,包括日志、异常的处理等等,多学习、多踩坑才能更快的成长。
Lua脚本之前在前公司也经常使用。但是Lua脚本有一个问题,就是在多主的Redis集群中不适合使用。因为一个Lua脚本可能同时涉及多个键的操作,多个键可能分布在不同的master上。当然如果只操作一个键也是可以的。
@Dragonriver1990 恩,我的理解是如果涉及到多个键的话可以在应用层解决,也可以利用hash tag将多个key存储到同一个redis实例,当然也有些业务场景这两种都不适用
分析得很好,学习了、、 有个问题,如果在执行到redis.expire(key, exp); 异常或者宕机了, 是不是这个key永久不会超时了呢
@onelee85 确实有这种可能,如果是sentinel模式部署,也可能执行到expire的时候master宕机,这个时候master切换到slave,但是incrBy还未同步到slave,也可能会造成丢数据,这里存在好几种corner case,具体如何处理还是要看业务场景
关于宕机的说法,我觉得用lua脚本的原子性去保证,至于分布式可以使用一些技巧让键尽量分配至一个实例上
不太明白解决方案的代码呢,假设按 30s 3次,服务启动后
第 0秒一次 计数=1
第31秒一次 计数=2
第61秒一次 计数=3 ,expire 30
第62秒一次 计数=4 ,//失败了
这种情况是预期的么?
@wen-long incrBy会返回自增后的新值,所以if条件只有第一次会满足,那段代码给的不全面,必须限制30S 3次,那么每次自增的val都是1,也就是只有第一次才会设置过期时间,但这段代码还是有很低的概率出问题,我更新了下
http://redisdoc.com/string/incr.html#incr 这里有简单的介绍
redis> TTL key
当 key 不存在时,返回 -2 。 当 key 存在但没有设置剩余生存时间时,返回 -1 。 否则,以秒为单位,返回 key 的剩余生存时间。
try (Jedis redis = getRedis()) {
Long count = redis.incrBy(key.getBytes(), val);
if (count == val) {
try{
redis.expire(key, exp);
}catche(Exception e){
redis.expire(key, exp);
}finally{
if(redis.ttl(key)==-1){
try{
redis.expire(key, exp);
}finally{
sendMQ(key);##发送给MQ,接着消费处理
}
}
}
}
}
redis> TTL key
当 key 不存在时,返回 -2 。 当 key 存在但没有设置剩余生存时间时,返回 -1 。 否则,以秒为单位,返回 key 的剩余生存时间。
try (Jedis redis = getRedis()) { Long count = redis.incrBy(key.getBytes(), val); if (count == val) { try{ redis.expire(key, exp); }catche(Exception e){ redis.expire(key, exp); }finally{ if(redis.ttl(key)==-1){ try{ redis.expire(key, exp); }finally{ sendMQ(key);##发送给MQ,接着消费处理 } } } } }
expire 失败不一定会抛出异常