# 数据库契约与应用边界

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

---

“应用能连上数据库”只证明传输路径存在，不证明双方理解同一个系统。一个可发布服务需要明确：

```text
what may be sent
what PostgreSQL guarantees after commit
what may be returned
how failure is identified
which application/database versions may coexist
how an operator proves the target is the intended target
```

这些约定合起来才是数据库契约。它不是一份 ORM model，也不是只有列名的 schema 文档，而是应用与数据库可以分别验证的行为边界。

## 12.1.1 模式、查询、错误与兼容性契约 {#item-12-1-1}

### 五张合同，而不是一张 ER 图

把数据库契约拆成五个互相引用、但可以独立评审的部分：

| 合同 | 要回答的问题 | 机器证据 |
|---|---|---|
| schema | 哪些对象、类型、约束与权限必须存在 | migration ID、catalog、constraint name |
| query | 参数与结果的类型、顺序、基数、排序是什么 | SQL text、fixture、result assertions |
| error | 哪些失败可区分，如何映射到领域语义 | SQLSTATE、constraint/routine identity |
| compatibility | 哪些 app/schema 版本组合可共同运行 | expand/switch/contract matrix |
| operations | 连接到谁、以谁运行、何时算 ready | database/user/recovery/service/metrics |

只写 schema 会漏掉大量破坏性变更。例如：

```sql
ALTER TABLE shop_ch12.sales_order
    ADD COLUMN note text;
```

对显式列查询可能兼容；对 `SELECT *` 加位置扫描、按列数解码或缓存 result description 的客户端可能不兼容。数据库的物理变更很小，不代表 query contract 不变。

反过来，一条 SQL 文本不变，也可能因为：

- `search_path` 改变；
- column type 或 collation 改变；
- RLS context 缺失；
- transaction pooling 换了 backend；
- generic/custom plan 或 statistics 改变；
- 连接到了 replica；
- 运行角色权限漂移；

而产生完全不同的行为。

### 用单行 marker 定义 schema contract

本章在隔离模式中保留：

```sql
CREATE TABLE shop_ch12.schema_version (
    singleton boolean PRIMARY KEY DEFAULT true,
    version integer NOT NULL,
    contract text NOT NULL,
    installed_at timestamptz NOT NULL,
    CHECK (singleton),
    CHECK (
        version = 1
        AND contract = 'pg36-ch12-service-contract-v1'
    )
);
```

marker 不是 migration history 的替代。它表示“应用启动所需的完整后置条件已经成立”，因此 readiness 可以检查：

```sql
SELECT EXISTS (
    SELECT 1
    FROM shop_ch12.schema_version
    WHERE singleton
      AND version = 1
      AND contract =
          'pg36-ch12-service-contract-v1'
);
```

成熟项目还会有不可变 migration ledger、artifact checksum、owner 和执行时间。关键是不能把：

```text
latest migration command returned zero
```

直接等同于：

```text
all required objects, privileges and data transitions are valid
```

第 11 章已经证明迁移可能停在 expand、backfill、validate 或 switch 中间；服务依赖的是状态，不是脚本文件名。

### Query contract 要包含“没有行”和“多于一行”

以订单详情为例，至少定义：

```text
input:
  order_id positive int64

success:
  exactly one order object
  items is an array ordered by line_no
  payment is object or JSON null
  money is integer minor units + currency

absence:
  no row → domain order_not_found / HTTP 404

failure:
  database error is not rewritten as not-found
```

`QueryRow().Scan()` 返回 `pgx.ErrNoRows` 与网络错误、取消、权限错误完全不同。若把所有 error 都映射成 404，数据库事故会被伪装成“用户输入不存在”。

列表接口还要声明：

```text
ordering key=(order_id ASC)
cursor means order_id > after
limit range=1..100
next_cursor exists only when another row is known to exist
```

如果没有稳定顺序，分页结果不是一个可重放合同。

### Error contract 使用身份，不使用文案

PostgreSQL error 至少有：

```text
SQLSTATE
severity
schema/table/column
constraint name
routine
message/detail/hint
```

其中程序分支优先使用 SQLSTATE 和命名对象。message 面向人，会受版本、locale 和上下文影响。例：

| PostgreSQL 身份 | 服务语义示例 |
|---|---|
| `23505` + specific unique constraint | resource/idempotency conflict |
| `23503` | referenced resource invalid |
| `23514` + named CHECK | invalid state transition or invariant |
| `40001` | retry whole transaction within budget |
| `40P01` | retry whole transaction only when operation is safe |
| `57014` | query cancelled; further distinguish timeout/client cancel |
| `42501` | deployment/privilege defect, not user input |

不是每个领域错误都要先撞约束。本章的“库存不足”由原子更新零行返回，再查询 SKU 是否存在，从而区分：

```text
404 sku_not_found
409 insufficient_inventory
```

约束仍然保存最终 `available >= 0` 防线。服务错误是协议；约束是持久状态护栏。

### Compatibility contract 是一个矩阵

模式发布不能只测试“new app + new schema”：

| application | database phase | 允许？ | 证明 |
|---|---|---:|---|
| old | legacy | yes | current production |
| old | expanded | yes | backward-compatible DDL |
| new | expanded/backfilling | conditional | fallback/nullable semantics |
| new | validated/switched | yes | new query contract |
| old rollback | switched | yes until contract | rollback window |
| old | contracted | no | old artifact inventory must be zero |

本章服务启动只接受 `service-contract-v1`。未来 v2 若需要新列：

```text
expand:
  DB accepts v1 and v2

deploy:
  v2 app rolls out, v1 remains rollback-capable

observe:
  old query/writer identity reaches zero

contract:
  DB stops accepting v1 only in separate release
```

服务发布与 database migration 有不同 identity、不同 rollback 方式和不同 owner，不能揉成一个“deploy succeeded”。

## 12.1.2 业务不变量在应用与数据库之间分工 {#item-12-1-2}

### 按“谁能看见全部竞争者”分工

应用擅长：

- 解析 HTTP/JSON 和认证上下文；
- 给用户返回稳定领域错误；
- 传播 deadline、trace 与 idempotency key；
- 协调远程 API、消息系统和缓存；
- 执行可观测的有限重试；
- 选择版本化 query。

数据库擅长：

- 在所有 writer 之间执行同一约束；
- 原子提交多表状态；
- 用唯一性、外键、CHECK 和锁仲裁并发；
- 保证 rollback 不留下半个业务转换；
- 保存请求与结果的持久关系；
- 把 outbox 与业务事实同事务提交。

判断问题不是“逻辑放 Go 还是 SQL 更优雅”，而是：

```text
谁拥有足够信息？
谁能在并发与故障下强制执行？
谁能给出稳定证据？
规则变化是否需要与 schema 一起发布？
```

### 库存不能先查后写

错误模式：

```text
SELECT available → application sees 1
another request also sees 1
both UPDATE available = 0
both report success
```

正确的数据库仲裁是一个条件写：

```sql
UPDATE shop_ch12.inventory
SET available = available - $2,
    version = version + 1
WHERE sku = $1
  AND available >= $2
RETURNING unit_price_minor,
          currency_code,
          available;
```

结果基数就是决策：

```text
one row → reservation succeeded
zero rows + SKU exists → insufficient
zero rows + SKU absent → not found
```

`CHECK (available >= 0)` 是最后防线，但不能告诉应用“为什么这次预留没有成功”。原子条件更新负责竞争，应用负责错误表达。

### 幂等不是“看到重复就返回 200”

请求键必须同时绑定 payload fingerprint：

```text
same key + same fingerprint
  → return the persisted first response

same key + different fingerprint
  → reject 409 idempotency_conflict
```

本章订单事务先执行：

```sql
INSERT INTO shop_ch12.order_request (
    request_key,
    fingerprint
)
VALUES ($1, $2)
ON CONFLICT (request_key) DO NOTHING;
```

冲突后读取并锁定既有 ledger：

```sql
SELECT fingerprint, response
FROM shop_ch12.order_request
WHERE request_key = $1
FOR UPDATE;
```

若相同，就返回保存的 JSON response；不是重新查询“现在的订单长什么样”。这样第一次返回的语义不随后续支付或状态更新漂移。

ledger、订单和 outbox 在同一 transaction：

```text
either:
  inventory decremented
  order exists
  item exists
  request response exists
  order.placed outbox exists

or:
  none of them commits
```

没有“库存扣了，但应用崩溃前没记 request key”的窗口。

### Outbox 不等于消息已经送达

事务中插入：

```sql
INSERT INTO shop_ch12.outbox (
    event_key,
    aggregate_type,
    aggregate_id,
    event_type,
    payload,
    trace_id
)
VALUES (...);
```

只保证：

```text
business fact committed ↔ intent to publish committed
```

它不保证 broker 已收到，也不保证 consumer 只执行一次。后续 publisher 还需要：

- claim/lease 或 `FOR UPDATE SKIP LOCKED` 协议；
- event key 去重；
- retry/backoff/dead-letter；
- consumer idempotency；
- lag 与 stuck event 告警。

本章故意不启动 publisher，避免把“事务 outbox”误写成“端到端 exactly once”。

### 远程副作用不能藏在持锁事务里

不要这样：

```text
BEGIN
  lock order
  call payment provider over network
  update payment
COMMIT
```

远程延迟会延长锁；HTTP 成功后数据库 commit 失败又会产生未知结果；数据库重试还可能重复扣款。

更可靠的边界通常是：

```text
persist intent/idempotency state
COMMIT
perform remote protocol with provider idempotency key
persist observed outcome in a new transaction
publish through outbox
```

不同支付协议的补偿语义不同，本章只建模“已经得到可信 capture 结果后，如何在数据库中幂等落账”。不能从样例推导出真实支付系统的完整协议。

### 防止两种极端

“全部放应用”会让第二个 writer、修复脚本或并发请求绕过规则；“全部放数据库”则容易隐藏远程副作用、把 API 版本耦合到 trigger，并让错误语义不可控。

一个实用评审表：

| 规则 | 主要执行者 | 数据库最后防线 |
|---|---|---|
| JSON 字段格式 | application | bounded column/check if durable |
| stock non-negative | atomic SQL transaction | CHECK |
| request replay | application protocol + ledger | PK/UNIQUE + transaction |
| one payment/order | transaction | UNIQUE(order_id) |
| exact amount | application/domain transaction | CHECK + locked order comparison |
| order state vocabulary | application + migration | named CHECK |
| remote payment retry | integration protocol | persisted idempotency/outbox |
| tenant identity | auth layer + transaction context | RLS in ch23 |

## 12.1.3 迁移版本与服务发布的依赖 {#item-12-1-3}

### 启动顺序由兼容性决定

不能机械规定“永远先迁移”或“永远先发应用”。正确顺序来自兼容矩阵：

```text
expand migration compatible with current app
  → verify database post-state
  → deploy new app with old-path fallback if needed
  → shadow/observe
  → switch new read/write path
  → retain rollback shape
  → separate contract release
```

若新应用在 schema marker 缺失时启动，它应该 fail readiness，而不是等第一位用户撞到 `undefined_column`。但 liveness 可以继续为真，让编排系统区分：

```text
process broken
database dependency not ready
business route overloaded
```

### Readiness 检查身份，而不只 `SELECT 1`

本章查询：

```sql
SELECT
    current_database(),
    current_user,
    NOT pg_catalog.pg_is_in_recovery(),
    EXISTS (
        SELECT 1
        FROM shop_ch12.schema_version
        WHERE singleton
          AND version = 1
          AND contract =
              'pg36-ch12-service-contract-v1'
    );
```

它同时防止：

- DNS/service 指向错误 database；
- 使用 admin 而非 runtime role；
- 写服务落到 recovery replica；
- migration 尚未达到可运行 post-state。

生产还可验证 tenant/extension/config baseline，但 readiness 必须轻量、有预算、失败不泄露敏感内部信息。完整 catalog 审计留给 deployment gate，而不是每个 probe 周期扫描。

### App artifact 要声明最低和最高兼容版本

示例 manifest：

```text
application=pg36-api
application_version=1.0.0-rc.1
database_contract_min=1
database_contract_max=1
query_bundle_checksum=...
driver=pgx/v5.10.0
query_mode=exec
pooling_assumption=transaction-compatible
```

只写 minimum 可能让应用在未知 future schema 上静默运行。是否允许 `contract >= 1` 取决于团队是否承诺所有 future expand 都 backward compatible；若没有这项治理，精确范围更安全。

### Migration 成功不自动放行服务

发布 gate 至少分为：

```text
database gate:
  target identity
  migration post-state
  constraints and grants
  old/new query compatibility
  lock/WAL/replica evidence

application gate:
  runtime role
  direct/pooler path identity
  readiness
  business smoke
  cancellation/retry/idempotency
  logs and metrics

traffic gate:
  SLI/error/tail latency
  pool and database saturation
  rollback observation window
```

三者任何一个缺证，都不能用另外两个“看起来正常”代替。

### 本章为何只发布 release candidate

本地证据已证明：

```text
PostgreSQL 18.6 direct path
pgx v5.10.0
pg36_app least privilege
application-side pool behavior
business and failure matrix
```

尚未证明：

```text
unchanged suite through Pigsty primary → PgBouncer transaction pool
PostgreSQL 14, 15, 16, 17 compatibility
HA failover and in-flight semantics
L1 load, tail latency, WAL and replica impact
application + database owner sign-off
```

因此 [baseline-v1.0-rc.json](/labs/ch12/baseline-v1.0-rc.json) 的状态是 `release-candidate`。这正是版本合同的价值：它把“已知可运行”与“允许晋级生产基线”分开。

## 本节检查表

- [ ] schema contract 有稳定 identity 与 catalog post-state；
- [ ] query contract 定义输入、基数、排序、null 与 no-row；
- [ ] error contract 使用 SQLSTATE/constraint identity；
- [ ] compatibility matrix 覆盖旧 app rollback；
- [ ] operations contract 验证 database、role、writable target；
- [ ] durable invariant 由所有 writer 都无法绕过的层执行；
- [ ] 原子竞争不用“先查后写”；
- [ ] idempotency key 绑定 fingerprint 和 persisted response；
- [ ] outbox 与业务状态同事务，但不冒充消息已送达；
- [ ] remote side effect 不在持锁事务或自动 retry callback 中；
- [ ] migration、application、traffic gate 各自留证；
- [ ] 未运行的环境矩阵保持 blocker，不写成成功。

---

[返回本章目录](../) · [下一节：为服务设计查询接口](../02/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
