
做 PostgreSQL 开发或 DBA 的朋友应该都有过这样的经历,表结构随便建,数据量上来后磁盘嗖嗖涨,明明没存多少数据,空间却告急了。其实很多时候,问题出在字段对齐上,PG 在存储行数据时会按规则做字节对齐,不合理的字段顺序会产生大量空间浪费,而掌握这个规则,只需调整字段顺序,就能轻松省出不少磁盘空间。
PG 中每一行数据,在大多数 64 位系统上,由于内存对齐的要求,它实际上会占据 24 字节 的物理空间。 被称为 HeapTupleHeaderDat,这个行头主要用于支持 PostgreSQL 的 MVCC(多版本并发控制) 机制,主要包含:
t_xmin (4 字节): 插入该行的事务 ID。
t_xmax (4 字节): 删除或更新该行的事务 ID(如果没被删除则为 0)。
t_cid / t_xvac (4 字节): 命令 ID 或用于清理(Vacuum)的特殊 ID。
t_ctid (6 字节): 指向该行当前版本或新版本的物理位置(Page/Offset)。
t_infomask2 (2 字节): 存储属性数量及一些元数据标志位。
t_infomask (2 字节): 存储关于元组的状态标志(如是否有 NULL 值、是否有 OID 等)。
t_hoff (1 字节): 记录从行头到实际数据开始处的偏移量。图片来自于: https://www.interdb.jp/pg/pgsql01/03.html
源代码的描述
typedef struct PageHeaderData
{
/* XXX LSN是*任何*块的成员,不仅限于页面组织的块 */
PageXLogRecPtr pd_lsn; /* LSN:指向此页面上次更改的xlog记录的下一个字节 */
uint16 pd_checksum; /* 校验和 */
uint16 pd_flags; /* 标志位,见下文 */
LocationIndex pd_lower; /* 空闲空间开始的偏移量 */
LocationIndex pd_upper; /* 空闲空间结束的偏移量 */
LocationIndex pd_special; /* 特殊空间开始的偏移量 */
uint16 pd_pagesize_version;
TransactionId pd_prune_xid; /* 最旧可修剪的XID,如果没有则为零 */
ItemIdData pd_linp[FLEXIBLE_ARRAY_MEMBER]; /* 行指针数组 */
} PageHeaderData;例如在以下的查询中,可以通过typalign这个字段则表示对齐所需字节数,c -> 1, i -> 4, d -> 8 ,我们参考以下的结果来设计表的结构。
postgres=# select typname, typalign, typlen from pg_type where typname in ('int4', 'int8', 'bool', 'float4', 'float8', 'bytea', 'text', 'timestamptz', 'serial', 'json', 'int8[]');
typname | typalign | typlen
-------------+----------+--------
bool | c | 1
bytea | i | -1
int8 | d | 8
int4 | i | 4
text | i | -1
json | i | -1
float4 | i | 4
float8 | d | 8
timestamptz | d | 8
(9 rows)
接下来我们创建一张实际的表:
postgres=# create table t1(c char, d float8);
CREATE TABLE
postgres=# insert into t1 values('a',1.1);
INSERT 0 1
postgres=# select pg_column_size(t1.*),pg_column_size(c),pg_column_size(d) from t1;
pg_column_size | pg_column_size | pg_column_size
----------------+----------------+----------------
40 | 2 | 8
(1 row)结果显示整行大小是 40 字节,但实际字段大小只有 2 + 8 = 10 字节,加上 24 字节的HeapTupleHeaderDat行头,理论上 34 字节就够了,多出来的 6 字节去哪了 ?
答案是对齐填充的原因,float8 需要 8 字节对齐,char 字段占 2 字节,必须填充 6 字节才能满足 8 字节的对齐要求,这 6 字节就是纯浪费的空间。计算逻辑是 :24(行头)+ 2(char)+ 6(填充)+ 8(float8)=40 字节。
我们调整一下字段的顺序,把 float8 放前面,char 放后面,创建表 t2:
postgres=# create table t2(d float8, c char);
CREATE TABLE
postgres=# insert into t2 values(1.1,'a');
INSERT 0 1
postgres=# select pg_column_size(t2.*),pg_column_size(c),pg_column_size(d) from t2;
pg_column_size | pg_column_size | pg_column_size
----------------+----------------+----------------
34 | 2 | 8
(1 row)
结果整行大小直接降到34 字节,没有任何填充空间,完美利用了磁盘,计算逻辑:24(行头)+ 8(float8)+2(char)= 34 字节。
仅仅是调换了两个字段的顺序,每一行就节省了 6 字节,千万级数据量的表,这个差距会被无限放大!
那为什么将占用字节数更大的字段(比如float8)放在表结构的前面,能减少字节填充、节省存储空间,这其实是数据库存储中数据对齐(Data Alignment) 和填充(Padding) 机制的核心问题。
除了基础类型,我们日常用的 bytea、text、数组、json 这些复杂类型,对齐规则有特殊点,而且部分类型有额外的 header 开销,设计时一定要注意,避免踩坑。
这两个类型都是1 字节对齐,几乎不会产生填充空间,而且 header 开销很小,仅 1 个字节,存储单个字符时,实际占用仅 2 字节(1 字节 header+1 字节数据)
postgres=# create table t_bytea(b bool, ba bytea);
CREATE TABLE
postgres=# insert into t_bytea values(false,'\xFF');
INSERT 0 1
postgres=# select pg_column_size(t_bytea.*) from t_bytea;
pg_column_size
----------------
27
(1 row)在以上的内容中可以看出 text 类型和 bytea 完全一致,无需额外考虑填充。
数组类型同样是1 字节对齐,但坑在header 开销极大,哪怕只存一个元素,也需要 20 多字节的 header,这是固定开销,和元素多少无关。
postgres=# create table t_int8_arr(b bool, arr int8[]);
CREATE TABLE
postgres=# insert into t_int8_arr values(false,ARRAY[1]);
INSERT 0 1
postgres=# select pg_column_size(t_int8_arr.*) from t_int8_arr;
pg_column_size
----------------
54
(1 row)而且数组元素越多,整体占用空间会线性增加,设计表时如果数组元素少,建议考虑拆成普通字段,减少 header 浪费。
json 类型是1 字节对齐,和 bytea、text 一样,header 开销很小,存储简单 json 时仅占 3-6 字节,是比数组更省空间的选择,适合存储轻量结构化数据。
postgres=# create table t_json(b bool, j json);
CREATE TABLE
postgres=# insert into t_json values(false,'{}'::json);
INSERT 0 1
postgres=# select pg_column_size(t_json.*) from t_json;
pg_column_size
----------------
28
(1 row)掌握了对齐规则和特殊类型的特点,设计 PG 表结构时,只需遵循这 3 个原则,就能从根源上减少空间浪费,
把 d 类型(8 字节,如 int8、float8、timestamptz)放在最前面,接着放 i 类型(4 字节,如 int4、float4),最后放 c 类型(1 字节,如 bool、char),彻底避免大对齐字段的填充空间。
bytea、text、json 这些可变长类型,本身 1 字节对齐,放在最后不会产生填充;而 bool 字段(仅 1 字节)可以插在其他字段的间隙里,比如某个字段占用 25 字节(1 字节对齐),后面可以直接加 bool 字段,完美利用空间,不产生任何填充。
如果需要存储少量结构化数据,优先用 json(轻量 header),而非数组(超大 header);如果数组元素固定且少,直接拆成普通字段,比数组更省空间。
PostgreSQL 的字段对齐规则,看似是个小细节,却是表设计的关键,很多人忽略它,导致磁盘空间被大量浪费,数据量越大,问题越严重。
其实优化方法很简单,就是按对齐字节数从大到小排列字段,可变长字段放最后,bool 字段见缝插针,只需这一步,就能让你的表节省 20% 甚至更多的磁盘空间,同时还能提升数据读取效率。