Day 21 查的是不存在的商品。今天換成存在、而且很多人在查的熱門商品。
快取有設 TTL,熱門商品的快取一樣會過期。過期的那一刻,大家同時查不到快取,同時去查 DB,這就叫快取擊穿。如果不是一個 key,是一大批 key 在同一秒過期,就叫快取雪崩。
新增 k6/breakdown.js,50 個人同時查 1 號商品:
// Day 22:熱門商品的快取剛好過期,50 個人同時查同一個商品
// 執行:./k6/run.sh breakdown
import http from 'k6/http';
import { check } from 'k6';
export const options = {
vus: 50, // 50 個虛擬使用者同時打
iterations: 5000, // 總共打 5000 次
};
export default function () {
const res = http.get('http://localhost:8080/product/1');
check(res, { '查了 DB': (r) => r.json('source') === 'MISS' });
}
先查一次 1 號商品,讓它進快取,當作平常一直有人在查的熱門商品。再 DEL 掉它的快取,當作剛好過期,接著跑 k6(在主機的終端機下):
curl "localhost:8080/product/1"
docker exec redis30days redis-cli DEL product:1
./k6/run.sh breakdown

check 只通過 50 out of 5000,5000 次裡有 50 次查了 DB,其他都是 HIT。
50 個人同時查,快取都沒有,就都去查 DB。查 DB 要 100 毫秒,這 100 毫秒內誰都還沒寫回快取,所以第一波的 50 個人全部去查 DB,等第一個人寫回快取,後面的才 HIT。
這裡只有 50 個人,熱門商品可能是幾千個人同時在查,DB 一瞬間就會被打爆。
只讓一個人去查 DB,其他人等它寫回快取。同一時間只有一個人拿得到的鎖,叫互斥鎖。
誰去查,用 Day 4 提過的 SETNX 來搶,ProductController 加一個 /product/lock:
private static final Duration LOCK_TTL = Duration.ofSeconds(10); // 搶到鎖的人掛掉,最多卡 10 秒
/** 快取沒有的時候,只讓搶到鎖的人去查 DB,其他人等它寫回快取 */
@GetMapping("/lock/{id}")
public Map<String, Object> lock(@PathVariable Long id) throws JsonProcessingException, InterruptedException {
long start = System.currentTimeMillis();
String key = "product:" + id;
String lockKey = "lock:" + key;
while (true) {
String json = redis.opsForValue().get(key);
if (json != null) {
return result(mapper.readValue(json, Product.class), "HIT", start);
}
if (Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(lockKey, "1", LOCK_TTL))) { // SET NX EX
try {
return get(id); // 搶到鎖,照原本的流程去查 DB
} finally {
redis.delete(lockKey); // 查完放掉鎖
}
}
Thread.sleep(50); // 沒搶到,等 50 毫秒再看一次快取
}
}
setIfAbsent 就是 SET lock:product:1 1 EX 10 NX,key 不存在才寫得進去,回 true 的就是搶到鎖的人。沒搶到的每 50 毫秒看一次快取,等到有了就是 HIT。
搶到鎖的人直接呼叫原本的 get(),get() 一開始會先查快取,前一個人如果剛好寫回去了,就不會再查一次 DB。
鎖一定要設過期時間,搶到鎖的人如果掛掉,沒人刪鎖,其他人最多等 10 秒,鎖過期了就能再搶。
重新啟動 Java,新增 k6/mutex.js,跟 breakdown.js 一樣,只換網址:
const res = http.get('http://localhost:8080/product/lock/1');
一樣先 DEL 再跑(在主機的終端機下):
docker exec redis30days redis-cli DEL product:1
./k6/run.sh mutex

從 50 次變成 1 次。第一波另外 49 個人等了一下,拿到的是那 1 個人寫回去的快取。
代價是等的人比較慢:最慢的一次從 123 毫秒變成 200 毫秒。沒搶到鎖的人每 50 毫秒才看一次快取,要等搶到鎖的人查完 DB、寫回快取才拿得到。
不想讓人等,還有一種做法是邏輯過期:熱門商品在 Redis 不設 TTL,過期時間改寫在 value 裡,例如:
{
"data": { "id": 1, "name": "商品 1", "price": 1299.00 },
"expireTime": "2026-10-04T15:00:00" // 過了這個時間就算過期,Redis 不會自己刪
}
讀出來發現 expireTime 已經過了,一樣只讓一個人去 DB 撈新的,但其他人不等,直接拿舊的回去。沒有人要等,代價是新的寫回去之前,大家拿到的都是舊資料。
擊穿是一個 key 過期。那一大批 key 在同一秒過期呢?
例如 App 啟動時,先把所有商品放進快取(預熱),TTL 都是 10 分鐘,十分鐘後就會在同一秒一起過期,所有商品同時去查 DB。
TTL 加一段隨機的秒數,把過期時間打散。get() 裡查得到的商品改用 randomTtl():
Duration ttl = product == null ? NULL_TTL : randomTtl(); // DB 也沒有,一樣寫進去,TTL 短一點
/** 10 分鐘再多 0~299 秒,同一批寫進去的 key 才不會同一秒一起過期 */
private Duration randomTtl() {
return TTL.plusSeconds(ThreadLocalRandom.current().nextInt(300));
}
重新啟動 Java,連續查三個商品,再看它們的 TTL(在主機的終端機下):
curl "localhost:8080/product/2"
curl "localhost:8080/product/3"
curl "localhost:8080/product/4"
docker exec redis30days redis-cli TTL product:2
docker exec redis30days redis-cli TTL product:3
docker exec redis30days redis-cli TTL product:4



三個商品差不多同一時間寫進去,TTL 一個 840 秒、一個 627 秒、一個 705 秒,不會在同一秒一起過期。
相關範例程式碼可以參考 https://github.com/gary880306/redis-30days/tree/dev
今天的商品都沒有改過。明天來改商品的價格,DB 改了,快取裡的還是舊的怎麼辦![]()