张博康,PingCAP 资深研发工程师
读完需要
速读仅需 7 分钟
导读

在过去的一段时间内,TiKV 碰到了一些内存泄露导致 OOM 的问题。虽然最终都得以一一解决,但排查过程有些情况需要依赖猜测,很难直观确定问题所在。生产场景中必须及时定位问题,因此,提升内存的可观测性、及时发现和解决内存泄露问题,显得尤为重要。
内存泄露难以察觉:内存泄露往往是一个缓慢积累的过程,可能需要数天甚至数周才能显现出问题。这种细水长流的泄露方式使得问题难以被及时发现,给排查带来了极大的挑战。 缺乏排查现场:当 OOM 事件发生时,系统会直接崩溃或进程被杀掉,导致没有现场数据可以用来排查问题。这种情况使得事后分析和诊断变得非常困难。 难以复现问题:内存泄露问题在测试环境中通常很难重现,尤其是在泄露积累需要较长时间的情况下。即使在相似的环境下,也可能因为不同的工作负载和系统状态而无法复现。
面:某个时刻的内存切面,即 Heap Profiling 获得的全局现有分配内存的堆栈 线:某段时间各个模块线程的内存使用量,通过 metrics 记录以展示变化趋势 点:精确的内存使用量统计与控制,通过 Memory Trace 手动追踪内存,精确知道内存大户的具体位置,并同时进行管控控制使用率。比如 block cache,entry cache,coprocessor 中间结果等等

没有默认开启,需要通过 HTTP debug 接口手动触发获得那一段时间增量的 profile data 操作麻烦(如下所示),获得的 profile data 还需要在 TiKV 宿主机上用 jemalloc 的命令行工具 jeprof 进行解析,在实际情况下常常因为环境等问题导致解析失败,十分影响效率
$curl -X GET 'http://$TIKV_ADDRESS/debug/pprof/heap?seconds=30' > prof_data
$jeprof --svg /path/to/tikv prof_data
默认开启,那统计的就是全量的已有内存占用,通过全量做差也可以知道增量的变化 不依赖 binary 进行解析,不需要额外命令一键式获取火焰图
2.1 默认开启

