排序键与数据局部性

在 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 <= '2025-11-16 23:59:59.000'
GROUP BY
    bucket_time;

结果表明不同的排序键会对索引扫描性能产生显著影响,其作用原理类似于复合索引的工作机制。

图片

Order Key Index MxVScan MxVIndexScan
id,type,time id,type,time 9s 100ms
id,time id,time 9s 100ms
time,id,station,type id,type,station,time 14s 6.8s
id,station,type,time id,station,type,time 9s 100ms

当时间字段被置于首位时,相同 ID 的元组会随时间推移分散存储在存储空间中。因此,索引扫描在获取对应数据块时必须执行大量随机读取操作。 然而,当 ID 置于首位时,所有属于同一 ID 的元组都会存储在相邻位置。这极大地减少了随机 I/O 操作,因为索引扫描只需读取少量连续的块。

排序键对于写入性能的影响

写入的数据在 Rowstore 向 Columnstore 转换的时候进行排序,Rowstore 本身是不排序的,如果 Rowstore 上有 BTREE 索引的话,那么 BTREE 索引也是有序的。只要指定排序键,那么:

  • 需要计算 key (取列值、处理 NULL、可能的类型转换/排序规则)
  • 需要做 key 比较(排序/归并/插入到有序结构)
  • 键列越多、类型越复杂、比较越频繁

不指定排序键通常可以少掉这部分,因此写入常更轻;但是排序键也会改变落盘数据的天然规整程度:

  • 排序匹配数据到达模式 → 形成更规整的 run / 更少的后续重写 → 写入长期更稳
  • 排序不匹配 → 后台资源消耗更大 (合并更多/更重) → 反过来抢 I/O、抢 CPU → 前台写入变慢
CREATE TABLE t_w0_nosort (
  id      bigint      NOT NULL,
  k1      bigint      NOT NULL,     -- 高基数
  k2      smallint    NOT NULL,     -- 低基数
  k3      bigint      NOT NULL,     -- 单调列(用 bigint 模拟递增)
  v1      double precision NOT NULL,
  v2      double precision NOT NULL,
  payload text        NOT NULL      -- 控制写入体量:建议 256B/1024B
) USING mars3;

CREATE TABLE t_w1_1key (LIKE t_w0_nosort INCLUDING ALL)
USING mars3
ORDER BY (k3);

CREATE TABLE t_w3a_3key (LIKE t_w0_nosort INCLUDING ALL)
USING mars3
ORDER BY (k1, k3, k2);

CREATE TABLE t_w3b_3key (LIKE t_w0_nosort INCLUDING ALL)
USING mars3
ORDER BY (k3, k1, k2);

构建中间数据集

DROP TABLE IF EXISTS t_src_s;
CREATE TABLE t_src_s USING MARS3 AS
SELECT
  g AS id,
  (hashint8(g)::bigint) AS k1,
  (g % 64)::smallint AS k2,
  g AS k3,
  (g % 1000) * 0.01 AS v1,
  (g % 10000) * 0.001 AS v2,
  repeat('x', 256) AS payload
FROM generate_series(1, 200000000) g;

TRUNCATE t_w0_nosort;
\timing on
INSERT INTO t_w0_nosort SELECT * FROM t_src_s;
\timing off

TRUNCATE t_w1_1key;
\timing on
INSERT INTO t_w1_1key SELECT * FROM t_src_s;
\timing off

TRUNCATE t_w3a_3key;
\timing on
INSERT INTO t_w3a_3key SELECT * FROM t_src_s;
\timing off

TRUNCATE t_w3b_3key;
\timing on
INSERT INTO t_w3b_3key SELECT * FROM t_src_s;
\timing off

每做完一次测试,重启一下数据库,对比写入时间

adw=# TRUNCATE t_w0_nosort;                                                                                                                                                                                                                                 
TRUNCATE TABLE                                                                                                                                                                                                                                              
adw=# \timing on                                                                                                                                                                                                                                            
Timing is on.                                                                                                                                                                                                                                               
adw=# INSERT INTO t_w0_nosort SELECT * FROM t_src_s;                                                                                                                                                                                                        
INSERT 0 200000000                                                                                                                                                                                                                                          
Time: 76859.371 ms (01:16.859)                                                                                                                                                                                                                              
adw=# \timing off                                                                                                                                                                                                                                           
Timing is off.   

adw=# TRUNCATE t_w1_1key;                                                                                                                                                                                                                                   
TRUNCATE TABLE                                                                                                                                                                                                                                              
adw=# \timing on                                                                                                                                                                                                                                            
Timing is on.                                                                                                                                                                                                                                               
adw=# INSERT INTO t_w1_1key SELECT * FROM t_src_s;                                                                                                                                                                                                          
INSERT 0 200000000                                                                                                                                                                                                                                          
Time: 82864.008 ms (01:22.864)                                                                                                                                                                                                                              
adw=# \timing off                                                                                                                                                                                                                                           
Timing is off. 

