Postgres 19 计划将默认的 TOAST 压缩算法从 pglz 更改为 LZ4,因此让我们看看 Postgres 如何在表存储和索引中压缩数据。Postgres 对表(heap)、TOAST 和索引使用统一的压缩框架。在 heap 和 TOAST 中,压缩是自动的,默认开启,适用于 TEXT、VARCHAR、BYTEA 和 JSONB 等可变长度类型。在索引中,压缩是机会性的:只有当单个键超过大小阈值时才会触发,而不是对索引中存储的每个可变长度值都进行压缩。
click to expand
Postgres 压缩的历史
压缩功能最早在 2000 年发布的 Postgres 7.0 中加入,但形式与今天不同。当时 Postgres 有严格的 8kB 最大行大小限制,尝试插入超过 8kB 的数据会报错。解决这个行大小限制是 Postgres 核心团队的优先事项。
绕过该限制的首次尝试是显式压缩字段。Postgres 7.0 推出了使用 pglz 压缩算法的 lztext 数据类型。该实现存在权衡:8kB 行限制依然存在,用户必须显式选择压缩数据类型。
7.0 发布后,下一步本应是添加更多压缩数据类型,如 LONG 或 BLOB(当时许多闭源数据库正在这样做)。但 Postgres 核心团队拒绝了额外数据类型,转而围绕 TOAST 展开。pgsql-hackers 邮件列表的讨论显示,团队将 8kB 问题拆分为两个独立问题:数据类型问题和物理存储问题。压缩和 TOAST 是物理存储问题的答案。
使用 pglz 的 TOAST
Postgres 7.1 中,TOAST 使用 pglz 实现。
pglz 算法位于 pg_lzcompress.c 文件中。由于 Postgres 是开源的,我们可以直接在注释中阅读作者自行实现压缩算法的理由:
- 以速度换比率: pglz 压缩和解压速度快,愿意牺牲压缩比率以换取速度。源代码称这是该算法的另一个“速度优先于比率”的偏好特征。
- 极低的内存占用: 该算法使用 4096 字节的滑动窗口,小到不会影响 Postgres 的内存需求。注释指出,压缩器在 1K 到 1M 大小的属性上表现最佳,因此会牺牲非常大值的性能。
- 激进的快速失败机制: 算法设计为在数据不易压缩时快速失败,以避免浪费 CPU 周期。
- 无外部依赖: 算法自包含,无需任何外部库。当时 Linux 还不是云服务器的默认操作系统(当时也没有“云”),因此 Postgres 需要一个在编译目标平台都能工作的压缩算法。
Jan Wieck 编写了该算法,并在文件底部留下致谢:
Many thanks to Adisak Pochanayon, who's article about SLZ inspired me to write the PostgreSQL compression this way.
Adisak Pochanayon 的 SLZ 文章描述了一种为游戏行业构建的压缩方案,用于压缩图形和音频数据。
为什么从 pglz 迁移到 LZ4?
pglz 是为不同时代构建的,而 LZ4 是一种现代算法,其权衡更符合现代硬件。Postgres 的 LZ4 推广路径与最初的 pglz 实现类似:先作为选项,再成为标准。从 Postgres 14 开始,LZ4 可通过系统级设置(default_toast_compression = 'lz4')或列级设置(column_name text COMPRESSION lz4)使用。
- 更快的压缩: LZ4 的压缩速度显著快于 pglz。在一次完全非科学的测试中,插入约 2000 行 ~10 kB 的值,LZ4 耗时约 6 ms,而 pglz 需 50 ms(快 8 倍)。在此数据集上,两种算法的解压吞吐量相近,因为 I/O 和内存延迟主导了 CPU 解码时间。
- 更好的压缩比率(有时): LZ4 的 64 kB 滑动窗口(而 pglz 为 4 kB)能在数据中找到更多回引,从而提高压缩率。在类似的非科学工作负载中,LZ4 将 10,400 字节原始数据压缩至 111 字节(减少 98.9%),而 pglz 为 186 字节(减少 98.2%),堆空间使用量分别为 312 kB 和 472 kB。存在 pglz 压缩率更好的情况。
- 继续快速失败: LZ4 具有高效的提前中止机制,设计用于快速检测随机或不可压缩数据。
Postgres 存储策略
首先需要了解 Postgres 有几种不同的列存储策略:
EXTENDED(我们大写是因为 Postgres 文档如此,并非大喊)允许 Postgres 使用所有可用工具。这是 TEXT、VARCHAR、BYTEA 和 JSONB 等可变长度类型的默认策略。值可以以未压缩、压缩、存储在 heap 或 TOAST 中的形式存在。
PLAIN 将列以未压缩形式内联存储在 heap 中。这是 INT、FLOAT 和 BOOL 等固定宽度类型的默认策略。这些值很少能从压缩中受益。
EXTERNAL 指示 Postgres 将列存储在 TOAST 中,但不压缩。
MAIN 指示 Postgres 尝试压缩列,但尽可能避免将其移至 TOAST。
varlena 格式
对于可变长度类型,Postgres 使用称为 varlena 的格式存储数据。varlena 是一种自描述格式,头部记录数据长度及是否压缩。它用于所有可变长度类型(包括 TEXT、VARCHAR、BYTEA 和 JSONB),也是存储在 heap、TOAST 和索引中的格式。
只有可变长度类型会被压缩。固定长度类型(如 INT、FLOAT 和 BOOL)从不压缩,直接存储在 heap 中。
压缩决策树
写入数据(插入或更新)时,Postgres 尝试将行大小控制在约 2 kB 的阈值以下(toast_tuple_target,默认 2040 字节)。它对 EXTENDED 列使用以下决策树:
- 如果整行已小于 ~2 kB,Postgres 直接将行以未压缩形式写入 heap。
- 如果行超过阈值,Postgres 按大小对 EXTENDED 列排序,并尝试压缩最大的列。如果压缩成功且使总行大小低于阈值,则停止并写入 heap。否则继续处理下一个最大的列。
- 如果所有符合条件的列都已压缩且行仍超过阈值,Postgres 开始将最大的 EXTENDED 或 EXTERNAL 列移出主 heap 元组到 TOAST 表,用 18 字节的指针替换,直到行能容纳。
- 如果仍无法容纳,Postgres 会回退尝试内联压缩 MAIN 列。如果失败,最后手段是将这些 MAIN 列移出到 TOAST。
更多关于 TOAST 的信息,请参阅 Postgres TOAST: The Greatest Thing Since Sliced Bread?
检查实际的压缩节省
-- pg_column_size: bytes as stored in the heap or TOAST table (after compression)
-- octet_length: bytes of raw character data (uncompressed)
SELECT
pg_column_size(payload) AS stored_bytes,
octet_length(payload) AS raw_bytes,
round(
(1 - pg_column_size(payload)::numeric
/ NULLIF(octet_length(payload), 0)) * 100, 1
) AS compression_pct
FROM events WHERE length(payload) > 100 LIMIT 5;
pg_column_size 报告压缩后的数据大小。当返回值远小于 octet_length 时,值被成功压缩。当两者大致相等时,值未压缩到足以节省空间。在这种情况下,如果值较大(超过 ~2 kB 阈值),仍会被移到 TOAST 表中但不压缩。
索引中的压缩
B-tree 索引页将键值存储为 IndexTuple 条目。每个条目包含一个 IndexTupleData 头部(8 字节)和键数据。对于可变长度类型,数据使用与 heap 元组相同的 varlena 格式(借用了为 8kB 行限制构建的解决方案)。
Postgres 使用与 TOAST 架构绑定的单一压缩框架。它在 varlena 头部标记数据是否被压缩。如果 heap 已压缩某个值,则该值以压缩形式进入索引。如果某个值未压缩且超过 510 字节(TOAST_INDEX_TARGET,约为 8 kB 缓冲页的 1/16),索引代码会内联调用相同的 TOAST 压缩例程尝试使其适合。如果压缩形式适合,则以压缩形式存储。如果即使压缩形式也超过限制,写入事务将失败。
这就是为什么 repeat('x', 5000) 可以被 B-tree 索引:LZ4 将 5000 个重复字符压缩到 ~38 字节,远低于 2704 字节的上限。相同长度的随机或伪随机数据压缩后的形式几乎与原始大小相同,超过上限则无法索引。
-- These succeed: both compressible, both fit after LZ4 compression
CREATE INDEX ON docs (body);
INSERT INTO docs VALUES (repeat('x', 5000)); -- stored: ~38 bytes compressed
INSERT INTO docs VALUES (repeat('ab', 2000)); -- stored: ~35 bytes compressed
-- This fails: md5 output is pseudo-random, essentially incompressible
INSERT INTO docs VALUES (
(SELECT string_agg(md5(g::text), '') FROM generate_series(1, 88) g)
);
-- 2816 chars of MD5 → compressed form ≈ 2816 bytes → index row size 2832 > 2704
-- ERROR: index row size 2832 exceeds btree version 4 maximum 2704 for index "..."
-- HINT: Values larger than 1/3 of a buffer page cannot be indexed.
Postgres 压缩的未来
通过压缩可以学到很多关于 Postgres 如何前进的知识。早期专用压缩数据类型的错误尝试被承认,底层的 pglz 工作被重新改造为 TOAST,这是一个巨大的成功。在从 pglz 迁移到 LZ4 的过程中,Postgres 采取了类似的方法:先测试,再迁移。核心团队有意采取措施,确保压缩算法的变更路径是正确的。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.