真实世界里,客户的数据系统很少是纯 OLTP 或纯 OLAP。在混合负载下,传统存储体系往往会自然分裂:有的引擎擅长扫描与压缩,却难以长期承受频繁的小写与更新;有的引擎写得快、点查稳,却在全表扫描与聚合上成本高、效率低;而当我们试图把多套系统拼装成所谓的 HTAP,又会引入数据新鲜度、资源隔离、运维复杂度与总体成本的问题。因此我们研发了 MARS3:一款面向 AP 核心混合负载的存储引擎,其目标不是万能替代一切,而是把客户最常见、最关键的混合工作负载放在同一套可治理的存储体系里,通过可解释的机制让性能与稳定性在高压下稳固可控。
如果你是做选型与方案的同学,建议优先阅读第 1–2 章把握适配边界;如果你是架构师或 DBA,建议重点关注第 3–6 章理解机制与代价;如果你负责交付与运维,第 7 章会提供从症状到处置的快速路径与模板。 MARS3 面向的是以 AP 为核心的混合负载 —— 优先保证扫描与聚合效率、持续写入下的稳定性,以及常见场景下的明细回查体验;但 MARS3 并不追求在所有维度都达到极致,例如对极端纯 TP、超高频大范围更新等负载。
在大多数客户系统里,“数据”不是先写完再分析,而是一边写、一边用,业务对于实时性的要求愈发严苛。业务侧希望数据尽可能新:生产线的设备数据写入后要立刻形成产线看板,车联网的车辆状态更新后要马上触发告警与分析,IoT 平台的传感器数据进入系统后要实时支撑趋势分析与异常检测;与此同时,运营与研发同学还需要随时做明细回查,比如定位某台设备、某辆车、某个工位在某个时间段的原始记录等,并在此基础上做聚合统计、相关性分析或多表关联。 这类场景的共同点是:负载是混合的,而且是分析为主、写入不断的混合场景。写入侧既可能是批量导入 (历史回溯、日批结算),也可能是持续微批 (T+0、分钟级聚合),甚至包含零散点写与修正;查询侧既有全局汇总报表,也有带条件过滤的扫描,还有对单实体的高频回查。 因此,这个矛盾的本质不是写得快或查得快二选一,而是在持续写入与持续分析并存时,系统能否既保持分析效率,又保持高效稳定的写入效率。
在混合负载里,问题往往不是某个系统好或坏,而是不同存储形态天生擅长的方向不同。
行存是传统数据库最经典的存储方式,比如 PostgreSQL、MySQL 等,它将每一行完整的数据打包放在一起,像一个个“整箱快递”。特点是一行数据,整体存放。以行存/事务路径为优势的系统对明细回查、按主键/条件取少量记录来说很合适;但一旦进入分析型查询 (只用到少数几列、需要扫大量数据做过滤/聚合),系统往往不得不把整行数据读出来,哪怕其中大部分列根本用不上。结果就是:
· 读了很多不需要的列,磁盘与缓存带宽被占用;
· 扫描与聚合变慢,同样的分析要耗更多资源;
· 数据量越大,这种无效 I/O 越明显,成本也越高。
列存为了获得高效扫描与高压缩比,通常需要更强的数据组织与元数据维护:数据被按列分块存放,并依赖编码、字典、统计信息等来提升处理效率,比如 Clickhouse、Hbase 等。在这种架构下,写入往往不仅是追加记录这么简单,还涉及更多结构化维护:
· 写放大:一次逻辑写入可能引入多处物理写 (多列块、元数据等),并在后台通过合并/重写来维持数据组织质量;
· 小批次写入的放大效应:当写入变成高频微批或持续小批量时,单位数据量分摊到每次写入的固定开销更高,合并/重写更频繁,容易在长期运行中形成更高的维护压力;
· 与更新删除的耦合:列存对 UPDATE/DELETE 往往通过版本/标记/重写来收敛碎片,若更新删除比例上升,后台治理不足时会出现空间放大与读路径变长 (需要处理更多无效版本/碎片块)。
因此,列存非常适合以读为主的大扫描分析,但在混合负载下,对写路径与后台治理的要求显著更高。
把写入放一套系统、分析放另一套系统,再通过同步/ETL 串起来,理论上能同时获得两边的优点;但现实里常见的代价是:数据要“搬家”,链路越长越难做到足够新;系统越多,口径一致、补数对账、故障恢复、权限与监控就越复杂,整体成本也会更高。
从客户需求表面看,“既要写得快、又要查得快、还要成本低”似乎是一个天然矛盾。但从工程角度,混合负载并不是平均分布的:在大多数业务里,系统的核心价值仍然体现在分析结果的产出 (报表、指标、诊断、洞察),而写入与明细回查更多是为了保证数据持续进入、随时可用。因此,MARS3 的设计取舍是明确的:面向混合负载,但将 AP 设为主战场,并围绕持续写入下的分析效率与稳定性建立一套可治理的存储体系:
1.在数据规模增长与并发上升时,系统应能以更少的无效 I/O、更好的跳读效果和更高的吞吐完成分析查询;在资源使用上追求“单位资源产出最大化”,让大盘类查询和周期性统计更可控。
2.支持从批量导入到持续微批 (T+0) 的多种写入形态,重点保障“写入不断、查询不断”的常态下系统依然可预测;后台整理 (如合并/回收等) 必须是可治理的,而不是把压力随机抛给线上查询。
3.面向真实业务里常见的单实体回查、时间范围查询、按条件过滤取数等需求,提供足够好的访问路径与体验,避免出现“分析很强但回查体验割裂”的系统落差。
MARS3 采用 LSM-Tree 风格的数据组织:写入侧快速吸收与顺序落盘,读取侧强调有序数据带来的跳读与扫描效率,中间由后台治理进程将写友好逐步演进为读友好。

在这个体系里,有三个核心对象:Run 与 Level (决定数据如何分段与分层)、行存与列存 (数据的物理布局如何在写与读之间做平衡)、以及 Delta 与 MVCC (更新删除如何表达、为何会带来放大、如何被回收),接下来将依次进行介绍。
MARS3 中存储的数据是有序的,一段连续有序的数据我们称为 Run。
在 Rocksdb/ Leveldb 等产品对内部数据有序的文件称呼为 SST,在 YMatrix 中,RUN 是 SST 的另一种叫法在 Rocksdb/ Leveldb 等产品对内部数据有序的文件称呼为 SST,在 YMatrix 中,RUN 是 SST 的另一种叫法
Run 分成两种,为了能够高速写入,插入的数据会以行存 Run 的形式储存下来,之后为了方便读取和压缩,我们会把行存 Run 转换成列存 Run。单个 Run 有大小上限:
表级参数 max_runsize,在建表时用于指定单个 RUN 的最大大小,最大 16384 MB
默认为 4096 MB
我们可以使用 matrixts_internal.mars3_files 函数用来查看 MARS3 表的扩展文件和增量文件,主要包括 DATA、LINK、FSM、DELTA,如果表上有索引,还会有相应的 INDEX 文件:

DATA:主要的数据文件,用于存储用户数据
FSM:Free Space Map 的缩写,在 YMatrix 中,更新和删除操作并不是对原有的数据空间进行操作,而是通过对元组的多版本形式来实现的,因此会导致“过期数据”的问题,即当一个版本的元组对所有事物都不可见时,那么它就是过期的,此时其占用的空间是可以被释放的,FSM 文件用于追踪这些可用空间,并在需要的时候能够高效地分配出去
LINK:在更新和删除操作中,用于维护 compact 中元组版本上下游关系的信息
DELTA:用于存储删除信息,MARS3 的更新和删除操作都不是采用原地修改数据的方式,而是依靠 DELTA 文件 (XMAX 等删除信息) 和版本信息屏蔽掉了老数据,从而控制数据的可见性
INDEX 和 INDEX_1_TOAST:用于存储索引文件,当前 MARS3 支持 BRIN 和 BTREE 索引
postgres=# select * from matrixts_internal.mars3_files('test');
segid | level | run | file | seq | path | bytes
-------+-------+-----+---------------+-----+----------------------------+---------
1 | 0 | 1 | DATA | 0 | base/14011/235713_meta_1.2 | 1081344
1 | 0 | 1 | DELTA | 0 | base/14011/235713_meta_1 | 32768
1 | 0 | 1 | LINK | 0 | base/14011/235713_meta_1.1 | 0
1 | 0 | 1 | FSM | 0 | base/14011/235713_meta_1.3 | 131072
1 | 0 | 1 | INDEX_1 | 0 | base/14011/235713_meta_1.4 | 65536
1 | 0 | 1 | INDEX_1_TOAST | 0 | base/14011/235713_meta_1.5 | 0
2 | 0 | 1 | DATA | 0 | base/14011/253866_meta_1.2 | 1081344
2 | 0 | 1 | DELTA | 0 | base/14011/253866_meta_1 | 32768
2 | 0 | 1 | LINK | 0 | base/14011/253866_meta_1.1 | 0
2 | 0 | 1 | FSM | 0 | base/14011/253866_meta_1.3 | 131072
2 | 0 | 1 | INDEX_1 | 0 | base/14011/253866_meta_1.4 | 65536
2 | 0 | 1 | INDEX_1_TOAST | 0 | base/14011/253866_meta_1.5 | 0
3 | 0 | 1 | DATA | 0 | base/14011/243459_meta_1.2 | 1081344
3 | 0 | 1 | DELTA | 0 | base/14011/243459_meta_1 | 32768
3 | 0 | 1 | LINK | 0 | base/14011/243459_meta_1.1 | 0
3 | 0 | 1 | FSM | 0 | base/14011/243459_meta_1.3 | 131072
3 | 0 | 1 | INDEX_1 | 0 | base/14011/243459_meta_1.4 | 65536
3 | 0 | 1 | INDEX_1_TOAST | 0 | base/14011/243459_meta_1.5 | 0
0 | 0 | 1 | DATA | 0 | base/14011/238674_meta_1.2 | 1081344
0 | 0 | 1 | DELTA | 0 | base/14011/238674_meta_1 | 32768
0 | 0 | 1 | LINK | 0 | base/14011/238674_meta_1.1 | 0
0 | 0 | 1 | FSM | 0 | base/14011/238674_meta_1.3 | 131072
0 | 0 | 1 | INDEX_1 | 0 | base/14011/238674_meta_1.4 | 65536
0 | 0 | 1 | INDEX_1_TOAST | 0 | base/14011/238674_meta_1.5 | 0
(24 rows)
MARS3 基于 LSM-TREE 组织数据,各个 Run 文件被组织到 Level 中,最大可有 10 层:L0,L1,L2......L9。每一层的 Run 个数达到一定数目,或者同一层多个 Run 的大小总和达到阈值都会触发合并,合成一个 Run 后升级到更高层去;并且为了加快 Run 的升级,允许同一层中同时进行多个合并任务。

