Day 4 的商品快取是先查 Redis,沒有才去 DB 撈,撈完寫回快取。
那如果商品本來就不存在呢?Redis 沒有,DB 也沒有,沒東西可以寫回快取,下一次查一樣穿過 Redis 打到 DB。這就叫快取穿透。
有人故意拿一堆不存在的 id 一直打,每一次都會打到 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




第一次 MISS 去查了 DB,第二次就 HIT 了,product 都是 null。Redis 裡存的是字串 "null",TTL 查到 281 秒。
同一個 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


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

5000 次裡 4953 次被擋下來,連 Redis 都沒查。沒擋到的 47 次(0.94%)就是誤判:明明不存在,布隆過濾器說可能有。平均從 111 毫秒降到 2.39 毫秒。
沒擋到的會照原本的流程走,所以布隆過濾器通常跟空值快取一起用,大部分在布隆過濾器就擋掉,漏掉的交給空值快取。
put 進布隆過濾器。 它說沒有就直接擋,上架時忘了放進去,DB 明明有這個商品,也會被當成不存在,要等 App 重開重新撈一次才查得到。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 會怎樣![]()