adw=# TRUNCATE t_w3a_3key;                                                                                                                                                                                                                                  
TRUNCATE TABLE                                                                                                                                                                                                                                              
adw=# \timing on                                                                                                                                                                                                                                            
Timing is on.                                                                                                                                                                                                                                               
adw=# INSERT INTO t_w3a_3key SELECT * FROM t_src_s;                                                                                                                                                                                                         
INSERT 0 200000000                                                                                                                                                                                                                                          
Time: 106929.500 ms (01:46.930)                                                                                                                                                                                                                             
adw=# \timing off                                                                                                                                                                                                                                           
Timing is off.  

adw=# TRUNCATE t_w3b_3key;                                                                                                                                                                                                                                  
TRUNCATE TABLE                                                                                                                                                                                                                                              
adw=# \timing on                                                                                                                                                                                                                                            
Timing is on.                                                                                                                                                                                                                                               
adw=# INSERT INTO t_w3b_3key SELECT * FROM t_src_s;                                                                                                                                                                                                         
INSERT 0 200000000                                                                                                                                                                                                                                          
Time: 83456.346 ms (01:23.456)                                                                                                                                                                                                                              
adw=# \timing off 

写入时间

  • t_w0_nosort (不排序):76.859 s
  • t_w1_1key (ORDER BY k3):82.864 s
  • t_w3a_3key (ORDER BY k1,k3,k2):106.930 s
  • t_w3b_3key (ORDER BY k3,k1,k2):83.456 s

吞吐:

  • t_w0_nosort:200,000,000 / 76.859 ≈ 2.60M rows/s
  • t_w1_1key:200,000,000 / 82.864 ≈ 2.41M rows/s
  • t_w3a_3key:200,000,000 / 106.930 ≈ 1.87M rows/s
  • t_w3b_3key:200,000,000 / 83.456 ≈ 2.40M rows/s

相对不排序的开销:

  • 单列排序 (k3):慢 ~7.8%
  • 三列排序但 k1 在前 (k1,k3,k2):慢 ~39%
  • 三列排序但 k3 在前 (k3,k1,k2):慢 ~8.6% (几乎和单列排序同一档)

把单调列 k3 放在排序键第一列几乎能把多列排序的写入代价压到接近单列排序。 把高基数随机列 k1 放第一列会让写入代价显著上升。

  1. 为什么不排序最快?

少做排序/比较/维护有序性这件事 → 前台写入代价最低,所以它给出基线吞吐 (2.60M rows/s)。

  1. 为什么 ORDER BY (k3) 只慢一点点?

输入流本来就按 k3 递增,排序键与输入顺序一致 → 大部分时候顺着写,只多一点元数据维护开销,所以只慢 ~8%。

  1. 为什么 ORDER BY (k1,k3,k2) 慢很多?

因为排序比较先看 k1,而 k1 是高基数近似随机列 ——这 会把整个写入流在 key 空间里打散:

  • 很难走顺序追加/局部有序的快路径
  • 比较次数显著增加 (多列比较 + 第一列几乎总要比较出结果)
  • 更容易触发更重的数据组织工作 (缓冲/归并/内部结构维护) 所以吞吐掉到 1.87M rows/s (慢 39%) 合理。
  1. 为什么 ORDER BY (k3,k1,k2) 差不多是单列排序水平?

因为第一排序列 k3 和输入流一致,系统能最大化利用天然有序:

  • 大量写入在 k3 上是顺的
  • k1/k2 只有在 k3 相同或很接近时才参与更深比较 (实际比较/重排压力小得多)

所以它几乎等于 ORDER BY (k3) 的性能 (2.40M vs 2.41M rows/s)。

adw=# \dt+                                                                                                                                                                                                                                                  
                            List of relations                                                                                                                                                                                                               
 Schema |    Name     | Type  |  Owner  | Storage |  Size   | Description                                                                                                                                                                                   
--------+-------------+-------+---------+---------+---------+-------------                                                                                                                                                                                  
 public | t_default   | table | mxadmin | mars3   | 160 kB  |                                                                                                                                                                                               
 public | t_sort_bad  | table | mxadmin | mars3   | 103 MB  |                                                                                                                                                                                               
 public | t_sort_good | table | mxadmin | mars3   | 35 MB   |                                                                                                                                                                                               
 public | t_src_s     | table | mxadmin | mars3   | 3040 MB |                                                                                                                                                                                               
 public | t_w0_nosort | table | mxadmin | mars3   | 2769 MB |                                                                                                                                                                                               
 public | t_w1_1key   | table | mxadmin | mars3   | 2764 MB |                                                                                                                                                                                               
 public | t_w3a_3key  | table | mxadmin | mars3   | 3453 MB |                                                                                                                                                                                               
 public | t_w3b_3key  | table | mxadmin | mars3   | 2764 MB |                                                                                                                                                                                               
 public | testmars3   | table | mxadmin | mars3   | 160 kB  |                                                                                                                                                                                               
(9 rows)              

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