12.1 数据库契约与应用边界
“应用能连上数据库”只证明传输路径存在,不证明双方理解同一个系统。一个可发布服务需要明确:
这些约定合起来才是数据库契约。它不是一份 ORM model,也不是只有列名的 schema 文档,而是应用与数据库可以分别验证的行为边界。
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 会漏掉大量破坏性变更。例如:
对显式列查询可能兼容;对 SELECT * 加位置扫描、按列数解码或缓存 result description 的客户端可能不兼容。数据库的物理变更很小,不代表 query contract 不变。
反过来,一条 SQL 文本不变,也可能因为:
search_path改变;- column type 或 collation 改变;
- RLS context 缺失;
- transaction pooling 换了 backend;
- generic/custom plan 或 statistics 改变;
- 连接到了 replica;
- 运行角色权限漂移;
而产生完全不同的行为。
用单行 marker 定义 schema contract
本章在隔离模式中保留:
marker 不是 migration history 的替代。它表示“应用启动所需的完整后置条件已经成立”,因此 readiness 可以检查:
成熟项目还会有不可变 migration ledger、artifact checksum、owner 和执行时间。关键是不能把:
直接等同于:
第 11 章已经证明迁移可能停在 expand、backfill、validate 或 switch 中间;服务依赖的是状态,不是脚本文件名。
Query contract 要包含“没有行”和“多于一行”
以订单详情为例,至少定义:
QueryRow().Scan() 返回 pgx.ErrNoRows 与网络错误、取消、权限错误完全不同。若把所有 error 都映射成 404,数据库事故会被伪装成“用户输入不存在”。
列表接口还要声明:
如果没有稳定顺序,分页结果不是一个可重放合同。
Error contract 使用身份,不使用文案
PostgreSQL error 至少有:
其中程序分支优先使用 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 是否存在,从而区分:
约束仍然保存最终 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 若需要新列:
服务发布与 database migration 有不同 identity、不同 rollback 方式和不同 owner,不能揉成一个“deploy succeeded”。
12.1.2 业务不变量在应用与数据库之间分工
按“谁能看见全部竞争者”分工
应用擅长:
- 解析 HTTP/JSON 和认证上下文;
- 给用户返回稳定领域错误;
- 传播 deadline、trace 与 idempotency key;
- 协调远程 API、消息系统和缓存;
- 执行可观测的有限重试;
- 选择版本化 query。
数据库擅长:
- 在所有 writer 之间执行同一约束;
- 原子提交多表状态;
- 用唯一性、外键、CHECK 和锁仲裁并发;
- 保证 rollback 不留下半个业务转换;
- 保存请求与结果的持久关系;
- 把 outbox 与业务事实同事务提交。
判断问题不是“逻辑放 Go 还是 SQL 更优雅”,而是:
库存不能先查后写
错误模式:
正确的数据库仲裁是一个条件写:
结果基数就是决策:
CHECK (available >= 0) 是最后防线,但不能告诉应用“为什么这次预留没有成功”。原子条件更新负责竞争,应用负责错误表达。
幂等不是“看到重复就返回 200”
请求键必须同时绑定 payload fingerprint:
本章订单事务先执行:
冲突后读取并锁定既有 ledger:
若相同,就返回保存的 JSON response;不是重新查询“现在的订单长什么样”。这样第一次返回的语义不随后续支付或状态更新漂移。
ledger、订单和 outbox 在同一 transaction:
没有“库存扣了,但应用崩溃前没记 request key”的窗口。
Outbox 不等于消息已经送达
事务中插入:
只保证:
它不保证 broker 已收到,也不保证 consumer 只执行一次。后续 publisher 还需要:
- claim/lease 或
FOR UPDATE SKIP LOCKED协议; - event key 去重;
- retry/backoff/dead-letter;
- consumer idempotency;
- lag 与 stuck event 告警。
本章故意不启动 publisher,避免把“事务 outbox”误写成“端到端 exactly once”。
远程副作用不能藏在持锁事务里
不要这样:
远程延迟会延长锁;HTTP 成功后数据库 commit 失败又会产生未知结果;数据库重试还可能重复扣款。
更可靠的边界通常是:
不同支付协议的补偿语义不同,本章只建模“已经得到可信 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 迁移版本与服务发布的依赖
启动顺序由兼容性决定
不能机械规定“永远先迁移”或“永远先发应用”。正确顺序来自兼容矩阵:
若新应用在 schema marker 缺失时启动,它应该 fail readiness,而不是等第一位用户撞到 undefined_column。但 liveness 可以继续为真,让编排系统区分:
Readiness 检查身份,而不只 SELECT 1
本章查询:
它同时防止:
- DNS/service 指向错误 database;
- 使用 admin 而非 runtime role;
- 写服务落到 recovery replica;
- migration 尚未达到可运行 post-state。
生产还可验证 tenant/extension/config baseline,但 readiness 必须轻量、有预算、失败不泄露敏感内部信息。完整 catalog 审计留给 deployment gate,而不是每个 probe 周期扫描。
App artifact 要声明最低和最高兼容版本
示例 manifest:
只写 minimum 可能让应用在未知 future schema 上静默运行。是否允许 contract >= 1 取决于团队是否承诺所有 future expand 都 backward compatible;若没有这项治理,精确范围更安全。
Migration 成功不自动放行服务
发布 gate 至少分为:
三者任何一个缺证,都不能用另外两个“看起来正常”代替。
本章为何只发布 release candidate
本地证据已证明:
尚未证明:
因此 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,不写成成功。
返回本章目录 · 下一节:为服务设计查询接口 · 查看全书目录 · 查看索引中心