今天開始秒殺:限量商品開賣的那一秒,很多人同時搶。先不用 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

庫存最後是 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 的 ++ 少加是一樣的事:讀完到寫回之間,別人也讀到同一個數字。
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

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


一樣剛好賣出 100 件,但 http_req_duration 的 avg 從 Lua 的 17.82ms 變成 53.14ms。
搶鎖、GET、SET、放鎖,要跟 Redis 來回四次,拿著鎖的人做完之前,其他人都在排隊。Lua 只要來回一次。
相關範例程式碼可以參考 https://github.com/gary880306/redis-30days/tree/dev
5000 個請求搶 100 件商品,4900 個注定搶不到,卻每個都打進了 Java 跟 Redis。明天來看怎麼在門口就先擋掉![]()