一個忘了設 TTL 的 key,把整台 Redis 撐到 OOM,順便把服務也帶走了
後端工程師,主力 Go,做過撮合引擎與金流高併發系統,被一個忘了設 TTL 的 key 半夜叫起來過一次。
半夜三點的那通告警
那天我在睡覺,凌晨三點多手機開始震。值班群裡一連串紅字:redis-master 記憶體使用率破 95%、下單服務 P99 延遲飆到兩秒、然後是一整排 OOM command not allowed when used memory > 'maxmemory'。我睡眼惺忪爬起來,第一個念頭是——「是不是被打了?半夜有人在刷量?」
我承認,接下來快一個小時,我都在往這個錯誤的方向鑽。
我一開始想錯的地方
我先去看流量。API Gateway 的 QPS 曲線平穩得不像話,跟前一天同時段幾乎重疊,沒有任何暴衝。再看 Redis 的 INSTANTANEOUS_OPS_PER_SEC,也就三四千,這台平常尖峰扛過五萬都沒事。所以根本不是流量問題。
那我又懷疑是不是連線洩漏、是不是有人跑了什麼 KEYS * 把主執行緒卡住。SLOWLOG GET 撈出來看,最慢的指令也才幾毫秒,乾乾淨淨。CPU 也不高。
這時候我才後知後覺地回去看那條最關鍵的曲線:記憶體用量。它不是尖刺,是一條非常平滑、非常固執的斜線,從幾天前的 2GB 一路單調爬升到現在的 8GB,中間沒有任何一次下降。那一刻我就知道了——這不是流量,這是有東西一直在塞,而且從來沒有被清掉。
記憶體是有限資源,我卻把它當無底洞
先講清楚背景。這台 Redis 我們設了 maxmemory 8gb,maxmemory-policy noeviction。當初選 noeviction 是有理由的:這台不只當快取,還存了一些「不能被淘汰」的資料(分散式鎖、部分業務狀態)。我們的想法是「寧可寫入失敗報錯,也不要讓 Redis 自己決定丟掉哪筆資料」。
這個決定本身沒錯。錯的是——我們把一台會無限成長的資料,塞進一個容量固定、又不准淘汰的空間裡。 當記憶體撞到 maxmemory 天花板,noeviction policy 做的事情很直白:所有會增加記憶體的寫入指令(SET、LPUSH、HSET…)通通拒絕,直接回 OOM error。於是連正常的下單、寫鎖全部失敗,服務就這樣被一個記憶體問題連坐搞掛了。
如果當時是 allkeys-lru 呢?也不見得好到哪去。那樣 Redis 不會報錯,但會開始淘汰「最近最少用」的 key——包括我那些不能掉的分散式鎖。到時候就不是報錯,而是更難查的資料神隱、鎖失效、詭異的業務錯亂。選錯 eviction policy,一邊是寫入全掛,一邊是熱資料被誤殺,沒有一個是免費的。
抓兇手:到底是哪類 key 在吃記憶體
記憶體是被誰吃掉的,Redis 其實給了不少工具。我當時的排查順序大概是這樣:
- 先跑
INFO memory,確認used_memory逼近maxmemory,而且mem_fragmentation_ratio正常,排除是碎片問題,確定是真的有這麼多資料。 - 再用
redis-cli --bigkeys掃一遍。這個指令會用SCAN分批掃,不會像KEYS *那樣阻塞主執行緒,可以放心在線上跑。它很快就吐出一個線索:某個 pattern 的 key 佔比高得誇張,而且數量多到嚇人。 - 針對可疑的前綴,我用
SCAN 0 MATCH "session:cache:*" COUNT 100搭配MEMORY USAGE <key>抽樣,估算單一 key 的實際佔用。
兇手浮出水面:一個 session:cache:{userId} 的前綴,底下躺著幾百萬個 key。每個裡面塞的是整包序列化後的使用者上下文——購物車、瀏覽紀錄、一坨查詢結果快取,動輒幾十 KB。而最致命的是:這批 key 全部沒有 TTL。
我去翻了 TTL session:cache:xxx,回傳 -1——代表永久存在。一查程式碼就笑不出來了:三週前一次重構,有人把原本 SETEX(帶過期時間)改成了 SET,理由是「順手統一了寫入介面」,然後就沒有人記得補上 EXPIRE。從那天起,每一個登入過的使用者都在 Redis 裡留下一塊永遠不會消失的墓碑。記憶體當然只會漲不會跌。
順便補一刀:bigkey 的隱藏傷害
這件事還牽出另一個問題。那些 cache value 我們是「一整包」塞進單一 key 的,有些膨脹到上百 KB,這就是典型的 bigkey。bigkey 的壞處不只是佔空間:
- 對它做序列化、刪除、或
MEMORY USAGE都可能在主執行緒上卡出明顯延遲,一個DEL大 key 就能讓別的請求跟著等。 - 在 Redis Cluster 下,bigkey 會讓資料分佈嚴重不均,某個 slot 特別胖,搬遷、備份都痛苦。
所以就算有 TTL,把「一大包東西塞一個 key」本身也是壞味道。該拆的要拆:能用 Hash field 分開的就分開,能只快取必要欄位的就不要整包塞。
我怎麼止血,又怎麼補結構
當下最緊急的是讓服務先活過來。我沒有直接 FLUSHDB(那會連該留的鎖一起清掉),而是用 SCAN + MATCH 分批把那批沒 TTL 的 session:cache:* 撈出來,一小批一小批 DEL,同時盯著記憶體曲線往下掉。記憶體從 8GB 慢慢回到 3GB 出頭,OOM error 停了,服務恢復。凌晨四點半,我終於能喘口氣。
真正的修法是隔天做的:
- 把 `SET` 改回帶過期時間的寫法,統一走一個封裝好的
setCache(key, value, ttl),並且在這層強制ttl為必填參數——沒帶就編譯不過 / 直接丟例外。讓「忘記設 TTL」這件事在程式碼層面就不可能發生。 - 把整包結構拆小,改用 Hash 存必要欄位,並對整個 Hash 設一個合理的過期時間,順便控制單一 key 的大小。
- 上監控與告警。之前我們只盯 CPU 跟 QPS,卻沒盯
used_memory的成長趨勢,也沒監控 key 總數。現在這兩條都進了 dashboard,而且加了「記憶體週增長率」的告警——一條單調上升、從不回頭的線,本身就是最大的警訊。 - 順手寫了個巡檢腳本,用
SCAN定期抽查是否有前綴的 key 大量處於TTL = -1的狀態,當作事後的安全網。
小結
我後來一直記得那條平滑的斜線。它沒有尖刺、沒有暴衝,安安靜靜地漲了三週,然後在一個凌晨把服務撞死。
這件事給我最實在的一課是:Redis 是記憶體,記憶體是有限資源,任何會持續寫入的 key 都必須有明確的生命終點。 TTL 不是可有可無的優化,它是你對「這塊記憶體什麼時候該還回來」的承諾。少了這個承諾,你的快取就不是快取,而是一顆會慢慢長大、遲早引爆的定時炸彈。
如果只能帶走一句話:每一個寫進 Redis 的 key,都要能回答「你什麼時候會消失」。答不出來的,就不該被寫進去。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。