一次 Erlang 线上内存优化分析:ETS 大对象、compressed 表与 eheap allocator 碎片
背景
近期对一批 Erlang 线上节点做内存采样时,发现整体内存水位偏高。采样内容主要包含两部分:
- ETS 表维度的内存占用;
ets_alloc与eheap_alloc分配器的block、carrier、usage情况。
从采样结果看,这批节点的内存问题并不是单一来源,而是由两类问题叠加造成:
- 部分核心业务 ETS 表中存在大对象,尤其是超长 list、复杂 record、大 map 或嵌套结构,导致单条记录非常重;
eheap_alloc的mbcscarrier 利用率偏低,部分高负载节点在进程堆分配高峰后留下较大的 carrier 空洞。
这两类问题性质不同:
- ETS 数据结构优化,解决的是业务数据本体过大的问题;
- allocator 参数调整,解决的是进程堆分配碎片和 carrier 利用率偏低的问题。
两者可以叠加优化,但需要分别评估收益、性能成本和适用场景。
一、线上内存现状
1. 节点整体情况
本次采样覆盖节点数:
节点数:444从 ETS 表占用看,内存主要集中在少数几个大表上。尤其是某个核心业务表,单表全服可见占用已经达到:
核心业务表 X 当前可见 ETS 占用:约 227.70 GiB从 allocator 看,eheap_alloc 的 mbcs 当前全服情况如下:
eheap_alloc mbcs:
block = 74.87 GiB
carrier = 157.78 GiB
waste = 82.91 GiB也就是说,eheap_alloc 的 mbcs 中有相当一部分 carrier 已经被申请出来,但当前并没有被有效使用。
2. ETS 表占用特征
ETS 侧的问题比较明确:内存主要集中在少数大表中,其中最突出的表是核心业务表 X。
这类表有几个典型特征:
- 单条记录很大;
- 记录数量不一定特别夸张,但 value 结构非常重;
- value 中包含超长 list、复杂 record、大 map、嵌套结构或冗余缓存字段;
- 数据长期常驻 ETS/DETS,生命周期较长。
在抽样分析中,某个核心记录的大致结构占用如下:
单条核心记录:约 52,576 bytes
其中超长列表字段:约 51,792 bytes也就是说,单条记录的大部分内存都来自某个超长 list 字段,而不是 record 的其他字段。
这个现象说明,ETS 表本身不是简单的“记录数太多”问题,而是存在明显的单条对象过胖问题。
3. allocator 占用特征
allocator 侧主要观察了 ets_alloc 与 eheap_alloc。
ets_alloc 的整体使用率相对较高,说明 ETS 这部分主要是业务数据真实占用,并不是 allocator 碎片导致的假性膨胀。
相比之下,eheap_alloc 的 mbcs 问题更明显:
eheap_alloc mbcs block = 74.87 GiB
eheap_alloc mbcs carrier = 157.78 GiB
eheap_alloc mbcs waste = 82.91 GiB这说明进程堆分配曾经被拉高,carrier 扩张后没有充分回收,导致保留了大量低利用率 carrier。
部分同类负载节点的 eheap_alloc mbcs usage 只有:
10% - 30%单节点碎片空间可能达到:
500 MiB - 1 GiB因此,这批节点的内存问题可以拆成两层:
- ETS 大表是真实业务数据占用,是主因;
eheap_alloc存在明显 carrier 浪费,是额外放大的因素。
二、核心问题定位
1. 核心业务表 X 中存在超长 list 字段
抽样结果显示,某个核心业务表 X 中有一个超长列表字段,占用了单条记录的大部分空间。
抽样数据如下:
单条记录:约 52,576 bytes
超长列表字段本身:约 51,792 bytes将该列表字段压缩后:
term_to_binary(List, [compressed]) 后正文:约 1,612 bytes
压缩后整条 record:约 2.4 KB
单条缩小比例:约 95%这说明该字段非常适合压缩存储。
对于这类“写入后长期存放、读取频率相对可控、字段内容重复度较高”的列表数据,直接以 Erlang list 常驻 ETS 的成本非常高。
2. 大 term 表适合 ETS compressed
除了针对某个字段做 term_to_binary(..., [compressed]) 之外,还测试了 ETS 表自身的 compressed 参数。
例如某类大对象业务表中存放的是完整的大 record。开启 ETS compressed 后,内存下降非常明显。
测试结果中,某张大对象业务表的内存从:
25167931 words下降到:
6677897 words压缩后约为原来的:
26.5%也就是:
内存减少约 73.5%
内存约减少 3.77 倍这说明,对于 value 是大 record、大 tuple、大 map 或嵌套 term 的 ETS 表,compressed 是一个非常有效的内存优化手段。
3. 小对象高密度表不一定适合 compressed
并不是所有 ETS 表都适合开启 compressed。
在测试中,有一张高密度业务表开启 compressed 后反而变大:
优化前:约 499.6 MiB
优化后:约 518.1 MiB
增加:约 18.5 MiB这类表的特点是:
- 记录数量非常多;
- 单条 value 很小;
- 平均单条只有几百字节;
- 压缩元数据、对象布局和访问结构开销可能抵消压缩收益。
因此,ETS compressed 更适合“大 term 表”,不适合盲目推广到所有 ETS 表。
4. eheap_alloc 的 mbcs carrier 利用率偏低
另一个问题来自 eheap_alloc。
在未调整 allocator 参数的节点上,很多同类负载节点存在较大的 mbcs carrier 浪费:
usage = 10% - 30%
单节点 waste = 500 MiB - 1 GiB而某个已调整 allocator 参数的样本节点表现如下:
mbcs block = 329.8 MiB
mbcs carrier = 458.6 MiB
waste = 128.8 MiB
usage = 71.9%对比来看,调整后 eheap_alloc mbcs 的 carrier 利用率明显更好。
这说明对于该类负载,适当调整 eheap_alloc 的 mbcs 策略,有机会减少进程堆 allocator 的碎片和 carrier 保留。
三、局部压缩实测
为了验证 ETS 压缩收益,选取了一个测试节点,对部分大表进行了压缩对比。
1. 优化前 Top ETS 表
优化前,Top ETS 表大致如下:
业务表 X 1,024,774,056 bytes 19,626 rows
业务表 A 523,826,456 bytes 2,422,681 rows
业务表 B 296,529,864 bytes 974,885 rows
业务表 H 217,739,000 bytes 6,876 rows
业务表 J 201,343,448 bytes 635 rows
业务表 C 197,554,088 bytes 31,176 rows
业务表 D 172,530,048 bytes 31,918 rows
业务表 E 171,194,880 bytes 750,870 rows
业务表 F 98,440,520 bytes 33,684 rows
业务表 G 90,481,488 bytes 24,000 rows可以看到,业务表 X 单表接近 1 GiB,是该节点上最突出的 ETS 内存来源。
2. 优化后 Top ETS 表
优化后,Top ETS 表发生了明显变化:
业务表 A 543,211,160 bytes 2,422,696 rows
业务表 B 287,866,928 bytes 974,885 rows
业务表 C 197,554,072 bytes 31,176 rows
业务表 D 172,530,624 bytes 31,918 rows
业务表 E 171,194,880 bytes 750,870 rows
业务表 F 98,450,456 bytes 33,684 rows
业务表 G 90,481,488 bytes 24,000 rows
业务表 H 60,932,032 bytes 6,755 rows
业务表 I 54,894,752 bytes 12,914 rows
业务表 J 53,423,176 bytes 635 rows其中,原先占用最高的业务表 X 已经不再出现在 Top 列表中,说明压缩后该表的内存占用已经从主要矛盾变成了非主要项。
进一步查看该表的 ets:info/1:
[{compressed,true},
{memory,1355986},
{size,19626},
{type,set},
{keypos,2},
{protection,public}]需要注意:ets:info(Tab, memory) 的单位是 word,不是 byte。
在 64 位 VM 下,1 word = 8 bytes,因此:
1355986 * 8 = 10847888 bytes也就是:
优化后业务表 X 约占 10.35 MiB3. 业务表 X 压缩收益
对比优化前后:
优化前:1,024,774,056 bytes,约 977.3 MiB
优化后:10,847,888 bytes,约 10.35 MiB
节省:1,013,926,168 bytes,约 967.0 MiB
降幅:约 98.9%这个结果和之前对超长 list 字段的抽样判断是一致的。
也就是说,该表的大头确实来自超长列表字段,压缩后内存占用出现数量级下降。
4. 几张典型表的压缩对比
这次测试中,几张典型表的变化如下:
| 表类型 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 业务表 X | 977.3 MiB | 10.35 MiB | 节省 967.0 MiB |
| 业务表 H | 207.7 MiB | 58.1 MiB | 节省 149.5 MiB |
| 业务表 J | 192.0 MiB | 51.0 MiB | 节省 141.1 MiB |
| 业务表 B | 282.8 MiB | 274.5 MiB | 节省 8.3 MiB |
| 业务表 A | 499.6 MiB | 518.1 MiB | 增加 18.5 MiB |
这 5 张目标表合计:
优化前合计:约 2158.4 MiB
优化后合计:约 912.0 MiB
净节省:约 1246.4 MiB
总降幅:约 57.7%从结果看:
- 业务表 X 压缩非常成功,单表节省接近 1 GiB;
- 业务表 H 和业务表 J 也非常适合 ETS
compressed; - 业务表 B 收益很小;
- 业务表 A 反而变大,不适合保留
compressed。
四、ETS compressed 的性能代价
ETS compressed 的收益并不是免费的。
在业务表 J 的读写测试中,开启 compressed 后,内存收益明显,但读写性能会下降。
以未压缩表作为基准,压缩表表现大致如下:
命中读 lookup hit:慢约 24% - 29%
随机命中读 rand lookup:慢约 22% - 26%
未命中读 lookup miss:快约 27% - 31%
更新写 insert update:慢约 75% - 85%
写入覆盖类测试:慢约 74% - 83%可以总结为:
内存节省非常明显,约 73.5%
命中读取性能下降约 20% - 30%
写入性能下降约 75% - 85%
miss 读取略快,但通常业务价值不如 hit/read 和 write 重要所以对大 record、大 tuple、大 map 这类对象,ETS compressed 的典型特征是:
- 很省内存;
- 读稍慢;
- 写明显更慢。
因此,是否开启 compressed,不能只看内存收益,还要结合表的访问模式。
更适合开启 compressed 的表:
- value 很大;
- 读多写少;
- 单条 term 重复度高;
- 不在极致低延迟路径上;
- 内存压力明显大于 CPU 压力。
不适合开启 compressed 的表:
- value 很小;
- 写入非常频繁;
- 每次请求都高频命中读取;
- 单条数据压缩收益有限;
- 开启后内存不降反升。
五、优化项与收益预估
1. 压缩核心业务表 X 中的超长列表字段
建议做法
将核心业务表 X 中的超长 list 字段从直接存储 list,改为压缩后的 binary。
示意结构:
{compressed, Bin}写入时:
Bin = term_to_binary(List, [compressed])读取时:
List = binary_to_term(Bin)也就是说,整条记录可以保持压缩形态在业务流程中传递,只有访问该字段时才解压。
这类字段级压缩比整表 compressed 更可控,适合特别明确的大字段。
收益预估
当前该核心业务表 X 全服可见占用:
227.70 GiB按保守 80% 回收估算:
227.70 GiB * 80% = 182.16 GiB如果按更谨慎的 50% 回收估算:
227.70 GiB * 50% = 113.85 GiB因此,该项优化的预估收益区间为:
约 114 GiB - 182 GiB其中,按 80% 回收估算是相对合理的保守目标;按 50% 回收估算则是非常谨慎的底线口径。
从局部实测看,业务表 X 单节点压缩后降幅达到约 98.9%。全服估算继续使用 50% - 80% 的口径,属于偏保守估算。
2. 对大 term ETS 表启用 compressed
除了字段级压缩外,还可以对部分大 term 表启用 ETS compressed。
适合的对象包括:
- 存放大 record 的业务表;
- 存放大 tuple 的业务表;
- 存放大 map 的业务表;
- value 明显大于 key 和表结构开销的 ETS 表。
局部实测中,业务表 J 内存约下降 73.5%;业务表 H 也从约 207.7 MiB 降到 58.1 MiB,节省约 149.5 MiB。
但是,表级 compressed 需要逐表验证,不应全局盲开。
尤其是小对象高密度表,可能出现不降反升:
业务表 A:
优化前约 499.6 MiB
优化后约 518.1 MiB
增加约 18.5 MiB因此,ETS compressed 的使用原则应该是:
大对象表优先测试
小对象高密度表谨慎使用
写频繁表谨慎使用
压缩后必须用真实数据回归内存和性能3. 调整 eheap_alloc 的 mbcs 策略
建议做法
在全服逐步启用:
+MHlmbcs 1024该参数用于调整 eheap_alloc 中 mbcs carrier 的行为,目标是改善进程堆 allocator 的 carrier 利用率,减少高峰后遗留的大量低利用率 carrier。
从已启用样本看,效果比较明显:
mbcs block = 329.8 MiB
mbcs carrier = 458.6 MiB
waste = 128.8 MiB
usage = 71.9%而未调整的同量级节点中,部分节点仍然存在:
usage = 10% - 30%
单节点 waste = 500 MiB - 1 GiB收益预估
基于已启用样本节点,对全服 eheap_alloc mbcs 做推断:
很保守:约回收 46.22 GiB
保守:约回收 50.51 GiB
中性偏好:约回收 55.29 GiB对外建议采用更稳妥的口径:
保守口径:46 GiB
中性口径:50 GiB这项优化不是业务数据本体压缩,而是减少 allocator carrier 浪费。因此它的收益不一定在所有节点上完全一致,但对高负载、高碎片节点更可能有效。
六、两项主优化叠加后的收益
这里先只按两项主优化估算:
- 核心业务表 X 超长字段压缩;
eheap_alloc参数调整。
按保守主口径合并:
核心业务表 X 压缩:约 182 GiB
eheap_alloc 参数调整:约 46 GiB
合计:约 228 GiB如果对核心业务表 X 压缩采用更谨慎的 50% 回收口径:
核心业务表 X 压缩:约 114 GiB
eheap_alloc 参数调整:约 46 GiB
合计:约 160 GiB因此,本轮两项主优化合计预计可回收:
约 160 GiB - 228 GiB比较稳妥的容量收益目标,可以先按:
约 200 GiB进行评估。
需要注意,其他大 term 表开启 ETS compressed 的收益没有纳入这个主口径。原因是表级 compressed 需要逐表验证,不能直接按全服统一比例外推。
七、容量价值估算
这里计算的是“容量价值”,不是立即等同于账单下降。
只有当优化结果能够带来以下变化时,才会转化为真实成本节省:
- 降低机器规格;
- 减少扩容需求;
- 延后新增机器;
- 提高单机承载密度;
- 减少内存水位告警和 OOM 风险。
按总节省:
160 GiB - 228 GiB估算。
按 15.75 元/GiB/月计算
月度价值:约 2,520 - 3,591 元/月
年度价值:约 3.0 - 4.3 万元/年按 27.75 元/GiB/月计算
月度价值:约 4,440 - 6,327 元/月
年度价值:约 5.3 - 7.6 万元/年按 200 GiB 保守主口径计算
15.75 元/GiB/月:
约 3,150 元/月
约 3.78 万元/年
27.75 元/GiB/月:
约 5,550 元/月
约 6.66 万元/年八、结论
这批 Erlang 节点的内存问题,主因已经比较明确:
- 核心业务表 X 中存在超长 list 字段,导致单条记录过大,业务数据本体占用偏高;
- 一些存放大 record、大 tuple、大 map 的 ETS 表,也存在明显压缩空间;
eheap_alloc mbcs存在明显 carrier 浪费,部分节点在进程堆分配高峰后利用率较低。
从实测结果看:
业务表 X:
优化前约 977.3 MiB
优化后约 10.35 MiB
节省约 967.0 MiB
降幅约 98.9%业务表 J 开启 ETS compressed 后:
内存约降到原来的 26.5%
内存减少约 73.5%但 ETS compressed 并不适合所有表。对于小对象高密度表,压缩后可能不降反升。
因此,本次优化可以拆成三类:
- 对明确的大字段做字段级压缩;
- 对大 term ETS 表逐表测试并启用
compressed; - 对高碎片节点调整
eheap_alloc mbcs策略。
预估收益方面,先只按两项主优化计算:
核心业务表 X 压缩:约 114 GiB - 182 GiB
eheap_alloc 参数调整:约 46 GiB - 50 GiB
合计:约 160 GiB - 228 GiB可以先按:
约 200 GiB作为本轮优化的容量收益目标。
折算容量价值:
约 3,150 - 5,550 元/月
约 3.8 - 6.7 万元/年其中,核心业务表 X 字段压缩是主要收益项;ETS compressed 适合作为大 term 表的补充优化;eheap_alloc 参数调整则是低侵入、低风险的 allocator 层补充收益项。
整体来看,这次优化值得做,且优先级较高。但具体落地时,不能简单地“所有 ETS 表都开启 compressed”,而应该按表的 value 大小、读写比例、压缩收益和性能代价逐表判断。