用 TDengine 3.0 碰到“記憶體洩露”?定位問題原因很關鍵

TDengine發表於2023-10-08

作為C/C++開發人員,記憶體洩漏是最容易遇到的問題之一,這是由C/C++語言的特性引起的。眾所周知,開源的時序資料庫(Time Series Database)TDengine OSS 就是使用C語言進行底層自研的,也因此,針對記憶體洩露問題,我們的研發小夥伴也做了諸多研究和思考。在本篇文章中,我們將從 GitHub 上的一個關於記憶體洩漏的 issue 入手,和大家探討下導致記憶體洩漏的原因,以及如何避免和定位記憶體洩漏。

這是一個疑似記憶體洩漏問題,該使用者使用 TDengine OSS 從 3.0.1.6 版本開始一直升級測到 3.0.2.2 版本,記憶體洩漏問題一直存在。該問題簡化總結即:在只有一個簡單查詢(例如 select count(*) from 子表)且不斷重複查詢的情況下,taosd 記憶體持續上漲。測試中 taosd 記憶體佔用從 400MB 可以一直漲到 24GB+。期間,另有其他使用者也評論反饋遇到相同的問題,在記憶體小的情況下,最終 taosd 會 OOM。

問題定位

遇到這種疑似記憶體洩漏問題時,第一步應該先用工具跑,在使用常用工具 Valgrind、Address sanitizer 嘗試之後,結果都報告沒有記憶體洩漏。這種情況在之前 2.x 版本也曾發生過,當時研發人員懷疑 glibc 的記憶體管理器有問題(不完善),然後切換到 jemalloc 或 tcmalloc,但是不是真的是 glibc 有 BUG 或者記憶體空洞問題導致的?我們需要尋找證據。

問題分析

在開始動手之前我們先要搞清楚概念,到底什麼是記憶體洩漏?我們都瞭解記憶體洩漏的最大害處是導致程式最終 OOM,在此之前能觀察到的現象是程式記憶體使用量持續上漲。那是不是隻要程式 OOM 了或者記憶體持續上漲就是有記憶體洩漏?並不是。簡單來說,記憶體洩漏是指不再使用的記憶體沒有釋放,這必然導致記憶體持續上漲直至 OOM,但不是隻有記憶體洩漏會導致記憶體持續上漲和 OOM,上面提到的記憶體空洞問題或者快取也會導致同樣的後果。所以嚴格來說,上述 issue 遇到的是記憶體持續上漲或 OOM 問題,並不一定是記憶體洩漏。但是不管是哪一種情況造成的,後果都是嚴重的,研發人員都要找到問題並解決它。

常見的 可能造成記憶體持續上漲的問題有記憶體洩漏、記憶體空洞、快取三類,而我們常用的 Valgrind、Address sanitizer 能夠發現解決的都是記憶體洩漏問題,而對於記憶體空洞和快取問題卻無法檢測,這就是為什麼 很多時候會有記憶體在漲但是工具檢測不到問題的情況發生。但想要說服使用者這是空洞問題也並不那麼容易,單純的記憶體空洞問題通常只會導致記憶體佔用多的問題,空洞部分是可以重複利用的,也就是說通常不會造成記憶體持續增長問題,只在一些極端使用場景下可能會出現持續增長的問題。如果工具可靠且可以排除記憶體空洞問題,那大機率就是快取問題了,而 taosd 在單個查詢重複執行的場景下又沒有明顯的快取問題。理論分析又陷入困境,我們需要一種能發現解決這三類問題的方法和工具。

雖然是三類問題,但他們也有共同點,那就是都是因為記憶體的分配和釋放造成的,如果 能夠找到並記錄每個記憶體分配和釋放的點就可以分析屬於什麼狀況了:

  • 分配後釋放了 – 沒有問題
  • 分配後未釋放 – 需要根據程式碼分析是記憶體洩漏還是快取

既然有了思路,接下來就是思考如何實現了,核心問題是 怎麼找到並記錄每個記憶體分配和釋放的點?開發程式碼可以記錄每一個 taosd 自己的記憶體分配和釋放,但是開發工作量不小短時間內難以完成,更重要的原因在於 taosd 的程式空間中除了我們自己開發的程式碼外還有 第三方庫包括 glibc 的程式碼,雖然出問題的機率較小,但如果是我們的使用方式有問題也是存在出問題的可能的,這些程式碼中出現的問題怎麼辦?我的答案是向下找介面,即 在系統呼叫層面捕捉記憶體的分配和釋放

