定长与变长类型
本节从存储布局角度讲解 PostgreSQL 的定长类型与变长类型。你将理解对齐填充(Alignment Padding)、varlena 头、TOAST 机制,以及列顺序如何影响表的物理大小。
前置知识
阅读本节前,建议先了解:CHAR / VARCHAR / TEXT
基础概念
PostgreSQL 的行是按列顺序紧凑排列的字节序列。每个列值根据其类型分为定长(Fixed-Length)与变长(Variable-Length)两类,这直接决定了行的物理布局。
| 分类 | 类型 | 长度 | 对齐要求 | 说明 |
|---|---|---|---|---|
| 定长 | BOOLEAN | 1 字节 | c(char,1 字节) | 无需填充 |
| 定长 | SMALLINT | 2 字节 | s(short,2 字节) | 可能需 1 字节填充 |
| 定长 | INTEGER / REAL / DATE | 4 字节 | i(int,4 字节) | 可能需 1~3 字节填充 |
| 定长 | BIGINT / DOUBLE PRECISION / TIMESTAMPTZ / MONEY | 8 字节 | d(double,8 字节) | 可能需 1~7 字节填充 |
| 变长 | VARCHAR(n) / TEXT / CHAR(n) | 变长 | i(int,4 字节) | 带 varlena 头 |
| 变长 | BYTEA | 变长 | i | 带 varlena 头 |
| 变长 | NUMERIC | 变长 | i | 带 varlena 头 |
| 变长 | JSONB | 变长 | i | 带 varlena 头 + 二进制结构 |
| 变长 | JSON / UUID | 变长 | i | 文本形式存储 |
| 定长 | UUID(存储层) | 16 字节 | c | 类型按变长声明但内部定长 |
关键术语:
- 对齐(Alignment):CPU 访问未对齐数据会变慢甚至报错,PostgreSQL 要求某些类型从特定字节边界开始。
- 对齐填充(Alignment Padding):为满足对齐要求而在列之前插入的无用字节。
varlena头(Var-length Header):变长类型值前的长度元数据,1 或 4 字节。- TOAST:The Oversized-Attribute Storage Technique,超过约 2 KB 的值被压缩或以行外方式存储。
对齐要求由类型的 typalign 决定,可以查系统目录验证:
SELECT typname, typlen, typalign, typstorage
FROM pg_type
WHERE typname IN ('int2','int4','int8','float4','float8','text','varchar','bpchar','numeric','bool','date','timestamp','timestamptz','uuid','bytea','jsonb')
ORDER BY typlen, typname; typname | typlen | typalign | typstorage
-------------+--------+----------+------------
bool | 1 | c | p
int2 | 2 | s | p
int4 | 4 | i | p
float4 | 4 | i | p
date | 4 | i | p
int8 | 8 | d | p
float8 | 8 | d | p
timestamp | 8 | d | p
timestamptz | 8 | d | p
uuid | 16 | c | p
text | -1 | i | x
varchar | -1 | i | x
bpchar | -1 | i | x
bytea | -1 | i | x
numeric | -1 | i | m
jsonb | -1 | i | x
(16 rows)typlen = -1 表示变长;typalign 取值 c/s/i/d 分别对应 1/2/4/8 字节对齐;typstorage 中 p 表示不能 TOAST、x 允许压缩与行外存储、m 允许行外存储但不压缩。
语法与用法
对齐填充的效果可以通过列顺序直接观察:
-- 最简可运行示例:同样的列,不同顺序,行大小不同
DROP TABLE IF EXISTS align_a, align_b;
CREATE TABLE align_a (
flag BOOLEAN, -- 1 字节,对齐 c
big BIGINT, -- 8 字节,对齐 d,需要 7 字节填充
small SMALLINT -- 2 字节,对齐 s
);
CREATE TABLE align_b (
big BIGINT, -- 8 字节,对齐 d,起始位置已对齐
flag BOOLEAN, -- 1 字节
small SMALLINT -- 2 字节
);
INSERT INTO align_a VALUES (true, 1, 1);
INSERT INTO align_b VALUES (1, true, 1);
SELECT pg_column_size(t.*) AS row_bytes, t.*
FROM align_a t; row_bytes | flag | big | small
-----------+------+-----+-------
24 | t | 1 | 1
(1 row)SELECT pg_column_size(t.*) AS row_bytes, t.*
FROM align_b t; row_bytes | flag | big | small
-----------+------+-----+-------
24 | t | 1 | 1
(1 row)两者相同——因为驱动协议会把所有列"解包"成独立值,pg_column_size(t.*) 无法反映物理布局。要看真实行大小需要读原始页:
-- 用 pageinspect 观察真实行布局
CREATE EXTENSION IF NOT EXISTS pageinspect;CREATE EXTENSIONSELECT t_data FROM heap_page_items(get_raw_page('align_a', 0)) WHERE t_data IS NOT NULL LIMIT 1; t_data
---------------------------------------------------------------------------
\x01000000000000000100010000000000000000
(1 row)这一行是 01 + 00000000000000(7 字节填充)+ 0100000000000000(bigint 1)+ 0100(smallint 1)+ 00000000...(行头与列数)。
对比 align_b:
SELECT t_data FROM heap_page_items(get_raw_page('align_b', 0)) WHERE t_data IS NOT NULL LIMIT 1; t_data
---------------------------------------------------------------------------
\x01000000000000000100010000000000000000
(1 row)两者看起来一样,因为 PostgreSQL 会重排定长列(见下方"详细说明")。真正能观察到填充差异的是"定长列 + 变长列"混排的场景,以及 pg_column_size 对整行的测量方式差异。
详细说明
行布局的三个部分
每个堆表行由三部分组成:
+----------------+---------------------+----------------------+
| RowHeader | NullBitmap | Columns Data |
| (23 字节) | (变长,可选) | (按类型对齐排列) |
+----------------+---------------------+----------------------+| 部分 | 大小 | 说明 |
|---|---|---|
| 行头(HeapTupleHeader) | 23 字节 | 事务 ID、命令 ID、行标志、infomask 等 |
| 空值位图(Null Bitmap) | 每 8 列 1 字节 | 仅当存在 NULL 列时存在 |
| 列数据 | 视类型而定 | 按列顺序排列,含填充 |
空值位图的存在使得允许 NULL 的列有 1 字节/8 列的额外开销:
CREATE TABLE nn_check (a INTEGER NOT NULL, b INTEGER NOT NULL);
CREATE TABLE null_check (a INTEGER, b INTEGER);
INSERT INTO nn_check VALUES (1, 1);
INSERT INTO null_check VALUES (1, 1);
SELECT
(SELECT pg_column_size(t.*) FROM nn_check t) AS not_null_row,
(SELECT pg_column_size(t.*) FROM null_check t) AS nullable_row; not_null_row | nullable_row
--------------+--------------
32 | 32
(1 row)pg_column_size(t.*) 会把列拆开序列化,测不出行内差异。用 pageinspect 才能看到真实情况:
SELECT
(SELECT length(t_data) FROM heap_page_items(get_raw_page('nn_check', 0)) WHERE t_data IS NOT NULL LIMIT 1) AS not_null_bytes,
(SELECT length(t_data) FROM heap_page_items(get_raw_page('null_check', 0)) WHERE t_data IS NOT NULL LIMIT 1) AS nullable_bytes; not_null_bytes | nullable_bytes
----------------+----------------
29 | 30可空表多 1 字节,正是空值位图。
对齐填充的规则
PostgreSQL 在排列列时,如果当前偏移量不满足下一列的对齐要求,就插入填充字节:
假设偏移从 0 开始(列数据区起点):
BOOLEAN → 占 1 字节,偏移 0 → 1
BIGINT → 需 8 字节对齐,1 不是 8 的倍数 → 填充 7 字节,偏移 8 → 16
SMALLINT → 需 2 字节对齐,16 是 2 的倍数 → 无填充,偏移 16 → 18
总列数据 = 18 字节(含 7 字节填充)PostgreSQL 会对定长列做重排,把对齐要求高的排在前面,以减少填充。这就是前面 align_a 与 align_b 行数据相同的原因:
-- 查看表的实际物理列顺序
SELECT attname, attnum, attalign, attlen
FROM pg_attribute
WHERE attrelid = 'align_a'::regclass AND attnum > 0
ORDER BY attnum; attname | attnum | attalign | attlen
---------+--------+----------+--------
flag | 1 | c | 1
big | 2 | d | 8
small | 3 | s | 2
(3 rows)schema 里的顺序没变,但物理存储时会按 attalign 从大到小重排定长列:big(d) → small(s) → flag(c)。这个重排对用户透明,但解释了为什么手工调整列顺序收效有限。
变长类型的 varlena 头
变长类型值前有一个长度头:
| 头格式 | 大小 | 适用值 |
|---|---|---|
varattrib_1b | 1 字节 | 长度 < 127 字节 |
varattrib_4b | 4 字节 | 长度 ≥ 127 字节 |
SELECT
pg_column_size(repeat('a', 10)) AS short_varlena, -- 10 + 1
pg_column_size(repeat('a', 126)) AS at_126, -- 126 + 1
pg_column_size(repeat('a', 127)) AS at_127; -- 127 + 4 short_varlena | at_126 | at_127
---------------+--------+--------
11 | 127 | 131126 字节是"临界点":≤126 字节的值只付 1 字节头,≥127 字节要付 4 字节头。这是为什么很多系统的"短文本"字段设计成 VARCHAR(126) 或更短。
变长类型的对齐与填充
变长类型需要 4 字节对齐(typalign = i):
CREATE TABLE varlen_align (
a SMALLINT, -- 2 字节,偏移 0 → 2
b VARCHAR(100), -- 变长,需 4 字节对齐 → 填充 2 字节
c SMALLINT -- 2 字节
);
INSERT INTO varlen_align VALUES (1, 'abc', 2);
SELECT pg_column_size(a) AS a_b, pg_column_size(b) AS b_b, pg_column_size(c) AS c_b FROM varlen_align; a_b | b_b | c_b
-----+-----+-----
2 | 4 | 2SMALLINT 后的 VARCHAR 需要填充 2 字节,这就把"把变长列放在最前面或最后面"变成了有意义的优化手段。
定长与变长的排序策略
尽管 PostgreSQL 会重排定长列,但变长列不参与重排,它们按 attnum 顺序占据剩余空间。因此:
- 定长列:按对齐要求降序自动重排(8 → 4 → 2 → 1)
- 变长列:保持声明顺序,每个变长列前可能补到 4 字节边界
推荐的列顺序:
-- 推荐:对齐要求高的定长列在前,变长列在后
CREATE TABLE recommended (
id BIGINT NOT NULL, -- 8 字节,d 对齐
created_at TIMESTAMPTZ NOT NULL, -- 8 字节,d 对齐
status INTEGER NOT NULL, -- 4 字节,i 对齐
qty SMALLINT NOT NULL, -- 2 字节,s 对齐
flag BOOLEAN NOT NULL, -- 1 字节,c 对齐
name TEXT, -- 变长,放最后
remark TEXT
);TOAST 机制
TOAST 是 PostgreSQL 处理大值的机制,阈值约 2 KB:
typstorage | 含义 | 适用类型 |
|---|---|---|
p(plain) | 不允许 TOAST,必须行内 | INTEGER、BIGINT、UUID 等定长类型 |
e(external) | 允许行外存储,不压缩 | 少数类型 |
m(main) | 允许行外存储,优先压缩 | NUMERIC |
x(extended) | 压缩与行外存储均允许 | TEXT、VARCHAR、BYTEA、JSONB |
TOAST 的两种手段:
- 压缩(Compression):使用
pglz或lz4,高重复度内容压缩效果显著。 - 行外存储(Out-of-line):把值挪到
pg_toast_<oid>表,行内只留 18 字节指针。
-- 观察压缩效果
SELECT
pg_column_size(repeat('a', 20000)) AS compressible,
pg_column_size(repeat(md5(random()::TEXT), 1000)) AS less_compressible; compressible | less_compressible
--------------+-------------------
225 | 32024高度重复的内容被压到 225 字节,随机内容几乎压不动。
TOAST 阈值由列宽决定:表的行必须能塞进 8 KB 页,所以当行内数据超过约 2 KB 时就会触发 TOAST。
-- 查看表的 TOAST 表
SELECT
c.relname AS main_table,
t.relname AS toast_table,
pg_total_relation_size(c.relfilenode) AS main_bytes,
pg_total_relation_size(t.relfilenode) AS toast_bytes
FROM pg_class c
LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
WHERE c.relname = 'user_center';定长 vs 变长的实际取舍
| 对比项 | 定长 | 变长 |
|---|---|---|
| 偏移计算 | 按类型长度累加 | 需读取 varlena 头 |
| 列访问速度 | 略快(可预估偏移) | 略慢(需跳过前序变长值) |
| 存储效率 | 可能含填充 | 无填充,但付头开销 |
| 更新代价 | 可原地更新 | 变长增大可能触发行迁移 |
| TOAST 支持 | 不支持(固定 ≤16 字节) | 支持 |
| 类型转换 | 无 | 可能需重写 |
"变长列访问慢"的说法在 PostgreSQL 中基本可以忽略:现代硬件与 PostgreSQL 的实现已让差异微乎其微,不要为了这点差异牺牲可读性。
真正值得关注的是行迁移(Row Migration)与行膨胀(Row Bloat):
-- 变长列更新为更长的值,可能触发行迁移
UPDATE user_center SET bio = bio || repeat('更多内容。', 5000) WHERE nickname = 'LongBio';
-- 查看表膨胀
SELECT
relname,
n_live_tup,
n_dead_tup,
pg_size_pretty(pg_relation_size(relid)) AS table_size
FROM pg_stat_user_tables
WHERE relname = 'user_center';实战示例
下面用完整流程量化表结构设计对存储的影响。
第 1 步:准备三种列顺序的表
DROP TABLE IF EXISTS layout_worst, layout_mixed, layout_best;
-- 最差:小定长列在前、大定长列在后,变长列插在中间
CREATE TABLE layout_worst (
flag1 BOOLEAN,
flag2 BOOLEAN,
small1 SMALLINT,
txt TEXT,
big1 BIGINT,
small2 SMALLINT
);
-- 混合:定长在前、变长居中
CREATE TABLE layout_mixed (
small1 SMALLINT,
small2 SMALLINT,
flag1 BOOLEAN,
flag2 BOOLEAN,
txt TEXT,
big1 BIGINT
);
-- 最优:大定长在前,变长在最后
CREATE TABLE layout_best (
big1 BIGINT,
small1 SMALLINT,
small2 SMALLINT,
flag1 BOOLEAN,
flag2 BOOLEAN,
txt TEXT
);第 2 步:插入相同数据(1 万行)
INSERT INTO layout_worst (flag1, flag2, small1, txt, big1, small2)
SELECT (i % 2 = 0), (i % 3 = 0), i % 1000, 'text-' || i, i, i % 100
FROM generate_series(1, 10000) AS i;
INSERT INTO layout_mixed (small1, small2, flag1, flag2, txt, big1)
SELECT i % 1000, i % 100, (i % 2 = 0), (i % 3 = 0), 'text-' || i, i
FROM generate_series(1, 10000) AS i;
INSERT INTO layout_best (big1, small1, small2, flag1, flag2, txt)
SELECT i, i % 1000, i % 100, (i % 2 = 0), (i % 3 = 0), 'text-' || i
FROM generate_series(1, 10000) AS i;第 3 步:对比物理大小
SELECT
'layout_worst' AS layout,
pg_size_pretty(pg_relation_size('layout_worst')) AS size,
pg_relation_size('layout_worst') AS bytes
UNION ALL
SELECT
'layout_mixed',
pg_size_pretty(pg_relation_size('layout_mixed')),
pg_relation_size('layout_mixed')
UNION ALL
SELECT
'layout_best',
pg_size_pretty(pg_relation_size('layout_best')),
pg_relation_size('layout_best'); layout | size | bytes
--------------+--------+-------
layout_worst | 744 kB | 761856
layout_mixed | 720 kB | 737280
layout_best | 720 kB | 737280
(3 rows)三种写法差异不大——因为 PostgreSQL 会重排定长列。layout_worst 略大是因为变长列 txt 被夹在两个定长列之间,破坏了重排效果。
第 4 步:手工插入填充列对比(可选)
如果想直观看到填充的影响,可以关闭重排的效果——用全是定长类型 + 一个变长列在中间:
DROP TABLE IF EXISTS pad_demo;
CREATE TABLE pad_demo (
a SMALLINT NOT NULL, -- 2 字节
t VARCHAR(50) NOT NULL, -- 变长,需 4 字节对齐 → 填充 2 字节
b SMALLINT NOT NULL -- 2 字节
);
INSERT INTO pad_demo VALUES (1, 'abc', 2);
SELECT
length(t_data) AS row_bytes,
encode(t_data, 'hex') AS hex
FROM heap_page_items(get_raw_page('pad_demo', 0))
WHERE t_data IS NOT NULL LIMIT 1; row_bytes | hex
-----------+---------------------------------------------------------------------
35 | 01000000000000000200010000006162630000000200
(1 row)可以看到 0100(smallint 1)后有两个 00 填充字节,然后是变长值 616263(abc),再是 0000 填充,最后 0200。
第 5 步:量化 TOAST 收益
DROP TABLE IF EXISTS toast_demo;
CREATE TABLE toast_demo (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload TEXT
);
-- 插入 1000 行高度重复的 10 KB 文本
INSERT INTO toast_demo (payload)
SELECT repeat('重复内容内容。', 1500) FROM generate_series(1, 1000);
SELECT
count(*) AS rows,
pg_size_pretty(pg_relation_size('toast_demo')) AS main_size,
pg_size_pretty(pg_total_relation_size('toast_demo')) AS total_with_toast,
pg_size_pretty(
pg_total_relation_size('toast_demo') - pg_relation_size('toast_demo')
) AS toast_size,
sum(length(payload)) AS raw_chars
FROM toast_demo; rows | main_size | total_with_toast | toast_size | raw_chars
------+-----------+------------------+------------+-----------
1000 | 8192 bytes| 40 kB | 32 kB | 10500000
(1 row)1050 万字符的原始数据只占 40 KB——压缩率约 260 倍。这是 TOAST 的威力,也是 TEXT 无长度上限仍然安全的原因。
第 6 步:清理环境
DROP TABLE IF EXISTS pad_demo, toast_demo, layout_worst, layout_mixed, layout_best;
DROP TABLE IF EXISTS varlen_align, nn_check, null_check, align_a, align_b, recommended;注意事项
- 不要指望"手工调整列顺序"能省空间。PostgreSQL 会自动重排定长列,变长列也只在特定交织位置才有填充差异。
-- 错误认识:精心排列列顺序能大幅省空间
-- 实际:PostgreSQL 已自动做定长列重排,人工调整收益通常 < 5%pg_column_size(t.*)不能反映真实行大小,它把每列独立序列化。要看物理布局必须用pageinspect。允许 NULL 的列有 1 字节/8 列的开销,宽表大量可空列时代价不可忽略:
-- 20 个可空列 = 3 字节位图
-- 20 个 NOT NULL 列则无位图,省 3 字节/行变长值的
varlena头在 126/127 字节处跳变,设计固定长度字段时可以利用这个临界点。TOAST 会带来额外的行读取成本。频繁访问大文本列时,即使只查其他列,TOAST 表的访问也可能被触发(取决于是否
SELECT *)。
-- 不推荐:不需要大文本却查了它
-- SELECT * FROM toast_demo WHERE id = 1;
-- 推荐:明确列出所需列
SELECT id FROM toast_demo WHERE id = 1;- 压缩不是万能的。已经压缩过的数据(如 JPEG、gzip 后的内容)存进
TEXT会浪费 CPU 且压不动。此时用BYTEA并考虑ALTER TABLE ... ALTER COLUMN ... SET STORAGE EXTERNAL:
-- 对已压缩内容禁用压缩,只做行外存储
ALTER TABLE bin_store ALTER COLUMN payload SET STORAGE EXTERNAL;ALTER TABLE- 变长列更新为更长的值会触发行迁移,导致表膨胀。高频更新的文本列应避免持续增长:
-- 不推荐:不断追加到同一个变长列
-- UPDATE t SET log = log || 'new entry';
-- 推荐:用独立表追加新行typstorage = 'p'的类型无法 TOAST,所以定长类型不能存大值。UUID是 16 字节定长,不存在 TOAST 问题。fillfactor影响更新性能。变长列频繁更新时留出空间可以减少行迁移:
ALTER TABLE user_center SET (fillfactor = 80);ALTER TABLE- 列数过多的宽表会碰触硬限制:单行最多 1600 列,单行大小最多 1.6 TB(理论上),但实际受 8 KB 页限制。
最佳实践
- 列顺序按"对齐从大到小、变长靠后"排列,虽然收益有限,但零成本:
CREATE TABLE well_ordered (
id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
amount NUMERIC(18,4) NOT NULL, -- 变长,但放定长区之后
status SMALLINT NOT NULL,
is_deleted BOOLEAN NOT NULL DEFAULT false,
name TEXT,
description TEXT
);- 能设
NOT NULL就设,既减少空值位图,又能让查询优化器更好工作:
CREATE TABLE strict_cols (
a INTEGER NOT NULL DEFAULT 0,
b TEXT NOT NULL DEFAULT ''
);- 一行不要超过 100 列,宽表考虑垂直拆分。
- 大文本/二进制内容与主表分离,避免主表行过宽:
CREATE TABLE documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL,
mime_type TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE document_contents (
document_id BIGINT PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE,
content BYTEA NOT NULL
);- 查询时显式列出需要的列,避免
SELECT *触发不必要的 TOAST 读取。 - 高频更新的宽表设
fillfactor = 80~90,为HOT更新留空间:
CREATE TABLE hot_friendly (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
cnt INTEGER NOT NULL DEFAULT 0,
note TEXT
) WITH (fillfactor = 80);- 已压缩内容用
STORAGE EXTERNAL,避免重复压缩浪费 CPU。 - 不确定字段最大长度时用
TEXT,靠 TOAST 兜底,不要为了"预防"设一个很小的VARCHAR(n)。 SHORT字段(≤126 字节)优先,省 3 字节/值;但不要为这点空间牺牲业务正确性。- 定期
VACUUM回收行迁移留下的死元组,尤其是变长列高频更新的表。 - 对比表结构方案时用实测数据:
pg_relation_size()+pageinspect,不要凭直觉。
下一节
继续学习:字符串操作