原文链接:https://dev.to/saulojb/storageengine-two-high-performance-table-access-methods-for-postgresql-analytics-and-htap-4hgp
标题:storage_engine: 面向 PostgreSQL 分析和 HTAP 工作负载的两种高性能表访问方法
作者:Saulo José Benvenutti
发布时间:4月16日
在使用了 PostgreSQL 12 版本引入的表访问方法(TAM)API 之后,我构建了 storage_engine------一个 PostgreSQL 扩展,它提供了两种作为一流访问方法(AM)的专业存储引擎:colcompress 用于列式分析,rowcompress 用于高负载追加压缩工作负载。两者可以在同一个数据库中共存,并且都可以在 PGXN 上获取。
本文面向正在评估 PostgreSQL 生态内(不离开生态系统)用于分析和 HTAP 工作负载的列式或压缩存储的 DBA 和数据库架构师。
血统与诚实归属
storage_engine 是 Hydra Columnar 的一个分支,而 Hydra Columnar 本身源于 citus_columnar------最初由 Citus Data(现为微软的一部分)构建。原始代码版权归 Citus Data / Hydra 所有,采用 AGPL-3.0 许可证。我在这些基础上进行了扩展,增加了 rowcompress、完整的 DELETE/UPDATE 支持、MergeTree 风格的排序、两级区域地图修剪以及重新设计的并行扫描。
从其他系统借鉴的想法已明确说明:
- ClickHouse MergeTree: 按表排序键和条带/块级区域地图修剪
- Apache Parquet: 行组统计信息、列投影、字典编码
- DuckDB: 列式批处理的向量化表达式求值
我提前说明这些,因为 PostgreSQL 社区重视透明度,这也有助于您理解哪些是新增功能,哪些是继承而来的。
为什么一个扩展提供两种访问方法?
大多数工作负载并非纯粹的分析型或纯粹的事务型。一个常见的 HTAP 模式包括:
- 批量更新、通过选择性列投影进行查询的宽事实表 →
colcompress - 高吞吐量仅追加的日志、审计跟踪或所有列一起读取的时间序列 →
rowcompress
在一个扩展中同时提供这两种 AM 意味着,只需一条 CREATE EXTENSION storage_engine 命令即可安装两者,所有目录对象都存放在 engine 模式中,并且 C 符号带有 se_ 前缀,以避免与您可能已加载的 citus_columnar 或任何其他列式扩展发生冲突。
sql
CREATE EXTENSION storage_engine;
-- 列式分析表
CREATE TABLE events (
ts timestamptz NOT NULL,
user_id bigint,
event_type text,
value float8
) USING colcompress;
-- 行式压缩日志表
CREATE TABLE audit_log (
id bigserial,
logged_at timestamptz NOT NULL,
message text
) USING rowcompress;
两个表与堆表共存于同一数据库中。它们与堆表之间的连接通过标准的 PostgreSQL 执行器正常工作。
colcompress: 支持向量化执行的列式存储
存储布局
数据按列存储。每列被划分为条带(默认 150,000 行),每个条带又被细分为块组(默认 10,000 行)。每个块记录其列的最小值和最大值:
表文件
├── 条带 1 (第 1 -- 150,000 行)
│ ├── 块组 0 (第 1 -- 10,000 行)
│ │ ├── 列 A [min, max, 压缩后的值...]
│ │ ├── 列 B [min, max, 压缩后的值...]
│ │ └── ...
│ └── 块组 1 (第 10,001 -- 20,000 行) ...
└── 条带 2 (第 150,001 -- 300,000 行) ...
一次扫描只读取查询引用的列。在一个 30 列表上,如果查询只涉及 4 列,I/O 减少约 87%。这是列式存储用于分析的根本优势。
压缩算法
每个块组独立压缩。可用算法:
| 名称 | 描述 |
|---|---|
| none | 无压缩 |
| lz4 | 快速压缩/解压,压缩比中等 |
| zstd | 高压缩比,可配置级别 1--19(默认 3) |
| pglz | PostgreSQL 内置的 LZ 变体 |
sql
SELECT engine.alter_colcompress_table_set('events'::regclass,
compression => 'zstd',
compression_level => 9);
两级区域地图修剪
这对于管理大型时间序列或事件表的 DBA 来说,是最具影响力的特性之一。
-
条带级修剪(粗粒度) --- 在读取任何数据之前,扫描会聚合每个条带所有块的最小/最大值,并使用 PostgreSQL 的
predicate_refuted_by将得到的条带级范围与查询的 WHERE 谓词进行测试。被证明与谓词不相交的条带将被完全跳过------不解压,无 I/O。EXPLAIN会直接报告:Custom Scan (ColumnarScan) on events Engine Stripes Removed by Pruning: 41 Engine Stripes Read: 12 -
块级修剪(细粒度) --- 在每个通过粗粒度筛选的条带内,各个块组会针对相同的谓词进行测试。那些最小/最大范围无法满足谓词的块组将被跳过。
这两层协同工作。在一个大型、良好排序的表上,一个日期范围查询会在接触整个条带之前就将其排除,然后在幸存者中进一步修剪块组。实际效果是,无需维护 B 树,即可获得与索引扫描相当的 I/O 放大效应。
修剪的有效性取决于数据的排序程度。这就引出了受 MergeTree 启发的排序机制。
MergeTree 风格排序
sql
-- 定义全局排序键
SELECT engine.alter_colcompress_table_set(
'events'::regclass,
orderby => 'ts ASC, user_id ASC'
);
-- 批量加载后,进行压缩和全局排序
SELECT engine.colcompress_merge('events');
colcompress_merge 将所有存活行复制到一个临时堆表,清空目标表,然后按定义的顺序重新插入行------写入全新的、全局排序的条带。在此之后,对于典型的时间序列数据,WHERE ts BETWEEN x AND y 将跳过几乎所有块。
DBA 注意事项 :colcompress_merge 在操作期间会持有 AccessExclusiveLock。对于大表,请安排在维护窗口期间进行。没有在线/并发模式。
向量化执行
colcompress 自带一个向量化表达式求值引擎,每次调用以最多 10,000 个值的列式批次处理 WHERE 子句和聚合。这消除了每行的解释器开销,并能自然地映射到列块上。
支持的向量化操作:
| 类别 | 类型 |
|---|---|
| 比较运算符 (=, <>, <, <=, >, >=) | int2, int4, int8, float4, float8, date, timestamp, timestamptz, char, bpchar, text, varchar, name, bool, oid |
| 聚合函数 (count, sum, avg, max, min) | int2, int4, int8, float8, date, timestamptz |
这两个特性默认开启,可以按会话切换:
sql
SET storage_engine.enable_vectorization = on;
SET storage_engine.enable_column_cache = on;
并行扫描
该 AM 通过动态共享内存 (DSM) 实现了完整的 PostgreSQL 并行表 AM 协议。协调器将条带范围划分给工作进程;每个工作进程独立读取和解压其分配的条带。并行扫描构建在向量化执行之上------每个工作进程运行自己的向量化管道。
sql
SET storage_engine.enable_parallel_execution = on;
SET storage_engine.min_parallel_processes = 8;
-- 标准的 PostgreSQL 并行控制参数同样适用
SET max_parallel_workers_per_gather = 4;
并行扫描与条带修剪(v1.0.6+):在顺序扫描和并行扫描路径中,条带修剪均处于活动状态。协调器在将条带 ID 分发给工作进程之前,会针对查询谓词对其进行预过滤,因此每个工作进程只接收到那些通过最小/最大测试的条带------并行模式下无 I/O 浪费。基准测试证实了这一点:Q5(日期范围,1个月)串行达到 22.4ms,并行达到 28.2ms------两者在修剪中都消除了 7 个条带中的 6 个。
DELETE、UPDATE 和 Upsert
colcompress 通过存储在 engine.row_mask 中的行掩码实现了 DELETE 和 UPDATE。每个被删除的行被记录为每个块组位掩码中的一个位;扫描引擎会跳过被掩码标记的行,而无需重写条带。UPDATE 是删除后插入。在 VACUUM 期间回收已删除的行,这会重写受影响的条带并清除掩码。
sql
SET storage_engine.enable_dml = on; -- 默认: on
DELETE FROM events WHERE ts < now() - interval '1 year';
UPDATE events SET value = value * 1.1 WHERE event_type = 'purchase';
-- 标准 upsert 也有效,需要在冲突目标上有唯一索引
INSERT INTO events (ts, user_id, event_type, value)
VALUES (now(), 42, 'click', 1.0)
ON CONFLICT (user_id, event_type) DO UPDATE SET value = EXCLUDED.value;
索引支持扫描
对于文档库------存储需要列式压缩但通过主键获取的 XML、PDF、JSON 大对象的表------可以使用索引扫描路径:
sql
-- 按表启用(跨连接持久化)
SELECT engine.alter_colcompress_table_set('documents'::regclass, index_scan => true);
-- 或者按会话为所有 colcompress 表启用
SET storage_engine.enable_columnar_index_scan = on;
对于文档存储用例,zstd 压缩(大型二进制/文本文档压缩比 3-10 倍)与索引驱动的点查找相结合是很有吸引力的。
不要在分析表上被 orderby 键覆盖的列上创建 B 树索引。PostgreSQL 规划器可能更倾向于对范围查询使用 IndexScan,这会完全绕过条带修剪(randomAccess=true)。对于 JSONB 和数组列,请使用 GIN 索引,并依赖条带修剪来处理范围谓词。
rowcompress: 批量压缩的行存储
rowcompress 将行存储在固定大小的批次中(默认每批 10,000 行)。每个批次使用堆元组格式序列化,并作为一个整体单元进行压缩。批次元数据------文件偏移、字节大小、起始行号、行数------存储在 engine.row_batch 中。
该 AM 适用于追加大型工作负载,其中压缩很重要但不需要列投影:事件日志、审计跟踪、许多或所有列一起查询的时间序列表。使用 zstd 的典型存储节省为 2-10 倍。
与 colcompress 相比:
- 读取完整行(无列投影)
- 每行写入延迟更低(写入时无需列转置)
- 无向量化执行或块级修剪
- 通过原子批次认领实现并行读取(工作进程自调度,协调器开销为零)
- 完整的压缩算法支持
sql
SELECT engine.alter_rowcompress_table_set(
'audit_log'::regclass,
batch_size => 10000,
compression => 'zstd',
compression_level => 5
);
-- 更改压缩选项后重写所有批次
SELECT engine.rowcompress_repack('audit_log');
基准测试
基准测试环境 :1,000,000 行,PostgreSQL 18.3,AMD Ryzen 7 5800H(8 核),40 GB RAM,shared_buffers=10GB。colcompress 配置为 lz4 压缩和 orderby = 'event_date ASC'(通过 colcompress_merge 全局排序)。结果为 3 次运行的中位数。
串行(JIT=off, max_parallel_workers_per_gather=0)
| 查询 | heap | colcompress | rowcompress | citus_columnar |
|---|---|---|---|---|
| Q1 count(*) | 39.8ms | 43.0ms | 313ms | 36.6ms |
| Q2 SUM/AVG numeric + double | 188.6ms | 117.4ms | 358ms | 122.9ms |
| Q3 GROUP BY country (10 vals) | 219.0ms | 162.0ms | 395ms | 139.4ms |
| Q4 GROUP BY event_type + p95 | 538.9ms | 448.4ms | 685ms | 469.7ms |
| Q5 date range 1 month | 20.8ms | 22.4ms | 59.1ms | 20.6ms |
| Q6 JSONB @> GIN | 123.1ms | 162.2ms | 326ms | 238.1ms |
| Q7 JSONB key + GROUP BY | 388.5ms | 310.3ms | 550ms | 358.2ms |
| Q8 array @> GIN | 63.0ms | 122.7ms | 274ms | 140.9ms |
| Q9 LIKE text scan | 150.9ms | 90.9ms | 338ms | 89.9ms |
| Q10 heavy multi-agg | 1953ms | 1939ms | 2109ms | 1925ms |
- Q5 在
colcompress上实现了与堆表相当的性能(22.4ms 对 20.8ms),因为条带修剪消除了 7 个条带中的 6 个------数据通过orderby按event_date物理排序。在修剪后的数据上,lz4 解压增加的 overhead 可以忽略不计。 - Q6 和 Q8(JSONB/数组上的 GIN 索引)受益于 GIN 索引,不需要条带修剪。这是预期行为。
并行(JIT=on, max_parallel_workers_per_gather=16)
| 查询 | heap | colcompress | rowcompress | citus_columnar |
|---|---|---|---|---|
| Q1 count(*) | 18.3ms | 16.4ms | 148ms | 37.9ms |
| Q2 SUM/AVG numeric + double | 53.5ms | 29.7ms | 166ms | 121.5ms |
| Q3 GROUP BY country | 61.6ms | 166ms | 161ms | 143ms |
| Q4 GROUP BY event_type + p95 | 540ms | 316ms | 674ms | 470ms |
| Q5 date range 1 month | 21.4ms | 28.2ms | 73.3ms | 21.1ms |
| Q6 JSONB @> GIN | 84.3ms | 40.4ms | 490ms | 245ms |
| Q7 JSONB key + GROUP BY | 392ms | 65.7ms | 687ms | 362ms |
| Q8 array @> GIN | 61.6ms | 32.7ms | 273ms | 146ms |
| Q9 LIKE text scan | 48.7ms | 25.4ms | 157ms | 91.7ms |
| Q10 heavy multi-agg | 1903ms | 641ms | 2085ms | 1920ms |
- Q5 在
colcompress上(28.2ms)现在与串行结果(22.4ms)和堆表(21.4ms)相当。从 v1.0.6 开始,规划器正确地应用disable_cost到并行索引路径,确保在index_scan=false时,带有条带修剪的Parallel Custom Scan (ColcompressScan)优先于Parallel Index Scan。
完整的基准测试套件位于 tests/bench/。有关环境详情和复现步骤,请参见 BENCHMARKS.md。
面向 DBA 的已知限制
这些是与生产部署决策最相关的限制:
- 无
AFTER ROW触发器或外键 。这是列式存储的架构限制。这也意味着pg_repack无法使用------它在内部依赖AFTER ROW触发器。请使用engine.colcompress_repack()作为替代。 - 无
VACUUM FULL/ 表重写 。请改用engine.colcompress_repack()/engine.rowcompress_repack()。 - 无
CLUSTER支持 。请使用带有orderby选项的engine.colcompress_merge()来实现等效的物理排序。 - 不支持
unlogged表 。不支持CREATE UNLOGGED TABLE ... USING colcompress。 - 当存在 B 树索引时,写入时排序被禁用 。批量加载后运行
engine.colcompress_merge()以重新建立全局排序顺序。 colcompress_repack不是在线的 。它在整个过程中持有AccessExclusiveLock。请在维护窗口期间安排。- 支持
AFTER STATEMENT触发器 。仅禁止行级(FOR EACH ROW)的AFTER触发器。
管理参考
| 函数 | 描述 |
|---|---|
engine.alter_colcompress_table_set(regclass, ...) |
设置 colcompress 表的选项 |
engine.alter_colcompress_table_reset(regclass, ...) |
将 colcompress 选项重置为系统默认值 |
engine.colcompress_merge(regclass) |
按 orderby 键重写并全局排序 colcompress 表 |
engine.colcompress_repack(regclass) |
colcompress_merge 的别名;作为 pg_repack 的替代品 |
engine.alter_rowcompress_table_set(regclass, ...) |
设置 rowcompress 表的选项 |
engine.alter_rowcompress_table_reset(regclass, ...) |
将 rowcompress 选项重置为系统默认值 |
engine.rowcompress_repack(regclass) |
使用当前选项重写所有批次 |
用于检查的目录视图:
| 视图 | 描述 |
|---|---|
engine.colcompress_options |
所有 colcompress 表的按表选项 |
engine.colcompress_stripes |
按表的条带级元数据(偏移、大小、行范围) |
engine.rowcompress_options |
所有 rowcompress 表的按表选项 |
engine.rowcompress_batches |
所有 rowcompress 表的批次级元数据 |
所有视图都授予 PUBLIC SELECT 权限。
安装
从源代码构建
需要 PostgreSQL 服务器头文件和 pg_config 在 PATH 中。支持 PostgreSQL 13--18。
bash
cd dist/
sudo make -j$(nproc) install
添加到 postgresql.conf:
ini
shared_preload_libraries = 'storage_engine'
如果 citus 或 pg_cron 也在 shared_preload_libraries 中,加载顺序很重要------citus 必须出现在 storage_engine 之前:
ini
shared_preload_libraries = 'pg_cron,citus,storage_engine'
然后:
sql
CREATE EXTENSION storage_engine;
许可证
AGPL-3.0。这对于分发修改版本或将扩展嵌入到 SaaS 产品中的组织很重要------请相应地审查许可条款。
链接
- GitHub:
https://github.com/saulojb/storage_engine - PGXN:
https://pgxn.org/(搜索:storage_engine) - BENCHMARKS.md:
https://github.com/saulojb/storage_engine/blob/main/BENCHMARKS.md - Hydra Columnar (上游):
https://github.com/hydradatabase/hydra
欢迎在 GitHub 上提供反馈、问题和拉取请求。