iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0

Day 4 的商品快取是先查 Redis,沒有才去 DB 撈,撈完寫回快取。

那如果商品本來就不存在呢?Redis 沒有,DB 也沒有,沒東西可以寫回快取,下一次查一樣穿過 Redis 打到 DB。這就叫快取穿透。

有人故意拿一堆不存在的 id 一直打,每一次都會打到 DB,快取等於沒有用。

先讓 DB 有查不到的商品

Day 4 的 loadFromDb 不管什麼 id 都會回一個商品,先改成只有 1 到 10000 號:

private Product loadFromDb(Long id) {
    try {
        Thread.sleep(100);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    if (id < 1 || id > 10_000) {
        return null; // 只有 1 到 10000 號商品
    }
    return new Product(id, "商品 " + id, new BigDecimal("1299.00"), LocalDateTime.now());
}

空值快取

最直覺的做法:DB 查不到,也寫一筆進 Redis,下次就是 HIT,不會再打 DB。

private static final Duration TTL = Duration.ofMinutes(10);
private static final Duration NULL_TTL = Duration.ofMinutes(5); // 空值的 TTL 短一點

@GetMapping("/{id}")
public Map<String, Object> get(@PathVariable Long id) throws JsonProcessingException {
    long start = System.currentTimeMillis();
    String key = "product:" + id;

    String json = redis.opsForValue().get(key);
    if (json != null) {
        return result(mapper.readValue(json, Product.class), "HIT", start);
    }

    Product product = loadFromDb(id); // sleep 100 毫秒,模擬查詢成本
    Duration ttl = product == null ? NULL_TTL : TTL; // DB 也沒有,一樣寫進去,TTL 短一點
    redis.opsForValue().set(key, mapper.writeValueAsString(product), ttl); // null 會存成字串 "null"
    return result(product, "MISS", start);
}

改的只有 TTL:DB 查不到的話,改用比較短的 NULL_TTL,不然商品之後上架了,還會一直查到空的。product 是 null 的時候,writeValueAsString 會轉成字串 "null",讀出來也還是 null,HIT 那段不用改。

重新啟動 Java,查一個不存在的商品兩次,再看 Redis 存了什麼(在主機的終端機下):

curl "localhost:8080/product/99999"

curl "localhost:8080/product/99999"

docker exec redis30days redis-cli GET product:99999

docker exec redis30days redis-cli TTL product:99999

https://ithelp.ithome.com.tw/upload/images/20261003/20184209im6GJHFSpk.png

https://ithelp.ithome.com.tw/upload/images/20261003/20184209PWtAQjarEg.png

https://ithelp.ithome.com.tw/upload/images/20261003/20184209CfrAOSw1qt.png

https://ithelp.ithome.com.tw/upload/images/20261003/20184209RbMUWszRCf.png

第一次 MISS 去查了 DB,第二次就 HIT 了,product 都是 null。Redis 裡存的是字串 "null",TTL 查到 281 秒。

每次換一個 id

同一個 id 查第二次擋得住,那每次都換一個呢?新增 k6/penetration.js,從 10001 開始,每次查一個新的不存在的 id:

// Day 21:每次都查一個不存在的商品,而且 id 都不一樣
// 執行:./k6/run.sh penetration
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 id = 10001 + exec.scenario.iterationInTest; // 商品只到 10000 號,從 10001 開始每次換一個
  const res = http.get(`http://localhost:8080/product/${id}`);
  check(res, { '查了 DB': (r) => r.json('source') === 'MISS' });
}

check 會檢查每一次回來的 source 是不是 MISS,跑完 k6 會算出通過幾次,就是結果裡的 5000 out of 5000。

跑完順便數一下 Redis 裡有幾個 product: 開頭的 key(在主機的終端機下),--scan 是一批一批找,不會像 Day 3 的 KEYS * 卡住 Redis:

./k6/run.sh penetration

docker exec redis30days redis-cli --scan --pattern "product:*" | wc -l

https://ithelp.ithome.com.tw/upload/images/20261003/20184209xPByZPNipM.png

https://ithelp.ithome.com.tw/upload/images/20261003/20184209WtJlBPt6A5.png

5000 次全部都查了 DB,平均 111 毫秒,跟沒有快取一樣。product: 開頭的 key 有 5000 個,都是這一輪寫進去的空值。

每次換一個 id,空值快取就一次都擋不到,DB 照打,Redis 還多了一堆用不到的 key。一直打下去,記憶體遲早會滿,就是 Day 13 的狀況:noeviction 讓寫入直接報錯,或 allkeys-lru 會把真的商品快取擠掉。

