做服务端开发这些年,内存问题一直是最让人头疼的疑难杂症。线上服务跑着跑着内存一路爬升,或者偶发崩溃,core dump 一拉发现栈早就被踩烂了,这种场景我猜不少人都经历过。排查这类问题,大家第一反应就是 Valgrind 或者 AddressSanitizer,但真到用的时候你会发现,这些工具要么慢得让人没法在测试环境跑全量用例,要么就是只能本地复现,到了线上完全使不上劲。后来我索性自己写了一个自定义内存检测工具,用轻量级的 hook 方案把 malloc/free 的调用都拦截下来,专门解决这些场景下的内存问题。这篇文章就把这套工具的完整设计思路、核心实现和实操过程拆开讲清楚,想自己动手折腾一套的可以参考着来。
1. 为什么非要自己写一个内存检测工具
1.1 现有工具的真实痛点
先聊聊大家常用的几个工具。Valgrind 的 memcheck 确实强大,能检测越界、使用未初始化内存、泄漏,但它有个致命的短板:慢。我实测过一个中等规模的服务,在 Valgrind 底下跑,性能直接下降 20 到 50 倍,原本 3 秒能跑完的用例要等一两分钟。这在单测场景下还能忍,想做压测或者长稳测试,基本不可能。
AddressSanitizer(ASAN)速度上好了很多,大概只有 2 到 3 倍的开销,但它要求整个项目重新编译,而且对编译器版本、链接方式有要求。更麻烦的是,ASAN 编译出来的版本行为有时和线上版本有差异,特别是涉及自定义内存池、第三方闭源库的时候,经常遇到 "unpoison" 报错或者误报,排查起来又是一堆额外的工作。
这些工具都解决不了另外一个非常实际的问题:线上问题怎么办?服务在线上跑了两周,内存持续增长但是很缓慢,每天涨 1%,这种"温和泄漏"用 Valgrind 因为太慢根本没法长时间挂载,ASAN 又不能直接上生产环境。最后我在实践中发现,与其被这些通用工具的各种限制卡住,不如针对自己的业务形态写一个只采集核心数据、开销可控的自定义内存检测工具。
1.2 自定义工具解决的三个现实场景
我复盘了一下,真正推动我去做这件事的需求主要有三个。
- 长稳测试场景的内存趋势观测:服务连续跑 48 小时甚至 7 天,需要每分钟记录一次进程的内存分配总量和各类分配占比,观测是否存在缓慢增长。
- 定制定位需求:比如我想知道某个模块内部的内存分配来源分布,或者想统计某个业务高峰期临时对象到底创建了多少个,这些通用工具都给不了。
- 线上轻量观测:在线上环境只开一个极低开销的统计模式,不打断业务,持续记录分配次数和字节数的粗粒度数据,配合监控系统做趋势告警。
1.3 明确目标:工具要能做什么
动手之前,我先把需求列成了一个清单,避免写着写着就跑偏。
| 功能需求 | 具体说明 | 优先级 |
|---|---|---|
| 分配/释放跟踪 | 记录每次 malloc/calloc/realloc/free 的指针、大小、调用位置 | 高 |
| 泄漏检测 | 退出时输出所有未释放的分配点,按大小排序 | 高 |
| 调用栈回溯 | 记录分配点处的调用栈,帮助快速定位代码位置 | 高 |
| 自定义阈值过滤 | 只统计大小超过 N 字节的分配,降低开销 | 中 |
| 自定义采样率 | 按比例采样记录,用于线上长稳模式 | 中 |
| 统计报表输出 | 按模块/大小区间/调用点聚合,方便快速分析 | 中 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心设计思路:技术方案选型与数据组织
2.1 三种 hook 方案怎么选
要实现对 malloc/free 的拦截,市面上主流有三种方案:宏替换、dlsym 符号拦截、LD_PRELOAD 预加载。我三种都折腾过,各自的优缺点比较清楚。
宏替换是最简单粗暴的方式,在编译期通过 #define malloc(size) my_malloc(size, __FILE__, __LINE__) 把所有调用替换成自己的函数,好处是能拿到文件名和行号,坏处是只能替换你自己的代码,第三方静态库和系统库调用的 malloc 还是走原来的路径,而且只要有一个文件忘记包含头文件,那个文件的分配就漏统计了。
dlsym 符号拦截是运行时方案,在自己的 so 里定义 malloc 函数,通过 dlsym(RTLD_NEXT, "malloc") 找到真实的 malloc 地址,然后做一层包装。好处是能覆盖整个进程的所有分配调用,包括第三方库;坏处是实现相对复杂,要处理递归调用、线程安全、重入等问题。
LD_PRELOAD 本质上和 dlsym 是配合使用的,通过环境变量把你的 so 预加载到进程里,实现开箱即用。但因为是全局生效,写不好非常容易崩,也容易被安全软件拦截。
我在工具里采用了 dlsym + LD_PRELOAD 为主、宏替换为辅的混合策略。主程序里需要精确源码位置的场景用宏替换,全局内存观测走 LD_PRELOAD 模式。两套逻辑可以共存,互不干扰。
2.2 需要采集哪些核心数据
设计数据结构的时候,我本着"够用就好"的原则,没有一上来就堆一堆字段。真正验证下来,核心的字段就这么多:
c复制typedef struct mem_record {
void *ptr; // 分配的内存地址
size_t size; // 分配的大小
uint64_t timestamp; // 分配时间,用单调时钟,避免墙钟被修改
uint32_t thread_id; // 分配线程 ID
int caller_count; // 回溯到的调用栈深度
void *caller_stack[16]; // 调用栈地址,最多回溯 16 层
struct mem_record *next;
} mem_record_t;
这个结构体每条记录大概占 200 字节左右。一开始我担心这个开销会不会太大,实测下来,单条记录 200 字节的成本和分配本身的开销相比完全可以接受,因为一次 malloc 内部本身就有不少元数据开销。
有个细节值得单独说:时间戳我用的是 clock_gettime(CLOCK_MONOTONIC) 而不是 time(),因为 time() 返回的是墙钟时间,NTP 同步或者人工改时间都会导致趋势图出现诡异的跳变,排查问题的时候容易被误导。
2.3 数据组织与输出格式设计
数据组织上,我用了一个全局哈希表来管理所有未释放的内存记录,key 是指针地址,value 是记录结构体。分配时插入,释放时删除。哈希表的好处是存取都是 O(1),不会因为分配次数增多导致查找变慢。
同时为了支持"按调用点聚合"的统计需求,我维护了一个全局统计表,以调用栈的哈希值作为 key,聚合分配次数和总字节数。这样输出报告的时候,不需要遍历所有未释放记录,直接从聚合表里按大小排序就能得到"泄漏嫌疑排行榜"。
输出格式上,我花了不少心思。最终定了一个兼容文本和 CSV 的格式,方便直接喂给脚本做分析:
text复制[LEAK] leak_suspects_summary:
count=128 total_bytes=1048576 avg_size=8192
callstack_hash=0xa1b2c3d4
allocation_site: /src/cache.c:245 /src/worker.c:87 /src/main.c:31
[STATS] total_alloc_count=52341
[STATS] total_alloc_bytes=268435456
[STATS] peak_alloc_bytes=134217728
[STATS] current_alloc_bytes=1048576
提示:输出格式一开始就要考虑机器可读性。我第一版用了自由文本,后面写脚本统计的时候解析得痛不欲生,第二版全部改成 key=value 格式后,awk、python 处理起来都舒服多了。
3. 核心实现:从零到可用的完整代码
3.1 malloc/free 的 hook 实现
核心的实现集中在两个地方:分配跟踪和释放跟踪。这里的难点不是简单的"包一层",而是要处理好各种边界条件。下面这段是核心代码的骨架,我加了详细的注释说明每个关键点。
c复制#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <stdint.h>
#include <execinfo.h>
// 定义真实的 malloc/free 函数指针
typedef void *(*malloc_fn)(size_t);
typedef void (*free_fn)(void *);
typedef void *(*calloc_fn)(size_t, size_t);
typedef void *(*realloc_fn)(void *, size_t);
static malloc_fn real_malloc = NULL;
static free_fn real_free = NULL;
static calloc_fn real_calloc = NULL;
static realloc_fn real_realloc = NULL;
// 全局锁,保护哈希表和统计表
static pthread_mutex_t mem_lock = PTHREAD_MUTEX_INITIALIZER;
// 哈希表大小,一般分配量级在几万到几十万,65537 够用
#define HASH_TABLE_SIZE 65537
static mem_record_t *record_table[HASH_TABLE_SIZE];
// 简单的哈希函数,实际用的是 FNV-1a 变体,这里简化展示
static uint32_t ptr_hash(void *ptr) {
uintptr_t addr = (uintptr_t)ptr;
return (uint32_t)((addr >> 4) ^ (addr >> 16));
}
static void record_alloc(void *ptr, size_t size) {
if (ptr == NULL) return;
pthread_mutex_lock(&mem_lock);
mem_record_t *record = (mem_record_t *)real_malloc(sizeof(mem_record_t));
record->ptr = ptr;
record->size = size;
record->timestamp = get_monotonic_ns();
record->thread_id = pthread_self();
// 回溯调用栈,最多 16 层
record->caller_count = backtrace(record->caller_stack, 16);
uint32_t idx = ptr_hash(ptr) % HASH_TABLE_SIZE;
record->next = record_table[idx];
record_table[idx] = record;
// 更新聚合统计
update_stats(record);
pthread_mutex_unlock(&mem_lock);
}
static void record_free(void *ptr) {
if (ptr == NULL) return;
pthread_mutex_lock(&mem_lock);
uint32_t idx = ptr_hash(ptr) % HASH_TABLE_SIZE;
mem_record_t *prev = NULL;
mem_record_t *curr = record_table[idx];
while (curr != NULL) {
if (curr->ptr == ptr) {
// 从哈希表摘除
if (prev) {
prev->next = curr->next;
} else {
record_table[idx] = curr->next;
}
// 从聚合统计里扣减
update_stats_free(curr);
real_free(curr);
break;
}
prev = curr;
curr = curr->next;
}
pthread_mutex_unlock(&mem_lock);
}
// 实际的 malloc 包装函数
void *malloc(size_t size) {
if (real_malloc == NULL) {
real_malloc = (malloc_fn)dlsym(RTLD_NEXT, "malloc");
}
void *ptr = real_malloc(size);
record_alloc(ptr, size);
return ptr;
}
// free、calloc、realloc 的实现类似,这里不展开
这段代码里有个极易踩的坑:record_alloc 内部调用了 real_malloc 而不是 malloc,如果写成 malloc 就会无限递归。另一个坑是锁的问题,为了保证线程安全必须加锁,但加了锁又会放大多线程竞争。我的优化方案是引入了线程局部缓冲,每条线程先从本地缓存取一块空间,缓存满了再全局加锁批量插入。这样在高并发场景下,锁竞争的开销能下降 90% 以上。
3.2 调用栈回溯与符号化
光有 malloc 地址和大小还不够,排查泄漏时你得知道是哪一行代码分配的。这里我用的是 Linux 上常用的 backtrace 系列函数,分为两步:运行时采集调用栈地址,生成报告时再转换成符号名。
c复制// 采集调用栈
void capture_stack(void **callstack, int max_depth, int *depth) {
*depth = backtrace(callstack, max_depth);
}
// 报告生成时,将地址转换为可读的符号信息
void dump_symbols(void **callstack, int depth) {
char **symbols = backtrace_symbols(callstack, depth);
if (symbols == NULL) {
fprintf(stderr, "backtrace_symbols failed\n");
return;
}
for (int i = 0; i < depth; i++) {
fprintf(stderr, " [%02d] %s\n", i, symbols[i]);
}
free(symbols);
}
这里有个经验:backtrace_symbols 返回的符号信息里,函数名通常是 _ZN6Module15DoSomethingEv 这种 C++ 修饰名,看着很头疼。建议在输出前调用 abi::__cxa_demangle 做一次反修饰,人类可读性会好很多。另外,编译时一定要加 -g -rdynamic 选项,否则很多地址无法解析成函数名,只会显示偏移地址,排查效率大打折扣。
如果部署环境的符号表被 strip 了,backtrace 的结果基本没法看。这种情况我的替代方案是:在程序启动时解析 /proc/self/maps,记录各个共享库的加载基址,结合 addr2line 工具离线解析。但这个方案比较重,常规项目不太需要,遇到再处理也来得及。
3.3 泄漏检测模块:退出时生成报告
泄漏检测的核心逻辑其实很简单:程序退出时,遍历哈希表,所有还留在表里的记录都是潜在泄漏。但直接全部输出通常没有意义,因为绝大部分程序退出时还没来得及清理的资源都会被算进来,比如全局单例、缓存池等。我的做法是做一个"智能排序 + 过滤"的机制。
c复制void report_leaks(int min_leak_bytes) {
pthread_mutex_lock(&mem_lock);
// 按调用栈的哈希值聚合,得到每个调用点的泄漏总量
leak_suspect_t suspects[MAX_SUSPECTS];
int suspect_count = 0;
for (int i = 0; i < HASH_TABLE_SIZE; i++) {
mem_record_t *record = record_table[i];
while (record != NULL) {
uint32_t stack_hash = hash_callstack(record->caller_stack, record->caller_count);
// 在 suspects 中查找或新增
int idx = find_or_add_suspect(suspects, &suspect_count, stack_hash);
if (idx >= 0) {
suspects[idx].count++;
suspects[idx].total_bytes += record->size;
}
record = record->next;
}
}
// 按总字节数降序排序,过滤掉小于阈值的内容
qsort(suspects, suspect_count, sizeof(leak_suspect_t), compare_suspects);
int leak_count = 0;
int64_t leak_total = 0;
for (int i = 0; i < suspect_count; i++) {
if (suspects[i].total_bytes < min_leak_bytes) continue;
fprintf(stderr, "[LEAK] %d allocations, %ld bytes, callstack hash: 0x%08x\n",
suspects[i].count, suspects[i].total_bytes, suspects[i].stack_hash);
leak_count++;
leak_total += suspects[i].total_bytes;
}
fprintf(stderr, "[LEAK-SUMMARY] %d leak suspects, %ld bytes total\n", leak_count, leak_total);
pthread_mutex_unlock(&mem_lock);
}
关于最小过滤阈值,我一般设置为 4096 字节。为什么是这个值?因为很多程序的缓存机制会保留一些小的分配,一直到退出才释放,这些大概率不是真正的业务泄漏。设置成 4KB 能过滤掉绝大部分"假阳性",又不会漏掉真正的泄漏。当然这个值需要根据实际情况调整,如果是移动端这种内存极度敏感的场景,阈值可以降为 0。
3.4 性能开销控制:让工具真正可用于线上
很多同行问我,这个工具用在线上环境真的不卡吗?我的回答是:看你怎么做降级设计。我把完整版工具拆成了两档模式,让用户按场景选。
- 调试模式:全量记录,每个 malloc/free 都走哈希表插入删除,开销约为原程序的 1.5 到 3 倍,适合本地压测、问题复现。
- 统计模式:不做逐条记录,只做全局计数器和按大小区间的桶统计,开销控制在 5% 以内,适合线上长稳观测。
统计模式的核心是一个原子操作就能完成的计数器累加,配合一个采样开关,每 N 次分配只统计一次,进一步降低开销。
c复制volatile uint64_t alloc_counter = 0;
volatile uint64_t alloc_bytes = 0;
volatile int sampling_enabled = 1;
volatile int sampling_period = 100; // 每 100 次采样 1 次
void update_stats_sampling(size_t size) {
if (!sampling_enabled) return;
// 原子累加
__sync_fetch_and_add(&alloc_counter, 1);
__sync_fetch_and_add(&alloc_bytes, size);
// 如果需要更细粒度,这里还可以加一个采样判断
if (unlikely(sampling_on_this_call())) {
// 有概率进到桶统计逻辑
}
}
实际上,内存检测工具的开销大头从来不是计数器,而是哈希表插入和调用栈回溯。backtrace 每调用一次大约会消耗几微秒,在高频分配场景下,这几十微秒就是压垮性能的主要因素。我的优化方案是引入缓存:同一个调用位置连续分配的记录,复用一个已经回溯好的栈,只有当调用地址变化时才重新回溯。测试下来这个优化能省掉 70% 的回溯开销。
4. 实操过程:接入项目与结果解读
4.1 编译和接入步骤
代码写好之后,接入项目的过程不复杂但有几个细节容易出问题。我按步骤拆开讲。
第一步,编译动态库:
bash复制gcc -shared -fPIC -O2 -g -rdynamic -o libmemtrack.so memtrack.c -ldl -lpthread
这里 -g 和 -rdynamic 必须加,否则 backtrace 拿到的栈全是问号。如果项目用了 C++,记得加 -lstdc++。
第二步,选择接入方式。宏替换模式的做法是在你的公共头文件里加:
c复制// memtrack_wrapper.h
#ifdef ENABLE_MEMTRACK
#define malloc(sz) memtrack_malloc(sz, __FILE__, __LINE__)
#define free(p) memtrack_free(p)
// calloc/realloc 同理
#endif
在 CMake 里条件编译:
cmake复制if(ENABLE_MEMTRACK)
target_compile_definitions(your_target PRIVATE ENABLE_MEMTRACK)
target_link_libraries(your_target PRIVATE memtrack)
endif()
LD_PRELOAD 模式则更轻量,不需要改业务代码:
bash复制LD_PRELOAD=/path/to/libmemtrack.so ./your_server
第三步,设置输出日志级别。我习惯把工具日志输出到 stderr 而不是 stdout,因为很多服务会用 stdout 输出业务日志,混在一起会增加解析工作量。接着通过环境变量控制是否开启泄漏报告和统计模式:
bash复制MEMTRACK_MODE=leak_report ./your_server
4.2 一个真实泄漏案例的排查过程
说一个真实的案例。某次我们一个缓存服务在压测中内存持续增长,每 10 分钟涨 200MB,重启后恢复,过一段时间又开始涨。第一反应就是有内存泄漏,但我用 Valgrind 跑了一遍,居然没查出问题。原因分析下来,是这个泄漏量级分布在大量小对象上,每个泄漏几百字节,数量一多总量就大了,Valgrind 的默认 leak-check 模式对这种"温水煮青蛙"型泄漏并不敏感。
换上我自己的工具,开了调试模式跑了 20 分钟,宕机后拉到的报告非常直观:
text复制[LEAK] 52000 allocations, 86425600 bytes, callstack hash: 0x8f4a2b3c
[00] ./libcache.so(_ZN5Cache6InsertERKSt6string+0x45)
[01] ./libcache.so(_ZN5Cache8EvictOldEv+0x1a0)
[02] ./yourserver(main+0x400)
[LEAK] 300 allocations, 15728640 bytes, callstack hash: 0x2d9f01ae
[00] ./libcache.so(_ZN5Cache10LoadPrefillEv+0x100)
[01] ./yourserver(main+0x400)
看到第一组数据的时候,我基本锁定问题了:Cache::Insert 分配了 52000 次但从未释放,而调用者是 EvictOld。这个函数名也叫 "剔除旧缓存",本来应该释放旧数据,结果从 Insert 路径进来反而分配了新内存,显然是逻辑写反了——应该在 Insert 新数据前先清理旧项,但代码里 Insert 在新数据写入后,EvictOld 却又调了一次 Insert 更新访问时间。这是一个典型的逻辑错误导致的泄漏。
对比一下,如果当时没有调用栈信息,光靠地址和大小,这个 bug 找起来至少要多花一整天。
4.3 结果解读与统计报表输出
工具输出的原始日志方便定位单点问题,但要做趋势分析就需要配合报表。我给工具加了一个 --stat-output 模式,可以直接输出 JSON 格式的聚合数据:
bash复制$ ./memtrack_report.py -i memtrack.log -o report.json
$ cat report.json
{
"total_alloc": 52341,
"total_alloc_bytes": 268435456,
"peak_bytes": 134217728,
"leak_bytes": 1024000,
"top_leak_sites": [
{
"callstack_hash": "8f4a2b3c",
"count": 52000,
"bytes": 86425600,
"rank": 1
}
]
}
拿到这个 JSON 后,接 Grafana 或者直接画趋势图都很快。我个人习惯是保留两份数据:一份原始 log 用于定位问题,一份 JSON 用于看趋势。定期抓取 JSON 里的 leak_bytes 字段做环比,如果持续增长就要警惕了。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
做这个工具的过程中,我在各种环境里踩了不少坑,也帮团队同事解决过接入时的问题。这里列一个速查表,按出现频率排个序。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 程序启动就崩溃 | hook 了系统库内部的 malloc 调用,但还没初始化完成 | 在构造函数里做一次性初始化,用 __attribute__((constructor)) |
| 死锁 | malloc 内部又调用了 malloc,锁重入 | 检查 hook 函数里的所有 malloc 调用是否都改成了 real_malloc |
| 泄漏报告全是乱码地址 | 编译时没加 -g -rdynamic |
重新编译,确认链接时带上调试符号 |
| 线上模式内存暴涨 | 统计模式误开了完整记录,哈希表记录太多 | 检查 MEMTRACK_MODE 环境变量,确认使用的是统计模式 |
| 多线程 Lind 下数据错乱 | 哈希表并发操作 | 确认锁粒度正确,或者使用线程局部缓冲 |
| backtrace 采样数量不足 | 编译器优化把内联函数展开 | 编译时加 -fno-omit-frame-pointer 避免省去帧指针 |
5.2 两个印象深刻的坑
第一个坑是 dlsym 的递归调用问题。刚开始实现的时候,record_alloc 里用了 malloc 来做哈希节点的内存分配,结果一运行就栈溢出。原因很简单:malloc 被 hook 成了我们自己的函数,而我们的 hook 函数内部又调用 malloc,于是无限递归。修正方法前面也说了:hook 函数里所有的内存分配必须用 real_malloc,通过 dlsym 拿到的原始函数指针。
第二个坑是 free 一个从未被 malloc 记录的地址(比如全局变量的地址)。我在 record_free 里遍历哈希表找不到记录时,直接什么都不做就返回。这样设计的好处是不影响业务,坏处是对于真正的非法释放场景,工具不会报警。后面我加了一个可选的 --strict-mode 参数,遇到这种情况直接打印告警并触发 abort,方便在测试环境抓非法释放。
第三个坑是线程局部缓冲引入的。一开始我用 __thread 变量做每条线程的局部记录缓冲,以为可以完全避免锁竞争。实际运行后发现,虽然写缓冲不需要锁了,但批量刷入全局哈希表时还是需要加锁,而且锁的粒度如果太大,线程越多竞争越严重。最终我把批量刷入的阈值调小到 64 条,并且把锁改成自旋锁(pthread_spinlock_t),在项目这种"临界区很短"的场景下,自旋锁比互斥锁性能好很多。
5.3 线上事故中的快速止血技巧
有一次在生产环境上,服务内存告警已经触发,但还没定位到问题。我在线上临时启用统计模式,配合火焰图观察,发现内存增长集中在某个业务接口。用工具的 --stat-output 模式跑了一会儿,确认是某个接口频繁创建超大缓存对象,而缓存淘汰策略有 bug,导致对象占了大量内存迟迟释放不了。这时因为工具开销只有 5% 左右,线上完全能扛住,我先加了限流,再让开发修逻辑,整个过程没有重启服务。
这个经历让我更坚定了:做工具一定要分成"全量调试"和"轻量观测"两种模式,宁可多写一套代码,也别让工具本身成为事故根源。
6. 自定义能力的进阶扩展
6.1 从泄漏检测走向内存画像
基础的泄漏检测做稳定之后,工具的能力可以往两个方向延伸。一个是内存画像(memory profiling):除了记录分配总量,还可以统计生命周期极短的临时对象、分配频率最高的热点函数、大对象(比如超过 1MB 的分配)的分布情况。这些数据对于性能调优非常有价值。比如我通过热点统计发现,某个序列化框架在每次 RPC 调用时会分配 200 多个小对象,直接导致 CPU 缓存命中率下降。
6.2 配合线程局部缓存定位并发问题
另一个扩展方向是和线程相关的分析。默认情况下我的工具会记录每个分配的线程 ID,但只在调试模式输出。要做并发问题的定位,可以在报告模式里增加一个 --by-thread 选项,输出每个线程的分配总量和调用栈。有一次排查线上偶发性延迟抖动,我开了这个功能,发现某个线程池中的工作线程在一个小时内执行了 5 万次 realloc,而其他线程都只有几百次,顺藤摸瓜找到了一个"复用 buffer 但长度计算错误导致频繁扩容"的性能 bug。
6.3 关于是否开源和继续维护
很多同行看到我这个工具后都问能不能拿来直接用。我的建议是:如果只是简单场景,用 Valgrind 或者 ASAN 足够;但如果你的项目有长稳测试、线上观测需求,值得花两三天时间照这篇文章的思路自己撸一个。因为只有自己写的工具才最理解你的业务形态,也方便后续按需求灵活扩展。我自己的版本已经在团队内部迭代了三个大版本,有些功能(比如按模块动态开关)是针对自家服务定制的,写进文章反而会误导别人,所以这里只公开核心思路和基础代码。
我在实际使用中还有一个体会:好的工具不是功能越多越好,而是在你需要的时候恰好能用,不需要的时候开销为零。这套自定义内存检测工具走到今天,最大的价值不是帮我抓了多少个泄漏,而是让"内存问题可观测"这件事变成了服务的默认能力,排查问题不再靠猜。最后分享一个建议:不管用哪种方案,内存检测工具一定要在系统测试阶段就接入,别等到线上出了问题才想起来搭工具,那时候的排查成本高得多。