heap_v2/524288
t*: 28106: 56637512 [0: 0]
[...]
t3: 352: 16777344 [0: 0]
[...]
t121: 17754: 29341640 [0: 0]
@ 0x5629e1b96e20 0x5629e1b97401 [...] 0x7fe62a7ce083 0x5629dce516fe
t*: 1: 67108864 [0: 0]
t0: 1: 67108864 [0: 0]
@ 0x5629e1b96e20 0x5629e1b97401 [...] 0x7fe62a8c9353
t*: 1: 4096 [0: 0]
t5: 1: 4096 [0: 0]
[...]
@ 0x5629e1b96e20 0x5629e1b97401 [...] 0x5629e186553d 0x7fe62a7ce083 0x5629dce516fe
t*: 1: 10485760 [0: 0]
t0: 1: 10485760 [0: 0]
MAPPED_LIBRARIES:
5629dc5fb000-5629dcb71000 r--p 00000000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
5629dcb71000-5629e24bc000 r-xp 00576000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
5629e24bc000-5629e384b000 r--p 05ec1000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
5629e384b000-5629e3c20000 r--p 0724f000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
5629e3c20000-5629e3c2e000 rw-p 07624000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
5629e3c2e000-5629e3e97000 rw-p 00000000 00:00 0
[...]
7fe61545c000-7fe61565c000 rw-p 00000000 00:00 0
7fe61565c000-7fe61fe00000 r--p 00000000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
7fe61fe00000-7fe620200000 rw-p 00000000 00:00 0
[...]
先是各个线程统计的仍然活跃的采样点对象个数(malloc 时被采样到 +1,free 时如果是之前被采样到的内存地址则 -1)以及内存使用量 bytes,其中 t* 表示所有线程的加合。至于 [0: 0] 表明的是历史累加值,而非当前活跃的,由于默认不开启统计累加值,因此总是为 0。
紧跟着的多个 @ <frame> <frame> ... <frame>是每次采样的栈信息,自顶向下得列出各层栈帧的运行时地址。同时相同的栈可能来自于不同的线程,因此也列出该栈来自于不同线程的采样点对象个数和内存占用量 bytes,格式同上。
最后是 MAPPED_LIBRARIES,实际就是当时 /proc/<tikv_pid>/maps 的内容,有什么作用我们后面会提到
<heap_profile_format_version>/<mean_sample_interval>
<aggregate>: <curobjs>: <curbytes> [<cumobjs>: <cumbytes>]
[...]
<thread_3_aggregate>: <curobjs>: <curbytes>[<cumobjs>: <cumbytes>]
[...]
<thread_99_aggregate>: <curobjs>: <curbytes>[<cumobjs>: <cumbytes>]
[...]
@ <top_frame> <frame> [...] <frame> <frame> <frame> [...]
<backtrace_aggregate>: <curobjs>: <curbytes> [<cumobjs>: <cumbytes>]
<backtrace_thread_3>: <curobjs>: <curbytes> [<cumobjs>: <cumbytes>]
<backtrace_thread_99>: <curobjs>: <curbytes> [<cumobjs>: <cumbytes>]
[...]
MAPPED_LIBRARIES:
</proc/<pid>/maps>
有了这个 heap profile 就可以用 jeprof 去生成火焰图,火焰图中我们看到的都是函数名而不是那些地址,因此 jeprof 就需要将这个地址映射成人类可读的 symbol。而这个映射就需要 binary 中的额外信息,分为两种:
Symbol table,ELF 使用两个 sections 来表示 symbol table:
$ readelf -S tikv-server | grep sym
[ 5] .dynsym DYNSYM 0000000000000988 00000988
[41] .symtab SYMTAB 0000000000000000 080071d0
.symtab:局部符号,程序中标识符和内存地址的对应关系 .dynsym:动态符号,前者的子集,用来保存与动态链接相关的导入导出符号
DWARF debug 信息(即 gcc -g 生成的调试符号表),ELF 使用以 debug 开头为 sections 来表示
$ readelf -S tikv-server | grep debug
[33] .debug_aranges PROGBITS 0000000000000000 07631338
[34] .debug_info PROGBITS 0000000000000000 076367d8
[35] .debug_abbrev PROGBITS 0000000000000000 078ee3e8
[36] .debug_line PROGBITS 0000000000000000 07903b90
[37] .debug_str PROGBITS 0000000000000000 07a3ae1a
[38] .debug_loc PROGBITS 0000000000000000 07a905d0
[39] .debug_ranges PROGBITS 0000000000000000 07e57978
[40] .debug_macro PROGBITS 0000000000000000 07fc8198
$addr2line 7fe62a7ce083 -e tikv-server -f -C
??
??:0
为什么会有这样的结果?因为如今 OS 都支持地址空间配置随机载入 (ASLR),即在装载时将程序装载在随机地址,以防止黑客利用已知地址信息执行恶意代码。为支持这一功能,编译器默认会编译程序为位置无关可执行文件(PIE)的形式,以方便加载到任意位置。因此每次进程的 text section 起始地址都是随机的,这就导致同样的代码每次运行时地址是变的,但不变的是该代码相对于 text section 起始位置的偏移。

$ cat /proc/<tikv_pid>/maps
5629dc5fb000-5629dcb71000 r--p 00000000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
5629dcb71000-5629e24bc000 r-xp 00576000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
5629e24bc000-5629e384b000 r--p 05ec1000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
5629e384b000-5629e3c20000 r--p 0724f000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
5629e3c20000-5629e3c2e000 rw-p 07624000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
5629e3c2e000-5629e3e97000 rw-p 00000000 00:00 0
[...]
7fe61545c000-7fe61565c000 rw-p 00000000 00:00 0
7fe61565c000-7fe61fe00000 r--p 00000000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
7fe61fe00000-7fe620200000 rw-p 00000000 00:00 0
[...]
<address start>-<address end> <mode> <offset> <major id:minor id> <inode id> <file path>
5629dcb71000-5629e24bc000 r-xp 00576000 08:03 8041447 /root/zbk/tikv/target/release/tikv-server
address start – address end 表示的这段内存映射的范围
mode (permissions) 表示具有的权限和模式,r 读,w 写,x 可执行,p/s 私有
offset 表示该段内存起始位置在原文件中的偏移
major:minor ids 表示映射文件所在硬件的 major and minor id
inode id 表示映射文件的 inode
file path 表示映射文件的路径

