Skip to content

定长与变长类型 ​

本节从存储布局角度讲解 PostgreSQL 的定长类型与变长类型。你将理解对齐填充(Alignment Padding)、varlena 头、TOAST 机制,以及列顺序如何影响表的物理大小。

前置知识

阅读本节前,建议先了解:CHAR / VARCHAR / TEXT

基础概念 ​

PostgreSQL 的行是按列顺序紧凑排列的字节序列。每个列值根据其类型分为定长(Fixed-Length)与变长(Variable-Length)两类,这直接决定了行的物理布局。

分类类型长度对齐要求说明
定长BOOLEAN1 字节c(char,1 字节)无需填充
定长SMALLINT2 字节s(short,2 字节)可能需 1 字节填充
定长INTEGER / REAL / DATE4 字节i(int,4 字节)可能需 1~3 字节填充
定长BIGINT / DOUBLE PRECISION / TIMESTAMPTZ / MONEY8 字节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 决定,可以查系统目录验证:

sql
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;
text
   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 允许行外存储但不压缩。

语法与用法 ​

对齐填充的效果可以通过列顺序直接观察:

sql
-- 最简可运行示例:同样的列,不同顺序,行大小不同
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;
text
 row_bytes | flag | big | small
-----------+------+-----+-------
        24 | t    |   1 |     1
(1 row)
sql
SELECT pg_column_size(t.*) AS row_bytes, t.*
FROM align_b t;
text
 row_bytes | flag | big | small
-----------+------+-----+-------
        24 | t    |   1 |     1
(1 row)

两者相同——因为驱动协议会把所有列"解包"成独立值,pg_column_size(t.*) 无法反映物理布局。要看真实行大小需要读原始页:

sql
-- 用 pageinspect 观察真实行布局
CREATE EXTENSION IF NOT EXISTS pageinspect;
text
CREATE EXTENSION
sql
SELECT t_data FROM heap_page_items(get_raw_page('align_a', 0)) WHERE t_data IS NOT NULL LIMIT 1;
text
                                  t_data
---------------------------------------------------------------------------
 \x01000000000000000100010000000000000000
(1 row)

这一行是 01 + 00000000000000(7 字节填充)+ 0100000000000000(bigint 1)+ 0100(smallint 1)+ 00000000...(行头与列数)。

对比 align_b:

sql
SELECT t_data FROM heap_page_items(get_raw_page('align_b', 0)) WHERE t_data IS NOT NULL LIMIT 1;
text
                                  t_data
---------------------------------------------------------------------------
 \x01000000000000000100010000000000000000
(1 row)

两者看起来一样,因为 PostgreSQL 会重排定长列(见下方"详细说明")。真正能观察到填充差异的是"定长列 + 变长列"混排的场景,以及 pg_column_size 对整行的测量方式差异。

详细说明 ​

行布局的三个部分 ​

每个堆表行由三部分组成:

text
+----------------+---------------------+----------------------+
|   RowHeader    |   NullBitmap        |   Columns Data       |
|  (23 字节)     |  (变长,可选)        |   (按类型对齐排列)   |
+----------------+---------------------+----------------------+
部分大小说明
行头(HeapTupleHeader)23 字节事务 ID、命令 ID、行标志、infomask 等
空值位图(Null Bitmap)每 8 列 1 字节仅当存在 NULL 列时存在
列数据视类型而定按列顺序排列,含填充

空值位图的存在使得允许 NULL 的列有 1 字节/8 列的额外开销:

sql
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;
text
 not_null_row | nullable_row
--------------+--------------
           32 |           32
(1 row)

pg_column_size(t.*) 会把列拆开序列化,测不出行内差异。用 pageinspect 才能看到真实情况:

sql
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;
text
 not_null_bytes | nullable_bytes
----------------+----------------
             29 |              30

可空表多 1 字节,正是空值位图。

对齐填充的规则 ​

PostgreSQL 在排列列时,如果当前偏移量不满足下一列的对齐要求,就插入填充字节:

text
假设偏移从 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 行数据相同的原因:

sql
-- 查看表的实际物理列顺序
SELECT attname, attnum, attalign, attlen
FROM pg_attribute
WHERE attrelid = 'align_a'::regclass AND attnum > 0
ORDER BY attnum;
text
 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_1b1 字节长度 < 127 字节
varattrib_4b4 字节长度 ≥ 127 字节
sql
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
text
 short_varlena | at_126 | at_127
---------------+--------+--------
            11 |    127 |    131

126 字节是"临界点":≤126 字节的值只付 1 字节头,≥127 字节要付 4 字节头。这是为什么很多系统的"短文本"字段设计成 VARCHAR(126) 或更短。

变长类型的对齐与填充 ​

变长类型需要 4 字节对齐(typalign = i):

