背景
最近公司出了一起故障妒潭,問題代碼如下:
/**
* TRUE: 觸發(fā)限流,F(xiàn)ALSE:未觸發(fā)限流
*/
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 人購買搜骡。
結(jié)果上線過后不久拂盯,運營就反饋線上出故障了,該爆款商品所有人都不能購買了记靡。
分析
上面代碼的思路很簡單:通過 $redis->set('key', '1', ['nx', 'ex'=>1]);
命令谈竿,設(shè)置值為 1 過期時間為 1 秒的計數(shù)器团驱,基于該計數(shù)器的扣減來達到 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) {
// 這里通過狀態(tài)碼來更方便的觀察
header('Is-Limited:1', true, 500);
} else {
header('Is-Limited:0', true, 200);
}
簡化后使用 siege 模擬 100 個用戶并發(fā)壓測一下嚎花。
非常穩(wěn)啊,1 秒鐘通過 1 個請求呀洲。
我們的開發(fā)同學(xué)也就是經(jīng)過了上述測試才放心把代碼發(fā)上線的紊选,咋一上線就炸了呢?
原因
我們來看下面一段操作道逗,
[root@e98dffb83384 src]# ./redis-cli
127.0.0.1:6379> SETNX k 1
(integer) 1
127.0.0.1:6379> EXPIRE k 10 # 為了方便演示兵罢,這里設(shè)置 10 秒過期時間
(integer) 1
127.0.0.1:6379> DECR k # 在過期時間內(nèi),第一次扣減成 0
(integer) 0
127.0.0.1:6379> DECR k # 繼續(xù)扣減成 -1
(integer) -1
127.0.0.1:6379> DECR k # 繼續(xù)扣減成 -2
(integer) -2
127.0.0.1:6379> TTL k # k 還有 2 秒過期
(integer) 2
127.0.0.1:6379> DECR k # 繼續(xù)扣減成 -3
(integer) -3
127.0.0.1:6379> TTL k # 距離設(shè)置過期時間 10 秒之后滓窍,k 已經(jīng)過期
(integer) -2
127.0.0.1:6379> DECR k # 這時候再扣減發(fā)現(xiàn) k 的值被扣減成 -1
(integer) -1
127.0.0.1:6379> DECR k # 繼續(xù)扣減成 -2
(integer) -2
127.0.0.1:6379> TTL k # 查看 k 過期時間是永不過期
(integer) -1
127.0.0.1:6379> SETNX k 3 # 再設(shè)置是不成功的
(integer) 0
127.0.0.1:6379> DECR k # 繼續(xù)扣減成 -3
(integer) -3
在 Redis key 未過期之前卖词,DECR
命令都是正常扣減的吏夯。一旦 key 過期了此蜈,再執(zhí)行 DECR
命令,會發(fā)現(xiàn) key 的值和過期時間都變?yōu)?-1 了噪生。
Redis 官網(wǎng)對 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);
假設(shè)在第一句 SETNX
之后第二句 DECR
之前,key 過期了杠园,再執(zhí)行 DECR
就會先生成一個永不過期值為 0 的 key顾瞪。
之后所有請求的 SETNX
都是 fasle,一直會基于這個永不過期的 key 進行遞減抛蚁,所有的 $leftTokenNum
都小于 0陈醒,因此導(dǎo)致所有請求被限流。
問題復(fù)現(xiàn)
自測時為啥發(fā)現(xiàn)不了問題瞧甩?因為自測時設(shè)置的過期時間是 1 秒钉跷,導(dǎo)致 key 在兩步之間過期出現(xiàn)的概率很小。我們只要將過期時間調(diào)的足夠小肚逸,很容易復(fù)現(xiàn)問題爷辙。
把過期時間改為 5 毫秒,
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$key = 'test_redis_key';
$redis->set($key, '3', ['nx', 'px' => 5]); // key 設(shè)置成 5 毫秒過期
$left = $redis->decr($key);
if ($left < 0) {
// 這里通過狀態(tài)碼來更方便的觀察
header('Is-Limited:1', true, 500);
} else {
header('Is-Limited:0', true, 200);
}
依然使用 siege 壓測:
由于設(shè)置的 5 毫秒放行一個請求朦促,因此前半部分基本上都是通過的請求膝晾,偶爾有幾個限流的,這是正常的务冕。
但是沒過多久血当,所有請求都被限流了,也就復(fù)現(xiàn)了線上的故障。
解決方案
如何改進代碼來正確的實現(xiàn)限流呢臊旭?
Redis 的 EVAL
命令 執(zhí)行 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
之間會出現(xiàn)過期的尷尬情況。
$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 壓測离熏,
持續(xù)壓了 10 多分鐘也沒出現(xiàn)之前問題佳谦,問題得以解決。
總結(jié)
- Redis 中
DECR
一個不存在的 key 會先把 key 值設(shè)置為 0 , TTL 設(shè)置為 -1(永不過期)滋戳,再進行減 1 操作钻蔑。 - 使用
SETNX
配合DECR
實現(xiàn)限流,會出現(xiàn) key 永不過期情況胧瓜。過期時間比較小或者高并發(fā)情況下矢棚,發(fā)生概率更高。 - 在 Redis 中執(zhí)行 Lua 腳本是原子操作府喳。
- 可以通過 Redis + Lua 實現(xiàn)高并發(fā)下的限流蒲肋。