redis用作访问流量限制时遇到的问题

最近公司有新的需求是记录登陆用户在给定时间段内所发起的请求次数,如果超过了设定好的上限,就触发警告,屏蔽之类的惩罚行为。我们采用redis的string类型来进行计数操作。
最早的代码版本如下,第一个用户请求都会触发userRequestManager类下的计数方法,调用counter类的record()方法来给当前用户对应的key进行自增操作,这里的逻辑是,如果原key不存在,那么在创建这个key后赋予它过期时间,若存在,则只进行自增操作,不去改变原有的过期时间,直到该key自动过期,重新计数。通过isExceeded()方法判断当前计数是否已经过超过设定的上限,如果超过了,就清0并执行惩罚操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class userRequestManager
{
//...other code
$user_request_counter->record();
if ($user_request_counter->isExceeded()){
$user_request_counter->clear();
//...other code
}

class counter
{
//...other code
public function record($key){
$count = $this->redis->get($key);
$this->redis->incr($key);
if ($count === false){
$this->redis->expire($this->key, $this->duration);
}
}
public function clear(){
$this->redis->del($this->key);
}
//...other code
}

因为redis有原子性的自增和自动过期的特性,原以为可以保证数据的准确性。但上线后还是马上出现了问题。问题具体表现为过了一段时间出现大量的用户触发上限被警报,而一般用户的操作是不会轻易达到上限的,于是开始分析问题所在。
经过排查,发现线上有大量的key过期时间都被设为永久,也就是说永远不过期,这就说明了这些key会一直增长到上限导致用户被惩罚,并不会过期清0。然而我们在测试环境和stage环境不管怎么调试都无法重现这样的状况。
后来经过与同事的讨论,想到了引发该问题的可能性:
counter类里对redis的每个操作之间,是序列化执行的,没有事务的概念,所以,一个key有可能在执行get和incr操作之间过期,如果发生这种情况,虽然该key会被从0开始计数,但是此时并不会执行expire操作给它设置过期时间。所以该key就会永久性地存在redis里,不难理解会发生如上诡异的问题了。
发现问题后,赶紧修改代码,如下

1
2
3
4
5
6
7
8
9
10
11
class counter
{
//...other code
public function record() {
$key = $this->key;
if ($this->redis->incr($key) <= 1){
$this->redis->expire($key, $this->duration);
}
}
//...other code
}

以incr的返回值来判断之前key是否存在,然后再决定是否要设置过期时间。如此改动之后上线代码,发现已经没有之前那种奇怪的情况了。
由此可知,像这样序列化,单线程执行的代码,要千万小心,在lab环境无论如何都不会出现的问题,可能在面对真实用户的条件下,可能会出现一些致命的问题。所幸这此发现较早,也说明实践比纸上谈兵要重要的多。
不过像这样限制用户流量行为的操作,并不建议放在php+redis端来执行,一来开销太大,二来因为类似滑动窗口的情形,判断并不能很精准。我们是因为server入口端没有一个很好的限流方案,暂时用这个方案来做简单的限制。了解到java的一些框架会有提供现成的令牌桶算法(token bucket algorithm)的实现,不知道php方面有没有类似成熟的解决方案。