$ jeprof --help
Usage:
...
jeprof [options] <profile>
<profile> is a remote form. Symbols are obtained from host:port/pprof/symbol
Each name can be:
/path/to/profile - a path to a profile file
host:port[/<service>] - a location of a service to get profile from
The /<service> can be /pprof/heap, /pprof/profile, /pprof/pmuprofile,
/pprof/growth, /pprof/contention, /pprof/wall,
/pprof/censusprofile(?:\?.*)?, or /pprof/filteredprofile.
如果是 GET 请求返回 symbol 的个数,返回数据的格式为 num_symbols: ### ,其中 ### 是可执行文件中 symbol 的数量(目前,唯一重要的区别是这个值是否为 0,对于缺少调试信息的可执行文件,此值为 0;否则不为 0)。 如果是 POST 请求返回地址对应的函数名,传入的数据为多个十六进制的地址以 连接,返回的数据则为多行,每一行以+ <hex address><tab><function name> 格式输出地址对应的函数名,
首先读取 获得内存映射信息/proc/self/maps 依次用传入的地址去对比看落在内存映射信息中的哪个 range 中, 并用该 range 的 address start 和 offset 来计算出转化后的 addr 从 proc/self/exe 获取 TiKV 自身 binary 的路径并加载 debug 信息 利用现成的库从 debug 信息中找到 addr 所对应的 symbol
--- symbol
binary=/root/zbk/tikv/target/release/tikv-server
0x00005629e1db950c rocksdb::Arena::AllocateAligned(unsigned long, unsigned long, rocksdb::Logger*)
[...]
0x00005629e08485aa tikv::read_pool::build_yatp_read_pool_with_name::{{closure}}::h6881170ec4231a36
---
--- heap
heap_v2/524288
t*: 572: 649942 [0: 0]
t0: 246: 891339 [0: 0]
2.3 集成进 TiDB Dashboard







能覆盖到的地方有限且不准确,只能覆盖已知的地方,不能发现未知的泄露 维护成本高,新修改的代码都需要手动添加逻辑追踪内存使用开销 增量容易遗漏导致累计误差

impl<T> MallocShallowSizeOf for Vec<T> {
fn shallow_size_of(&self, ops: &mut MallocSizeOfOps) -> usize {
unsafe { ops.malloc_size_of(self.as_ptr()) }
}
}
impl<T: MallocSizeOf> MallocSizeOf for Vec<T> {
fn size_of(&self, ops: &mut MallocSizeOfOps) -> usize {
let mut n = self.shallow_size_of(ops);
for elem in self.iter() {
n += elem.size_of(ops);
}
n
}
}
#[derive(Clone, MallocSizeOf)]
struct BaseRowSampleCollector {
null_count: Vec<i64>,
count: u64,
fm_sketches: Vec<FmSketch>,
#[ignore_malloc_size_of = "Rng is not easy to calculate size"]
rng: StdRng,
total_sizes: Vec<i64>,
memory_usage: usize,
reported_memory_usage: usize,
uuid: String,
}
有相对较多额外的开销,降低频率即时性会差一点 对于 Arc 不能很好的处理,需要单独标注或者跳过,否则会被重复统计 对于 hashmap 或者 channel 等结构体的内存不一定是连续的,可能统计起来就没那么准确了。


Heap Profiling 提供了中等颗粒度和准确度的内存使用快照,适合在内存问题出现时进行详细分析,但不具备趋势性。 Memory Stats 通过 metrics 记录内存使用量的变化趋势,能帮助我们监控和分析内存使用的历史数据,但其颗粒度和准确度较低。 Memory Trace 提供了高颗粒度和高准确度的内存使用统计,能够精确定位内存消耗大的模块和位置,并进行内存控制,但覆盖范围有限且实现复杂。













