跳至内容
12.1 数据库契约与应用边界

12.1 数据库契约与应用边界

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

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 模式、查询、错误与兼容性契约

五张合同,而不是一张 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连接到谁、以谁运行、何时算 readydatabase/user/recovery/service/metrics

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

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

本章在隔离模式中保留:

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 可以检查:

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 和执行时间。关键是不能把:

latest migration command returned zero

直接等同于:

all required objects, privileges and data transitions are valid

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

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

以订单详情为例,至少定义:

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,数据库事故会被伪装成“用户输入不存在”。

列表接口还要声明:

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 至少有:

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

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

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

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

404 sku_not_found
409 insufficient_inventory

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

Compatibility contract 是一个矩阵

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

applicationdatabase phase允许?证明
oldlegacyyescurrent production
oldexpandedyesbackward-compatible DDL
newexpanded/backfillingconditionalfallback/nullable semantics
newvalidated/switchedyesnew query contract
old rollbackswitchedyes until contractrollback window
oldcontractednoold artifact inventory must be zero

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

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 业务不变量在应用与数据库之间分工

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

应用擅长:

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

数据库擅长:

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

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

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

库存不能先查后写

错误模式:

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

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

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

结果基数就是决策:

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

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

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

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

same key + same fingerprint
  → return the persisted first response

same key + different fingerprint
  → reject 409 idempotency_conflict

本章订单事务先执行:

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

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

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

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

ledger、订单和 outbox 在同一 transaction:

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

or:
  none of them commits

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

Outbox 不等于消息已经送达

事务中插入:

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

只保证:

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”。

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

不要这样:

BEGIN
  lock order
  call payment provider over network
  update payment
COMMIT

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

更可靠的边界通常是:

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 字段格式applicationbounded column/check if durable
stock non-negativeatomic SQL transactionCHECK
request replayapplication protocol + ledgerPK/UNIQUE + transaction
one payment/ordertransactionUNIQUE(order_id)
exact amountapplication/domain transactionCHECK + locked order comparison
order state vocabularyapplication + migrationnamed CHECK
remote payment retryintegration protocolpersisted idempotency/outbox
tenant identityauth layer + transaction contextRLS in ch23

12.1.3 迁移版本与服务发布的依赖

启动顺序由兼容性决定

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

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 可以继续为真,让编排系统区分:

process broken
database dependency not ready
business route overloaded

Readiness 检查身份,而不只 SELECT 1

本章查询:

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:

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 至少分为:

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

本地证据已证明:

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

尚未证明:

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 的状态是 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,不写成成功。

返回本章目录 · 下一节:为服务设计查询接口 · 查看全书目录 · 查看索引中心

最后更新于