前两篇先建立了性能基线,再说明如何阅读 EXPLAIN ANALYZE。当证据指向重复转换、无关大字段读取、随机写入或索引维护成本时,下一步才是检查表结构,而不是先调整数据库参数。

表结构优化的目标

表结构优化不是把每一列压到最短,而是让类型、约束、访问方式和数据生命周期保持一致。类型明确的列能在写入时拒绝无效数据,也让统计信息和查询表达式更接近真实业务语义;冷热字段分离则能减少常用路径需要搬运的数据。

设计时应同时回答三个问题:业务需要表达什么约束,常用查询实际读取哪些字段,字段会以什么频率写入和更新。选择会在存储空间、缓存密度、写入成本与使用复杂度之间交换收益,不存在脱离负载的“最快类型”。

text 与 varchar 的选择

在 PostgreSQL 中,text、不限制长度的 varcharvarchar(n) 不应按查询速度选型。只有业务规则确实存在稳定上限时,才需要用 varchar(n)CHECK 把上限交给数据库验证:

provider_trade_no varchar(64),
remark text

支付渠道编号若由协议限定为 64 个字符,长度约束能尽早发现错误;备注没有稳定上限时,text 更直接。不要为了所谓的定长性能改用很大的 char(n):它会以空格填充,并引入额外的比较和展示语义。

金额精度必须先定义业务规则

金额列首先要确定币种、小数位、舍入时机和允许范围。十进制金额可以直接声明精度与非负约束:

total_amount numeric(14, 2) NOT NULL
  CHECK (total_amount >= 0)

numeric(14, 2) 能精确表达最多 12 位整数和 2 位小数,代价是存储与计算成本通常高于整数。另一种做法是用 bigint 保存最小货币单位,但应用、接口和报表必须共享相同的换算及舍入规则。多币种或小数位不同的场景不能只为减少计算成本而强行套用一种整数语义。

JSONB 不应吞掉稳定关系字段

JSONB 适合结构经常变化、通常整体读取,或只有少数属性需要检索的数据。订单状态、用户 ID、金额与创建时间承担过滤、关联、排序和约束职责,应保留为类型明确的普通列,只把扩展属性留在 JSONB:

CREATE TABLE orders (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id bigint NOT NULL,
  status text NOT NULL,
  total_amount numeric(14, 2) NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);

稳定字段全部塞进 metadata 后,必填和类型约束更难表达,SQL 需要反复提取与转换,字段统计也更难反映业务分布。需要检索 JSONB 时,再根据实际操作符选择访问路径;GIN、表达式索引和执行计划留到第 4 篇讨论。

宽行、TOAST 与缓存密度

大型原始报文、审计快照和高频业务字段放在同一行,会扩大常用数据的存储工作集。PostgreSQL 可以通过 TOAST 压缩或行外保存较大的变长值,但这不会让大字段变成零成本:写入、更新、Vacuum 以及需要取回值的查询仍需处理额外数据。

当列表查询很少读取载荷,可以按访问冷热程度纵向拆分:

CREATE TABLE order_payloads (
  order_id bigint PRIMARY KEY REFERENCES orders (id),
  request_payload jsonb,
  response_payload jsonb,
  audit_snapshot text
);

更窄的热点行通常能让每个数据页容纳更多元组,提高缓存利用率。拆表也会让完整读取订单多一次连接,因此只适合访问频率差异明显的大字段,而不是机械地拆分所有字段组。

时间点优先使用 timestamptz

订单创建、支付发生时间代表现实世界中的时间点,通常应使用 timestamptz

created_at timestamptz NOT NULL DEFAULT now()

PostgreSQL 保存绝对时间点,输出时按会话时区转换。API、报表和界面应明确各自的展示时区,而不是去掉时区信息来回避转换。账期、生日等只表示日历日期的概念则适合 date;两者语义不同,不能只看显示格式决定类型。

主键选择与 uuidv7()

主键会同时影响行引用宽度、B-tree 大小与插入局部性:

选择优点代价
bigint identity8 字节、递增、内部连接紧凑容易暴露数量趋势,跨节点生成需额外机制
UUIDv4可分布式生成,不依赖递增序列16 字节且随机插入,局部性较差
UUIDv7可分布式生成,并按时间大致有序仍为 16 字节,也会暴露大致生成时间

PostgreSQL 18 内置了时间有序 UUID 生成函数:

SELECT uuidv7();

UUIDv7 组合毫秒级 UNIX 时间戳、亚毫秒信息和随机数据,通常比 UUIDv4 更有利于 B-tree 插入局部性。但时间有序不等于事务严格有序,不能替代版本号或单调序列。订单系统也可以用 bigint 作为内部关联键、用带唯一约束的 UUIDv7 对外暴露;收益是内部结构紧凑且编号不连续,代价是多维护一个唯一索引。

虚拟生成列

PostgreSQL 18 的生成列默认是虚拟列:值在读取时计算,不占用普通列的存储空间。显式声明可以把稳定的派生规则集中在表结构中:

ALTER TABLE users
ADD COLUMN email_domain text
GENERATED ALWAYS AS (
  split_part(lower(email), '@', 2)
) VIRTUAL;

生成表达式只能使用不可变表达式,虚拟生成列对用户自定义函数和类型还有额外限制。它减少的是派生逻辑散落和重复存储,不保证读取更快;若目标是加速固定表达式过滤,应在第 4 篇结合表达式索引与查询证据判断。需要避免读取时重复计算且能接受写入及存储成本时,再评估 STORED 生成列。

fillfactor 与 HOT 更新

PostgreSQL 更新行时会产生新元组版本。若被修改的列没有出现在相关索引中,并且原数据页有足够空间,HOT(Heap-Only Tuple)更新可以减少不必要的索引版本。频繁更新非索引状态列的表可以评估预留页内空间:

ALTER TABLE orders SET (fillfactor = 85);

较低的 fillfactor 能提高新版本留在同一页的机会,却会降低页密度、扩大表并增加扫描块数;设置只影响后续写入或重写的页面,也不会立即消除膨胀。它与虚拟生成列解决的是不同问题:前者在特定条件下减少更新链对索引的影响,后者集中派生规则并选择计算发生在读时还是写时,两者都应依据真实读写模式评估。

参考资料