在介绍 Compact 之前, 我们先来了解 3 个重要的概念:
LSM-TREE 通过顺序写入和后台合并来获得极高的写入吞吐能力,而 Compaction 策略决定了系统在写性能、读性能与资源消耗之间的核心权衡。 对于传统的 LSM-TREE,compaction 的策略主要有两种:Size-Tiered Compaction 和 Level compaction。

同一层允许 SST 互相重叠,更多是把大小相近的 SST 批量合并成更大的 SST,不强求每层严格不重叠。

Size-Tiered Compaction Strategy (STCS) 的思路就是将大小相近的 sst merge 成一个新文件 memtable 逐步刷入到磁盘 sst,刚开始 sst 都是小文件,随着小文件越来越多,当数据量达到一定阈值时,STCS 策略会将这些小文件 compaction 成一个中等大小的新文件。同样的道理,当中等文件数量达到一定阈值,这些文件将被 compaction 成大文件,这种方式不断递归,会持续生成越来越大的文件。
核心原则:
假设每个 SST 能存 4 个 key
此时 Level 0 结构:[1–4] [5–8] [3–10]
查询 key=6 时必须同时查多个文件,这就是读放大。Tiered 的合并方式是当某一层文件数量达到阈值就触发合并,输出一个大文件,最终变成 [1 – 10]。 因此其优点是写入快、写放大小,但是缺点是查询要扫描多个文件,读放大。
每一层的 SST 尽量互不重叠 (key range disjoint),一旦有重叠就要把上层 SST + 下层所有重叠 SST合并重写。

leveled 每层由多个 sstable 组成一个有序的的 run,sstables 之间互相也保持有序的关系,每层的数据 size 到达上限后与下一层的 run merge。这种方式将 level 的多个 run 降为一个,减小了读放大和空间放大,小 sstable 的方式提供了精细化任务拆分和控制的条件,控制任务大小也就是控制临时空间的大小。
核心原则:
同样的场景,发现重叠后立刻进行合并 [1–4] + [5–8] + [3–10] → [1–10],然后再次切分为 [1–5] [6–10],以此保证同层区间不重叠。 因此其优点是查询快,查询最多查一个文件,但是写放大大,后台 compaction 持续发生,IO 压力大。
| 维度 | Tiered | Leveling |
|---|---|---|
| 同层是否重叠 | 允许 | 不允许 |
| 写入成本 | 极低 | 中高 |
| 查询成本 | 高 | 极低 |
| 写放大 | 小 | 大 |
| IO 压力 | 轻 | 重 |
| 延迟稳定性 | 差 | 好 |
| 优先目标 | 吞吐 | 响应 |
MARS3 采用 Tiered compaction 的方式,简而言之,在 VIN+TS 的典型时序负载下,数据持续写入会导致新 run 与下层多个 run 的 key space 广泛重叠,使 Leveled Compaction 很难维持层内不重叠,进而频繁触发大范围重写,写放大显著且在 MPP 多实例场景被进一步放大。
假设现在有 3 个设备 (VIN=A,B,C),每个设备都在持续上报最新值。
现在系统里已经有一层 L1 文件,每个文件覆盖一个 VIN 的一个时间段:
这时新数据来了:
这些新写入通常会先形成一个新的 run (比如在 L0 或上层),我们叫它 NewRun。NewRun 里面同时包含 A/B/C 的最新时间片,它覆盖的键范围大概是:
现在它会和 L1 哪些文件重叠?
也就是说一个 NewRun 会同时重叠 L1 的多个文件(每个 VIN 都重叠一个)。
如果设备更多,比如 10,000 个 VIN 同时写,那么一个 NewRun 可能会重叠 成百上千个 L1 文件(取决于 L1 的切分方式)。而 Leveled 的要求是:L1 里文件要尽量不重叠。所以当 NewRun 要合并进 L1 时,Leveled 必须做这件事:把 NewRun + 所有与它重叠的 L1 文件读出来,重新合并排序,再写回成新的 L1 文件,保证写回后依然不重叠。这一步的代价是即便这次新写入只有很少数据 (比如 1GB),只要它碰到了很多 VIN 的历史段,它就会拖进来很多历史文件一起重写 (比如 10GB、50GB、100GB),造成验证的写放大。而 Size-Tiered 不强求下层不重叠,它更多是把同尺寸的 run 合并变大,不会为了消除重叠而每次拖进来一堆下层文件重写,所以在这个场景下,相对来说写放大的影响会更小一些。
与此同时,MARS3 的 RUN 为列存结构,需要足够大的物理连续性以保障扫描吞吐与压缩效率,这与 Leveled 常用的小 SST 粒度相冲突。Size-Tiered Compaction 则更契合该负载:它以合并同尺寸 run 为主,显著降低写放大,同时在时间推进的数据流下自然形成按时间聚集的 run 组织,使基于时间条件的过滤与跳读更有效。 为了控制读放大,我们为每一层限制了读放大系数,每一层的读放大超过这个系数以后就会触发向下层的 compaction:

