Day 25 的 5000 個請求搶 100 件商品,4900 個注定搶不到,卻每個都跑了一次扣庫存。真的秒殺搶到之後還要建訂單、寫 DB,開賣那一秒湧進來的量,後面不一定撐得住。
今天做兩件事:限流,一秒最多放幾個進來,超過的直接擋掉;削峰,搶到的也不要當場寫 DB,先排隊再慢慢處理。
最簡單的限流是計數:進來一個加一,超過上限就擋掉,1 秒到了從 0 重新算。這 1 秒叫一個視窗,1 秒一格、一格接著一格,叫固定視窗。
計數放在 Redis,App 有好幾台,算的也是同一個數字。
新增 resources/scripts/fixed_window.lua:
-- Day 26:固定視窗限流,第一個進來的開始算,1 秒內最多放 ARGV[1] 個
-- KEYS[1]:計數的 key,例如 limit:fixed:1
-- ARGV[1]:上限,例如 100
local count = redis.call('INCR', KEYS[1]) -- 進來一個加一,key 不存在會從 0 開始加
if count == 1 then
redis.call('EXPIRE', KEYS[1], 1) -- 第一個進來的設 1 秒過期,過期了就從頭算
end
if count > tonumber(ARGV[1]) then
return 0 -- 超過上限,擋掉
end
return 1 -- 放進來
INCR 跟 EXPIRE 是兩個指令。跟 SETNX 一樣,中間掛掉的話 key 就沒有過期時間,數字不會歸零,超過 100 之後全部被擋。所以包在 Lua 裡一次做完。
StockController 加一個 /stock/buy/fixed,先過限流,放進來的才扣庫存:
/** 固定視窗:第一個進來的開始算,1 秒內最多放幾個 */
private static final RedisScript<Long> FIXED_WINDOW =
RedisScript.of(new ClassPathResource("scripts/fixed_window.lua"), Long.class);
/** 先過固定視窗,1 秒最多放 100 個進來扣庫存,其他的直接回 429 */
@PostMapping("/buy/fixed")
public ResponseEntity<Long> buyFixed(@RequestParam long id) {
Long pass = redis.execute(FIXED_WINDOW, List.of("limit:fixed:" + id), "100"); // ARGV[1]:上限
if (pass == 0) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build(); // 429,不會往下扣庫存
}
return ResponseEntity.ok(buy(id)); // 放進來的才用 Day 20 的 Lua 扣
}
超過的回 HTTP 狀態碼 429(Too Many Requests),意思是太多人了,等一下再來。
新增 k6/fixed.js,200 個人每秒按一次:
// Day 26:100 件商品,200 個人每秒按一次,打的是有固定視窗限流的 /stock/buy/fixed
// 執行:./k6/run.sh fixed
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 200, // 200 個虛擬使用者同時打
iterations: 1000, // 總共打 1000 次
};
export default function () {
const res = http.post('http://localhost:8080/stock/buy/fixed?id=1');
check(res, {
'被擋下來': (r) => r.status === 429,
'搶到': (r) => r.status === 200 && r.json() >= 0, // 被擋下來的沒有內容,先看 200 再看數字
});
sleep(1); // 按完等 1 秒再按下一次
}
sleep(1) 讓每個人按完等 1 秒再按,所以每秒都是 200 個一起進來,每個人按 5 次,總共 1000 次。
重新啟動 Java,把庫存設成 100 再跑(在主機的終端機下):
docker exec redis30days redis-cli SET stock:1 100
./k6/run.sh fixed