背景知識
  • glibc 中的記憶體管理器 ptmalloc 透過 brk、mmap、munmap 3 個系統呼叫從 OS 分配和釋放記憶體,對於大塊記憶體每次都透過 mmap、munmap 直接分配和回收,對於小塊記憶體則是透過 brk 從堆上分配一個大片記憶體然後進行內部切分來分配、釋放、複用,因此預設情況下單個小塊記憶體的分配是不一定能從系統呼叫的追蹤中看到的。這裡的“大塊”與“小塊”的邊界值大小預設是 128K,同時提供了 mallopt(M_MMAP_THRESHOLD,threshold_value)來改變這個邊界值。這就給我們提供了一種便利,只要將這個值調到足夠小就可以觀察到使用者空間所有的記憶體分配與釋放。
  • strace 命令可以捕獲所有使用者空間程式發出的系統呼叫和其引數資訊,帶來的便利就是可以觀察到所有記憶體分配與釋放的系統呼叫,同時對於日誌資訊可以被記錄觀察到。

定位步驟

  • taosd 啟動時呼叫如下程式碼強制所有記憶體分配與釋放都透過 mmap、munmap 進行,進而可以觀察到使用者所有記憶體的分配與釋放。

  int ret = mallopt(M_MMAP_THRESHOLD, 0);
  if (0 == ret) {
    return TAOS_SYSTEM_ERROR(errno);
  }
  • 配置中開啟 taosd 所有模組的 DEBUG 日誌開關,關閉非同步日誌,啟動 taosd 程式,啟動測試程式。
  • shell 中執行下面的命令捕捉系統呼叫。
strace -TttFf -e write=0,1,2,3 -p `pidof taosd` -o strace_log.txt
  • 在測試執行完成後或觀察到明顯的記憶體增長後停止 strace 命令,strace_log.txt 內容示例如下:
1230673 12:56:10.273506 <... futex resumed>) = 0 <0.001681>
1230741 12:56:10.273535 write(3, "01/13 12:56:10.273516 01230741 Q"..., 129 <unfinished ...>
1230673 12:56:10.273547 futex(0x7ff766f4d01c, FUTEX_WAIT_BITSET_PRIVATE|FUTEX_CLOCK_REALTIME, 3, NULL, FUTEX_BITSET_MATCH_ANY <unfinished ...>
1230741 12:56:10.273566 <... write resumed>) = 129 <0.000022>
 | 00000  30 31 2f 31 33 20 31 32  3a 35 36 3a 31 30 2e 32  01/13 12:56:10.2 |
 | 00010  37 33 35 31 36 20 30 31  32 33 30 37 34 31 20 51  73516 01230741 Q |
 | 00020  52 59 20 51 49 44 3a 30  78 65 33 39 37 66 65 37  RY QID:0xe397fe7 |
 | 00030  63 33 65 30 38 38 36 63  30 2c 54 49 44 3a 30 78  c3e0886c0,TID:0x |
 | 00040  63 33 32 34 2c 45 49 44  3a 30 20 74 61 73 6b 20  c324,EID:0 task  |
 | 00050  73 74 61 74 75 73 20 75  70 64 61 74 65 64 20 66  status updated f |
 | 00060  72 6f 6d 20 45 58 45 43  55 54 49 4e 47 20 74 6f  rom EXECUTING to |
 | 00070  20 50 41 52 54 49 41 4c  5f 53 55 43 43 45 45 44   PARTIAL_SUCCEED |
 | 00080  0a                                                .                |
1230741 12:56:10.273603 futex(0x7ff766f4d01c, FUTEX_WAKE_PRIVATE, 1) = 1 <0.000027>
1230749 12:56:10.273644 <... futex resumed>) = 0 <0.001744>
1230741 12:56:10.273655 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0 <unfinished ...>
1230749 12:56:10.273669 write(3, "01/13 12:56:10.271877 01230749 U"..., 83 <unfinished ...>
1230741 12:56:10.273681 <... mmap resumed>) = 0x7ff50f4c8000 <0.000020>
  • 透過下面的 shell 命令從 strace 生成的檔案中提取所有的記憶體分配地址與釋放地址,map.txt 檔案中的每行內容為一個記憶體分配的地址,unmap.txt 檔案中的每行內容為一個記憶體釋放的地址。
