iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0

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

https://ithelp.ithome.com.tw/upload/images/20261004/201842091qUD6xlek5.png

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

https://ithelp.ithome.com.tw/upload/images/20261004/20184209DzknhldAXn.png

從 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

https://ithelp.ithome.com.tw/upload/images/20261004/20184209e6wOX8uiOv.png

https://ithelp.ithome.com.tw/upload/images/20261004/201842093wPmAPbRu2.png

https://ithelp.ithome.com.tw/upload/images/20261004/20184209EmFq4FWbeU.png

三個商品差不多同一時間寫進去,TTL 一個 840 秒、一個 627 秒、一個 705 秒,不會在同一秒一起過期。

為什麼要在意

  • 這把鎖還有坑。 鎖 10 秒就過期。如果 A 搶到鎖之後查 DB 查了 15 秒,第 10 秒鎖就自己過期了,在等的人馬上會有一個搶到新的鎖,也去查 DB,變成兩個人同時在查。會查到 15 秒,代表 DB 已經很慢了,這時候再多一個人去查,只會更慢。這個之後再來處理。
  • Redis 整台掛掉也是雪崩。 不是 key 過期,是快取整個不見。程式如果寫成 Redis 連不上就改查 DB,所有請求會一起打到 DB。所以 Redis 要有人接手,Day 17 的 Sentinel、Day 18 的 Cluster 都是在做這件事:主節點掛了,趕快換一台上來。換手的那幾秒,App 這邊通常還會加熔斷:發現後面撐不住,就先直接回錯誤或預設值,不讓請求繼續往 DB 送。

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

明天

今天的商品都沒有改過。明天來改商品的價格,DB 改了,快取裡的還是舊的怎麼辦/images/emoticon/emoticon12.gif


上一篇
Day 21|快取穿透
下一篇
Day 23|快取一致性
系列文
從購物車到秒殺:30 天 Redis 高併發自學筆記 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言