每秒進來 200 個,但只放 100 個,1000 個裡被擋下來 500 個。放進來的 500 個,第 1 秒的 100 個就把庫存搶完了,後面放進來的都回 -1。
被擋下來的只跑了一支很短的 Lua 就回 429,不會往下扣庫存。這裡後面只有扣庫存,看不太出差別;真的秒殺搶到之後還要建訂單、寫 DB,擋在門口的就碰不到這些。
固定視窗只看同一個視窗裡進來幾個。上限每秒 100 個,剛好卡在重新算的前後,由上往下照時間順序排:
第 0.0 秒:進來 1 個,開始算
第 0.9 秒:進來 99 個,加起來 100 個,都放進來
第 1.0 秒:key 過期,從 0 重新算
第 1.1 秒:進來 100 個,都放進來
--------------------
0.9 到 1.1 秒,0.2 秒就放進來 199 個
兩個視窗各自都沒超過 100 個,加起來卻快兩倍。
滑動視窗不切一格一格的,每個請求進來都往回看 1 秒,數這 1 秒放進來幾個。上面第 1.1 秒往回看是 0.1~1.1 秒,裡面已經有 0.9 秒那 99 個,只能再放 1 個。
做法是用 Sorted Set,score 放每個放進來的時間,每次先把 1 秒前的刪掉,再數剩幾個。代價是每放進來一個就多存一筆,上限越大存越多;固定視窗只存一個數字。
還有一種常聽到的令牌桶,可以想成發號碼牌:每秒補 100 張,桶子最多存 300 張,拿到號碼牌的才能進來。一陣子沒人來,桶子存滿 300 張,突然湧進 300 個人可以一次全部進來;號碼牌用完之後,一秒就只能再進來 100 個。
限流擋掉的是多出來的,放進來搶到的,還是要建訂單、寫 DB。開賣那一秒搶到的人一起寫 DB,就是一個尖峰。
削峰就是把尖峰削平:搶到的先丟進 Stream 排隊,馬上回「排隊中」,後面的消費者照 DB 寫得進去的速度慢慢拿。
StockController 加一個 /stock/buy/queue:
/** 削峰:搶到的先丟進 Stream 排隊就回,建訂單、寫 DB 交給後面的消費者慢慢做 */
@PostMapping("/buy/queue")
public Map<String, Object> buyQueue(@RequestParam long id, @RequestParam String user) {
if (buy(id) < 0) {
return Map.of("result", "沒搶到");
}
Map<String, String> order = Map.of("productId", String.valueOf(id), "user", user);
redis.opsForStream().add("order:seckill", order); // Day 10 的 XADD
return Map.of("result", "排隊中");
}
新增 k6/queue.js,50 個人搶 5000 次,每一次都當成不同的使用者:
// Day 26:100 件商品,50 個人同時搶,打的是搶到先排隊的 /stock/buy/queue
// 執行:./k6/run.sh queue
import http from 'k6/http';
import { check } from 'k6';
import exec from 'k6/execution';
export const options = {
vus: 50, // 50 個虛擬使用者同時打
iterations: 5000, // 總共打 5000 次
};
export default function () {
const user = exec.scenario.iterationInTest; // 第幾次打就當作第幾號使用者
const res = http.post(`http://localhost:8080/stock/buy/queue?id=1&user=${user}`);
check(res, { '排隊中': (r) => r.json('result') === '排隊中' });
}
一樣把庫存設回 100,跑完看 Stream 裡有幾筆(在主機的終端機下):
docker exec redis30days redis-cli SET stock:1 100
./k6/run.sh queue
docker exec redis30days redis-cli XLEN order:seckill
docker exec redis30days redis-cli XRANGE order:seckill - + COUNT 2



搶到的 100 個都在 Stream 裡排隊,每一筆記著誰搶到、搶到哪一件。user 不是照 0、1、2 排,因為 50 個人同時打,誰先扣到庫存不一定。
消費者今天不寫,做法是用 XREADGROUP 拿、寫完 DB 再 XACK。
相關範例程式碼可以參考 https://github.com/gary880306/redis-30days/tree/dev
秒殺的時候,所有人都在搶同一個 stock:1,這種很多人同時讀寫的 key 叫熱 Key。明天來看熱 Key 跟大 Key 會造成什麼問題![]()