布隆過濾器

要擋的是「根本不存在的 id」,那就先準備一份存在的 id 名單,不在名單上的直接擋掉。但商品一多,整份名單放進記憶體也不小。

布隆過濾器就是一份很省記憶體、但會誤判的名單。它是一排位元,跟 Day 9 的 Bitmap 一樣只有 0 跟 1。放一個 id 進去,用幾個不同的雜湊函數算出幾個位置,把這幾格設成 1。

查的時候用同樣的方法算出那幾格,只要有一格是 0,就一定沒放進去過。全部都是 1 也不能確定,可能是別的 id 剛好把這幾格都設成 1 了。所以它只會回答「一定沒有」或「可能有」。

Guava 已經寫好了,pom.xml 加上:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>33.7.2-jre</version>
</dependency>

ProductController 啟動時把所有商品 id 放進去,再加一個先問布隆過濾器的 /product/bloom:

/** 預計放一萬個 id,誤判率 1% */
private final BloomFilter<Long> bloomFilter = BloomFilter.create(Funnels.longFunnel(), 10_000, 0.01);

public ProductController(StringRedisTemplate redis, ObjectMapper mapper) {
    this.redis = redis;
    this.mapper = mapper;
    for (long id = 1; id <= 10_000; id++) {
        bloomFilter.put(id); // 模擬啟動時從 DB 撈出所有商品 id 放進去
    }
}

/** 先問布隆過濾器,它說沒有就一定沒有,Redis 跟 DB 都不用查 */
@GetMapping("/bloom/{id}")
public Map<String, Object> bloom(@PathVariable Long id) throws JsonProcessingException {
    if (!bloomFilter.mightContain(id)) {
        return result(null, "BLOOM", System.currentTimeMillis());
    }
    return get(id); // 可能有,照原本的流程走
}

BloomFilter.create 只要給「預計放幾個」跟「能接受多少誤判」,要幾格、幾個雜湊函數它自己算。一萬個 id、誤判率 1%,算出來是 7 個雜湊函數,整個過濾器大約 12KB。

重新啟動 Java,新增 k6/bloom.js,一樣查 10001 到 15000 這 5000 個 id,只換網址跟 check(在主機的終端機下):

const res = http.get(`http://localhost:8080/product/bloom/${id}`);
check(res, { '被布隆過濾器擋下來': (r) => r.json('source') === 'BLOOM' });
./k6/run.sh bloom

https://ithelp.ithome.com.tw/upload/images/20261003/2018420939WnYaOBWe.png

5000 次裡 4953 次被擋下來,連 Redis 都沒查。沒擋到的 47 次(0.94%)就是誤判:明明不存在,布隆過濾器說可能有。平均從 111 毫秒降到 2.39 毫秒。

沒擋到的會照原本的流程走,所以布隆過濾器通常跟空值快取一起用,大部分在布隆過濾器就擋掉,漏掉的交給空值快取。

為什麼要在意

  • 新商品上架,要記得 put 進布隆過濾器。 它說沒有就直接擋,上架時忘了放進去,DB 明明有這個商品,也會被當成不存在,要等 App 重開重新撈一次才查得到。
  • Guava 的布隆過濾器在每台 App 自己的記憶體裡。 開三台 App 就有三份,App 重開就要重新從 DB 撈一次。Redis 8 開始內建布隆過濾器(BF.ADD、BF.EXISTS),放在 Redis 裡就能多台共用。

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

第三週小結

Day 主題 一句話
15 AOF 每一筆寫入都記下來,everysec 最壞掉一秒左右
16 主從複製 從節點跟著主節點同步,但主節點回了 OK 不代表從節點拿到了
17 Sentinel 主節點掛了自動換人,切換那幾秒請求會卡住
18 Cluster 資料拆到好幾台,要一起動的 key 得在同一個 slot
19 Pipeline 與事務 Pipeline 省來回,MULTI 中間不被插隊,但不會回滾
20 Lua 腳本 判斷跟寫入在 Redis 裡一次做完,一樣不會回滾
21 快取穿透 空值快取要查過一次 DB 才擋得住,布隆過濾器第一次查就擋下來

Day 15–18 顧的是資料不要掉、機器掛了有人接、一台不夠用,Day 19–20 是好幾個指令一起送,今天開始回到快取本身會出的事。

明天

今天查的是不存在的商品。明天換成存在的熱門商品,快取剛好過期的那一秒,所有人一起衝去查 DB 會怎樣/images/emoticon/emoticon12.gif


上一篇
Day 20|Lua 腳本
系列文
從購物車到秒殺:30 天 Redis 高併發自學筆記 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言