在 YMatrix 中,有一定数量的后台合并进程会周期性地检测各个表的状态,并执行合并操作。
YMatrix 提供了 matrixts_internal.mars3_level_stats 实用函数用于查看 MARS3 表中每个层级的状态。此操作对于评估表的健康状况非常有用,比如检查 Runs 是否按预期合并,是否有过多不可见的 Runs,以及运行次数 Run counts 是否在正常范围内。
postgres=# select * from matrixts_internal.mars3_level_stats('test') limit 10;
segid | level | total_nruns | visible_nruns | invisible_nruns | object_nruns | object_visible_nruns | level_size
-------+-------+-------------+---------------+-----------------+--------------+----------------------+------------
1 | 0 | 1 | 1 | 0 | 0 | 0 | 1280 kB
1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 bytes
1 | 2 | 0 | 0 | 0 | 0 | 0 | 0 bytes
1 | 3 | 0 | 0 | 0 | 0 | 0 | 0 bytes
1 | 4 | 0 | 0 | 0 | 0 | 0 | 0 bytes
1 | 5 | 0 | 0 | 0 | 0 | 0 | 0 bytes
1 | 6 | 0 | 0 | 0 | 0 | 0 | 0 bytes
1 | 7 | 0 | 0 | 0 | 0 | 0 | 0 bytes
1 | 8 | 0 | 0 | 0 | 0 | 0 | 0 bytes
1 | 9 | 0 | 0 | 0 | 0 | 0 | 0 bytes
(10 rows)
按照经验法则:
当 level =0 时,若 runs 数大于 3,则状态不健康。
当 level = 1 时,若 runs 数大于 50,则状态不健康。
当 level > 1 时,若 runs 数大于 10,则状态不健康。
columnstore 都是直接写直接读,没有类似 Shared Buffers 这样的缓冲层,也没有页面刷新。
每 compress_threshold (默认为 1200) 行的数据,我们称为一个 Range;一个 Range 内的某一列数据 (包含 compress_threshold 行),称之为 stripe;这一列数据如果特别大,那么 stripe 会切成若干个 1MB 的 chunk,读的时候也不会一下读整个 compress_threshold 的数据出来。
RUN
└── range(按行切分,默认每 1200 行一个range)
├── column1 stripe(1200 个 datum)
├── column2 stripe(1200 个 datum)
├── column3 stripe(1200 个 datum)
└── ...
range 是逻辑行窗口:每 1200 行组成一个 range,range 内部以列存格式存储,按列压缩
stripe 是物理列块:某一列在这个 1200 行范围内的连续存储块
datum 是最小值单元:一行某一列的值 (PG 内核原生单位)

混合负载的现实要求是:写入往往以小批次或持续微批发生,但分析查询又希望以列式方式高效扫描。MARS3 的思路是把这两类诉求放在同一套体系里,通过行存与列存的组合在不同阶段承担不同职责:
行存形态更偏写入与新鲜数据访问:它更容易快速接收新数据,也更适合小范围读取与明细回查 (尤其是数据刚写入、尚未完成整理时)。
列存形态更偏扫描与聚合:当数据经过整理,列存能显著提升扫描吞吐与压缩效率,使大范围聚合、过滤查询更省资源。
所谓“先行后列”,本质就是让数据在生命周期里先以更适合写入的形态进入系统,再在后台治理中逐步转为更适合分析的形态,从而实现“写入不断、分析不断”的常态运行。为了适应不同场景的需求,在 YMatrix 中支持三种写入模式,由表级参数 prefer_load_mode 和 rowstore_size 共同决定:
Normal:表示正常模式,新写入数据先写到 L0 层的行存 Run 中,积累到 rowstore_size 之后,落至 L1 层的列存 Run,相对于 bulk 模式会多一次 I/O,列存转换由同步变成了异步,但适用于 I/O 能力充足且对延迟敏感的高频次小批量写入场景;
Bulk:批量加载模式,适用于低频大批量写入场景,直接写至 L1 层的列存 Run,相对于 normal 模式,减少了一次 I/O,列存转换由异步变成了同步,适用于 I/O 能力不足且对延迟不敏感的低频大批量的数据写入
Single:数据直接插入到 rowstore,元组被直接放置在 Shared Buffers 中。
更多详细内容可以参照第4章写路径总览。
目前 MARS3 目前支持 BRIN 和 BTREE 索引,BTREE 适合以“精确查找”为核心的事务型系统,通过行级指针实现快速定位;而 BRIN 适合以“范围扫描”为主的大规模分析型系统,通过块级摘要显著减少无效 IO。值得注意的是,对于 MARS3 表,当前一个表上最多允许 16 个索引 (不管是否是同一个列,不管是 BRIN 还是 BTREE),超过就会提示:ERROR: IndexBuild error: too many indexes (index_am.h:162)。
BRIN 是一种面向超大规模数据表的轻量级范围摘要索引,它不直接指向具体行,而是为连续的数据块范围维护最小值、最大值等统计信息,用于在查询阶段快速裁剪不可能命中的数据块区域,从而显著减少扫描 IO。

BRIN 的空间占用极小、构建和维护成本极低,但查询效果强依赖数据在物理层面的有序性,当数据按时间戳或递增键顺序写入时,可在时序分析和日志类场景中获得接近分区裁剪级别的扫描效率,是分析型大表的重要索引补充方案,比如如下条件,检索 250 这条数据可以快速定位到位于第三个数据块中:

由于 BRIN 这种数据结构的特殊性,核心思想不是“给每一行建索引”,而是:给一段连续数据块维护摘要信息,因此 BRIN 不适用于随机物理分布的数据,以及高更新、频繁乱序更新的场景,在这种情况下,会退化为顺序扫描,每个数据块都需要进行扫描。

在 YMatrix 中,还支持 Default BRIN。
BTREE 索引是一种基于平衡多叉树结构的通用型索引,通过按键值有序组织索引节点,实现对单行或小范围数据的快速精确定位,查询复杂度稳定在 O(logN),既支持等值查询,也高效支持范围扫描与排序操作。由于其不依赖数据物理分布特性,BTREE 在高并发事务处理场景下具备极强的稳定性,是主键、唯一约束及高选择性查询的默认首选索引类型,但是不适用于低选择性列和大表的宽范围扫描。