egrep "mmap|mremap" strace_log.txt |grep -v unfinished|awk -F "=" '{print $2}'|awk '{print $1}'>map.txt 
egrep "munmap|mremap" strace_log.txt |grep -v resumed| awk -F "(" '{print $2}'|awk -F "," '{print $1}'>unmap.txt
  • 透過自己開發的一個小工具從 map.txt 依次讀取每一行,然後在 unmap.txt 檔案中依次尋找該地址是否存在,如果存在則該記憶體分配釋放沒有問題;如果不存在,則該地址(A)為記憶體洩漏或者一個快取的地址。
  • 在 strace_log.txt 中找到最後一次 mmap 分配的上一步找到的可疑地址 (A),透過執行緒號觀察該次記憶體分配的上下文資訊(系統呼叫和日誌資訊),進而在程式碼中找到對應的記憶體分配的地方。
  • 透過程式碼分析確認該次分配的記憶體在 strace 觀察的時間段內未釋放是否是正常的程式行為,如果是則可以劃分為快取類別;如果不是則判斷為記憶體洩漏或異常快取,修改後驗證直至記憶體不再增長。
說明
  • 開啟 taosd 所有模組日誌、關閉非同步日誌、跟蹤所有系統呼叫的目的都是為了在第 7 步有足夠的上下文資訊判斷記憶體分配的程式碼,但對於日誌較少的模組我們可能需要透過增加日誌逐步縮小範圍來最終找到記憶體的分配點;
  • 在第 4 步我們需要充足時間保證測試完整執行完,進而保證最終找到可疑地址(A)不是因為觀察時間不足還未等到 munmap 的場景(排除干擾);
  • 使用限制:只適用於 glibc 的記憶體管理器(Linux + glibc);
  • 工具程式碼如下,編譯後跟第 5 步生成的結果放在一個目錄直接執行即可(無需引數):
#include "stdlib.h"
#include "stdio.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
char in1[16] = {0};
char in2[500*1048576][16] = {0};
main()
{
  FILE* fd1=fopen("map.txt", "r");
  FILE* fd2=fopen("unmap.txt", "r");
  int i = 0, n = 0, found = 0,m=0, minIdx = 0, non0 = 0;
  while(fgets(in2[i], sizeof(in2[0]), fd2) != NULL)
  {
    if (in2[i][14] = '\n') {
      in2[i][14] = 0;
    }
    i++;  
  }
  printf("%d rcords in unmap.txt read\n", i);
  while(fgets(in1, sizeof(in1), fd1) != NULL) 
  {
     if (in1[14] = '\n') {
       in1[14] = 0;
     }
     m++;
     non0 = 0;
     for(n=minIdx;n<i;n++)
     {
        if(in2[n][0]==0) {
           if (0 == non0) {
               minIdx++;
           }
           continue;
        }
        non0 = 1;
        if((in1[0]==in2[n][0]) && (0==strcmp(in1, in2[n])))
        {
           in2[n][0]=0;
           break;
        }
     }
     if (n==i)
     {
         found++;
         printf("%dth found, %s, it's the %dth in map.txt\n", found, in1, m);
         //if(found>=100)
         //  break;
     }
     if (m > (minIdx+10000)) {
        minIdx++;
     }
  }
}

定位結果

透過使用上面介紹的方法,我們最終定位到了兩個問題:

  • 一處記憶體錯誤問題,按照上面的分類屬於非預期的快取造成的:
  atexit(cleanupRefPool);

說明:我們在建立每個查詢子任務時都直接呼叫了上面這個語句,它會每次快取一個函式地址,最終在程式退出時又都全部釋放了,因此不屬於記憶體洩漏,Valgrind 和 Address sanitizer 都檢測不到,這是造成查詢記憶體一直增長的原因。

  • 一處可最佳化的快取管理,不是記憶體增長的原因,但是針對特定使用場景快取有最佳化空間。

總結與後續

上述問題是一個從 3.0.0.0 版本開始就一直存在的“記憶體洩漏”問題,任何一個查詢都存在,直到 3.0.2.5 版本出來之後,我們才可以說 taosd 終於沒有“記憶體洩漏”問題了。本文透過一種不需要額外程式碼開發的方法,在傳統的記憶體洩漏檢測工具能力範圍之外, 一站式定位解決程式記憶體佔用持續增長或 OOM 問題,讓徹底解決這類問題成為可能。此外面對這一類問題,目前 TDengine OSS 已經在 taosd/taosc 增加線上開閉記憶體除錯模式,可以隨時在現場定位記憶體增長問題,不需要安裝工具,不需要編譯 ASAN 版本,尤其 適合解決 Valgrind/ASAN 發現不了的記憶體增長問題。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70014783/viewspace-2987300/,如需轉載,請註明出處,否則將追究法律責任。

相關文章