LinkedIn 的 Venice 团队遇到过一桩悬案:RocksDB 服务的 RSS 单调上涨,JVM 与 native 堆剖析都查不到泄漏,最终进程被 OOM killer 击毙。换上 jemalloc 后,问题凭空消失。Percona 在 MySQL 上、OpenJDK 在 JDK-8193521 号缺陷里记录了几乎同款的剧本——这不是内存泄漏,是 glibc 分配器的碎片化。要理解这类事故,得先看清内存分配的完整链路:内核怎么发内存、内核自己怎么用内存、用户态分配器怎么二次批发。本篇沿这条链走一遍,最后讲清 OOM killer 的判决书是怎么写的。
一、内核怎么发内存:伙伴系统
内核管理物理页的算法叫伙伴系统(buddy system):所有空闲内存按 2 的幂组织成块,从最小 4KB(order-0)到经典的单次最大 4MB(order-10,1024 页;以 x86-64 经典内核为例,MAX_ORDER=11)。申请 64KB 就去找 order-4 的块;没有就到 order-5 拆一块,一半给你、另一半挂回 order-4 的链表——拆出来的两块互为「伙伴」,将来归还时若伙伴也空闲就合并回去,防止外部碎片越积越碎。这个「拆一半、还一半」的对称设计是它名字的由来。实现的骨架是十几条链表按阶挂空闲块,分配释放都是 O(1) 量级的链表操作,快,但「2 的幂」意味着申请 5KB 也得给 8KB——页粒度管理的天然内部碎片税。这笔税加上每次进内核的系统调用成本,正是用户态分配器存在的两个理由。
伙伴系统对付不了的是另一种碎片:内存够、但不连续。系统跑了很久,空闲页散落各处,想拿一块连续的 2MB 就难了——这正是 THP 最需要的形状。内核的两道补丁:一是 migratetype 分区,把可移动(页缓存、匿名页可迁移)、不可移动(内核关键结构)、可回收的页分开安置,别让不可移动的页把大块内存「钉碎」(2005 年起合入的 placement policy);二是 内存规整(compaction),由 kcompactd 后台线程把可移动页往一端搬、腾出连续大块——上一篇讲 THP 分配失败会同步触发规整导致停顿,指的就是它。
二、内核自己的对象商店:SLUB
内核里海量的小对象——task_struct、inode、各种 sock——分配释放频率极高,直接走伙伴系统按 4KB 起步太浪费。解法是 slab 分配器(思想源自 Sun 工程师 Jeff Bonwick 1994 年的论文):把伙伴系统的页切成固定尺寸的对象池,每种类型一个池,释放的对象不归还而是留在池里复用,还顺手做 CPU 缓存友好布局。Linux 长期存在三个实现:SLAB(原始版)、SLUB(简化高效版)、SLOB(嵌入式极简版),SLUB 自 2.6.23 起是默认,SLAB 与 SLOB 已先后被清理出内核(2023 年前后的移除补丁),如今 SLUB 是唯一实现。它的关键设计是 per-CPU 活跃 slab:每个 CPU 有自己的一份对象快取,分配释放几乎无锁——和用户态分配器的 thread cache 思路殊途同归,这个概念马上就会再次出现。
三、用户态分配器三国志
malloc 不直接向内核要内存(brk/mmap 的系统调用成本太高),而是批发一大块再零售。先补一点零售常识:ptmalloc 的堆从 brk 起步,小块在堆里按 chunk 切分;超过 mmap 阈值(默认 128KB 且会动态调整)的大块直接 mmap 单独映射、释放即还内核——这条隔离通道让大对象不拖累堆的碎片。三大分配器在这套常识之上的分野,集中在「谁来缓存、怎么分档、怎么还债」:
| 分配器 | 核心设计 | 强项 | 软肋与备注 |
|---|---|---|---|
| ptmalloc2(glibc 默认) | 主 arena + 多个子 arena(各 64MB 虚拟区,默认上限约 8×核数),arena 内锁竞争 | 零配置、通用 | 多线程锁争用与碎片化 RSS 膨胀,经典事故源 |
| tcmalloc(Google) | 线程本地缓存 + 中央空闲链表,小对象几乎无锁 | 高并发小对象吞吐 | 官方文档自述:线程本地缓存让小对象分配近乎零锁争用 |
| jemalloc(Jason Evans) | 多 arena + size class 分档,FreeBSD 默认 | 抗碎片、元数据开销小于总内存 2%(Facebook 2011 实测口径) | 需显式链接替换,调优项较多 |
ptmalloc 的问题机制值得单独说:多线程抢同一个 arena 时锁竞争激烈,glibc 的解法是开更多 arena——但 arena 一多,同一进程的空闲内存被摊薄在各 arena 里,谁都凑不出整块还给别人,RSS 虚高而内部明明有空闲。这就是 Venice、MySQL、JDK-8193521 三案共同的病理。缓解手段从轻到重:MALLOC_ARENA_MAX 限死 arena 数、malloc_trim() 手工催还、或者干脆换 jemalloc/tcmalloc。Cloudflare 给 RocksDB 换 TCMalloc 后 RSS 显著下降,他们的评价一针见血:ptmalloc 是为单线程设计、后来打补丁改造的,TCMalloc 从第一天就是为多线程而生的。
三家的策略差异可以再浓缩成三句话。ptmalloc 把缓存挂在 arena 上,arena 多则缓存散、谁都凑不出整块;tcmalloc 按尺寸分成上百个 size class,每线程本地缓存各档的 free list,攒多了批量上交中央链表;jemalloc 同样按 size class 分档,但缓存归属 arena,并用 decay 策略让脏页在数秒内渐次归还内核——「晚还但稳还」,这正是它抗碎片的底牌。Facebook 2011 年的迁移文还点名了两者的亲缘:jemalloc 的 arena 设计吸收了 tcmalloc 线程缓存的思路,它们是互相借鉴的近亲,选型时不必迷信「谁碾压谁」,基准要拿自己的负载跑。这套「线程本地批发」的思想还血脉相承进了语言运行时:Go 的内存分配器在官方文档里明言借鉴 TCMalloc 的分级缓存设计,Java 的 TLAB(线程本地分配缓冲)同属这一家族——理解了分配器,等于顺手理解了运行时的内存行为。
四、OOM killer 与 overcommit:判决书怎么写
为什么内核要赌?因为承诺与实际使用的差距巨大:绝大多数进程申请的内存只触碰一小部分,fork 更是最大受益者——如果 fork 要立刻复制全部物理内存,大型进程根本起不来。超售是虚拟内存红利的另一面,代价则是把局部的分配失败推迟成了全局的杀进程时刻。
Linux 默认允许内存超售:malloc 承诺的比物理内存多得多,赌的是进程不会全用。赌输的那一刻,内核启动 OOM killer 挑选牺牲者:badness 评分主体约为任务 RSS + swap + 页表,谁占得多杀谁;评分可用 /proc/<pid>/oom_score_adj 人工干预,范围 -1000 到 +1000,设为 -1000 即免死金牌(数据库主进程常见的保命配置,旧接口 oom_adj 已被取代)。
超售的松紧由 vm.overcommit_memory 控制,官方文档定义三档:0 启发式(默认,拒绝明显离谱的承诺)、1 永远答应、2 永不超售——承诺总额被限制在 swap + 物理内存 × overcommit_ratio(ratio 默认 50)以内。注意一个常见误区:mode 2 并不能让进程对 OOM killer 免疫,它只是让系统尽量别走到那一步;真正免死还是靠 oom_score_adj。
事故现场的标准证据在 dmesg 里:一行 Out of memory: Killed process … 连同当时的内存快照与各进程评分表,是唯一权威的判决记录。两个旋钮值得认识:vm.panic_on_oom=1 让内核在 OOM 时直接 panic——配合高可用重启,适合「宁可全节点重启也不能死错进程」的场合;vm.oom_kill_allocating_task=1 则让内核免于逐个算分、直接杀掉申请者,省时间但粗糙。swap 还会改变判决的节奏:有 swap 的机器先经历颠簸(thrashing),si/so 飙升、延迟先劣化再死亡,监控上有预警窗口;无 swap 的容器节点则从「健康」直接跳到「被杀」。这套机制与容器的联动(cgroup 的 memory.max 触发的 OOM 是另一套记账)留到第 8 篇展开。
排查手段也是配套的:ptmalloc 有 malloc_stats() 与 mallinfo 家族,tcmalloc 支持 pprof 堆快照,jemalloc 有 mallctl 与 stats 接口——「看不见才靠猜,看得见就能修」。Cloudflare 那篇 RocksDB 文能把结论落定在 TCMalloc 上,靠的正是分配器自带的剖析数据;只盯 JVM 堆或业务指标,碎片这个嫌疑人永远不会自首。
五、工程落地清单
- 排查「内存泄漏」先看分配器。RSS 涨、堆剖析干净、释放一直发生——八成是碎片。
MALLOC_ARENA_MAX=4起步尝试。 - 高并发服务显式选型。多线程大服务(RocksDB、代理网关、JVM native 侧)换 jemalloc 或 tcmalloc,常有两位数 RSS 收益。
- 给关键进程发免死金牌。
oom_score_adj=-1000保护数据库主进程,同时确保它真不会失控——免死金牌是双刃剑。 - 内存大页与碎片是连体问题。禁 THP 之外,长期运行的节点安排 compaction 压测,确认 THP 关闭后高阶分配不再抖动。
- 关注内核升级的分配器变化。SLUB 与用户态分配器都在持续演进,升级内核或 glibc 后重跑内存基线。
- 换分配器是低风险高收益的运维动作。
LD_PRELOAD链接 jemalloc/tcmalloc 不用改一行代码,灰度观测 RSS 与 P99 两个指标即可验证。 - 无 swap 的容器节点要更灵敏的内存告警。那里没有 thrashing 的预警窗口,内存曲线到位就是突然的 OOM。
结语
内存分配是一条批发零售链:伙伴系统批发页、SLUB 批发内核对象、用户态分配器批发堆,每一层都在跟碎片作斗争。事故的规律也贯穿始终:碎片不会让 malloc 失败,它只会让 RSS 悄悄膨胀,直到 OOM killer 替你做决定。看清这条链,那些「查不到泄漏的内存上涨」就有了标准排查路径。下一篇转向持久侧:文件系统、Page Cache,以及那句著名的「fsync 到底向你承诺了什么」。
参考资料
- PageAllocation — linux-mm.org Wiki——伙伴系统 order 与 MAX_ORDER 的经典口径。
- Avoiding external fragmentation with a placement policy — LWN——migratetype 分区策略的原始论述。
- Memory Concepts — kernel.org 官方文档——compaction 与 kcompactd 的官方描述。
- Scalable memory allocation using jemalloc — Facebook Engineering——jemalloc 元数据开销与设计动机的一手分析。
- The effect of switching to TCMalloc on RocksDB memory use — Cloudflare——ptmalloc 碎片换 TCMalloc 的实测收益。
- Taming memory fragmentation in Venice with jemalloc — LinkedIn Engineering——开篇悬案的完整复盘。
- MySQL Memory Management, Memory Allocators and OS — Percona——MySQL RSS 虚高与 MALLOC_ARENA_MAX。
- proc(5) man page — man7.org——oom_score_adj 与 overcommit 参数的一手定义。
读者留言
COMMENTS 暂无还没有留言,来说第一句?