用 TDengine 3.0 碰到“内存泄漏”?定位问题原因很关键

作为 C/C++ 开发人员,内存泄漏是最容易遇到的问题之一,这是由 C/C++ 语言的特性引起的。众所周知,开源的时序数据库Time Series DatabaseTDengine OSS 就是使用 C 语言进行底层自研的,也因此,针对内存泄漏问题,我们的研发小伙伴也做了诸多研究和思考。在本篇文章中,我们将从 GitHub 上的一个关于内存泄漏的 issue 入手,和大家探讨下导致内存泄漏的原因,以及如何避免和定位内存泄漏。

issue 链接:https://github.com/taosdata/TDengine/issues/18276

从上述 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 发现不了的内存增长问题。