iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

今天開始秒殺:限量商品開賣的那一秒,很多人同時搶。先不用 Lua,用最直覺的寫法,先查庫存,有剩再扣,看 100 件商品會不會賣出超過 100 件。賣出去的比庫存還多,就叫超賣。

先讀再寫

StockController 加一個 /stock/buy/wrong:

/** 錯誤示範:先 GET 庫存,有庫存再 SET 回去減一,回傳值跟 /buy 一樣 */
@PostMapping("/buy/wrong")
public Long buyWrong(@RequestParam long id) {
    String key = "stock:" + id;
    String value = redis.opsForValue().get(key);
    long stock = value == null ? 0 : Long.parseLong(value); // key 不存在當 0

    if (stock <= 0) {
        return -1L; // 沒庫存,不扣
    }

    redis.opsForValue().set(key, String.valueOf(stock - 1)); // GET 跟 SET 中間,別人也可能讀到同一個數字
    return stock - 1;
}

跟 Day 20 的 /stock/buy 一樣,搶到回剩幾個,沒庫存回 -1。

新增 k6/oversell.js,50 個人同時搶:

// Day 25:100 件商品,50 個人同時搶,打的是先讀再寫的 /stock/buy/wrong
// 執行:./k6/run.sh oversell
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  vus: 50,          // 50 個虛擬使用者同時打
  iterations: 5000, // 總共打 5000 次
};

export default function () {
  const res = http.post('http://localhost:8080/stock/buy/wrong?id=1');
  check(res, { '搶到': (r) => r.json() >= 0 }); // 沒庫存回 -1
}

r.json() 就是回來的數字,大於等於 0 就是搶到,check 通過幾次就是賣出幾件。

重新啟動 Java,把庫存設成 100,跑完 k6 再看庫存剩多少(在主機的終端機下):

docker exec redis30days redis-cli SET stock:1 100

./k6/run.sh oversell

docker exec redis30days redis-cli GET stock:1

https://ithelp.ithome.com.tw/upload/images/20261007/201842098RqNgGjZXA.png

庫存最後是 0,看起來剛好賣完。但 check 通過了 2308 次,100 件商品賣出了 2308 件。

把剩下的加上賣出的,應該要等於一開始的 100。這裡是 0 + 2308 = 2308,多賣了 2208 件。

為什麼會超賣

GET 跟 SET 是兩個指令,中間會被插隊。A、B 兩個人同時搶最後 1 件,由上往下照時間順序排:

A:GET stock:1,拿到 1
B:GET stock:1,拿到 1
A:SET stock:1 0,搶到
B:SET stock:1 0,搶到
--------------------
最後 1 件賣給了兩個人

不只最後 1 件,每一件都可能這樣。50 個人都讀到 100,都寫回 99,就賣出了 50 件,庫存卻只少 1。

這跟之前計數器 Java 的 ++ 少加是一樣的事:讀完到寫回之間,別人也讀到同一個數字。

用 Lua 扣

Day 20 的 deduct.lua 把「看有沒有庫存、有才扣」在 Redis 裡一次做完,中間不會被插隊。

新增 k6/deduct.js,跟 oversell.js 只差網址,改打 /stock/buy:

export default function () {
  const res = http.post('http://localhost:8080/stock/buy?id=1');
  check(res, { '搶到': (r) => r.json() >= 0 }); // 沒庫存回 -1
}

一樣把庫存設回 100 再跑(在主機的終端機下):

docker exec redis30days redis-cli SET stock:1 100

./k6/run.sh deduct

docker exec redis30days redis-cli GET stock:1

https://ithelp.ithome.com.tw/upload/images/20261007/20184209UFRpcNuDdz.png

check 剛好通過 100 次,庫存一樣是 0。0 + 100 = 100,對得起來。

用鎖

Day 24 的分散式鎖也可以:把先讀再寫整段包起來,同一時間只有一個人在讀跟寫。StockController 再加一個 /stock/buy/lock:

/** 用 Day 24 的鎖把先讀再寫包起來,同一時間只有一個人在扣 */
@PostMapping("/buy/lock")
public Long buyLock(@RequestParam long id) {
    RLock lock = redisson.getLock("lock:stock:" + id);
    lock.lock(); // 跟 tryLock() 不一樣,沒搶到會一直等到搶到
    try {
        return buyWrong(id); // 裡面跟剛剛的先讀再寫一樣,只是外面多了鎖
    } finally {
        lock.unlock();
    }
}

Day 24 的 tryLock() 沒搶到馬上回 false,用在這裡的話,還有庫存也會變成沒搶到。所以改用 lock(),沒搶到就排隊等。

新增 k6/lock.js,一樣只差網址:

export default function () {
  const res = http.post('http://localhost:8080/stock/buy/lock?id=1');
  check(res, { '搶到': (r) => r.json() >= 0 }); // 沒庫存回 -1
}

重新啟動 Java,Lua 跟鎖接著各跑一次來比(在主機的終端機下):

docker exec redis30days redis-cli SET stock:1 100

./k6/run.sh deduct

docker exec redis30days redis-cli SET stock:1 100

./k6/run.sh lock

https://ithelp.ithome.com.tw/upload/images/20261007/20184209824lb2UBEs.png

https://ithelp.ithome.com.tw/upload/images/20261007/20184209erWsBqFZUN.png

一樣剛好賣出 100 件,但 http_req_duration 的 avg 從 Lua 的 17.82ms 變成 53.14ms。

搶鎖、GET、SET、放鎖,要跟 Redis 來回四次,拿著鎖的人做完之前,其他人都在排隊。Lua 只要來回一次。

為什麼要在意

  • 只看庫存看不出超賣。 先讀再寫跟 Lua 跑完,庫存都是 0,但一個賣了 2308 件,一個賣了 100 件。要另外記賣出幾件,例如每賣一件就寫一筆訂單,剩下的加賣出的對得上一開始的庫存,才是真的沒賣錯。
  • 能用 Lua 就不用鎖。 只動 Redis 裡的資料、一下子就做完的,像扣庫存,用 Lua 就好。要做好幾秒、還要查 DB 或呼叫其他系統的,例如排程,Lua 做不到,才用鎖。

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

明天

5000 個請求搶 100 件商品,4900 個注定搶不到,卻每個都打進了 Java 跟 Redis。明天來看怎麼在門口就先擋掉/images/emoticon/emoticon12.gif


上一篇
Day 24|分散式鎖
下一篇
Day 26|秒殺:限流與削峰
系列文
從購物車到秒殺:30 天 Redis 高併發自學筆記 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言