原文链接: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或任何其他列式扩展发生冲突。
CREATEEXTENSION storage_engine;-- 列式分析表CREATETABLEevents(ts timestamptzNOTNULL,user_idbigint,event_typetext,valuefloat8)USINGcolcompress;-- 行式压缩日志表CREATETABLEaudit_log(id bigserial,logged_at timestamptzNOTNULL,messagetext)USINGrowcompress;两个表与堆表共存于同一数据库中。它们与堆表之间的连接通过标准的 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 变体 |
SELECTengine.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 风格排序
-- 定义全局排序键SELECTengine.alter_colcompress_table_set('events'::regclass,orderby=>'ts ASC, user_id ASC');-- 批量加载后,进行压缩和全局排序SELECTengine.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 |
这两个特性默认开启,可以按会话切换:
SETstorage_engine.enable_vectorization=on;SETstorage_engine.enable_column_cache=on;并行扫描
该 AM 通过动态共享内存 (DSM) 实现了完整的 PostgreSQL 并行表 AM 协议。协调器将条带范围划分给工作进程;每个工作进程独立读取和解压其分配的条带。并行扫描构建在向量化执行之上——每个工作进程运行自己的向量化管道。
SETstorage_engine.enable_parallel_execution=on;SETstorage_engine.min_parallel_processes=8;-- 标准的 PostgreSQL 并行控制参数同样适用SETmax_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期间回收已删除的行,这会重写受影响的条带并清除掩码。
SETstorage_engine.enable_dml=on;-- 默认: onDELETEFROMeventsWHEREts<now()-interval'1 year';UPDATEeventsSETvalue=value*1.1WHEREevent_type='purchase';-- 标准 upsert 也有效,需要在冲突目标上有唯一索引INSERTINTOevents(ts,user_id,event_type,value)VALUES(now(),42,'click',1.0)ONCONFLICT(user_id,event_type)DOUPDATESETvalue=EXCLUDED.value;索引支持扫描
对于文档库——存储需要列式压缩但通过主键获取的 XML、PDF、JSON 大对象的表——可以使用索引扫描路径:
-- 按表启用(跨连接持久化)SELECTengine.alter_colcompress_table_set('documents'::regclass,index_scan=>true);-- 或者按会话为所有 colcompress 表启用SETstorage_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相比:
- 读取完整行(无列投影)
- 每行写入延迟更低(写入时无需列转置)
- 无向量化执行或块级修剪
- 通过原子批次认领实现并行读取(工作进程自调度,协调器开销为零)
- 完整的压缩算法支持
SELECTengine.alter_rowcompress_table_set('audit_log'::regclass,batch_size=>10000,compression=>'zstd',compression_level=>5);-- 更改压缩选项后重写所有批次SELECTengine.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表的批次级元数据 |
所有视图都授予PUBLICSELECT权限。
安装
从源代码构建
需要 PostgreSQL 服务器头文件和pg_config在 PATH 中。支持 PostgreSQL 13–18。
cddist/sudomake-j$(nproc)install添加到postgresql.conf:
shared_preload_libraries = 'storage_engine'如果citus或pg_cron也在shared_preload_libraries中,加载顺序很重要——citus必须出现在storage_engine之前:
shared_preload_libraries = 'pg_cron,citus,storage_engine'然后:
CREATEEXTENSION 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 上提供反馈、问题和拉取请求。