New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
aCoder2013 opened this Issue Apr 29, 2018 · 7 comments

Comments

Projects
None yet
5 participants
@aCoder2013
Owner

aCoder2013 commented Apr 29, 2018

背景

今天分享下前段时间遇到的一个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

@Dragonriver1990

This comment has been minimized.

Show comment
Hide comment
@Dragonriver1990

Dragonriver1990 Apr 30, 2018

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

Dragonriver1990 commented Apr 30, 2018

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

@aCoder2013

This comment has been minimized.

Show comment
Hide comment
@aCoder2013

aCoder2013 May 1, 2018

Owner

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

Owner

aCoder2013 commented May 1, 2018

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

@onelee85

This comment has been minimized.

Show comment
Hide comment
@onelee85

onelee85 May 2, 2018

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

onelee85 commented May 2, 2018

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

@aCoder2013

This comment has been minimized.

Show comment
Hide comment
@aCoder2013

aCoder2013 May 2, 2018

Owner

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

Owner

aCoder2013 commented May 2, 2018

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

@liuweiccy

This comment has been minimized.

Show comment
Hide comment
@liuweiccy

liuweiccy May 3, 2018

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

liuweiccy commented May 3, 2018

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

@wen-long

This comment has been minimized.

Show comment
Hide comment
@wen-long

wen-long Jul 29, 2018

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

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

这种情况是预期的么?

wen-long commented Jul 29, 2018

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

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

这种情况是预期的么?

@aCoder2013

This comment has been minimized.

Show comment
Hide comment
@aCoder2013

aCoder2013 Jul 30, 2018

Owner

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

Owner

aCoder2013 commented Jul 30, 2018

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment