# 类型与约束的物理代价

LLMS 索引： [llms.txt](/llms.txt)

---

可靠性不是无成本的，但“为了性能去掉约束”也不是成本分析。类型决定每行布局和可用运算，PK/UK/EXCLUDE 带来索引，FK/CHECK/trigger 增加写时检查；这些成本必须测量并与它们阻止的错误一起评估。

## 4.5.1 行宽、对齐、TOAST 与更新成本 {#item-4-5-1}

一行不等于各列声明大小简单相加。heap tuple 还有 header、NULL bitmap 与对齐 padding；`text`、`numeric`、JSONB、array 等 varlena 值有长度头，足够宽时可能压缩或移到 TOAST table。列顺序、空值分布和具体内容都会改变实际大小。

先用 PostgreSQL 测，而不是凭类型名猜：

```sql
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.6 样例分别得到 8、8、22。它说明“小 numeric 有时与 bigint 同样紧凑”，不说明两者物理/运算成本等价；numeric 是变长、按四位十进制一组存储并带额外开销，值越宽占用越多。选择 integer minor unit 的首要理由仍是单位与范围合同，固定宽度只是可预期的附带收益。

测完整行：

```sql
SELECT
    round(avg(pg_column_size(t))) AS avg_row_payload
FROM shop.sales_order AS t;
```

在当前两行确定性 fixture 上约为 187 bytes；这不是生产容量估算。生产要取有代表性的长文本、NULL 比例和状态，结合：

```sql
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 隐式转换、操作符与索引可用性 {#item-4-5-2}

SQL 中的 `=` 不是一个能比较任意两值的万能函数。PostgreSQL 根据两边类型、可见 operator、implicit cast 和 preferred type 选择具体实现。unknown string literal 常能借另一边类型推断：

```sql
WHERE order_id = '1001'
```

这里 literal 可以解析为 bigint。但 driver parameter 一旦被声明成 text，就不再是 unknown：

```sql
PREPARE bad(text) AS
SELECT * FROM shop.sales_order WHERE order_id = $1;
-- operator does not exist: bigint = text
```

正确做法是让 driver 绑定 bigint，或在确定输入已经验证时显式 cast parameter：

```sql
WHERE order_id = $1::bigint
```

不要为了“兼容所有输入”cast indexed column：

```sql
WHERE order_id::text = $1
```

普通 `sales_order_pkey(order_id)` 索引保存 bigint operator class；对列包一层 text cast 后，表达式不同，除非另有 matching expression index，否则通常不能用原 PK index 作为相同条件。

### 三件事必须一致

索引可用性取决于：

1. query expression；
2. 解析出的 operator 与类型；
3. index key expression、collation 与 operator class。

文本大小写查询若写 `lower(email)`，普通 `UNIQUE(email)` 不是该表达式的索引。若创建 expression index，查询又必须使用可匹配的表达式与 collation。一个隐式 collation 或 cast 的变化，既可能改变语义，也可能改变计划。

检查 parameter 类型：

```sql
SELECT
    name,
    parameter_types,
    statement
FROM pg_catalog.pg_prepared_statements;
```

检查 cast 策略：

```sql
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 约束、索引与写放大的关系 {#item-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：

```sql
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.6 空间分配下，每个小 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 可能阻止静默舍入历史金额。把它们只归类为“写性能开销”会漏掉修复、对账和事故成本。

优化顺序应当是：

1. 证明具体写路径受哪个检查/索引限制；
2. 检查冗余 index、错误列序和不必要更新；
3. 批量写入遵守事务/锁/WAL预算；
4. 在不改变不变量时优化表达；
5. 若必须改变合同，走业务 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：数值物理存储](https://www.postgresql.org/docs/18/datatype-numeric.html)
- [PostgreSQL 18：TOAST](https://www.postgresql.org/docs/18/storage-toast.html)
- [PostgreSQL 18：数据库对象大小函数](https://www.postgresql.org/docs/18/functions-admin.html)
- [PostgreSQL 18：operator type resolution](https://www.postgresql.org/docs/18/typeconv-oper.html)
- [PostgreSQL 18：索引类型](https://www.postgresql.org/docs/18/indexes-types.html)
- [PostgreSQL 18：约束与索引](https://www.postgresql.org/docs/18/ddl-constraints.html)

---

[上一节：用约束表达不变量](../04/) · [返回本章目录](../) · [下一节：分区决策门](../06/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
