blog icon indicating copy to clipboard operation
blog copied to clipboard

Redis用于频率限制上踩过的坑

Open aCoder2013 opened this issue 6 years ago • 10 comments

背景

今天分享下前段时间遇到的一个case,相信大家都有做过类似频率限制的东西,我们的也有类似的业务场景,某个接口或者功能需要限制用户一段时间内的访问量,我们的解决方案是通过Redis去做,一方面是由于Redis完全是内存访问性能比较高,另一方面系统是分布式的,如果是单机的或者说只需要限制单机访问的QPS那么可以采用GuavaRateLimiter

现象

比如有这么一个场景,接口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就会一直存在,也就会导致上述的问题。

  1. 客户端执行set命令,这个时候key还未过期,因此set命令不会设置value也不会设置过期时间
  2. set命令执行完毕,这个时候key过期
  3. 客户端执行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的,包括日志、异常的处理等等,多学习、多踩坑才能更快的成长。

Flag Counter

aCoder2013 avatar Apr 29 '18 14:04 aCoder2013

Lua脚本之前在前公司也经常使用。但是Lua脚本有一个问题,就是在多主的Redis集群中不适合使用。因为一个Lua脚本可能同时涉及多个键的操作,多个键可能分布在不同的master上。当然如果只操作一个键也是可以的。

Dragonriver1990 avatar Apr 30 '18 15:04 Dragonriver1990

@Dragonriver1990 恩,我的理解是如果涉及到多个键的话可以在应用层解决,也可以利用hash tag将多个key存储到同一个redis实例,当然也有些业务场景这两种都不适用

aCoder2013 avatar May 01 '18 04:05 aCoder2013

分析得很好,学习了、、 有个问题,如果在执行到redis.expire(key, exp); 异常或者宕机了, 是不是这个key永久不会超时了呢

onelee85 avatar May 02 '18 03:05 onelee85

@onelee85 确实有这种可能,如果是sentinel模式部署,也可能执行到expire的时候master宕机,这个时候master切换到slave,但是incrBy还未同步到slave,也可能会造成丢数据,这里存在好几种corner case,具体如何处理还是要看业务场景

aCoder2013 avatar May 02 '18 10:05 aCoder2013

关于宕机的说法,我觉得用lua脚本的原子性去保证,至于分布式可以使用一些技巧让键尽量分配至一个实例上

liuweiccy avatar May 03 '18 03:05 liuweiccy

不太明白解决方案的代码呢,假设按 30s 3次,服务启动后

第 0秒一次 计数=1
第31秒一次 计数=2
第61秒一次 计数=3 ,expire 30
第62秒一次 计数=4 ,//失败了

这种情况是预期的么?

wen-long avatar Jul 29 '18 14:07 wen-long

@wen-long incrBy会返回自增后的新值,所以if条件只有第一次会满足,那段代码给的不全面,必须限制30S 3次,那么每次自增的val都是1,也就是只有第一次才会设置过期时间,但这段代码还是有很低的概率出问题,我更新了下

aCoder2013 avatar Jul 30 '18 01:07 aCoder2013

http://redisdoc.com/string/incr.html#incr 这里有简单的介绍

ring0li avatar Nov 05 '18 23:11 ring0li

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,接着消费处理
                        }
                   }
              }
	   
	}
}

newhcw avatar Apr 29 '19 11:04 newhcw

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 失败不一定会抛出异常

linchuanuestc avatar Jun 05 '19 07:06 linchuanuestc