4.5 类型与约束的物理代价
可靠性不是无成本的,但“为了性能去掉约束”也不是成本分析。类型决定每行布局和可用运算,PK/UK/EXCLUDE 带来索引,FK/CHECK/trigger 增加写时检查;这些成本必须测量并与它们阻止的错误一起评估。
4.5.1 行宽、对齐、TOAST 与更新成本
一行不等于各列声明大小简单相加。heap tuple 还有 header、NULL bitmap 与对齐 padding;text、numeric、JSONB、array 等 varlena 值有长度头,足够宽时可能压缩或移到 TOAST table。列顺序、空值分布和具体内容都会改变实际大小。
先用 PostgreSQL 测,而不是凭类型名猜:
SELECT
pg_column_size(8800::bigint) AS bigint_bytes,
pg_column_size(88.00::numeric) AS small_numeric_bytes,
pg_column_size(
12345678901234567890.1234567890::numeric
) AS wide_numeric_bytes;本章 PG18.4 样例分别得到 8、8、22。它说明“小 numeric 有时与 bigint 同样紧凑”,不说明两者物理/运算成本等价;numeric 是变长、按四位十进制一组存储并带额外开销,值越宽占用越多。选择 integer minor unit 的首要理由仍是单位与范围合同,固定宽度只是可预期的附带收益。
测完整行:
SELECT
round(avg(pg_column_size(t))) AS avg_row_payload
FROM shop.sales_order AS t;在当前两行确定性 fixture 上约为 187 bytes;这不是生产容量估算。生产要取有代表性的长文本、NULL 比例和状态,结合:
pg_relation_size(...)
pg_table_size(...)
pg_indexes_size(...)
pg_total_relation_size(...)区分 heap、TOAST、索引与总占用。pg_column_size(row) 也不包含页面空闲、dead tuple、FSM/VM 和索引。
TOAST 解决页限制,不消除宽值成本
PostgreSQL 常见 page size 为 8 KiB,单个 tuple 不能跨页。TOAST 会对可 TOAST 类型压缩和/或拆成外置 chunk;触发阈值通常约 2 KiB。主 heap 只留 pointer,查询不读取宽列时可少拉取数据。
但:
- 宽值仍占磁盘、WAL、备份和网络;
- 读取它需要 detoast/decompress;
- 更新宽值会产生新版本及新的 TOAST 数据;
- 一个 table 有 toast relation 不代表当前已经有值被外置。
本章五表都有 text,因此目录显示 reltoastrelid;固定短样例并未因此“免费存储无限文本”。给 provider payload 一个无限 JSONB 列,会把更新竞争、保留期与敏感数据一起带进主行。
更新会创建新行版本
PostgreSQL MVCC 的 UPDATE 通常写一个新 tuple version。若没有修改 indexed column 且同页有空间,可能使用 HOT 降低索引更新;列变宽、索引过多或页面太满会降低机会。stored generated line_total_minor 又增加 8 bytes,并在单价/数量变化时重算。
不要为省几个 padding byte 就随意重排成熟表的列:重写表、应用兼容和迁移锁的代价常远大于收益。新表可以把固定宽、常用非空列放在合理位置,但最终仍用真实数据测量。
4.5.2 隐式转换、操作符与索引可用性
SQL 中的 = 不是一个能比较任意两值的万能函数。PostgreSQL 根据两边类型、可见 operator、implicit cast 和 preferred type 选择具体实现。unknown string literal 常能借另一边类型推断:
WHERE order_id = '1001'这里 literal 可以解析为 bigint。但 driver parameter 一旦被声明成 text,就不再是 unknown:
PREPARE bad(text) AS
SELECT * FROM shop.sales_order WHERE order_id = $1;
-- operator does not exist: bigint = text正确做法是让 driver 绑定 bigint,或在确定输入已经验证时显式 cast parameter:
WHERE order_id = $1::bigint不要为了“兼容所有输入”cast indexed column:
WHERE order_id::text = $1普通 sales_order_pkey(order_id) 索引保存 bigint operator class;对列包一层 text cast 后,表达式不同,除非另有 matching expression index,否则通常不能用原 PK index 作为相同条件。
三件事必须一致
索引可用性取决于:
- query expression;
- 解析出的 operator 与类型;
- index key expression、collation 与 operator class。
文本大小写查询若写 lower(email),普通 UNIQUE(email) 不是该表达式的索引。若创建 expression index,查询又必须使用可匹配的表达式与 collation。一个隐式 collation 或 cast 的变化,既可能改变语义,也可能改变计划。
检查 parameter 类型:
SELECT
name,
parameter_types,
statement
FROM pg_catalog.pg_prepared_statements;检查 cast 策略:
SELECT
castsource::regtype,
casttarget::regtype,
castcontext
FROM pg_catalog.pg_cast
WHERE castsource IN ('text'::regtype, 'bigint'::regtype)
OR casttarget IN ('text'::regtype, 'bigint'::regtype);castcontext 区分 implicit、assignment 和 explicit;不是目录里存在 cast 就能自动应用。
由计划验证,不靠规则口诀
在小 fixture 上 planner 选择 seq scan 很正常,不能据此判定索引“失效”。ch07 会用有规模的数据与 EXPLAIN (ANALYZE, BUFFERS)。本章先保留方法:
- 确认 column/parameter 精确类型;
- 查看 predicate 中是否对 indexed column 做函数/cast;
- 查看实际 operator 和 index definition;
- 在代表性数据量、统计信息与配置下比较计划;
- 不用长期关闭
enable_seqscan来“逼出答案”。
金额 API 同理:把 amount_minor 作为整数传输,不能在 SQL 中反复 amount_minor / 100.0 再与 numeric 参数比较并期待原索引语义不变。展示单位转换放投影层,过滤/连接使用存储单位。
4.5.3 约束、索引与写放大的关系
每个 INSERT/UPDATE 不只写 heap:
- PK/UNIQUE 要维护 B-tree 并检查冲突;
- EXCLUDE 要维护指定 index 并检查 operator 冲突;
- FK 要查询 referenced key,parent 更新/删除还要查 child;
- CHECK 计算表达式;
- transition trigger 查询私有边表;
- WAL、replica、backup 和 cache 都会承受更多字节。
v1 的五张业务表合计只有 12 行 fixture,却已经有 13 个 constraint-backed indexes:
SELECT
tablename,
indexname,
pg_size_pretty(
pg_relation_size(
format('%I.%I', schemaname, indexname)::regclass
)
) AS size
FROM pg_catalog.pg_indexes
WHERE schemaname = 'shop'
ORDER BY tablename, indexname;在本章 PG18.4 空间分配下,每个小 index 即使只有数行也显示 16 KiB。这是页面级最低分配的演示,不应线性外推;但它直观说明“多一个 unique”永远不是零成本。
给每个索引一个理由
| index 来源 | 本章理由 |
|---|---|
| 五表 PK | 行身份、FK target、点查 |
| customer_ref/email、SKU、order_no | 已批准业务唯一性 |
| customer + request_key | 并发幂等命令 |
| provider + provider ref/idempotency | 外部/命令作用域唯一 |
| order_id + currency | 复合 FK 保证订单聚合单币种 |
最后一个是有意冗余 index:order_id 已全局唯一,但 PostgreSQL 要求复合 FK 指向合格 unique key。我们用额外索引换取数据库可声明的跨表币种一致性。若生产写入证明它太贵,可重审多币种建模或约束实现,不能只删索引后假装规则仍在。
FK child columns没有自动 index。本章 fixture 很小,暂不为每条 FK 增加可能重复的索引;ch07 根据查询与 parent delete/update 路径统一设计。漏建和盲建同样是问题。
约束的收益也要计量
一次 23505 可能阻止两个并发请求生成重复订单,一次 FK 可能避免数月后才暴露的孤儿,一次 migration precheck 可能阻止静默舍入历史金额。把它们只归类为“写性能开销”会漏掉修复、对账和事故成本。
优化顺序应当是:
- 证明具体写路径受哪个检查/索引限制;
- 检查冗余 index、错误列序和不必要更新;
- 批量写入遵守事务/锁/WAL预算;
- 在不改变不变量时优化表达;
- 若必须改变合同,走业务 ADR,而不是 DBA 私删约束。
后续用 pg_stat_user_indexes、pg_stat_all_tables、WAL 与 latency 指标验证长期成本。刚创建的 index “scan count=0”也不能立即判废,它可能只为 rare integrity path 或 FK parent delete 服务。
本节验收
- 能用
pg_column_size与 relation size 函数区分值、heap、index、TOAST 和总量; - 不把 TOAST 误解为宽字段免费,也不从 toast relation 存在推断已经外置;
- driver parameter 使用列的真实类型,indexed column 不被无谓 cast;
- 能从 expression/operator/collation/opclass 四层解释索引匹配;
- 列出 v1 的 13 个索引及每一个不变量理由;
- 明确复合币种 unique 的可靠性收益与写放大;
- 性能优化以证据为入口,不用删约束代替建模。
参考资料
- PostgreSQL 18:数值物理存储
- PostgreSQL 18:TOAST
- PostgreSQL 18:数据库对象大小函数
- PostgreSQL 18:operator type resolution
- PostgreSQL 18:索引类型
- PostgreSQL 18:约束与索引
上一节:用约束表达不变量 · 返回本章目录 · 下一节:分区决策门 · 查看全书目录 · 查看索引中心