DeepSeek总结的面向 PostgreSQL 分析和 HTAP 工作负载的两种高性能表访问方法

原文链接: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=10GBcolcompress 配置为 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
  • Q5colcompress 上实现了与堆表相当的性能(22.4ms 对 20.8ms),因为条带修剪消除了 7 个条带中的 6 个------数据通过 orderbyevent_date 物理排序。在修剪后的数据上,lz4 解压增加的 overhead 可以忽略不计。
  • Q6Q8(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
  • Q5colcompress 上(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'

如果 cituspg_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 上提供反馈、问题和拉取请求。


相关推荐
九皇叔叔2 小时前
MySQL 8.x 隔离级别调整
数据库·mysql·事务·隔离级别
许愿OvO2 小时前
Redis 7.4.8高可用集群实战:主从复制+哨兵+Cluster全解析
数据库·redis·bootstrap
2401_831419442 小时前
如何用 http 模块创建一个基础的 Web 服务器处理请求
jvm·数据库·python
pele2 小时前
Redis如何防止AOF文件无限增大_触发BGREWRITEAOF命令进行日志重写
jvm·数据库·python
qq_414256572 小时前
golang如何设计HTTP中间件链_golang HTTP中间件链设计方法
jvm·数据库·python
m0_746752302 小时前
如何用方法简写语法在对象字面量中快速定义成员函数
jvm·数据库·python
qq_189807032 小时前
JavaScript 中高效定位二维数组间不匹配元素的行列索引
jvm·数据库·python
qq_349317482 小时前
Python GUI界面如何实现主题美化_引入ttk模块实现原生外观风格
jvm·数据库·python
草履虫君2 小时前
我们用纯命令行方式,给openclaw配置minimax2.7
数据库·经验分享·功能测试·ai