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

812 阅读3分钟

背景

今天分享下前段时间遇到的一个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,那么说明这是第一次调用因此需要设置下过期时间,这里涉及到了两次网络调用,因此可以改成lua脚本这样就只有一次网络调用,如果还想优化那么可以改成evalsha命令,避免每次都需要传递lua脚本避免额外的网络开销。

经验教训

分布式、高并发系统是一个很复杂的领域,编写相关的代码也需要更好的意识,写完代码后,我们需要仔细分析下代码在各种case下的表现,比如其中一个服务超时了,这个时候如何处理,是重试还是直接往上层抛异常等,以及代码在高并发下会如何表现等等。 我的建议是多多阅读优秀的代码,多思考他们是如何处理各种case的,包括日志、异常的处理等等,多学习、多踩坑才能更快的成长。