Postgres 19 计划把默认 TOAST 压缩算法从 pglz 改为 LZ4。这不只是替换一个配置默认值:表内行、TOAST 表和索引都使用以 varlena 为基础的统一压缩框架,算法变化会同时影响写入 CPU、存储空间以及大索引键能否写入。
LZ4 从 Postgres 14 起已经可以显式启用。Postgres 19 的变化可以理解为一次谨慎推进:先让用户选择并积累实践,再考虑把它设为默认方案。
TOAST 解决的不是“字段类型”,而是物理存储
Postgres 早期存在严格的 8 kB 行大小限制。7.0 版本曾提供使用 pglz 的 lztext 类型,但它要求用户主动选择一种特殊类型,也没有从根本上解除行大小限制。
Postgres 7.1 引入 TOAST 后,压缩下沉到物理存储层。应用仍然使用 TEXT、VARCHAR、BYTEA 或 JSONB,数据库负责决定值应该:
- 未压缩地放在主表行中;
- 压缩后留在主表行中;
- 移到 TOAST 表并在主表中留下约 18 字节的指针;
- 在 TOAST 表中以压缩或未压缩形式保存。
这些变长类型使用自描述的 varlena 格式,其头部记录长度和压缩状态。INT、FLOAT、BOOL 等定长类型不会经过这套压缩流程。
列的存储策略决定了 Postgres 可以采取哪些动作:
| 策略 | 行为 |
|---|---|
EXTENDED |
允许压缩和移出行外,是多数变长类型的默认策略 |
PLAIN |
保持行内且不压缩,通常用于定长类型 |
EXTERNAL |
可以移到 TOAST,但不压缩 |
MAIN |
优先压缩并留在主表,必要时才移到 TOAST |
一次写入如何触发压缩
默认情况下,Postgres 会尝试把主表行控制在约 2 kB,也就是默认 toast_tuple_target 对应的 2040 字节附近。对于 EXTENDED 列,处理过程大致如下:
- 行本来就小于目标值时,直接写入,不为压缩额外消耗 CPU。
- 行过大时,按字段大小尝试压缩最大的可压缩列。
- 压缩后已经达到目标便停止,否则继续处理其他列。
- 仍然过大时,把最大的
EXTENDED或EXTERNAL值移到 TOAST 表。 - 必要时再处理
MAIN列,先压缩,最后才将其移出行外。
这解释了一个容易误解的现象:声明一个 TEXT COMPRESSION lz4 列,并不意味着每个短字符串都会被压缩。压缩由整行大小和存储策略共同触发。
pglz 当初针对低内存、跨平台和无外部依赖设计,使用 4 kB 滑动窗口,并在数据难以压缩时尽早放弃。LZ4 使用更大的 64 kB 窗口,在现代硬件上通常能更快完成压缩,有时也能找到更多重复片段。压缩比并非任何数据上都必然优于 pglz,因此迁移判断仍应基于真实负载。
可以这样实践:对比 pglz 与 LZ4
下面的实验适用于支持 LZ4 的 Postgres 14 及以上版本。先确认服务器编译时包含 LZ4,然后创建两个仅压缩算法不同的列。示例会删除同名临时表,请勿直接对生产表执行。
SHOW server_version;
SHOW default_toast_compression;
DROP TABLE IF EXISTS compression_lab;
CREATE TABLE compression_lab (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload_pglz text COMPRESSION pglz,
payload_lz4 text COMPRESSION lz4
);
INSERT INTO compression_lab (payload_pglz, payload_lz4)
SELECT payload, payload
FROM (
SELECT repeat(
'{"service":"billing","level":"info","message":"payment accepted"}',
250
) AS payload
) AS sample;
SELECT
octet_length(payload_pglz) AS raw_bytes,
pg_column_size(payload_pglz) AS pglz_bytes,
pg_column_size(payload_lz4) AS lz4_bytes,
pg_column_compression(payload_pglz) AS pglz_method,
pg_column_compression(payload_lz4) AS lz4_method
FROM compression_lab;
SELECT
pg_size_pretty(pg_total_relation_size('compression_lab')) AS total_size,
pg_size_pretty(pg_relation_size('compression_lab')) AS heap_size;
octet_length 给出原始字符数据的字节数,pg_column_size 反映值实际存储时的大小。两者差距明显,说明压缩取得了空间收益;如果大小接近,数据可能不可压缩,较大的值仍可能被原样移入 TOAST。
测试系统级默认值时,可以先限定在当前会话,避免影响其他连接:
SET default_toast_compression = 'lz4';
SHOW default_toast_compression;
CREATE TEMP TABLE session_default_test (
payload text
);
INSERT INTO session_default_test
VALUES (repeat('compressible payload ', 1000));
SELECT
pg_column_compression(payload) AS method,
octet_length(payload) AS raw_bytes,
pg_column_size(payload) AS stored_bytes
FROM session_default_test;
修改默认算法或列的 COMPRESSION 属性时,应把它理解为后续写入策略,而不是一次自动的全表重写。评估已有数据迁移时,需要明确安排表重写或分批更新,并计算 WAL、复制延迟和锁竞争成本。
索引压缩决定“大键”能否存进去
B-tree 索引中的变长键同样使用 varlena。压缩在这里是机会式的:并非每个索引字符串都会压缩,只有单个未压缩键超过约 510 字节的 TOAST_INDEX_TARGET 时,索引代码才会尝试调用同一套压缩能力。
B-tree 单条索引记录仍受页面约束。对于标准 8 kB 页面,键值最终通常不能超过约三分之一页,也就是报错信息中常见的 2704 字节上限。高度重复的 5000 字符可能压缩到几十字节后成功入索引;相同长度的随机数据则可能压不下来并导致写入失败。
可以在测试库复现这条边界。最后一条语句是预期失败案例:
DROP TABLE IF EXISTS index_key_lab;
CREATE TABLE index_key_lab (
body text COMPRESSION lz4
);
CREATE INDEX index_key_lab_body_idx ON index_key_lab (body);
-- 可压缩,通常能够写入索引。
INSERT INTO index_key_lab VALUES (repeat('x', 5000));
INSERT INTO index_key_lab VALUES (repeat('ab', 2000));
-- 伪随机 MD5 文本接近不可压缩,预期触发 B-tree 索引行过大错误。
INSERT INTO index_key_lab
SELECT string_agg(md5(g::text), '')
FROM generate_series(1, 88) AS g;
不要把压缩当作绕过 B-tree 大键限制的接口契约。数据分布一变,原本可写入的值就可能失败。长正文、文档或事件载荷通常更适合索引摘要、业务标识、表达式结果,或者采用适合检索目标的全文与倒排索引方案。
升级前应该验证什么
计划采用 LZ4 时,可以按以下清单执行:
- 用真实
INSERT和UPDATE流量比较吞吐、CPU、WAL 量和复制延迟,而不只测试压缩比。 - 分别采样重复文本、JSON、二进制文件和加密数据;后两类内容经常难以再次压缩。
- 使用
pg_column_size、octet_length、pg_column_compression和关系大小函数核对实际结果。 - 检查索引中是否直接包含长
TEXT、VARCHAR或BYTEA键,并加入不可压缩输入测试。 - 区分“新写入采用 LZ4”和“历史数据已经转换”,不要默认配置修改会重写旧值。
- 在生产启用前确认 Postgres 构建包含 LZ4 支持,并验证备份、恢复及副本环境一致。
从 pglz 转向 LZ4 的核心价值不是追求一个固定的压缩率数字,而是用更符合现代 CPU 和内存特征的算法降低写入成本。TOAST 的抽象让这次变化不需要引入新的业务字段类型,但统一框架也意味着表、TOAST 和索引必须放在同一套基准测试中评估。