一次 Erlang 线上内存优化分析:ETS 大对象、compressed 表与 eheap allocator 碎片

背景

近期对一批 Erlang 线上节点做内存采样时,发现整体内存水位偏高。采样内容主要包含两部分:

  1. ETS 表维度的内存占用;
  2. ets_alloceheap_alloc 分配器的 blockcarrierusage 情况。

从采样结果看,这批节点的内存问题并不是单一来源,而是由两类问题叠加造成:

  1. 部分核心业务 ETS 表中存在大对象,尤其是超长 list、复杂 record、大 map 或嵌套结构,导致单条记录非常重;
  2. eheap_allocmbcs carrier 利用率偏低,部分高负载节点在进程堆分配高峰后留下较大的 carrier 空洞。

这两类问题性质不同:

  • ETS 数据结构优化,解决的是业务数据本体过大的问题;
  • allocator 参数调整,解决的是进程堆分配碎片和 carrier 利用率偏低的问题。

两者可以叠加优化,但需要分别评估收益、性能成本和适用场景。


一、线上内存现状

1. 节点整体情况

本次采样覆盖节点数:

节点数:444

从 ETS 表占用看,内存主要集中在少数几个大表上。尤其是某个核心业务表,单表全服可见占用已经达到:

核心业务表 X 当前可见 ETS 占用:约 227.70 GiB

从 allocator 看,eheap_allocmbcs 当前全服情况如下:

eheap_alloc mbcs:
block   = 74.87 GiB
carrier = 157.78 GiB
waste   = 82.91 GiB

也就是说,eheap_allocmbcs 中有相当一部分 carrier 已经被申请出来,但当前并没有被有效使用。


2. ETS 表占用特征

ETS 侧的问题比较明确:内存主要集中在少数大表中,其中最突出的表是核心业务表 X。

这类表有几个典型特征:

  1. 单条记录很大;
  2. 记录数量不一定特别夸张,但 value 结构非常重;
  3. value 中包含超长 list、复杂 record、大 map、嵌套结构或冗余缓存字段;
  4. 数据长期常驻 ETS/DETS,生命周期较长。

在抽样分析中,某个核心记录的大致结构占用如下:

单条核心记录:约 52,576 bytes
其中超长列表字段:约 51,792 bytes

也就是说,单条记录的大部分内存都来自某个超长 list 字段,而不是 record 的其他字段。

这个现象说明,ETS 表本身不是简单的“记录数太多”问题,而是存在明显的单条对象过胖问题。


3. allocator 占用特征

allocator 侧主要观察了 ets_alloceheap_alloc

ets_alloc 的整体使用率相对较高,说明 ETS 这部分主要是业务数据真实占用,并不是 allocator 碎片导致的假性膨胀。

相比之下,eheap_allocmbcs 问题更明显:

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

因此,这批节点的内存问题可以拆成两层:

  1. ETS 大表是真实业务数据占用,是主因;
  2. 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

这类表的特点是:

  1. 记录数量非常多;
  2. 单条 value 很小;
  3. 平均单条只有几百字节;
  4. 压缩元数据、对象布局和访问结构开销可能抵消压缩收益。

因此,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_allocmbcs 策略,有机会减少进程堆 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 MiB

3. 业务表 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. 几张典型表的压缩对比

这次测试中,几张典型表的变化如下:

表类型优化前优化后变化
业务表 X977.3 MiB10.35 MiB节省 967.0 MiB
业务表 H207.7 MiB58.1 MiB节省 149.5 MiB
业务表 J192.0 MiB51.0 MiB节省 141.1 MiB
业务表 B282.8 MiB274.5 MiB节省 8.3 MiB
业务表 A499.6 MiB518.1 MiB增加 18.5 MiB

这 5 张目标表合计:

优化前合计:约 2158.4 MiB
优化后合计:约 912.0 MiB
净节省:约 1246.4 MiB
总降幅:约 57.7%

从结果看:

  1. 业务表 X 压缩非常成功,单表节省接近 1 GiB;
  2. 业务表 H 和业务表 J 也非常适合 ETS compressed
  3. 业务表 B 收益很小;
  4. 业务表 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 的典型特征是:

  1. 很省内存;
  2. 读稍慢;
  3. 写明显更慢。

因此,是否开启 compressed,不能只看内存收益,还要结合表的访问模式。

更适合开启 compressed 的表:

  1. value 很大;
  2. 读多写少;
  3. 单条 term 重复度高;
  4. 不在极致低延迟路径上;
  5. 内存压力明显大于 CPU 压力。

不适合开启 compressed 的表:

  1. value 很小;
  2. 写入非常频繁;
  3. 每次请求都高频命中读取;
  4. 单条数据压缩收益有限;
  5. 开启后内存不降反升。

五、优化项与收益预估

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

适合的对象包括:

  1. 存放大 record 的业务表;
  2. 存放大 tuple 的业务表;
  3. 存放大 map 的业务表;
  4. 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_allocmbcs 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 浪费。因此它的收益不一定在所有节点上完全一致,但对高负载、高碎片节点更可能有效。


六、两项主优化叠加后的收益

这里先只按两项主优化估算:

  1. 核心业务表 X 超长字段压缩;
  2. 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 需要逐表验证,不能直接按全服统一比例外推。


七、容量价值估算

这里计算的是“容量价值”,不是立即等同于账单下降。

只有当优化结果能够带来以下变化时,才会转化为真实成本节省:

  1. 降低机器规格;
  2. 减少扩容需求;
  3. 延后新增机器;
  4. 提高单机承载密度;
  5. 减少内存水位告警和 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 节点的内存问题,主因已经比较明确:

  1. 核心业务表 X 中存在超长 list 字段,导致单条记录过大,业务数据本体占用偏高;
  2. 一些存放大 record、大 tuple、大 map 的 ETS 表,也存在明显压缩空间;
  3. 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 并不适合所有表。对于小对象高密度表,压缩后可能不降反升。

因此,本次优化可以拆成三类:

  1. 对明确的大字段做字段级压缩;
  2. 对大 term ETS 表逐表测试并启用 compressed
  3. 对高碎片节点调整 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 大小、读写比例、压缩收益和性能代价逐表判断。