mars3btree 是 MARS3 存储引擎里专用的 B-tree 实现,索引的内部页仍是标准 B-tree 页面,mars3btree 支持两种类型:
NORMAL:标准行式 B-tree (用于RowStore),不压缩
COMPRESSED:列式压缩 B-tree (用于ColumnStore),压缩
对于列式压缩 BTREE,整体架构如下:
Min/Max 元信息:构建时维护 min/max,并写入 metapage;作用是:在真正下探 btree 之前,可以先用查询条件做全局剪枝:相当于给索引再加一层超轻量目录,如果查询条件落在 min/max 之外,直接判定索引不可能命中。
Bloom Filter:仅对唯一/主键索引构建,并且限制在 1000 万条以内,对唯一/主键索引的点查或是否存在类查询:Bloom 能快速判定不可能命中,减少不必要的 leaf 读取与解压。
Fast Path Check:ColumnStore 才启用,失败则立即返回 nullptr,把是否值得解压叶子变成一个极轻的检查 (min/max + bloom),不命中则零解压、零 leaf 读取
关于 mars3btree 索引压缩语法参照索引压缩章节。
和 PostgreSQL 类似,在 YMatrix 中,更新和删除操作并不是对原有的数据空间进行操作,而是通过对元组的多版本形式来实现的:
MARS3 的更新和删除操作都不是采用原地修改数据的方式,而是依靠 DELTA 文件和版本信息屏蔽掉了老数据,从而控制数据的可见性
MARS3 通过 DELETE 进行删除,删除会在对应 Run 的 Delta 文件中进行记录,在进行 Run 合并的时候真正把数据删除
MARS3 通过 UPDATE 进行更新,更新会先删除原本数据,再重新插入一条新数据
在 MARS3 中,排序键是决定引擎能否发挥扫描效率、能否长期稳定运行的核心设计点。有序数据 + 可靠的块级元数据可以大幅提升扫描效率,排序键选得好,数据在 Run 内以及更高层级中会呈现更强的局部性,查询的过滤条件更容易命中连续范围,跳读更有效;排序键选得不合理,数据分布会更“散”,过滤条件无法收敛扫描范围,系统会表现为“看似有索引/有元数据,但读起来还是像全扫”。
排序键带来的核心收益可以拆成五类
1、提升过滤与范围查询效率:当 WHERE 条件与排序键高度相关 (例如时间范围、设备 ID 范围等),数据在存储层呈现更强的聚集性,查询可以更早、更精准地跳过无关数据块,从而减少 I/O 和 CPU 处理量。
2、提升统计信息的“可靠性”与“可跳读性”:块级 min/max、BRIN 等元数据的价值依赖于数据分布。如果同一块里包含的键值跨度很大、或者分布被打散,min/max 覆盖范围会变宽,跳读会变“保守” (不得不读更多块)。良好的排序键能让每个块的值域更集中,从而让元数据更有判别力。
3、影响后台合并与长期运行成本:排序键也会影响 Run 的形态与合并效果:数据越有序、越聚集,合并后的 Run 越规整,空间与读路径更容易收敛;如果排序键导致数据“天然离散”,即使合并也难以形成良好的局部性,长期运行会更依赖更多的治理成本来维持体验。
4、影响压缩:压缩 (不管是 zstd/lz4 还是 RLE/dict/bitpack 这些编码) 都依赖一个核心事实:同一块/同一 stripe 内的数据越规律,压缩越好。当相近实体/相近时间的数据聚集在同一 stripe 内时,块内值域收敛、重复与成段重复增强,字典规模下降,delta/bitpacking 的 bit 宽度降低,从而提升编码与通用压缩的效果,具体验证效果可以参照排序键对于压缩的影响章节。
5、影响写入性能:参照排序键对于写入性能的影响章节。
用最常见、过滤效果最强的查询维度来组织数据。实践中,排序键的选择通常来自两个维度的组合:
时间维度 (Time):时序/日志/指标类负载几乎都会有时间范围过滤;
实体维度 (Entity):设备、车辆、用户、工位、站点等“单实体回查”的主键或高频过滤字段。
在排序键中,过滤条件中频繁使用的列应置于更靠前的位置。
法则 1:将 WHERE 过滤条件中出现频率最高的列作为排序键的最左前缀
法则 2:将高基数且高选择性的列置于排序键前端
法则 3:若使用 BRIN 索引,排序键中列的出现位置越靠前,其重要性越高 —— 因为该列对索引跳过无关数据的能力影响越大。
简而言之,让最常见的查询条件能够尽可能把扫描范围收敛到连续的、有聚集性的区间。
如下是一个真实的客户案例 —— 时序场景,查询样例如下:
SELECT time_bucket_gapfill ('5 min', time) AS bucket_time,
locf (LAST (value, time)) AS last_value,
locf (LAST (quality, time)) AS last_quality,
locf (LAST (flags, time)) AS last_flags
FROM
xxx
WHERE
id = '116812373032966284'
AND type = 'ANA'
AND time >= '2025-11-16 00:00:00.000'
AND time 、= 操作符的列创建 default_brin 索引;
- N :系统会自动为前N列中支持 、= 操作符的列创建 default_brin 索引。
```sql
postgres=# \d+ t_default
Table "public.t_default"
Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
--------+--------+-----------+----------+---------+---------+--------------+-------------
c1 | bigint | | | | plain | |
c2 | bigint | | | | plain | |
c3 | bigint | | | | plain | |
Distributed by: (c1)
Access method: mars3
Options: mars3options=default_brinkeys=30, compresslevel=1, compresstype=zstd
我们可以使用 UDF 来检验哪些列有 Default Brin
CREATE FUNCTION matrixts_internal.mars3_brinkeys (IN r1 regclass, OUT nbrinkeys int, OUT brinkeys text)
RETURNS SETOF RECORD
AS '$ libdir / matrixts;', 'mars3_brinkeys'
LANGUAGE C
VOLATILE PARALLEL UNSAFE STRICT EXECUTE ON ALL SEGMENTS;
postgres=# select * from matrixts_internal.mars3_brinkeys('t_default'::regclass);
nbrinkeys | brinkeys
-----------+------------
3 | (c1,c2,c3)
3 | (c1,c2,c3)
3 | (c1,c2,c3)
3 | (c1,c2,c3)
(4 rows)
为了适应不同场景的需求,在 YMatrix 中支持三种写入模式,由表级参数 prefer_load_mode 和 rowstore_size 共同决定:
不同模式的写入流程

在 Single 模式下,数据会在本地内存中最多积攒 1MB,然后直接插入到 rowstore 中,整个写入过程和传统的 PostgreSQL Heap 写入类似,数据直接放置在 Shared Buffers 中,其大小由同名参数 shared_buffers 控制,在 Single 模式下,可以酌情调大此参数,否则也会发生内存置换。
Shared Buffers 的置换策略

优点:插入延迟最小,内存消耗最小。
缺点:
对于大数据量插入, 数据由于先进入到 rowstore,多了一次合并的过程
数据进入 rowstore 后,假如不能尽快的转换成 columnstore,查看表大小 (\dt+) 会比较大,可以在查看前手动执行一次 vacuum + vacuum full
AP 查询 和 BRIN 查询效率均不如 columnstore
由于没有压缩,数据和 XLOG 大小均大于 columnstore 数倍,不适合 IO 很差的环境
在 Bulk 模式下,数据会先拷贝到本地内存,最多达到 rowstore_size 大小,当达到 rowstore_size 大小时,会被直接转换成 columnstore 刷新到磁盘,当插入结束,本地内存未达到 rowstoresize 大小时,仍然会转换成 columnstore 刷新到磁盘中。
优点:
数据经过压缩,对磁盘 IO 更加友好
AP 查询 和 BRIN 查询效率比 rowstore 好
少了一次写放大
缺点:
占用内存更多,数倍于 rowstore,尤其是分区表数量多的情况
插入延迟不如 rowstore
智能插入模式,数据会被拷贝到本地内存,当达到 rowstore_size 大小时,会被直接转换成 columnstore 刷新到磁盘,当插入结束,本地内存未达到 (rowstore_size / 2) 大小时,则会写入到当前的 rowstore 中,否则会转换成 columnstore 刷新到磁盘中,默认为 normal 模式。
| 测试类型 | 每客户端事务数 | 客户端数 | 线程数 | 平均延迟 (ms) | TPS (包括连接) | TPS (不包括连接) |
|---|---|---|---|---|---|---|
| single_insert.sql | 100,000 | 1 | 1 | 1.98 | 505.03 | 505.06 |
| bulk_insert.sql | 100,000 | 1 | 1 | 15.282 | 65.44 | 65.44 |
| normal_insert.sql | 100,000 | 1 | 1 | 1.968 | 508.02 | 508.05 |
| 模式 | 总耗时 | 总秒数 (s) | 每秒行数 (rows/s) |
|---|---|---|---|
| single | 2m27s | 147 | ≈ 680 |
| normal | 2m28s | 148 | ≈ 676 |
| bulk | 4m55s | 295 | ≈ 339 |
| 模式 | 行数 | 用时 (秒) |
|---|---|---|
| bulk | 50,000,000 | 69.287 |
| single | 50,000,000 | 50.868 |
| normal | 50,000,000 | 70.612 |
压缩比接近 10 倍,TPS 相较于 heap 约 25% 左右的损耗
| 表名 | 压缩比 | 存储节省 |
|---|---|---|
| fi_voucher | 7:01 | 86% |
| fi_voucher_b | 15:01 | 93% |
| aai_voucher | 4.5:1 | 78% |
| aai_voucher_record | 8.9:1 | 89% |
某实测场景中,插入相同大小的数据,可以看到产生 WAL 的速率相同,但是总体生成的 WAL 大约是 Heap 的 1/3,可以大幅减少 WAL 占用的存储空间。

由于 Range 压缩后是变长的,不能使用现成的 Shared Buffers 机制,因此设计了一个只读的支持变长数据的缓存,用于支持索引扫描路径进来的查询,索引扫描查询对延迟比较敏感,我们通过缓存的方式来缓解列存带来的读放大问题,同时缓存中存放的是对索引扫描更友好的格式,索引扫描准确的知道是哪一个元组,所以缓存里格式以及读取可以快速定位到某一行。由于 Range 压缩后是变长的,不能使用现成的 Shared Buffers 机制,因此设计了一个只读的支持变长数据的缓存,用于支持索引扫描路径进来的查询,索引扫描查询对延迟比较敏感,我们通过缓存的方式来缓解列存带来的读放大问题,同时缓存中存放的是对索引扫描更友好的格式,索引扫描准确的知道是哪一个元组,所以缓存里格式以及读取可以快速定位到某一行。
对于 MARS3,有一块和 Shared Buffers 类似的缓存,称之为 varbuffer,varbuffer 主要用于优化索引扫描,由于 columnstore 都是直接写直接读,varbuffer 用于缓存解压后的 stripe 数据,因为磁盘里存放的都是压缩后的数据。如果都是索引扫描,可以根据情况调整此参数(需要重启)
① 先查 varbuffer
命中 → 直接用解压后的stripe(看到的是压缩前的stripe)
② miss → 发起 buffer io
↓
OS cache 命中?
是 → 拿到压缩stripe(无磁盘IO)
否 → 真磁盘读
③ 拿到压缩stripe
→ 解压
→ 放入 varbuffer
postgres=# show mx_varbuffer_size ;
mx_varbuffer_size
-------------------
1GB
(1 row)
根据理论,varbuffer 越大,索引扫描的效果越好,对于顺序扫描的影响弱。其次也要注意 OS cache 的影响,每次测试前删除 OS cache,并且重启数据库。构建数据集
CREATE TABLE t_m3 (
id bigint,
ts timestamptz,
v double precision,
pad int
)
USING MARS3
DISTRIBUTED BY (id);
INSERT INTO t_m3
SELECT
(g % 1000000)::bigint AS id,
'2026-01-01'::timestamptz + (g || ' seconds')::interval AS ts,
(random()*1000)::float8 AS v,
(g % 1000)::int AS pad
FROM generate_series(1, 20000000) g;
ANALYZE t_m3;
CREATE INDEX idx_t_m3_id_ts ON t_m3 (id, ts);
ANALYZE t_m3;
验证结果
adw=# show mx_varbuffer_size;
mx_varbuffer_size
-------------------
64MB
(1 row)
adw=# \q
[mxadmin@sdw ~]$ /usr/bin/time -f "pass1: %e s" bash -c '
> for i in $(seq 1 20000); do
> psql -X -qAt -c "SELECT v FROM t_m3 WHERE id=$i AND ts='\''2026-01-02 00:00:00+00'\'';" >/dev/null
> done'
pass1: 238.07 s
adw=# show mx_varbuffer_size;
mx_varbuffer_size
-------------------
1GB
(1 row)
adw=# \q
[mxadmin@sdw ~]$ /usr/bin/time -f "pass2: %e s" bash -c '
> for i in $(seq 1 20000); do
> psql -X -qAt -c "SELECT v FROM t_m3 WHERE id=$i AND ts='\''2026-01-02 00:00:00+00'\'';" >/dev/null
> done'
pass2: 238.72 s
adw=# select * from t_m3 where ts = '2026-01-02 00:00:00+00';
id | ts | v | pad
--------+------------------------+-------------------+-----
115200 | 2026-01-02 08:00:00+08 | 681.0068987279756 | 200
(1 row)
因为查询每次最多只命中 0~1 行,且几乎不复用 stripe,没有形成可缓存的工作集,所以二者差异并不大。
重新设计需要满足 4 个条件:
索引扫描稳定触发
单次查询返回中等规模数据
重复查询会反复命中同一批 stripe,形成热点
热点工作集大小可以卡在 64MB 和 1GB 之间
由于 OS cache 的影响,每次验证之前需要 echo 3 > /proc/sys/vm/drop_caches 清空 OS cache 以及重启数据库
测试结论:在清空 OS page cache 且重启数据库的冷启动条件下,针对同一热点窗口执行 1000 次 MxVIndexScan 聚合查询,mx_varbuffer_size=1GB 相比 64MB 将总耗时从 87.986s 降低至 17.514s (约 5.02 倍加速,80.1% 耗时下降)。该结果表明,varbuffer 容量对 MARS3 索引扫描场景下的解压后列数据复用具有决定性影响;当容量不足时,会出现显著的重复解压与缓存淘汰开销。
DROP TABLE IF EXISTS t_m3_vb_test;
CREATE TABLE t_m3_vb_test (
device_id int, -- 查询主键之一(高基数)
ts timestamptz, -- 时间维度(索引第二列)
metric_id smallint, -- 指标编号(低基数)
v1 double precision, -- 常用数值列
v2 double precision, -- 常用数值列
v3 double precision, -- 常用数值列
status int, -- 状态列(低基数)
tag int -- 填充列(增加工作集大小)
)
USING MARS3
DISTRIBUTED BY (device_id);
CREATE INDEX idx_t_m3_vb_test_dev_ts ON t_m3_vb_test (device_id, ts);
ANALYZE t_m3_vb_test;
-- 大约 3KW 数据
INSERT INTO t_m3_vb_test
SELECT
d.device_id,
t.ts,
m.metric_id,
(random() * 1000)::float8 AS v1,
(random() * 1000)::float8 AS v2,
(random() * 1000)::float8 AS v3,
(random() * 10)::int AS status,
(random() * 100000)::int AS tag
FROM generate_series(1, 100) AS d(device_id)
CROSS JOIN generate_series(
'2026-01-01 00:00:00+00'::timestamptz,
'2026-01-01 23:59:59+00'::timestamptz,
'1 second'::interval
) AS t(ts)
CROSS JOIN generate_series(1, 4) AS m(metric_id);
64 MB varbuffer
每次 index scan 都会反复访问同一批 stripe / range,varbuffer 容量不足以容纳该热点窗口涉及的解压后列数据,因此每轮执行都发生大量:
stripe 再读取
重新解压
重新构造执行批次
导致 CPU/内存开销被放大。
adw=# show mx_varbuffer_size ;
mx_varbuffer_size
-------------------
64MB
(1 row)
adw=# set enable_seqscan to off;
SET
adw=# set enable_bitmapscan to off;
SET
adw=# \timing on
Timing is on.
adw=# DO $$
DECLARE i int;
DECLARE s1 float8;
DECLARE s2 float8;
DECLARE s3 float8;
BEGIN
FOR i IN 1..1000 LOOP
SELECT sum(v1), avg(v2), max(v3)
INTO s1, s2, s3
FROM t_m3_vb_test
WHERE device_id = 42
AND ts >= '2026-01-01 10:00:00+00'
AND ts = '2026-01-01 10:00:00+00'
AND ts varbuffer 主要优化索引驱动的随机/热点访问,而不是顺序扫描主路径。
64 MB varbuffer
```sql
adw=# set enable_bitmapscan to off;
SET
adw=# set enable_indexscan to off;
SET
adw=# \timing on
Timing is on.
adw=# DO $$
DECLARE i int;
DECLARE s1 float8;
DECLARE s2 float8;
DECLARE s3 float8;
BEGIN
FOR i IN 1..1000 LOOP
SELECT sum(v1), avg(v2), max(v3)
INTO s1, s2, s3
FROM t_m3_vb_test
WHERE device_id = 42
AND ts >= '2026-01-01 10:00:00+00'
AND ts = '2026-01-01 10:00:00+00'
AND ts 同一批次压缩的数据多了,可能压缩效果更好,原理上重复的数据被压的更多了,要不然在两个批次里面就没压到一起。
### 4.3.1 compress_threshold 对于读取的性能影响
| id | compress_threshold=1200 | 3600 | 10000 | 50000 |
|----|------------------------|---------|---------|---------|
| 1 | 8.8032 | 7.7846 | 6.6471 | 6.6824 |
| 2 | 0.981 | 0.997 | 2.2284 | 2.2322 |
| 3 | 3.3512 | 3.3143 | 3.3184 | 3.3177 |
| 4 | 7.7059 | 7.7326 | 6.6034 | 5.5994 |
| 5 | 5.5732 | 5.5042 | 5.5939 | 6.633 |
| 6 | 0.478 | 0.38 | 0.545 | 0.545 |
| 7 | 1.1908 | 1.1917 | 2.2172 | 2.2222 |
| 8 | 3.3602 | 3.3129 | 3.3018 | 3.3227 |
| 9 | 8.8429 | 7.74 | 7.7751 | 7.7795 |
| 10 | 3.3835 | 4.4239 | 5.5496 | 5.5499 |
| 11 | 0.845 | 0.743 | 2.2307 | 2.2299 |
| 12 | 1.1998 | 1.1887 | 2.2164 | 2.2138 |
| 13 | 6.6514 | 6.6351 | 6.6784 | 6.6424 |
| 14 | 0.837 | 0.776 | 1.1364 | 1.1275 |
| 15 | 1.1436 | 1.1359 | 1.183 | 1.1783 |
| 16 | 1.1768 | 1.1787 | 2.2673 | 2.2757 |
| 17 | 9.9419 | 8.8705 | 8.8922 | 8.8771 |
| 18 | 11.11337 | 11.11522| 10.10384| 10.10647|
| 19 | 4.4792 | 3.3942 | 4.4918 | 4.467 |
| 20 | 2.2668 | 2.2389 | 3.3475 | 3.3346 |
| 21 | 11.11391 | 10.10633| 11.1119 | 10.10742|
| 22 | 3.3009 | 2.2864 | 3.372 | 3.3377 |
| sum| 97.73958 | 92.05015| 100.81134| 99.78189|
### 4.3.2 compress_threshold 对于写入的性能影响
| 测试场景 | compress_threshold | 1200 | 3600 | 10000 | 50000 |
|------------|---------------------|------------|-------------|--------------|--------------|
| 写入性能 | 分区表 (条/s) | 852,334 | 919,031 | 1,055,371 | 1,057,424 |
| 写入性能 | 非分区表 (条/s) | 991,463 | 1,033,751 | 1,054,292 | 1,076,714 |
| 查询性能 | time_bucket=1h (1天范围) | 357ms | 342ms | 333ms | 324ms |
| 查询性能 | time_bucket=1d (1月范围) | 7,384ms | 6,312ms | 6,008ms | 6,025ms |
| 查询性能 | time_bucket=30d (1年范围)| 94,278ms | 75,101ms | 68,184ms | 69,006ms |
| 查询性能 | 点查场景 | 19.416 ms | 20.043 ms | 23.692 ms | 37.844 ms |
### 4.3.3 索引压缩
索引压缩架构参照 mars3btree 章节
```sql
CREATE INDEX idx_name ON table_name
USING mars3btree (column_list)
WITH (
compresstype = 'lz4', -- 压缩算法
compresslevel = 1, -- 压缩级别
compressctid = true, -- 是否压缩CTID列
encodechain = '', -- 编码链
minmax = true -- 启用min/max优化
);
compresstype (压缩算法):支持 lz4、zstd 和 mxcustom。默认为 lz4,lz4:压缩/解压速度快,压缩率中等;zstd:压缩率高,速度稍慢;mxcustom:需配合 encodechain 使用
compresslevel (压缩级别):支持 1 ~ 9,默认 1。对于查询密集型建议 1 ~ 3 (优先解压速度),存储敏感型建议 6 ~ 9 (优先压缩率)
compressctid (CTID 列是否压缩):默认为 true,建议保持为 true,CTID 列压缩率通常很高
在某客户场景中,使用索引压缩后:
对 TOB 集群影响较小,节点 CPU 与 MEM 变化不大,同时可节省 24% Ymatrix 分区存储空间; 对 TOC 集群效果显著,在牺牲一部分 CPU、MEM 的情况下,可节省 63% Ymatrix 分区存储空间。 50 辆车查询 1 天 GPS 数据,开启索引压缩的服务吞吐量是未开启索引压缩的 3.3 倍; 50 辆车查询 3 天 GPS 数据,开启索引压缩的服务吞吐量是未开启索引压缩的 1.1 倍。
前面提到,压缩 (不管是 zstd/lz4 还是 RLE/dict/bitpack 这些编码) 都依赖一个核心事实:同一块/同一 stripe 内的数据越规律,压缩越好。lz4/zstd 依赖重复子串/重复模式。排序后,同一块内字段组合更相似,尤其是宽表。当相近实体/相近时间的数据聚集在同一 stripe 内时,块内值域收敛、重复与成段重复增强,字典规模下降,delta/bitpacking 的 bit 宽度降低,从而提升编码与通用压缩的效果。
adw=# CREATE TABLE t_sort_good (
device_id int NOT NULL,
ts timestamptz NOT NULL,
site_id int NOT NULL,
status smallint NOT NULL,
v1 double precision NOT NULL,
v2 double precision NOT NULL,
attrs jsonb NOT NULL
)
USING MARS3
WITH (compresstype=zstd,compresslevel=3,mars3options='prefer_load_mode=bulk')
ORDER BY (device_id, ts);
NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'device_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
adw=# CREATE TABLE t_sort_bad (
device_id int NOT NULL,
ts timestamptz NOT NULL,
site_id int NOT NULL,
status smallint NOT NULL,
v1 double precision NOT NULL,
v2 double precision NOT NULL,
attrs jsonb NOT NULL
)
USING MARS3
WITH (compresstype=zstd,compresslevel=3,mars3options='prefer_load_mode=bulk')
ORDER BY (ts, device_id);
NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'device_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
然后生成原始数据源,同时往两张表里插入数据
DROP TABLE IF EXISTS t_src;
CREATE UNLOGGED TABLE t_src AS
WITH
dev AS (
SELECT d AS device_id,
(d % 200) AS site_id,
(1000 + (d % 500))::double precision AS base
FROM generate_series(1, 10000) AS d
),
pts AS (
SELECT device_id, site_id, base,
g AS seq,
(timestamp '2026-01-01' + (g || ' seconds')::interval) AS ts
FROM dev
CROSS JOIN generate_series(1, 1000) AS g
)
SELECT
device_id,
ts,
site_id,
-- status:每 300 秒变一次段,形成长 run
((seq / 300) % 4)::smallint AS status,
-- v1/v2:设备基线 + 小波动
(base + (seq % 10) * 0.1)::double precision AS v1,
(base * 0.1 + (seq % 20) * 0.01)::double precision AS v2,
-- attrs:key 模式高度相似,但值随设备/时间变化
jsonb_build_object(
'fw', '1.2.' || (device_id % 10),
'model', 'm' || (device_id % 50),
'tag', 'S' || site_id,
'k', seq % 5
) AS attrs
FROM pts;
INSERT INTO t_sort_good SELECT * FROM t_src;
INSERT INTO t_sort_bad SELECT * FROM t_src;
为了确保数据的准确性,插入完成后执行多次 vacuum full + vacuum
adw=# select segid,level,total_nruns,visible_nruns,invisible_nruns,level_size from matrixts_internal.mars3_level_stats('t_sort_good') where level_size '0 bytes';
segid | level | total_nruns | visible_nruns | invisible_nruns | level_size
-------+-------+-------------+---------------+-----------------+------------
0 | 1 | 1 | 1 | 0 | 8839 kB
1 | 1 | 1 | 1 | 0 | 8933 kB
3 | 1 | 1 | 1 | 0 | 8552 kB
2 | 1 | 1 | 1 | 0 | 8728 kB
(4 rows)
adw=# select segid,level,total_nruns,visible_nruns,invisible_nruns,level_size from matrixts_internal.mars3_level_stats('t_sort_bad') where level_size '0 bytes';
segid | level | total_nruns | visible_nruns | invisible_nruns | level_size
-------+-------+-------------+---------------+-----------------+------------
0 | 2 | 1 | 1 | 0 | 26 MB
1 | 2 | 1 | 1 | 0 | 26 MB
3 | 2 | 1 | 1 | 0 | 25 MB
2 | 2 | 1 | 1 | 0 | 25 MB
(4 rows)
adw=# \dt+ t_sort_bad
List of relations
Schema | Name | Type | Owner | Storage | Size | Description
--------+------------+-------+---------+---------+--------+-------------
public | t_sort_bad | table | mxadmin | mars3 | 103 MB |
(1 row)
adw=# \dt+ t_sort_good
List of relations
Schema | Name | Type | Owner | Storage | Size | Description
--------+-------------+-------+---------+---------+-------+-------------
public | t_sort_good | table | mxadmin | mars3 | 35 MB |
(1 row)
adw=# select * from t_sort_good limit 10;
device_id | ts | site_id | status | v1 | v2 | attrs
-----------+------------------------+---------+--------+--------+--------------------+-----------------------------------------------------
3 | 2026-01-01 00:00:01+08 | 3 | 0 | 1003.1 | 100.31000000000002 | {"k": 1, "fw": "1.2.3", "tag": "S3", "model": "m3"}
3 | 2026-01-01 00:00:02+08 | 3 | 0 | 1003.2 | 100.32000000000001 | {"k": 2, "fw": "1.2.3", "tag": "S3", "model": "m3"}
3 | 2026-01-01 00:00:03+08 | 3 | 0 | 1003.3 | 100.33000000000001 | {"k": 3, "fw": "1.2.3", "tag": "S3", "model": "m3"}
3 | 2026-01-01 00:00:04+08 | 3 | 0 | 1003.4 | 100.34000000000002 | {"k": 4, "fw": "1.2.3", "tag": "S3", "model": "m3"}
3 | 2026-01-01 00:00:05+08 | 3 | 0 | 1003.5 | 100.35000000000001 | {"k": 0, "fw": "1.2.3", "tag": "S3", "model": "m3"}
3 | 2026-01-01 00:00:06+08 | 3 | 0 | 1003.6 | 100.36000000000001 | {"k": 1, "fw": "1.2.3", "tag": "S3", "model": "m3"}
3 | 2026-01-01 00:00:07+08 | 3 | 0 | 1003.7 | 100.37 | {"k": 2, "fw": "1.2.3", "tag": "S3", "model": "m3"}
3 | 2026-01-01 00:00:08+08 | 3 | 0 | 1003.8 | 100.38000000000001 | {"k": 3, "fw": "1.2.3", "tag": "S3", "model": "m3"}
3 | 2026-01-01 00:00:09+08 | 3 | 0 | 1003.9 | 100.39000000000001 | {"k": 4, "fw": "1.2.3", "tag": "S3", "model": "m3"}
3 | 2026-01-01 00:00:10+08 | 3 | 0 | 1003 | 100.4 | {"k": 0, "fw": "1.2.3", "tag": "S3", "model": "m3"}
(10 rows)
adw=# select * from t_sort_bad limit 10;
device_id | ts | site_id | status | v1 | v2 | attrs
-----------+------------------------+---------+--------+--------+--------------------+-------------------------------------------------------
1 | 2026-01-01 00:00:01+08 | 1 | 0 | 1001.1 | 100.11000000000001 | {"k": 1, "fw": "1.2.1", "tag": "S1", "model": "m1"}
12 | 2026-01-01 00:00:01+08 | 12 | 0 | 1012.1 | 101.21000000000001 | {"k": 1, "fw": "1.2.2", "tag": "S12", "model": "m12"}
15 | 2026-01-01 00:00:01+08 | 15 | 0 | 1015.1 | 101.51 | {"k": 1, "fw": "1.2.5", "tag": "S15", "model": "m15"}
20 | 2026-01-01 00:00:01+08 | 20 | 0 | 1020.1 | 102.01 | {"k": 1, "fw": "1.2.0", "tag": "S20", "model": "m20"}
23 | 2026-01-01 00:00:01+08 | 23 | 0 | 1023.1 | 102.31000000000002 | {"k": 1, "fw": "1.2.3", "tag": "S23", "model": "m23"}
35 | 2026-01-01 00:00:01+08 | 35 | 0 | 1035.1 | 103.51 | {"k": 1, "fw": "1.2.5", "tag": "S35", "model": "m35"}
38 | 2026-01-01 00:00:01+08 | 38 | 0 | 1038.1 | 103.81000000000002 | {"k": 1, "fw": "1.2.8", "tag": "S38", "model": "m38"}
40 | 2026-01-01 00:00:01+08 | 40 | 0 | 1040.1 | 104.01 | {"k": 1, "fw": "1.2.0", "tag": "S40", "model": "m40"}
44 | 2026-01-01 00:00:01+08 | 44 | 0 | 1044.1 | 104.41000000000001 | {"k": 1, "fw": "1.2.4", "tag": "S44", "model": "m44"}
47 | 2026-01-01 00:00:01+08 | 47 | 0 | 1047.1 | 104.71000000000001 | {"k": 1, "fw": "1.2.7", "tag": "S47", "model": "m47"}
(10 rows)
简而言之
t_sort_good 把相似的数据放在一起,于是同一批落盘的数据更规律、更重复,压缩器更容易工作; t_sort_bad 把完全不同的数据混在一起,于是每一批落盘的数据更杂、更随机,压缩器很难压缩。
同样的数据量,只是排序键不同:
t_sort_good 总大小 35 MB,
t_sort_bad 总大小 103 MB (约 3 倍)
从 run/level 统计看:每个 seg 都只有一个 run,但:
t_sort_good 的 run 在 level 1,每 seg 大约 8.5–8.9 MB
t_sort_bad 的 run 在 level 2,每 seg 大约 25–26 MB
对于 t_sort_good 来说,按 (device_id, ts) 排序 → 相似聚集 → 压缩友好,这样在同一个 stripe/块里,往往是“同一个设备的一段连续时间”。这会带来:
device_id/site_id/status 这些列会出现长段重复 (很适合 RLE/字典编码)
ts 和 v1/v2 这种随时间缓慢变化的列,块内增量很小 (很适合 delta/bitpacking)
attrs(jsonb) 的 key 模式在同设备内高度相似(对 zstd/lz4 也更友好)
因此,同样的行数,物理字节会明显更小。
对于 t_sort_bad 来说,按 (ts, device_id) 排序 → 设备混杂 → 压缩不友好,同一个时间点会混进大量设备,导致同一块里:
device_id 几乎每行都变化 (字典更大、RLE 段长度接近 1)
site_id/status 也会被打散 (run-length 被破坏)
attrs 的组合模式更杂 (重复片段减少)
即使 ts 是递增的,但其它列的“随机性”把整体压缩收益吃掉了
所以压缩比显著变差,物理大小变成 3 倍非常正常。
换句话说:坏排序不仅更大,还更难治理,到了更高层才稳定下来。
在 MARS3 中,排序键决定了数据在落盘单元 (run/stripe) 内的相似性。相似数据聚集时,块内的值域更窄、重复更集中,字典/RLE/delta 以及通用压缩都能充分发挥,最终得到更小的物理占用;相反,排序键导致数据混杂时,块内分布更散、重复被打碎,压缩与编码效率显著下降,甚至会增加后台整理的难度与成本。
Unique Mode 是 MARS3 为特定写入模型提供的一种模式:把“更新某条记录”的需求,转换为“按唯一键再次插入一条新记录”,由引擎自动完成同一键值下的新旧版本替换。它的核心价值是让业务在高频写入场景中,用更简单、更统一的写入方式 (INSERT) 表达更新语义,减少显式 UPDATE 的使用与代价,并保持数据组织在持续写入下更可控。
适用场景:
按实体键持续写入最新状态:实体 (设备/车/用户/订单) 维度强、反复写同一键的最新值
写入高频、小批次:希望写路径简单、稳定、可持续
查询以最新值/最新快照为主:例如最新状态看板、最新告警态、最新指标面板
不依赖物理删除语义:业务侧不需要频繁 DELETE (或可以用其他方式表达无效/过期)
在 Unique Mode 下,唯一键由建表时的排序键 ORDER BY (...) 定义:当你插入一条与已有记录 相同 Unique Key 的新数据时,引擎会将其视为对该键对应数据的更新,无需显式执行 UPDATE,直接 INSERT 即可完成更新语义。与传统的 upsert (insert .. on conflict) 不同,Unique Mode 是读时合并,数据在写入的时候依旧会写入到存储中,读取的时候通过一定的版本链和可见性规则确保读取的是最新的数据,并通过 compact 移除重复数据;upsert 则是写时合并,在写入时,即检查是否可能会重复,有额外的写入开销。
这种预检查可以避免将元组插入堆中,在元组重复的情况下再将其删除的开销。
HEAP_INSERT_IS_SPECULATIVE 即所谓的“speculative insertion” —— 推测性插入,如果发现冲突,直接撤消,而无需取消整个事务。其他会话可以等待推测性插入得到确认,将其变成常规元组,或者取消。
因此,Unique Mode 的写入效率会远高于传统 upsert 的方式,根据测试表明,Unique Mode 的性能大约是传统 upsert 的 1.5 倍。
在 Unique Mode 的同键再次写入场景中,标量列采用新值覆盖旧值的默认行为;但当某列为 JSONB 类型时,引擎支持增量合并语义:新版本的 JSONB 值不是直接写入,而是按以下规则得到:new_jsonb = jsonb_concat(old_jsonb, incoming_jsonb),数据库内部会直接调用 jsonb_concat 实现合并,比如:
postgres=# SELECT jsonb_concat('[1,2]'::jsonb, '[2,3]'::jsonb);
jsonb_concat
--------------
[1, 2, 2, 3]
(1 row)
postgres=# SELECT jsonb_concat('{"k":1,"x":2}'::jsonb, '{"k":null}'::jsonb);
jsonb_concat
---------------------
{"k": null, "x": 2}
(1 row)
postgres=# SELECT jsonb_concat('{"a":1,"b":2}'::jsonb, '{"b":99,"c":3}'::jsonb);
jsonb_concat
---------------------------
{"a": 1, "b": 99, "c": 3}
(1 row)
这样当业务使用 JSONB 承载动态属性/扩展字段/标签等信息时,可以只写入增量 JSONB,由引擎完成合并,避免应用层读旧值合并然后写回的额外复杂度与并发冲突。
数组合并的幂等性风险:JSONB array 追加且不去重,若写入有重试/重放可能导致重复元素累积。建议避免把事件明细长期追加在 array 中;需要幂等时可引入 event_id 并在上层保证不重复,或改用 object/map 结构按 key 覆盖。
JSONB 增长治理:JSONB 合并会让字段逐步变大,可能推高读取成本与后台治理压力。建议对 JSONB 做容量治理 (裁剪、拆表、分桶、只保留最近 N 条等)。
null 不等于删除:{"k": null} 会把 k 设置为 null,不会删除该 key;如需删除语义需另行设计 (例如专用标记字段/单独删除表/业务侧约定)。
为什么要做 UpdateChain:解决并发更新同一行时的正确性。对于 PG Heap 的更新不是原地改一行,而是旧 tuple 标记删除 + 插入新 tuple,并用 ctid 把旧版本指向新版本,形成一条 update chain。

而对于 MARS3,MARS3 的删除/更新不是像 Heap 那样把 xmax 写在 tuple header,而是把类似的“删除/更新标记”写到 Delta 文件;同时为了保证 compaction/flush 后这些信息不丢,还需要 Link 文件。Heap 的 xmax/删除信息在 MARS3 里存 Delta,Link 确保 compaction 过程中 delete 信息不丢失。
因此,
Heap:版本链主要靠 tuple header + ctid 链 (数据本体里就带着版本指针)
MARS3:版本/删除语义被外置到 Delta + Link 这种旁路结构里,因此正确性依赖的不只是数据文件,还依赖Delta/Link 在 flush/compaction/vacuum 中不丢、不乱、不破链。
早期 MARS3 在同一行并发更新场景下会直接报错退出,没有 UpdateChain 的支持,无法确保结果的并发正确性;引入 UpdateChain 后,通过 TupleLock 对逻辑行加锁,并沿 update-chain 定位最新版本,使并发更新按顺序推进,从而保证 Update 语义正确性。
和 PostgreSQL 类似,在 YMatrix 中,更新和删除操作并不是对原有的数据空间进行操作,而是通过对元组的多版本形式来实现的:
MARS3 的更新和删除操作都不是采用原地修改数据的方式,而是依靠 DELTA 文件和版本信息屏蔽掉了老数据,从而控制数据的可见性
MARS3 通过 DELETE 进行删除,删除会在对应 Run 的 Delta 文件中进行记录,在进行 Run 合并的时候真正把数据删除
MARS3 通过 UPDATE 进行更新,更新会先删除原本数据,再重新插入一条新数据
更新和删除过程中产生的死元组 (invisible runs) 会由后台进程 autovacuum 定时清理,也可以手动执行 vacuum 进行清理,另外在 compact 的过程中,随着小的 Run 合并为一个新的更大的 Run,invisible runs 也会被清理掉。vacuum full 则更进一步,它会将多个小的 Run 合并成一个大的 Run。如果没有新的写入操作发生,运行一次 vacuum full 通常会尽可能地合并这些批次,直到无法再进行合并为止。最终,剩余的每个批次的大小将约等于 max_runsize 的设定值 (注意,vacuum full 的过程中也可能会产生 invisible runs)。
简而言之:
vacuum 是 flush + 移除 invisible runs,并且会把 rowstore 刷下去
vacuum full 在 vacuum 的基础上还会做合并
在介绍 Compact 之前, 我们先来了解 3 个重要的概念:
读放大:读取数据时实际读取的数据量大于真正的数据量。例如 LSM-TREE 读取数据时需要扫描多个 SSTable
写放大:写入数据时实际写入的数据量大于真正的数据量。例如在 LSM-TREE 树中写入时可能触发 Compact 操作,导致实际写入的数据量远大于该 key 的数据量
空间放大:数据实际占用的磁盘空间比数据的真正大小更多。例如 SSTable 中存储的旧版数据都是无效的
LSM-TREE 通过顺序写入和后台合并来获得极高的写入吞吐能力,而 Compaction 策略决定了系统在写性能、读性能与资源消耗之间的核心权衡。
对于传统的 LSM-TREE,compaction 的策略主要有两种:Size-Tiered Compaction 和 Level compaction。
Tiering:先堆起来,攒多了再一起合并 (写优先),Tiering 就像文件先丢抽屉,满了再统一归档
Leveling:每一层都整理整齐,不允许乱放 (读优先),Leveling 就像每次都立刻放到分类清晰的书架上
Tiered 为写付费,Leveling 为读付费。
Size-Tiered Compaction vs Level Compaction

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

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

leveled 每层由多个 sstable 组成一个有序的的 run,sstables 之间互相也保持有序的关系,每层的数据 size 到达上限后与下一层的 run merge。这种方式将 level 的多个 run 降为一个,减小了读放大和空间放大,小 sstable 的方式提供了精细化任务拆分和控制的条件,控制任务大小也就是控制临时空间的大小。
核心原则:
每一层的 SST 文件 Key 范围必须互不重叠
全系统形成连续有序空间
同样的场景,发现重叠后立刻进行合并 [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 的一个时间段:
L1 里已有:
A 的历史:A:[0~999]、A:[1000~1999]
B 的历史:B:[0~999]、B:[1000~1999]
C 的历史:C:[0~999]、C:[1000~1999]
这时新数据来了:
A 又上报了一些点:TS=1500~1700
B 又上报了一些点:TS=1600~1800
C 又上报了一些点:TS=1400~1650
这些新写入通常会先形成一个新的 run (比如在 L0 或上层),我们叫它 NewRun。NewRun 里面同时包含 A/B/C 的最新时间片,它覆盖的键范围大概是:
A:[1500~1700]
B:[1600~1800]
C:[1400~1650]
现在它会和 L1 哪些文件重叠?
A 的新数据会重叠 A:[1000~1999]
B 的新数据会重叠 B:[1000~1999]
C 的新数据会重叠 C:[1000~1999]
也就是说一个 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 堆积,从而限制读放大,同时尽量保留写吞吐优势。更多细节参照7.2 可观测性

Compaction scheduler 主要负责:
启停 compaction worker
管理复用 compaction worker
确定 compaction 任务的优先级
正确响应数据库停机请求
Compaction worker:负责具体的 compaction 请求,同时检测到 level 的读放大超过限制以后,通知 scheduler 进程新的 compaction 请求,目前掣肘于进程模型,Compaction worker 的数量上限在代码中写死了 16 个。
Compaction prober:负责主动探测 compaction 任务,比如:
一些定时compaction任务
被中断的compaction任务
大体流程是:
1. Insert Backend 持续写入 RowStore
2. 当某个 RowStore 达到阈值,就会切换到一个新的 RowStore
3. 向 scheduler 发送 SIGUSR1,同时在共享内存里写入 compaction 请求信息:(relid, level)
4. scheduler 被唤醒,读取共享内存里的请求:
- 排队 / 去重 / 排优先级
- 选择新拉起 worker或复用空闲 worker
5. worker 开始执行 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 说明:
guc mars3.autoprobe_period 控制多长时间探测一次,单位是秒,大于 0 为打开,0 为关闭,默认为关闭,可以先设置 10 分钟探测一次,再根据工作负载调整
guc mars3.autoprobe_workers 这个控制一次探测选出几个 level,默认为 2,注意这个占用的是普通 compaction 的 worker 数,并不是启动新的 worker
guc mars3.autoprobe_retry 控制重试次数,值为 2 则一共尝试 3 次;
guc 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 的大小 (TotalSize) 推算它应该属于哪个层级 (desired_level)
如果现在不在那个层级 (cur_level),就加锁,把这个 Run 从当前层挪到目标层
根据上面代码流程
run_size ≈ rowstore_size = 64MB
total_amp = run_size / rowstoresize = 64MB / 64MB = 1.0
desired_level = log(1.0) / log(8) = 0 / log(8) = 0
因此,这个 run 的理论相对层号是 0,其大小正好等于 rowstore_size 这一档
if (desired_level ERROR: there are too many segments, 6400 at most. please use VACUUM FULL.
某客户遇到的现象,将一个表 (同样结构,同样数据),从 BULK 模式往 Single 模式写入期间报错。原因是 Single 模式下写入太快,导致 compactor 来不及,生成了过的 Run,最终调大 rowstore_size 解决,但是调大 rowstore_size 会占用过多内存,需要进行权衡。
### 7.3.4 表大小过大
现象:使用元命令 \dt+ 查看表大小过大
可能原因:
- rowstore 中过多的 Run,仍是行存
- 过多的 invisible Runs
解决方式:
手动执行 vacuum + vacuum full,刷新为 columnstore,需要执行多轮,因为 vacuum full 涉及到 MERGE,也会产生 invisible,直到没有更多合并
### 7.3.5 如何获取表的排序键
```sql
adw=# create table testmars3(id int,info text) using mars3 distributed by (id) order by(id) ;
CREATE TABLE
adw=# \d+ testmars3
Table "public.testmars3"
Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
--------+---------+-----------+----------+---------+----------+--------------+-------------
id | integer | | | | plain | |
info | text | | | | extended | |
Distributed by: (id)
Access method: mars3
Order by: (id)
adw=# select * from matrixts_internal.mars3_sortkeys('testmars3');
sks
------
(id)
(1 row)
从 6.5.0 开始 MARS3 正式支持增量备份,具备与 AO 表一致的表级增量备份能力:备份系统可以判断一张表自上次备份以来是否发生过数据修改或结构调整,从而只备份真正变过的内容,大幅减少备份规模与耗时。
YMatrix 新增了对数据修改变化的精确记录能力,类似 AO 表的 modcount 属性,通过参数 mars3.update_modcount 用户可以灵活控制是否开启该功能,从而让系统在每次对表进行写入、更新或删除时自动累加修改计数。此外,MARS3 也支持从 pg_stat_last_operation 视图中获取表的最近一次属性变更时间,用于识别结构层面的变化。
MARS3 最新支持版本支持记录数据修改变化情况,类似 AO 表的 modcount 属性,通过 mars3.update_modcount 参数来控制开启和关闭。并且支持从 pg_stat_last_operation 表中获取上一次修改表属性的时间。基于这两个功能实现 MARS3 表和 AO 表相同的增量 analyzedb 能力。
MARS3 面向的是企业中最常见、也最难平衡的一类场景:数据持续写入、查询持续发生,既要承接实时数据,又要支撑分析决策;既要明细回查,又要大范围聚合。
在这类场景下,客户最头疼的问题通常不是单点性能不够,而是系统容易割裂:为了同时满足实时写入与分析查询,不得不维护多套存储与计算链路,带来更高的架构复杂度、运维成本以及性能不确定性。MARS3 的核心价值,就是用一套统一的存储体系,把这些原本相互牵制的能力整合起来,形成可交付、可验证、可持续的系统能力。
在写入侧,MARS3 能够承接高频、小批、实时到达的数据,并通过后台有节奏地将数据整理为更适合分析的列式组织。这意味着客户不必在实时写入能力和分析性能之间做二选一:新数据可以快速进入系统,沉淀后的数据又能保持高效扫描与聚合。与此同时,MARS3 采用更适合混合负载的后台整理策略,有效控制写放大与资源竞争,避免系统随着数据增长、业务高峰和多实例并发而越来越“难写、难查、难维护”。
在查询侧,MARS3 的价值不仅是把数据读得更快,更重要的是尽量少读无效数据。通过排序键驱动的数据局部性、块级统计信息以及索引访问路径优化,系统可以在查询早期就缩小实际扫描范围,把“不命中就不读”的判断尽可能前移。对客户来说,这带来的直接收益是:同样的硬件资源可以支撑更多分析任务,同样的数据规模下查询更稳定,整体资源投入更可控。 这种收益并不依赖人工反复调优,而是由存储组织、跳读能力和索引优化共同形成的系统性能力。
更重要的是,MARS3 并不是只在理想负载下表现亮眼的“实验型能力”,而是补齐了生产环境真正需要的正确性与运维边界。在更新与删除场景下,它能够保证后台搬迁与合并过程中语义不丢;在并发更新场景下,它能够把“冲突报错退出”升级为“可等待、可追链、可重检”的正确行为;在治理与运维层面,它提供可观测的层级/run 统计、退化模式识别与参数化治理手段,使系统从“能跑”走向“能长期稳定跑”。这意味着,客户得到的不只是一个性能更好的存储引擎,而是一套更容易落地、更容易运维、也更适合长期承载核心业务的基础能力。
归根结底,MARS3 在 AP 核心 mixed workload 下的价值可以归纳为三点:
统一能力:用一套存储体系同时承接实时写入、分析扫描与明细回查,减少多套系统拼装带来的复杂度。
稳定能力:在持续写入、持续查询和后台治理并存的情况下,仍然保持性能与行为可预测,而不是“只在理想场景下快”。
落地能力:不仅追求性能指标,更注重正确性、可观测性与运维闭环,让系统真正具备长期上线运行的条件。
这正是 MARS3 的核心价值:它不是单纯提升某一项能力,而是帮助客户在最常见、最复杂的混合负载场景中,用更少的系统割裂、更低的总体成本和更高的长期稳定性,获得真正可持续的业务支撑能力。
BRIN 索引支持 multi-minmax
MARS3 支持 GIN 索引,很多现场遇到 like % 这类情况,只能用 HEAP + BTREE
可观测性进一步提升,目前关于 pick、compact 等都是记录在日志中的,需要去对应的 QE 上找,并且有些是面向内核开发者的,对于 field 不友好,希望有一个比如系统表类似gp_segment_configuration 记录历史 compact 信息,比如 compact 花费多少时间、merge 了多少 run 等等
期望 matrixts 和 mxnumeric 都放在 template1 ,然后设置 numeric 使用 mxnumeric,default_table_access_method = mars3 的问题就迎刃而解了,不会因为新的库没 matrixts 插件导致循环死锁,建不出来 extension 了,更何况 APM 啥的也是集成在 matrixts 插件,客户用 APM 还是要 create 这个插件
将 single 设为默认的写入模式
不过会有些情况下存在写放大的问题,所以等 Auto probe 搞好以后,可以默认加载模式设置成 single
mxnumeric 与 MARS3
PostgreSQL 的 numeric 类型 (以下简称 pg numeric) 虽然精度远高于其它数据库中的同名类型,但其实现更为复杂,性能也相对较差。pg numeric 难以实现向量化,导致在向量化执行器中不仅没有性能提升,反而因采用兼容机制运行,性能甚至低于非向量化执行器。与经过向量化加速的 int/float 等类型相比,pg numeric 的性能慢约 1-2 个数量级。为了提高 numeric 类型的性能,实现了一个有限精度的 numeric 类型 (以下简称 mxnumeric),最大支持 38 位精度,并通过 mxnumeric 扩展提供。
因为 mars3 和 mxnumeric 都是扩展,不能保证 mars3 的安装顺序一定在 mxnumeric 之前,所以默认 mxnumeric 类型是不支持 mars3_btree 和 mars3_brin 索引类型的。
如果需要使用 mars3_btree 和 mars3_brin 索引类型,可以使用 mxnumeric.set_config('mars3', true) 来手动创建 operator class,以支持 mxnumeric 类型使用 mars3_btree 和 mars3_brin 索引类型。不过必须保证 matrixts 插件已经创建,否则会提示错误
ERROR: data type numeric has no default operator class for access method "mars3_btree" HINT: You must specify an operator class or define a default operator class for the data type.
并且可以使用 mxnumeric.set_config('mars3', false) 来删除 operator class,恢复到默认状态。
术语表
Run:MARS3 中一段按排序键有序的数据集合,是存储与后台治理(合并/转列/回收)的基本单位。
Level:按层级组织 Run 的结构,低层更偏写入与快速落盘,高层更偏读优化与数据规整。
Delta:为支持更新/删除而追加记录的增量变化信息 (新版本/标记/差异),需通过后台治理逐步收敛回收。
MVCC:多版本并发控制机制,用版本可见性判断哪条数据对当前事务可见,并支持并发读写一致性。
先行后列:数据先以更适合写入/新鲜访问的形态进入系统,再在后台逐步转为更适合扫描与压缩的列式形态的生命周期策略。
行存 (RowStore):按行组织数据的物理布局,适合点查/明细回查与小范围读取,但分析扫描可能产生较多无效 I/O。
列存 (ColumnStore):按列组织数据的物理布局,适合大范围扫描与聚合、压缩效率高,但对高频小批次写入更敏感(维护/写放大更显著)。
排序键 (Sort Key / ORDER BY):决定数据在 Run 内的有序方式,是影响跳读效果、扫描效率与治理成本的关键设计。
数据局部性 (Locality):相近键值的数据在物理上尽量相邻,从而让范围查询更容易集中访问、减少扫描范围。
跳读 (Data Skipping):利用块级元数据 (如 min/max、BRIN 等) 在读取时跳过不可能命中的数据块,实现“少读”。
块级元数据 (Block Metadata):用于跳读与过滤的统计信息,例如块的最小/最大值、行数、可见性信息等。
BRIN:一种基于块范围摘要 (range summary) 的索引/元数据机制,用较低成本提供范围剪枝能力,常用于大表范围过滤。
default_brin:MARS3 针对典型工作负载默认启用/维护的一套 BRIN/跳读元数据策略,用于降低扫描范围与成本。
选择率 (Selectivity):谓词过滤后预计保留的数据比例;在 BRIN 场景下常对应“命中多少 range/页”的比例。
读放大 (Read Amplification):为了读到需要的数据,实际读取的数据量/对象数大于逻辑需求的倍数(例如需要跨多个 Run/版本查找)。
写放大 (Write Amplification):一次逻辑写入导致更多的物理写入(例如元数据维护、合并重写、转列等)产生的倍数效应。
合并 (Compaction):后台将多个 Run 整理/合并为更规整的数据形态,以降低读放大、回收无效版本并改善跳读与压缩效果。
合并债务 (Compaction Debt):尚未完成的后台合并工作量(治理缺口)的度量,债务累积通常会导致读路径变长或写入抖动。
转储 (Flush / Dump):将内存中的增量数据按顺序落盘,形成可持久化的 Run 的过程。
转列 (Row-to-Column / Columnization):将数据从更写友好的形态转换为列式布局,以提升扫描吞吐与压缩效率。
回收 (GC / Vacuum-like Reclaim):清理不再可见的版本/标记并回收空间的过程,通常与合并/整理协同完成。
Unique Mode:一种按唯一键(由排序键定义)插入即更新的模式,同键写入生成新版本,适合最新态/快照型数据模型。
shared_buffers:PostgreSQL 传统缓冲池,是否参与某类读路径取决于存储引擎的实现与访问方式。
varbuffer (mx_varbuffer_size_mb):MARS3 为特定访问路径(常见于索引相关访问)提供的专用缓存,用于降低重复读取与尾部延迟。
参数说明
adw=# select name,setting from pg_settings where name like '%mars3%';
name | setting
----------------------------------------+-------------------
mars3.allow_alter_rewrite | off
mars3.append_sync | off
mars3.archive_dontvacuum | off
mars3.autoprobe_period | 0
mars3.autoprobe_retry | 2
mars3.autoprobe_workers | 2
mars3.debug_block_skip | off
mars3.debug_btree_bloomfilter | off
mars3.debug_btree_build_summary | off
mars3.debug_btree_minmax | off
mars3.debug_clean_ignore_successor | off
mars3.debug_columnstripereader | off
mars3.debug_indexrollback | off
mars3.debug_logicdecode | off
mars3.debug_thread_insert | off
mars3.debug_uniquemode_sortkey | on
mars3.debug_update_chain | off
mars3.debug_use_deltachain | off
mars3.default_btree_options |
mars3.default_storage_options | compresstype=none
mars3.disable_physical_tlist | on
mars3.enable_autofreeze | off
mars3.enable_block_sample | off
mars3.enable_block_skip | on
mars3.enable_btree_bloomfilter | on
mars3.enable_btree_minmax | on
mars3.enable_inorderscan | on
mars3.enable_post_customscan_vectorize | on
mars3.force_allocate | off
mars3.freeze_in_compact | on
mars3.inplace_freeze_columnstore | off
mars3.mars3_autoprobe_blacklist_size | 1000
mars3.max_insert_threads | 2
mars3.punish_inorderscan | 1.15
mars3.test_print_index_info | off
mars3.trace_run_life | on
mars3.update_modcount | off
mars3.verify_rangefile | on
mars3_auto_analyze_projection | on
mars3_brin_buildsleep | 0
mars3_orderkey_contain_partkey | on
optimizer_enable_mars3_indexscan | on
(42 rows)
注意:
debug/test 开头的 GUC ,生产基本也不要碰,调试用的
划线的 是已经弃用的参数
| 参数名称 | 当前设置 | 含义 |
|---|---|---|
| mars3.allow_alter_rewrite | off | 允许由 ALTER 操作触发的表重写。当修改表的存储选项会导致表重写时,需要显式启用此参数来确认操作。 |
| mars3.append_sync | off | 强制启用追加同步。控制 mars3 在追加数据时的同步行为。 |
| mars3.archive_dontvacuum | off | 归档后不执行 vacuum 操作,便于调试分析。 |
| mars3.autoprobe_period | 0 | 自动探测压缩任务的间隔时间(秒)。设置为 0 表示禁用自动探测功能。 |
| mars3.autoprobe_retry | 2 | 失败任务的重试次数。设置为 0 表示禁用重试。 |
| mars3.autoprobe_workers | 2 | 执行自动探测任务的工作线程数量(范围: 1-16)。 |
| mars3.debug_block_skip | off | 输出 block skip 的调试信息,用于调试跳过块的优化逻辑。 |
| mars3.debug_btree_bloomfilter | off | 输出 B 树布隆过滤器的调试信息。 |
| mars3.debug_btree_build_summary | off | 输出 B 树构建摘要的调试信息。 |
| mars3.debug_btree_minmax | off | 输出 B 树最小/最大值边界检查的调试信息。 |
| mars3.debug_clean_ignore_successor | off | 调试清理操作时忽略后继节点的逻辑。 |
| mars3.debug_columnstripereader | off | 调试 ColumnStripeReader 的读取行为。 |
| mars3.debug_index_rollback | off | 显示索引回滚的调试信息,用于分析 index rollback 相关问题。 |
| mars3.debug_logicdecode | off | 调试逻辑解码功能。 |
| mars3.debug_thread_insert | off | 调试多线程插入功能。 |
| mars3.debug_uniquemode_sortkey | on | 调试唯一模式下的排序键扫描。 |
| mars3.debug_update_chain | off | 调试更新链(update-chain)逻辑,用于分析 update-chain 相关问题。 |
| mars3.debug_use_deletechain | off | 调试删除操作使用增量链的逻辑。 |
| mars3.default_btree_options | (空) | 设置 mars3 存储 B 树索引的默认选项(如 compresstype、compresslevel、fillfactor、minmax、compressstd 等)。 |
| mars3.default_storage_options | compresstype=none | 设置 mars3 存储的默认选项(如 compresstype、compresslevel、mars3options、encodekind、uniquemode 等)。 |
| mars3.disable_physical_tlist | on | 禁止为 mars3 使用物理 tlist(目标列表)优化。 |
| mars3.enable_autofreeze | off | mars3 的 autovacuum 是否自动执行 autofreeze 操作。 |
| mars3.enable_block_sample | off | mars3 使用块采样方式进行分析统计。 |
| mars3.enable_block_skip | on | mars3 启用基于 BRIN 信息的块跳过优化。默认 brin 可以在 SeqScan 时跳过不相关的块,可以通过此参数禁用该优化。 |
| mars3.enable_btree_bloomfilter | on | 启用 B 树布隆过滤器检查。 |
| mars3.enable_btree_minmax | on | 启用 B 树最小/最大值边界检查。 |
| mars3.enable_inorderscan | on | mars3 提供带排序键的顺序扫描路径。 |
| mars3.enable_post_customscan_vectori ze | on | 已弃用,当时为了支持 ORCA 产生的 bitmapscan 的向量化而加的 |
| mars3.force_allocate | off | 强制分配 segment 和 run slot,平时必须为 false,仅用于处理当 run 或 segment 达到上限无法写入数据时,启用预留的 slot 进行数据合并的紧急情况。 |
| mars3.freeze_in_compact | on | 在 compact 过程中执行 freeze 操作。 |
| mars3.inplace_freeze_columnstore | off | 启用列存的就地冻结(inplace freeze)功能。 |
| mars3.mars3_autoprobe_blacklist_size | 1000 | 自动探测黑名单的最大容量,即最多可以跳过多少个任务。 |
| mars3.max_insert_threads | 2 | 单条插入操作最大可用的线程数(范围: 0-6)。0 表示不启用多线程,默认值为 2。 |
| mars3.punish_inorderscan | 1.15 | 为 mars3 的 inorderscan 成本增加惩罚系数(范围: 1.0-10.0),影响优化器选择顺序扫描的概率。 |
| mars3.test_print_index_info | off | 仅用于测试,打印额外的索引信息。 |
| mars3.trace_run_life | on | 打印 mars3 内部 run 的生命周期信息,用于分析问题。默认值为 true。 |
| mars3.update_modcount | off | 启用 modcount(修改计数)的更新。 |
| mars3.verify_rangefile | on | 列存输出 rangefile 时进行数据校验。默认值为 true。 |
| mars3.auto_analyze_projection | on | 自动分析时仅分析 order by 列,减少分析开销。 |
| mars3_brin_buildsleep | 0 | 索引构建前的休眠时间(毫秒),范围 0-600。用于测试和调试。 |
| mars3_orderkey_contain_parkey | on | 分区 mars3 表必须在 orderkey 中包含分区键(parkey)。 |
| optimizer_enable_mars3_indexscan | on | ORCA 优化器启用 mars3 的索引扫描。 |
参考配置模板与示例 SQL
CREATE EXTENSION matrixts;
CREATE TABLE t(
time timestamp with time zone,
tag_id int,
i4 int4,
i8 int8
)
USING MARS3
WITH (compresstype=zstd, compresslevel=3,compress_threshold=1200,
mars3options='rowstore_size=64,prefer_load_mode=normal,level_size_amplifier=8')
DISTRIBUTED BY (tag_id)
ORDER BY (time, tag_id);
分区表
CREATE EXTENSION matrixts;
CREATE TABLE t(
time timestamp with time zone,
tag_id int,
i4 int4,
i8 int8
)
USING MARS3
WITH (compresstype=zstd, compresslevel=3,compress_threshold=1200,
mars3options='rowstore_size=64,prefer_load_mode=normal,level_size_amplifier=8')
DISTRIBUTED BY (tag_id)
PARTITION BY RANGE (time)
(
START ('2026-02-01 00:00:00+08')
END ('2026-03-01 00:00:00+08')
EVERY (INTERVAL '1 day')
)
ORDER BY (time, tag_id);
ORDER BY (time, tag_id);
lz4、zstd、zlib 三种通用压缩算法需要在建表时用 WITH 语句中实现,示例如下:
=# WITH (compresstype=zstd, compresslevel=3, compress_threshold=1200)
参数说明如下:
| 参数名 | 默认值 | 最小值 | 最大值 | 描述 |
|---|---|---|---|---|
| compress_threshold | 1200 | 1 | 8000 | 压缩阈值。用于控制单表多少元组(Tuple)进行一次压缩,是同一个单元中压缩的 Tuple 数上限 |
| compresstype | none | 压缩算法,支持zstd、zlib 和 lz4 | ||
| compresslevel | 0 | 1 | 压缩级别。值越小压缩越快,但压缩效果越差;值越大压缩越慢,但压缩效果更好。 不同算法有效值范围: 1. zstd: 1-19 2. zlib: 1-9 3. lz4: 1-20 |
当指定 compresstype 默认值,未指定 compresslevel 默认值时,compresslevel 默认值为 1。 当 compresslevel > 0,未指定 compresstype 默认值时,compresstype 默认值为 zlib。
以下参数用于调节 L0 层 Run 的大小,也可间接控制 L1 层之上的 Run 大小。
| 参数 | 单位 | 默认值 | 取值范围 | 描述 |
|---|---|---|---|---|
| rowstore_size | MB | 64 | 8 ~ 1024 | 用于控制 L0 Run 何时切换。当数据大小超过该值,将会切换下一个 Run |
以下参数用于指定数据在 MARS3 中的加载模式。
| 参数 | 默认值 | 取值范围 | 描述 |
|---|---|---|---|
| prefer_load_mode | normal | normal / bulk | 数据加载模式。 - normal:正常模式,新写入数据先写到 L0 层的行存 Run 中,积累到 rowstore_size 之后,落至 L1 层的列存 Run;相对于 bulk 模式多一次 I/O,列存转换由同步变为异步,适用于 I/O 能力充足且对延迟敏感的高频次小批量写入场景。- bulk:批量加载模式,适用于低频大批量写入场景,直接写至 L1 层的列存 Run;相对于 normal 模式减少一次 I/O,列存转换由异步变为同步,适用于 I/O 能力不足且对延迟不敏感的低频大批量数据写入场景。 |
以下参数用于指定 Level 尺寸的放大系数。
| 参数 | 默认值 | 取值范围 | 描述 |
|---|---|---|---|
| level_size_amplifier | 8 | 1 ~ 1000 | Level 尺寸的放大系数。Level 触发合并操作的阈值,计算方式为:rowstore_size * (level_size_amplifier ^ level)。其值越大,读速越慢,写速越快。可根据具体场景(写多读少/读多写少、压缩率等)决定具体值。 注意:确保每个 Level 的 run 数量不要过多,否则会影响查询性能,甚至阻止新数据插入。 |
6.7.0
MARS3 支持了在 drop/truncate table 时终止持锁阻塞的 compact 任务
pg_rewind 支持了 MARS3 表的增量修复
6.6.0
MARS3 支持了 DEFAULT BRIN
支持并发 alter table split partition,split 的同时支持并发的读和写 (暂时不支持 aoco 表,stream 流表,且父表带有 trigger 的情况)
MARS3 支持了线程插入 (目前只在 prefer_load_mode = normal/bulk 时且数据量超过 rowstore 大小时有效),不是所有的插入都会触发线程化插入,只有当单次插入超过 rowstore_size 时才会触发线程化插入,另外线程化插入也会导致内存使用变大
6.5.0
MARS3 放松了unique mode 创建 btree 索引时的顺序限制
MARS3 支持了 unique mode 场景下的 delete 操作
MARS3 支持了 btree inorder scan
MARS3 支持了增量 analyzedb 与增量 mxbackup
MARS3 新增提供 mars3.default_storage_options, mars3.default_btree_options 两个GUC,用于指定默认的 mars3 存储参数
6.4.1
新增 ORCA 支持 MARS3 索引
增强了 MARS3 的 analyze 逻辑,采用基于行的采样
新增了 MARS3 在分区表上的 drop index 的功能 (#ICGEOH)
6.4.0
MARS3 支持了 roi 索引
MARS3 支持了自动转换 btree/brin 到 mars3_btree/mars3_brin 索引
MARS3 分区表支持了 drop index