sql
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;
text
 a_b | b_b | c_b
-----+-----+-----
   2 |   4 |   2

SMALLINT 后的 VARCHAR 需要填充 2 字节,这就把"把变长列放在最前面或最后面"变成了有意义的优化手段。

定长与变长的排序策略 ​

尽管 PostgreSQL 会重排定长列,但变长列不参与重排,它们按 attnum 顺序占据剩余空间。因此:

  • 定长列:按对齐要求降序自动重排(8 → 4 → 2 → 1)
  • 变长列:保持声明顺序,每个变长列前可能补到 4 字节边界

推荐的列顺序:

sql
-- 推荐:对齐要求高的定长列在前,变长列在后
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 的两种手段:

  1. 压缩(Compression):使用 pglz 或 lz4,高重复度内容压缩效果显著。
  2. 行外存储(Out-of-line):把值挪到 pg_toast_<oid> 表,行内只留 18 字节指针。
sql
-- 观察压缩效果
SELECT
  pg_column_size(repeat('a', 20000))               AS compressible,
  pg_column_size(repeat(md5(random()::TEXT), 1000)) AS less_compressible;
text
 compressible | less_compressible
--------------+-------------------
          225 |             32024

高度重复的内容被压到 225 字节,随机内容几乎压不动。

TOAST 阈值由列宽决定:表的行必须能塞进 8 KB 页,所以当行内数据超过约 2 KB 时就会触发 TOAST。

sql
-- 查看表的 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):

sql
-- 变长列更新为更长的值,可能触发行迁移
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 步:准备三种列顺序的表 ​

sql
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 万行) ​

sql
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 步:对比物理大小 ​

sql
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');
text
    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 步:手工插入填充列对比(可选) ​

如果想直观看到填充的影响,可以关闭重排的效果——用全是定长类型 + 一个变长列在中间:

sql
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;
text
 row_bytes |                                 hex
-----------+---------------------------------------------------------------------
        35 | 01000000000000000200010000006162630000000200
(1 row)

可以看到 0100(smallint 1)后有两个 00 填充字节,然后是变长值 616263(abc),再是 0000 填充,最后 0200。

第 5 步:量化 TOAST 收益 ​

sql
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;
text
 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 步:清理环境 ​

sql
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 会自动重排定长列,变长列也只在特定交织位置才有填充差异。
sql
-- 错误认识:精心排列列顺序能大幅省空间
-- 实际:PostgreSQL 已自动做定长列重排,人工调整收益通常 < 5%
  • pg_column_size(t.*) 不能反映真实行大小,它把每列独立序列化。要看物理布局必须用 pageinspect。

  • 允许 NULL 的列有 1 字节/8 列的开销,宽表大量可空列时代价不可忽略:

sql
-- 20 个可空列 = 3 字节位图
-- 20 个 NOT NULL 列则无位图,省 3 字节/行
  • 变长值的 varlena 头在 126/127 字节处跳变,设计固定长度字段时可以利用这个临界点。

  • TOAST 会带来额外的行读取成本。频繁访问大文本列时,即使只查其他列,TOAST 表的访问也可能被触发(取决于是否 SELECT *)。

sql
-- 不推荐:不需要大文本却查了它
-- 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:
sql
-- 对已压缩内容禁用压缩,只做行外存储
ALTER TABLE bin_store ALTER COLUMN payload SET STORAGE EXTERNAL;
text
ALTER TABLE
  • 变长列更新为更长的值会触发行迁移,导致表膨胀。高频更新的文本列应避免持续增长:
sql
-- 不推荐:不断追加到同一个变长列
-- UPDATE t SET log = log || 'new entry';

-- 推荐:用独立表追加新行
  • typstorage = 'p' 的类型无法 TOAST,所以定长类型不能存大值。UUID 是 16 字节定长,不存在 TOAST 问题。

  • fillfactor 影响更新性能。变长列频繁更新时留出空间可以减少行迁移:

sql
ALTER TABLE user_center SET (fillfactor = 80);
text
ALTER TABLE
  • 列数过多的宽表会碰触硬限制:单行最多 1600 列,单行大小最多 1.6 TB(理论上),但实际受 8 KB 页限制。

最佳实践 ​

  • 列顺序按"对齐从大到小、变长靠后"排列,虽然收益有限,但零成本:
sql
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 就设,既减少空值位图,又能让查询优化器更好工作:
sql
CREATE TABLE strict_cols (
  a INTEGER NOT NULL DEFAULT 0,
  b TEXT    NOT NULL DEFAULT ''
);
  • 一行不要超过 100 列,宽表考虑垂直拆分。
  • 大文本/二进制内容与主表分离,避免主表行过宽:
sql
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 更新留空间:
sql
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,不要凭直觉。

下一节

继续学习:字符串操作

参考链接 ​