level_size_amplifier 用于指定 Level 尺寸的放大系数:
Level 尺寸的放大系数。Level 触发合并操作的阈值,计算方式为:rowstore_size * (level_size_amplifier ^ (level -1))。其值越大,读速越慢,写速越快。可以根据具体场景信息 (写多读少/读多写少、压缩率等) 来决定具体值。注意:确保每个 Level 的 run 数量不要过多,否则会影响查询性能,甚至阻止新数据插入
level_size_amplifier 的本质是控制每一层比上一层能大多少倍,就好比越往上是缓冲区,越往下是长期存储区
因此不难理解,amplifier 更大,每层能堆更久,run 更可能变多,导致查询检查更多对象 (读放大),但 compaction 触发少 (写更快),amplifier 更小,更早触发下沉整理,run 更少更整洁,读更快,但是 compaction 更频繁。rowstore_size 和 level_size_amplifier 两者合在一起,形成一个可控的下沉节奏,用来在 Tiered 模型下约束 run 堆积,从而限制读放大,同时尽量保留写吞吐优势。更多细节参照可观测性

Compaction worker:负责具体的 compaction 请求,同时检测到 level 的读放大超过限制以后,通知 scheduler 进程新的 compaction 请求,目前掣肘于进程模型,Compaction worker 的数量上限在代码中写死了 16 个。
Compaction prober:负责主动探测 compaction 任务,比如:
对于 compaction,如果有多个表都需要做 compaction,但是总共就 16 个 worker,因此需要有一定的优先级,保证“最需要”的表能够及时进行 compact。 简而言之,compaction 会考虑比如 Level 的层次,越低级别越高;考虑是否是 Eager 还是 Lazy (手动和自动);compaction 的类型等等。
Autoprobe 的原理是,定时探测所有 Mars3 表的事务年龄和大小,以年龄除以大小为 score,autoprobe 可以对历史 Level 进行压缩。选出其中 score 最高的 N 个level触发 flush/compaction 任务。通过函数 matrixts_internal.mars3_autoprobe_candidates() 可以查看 autoprobe 的候选 level 及其 score。
adw=# select * from matrixts_internal.mars3_autoprobe_candidates();
segid | datname | relname | level | nruns | age | bytes | score
-------+---------+-------------+-------+-------+------------+-----------+-----------------------
0 | adw | t_w0_nosort | 1 | 13 | 616005 | 49888440 | 0.012347650076851471
0 | adw | t_w0_nosort | 0 | 1 | 2147483647 | 25034752 | 85.78010467209741
0 | adw | t_w3b_3key | 0 | 1 | 2147483647 | 25034752 | 85.78010467209741
0 | adw | t_w1_1key | 1 | 7 | 329693 | 23821378 | 0.013840215288972788
...
因为 autoprobe 总是选择 score 高的 level,为了防止由于 compaction 失败导致 autoprobe 不能处理其他 score 更低 level。增加了 autoprobe 的黑名单机制,同一个 compaction 失败 3 次后进入黑名单,不再继续重试:
mars3_autoprobe_blacklist() 可以查看黑名单;mars3_autoprobe_blacklist_remove(regclass, level) 可以删除当前数据库指定的level的黑名单;mars3_autoprobe_blacklist_clear() 可以删除所有黑名单;GUC 说明:
mars3.autoprobe_period 控制多长时间探测一次,单位是秒,大于 0 为打开,0 为关闭,默认为关闭,可以先设置 10 分钟探测一次,再根据工作负载调整mars3.autoprobe_workers 这个控制一次探测选出几个 level,默认为 2,注意这个占用的是普通 compaction 的 worker 数,并不是启动新的 workermars3.autoprobe_retry 控制重试次数,值为 2 则一共尝试 3 次;mars3.autoprobe_blacklist_size 控制黑名单大小;autoprobe 关了的影响就是没有写入的表,就不会进行 compact 了;有写入的表还是会不断 merge 的
ps aux | grep postgres | grep 'compact worker' :此命令用于验证所有工作进程是否处于运行状态。检查是否存在未处于运行状态但已启动较长时间且未退出的工作进程,这些系统运行异常的迹象。
其次,在数据库日志中也会有 compact 相关活动信息,创建一张 t1 表并不断插入数据:
postgres=# create table t1(id int,info text)
using mars3 with(mars3options='prefer_load_mode=single,rowstore_size=64');
NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'id' as the Greenplum Database data distribution key for this table.
HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew.
CREATE TABLE
新开窗口使用 matrixts_internal.mars3_level_stats 不断观察各个 Level 的状态

