iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0

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

https://ithelp.ithome.com.tw/upload/images/20261008/20184209T5hw6EOgKE.png

每秒進來 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

https://ithelp.ithome.com.tw/upload/images/20261008/20184209hxAf0RjmZ7.png

https://ithelp.ithome.com.tw/upload/images/20261008/20184209C9b7Yfdm55.png

https://ithelp.ithome.com.tw/upload/images/20261008/20184209rhgCGufmuA.png

搶到的 100 個都在 Stream 裡排隊,每一筆記著誰搶到、搶到哪一件。user 不是照 0、1、2 排,因為 50 個人同時打,誰先扣到庫存不一定。

消費者今天不寫,做法是用 XREADGROUP 拿、寫完 DB 再 XACK。

為什麼要在意

  • 429 不是當機。 被擋下來的要讓使用者知道是人太多,例如顯示「目前人數過多,請稍後再試」。前端也不要一收到 429 就馬上重試,大家一起重試,門口只會更擠。
  • 排隊中不是下單成功。 丟進 Stream 只代表搶到庫存,訂單還沒寫進 DB。前端要能再查結果;消費者寫到一半掛掉的,要從 pending 撿回來重做,不然就是扣了庫存卻沒有訂單。

相關範例程式碼可以參考 https://github.com/gary880306/redis-30days/tree/dev

明天

秒殺的時候,所有人都在搶同一個 stock:1,這種很多人同時讀寫的 key 叫熱 Key。明天來看熱 Key 跟大 Key 會造成什麼問題/images/emoticon/emoticon12.gif


上一篇
Day 25|秒殺:重現超賣
下一篇
Day 27|大 Key 與熱 Key
系列文
從購物車到秒殺:30 天 Redis 高併發自學筆記 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言