背景
最近公司出了一起故障,問題程式碼如下:
/**
* TRUE: 觸發限流,FALSE:未觸發限流
*/
public function acquire() {
try {
$redisHandler = $this->redisInstance->getHandler();
$redisHandler->set($this->rateLimitKey, $this->tokenNum, ['nx', 'ex' => $this->expireTime]);
$leftTokenNum = $redisHandler->decr($this->rateLimitKey);
if ($leftTokenNum < 0) {
return TRUE;
}
return FALSE;
} catch (\Exception $e) {
return FALSE;
}
}
作者的目的是針對爆款商品的購買,使用 redis 來起到一個限流的作用,1 秒鐘只允許 1 人購買。
結果上線過後不久,運營就反饋線上出故障了,該爆款商品所有人都不能購買了。
分析
上面程式碼的思路很簡單:通過 $redis->set('key', '1', ['nx', 'ex'=>1]);
命令,設定值為 1 過期時間為 1 秒的計數器,基於該計數器的扣減來達到 1 秒鐘放行 1 個請求的目的。
測試
我們簡化一下上面的程式碼,
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$key = 'test_redis_key';
$redis->set($key, '1', ['nx', 'ex' => 1]);
$left = $redis->decr($key);
if ($left < 0) {
// 這裡通過狀態碼來更方便的觀察
header('Is-Limited:1', true, 500);
} else {
header('Is-Limited:0', true, 200);
}
簡化後使用 siege 模擬 100 個使用者併發壓測一下。
非常穩啊,1 秒鐘通過 1 個請求。
我們的開發同學也就是經過了上述測試才放心把程式碼發上線的,咋一上線就炸了呢?
原因
我們來看下面一段操作,
[root@e98dffb83384 src]# ./redis-cli
127.0.0.1:6379> SETNX k 1
(integer) 1
127.0.0.1:6379> EXPIRE k 10 # 為了方便演示,這裡設定 10 秒過期時間
(integer) 1
127.0.0.1:6379> DECR k # 在過期時間內,第一次扣減成 0
(integer) 0
127.0.0.1:6379> DECR k # 繼續扣減成 -1
(integer) -1
127.0.0.1:6379> DECR k # 繼續扣減成 -2
(integer) -2
127.0.0.1:6379> TTL k # k 還有 2 秒過期
(integer) 2
127.0.0.1:6379> DECR k # 繼續扣減成 -3
(integer) -3
127.0.0.1:6379> TTL k # 距離設定過期時間 10 秒之後,k 已經過期
(integer) -2
127.0.0.1:6379> DECR k # 這時候再扣減發現 k 的值被扣減成 -1
(integer) -1
127.0.0.1:6379> DECR k # 繼續扣減成 -2
(integer) -2
127.0.0.1:6379> TTL k # 檢視 k 過期時間是永不過期
(integer) -1
127.0.0.1:6379> SETNX k 3 # 再設定是不成功的
(integer) 0
127.0.0.1:6379> DECR k # 繼續扣減成 -3
(integer) -3
在 Redis key 未過期之前,DECR
命令都是正常扣減的。一旦 key 過期了,再執行 DECR
命令,會發現 key 的值和過期時間都變為 -1 了。
Redis 官網對 DECR
命令介紹裡有這麼一段:
Decrements the number stored at key by one. If the key does not exist, it is set to 0 before performing the operation.
對於出問題的程式碼,
$redisHandler->set($this->rateLimitKey, $this->tokenNum, ['nx', 'ex' => $this->expireTime]);
$leftTokenNum = $redisHandler->decr($this->rateLimitKey);
假設在第一句 SETNX
之後第二句 DECR
之前,key 過期了,再執行 DECR
就會先生成一個永不過期值為 0 的 key。
之後所有請求的 SETNX
都是 fasle,一直會基於這個永不過期的 key 進行遞減,所有的 $leftTokenNum
都小於 0,因此導致所有請求被限流。
問題復現
自測時為啥發現不了問題?因為自測時設定的過期時間是 1 秒,導致 key 在兩步之間過期出現的概率很小。我們只要將過期時間調的足夠小,很容易復現問題。
把過期時間改為 5 毫秒,
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$key = 'test_redis_key';
$redis->set($key, '3', ['nx', 'px' => 5]); // key 設定成 5 毫秒過期
$left = $redis->decr($key);
if ($left < 0) {
// 這裡通過狀態碼來更方便的觀察
header('Is-Limited:1', true, 500);
} else {
header('Is-Limited:0', true, 200);
}
依然使用 siege 壓測:
由於設定的 5 毫秒放行一個請求,因此前半部分基本上都是通過的請求,偶爾有幾個限流的,這是正常的。
但是沒過多久,所有請求都被限流了,也就復現了線上的故障。
解決方案
如何改進程式碼來正確的實現限流呢?
Redis 的 EVAL
命令 執行 Lua 指令碼時可以保證原子性。
Atomicity of scripts
Redis uses the same Lua interpreter to run all the commands. Also Redis guarantees that a script is executed in an atomic way: no other script or Redis command will be executed while a script is being executed.
EVAL
命令的格式為:
EVAL script numkeys key [key ...] arg [arg ...]
例子:
> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"
我們可以藉助 Lua 指令碼來實現 SETNX
和 DECR
之間會出現過期的尷尬情況。
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$key = 'test_redis_key1';
$script = <<<LUA
local max = tonumber(ARGV[1])
local interval_milliseconds = tonumber(ARGV[2])
local current = tonumber(redis.call('get', KEYS[1]) or 0)
if (current + 1 > max) then
return true
else
redis.call('incrby', KEYS[1], 1)
if (current == 0) then
redis.call('pexpire', KEYS[1], interval_milliseconds)
end
return false
end
LUA;
$redis->script('load', $script);
$isLimited = $redis->eval($script, [$key, 1, 5], 1); // key 5 毫秒過期
if ($isLimited) {
header('Is-Limited:1', true, 500);
} else {
header('Is-Limited:0', true, 200);
}
依然使用 siege 壓測,
持續壓了 10 多分鐘也沒出現之前問題,問題得以解決。
總結
- Redis 中
DECR
一個不存在的 key 會先把 key 值設定為 0 , TTL 設定為 -1(永不過期),再進行減 1 操作。 - 使用
SETNX
配合DECR
實現限流,會出現 key 永不過期情況。過期時間比較小或者高併發情況下,發生概率更高。 - 在 Redis 中執行 Lua 指令碼是原子操作。
- 可以通過 Redis + Lua 實現高併發下的限流。
本作品採用《CC 協議》,轉載必須註明作者和本文連結