快取有一個大家很少注意的盲點:它只會記「存在的資料」。
查商品 1001,DB 有 → 回填快取,下次命中。查商品 999999999——DB 沒有這個商品,所以沒有東西可以回填。下一次再查 999999999?還是 miss、還是打 DB。第一百萬次?照樣打 DB。你的快取對這種查詢的攔截率是零,每一發都直接穿透到 DB 身上。
這就是 cache penetration(穿透)。它跟擊穿、雪崩是三種不同的病:擊穿是「熱門 key 過期的瞬間」被打穿,雪崩是「一大批 key 同時到期」,而穿透是「從頭到尾就不存在的資料」——前兩個的傷口會癒合(快取填回來就好了),穿透的傷口永遠不會癒合,因為永遠沒有東西能填。
它從哪來?三個常見來源,惡意程度遞增:
- 正常業務:資料刪了,但外面還有舊連結、舊 app 版本指著那個 id。
- 上游 bug:某個服務組 id 的邏輯錯了,拿一堆格式錯誤的 key 來查。
- 惡意掃描:有人發現「隨機 id 會繞過你的快取直打 DB」,用它當低成本的攻擊面。
解法一:空值快取——把「沒有」也記下來
最直接的思路:DB 查不到,就把「查無此物」這個事實本身快取起來——存一個空值標記、配一個短 TTL(例如三十到六十秒)。同一個不存在的 id 再來,快取直接回「沒有」,DB 毫髮無傷。
兩個實作要點:
- TTL 要短。 「現在不存在」不代表「等等不存在」——新商品上架的瞬間,它的 id 可能還被空值快取擋著。短 TTL 讓這個誤擋窗口小到可忍。
- 空值標記要跟「真的快取 miss」分得開。 你的 CacheService 拿到這個標記時要知道「這是快取命中、答案是不存在」,而不是當成 miss 又去打一次 DB——不然就白做了。
空值快取的弱點也很直白:每個不存在的 id 都佔一個 key。 對手拿一百萬個隨機 id 掃你,你就多一百萬個空值 key——DB 是保住了,記憶體在流血。所以它適合「不存在的 id 種類有限」的場景(刪掉的資料、上游 bug);面對隨機大量掃描,要靠下一招。
解法二:Bloom Filter——先問「有沒有可能存在」
Bloom filter 是一種極度省空間的機率型結構,它能回答一個特別的問題:「這個東西有沒有可能在集合裡?」它的回答有個不對稱的特性——
- 說「不在」→ 絕對不在。可以放心直接拒絕,連快取都不用查。
- 說「在」→ 只是可能在(有小機率誤判),要繼續往快取和 DB 查證。
用法:把「所有存在的商品 id」灌進 bloom filter,放在快取前面當門神。隨機掃描的請求絕大多數會被「絕對不在」直接彈掉——DB 和快取連被摸到的機會都沒有,而且不管對手掃多少個 id,filter 的大小都不變。這就是它對付大量隨機掃描的本錢。
代價在維護那一側:新資料建立時要記得把 id 加進 filter(漏加會把真實存在的資料誤擋——這是此方案最嚴重的事故模式);標準 bloom filter 不支援刪除,刪掉的資料會殘留「可能在」的誤判,要嘛容忍、要嘛定期重建。Redis 生態有現成的 RedisBloom 模組,自己刻也不難,難的是把「同步維護 filter」納入資料寫入的紀律。
怎麼選
種類有限的穿透(刪掉的資料、bug 造成的固定幾種壞 key)→ 空值快取,五分鐘就能上線。開放性的隨機掃描(id 空間巨大、來源不可控)→ bloom filter,一次投資擋掉整類攻擊。兩個不互斥,很多系統是 filter 擋大面、空值快取補小漏。
另外別忘了最外圈還有一層更便宜的防線:參數驗證。id 格式明顯不對的(負數、超長、非數字)在 API 邊界直接 400,連快取層都不用勞駕——很多「穿透」其實在這裡就該死掉了。
這整篇對我不是理論——02 的翻車帳單那次事故的主角就是穿透:有人寫爬蟲掃我們的站,掃的大量是長尾、甚至根本不存在的資料,每一發都 miss、每一發都直穿 DB,查無資料連回填的機會都沒有,快取形同虛設。後來空值快取補上了,DB 保住了,卻立刻繳了第二筆學費——就是上面那句「記憶體在流血」:空值的 TTL 沒有跟正常資料分開規劃,爬蟲掃過的每個「不存在」都活得跟真資料一樣久,帳單先於監控告訴我們出事。「空值 TTL 要短」這六個字,我是用錢學會的。還有一課在快取層之外:rate limit 這道更便宜的外圈防線,當時也還沒上——穿透從來不是單一層的問題,是每一層都漏一點,最後在 DB 匯合。
一句收束:快取預設只保護「存在的世界」,穿透就是從「不存在的世界」進攻。 空值快取把「沒有」記下來、bloom filter 在門口先問「有沒有可能有」、參數驗證把明顯的垃圾擋在最外面——三層都便宜,值得一起上。
(擊穿與雪崩——「存在的世界」那兩種塌法——見同系列 #18。)
接下來往哪走
- Cache Stampede:快取撐得越好,倒下的時候摔得越重 — 穿透的姊妹病:key 存在但集體過期的那種打法
- TTL 策略 — 空值 TTL 該多短?TTL 的整套設計思路
- 為什麼「加個 cache」常常讓事情更糟 — 這次事故的完整帳單,四種翻車一次看