Redis 作為高性能的 key-value 內存型數據庫,普遍使用在對性能要求較高的系統中,同時也是滴滴內部的內存使用大戶。本文從 KV 團隊對線上 Redis 內存泄漏定位的時間線維度,簡要介紹 Linux 上內存泄漏的問題定位思路和工具。
16:30 問題暴露
業務反饋縮容后內存使用率90%告警,和預期不符合,key 只有1萬個,使用大 key 診斷,沒有超過512字節以上的大 key。
16:40 確認內存泄漏
發現該系統中有部分實例內存明顯偏高達到300~800MB,正常實例只有10MB左右,版本號為4ce35dea,在9月份時已經有發現49bdcd0b這個較老版本有內存泄漏情況發生,現象看起來一樣,說明內存泄漏問題一直存在,未被修復,于是開始排查該問題。
17:30 開始排查社區版本
排查問題先易后難,先排除是不是社區的版本Bug問題:
不需要從最新修復一直倒敘確認到3系列的 commit 提交,因為如果是嚴重的內存泄漏,3系列的舊版本也一定會有 backport 修復記錄。
查看3.2.8的commit記錄,只有一次內存泄漏相關提交:Memory leak in clusterRedirectBlockedClientIfNeeded.
本次提交只修復了在 cluster 出現 key 重定向錯誤時對 block client 處理時對一個指針的泄漏,不可能出現如此大的泄漏量。3.2.8的社區版已上線數年,但在社區內未搜索到相關內存泄漏問題,因此推測是我們的某些定制功能開發引入的 Bug。
18:10 整理監控和日志
整理當前已知監控和日志信息,分析問題的表面原因和發生時間
1、監控信息
odin 監控只能看到最近兩個月的內存使用曲線,從監控上可以得到三點信息:
兩個月前已經發生內存泄漏
內存泄漏不是持續發生的,是由于某次事件觸發的
內存泄漏量大,主實例使用內存800MB,從實例使用內存10MB
2、日志信息
排查發生內存泄漏的容器日志:? ? ? ?
Redis 在10月11日被創建后,只有在20日出現有大量日志,之后無日志,日志有以下內容:
Redis 橫向擴容 slot 遷移
主從切換
AOF 重寫
搜索該系統的歷史短信告警,在10月11日11:33分出現三次內存使用率達到100%的告警,因此可以推測出現 key 淘汰
Manager平臺操作信息:
垂直擴容
橫向擴容
Redis 重啟
綜合 Redis 的日志和平臺日志信息,雖然未能直接發現問題原因,可以確定內存泄漏發生在10月20日11:30左右,由以下單個事件或者混合觸發的:
主從切換
key 遷移
key 驅除
18:00 打印內存 dump 信息
在實例上使用 GDB ?把泄漏實例的所有內存 dump 出來,初步發現內存上有很多 key(647w個),不屬于本節點,info 里數據庫只有1.6W個 key, ?懷疑是slot 遷移有問題。
18:30 第一次 diff 代碼
由于3.2.8自研版本有兩個重大修改:
slot 的所屬 key 集合記錄,把跳躍表改為了4.0以后的基數樹結構,從社區的 unstable 分支 backport 下來的;
支持多活
由于出問題的系統沒有使用多活功能,且恰巧事發時有 slot 遷移,因此重點懷疑 slot 遷移中 rax 樹相關操作有內存泄漏,首先查看了相關代碼,有幾個疑似的地方,但都排除掉了。
20:30 嘗試使用工具定位
memory doctor
Redis4 引入的內存診斷命令,3系列未實現
3.2.8版本使用 jemalloc-4.0.3作為內存分配器,嘗試使用 jeprof 工具分析內存使用情況,發現 jemalloc 編譯時需要提前添加--enable-prof編譯選項,此路不通
使用 perf 抓取 brk 系統調用,未發現異常(實際上最近兩個月也未發生泄漏)
valgrind 作為最后手段,不確定是否可以復現
22:00 組內溝通進展
和組內同學溝通下午的調查情況,仍然懷疑 rax 泄漏,其次多活或者 failover 混合動作觸發的 case 導致泄漏。
第二天10:00 重新整理思路
使用 hexdump 觀察昨天的內存 dump 文件,發現泄漏內存為 SDS 字符串數據類型,且連續分布。
每隔4、5行都會出現OO TT SS等字符,對應 SDS 類型的 sdshdr 結構體。? ? ??
每個泄漏的 key 字符串大約在80字節左右,因此使用時 sdshdr8(為了節約內存,sds 的 header 有五種 sdshdr5,sdshdr8、sdshdr16、sdshdr32、sdshdr64,其中8指的是長度小于1<<8的字符串使用的 sdshdr)。? ??
以TT那行為例,結合 SDS 字符串的 new 函數分析,key 字符串長度為84字節等于0x54,結合代碼看,sh->len和sh->alloc都是0x54,第三個字節標識 type 類型,sdshdr8 的 type 值剛好是0x1,因此可以確認泄漏的是 sds 類型的 key 值,并且排除 rax 樹泄漏的可能,因為內存 dump 和 rax 樹的存儲結構不符。附典型的 rax 存儲結構:???
14:00 根據dump的分析重新排查代碼
排除了 rax 樹的泄漏,同時綜合 redis 使用 sds key 的情況,此時把懷疑重點放在了 write 等 dict 的釋放方法上,以及 rdb 的加載時 key 的臨時結構體變量。
此時 diff 代碼,不再局限有變更的代碼,以功能為粒度進行走讀代碼,但把重點放在了 failover 時的 flushdb 和 loadRDB 操作上。
17:00 排查slot遷移代碼
在上一輪代碼走讀中,再次排除了 failover,key 淘汰的代碼有內存泄漏的可能,因此重新懷疑 slot 遷移中的某些動作導致 key 字面值的內存泄漏,尤其是 slot 清空等操作。
18:30 找到根因
在 slot 遷移過程中,會遍歷舊節點中的所有 key,然后把遍歷得到的 key 從舊節點遷移到新節點中。
這個功能在3.2.8代碼中沒有被改動,但其調用的 getKeysInSlot 函數有了修改。getKeysInSlot 是遍歷 rax 樹,拿到待遷移 key 列表,對每個 key 從 rax 樹中取出完整字符串,來拷貝創建 obj 類型指向 sds 字符串;這些字符串作為數組指針類型返回給了出參 keys,但在上層調用把這些字符串返回給客戶端后,沒有釋放這些字符串,導致了內存泄漏的發生。
原生的3.2.8代碼中 getKeysInSlot 函數,由于使用的是跳躍表,該跳躍表中的每個節點都是一個 key 的 obj 類型,因此只需要返回這個 key 的指針即可,無需內存拷貝動作,因此上層調用中也就不需要內存釋放動作。這個根因查明,也反過來解釋了很多疑問:
為什么剛開始只有老版本才有內存泄漏,新版本未發現。原因是老版本的實例上線時間長,有水平擴容的需求較多,內存泄漏的實例也就較多。
泄漏的內存為什么連續分布?原因是在一次 slot 遷移動作中,這些 key 遍歷動作都是連續進行的。
?這個系統為什么泄漏比例這么高?原因是該系統中 key 占用的內存比 value值更高,key 通常80字節,而 value 大多是0、1等數值。
20:00 修復動作
相比較根因的查找,修復就簡單多了,只需添加一行代碼即可。? ?
后續思考
1、代碼 review 需要從功能視角去走讀代碼,不能只關注 diff 不同。在本次調查中,第一遍走讀代碼只關注 diff 點,是無法發現問題的。
2、對內存泄漏的排查,在代碼設計階段是避免此類問題的效率最優解,代碼 review 階段比測試階段代價要小,測試階段發現要比上線后排查容易得多,越是工程后期修復 bug 越難。具體在該函數設計中,由于內存申請和釋放沒有內聚性,導致內存泄漏很容易出現,而這個函數在3系列使用跳躍表時是沒有問題的,因為不涉及到內存的申請釋放。開發和 QA 在測試中引入工具進行功能覆蓋測試,動態工具如 valgrind、sanitizers 等,線上工具如memleak、perf等。