可以看到,当 L0 写满 64MB (rowstore_size) 之后,L1 短暂出现过 32 KB,然后直接往 L2 进行写。后续也是直接写入 L2,那么为什么会这样,为什么不是先直接写入 L1?

在 MARS3 中,有 adjust level 的逻辑 —— GetDesiredLevel
根据上面代码流程
因此,这个 run 的理论相对层号是 0,其大小正好等于 rowstore_size 这一档
if (desired_level < 0)
{
ret = desired_level < -0.5 ? 1 : 2;
}
else
{
ret = Min(2 + round(desired_level), MAXLEVELS - 1);
}
因为我们算出来 desired_level = 0,走 else:
所以最终结果:GetDesiredLevel() 返回 2,也就是说:这个 64MB 的 run,按新版本规则应放到 L2。
MARS3 的 tiered 模型里,稳定的几何规律不是靠每层总量瞬间严格满足,而是靠 compaction 让 run 的目标大小档位逐步趋近 amp 倍增长。触发 compaction 的核心阈值是:
threshold(level)=rowstoresize × amp^levelthreshold(level)=rowstoresize × amp^(level - 1)这里 bytes_threshold 用于触发一次 compaction / pick 输入集合规模预算,Compact 会 pick N 个 run;这 N 个 run 的 total_size 大概是 bytes_threshold。 所以按照这个计算方式:
同时加上 Min(MAX_RUN_SIZE, …) 的限制,如果算出来超过了 MAX_RUN_SIZE,就按 MAX_RUN_SIZE 来 (防止 job 或输出 run 过大)。
rowstore_size 是行存粒度level_size_amplifier 越大:所以 amp 越大,读越慢写越快。
还是以上方的 t1 表为例,观察 L2 何时进行 Pick,按照刚刚的计算模型,L2 大约在 512 MB 的时候就会触发 compaction:
Thu 29 Jan 2026 02:42:07 PM CST (every 1s)
segid | level | total_nruns | visible_nruns | invisible_nruns | object_nruns | object_visible_nruns | level_size
-------+-------+-------------+---------------+-----------------+--------------+----------------------+------------
3 | 0 | 1 | 1 | 0 | 0 | 0 | 64 MB
3 | 1 | 0 | 0 | 0 | 0 | 0 | 0 bytes
3 | 2 | 15 | 15 | 0 | 0 | 0 | 533 MB
3 | 3 | 0 | 0 | 0 | 0 | 0 | 0 bytes
(4 rows)
Thu 29 Jan 2026 02:42:08 PM CST (every 1s)
segid | level | total_nruns | visible_nruns | invisible_nruns | object_nruns | object_visible_nruns | level_size
-------+-------+-------------+---------------+-----------------+--------------+----------------------+------------
3 | 0 | 2 | 2 | 0 | 0 | 0 | 69 MB
3 | 1 | 0 | 0 | 0 | 0 | 0 | 0 bytes
3 | 2 | 15 | 15 | 0 | 0 | 0 | 533 MB
3 | 3 | 0 | 0 | 0 | 0 | 0 | 0 bytes
(4 rows)
Thu 29 Jan 2026 02:42:09 PM CST (every 1s)
segid | level | total_nruns | visible_nruns | invisible_nruns | object_nruns | object_visible_nruns | level_size
-------+-------+-------------+---------------+-----------------+--------------+----------------------+------------
3 | 0 | 1 | 1 | 0 | 0 | 0 | 16 MB
3 | 1 | 0 | 0 | 0 | 0 | 0 | 0 bytes
3 | 2 | 16 | 16 | 0 | 0 | 0 | 577 MB
3 | 3 | 1 | 0 | 1 | 0 | 0 | 32 kB
(4 rows)
Thu 29 Jan 2026 02:42:10 PM CST (every 1s)
segid | level | total_nruns | visible_nruns | invisible_nruns | object_nruns | object_visible_nruns | level_size
-------+-------+-------------+---------------+-----------------+--------------+----------------------+------------
3 | 0 | 1 | 1 | 0 | 0 | 0 | 16 MB
3 | 1 | 0 | 0 | 0 | 0 | 0 | 0 bytes
3 | 2 | 16 | 16 | 0 | 0 | 0 | 626 MB
3 | 3 | 1 | 0 | 1 | 0 | 0 | 32 kB
(4 rows)
...
...
Thu 29 Jan 2026 02:42:26 PM CST (every 1s)
segid | level | total_nruns | visible_nruns | invisible_nruns | object_nruns | object_visible_nruns | level_size
-------+-------+-------------+---------------+-----------------+--------------+----------------------+------------
3 | 0 | 1 | 1 | 0 | 0 | 0 | 32 MB
3 | 1 | 0 | 0 | 0 | 0 | 0 | 0 bytes
3 | 2 | 16 | 16 | 0 | 0 | 0 | 626 MB
3 | 3 | 1 | 0 | 1 | 0 | 0 | 467 MB
(4 rows)
...
...
Thu 29 Jan 2026 02:43:58 PM CST (every 1s)
segid | level | total_nruns | visible_nruns | invisible_nruns | object_nruns | object_visible_nruns | level_size
-------+-------+-------------+---------------+-----------------+--------------+----------------------+------------
3 | 0 | 1 | 1 | 0 | 0 | 0 | 32 MB
3 | 1 | 0 | 0 | 0 | 0 | 0 | 0 bytes
3 | 2 | 1 | 1 | 0 | 0 | 0 | 36 MB
3 | 3 | 1 | 1 | 0 | 0 | 0 | 531 MB
(4 rows)
Thu 29 Jan 2026 02:43:59 PM CST (every 1s)
segid | level | total_nruns | visible_nruns | invisible_nruns | object_nruns | object_visible_nruns | level_size
-------+-------+-------------+---------------+-----------------+--------------+----------------------+------------
3 | 0 | 1 | 1 | 0 | 0 | 0 | 32 MB
3 | 1 | 0 | 0 | 0 | 0 | 0 | 0 bytes
3 | 2 | 1 | 1 | 0 | 0 | 0 | 36 MB
3 | 3 | 1 | 1 | 0 | 0 | 0 | 531 MB
(4 rows)
结合日志和现象,我们可以观察到: 14:42:07 ~ 14:42:08
L0: 1 run, 64MB
L2: 15 runs, 533MB
L1: 0
L3: 0
14:42:09 ~ 14:42:10
L0: 回到 16MB (新 run 开始累计)
L2: 16 runs, 577 → 626MB (又新增了一个 run)
L3: 1 run, invisible=1, 32KB
14:42:26
L2: 16 runs, 626MB
L3: invisible 32KB → 467MB(仍不可见)
14:43:58 ~ 14:43:59 (收尾)
L2: 1 run, 36MB
L3: 1 run, 531MB (可见)
L0: 32MB (新的 RowStore run 正在累计)
这说明 compaction 完成并提交:

值得注意的是,L0 的 bytes_threshold 为 0,是 flush 下去的,没啥意义 RunLife-Flushed,用于代码调试,追踪一个 Run 的生命周期,包括:

返回上一章节:存储引擎原理