跳转到主要内容

12 一气呵成:从数据库契约到后端服务

前十一章分别建立了模型、查询、事务、诊断、索引、并发与模式发布能力。本章把这些能力压进一个真实服务边界:

HTTP request
  → bounded application context
  → pgxpool acquisition
  → parameterized SQL
  → one atomic PostgreSQL transaction
       inventory invariant
       idempotency ledger
       order/payment state
       outbox event
  → commit
  → stable response

这里最重要的不是 Go 框架,而是接口两侧能否对同一事实达成可执行合同:

application owns:
  protocol, validation, deadline, retry budget,
  response shape, trace propagation, external coordination

PostgreSQL owns:
  durable state, constraints, atomic transition,
  concurrency arbitration, idempotency record, outbox commit

Pigsty owns:
  service routing, pooler, role/database declaration,
  HA boundary, secrets delivery, monitoring and operational evidence

任何一层都不能替另一层“猜”。数据库约束不能替 HTTP 定义错误语义;应用先查库存也不能替原子条件更新;进程存活不能替数据库 readiness;直连成功更不能替 PgBouncer 路径验收。

本章目标

完成本章后,读者应当能够:

  • 把 schema、query、error、compatibility 与 operations 写成数据库契约;
  • 判断业务不变量应由输入校验、数据库约束、事务还是外部协议负责;
  • 使用占位参数传值,并把动态 identifier 限制为受控白名单;
  • 设计显式列、确定顺序、keyset cursor 与稳定 JSON 结果;
  • 在服务查询中正确使用 CTE、窗口函数和 LATERAL
  • 使用 SQLSTATE 与命名约束映射错误,不解析本地化 message;
  • 用一个事务完成库存预留、订单、幂等账本与 outbox;
  • 区分领域冲突、约束拒绝、语句超时、请求取消和池获取失败;
  • 只对声明的 40001 / 40P01 做有界整事务重试;
  • 把远程 API、消息发布等外部副作用放在数据库提交边界之外;
  • 计算应用侧连接池与 PgBouncer server pool 的联合预算;
  • 理解 session、transaction、statement pooling 的状态边界;
  • 说明 SET LOCAL 为什么适合 transaction pooling 内的事务上下文;
  • 按 pgx 与 PgBouncer 的实际版本组合决定预备语句策略;
  • 区分 liveness、startup readiness 与业务 readiness;
  • 用低基数 application_name、trace ID、query identity 和 outbox 关联请求;
  • 同时观察请求延迟、pool wait、数据库 wait、SQLSTATE 和业务结果;
  • 通过 Pigsty primary service 接入生产应用,通过 direct service 做受控管理;
  • 拒绝把 PostgreSQL 18.6 直连实验冒充 Pigsty/PgBouncer 已验证;
  • 冻结一个可运行参考服务,并把累计规约形成 v1.0 release candidate。

参考服务与实验边界

本章只维护一个 Go/pgx 参考服务。它不是 Go 教程,也不是可复制到所有业务的“微服务模板”。样例只保留能验证 PostgreSQL 合同的四类接口:

POST /v1/orders
  atomic inventory reservation
  request-key fingerprint
  order + item + outbox in one transaction

POST /v1/payments
  payment-key fingerprint
  exact amount and state transition
  payment + order state + outbox in one transaction

GET /v1/orders/{id}
  explicit result shape
  ordered item aggregation with LATERAL

GET /v1/orders
  keyset page
  CTE + row_number + LATERAL

另外提供:

/health/live   process/event-loop only; no database
/health/ready  acquire pool + database/user/writable/schema contract
/metrics       request, SQLSTATE, retry and pgxpool state
/debug/hold    lab-only controlled pool/cancellation fault

数据库对象全部位于独立模式 shop_ch12。运行角色 pg36_app

  • 有 schema USAGE
  • 只获得必要的 SELECTINSERTUPDATE 与 identity sequence 权限;
  • 没有 schema CREATE
  • 没有表 DELETE
  • 不能复位或修改模式;
  • 不能通过服务调用任意 SQL。

实验模式保存:

schema_version       exact application/database contract marker
inventory            non-negative stock and monotonic version
order_request        order idempotency key + fingerprint + response
sales_order          placed/paid state and trace
sales_order_item     quantity, unit price and generated total
payment_request      payment idempotency ledger
payment              one captured payment per order
outbox               event committed with the business transition
retry_fault_seq      lab-only non-transactional retry gate

setup 与 reset 都先检查对象 marker。reset 还要求精确动作令牌、精确目标、对象白名单以及零 pg36-ch12-api 会话;它不删除 database、role、extension 或 shop.*

下载资产

服务源码固定为一个独立 Go module:

service/
  go.mod / go.sum
  main.go       lifecycle and configuration
  server.go     HTTP contract, logs and health
  store.go      parameterized SQL and transactions
  model.go      request/response/error types
  metrics.go    bounded metrics and pgxpool stats

本章验证的 frozen 组合是:

PostgreSQL 18.6
pgx v5.10.0
Go 1.26.4 runtime
QueryExecModeExec
direct PostgreSQL service
application pgxpool MaxConns=2 in the failure lab

这不是“当前所有环境的默认版本”,而是证据绑定的实际组合。

本章目录

12.1 数据库契约与应用边界

12.2 为服务设计查询接口

12.3 Go 服务中的连接与事务

12.4 会话状态与连接池陷阱

12.5 服务级可观测性

12.6 部署与接入 pg36_shop

12.7 实战:交付应用闭环与规约 v1.0

实测摘要

task.sh all 先运行完整服务矩阵,再证明错误 token、错误 target 和 active service 都不能 reset;随后执行精确 reset,从空模式重建并再次运行同一套矩阵。第二轮 PostgreSQL 18.6 证据:

business:
  orders=2
  payments=1
  outbox=3
  order requests=2
  payment requests=1
  SKU-001=8:v1
  SKU-002=4:v1

idempotency:
  order replay → same 1200001 response / no second decrement
  payment replay → same 1200001 response / no second payment
  same key + different payload → 409 idempotency_conflict

failure:
  statement_timeout → 57014 / HTTP 504 / committed state unchanged
  injected 40001 → whole transaction retried once / order 1200002 once
  client timeout → backend active observed=1 / after cancel=0

pool MaxConns=2:
  two database workers held
  liveness=200
  readiness=503 pool_unavailable
  business request=503 pool_unavailable
  both holders=200
  readiness after release=200

metrics:
  transaction retries=1
  idempotent replays=2
  SQLSTATE 40001=1
  SQLSTATE 57014=1
  canceled pool acquisitions=2

security:
  current_user=pg36_app
  schema CREATE=false
  table DELETE=false
  active API query after suite=0
  ch04 checksum=f8a7bfae59c6d16cd323abecfefe1014

连接获取次数、持续时间、端口、PID 和时间戳不是 golden。稳定结论是状态基数、只发生一次的扣减/支付、SQLSTATE、取消清理、池耗尽时三种健康语义以及业务 checksum。

v1.0 artifact 的 canonical checksum 为:

c85a930af366a9e96be7a0e166d3d0c04faace778208743718af51f633d8044d

状态仍是 release-candidate。当前没有在真实 Pigsty primary/PgBouncer 路径、PostgreSQL 14–18 矩阵和 L1 生产型负载上完成晋级证据;截止时间与本地成功都不能把未执行条件改成 PASS。

章节验收

  1. schema、query、error、compatibility 与 operations contract 都有版本身份;
  2. 约束负责可强制执行的持久不变量,应用负责协议与外部协调;
  3. 库存通过条件 UPDATE ... RETURNING 原子预留,不先查后写;
  4. idempotency key 同时绑定 payload fingerprint 与持久响应;
  5. 不同 payload 复用 key 必须拒绝;
  6. order/payment/outbox 在同一事务提交;
  7. 事务内不调用远程支付或消息系统;
  8. 所有值使用参数,动态 identifier 只能来自封闭白名单;
  9. 结果显式列出字段,聚合内部有稳定 ORDER BY
  10. 分页使用 keyset cursor,不以 OFFSET 扫描替代;
  11. SQLSTATE 和命名 constraint 是错误映射证据,不解析 message;
  12. request deadline 传播到 pool acquisition 与 SQL;
  13. statement_timeout 与 client cancellation 被区分并各自留证;
  14. 只对列明的完整事务错误执行有限重试;
  15. 应用 pool 与 PgBouncer server pool 共同纳入连接预算;
  16. liveness 不获取数据库连接;
  17. readiness 验证 database、role、writable target 与 schema marker;
  18. application_name 低基数稳定,trace ID 不塞进连接名;
  19. 日志不含 connection string、密码或完整敏感参数;
  20. pool wait 与 PostgreSQL wait 分开度量;
  21. transaction pooling 下不依赖跨事务 session state;
  22. SET LOCAL 位于显式事务内;
  23. pgx query mode 与 PgBouncer prepared-statement 配置按实际版本验证;
  24. Pigsty primary 与 direct service 的职责没有混用;
  25. reset 的 target、token、object marker 和 active-session guard 全部生效;
  26. direct evidence 不被标成 pooler evidence;
  27. v1.0 未满足晋级条件时保持 release candidate;
  28. 参考服务在本章冻结,后续不演变成框架教程。

下一章 ch13《言出法随:函数、触发器与存储过程》 将从“服务与数据库如何分工”继续深入数据库端逻辑:何时值得把规则放进函数或触发器,以及如何避免隐藏副作用。

参考资料


上一章:守正出奇:模式变更与安全发布 · 返回上卷导读 · 下一章:言出法随:函数、触发器与存储过程 · 查看全书目录 · 查看索引中心

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 连接到谁、以谁运行、何时算 ready database/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 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 是否存在,从而区分:

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 若需要新列:

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 字段格式 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 迁移版本与服务发布的依赖

启动顺序由兼容性决定

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

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.6 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,不写成成功。

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

12.2 为服务设计查询接口

服务中的 SQL 不是藏在字符串里的实现细节,而是一组版本化接口。好的 query contract 让评审者不看 Go 也能回答:

input type and bound
row cardinality
ordering
null and no-row semantics
locking and transaction requirement
expected SQLSTATE
result shape across schema versions

这一节使用 store.go 中实际运行过的 SQL,不另外发明一套“正文专用”伪代码。

12.2.1 参数化 SQL 与稳定结果语义

绑定参数只解决 value

pgx 使用 $1$2

SELECT state, total_minor, currency_code
FROM shop_ch12.sales_order
WHERE order_id = $1
FOR UPDATE;

参数化的主要价值:

  • value 不再与 SQL grammar 拼接;
  • 类型编码由 driver/protocol 处理;
  • query text 稳定,便于 query identity 与计划复用;
  • 日志可以记录 query identity,而不必记录敏感 value;
  • 测试可以把恶意输入当数据,不会改变语法。

但参数不能代替 table、column、direction 或 operator:

-- 不成立:$1 不会被当作列名
ORDER BY $1;

动态 identifier 需要:

  1. 尽量改成几条固定 SQL;
  2. 若确实需要,输入先映射到封闭 enum;
  3. 使用 driver 提供的 identifier quoting;
  4. value 仍然单独参数化。

不要把用户字符串传入 fmt.Sprintf("ORDER BY %s", input),再声称其他 value 已参数化所以安全。

把原子决策放进语句结果

库存预留:

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

这条 SQL 的 contract 包含:

input:
  sku text matching API vocabulary
  quantity int32 in 1..1000

success:
  exactly one row
  price/currency are the values used for this order
  stock and version changed atomically

zero rows:
  SKU absent or quantity unavailable

constraint:
  available remains >= 0 for every writer

RETURNING 避免一次 UPDATE 后再读“可能已经被别人改过”的当前值。本章不把剩余库存放进响应,因此代码只用 price/currency 完成订单;但证据保留 final inventory。

显式列优于 SELECT *

SELECT * 会把 schema 顺序变成 query contract。新增列后:

  • positional scanner 可能列数不符;
  • result description cache 可能失效;
  • API 无意暴露新字段;
  • 大字段可能突然进入热路径;
  • 同名列 join 后难以辨认;
  • rolling deployment 的 old decoder 可能失败。

服务查询逐列列出:

SELECT
    orders.order_id,
    orders.customer_ref,
    orders.state,
    orders.total_minor,
    orders.currency_code,
    orders.trace_id,
    orders.created_at,
    items.value,
    payment.value
...

“显式”不表示永不改变;它让改变发生在可 review 的 query diff,而不是 table diff 的隐式副作用。

稳定 JSON 必须定义内部顺序

聚合 items:

SELECT COALESCE(
    jsonb_agg(
        jsonb_build_object(
            'line_no', line.line_no,
            'sku', line.sku,
            'quantity', line.quantity,
            'unit_price_minor', line.unit_price_minor,
            'line_total_minor', line.line_total_minor
        )
        ORDER BY line.line_no
    ),
    '[]'::jsonb
)
FROM shop_ch12.sales_order_item AS line
WHERE line.order_id = orders.order_id;

没有 aggregate 内部的 ORDER BY,上层查询排序不能保证数组元素顺序。空集合用 [],不是 SQL NULL;payment 没有行则返回 JSON null。这些都是 API contract,不是格式喜好。

不要用 JSON 文本字节逐字符比较 jsonb object key order。稳定语义是字段和值;array order 才由 ORDER BY 明确定义。

Keyset pagination

第一页:

GET /v1/orders?limit=1
→ item 1200001
→ next_cursor=1200001

下一页:

GET /v1/orders?limit=1&after=1200001
→ WHERE order_id > 1200001
→ item 1200002
→ next_cursor=null

核心 predicate:

WHERE orders.order_id > $1
ORDER BY orders.order_id
LIMIT $2;

相比高 OFFSET,keyset 不必反复扫描并丢弃前 N 行,也更能抵抗前页插入/删除造成的位置漂移。但它要求:

  • order key 唯一或追加唯一 tie-breaker;
  • cursor 包含完整 sort key;
  • filter、sort 与 cursor semantics 绑定版本;
  • 向后翻页需要单独设计;
  • snapshot 一致性若是需求,不能仅靠 cursor。

参数类型与 query mode 也属于合同

本章固定 pgx QueryExecModeExec。它使用 extended protocol、text-formatted 参数与结果,并在一个 round trip 执行;它不会像默认 cache_statement 那样自动缓存 named prepared statement。

这带来一个容易遗漏的类型边界:在该模式中,Go []byte 会自然表示 PostgreSQL bytea;JSON/JSONB 参数应传 string、注册类型或实现相应 codec。本章持久化 response 时使用:

string(payload)

而不是假设任意字节都会被数据库自动理解为 JSON。参数化解决 injection,不替你解决不明确的类型映射。

12.2.2 CTE、窗口函数和 LATERAL 的工程用法

这些构造不是“高级 SQL 展示”。它们分别解决:

CTE:       name a query stage and stabilize one statement's shape
window:    compute across related rows without collapsing them
LATERAL:   evaluate a right-side subquery using the current left row

LATERAL 生成每个订单的嵌套结果

订单详情先取得一个 order,再为这一行计算 items:

FROM shop_ch12.sales_order AS orders
CROSS JOIN LATERAL (
    SELECT COALESCE(
        jsonb_agg(... ORDER BY line.line_no),
        '[]'::jsonb
    ) AS value
    FROM shop_ch12.sales_order_item AS line
    WHERE line.order_id = orders.order_id
) AS items

LATERAL 允许右侧引用 orders.order_id。这里 aggregate 即使没有 item 也返回一行,所以 CROSS JOIN 不会丢掉 order。另一种常见形态:

LEFT JOIN LATERAL (
    SELECT ...
    WHERE child.parent_id = parent.id
    ORDER BY ...
    LIMIT 1
) AS latest ON true

适合“每个 parent 的 top-N/latest”。风险是外层行很多时,右侧可能反复执行;仍要用 EXPLAIN (ANALYZE, BUFFERS) 检查实际 loops、index 与行数,不能因 SQL 简洁就假设代价小。

CTE 表达分页阶段

本章列表查询:

WITH page AS (
    SELECT
        orders.order_id,
        orders.state,
        orders.total_minor,
        orders.created_at
    FROM shop_ch12.sales_order AS orders
    WHERE orders.order_id > $1
    ORDER BY orders.order_id
    LIMIT $2
),
ranked AS (
    SELECT
        page.*,
        row_number() OVER (
            ORDER BY page.order_id
        ) AS page_position
    FROM page
)
SELECT ...
FROM ranked
CROSS JOIN LATERAL (...)
ORDER BY ranked.order_id;

阶段关系清楚:

page:
  use keyset + limit to bound parent rows

ranked:
  number only the bounded page

final:
  build nested items only for selected parents

如果先 join/aggregate 所有 items,再 LIMIT parent,会做无谓工作,甚至把 LIMIT 作用到 join rows 而不是 orders。

CTE 不是永久 materialized temp table。PostgreSQL 会根据引用次数、side effect 与 MATERIALIZED / NOT MATERIALIZED 选择折叠边界。需要性能结论时看计划;不要拿“CTE 一定是优化屏障”这种旧经验当跨版本规则。

Window 不改变行基数

row_number()

row_number() OVER (ORDER BY page.order_id)

给 page 内每个 order 编号,但不把多行聚合成一行。窗口函数逻辑上在 WHERE/GROUP BY/HAVING 后执行,所以不能直接写:

WHERE row_number() OVER (...) <= 10;

需要再包一层 subquery/CTE 后过滤。

本例的 page_position 是响应可解释性,不是全表序号。第一页和下一页都会从 1 开始;若 API 要“全局第几条”,那会引入全局扫描、并发变化与成本合同,不能偷换。

CTE 不是拆事务

一个 data-modifying CTE 可以在单条 statement 里组合多个写,但:

  • 所有子语句仍是同一 statement snapshot;
  • 执行顺序不是普通过程语言;
  • RETURNING 是各阶段传值方式;
  • error 会回滚整个 statement;
  • 多 statement transaction 仍适合需要条件分支、错误映射与重复请求读取的流程。

本章订单流程用显式 transaction,而不是把所有逻辑压进一条巨大 CTE。选择标准是可验证的 atomicity 与清晰失败语义,不是 SQL 行数最少。

12.2.3 错误码、约束名与领域错误映射

先保留原始身份

服务内部 error 至少保存:

domain code
HTTP status
retryable flag
SQLSTATE when present
constraint name when present
trace_id
cause for internal log/tracing

外部响应:

{
  "error": {
    "code": "database_timeout",
    "message": "database statement exceeded its time budget",
    "retryable": true,
    "trace_id": "trace-timeout-001"
  }
}

不返回 raw SQL、connection string、table internals 或 PostgreSQL DETAIL。内部结构化日志保留:

{
  "msg": "request_error",
  "error_code": "database_timeout",
  "status": 504,
  "retryable": true,
  "trace_id": "trace-timeout-001",
  "sqlstate": "57014"
}

一个建议映射表

条件 HTTP/领域 默认 retryable 备注
invalid JSON/value 400 invalid_* false 在 DB 前拒绝
missing row 404 *_not_found false 只对明确 no-row
same key/different fingerprint 409 idempotency_conflict false 客户端必须换 payload/key
insufficient inventory 409 insufficient_inventory false 业务竞争,不是 DB 故障
amount mismatch 422 amount_mismatch false 语义可解析但不满足合同
23505 409 unique_conflict usually false 最好按 constraint 细分
23503 / 23514 422 database_constraint false 不暴露内部 message
40001 / 40P01 after budget 503 transaction_retry_exhausted true 中间尝试不返回给 client
57014 from DB timeout 504 database_timeout conditional 要结合幂等性
pool acquire deadline 503 pool_unavailable true SQL 尚未执行
client context canceled 499 internal log n/a 客户端通常已离开
42501 500 database_privilege false deployment defect

retryable=true 不是“任意客户端立刻重放”。它只表示协议允许在同一 idempotency contract 下重试;客户端仍要有 deadline、backoff、attempt budget。

同一 SQLSTATE 需要上下文

57014 的 symbolic condition 是 query_canceled。来源可以是:

  • statement_timeout
  • client cancel request;
  • operator pg_cancel_backend()
  • driver context cancellation。

本章故障矩阵分别注入:

SET LOCAL statement_timeout='50ms'
SELECT pg_sleep(0.2)
→ PostgreSQL 57014
→ service 504 database_timeout

HTTP client times out while pg_sleep
→ request context canceled
→ driver cancels DB work
→ service log client_cancelled/499
→ active worker reaches zero

只看到 SQLSTATE 57014 时不要武断写“数据库慢”。需要同时看 application cancellation cause、timeout 配置、database log 与 request timeline。

Constraint name 是可版本化 API

如果服务要把某个 23514 细分为 invalid_state_transition,约束名就成为 error contract:

ch12_sales_order_state_check

重命名、拆分或合并约束都可能改变映射。发布时应:

  • 所有重要约束显式命名;
  • 映射 unknown constraint 到安全通用错误;
  • 在 app/schema coexistence 期接受 old/new 名称;
  • 测试 SQLSTATE + name,不测试英文 message;
  • 记录 PostgreSQL version difference。

不要吞掉未知错误

最危险的映射:

if err != nil {
    return notFound
}

它会把权限失败、连接断开、取消、decode bug 和 schema drift 全伪装成业务缺失。正确的默认分支应:

return controlled 500
preserve trace and internal cause
increment error metric
do not expose raw detail
page/operator alert if it represents contract drift

未知错误不是“用户体验问题”,而是你发现合同不完整的信号。

本节检查表

  • value 使用 $n,identifier 来自封闭白名单;
  • query 显式列出结果,不依赖 SELECT *
  • row cardinality、no-row 与 null 已定义;
  • array aggregate 内部有 ORDER BY
  • pagination 有唯一完整 sort key;
  • CTE 各阶段有明确基数,性能结论来自计划;
  • LATERAL loops 与索引在真实规模评估;
  • window 的 partition/order/frame 语义明确;
  • query mode 与 Go/PostgreSQL 类型映射已测试;
  • SQLSTATE/constraint identity 在 error 中保留;
  • 外部错误不泄露 SQL、凭据或敏感参数;
  • retryable 只在幂等和预算条件下成立;
  • unknown DB error 不被错误映射成 404/409。

参考资料


上一节:数据库契约与应用边界 · 返回本章目录 · 下一节:Go 服务中的连接与事务 · 查看全书目录 · 查看索引中心

12.3 Go 服务中的连接与事务

driver 把 SQL 发给 PostgreSQL;它不会替你决定连接预算、deadline、事务重试和健康语义。真正的服务可靠性来自这些边界能否闭合:

request context
  bounds pool acquisition
  bounds SQL execution
  triggers cancellation

transaction wrapper
  owns begin/commit/rollback
  retries only a complete safe unit

pool configuration
  fits the global connection budget
  exposes wait and cancellation evidence

12.3.1 连接池大小、超时与上下文取消

NewWithConfig 不等于已连接

pgxpool 的构造可以在没有建立连接时返回。启动 gate 必须主动 PingAcquire

config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
    return nil, err
}

config.MaxConns = maxConns
config.MinConns = 0
config.MinIdleConns = minIdleConns
config.ConnConfig.DefaultQueryExecMode =
    pgx.QueryExecModeExec
config.ConnConfig.RuntimeParams["application_name"] =
    "pg36-ch12-api"

pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
    return nil, err
}

pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
    pool.Close()
    return nil, err
}

这只能证明 startup 时取得过一条连接。持续 readiness、业务 SLI 和 pool metrics 仍然必要。

连接预算是一个全局不等式

直连时,一个保守起点:

[ \sum_i (\text{replicas}_i \times \text{MaxConns}_i)

  • \text{admin}
  • \text{migrations}
  • \text{jobs}
  • \text{monitoring} \le \text{PostgreSQL usable connections} ]

usable 不是机械等于 max_connections;要保留:

  • superuser/emergency;
  • Patroni/monitoring/replication;
  • migration 与诊断;
  • failover 后可能同时重连的 headroom;
  • 连接建立的 CPU/内存成本。

有 PgBouncer 后还要分两层:

application pgxpool MaxConns
  = one process's concurrent client connections to PgBouncer

PgBouncer pool_size/reserve/connlimit
  = server connections PgBouncer may open to PostgreSQL

不要把“每个 pod 50 × 100 pods”直接当成 PostgreSQL 5000 条 backend,也不要因此完全忽略 app pool。前一层决定本进程排队、fd 和 PgBouncer client 压力;后一层决定数据库真实并发。两层都过大只会把拥塞从一个队列搬到另一个。

池大小不是 CPU 数量公式

需要从 workload 推导:

concurrency in DB
≈ request rate × time actually holding a DB connection

然后用负载实验观察:

  • acquire wait 与 canceled acquire;
  • database active sessions;
  • CPU、IO、locks 与 cache behavior;
  • p95/p99 request latency;
  • throughput 是否继续增加;
  • failover/reconnect storm。

如果业务一次请求 100 ms,其中 SQL 只占 5 ms,不应在远程调用期间一直占连接。先缩短 hold time,通常比扩大池更有效。

本章把 MaxConns=2 作为故障夹具,不是生产推荐值。两条连接都执行受控 pg_sleep 后:

database workers observed=2
pool canceled acquires=2
liveness=200
readiness=503
business request=503
release → readiness=200

它证明排队与健康语义,不测容量。

Deadline 要形成预算瀑布

不同 timeout 回答不同问题:

client deadline
  total willingness to wait

HTTP/server request deadline
  service execution budget

pool acquire deadline
  queue budget before any SQL starts

statement_timeout
  PostgreSQL statement execution budget

lock_timeout
  one lock acquisition wait budget

transaction timeout / idle-in-transaction timeout
  transaction lifecycle guard

常见设计是让数据库 timeout 早于最外层 deadline,给 rollback、错误映射和响应留出时间:

client 1500 ms
service 1200 ms
pool acquire 150 ms
statement 900 ms
lock 100 ms where appropriate
response/cleanup headroom

数字必须来自 SLO 与实测,不可照抄。要避免:

  • inner timeout 大于 outer,永远没有机会生效;
  • 全局 statement_timeout 误伤 migration/ETL;
  • request context 没传给 Acquire/Exec/Query
  • timeout 后用背景 context 继续业务写;
  • rollback 没有独立有限 cleanup context。

本章业务调用始终传 request.Context();只有 rollback 使用新的 1 秒 cleanup context,避免 client cancel 让 rollback 根本发不出去,也避免无界挂起。

取消不是“goroutine 返回就结束”

HTTP client 离开后要验证整条链:

request context canceled
  → pgx sends cancel / stops waiting
  → PostgreSQL worker leaves active query
  → connection is reusable or discarded safely
  → transaction rolls back
  → no committed business delta

实验:

GET /debug/hold?ms=2000
client transport timeout=400 ms
active worker observed=1
active worker after cancel=0
structured log=client_cancelled / 499

499 是内部 observability 分类,不是 PostgreSQL 或 HTTP 标准必须返回的业务 contract;客户端已经断开,通常收不到该响应。

连接错误与查询错误要分开

pool.Acquire(ctx) 处失败,SQL 还没开始。本章映射:

503 pool_unavailable

取得连接后 statement_timeout

504 database_timeout
SQLSTATE=57014

这一区分能快速回答“请求慢在应用 pool,还是慢在 PostgreSQL”。若只记一个 db_error_total,诊断又退回猜测。

12.3.2 事务函数、失败重试与外部副作用

Wrapper 必须拥有完整 transaction lifecycle

本章 wrapper 每次 attempt:

Acquire
BEGIN with explicit options
run complete callback
COMMIT
on failure ROLLBACK with bounded cleanup context
Release
classify error
retry or return

不能只重试最后一条 SQL:

BEGIN
  read A
  compute decision
  update B → 40001
  retry update B only    ← decision used a dead snapshot

PostgreSQL 对 serialization failure 的要求是 abort 并从 transaction 起点重新执行。40P01 也使 transaction 失败;若选择重试,同样要重放完整业务单元。

Retry allowlist

本章只允许:

40001 serialization_failure
40P01 deadlock_detected

最多三次 attempt,attempt 之间有限 backoff,并受 request context 约束。以下错误不应被 wrapper 盲重试:

error 原因
23505 / 23514 通常是确定性业务/contract 冲突
42501 权限发布缺陷
42P01 / 42703 schema/version 缺陷
57014 预算已耗尽或显式取消
unknown connection loss after COMMIT sent commit outcome 可能未知

连接断开尤其危险:

client did not receive COMMIT response

不等于:

database did not commit

这就是 idempotency ledger 的用途。客户端用同 key 重放,数据库返回既有结果,而不是凭网络异常猜 commit outcome。

Callback 必须可重放

事务 retry callback 中不能做:

  • 发送邮件/短信;
  • 调支付 API;
  • publish broker message;
  • 写不可回滚文件;
  • 增加非幂等外部计数;
  • 返回 response 给 client;
  • 修改无法重置的 process state。

数据库 sequence 自身也是 non-transactional:失败 attempt 可能消耗 ID。业务必须允许 identity gap,不能把连续 ID 当作无失败证据。

本章的 40001 注入器正是利用 sequence 不回滚:

attempt 1:
  nextval=1
  raise 40001
  transaction rolls back

attempt 2:
  nextval=2
  continue
  order commits once

最终是 order 1200002,库存只减 1,outbox 只多 1,metric retry=1。测试的是完整 retry 关系,不是生产中用函数制造错误。

下单 transaction

逻辑顺序:

BEGIN
  optional lab fault before business writes
  claim request key or read existing response
  atomic inventory UPDATE ... RETURNING
  INSERT order
  INSERT item
  INSERT outbox order.placed
  persist idempotent response
COMMIT

任何 domain error 都 rollback,包括:

insufficient inventory
missing SKU
same key + different fingerprint

所以 failed request 不留下一个 response=NULL 的 committed ledger。

支付 transaction

BEGIN
  claim payment key or return existing response
  SELECT order FOR UPDATE
  require state=placed
  require amount=order total
  INSERT one payment
  UPDATE order to paid
  INSERT outbox payment.captured
  persist response
COMMIT

数据库还用:

UNIQUE (payment.order_id)

保护“一单一笔 captured payment”。应用的 state/amount 检查提供领域错误;unique 是竞态或其他 writer 下的最终护栏。

defer tx.Rollback() 不是完整策略

常见 Go pattern:

tx, err := pool.Begin(ctx)
if err != nil { ... }
defer tx.Rollback(ctx)

它可以作为防漏,但仍要回答:

  • commit error 如何分类;
  • canceled ctx 下 rollback 用什么 context;
  • connection 是否仍可复用;
  • callback error 与 rollback error 哪个保留;
  • retry 前连接何时 release;
  • panic 如何处理;
  • max attempts 与 backoff;
  • unknown commit 如何用幂等协议恢复。

一个 helper 减少 boilerplate,不会自动赋予正确业务语义。

12.3.3 健康检查不等于业务可用

三种不同问题

Probe 问题 是否访问 DB 失败动作
liveness process/event loop 是否活着 no restart
readiness 是否应接收新流量 yes, bounded remove from routing
business synthetic 核心功能是否成立 controlled alert/release gate

把 database SELECT 1 放进 liveness,会在数据库短暂不可用或 pool saturated 时重启所有 app,制造 reconnect storm。进程本身没有坏,重启只会放大事故。

Readiness 验证依赖身份

SELECT 1 在这些错误目标也会成功:

wrong database
wrong user
read-only replica
schema migration incomplete
pool route not intended

本章 readiness 返回:

{
  "status": "ok",
  "database": "pg36_shop",
  "user": "pg36_app",
  "writable": true,
  "schema_ready": true
}

公开生产 API 不一定暴露这些字段;可以只在内部 probe 网络保留或转成 metric。验证逻辑本身必须存在。

Readiness 也需要限流与预算

如果 100 pods 每秒 probe 10 次,每次新建连接,健康检查本身就会成为故障。应:

  • 复用 app pool;
  • 使用短 context;
  • 查询轻量稳定 marker;
  • 合理 probe interval 与 failure threshold;
  • 不在 probe 中运行 migration;
  • 区分 startup 较长初始化与 steady-state readiness;
  • 观察 probe 对 pool 的贡献。

本章 readiness 的内部预算为 150 ms。池耗尽时它返回 503,不等一个 1 秒 holder 释放;liveness 同时立即 200。

Ready 不等于核心业务成功

readiness 证明:

can acquire
right identity
writable
schema marker

它不证明:

  • order constraints 与 grants 全部正确;
  • idempotency 能处理重复;
  • outbox 能提交;
  • PgBouncer prepared statement 组合正确;
  • failover 后 in-flight retry 安全;
  • tail latency 达标。

这些由 deployment smoke、fault matrix、持续 SLI 与 synthetic transaction 覆盖。本章 run_service_lab.py 是发布 gate,不应以每秒频率运行;它会真实写入隔离 fixture。

实测连接指标

第二轮全量证据结束时:

pg36_pool_acquire_total=24
pg36_pool_empty_acquire_total=2
pg36_pool_canceled_acquire_total=2
pool acquired=0
pool idle=2
pool total=2
pool max=2

acquire_total=24 会随 probe 和测试步骤改变,不是 golden。关系断言是:

two holders consume max=2
two waiting acquisitions cancel within their own budget
holders finish successfully
pool returns to acquired=0
readiness recovers

本节检查表

  • pool 构造后显式验证首次连接;
  • app replica × MaxConns 纳入全局预算;
  • app pool 与 PgBouncer server pool 分层计算;
  • request context 传给 Acquire/Begin/Exec/Query/Commit;
  • pool wait 与 SQL execution 有不同 timeout/metric;
  • database timeout 早于 outer deadline并留 cleanup headroom;
  • cancel 后 active worker 与 transaction 清零;
  • retry 只处理列明 SQLSTATE;
  • 每次 retry 从 BEGIN 前开始;
  • callback 没有非幂等外部副作用;
  • unknown commit 用 idempotency key 恢复;
  • liveness 不访问 DB;
  • readiness 验证 identity、writable 与 schema contract;
  • synthetic business check 不被当成高频 probe。

参考资料


上一节:为服务设计查询接口 · 返回本章目录 · 下一节:会话状态与连接池陷阱 · 查看全书目录 · 查看索引中心

12.4 会话状态与连接池陷阱

应用看到的“一个数据库连接”可能依次经过:

request
  → application pgxpool connection
  → HAProxy service
  → PgBouncer client connection
  → one of many PostgreSQL server connections

当 PgBouncer 采用 transaction pooling 时,client connection 不是 PostgreSQL session 的永久所有者。应用必须把正确性限制在 transaction 边界,不能把上一个 transaction 留下的 session state 当作下一个 transaction 的前提。

12.4.1 session、transaction 与 statement pooling

三种“归还 backend”的时刻

mode server connection 何时归还 兼容性 复用率
session client 断开 最接近直连 最低
transaction transaction 结束 app 必须 transaction-aware
statement 每条 statement 后 multi-statement transaction 不成立 最高、限制最多

session pooling 保留一个 client 对一个 backend 的 session 关系,许多 session feature 可用,但吸收连接峰值的能力有限。

transaction pooling:

BEGIN
  all statements use one backend
COMMIT
backend returns to pool
next transaction may use another backend

这非常适合把业务状态装进显式 transaction 的服务,也意味着跨 transaction 的 session assumption 会破坏。

statement pooling 连一个多语句事务都不能正常表达,不适合作为本章业务主线。

Transaction pooling 中哪些东西会坏

PgBouncer 官方 feature map 明确指出 transaction pooling 下不能依赖:

  • 普通 SET/RESET 的跨事务效果;
  • LISTEN
  • SQL-level PREPARE/DEALLOCATE
  • WITH HOLD cursor;
  • preserve/delete rows temp tables;
  • session-level advisory locks;
  • LOAD

例:

SET app.tenant_id = 'tenant-a';
COMMIT;

BEGIN;
SELECT ...;  -- 可能已经换 backend

第二个 transaction 不应假定 app.tenant_id 仍存在。更糟的是,拿到的 backend 可能曾服务另一个 client;可靠 pooler 会 reset/track 一部分参数,但应用不能把未知 session residue 当作隔离机制。

一个 transaction 内的状态仍然有意义

transaction pooling 在 BEGINCOMMIT/ROLLBACK 期间固定 backend,所以:

BEGIN;
SET LOCAL app.tenant_id = 'tenant-a';
SELECT ...;  -- same transaction/backend
COMMIT;

语义成立。关键不是“永远不用 SET”,而是:

state lifetime <= transaction lifetime

且每个 transaction 都重新建立需要的 context。

Autocommit 是 transaction

一条没有显式 BEGIN 的 SQL 在 PostgreSQL 中也运行于一个 transaction;在 transaction pooling 中,statement 完成后 backend 就可能归还。

所以这种代码不成立:

Exec("SET LOCAL ...")   // outside explicit transaction; warning/no effect
Query("SELECT ...")     // another transaction/backend

SET LOCAL 必须与受保护查询在同一个显式 transaction object 上执行。

不要用 session advisory lock 做跨请求 ownership

第 10 章已区分 transaction/session advisory lock。transaction pooling 下:

pg_advisory_lock()
client transaction ends
backend returns, session lock may remain on backend
next client may inherit effect
original client cannot reliably unlock same backend

这既会泄漏锁,也会让 unlock 不可达。使用:

  • pg_advisory_xact_lock,生命周期绑定当前 transaction;
  • 或把 ownership 建模成持久 lease/row;
  • 或为确需 session affinity 的任务使用单独 direct/session-pooled service。

不能为一个特殊 job 把所有在线业务都切到 session pooling;服务职责可以拆分。

12.4.2 预备语句行为必须绑定 PgBouncer 与驱动版本

“Prepared statement 与 PgBouncer 不兼容”过于粗糙

需要至少区分:

SQL PREPARE name AS ...
protocol-level named prepared statement
unnamed statement/extended protocol
driver statement cache
description cache
simple protocol interpolation
PgBouncer prepared-statement tracking

PgBouncer 1.21 起可以在 transaction pooling 中跟踪 protocol-level named prepared statements,但必须:

max_prepared_statements > 0

它会在 client/server name 之间重写,并确保目标 backend 已准备该 query。SQL-level PREPARE/DEALLOCATE 仍不受 transaction pooling 支持。

因此不能从“PgBouncer 版本够新”直接推导“所有 driver 默认都安全”。要验证:

PgBouncer exact version
max_prepared_statements actual value
pool_mode at database/user level
driver exact version
driver query mode
query parameter/result types
DDL/cache invalidation behavior
reconnect procedure

pgx 的默认行为

pgx v5.10.0 默认 QueryExecModeCacheStatement

extended protocol
automatically prepare and cache statements
single round trip after cached

如果 schema 或 search_path 在缓存后变化,第一次重新执行可能失败,例如 SELECT * 列数变化或 result type 变化。pgx 文档也提示默认 prepared statements 可能与 proxy/PgBouncer 不兼容,建议按环境选择 QueryExecModeExec 或在必要时 simple protocol。

本章保守固定:

config.ConnConfig.DefaultQueryExecMode =
    pgx.QueryExecModeExec

该模式:

  • 仍使用 extended protocol;
  • 不使用 named prepared statement cache;
  • 根据 Go argument type 推断 PostgreSQL parameter type;
  • 使用 text-formatted parameters/results;
  • 单 round trip;
  • 比 simple protocol 更优先。

这减少了本章未验证 PgBouncer config 下的一个变量,不代表 statement cache 永远不该用。生产若确认 PgBouncer tracking 与 workload 收益,应单独 A/B 并保存版本/config/DDL 恢复证据。

不要误用 SimpleProtocol

simple protocol 不是“更安全的参数化”。pgx 会在 client 端对参数插值并转义,它适合某些不支持 extended protocol 的 proxy。对标准 PostgreSQL/PgBouncer,优先尝试 QueryExecModeExec

simple/exec mode 还要求你认真处理 type mapping,尤其 []byte、JSON、用户自定义类型。不要为躲开 prepared statement 问题,悄悄改变参数编码语义而不运行 contract suite。

DDL 后的 cached plan

PgBouncer prepared-statement tracking 提升复用,但若相同 query 的 parameter/result types 在 DDL 后改变,PostgreSQL 可能报:

cached plan must not change result type

PgBouncer 文档建议这类 migration 后通过 admin console RECONNECT 让 server connections 重建计划。发布设计应回答:

  • DDL 是否改变返回列数/type;
  • old/new app 是否使用相同 query text 却期待不同 shape;
  • 是否使用 SELECT *
  • app pool 是否也有 cache;
  • PgBouncer reconnect 如何执行、影响多少连接;
  • reconnect storm 与 rollback;
  • failure metric 与 smoke query。

不能把 RECONNECT 当作每次 DDL 的盲目万能命令;先证明目标、作用域和版本。

建议的组合矩阵

pgx mode PgBouncer transaction pool 需要验证
cache_statement tracking on exact versions/config, DDL invalidation
cache_statement tracking off/unknown 不应默认放行
cache_describe no named plan result/arg type drift
describe_exec two round trips pooler round-trip backend affinity
exec conservative mainline type mapping, performance
simple_protocol fallback only client interpolation/type semantics

“能跑一条 SELECT 1”不能覆盖这个矩阵。本章 promotion blocker 要求用完整下单/支付/DDL smoke 通过实际 primary service。

12.4.3 SET LOCAL、事务边界与 RLS 上下文

SETSET LOCAL

PostgreSQL:

SET / SET SESSION
  current session
  if transaction commits, value persists after transaction

SET LOCAL
  only current transaction
  COMMIT or ROLLBACK ends it
  outside transaction block warns and has no effect

transaction pooling 的主线应是:

tx, err := conn.BeginTx(ctx, options)
...
_, err = tx.Exec(
    ctx,
    `SELECT set_config('app.tenant_id', $1, true)`,
    tenantID,
)
...
rows, err := tx.Query(ctx, tenantScopedSQL, ...)

set_config(..., true) 的第三个参数表示 transaction-local,便于参数化 value。不要拼:

SET LOCAL app.tenant_id = '<user input>';

RLS context 要 fail closed

若 ch23 使用:

current_setting('app.tenant_id', true)

policy 要明确 missing context 是:

zero rows / reject

而不是 fallback 到“全部租户”。还要验证:

  • runtime role 不具 BYPASSRLS
  • object owner 是否绕过 RLS;
  • FORCE ROW LEVEL SECURITY 是否需要;
  • SECURITY DEFINER 是否重新建立 context;
  • connection reset 后不存在可继承 tenant;
  • transaction retry 每次重新 SET LOCAL
  • background jobs 使用什么身份。

把 tenant 放到 application_name 不安全也会产生高基数;它是 observability label,不是授权 context。

同一 transaction 才能相信 context

错误:

pool.Exec(ctx, "SELECT set_config(..., true)")
pool.Query(ctx, tenantSQL)

两个 pool method 可能 acquire 不同连接,也一定是不同 autocommit transaction。正确:

tx, _ := pool.Begin(ctx)
tx.Exec(ctx, setLocalSQL, tenant)
tx.Query(ctx, tenantSQL)
tx.Commit(ctx)

若 query 是单条并且能把 tenant 作为普通 $1 predicate,就优先显式参数;RLS context 用于数据库必须统一执行的访问策略,不是减少一个参数的技巧。

search_path 也不要依赖 session residue

本章所有对象 schema-qualified:

shop_ch12.sales_order

SECURITY DEFINER function 固定:

SET search_path = pg_catalog

然后引用 qualified object。这样:

  • transaction pooling 不依赖前一 transaction 的 path;
  • 恶意同名 object 更难劫持;
  • query contract 明确;
  • migration 与 app 看同一对象。

若通过 role/database startup parameter 固定 search_path,仍要把它作为 connection contract 验证。

12.4.4 在 ch22、ch23 分别深化池化与权限

本节只建立应用必须知道的最小边界,不在这里展开两个独立大主题。

第 22 章将深入连接治理:

max_connections budget
PgBouncer topology and auth
pool_size/reserve/connlimit
queueing and admission control
pause/resume/reconnect
failover and connection storm
per-user/per-database pools
SHOW POOLS/STATS evidence

第 23 章将深入安全与访问:

roles and ownership
default privileges
RLS and FORCE RLS
tenant/session context
SECURITY DEFINER hardening
credential rotation
TLS/HBA
audit and break-glass

本章保留的 cross-chapter contract:

  1. 在线服务默认可以在 transaction pooling 下正确运行;
  2. 正确性不依赖跨 transaction session state;
  3. runtime role 最小权限、不是 object owner、没有 BYPASSRLS;
  4. connection/query mode 与 pooler 版本配置绑定;
  5. 特殊 session workload 使用独立 service,不污染在线主线;
  6. 权限或 pool 配置变化后重跑同一业务/failure matrix。

本节检查表

  • 知道实际 pool_mode,不从端口名猜;
  • transaction pooling 下没有跨事务 SET 前提;
  • LISTEN、temp table、cursor、advisory lock 的 lifetime 已评审;
  • transaction-local state 与业务 SQL 在同一 tx object;
  • pgx 与 PgBouncer exact versions 已记录;
  • max_prepared_statements 实际值已记录;
  • 区分 protocol prepared、SQL PREPARE 与 driver cache;
  • query mode 改变后重新验证 type mapping;
  • DDL result-shape change 有 cache/reconnect 计划;
  • SET LOCALset_config(..., true) fail closed;
  • tenant context 不是 authorization 的唯一应用侧证据;
  • SQL schema-qualified,SECURITY DEFINER path 固定;
  • 特殊 session workload 使用独立连接路径。

参考资料


上一节:Go 服务中的连接与事务 · 返回本章目录 · 下一节:服务级可观测性 · 查看全书目录 · 查看索引中心

12.5 服务级可观测性

一次请求跨过 HTTP、应用 pool、PgBouncer、PostgreSQL transaction 与 outbox。每层都有自己的 identity:

request/trace ID       one logical request or business flow
idempotency key        one replayable business command
application_name       one low-cardinality workload class
query identity         one normalized SQL shape
transaction/backend    one execution attempt
outbox event key       one publishable fact
release identity       one application/database artifact

把它们全塞进一个字符串会失去可聚合性;一个都不关联又无法从用户症状走到数据库证据。

12.5.1 请求、事务、查询指纹与 application_name

Identity 分层

本章的关联关系:

request:
  X-Request-ID=trace-order-001

database session class:
  application_name=pg36-ch12-api

durable facts:
  sales_order.trace_id=trace-order-001
  outbox.trace_id=trace-order-001
  outbox.event_key=order:order-001:placed

business replay:
  request_key=order-001
  fingerprint=sha256(canonical command fields)

trace_id 告诉你一次调用从哪里来;request_key 告诉数据库它是否与先前命令是同一个业务意图。两者不能互换:

  • retry 可以有新 trace,但保留同 idempotency key;
  • 同一 trace 可能跨多个内部调用;
  • trace 通常有保留期,idempotency ledger 是业务状态;
  • trace 不应承担 uniqueness/authorization。

application_name 保持低基数

本章所有业务 backend:

application_name=pg36-ch12-api

不要每请求改成:

pg36-api/tenant-123/user-456/trace-abcdef...

否则:

  • metrics cardinality 爆炸;
  • pg_stat_activity 分组失去意义;
  • PgBouncer tracking/reset 行为更复杂;
  • 日志与 dashboard 成本上升;
  • 可能泄露 tenant/user。

合理粒度:

pg36-api-rw
pg36-worker-outbox
pg36-migration
pg36-read-report

必要时附固定 version/channel,但先评估 cardinality。per-request identity 放结构化 log/trace,不放 session label。

Query identity 不等于 raw SQL + values

观测 query 应优先关联:

  • PostgreSQL query_id / pg_stat_statements.queryid
  • normalized query text;
  • driver operation name;
  • route/query bundle mapping;
  • application version。

不要把 password、token、PII、完整 JSON 或 payment data 作为 metric label。参数对诊断重要时:

  • 保存经过批准的 bounded class,例如 sku_class=known/missing
  • 对值 hash/pseudonymize;
  • 只在短期受控 evidence 中保留;
  • 遵循日志保留与访问政策。

第 8 章已说明 query text 与 parameter identity 缺一不可;本章增加了 API route 与 trace,但没有取消隐私边界。

Transaction attempt 与 logical request

trace-retry-001 的服务请求只返回一次,数据库执行了两个 transaction attempt:

attempt 1 → 40001 rollback
attempt 2 → commit order 1200002

metrics 同时需要:

request total=1
transaction retry total=1
SQLSTATE 40001 total=1
committed order total=1

如果只看 HTTP 201,会漏掉 serialization pressure;如果把每次 attempt 都算一个请求,会夸大业务流量。

结构化日志

正常请求:

{
  "msg": "request",
  "route": "orders.create",
  "status": 201,
  "duration_ms": 3.004,
  "trace_id": "trace-order-001"
}

数据库 timeout:

{
  "msg": "request_error",
  "error_code": "database_timeout",
  "status": 504,
  "retryable": true,
  "trace_id": "trace-timeout-001",
  "sqlstate": "57014",
  "constraint": ""
}

日志不包含:

PG36_DATABASE_URL
password
raw request body
full SQL arguments
stack trace in client response

不是所有错误都要打 stack;预期 domain conflict 应按可聚合 code 记录,真正未知 defect 才需要更深内部 cause。

12.5.2 延迟、错误、连接等待和数据库等待

一个慢请求至少有四段

[ T_{request} = T_{app}

  • T_{pool}
  • T_{db}
  • T_{response} ]

T_db 还可拆:

parse/plan
execution CPU/IO
lock wait
client read/write wait
commit/WAL

仅看总延迟无法决定加 index、加 connection 还是减并发。

应用 pool wait

pgxpool 暴露:

AcquireCount
AcquireDuration
EmptyAcquireCount
EmptyAcquireWaitTime
CanceledAcquireCount
AcquiredConns
IdleConns
TotalConns
MaxConns

本章转换成:

pg36_pool_acquire_total
pg36_pool_acquire_seconds_total
pg36_pool_empty_acquire_total
pg36_pool_canceled_acquire_total
pg36_pool_connections{state=...}

CanceledAcquireCount 上升表示请求在拿到 DB connection 前就耗尽 context。此时 PostgreSQL pg_stat_activity 看不到对应 query;从数据库侧“没有慢 SQL”并不能证明数据库路径无关,连接预算或 pool queue 可能已经挡在前面。

PgBouncer wait

通过 Pigsty primary service 时还有 PgBouncer 队列。需要结合:

  • PgBouncer client active/waiting;
  • server active/idle;
  • pool size/reserve;
  • average/max wait;
  • connection errors;
  • user/database pool identity;
  • HAProxy/service health。

app pool 不等待、PostgreSQL backend 也不多,仍可能是 PgBouncer client 在等 server slot。第 22 章会用 SHOW POOLS / SHOW STATS 和 Pigsty dashboard 深化。

PostgreSQL wait

取得 backend 后,观察:

SELECT
    pid,
    application_name,
    state,
    wait_event_type,
    wait_event,
    query_id,
    xact_start,
    query_start
FROM pg_catalog.pg_stat_activity
WHERE application_name = 'pg36-ch12-api';

典型解释:

state/wait 方向
active + Lock blocking graph
active + IO plan/buffers/storage
active + Client* application consume/send
idle in transaction leaked transaction/locks/vacuum impact
no backend + app pool wait app-side admission
PgBouncer waiting + few DB slots pooler/server budget

不要把 state=active 等同于“在用 CPU”;wait event 才说明当前等待类型。

Error 指标保留 SQLSTATE 与领域 code

本章同时记录:

HTTP route/status class
domain error code in logs
SQLSTATE counter
transaction retries
idempotent replays

一次运行:

pg36_db_errors_total{sqlstate="40001"}=1
pg36_db_errors_total{sqlstate="57014"}=1
pg36_transaction_retries_total=1
pg36_idempotent_replays_total=2

SQLSTATE cardinality 是 bounded vocabulary;constraint name 通常也相对 bounded,但 table/tenant/raw error message 不适合无审查直接做 label。

Rate、error、duration 与 saturation 同看

最小服务面板:

traffic:
  requests/s by route

errors:
  status class + domain code + SQLSTATE

duration:
  p50/p95/p99 request
  DB/query duration
  pool acquire duration

saturation:
  app pool acquired/max/wait/cancel
  PgBouncer wait/server slots
  PostgreSQL active/wait/CPU/IO

平均值会隐藏 tail。pool wait 若只占 1% 请求,平均可能很小,p99 已经超时。

12.5.3 从一次请求追到数据库证据

一条可执行调查路径

用户报告下单超时,先固定:

UTC window
route=orders.create
trace_id
idempotency key if authorized
application version
service endpoint/pool mode

然后沿层次走:

1. application log
   status/domain code/duration/trace

2. pool metrics
   acquired/max/acquire wait/canceled

3. PgBouncer/Pigsty
   client wait/server slots/service target

4. PostgreSQL
   application_name/query_id/wait/blocker/SQLSTATE

5. durable business evidence
   idempotency ledger/order/outbox

6. client outcome
   response received, timed out, or unknown

最后一步不是“SQL 后来成功了吗”,而是:

logical command committed?
what response is persisted?
is replay safe?
is an outbox event pending?

实测 trace 关系

本章 observer 独立于 app 查询:

order 1200001:
  sales_order.trace_id=trace-order-001

payment 1200001:
  payment.trace_id=trace-payment-001

outbox:
  order:order-001:placed
    → trace-order-001
  order:order-retry:placed
    → trace-retry-001
  payment:pay-001:captured
    → trace-payment-001

pg_stat_activity:
  distinct application_name=[pg36-ch12-api]

这让 operator 能从 request 走到 committed fact,又保持 database session label 可聚合。

诊断一次 pool exhaustion

实验时间线:

t0:
  holder-1 acquires connection
  holder-2 acquires connection
  database active sleepers=2

t1:
  /health/live → 200

t2:
  /health/ready waits its 150 ms budget
  → 503 pool_unavailable

t3:
  business GET waits 100 ms request budget
  → 503 pool_unavailable

t4:
  holders complete 200
  acquired returns 0
  readiness → 200

证据组合:

app pool max=2
canceled acquisition=2
PostgreSQL had exactly two sleepers
no business SQL for rejected GET
liveness unaffected
recovery without process restart

这排除了“PostgreSQL query 本身超时”,并证明 admission queue 按预算失败。

诊断一次 statement timeout

trace=trace-timeout-001
fault=statement-timeout
SET LOCAL statement_timeout=50ms
pg_sleep(200ms)
SQLSTATE=57014
HTTP=504 database_timeout
state snapshot before == after

若只看 504,会与 gateway timeout 混淆;若只看 57014,又会与 client cancel 混淆。timeline + source timeout + state comparison 才是完整证据。

Unknown commit 的调查

客户端 timeout 后不要先删 key 再重试。先用相同 idempotency key 查询/重放:

ledger has completed response
  → return it; command committed

ledger absent
  → safe to attempt command

ledger exists but incomplete
  → protocol defect/manual repair path

本章 schema constraint 允许 transaction 内暂时 response=NULL,但完整 transaction 失败会 rollback;正常 committed state 只能是 key+order/payment+response 完整。verify 把 incomplete committed row 当失败。

本节检查表

  • trace、idempotency、application、query、event identity 分层;
  • application_name 是低基数 workload class;
  • request retry 与 transaction attempt 分开计数;
  • log 使用结构化 code,不泄露 secret/raw payload;
  • request、pool、PgBouncer、DB 延迟可分别观察;
  • pool canceled acquire 有独立 metric;
  • pg_stat_activity 同时看 state 与 wait event;
  • SQLSTATE 与 domain code 都保留;
  • tail latency 与 saturation 同窗;
  • trace 能关联到 durable order/payment/outbox;
  • unknown commit 通过 ledger 判断,不凭网络结果猜;
  • 故障结论同时包含反证与 committed state。

参考资料


上一节:会话状态与连接池陷阱 · 返回本章目录 · 下一节:部署与接入 pg36_shop · 查看全书目录 · 查看索引中心

12.6 部署与接入 `pg36_shop`

部署数据库应用不是把一条 URL 放进环境变量。完整交付关系是:

role identity and privilege
  + database/schema contract
  + credential delivery
  + service endpoint and routing semantics
  + application/pool configuration
  + readiness and business evidence
  + rollback/reconnect procedure

Pigsty 提供 role/database 声明、PgBouncer、HAProxy service 与监控;应用团队仍要声明自己使用哪个入口、依赖什么 pool mode、允许多少并发,以及如何证明业务合同成立。

12.6.1 角色、数据库、服务与凭据声明

Owner 与 runtime 分离

本书延续第 4、6 章角色:

pg36_owner
  NOLOGIN
  object owner
  migration effective role

pg36_app
  LOGIN
  runtime DML only
  no CREATEDB/CREATEROLE/SUPERUSER/REPLICATION/BYPASSRLS

pg36_ro
  LOGIN
  reviewed read path

应用绝不能用 owner 连接。否则:

  • schema injection/DDL defect 的 blast radius 扩大;
  • owner 可能绕过 RLS;
  • migration 与业务 activity 无法区分;
  • secret 泄露可修改所有 objects;
  • readiness 的 current_user 失去保护价值。

本章 setup 最终断言:

current_user=pg36_app
has_schema_privilege(CREATE)=false
has_table_privilege(DELETE)=false

“最小权限”必须由 catalog 证明,不能只看 YAML。

Pigsty declaration

声明示例 中的关键部分:

pg_users:
  - name: pg36_app
    login: true
    superuser: false
    createdb: false
    createrole: false
    replication: false
    bypassrls: false
    connlimit: 40
    pgbouncer: true
    pool_mode: transaction
    pool_connlimit: 32

pg_databases:
  - name: pg36_shop
    owner: pg36_owner
    revokeconn: true
    pgbouncer: true
    pool_mode: transaction
    pool_size: 32
    pool_reserve: 8
    pool_connlimit: 64

这些数字是教学起点,不是容量答案。需要按:

app replicas and MaxConns
PgBouncer pool partitioning by user/database
PostgreSQL connection budget
workload hold time
HA/failover headroom

重新计算。

Pigsty declaration 负责创建/管理 cluster-level identity;对象级:

GRANT
ALTER DEFAULT PRIVILEGES
schema marker
named constraints
SECURITY DEFINER
fixture/migration

仍应进入 versioned SQL 与 code review。不要把所有权限散落在临时 psql 历史里。

Credential 是输入,不是源码

样例只要求:

PG36_DATABASE_URL

但不记录它。生产建议:

  • secret store/inventory overlay 生成 runtime file;
  • file owner 是 service account,mode 0600;
  • 不进 Git、artifact、process args、日志;
  • role credential 可轮换;
  • 支持 overlap/dual credential 时有明确窗口;
  • TLS verification 与 CA/hostname 按环境配置;
  • PgBouncer auth 与 PostgreSQL role 同步路径已验证。

systemd unit 示例 从:

/etc/pg36-api/runtime.env

读取变量。示例 URL 故意没有密码;部署系统负责填充 secret。不要把:

ExecStart=... --database-url 'postgres://user:password@...'

写进 process list。

Connection string 也要版本化非秘密部分

应记录:

host/service DNS
port
database
user
sslmode
target_session_attrs if used
connect timeout
application_name
query mode
pool bounds

秘密值单独管理。发布 evidence 可以保存“参数名与非秘密身份”,不保存 expanded URL。

12.6.2 通过连接池和服务端点接入

Pigsty 默认服务语义

Pigsty v4.5 默认:

service port target
primary 5433 read/write primary via PgBouncer 6432
replica 5434 read-only replicas via PgBouncer 6432
default 5436 direct primary PostgreSQL 5432
offline 5438 direct offline/replica analytical path

因此:

online application → primary :5433
reviewed DDL/admin → default :5436

不是“应用永远只能用 5433”的宇宙规则:Pigsty 允许修改 service destination 或自定义 services。运行手册必须保存目标集群的实际配置,不能仅凭端口推断。

为什么 migration 用 direct path

DDL、session-level diagnostic、某些 bulk operation 或 pooler admin 不适合 transaction pool。direct service:

  • session identity 稳定;
  • prepared/session state 边界简单;
  • DDL error 与 backend 更直接;
  • 不与在线 client pool 混在同一入口。

这不等于 direct path 可绕过审核。它应只对 migration/admin role 开放,设置:

application_name
lock_timeout
statement_timeout
target guard
change identity
evidence directory

应用运行角色不需要 direct 管理权限。

应用侧仍然使用 pgxpool

PgBouncer 不是 Go 并发安全 connection handle 的替代。应用侧 pool:

  • 复用 client connections;
  • 限制每个 process 同时进入数据库路径的请求;
  • 暴露 acquisition queue;
  • 管理 connection lifetime/health;
  • 将 request context 传播到 acquire。

但两层池不要无限叠加:

100 pods × MaxConns 100
→ 10,000 PgBouncer clients

即使 PostgreSQL 只有 64 server connections,app、network、PgBouncer fd/memory 和排队仍可能过载。

晋级时使用不变的 service suite

本地验证连接:

service=pg36-admin user=pg36_app
direct PostgreSQL socket

manifest 明确:

validation_path=direct-postgresql
pooler_validation=not-run

Pigsty 晋级不能把字段手改成 runtask.sh 保留 admin PGSERVICE 用于 setup/observer,并允许用独立的 PG36_APP_DATABASE_URL 指向实际 primary service:

cd static/labs/ch12
export PGSERVICEFILE=/secure/path/pg_service.conf
export PGSERVICE=pg36-admin
export PG36_APP_DATABASE_URL='postgres://pg36_app@pg-demo:5433/pg36_shop?sslmode=verify-full'
./task.sh all

不设置该变量时,本地教学路径从 named admin service 派生 user=pg36_app 的 direct connection。设置后,manifest 只会声明:

validation_path=operator-supplied-application-endpoint
pooler_validation=behavior-run-config-identity-required

这表示完整行为矩阵确实经过该 endpoint,但 endpoint 自称是 5433 仍不能证明其内部 pool mode/config。promotion job 应显式接收两个独立目标:

admin direct URL
application primary/PgBouncer URL

并在证据中保存:

  • resolved service/port;
  • PgBouncer version;
  • database/user pool_mode;
  • max_prepared_statements
  • TLS/auth identity;
  • query mode;
  • primary writable identity;
  • full HTTP/failure suite。

不要为了“复用脚本”让 admin DDL 也走 app pool,或让 app smoke 使用 owner。

Failover 不由连接串自动变安全

Pigsty service 可在 primary 变化后把新连接路由到新主库。但 in-flight transaction 可能:

  • 连接断开;
  • rollback;
  • commit outcome unknown;
  • 请求超时后在新 primary 重试。

应用仍需要:

  • idempotency key;
  • finite retry;
  • writable readiness;
  • no remote side effect in transaction callback;
  • failover fault test;
  • reconnect/backoff jitter;
  • old primary fencing 由 HA layer 保证。

“HA service”解决目标发现与路由,不替应用解决命令重放语义。

12.6.3 用平台指标验证部署,而非只看进程存活

Deployment gate

部署后按层验证:

process:
  exact binary/config checksum
  liveness

service:
  DNS/VIP/HAProxy target
  TLS/auth
  primary writable identity

pool:
  pgxpool bounds
  PgBouncer mode and slots
  no unexpected waiting/cancel spike

database:
  current_database/current_user
  schema marker
  privileges
  query/constraint contract

business:
  idempotent order/payment smoke
  outbox and trace

operations:
  logs/metrics/dashboard
  rollback and reconnect

systemctl is-active 只覆盖第一层的一小部分。

Pigsty 观察面

发布窗口至少同时看:

  • PostgreSQL overview/cluster/instance;
  • active sessions 与 wait events;
  • query statistics 与 error/latency;
  • PgBouncer clients, servers, pools, wait;
  • HAProxy/service health;
  • WAL rate 与 replica lag;
  • CPU、memory、disk、network;
  • application R/E/D/S(rate/error/duration/saturation)。

具体 dashboard 名称随 Pigsty 版本调整,运行手册应链接目标环境实际页面,不在代码里硬编码一串脆弱 panel ID。

用关系做验收

不设跨环境绝对 golden:

p95 < 12.3 ms
acquire count = 24
backend PID = 12345

应设合同关系和 SLO:

same idempotency key does not add writes
pool saturation fails within budget
liveness does not consume DB slot
readiness removes unready instance
cancel clears backend
SQLSTATE ratio remains within error budget
no idle-in-transaction leak
primary change does not duplicate business effect
tail latency meets declared SLO under declared load

其中最后两项必须在目标环境实测。

发布后观察而非立即 contract

应用 100% 新版本不等于旧连接/worker 已退出。保留观察窗口:

old application_name/query identity=0
old deployment replicas=0
queue/cron/ETL inventory reviewed
new error/tail latency stable
pool wait stable
rollback artifact still usable

满足后才让第 11 章 contract gate 进入审批。数据库 DDL 与应用 rollout 的 observability 要合并在同一个 UTC window 中。

失败时停止什么

信号 首要动作
wrong DB/user/replica readiness fail,立即停止流量
schema marker missing 停应用晋级,检查 migration state
PgBouncer mode/config unknown 不发布 prepared/session-dependent path
pool wait 上升、DB 未饱和 降 app admission/查 pooler,不先加 index
DB lock/IO saturated 停 rollout/回退流量,按第 8 章诊断
idempotency duplicate 停止写入晋级,保存 ledger/outbox
unknown commit during failover 同 key 查询/重放,不换 key
logs 泄露 secret 安全事件处置与 credential rotation

本节检查表

  • owner NOLOGIN,runtime 非 owner;
  • Pigsty user/database declaration 无明文 secret;
  • object grants 由 versioned SQL 管理;
  • catalog 证明 app 无 CREATE/DELETE/BYPASSRLS;
  • primary/default/offline service 职责明确;
  • 实际 service destination/pool_mode 已验证;
  • app pool 与 PgBouncer pool 联合预算;
  • app path 与 admin path 使用不同 role/endpoint;
  • deployment artifact 不记录 expanded URL;
  • readiness 验证 writable primary;
  • full business/failure suite 通过实际 pooler path;
  • Pigsty、PgBouncer、PostgreSQL 与 app 同窗观察;
  • failover unknown commit 通过 idempotency 处理;
  • 旧 artifact/worker identity 清零后才 contract。

参考资料


上一节:服务级可观测性 · 返回本章目录 · 下一节:实战:交付应用闭环与规约 v1.0 · 查看全书目录 · 查看索引中心

12.7 实战:交付应用闭环与规约 v1.0

本节把交付做成一条两次运行的证据链:

target/model guard
  → exact shop_ch12 fixture
  → build frozen Go module
  → start as pg36_app / MaxConns=2
  → business + idempotency matrix
  → timeout/retry/cancel/pool faults
  → SQL/catalog/model verification
  → wrong-token reset refusal
  → active-service reset refusal
  → wrong-target reset refusal
  → exact reset
  → ch04 checksum verification
  → rebuild from empty
  → rerun the same suite
  → release-candidate review

它证明 reference implementation 在当前直连组合中的机制;不把本地几秒钟实验写成 Pigsty HA、PgBouncer 或生产容量证据。

12.7.1 跑通下单、扣库存、支付幂等与查询

确认目标是可重建 L1

export PGSERVICEFILE=/absolute/private/path/pg_service.conf
export PGSERVICE=pg36-admin

psql -X -w \
  --dbname='service=pg36-admin application_name=pg36-ch12-preflight' \
  --command="
    SELECT
        current_database(),
        session_user,
        current_setting('server_version'),
        pg_is_in_recovery();
  "

只在已确认的开发/测试目标继续。脚本还会 fail closed:

database must be pg36_shop
target must be writable
PostgreSQL >= 14
session can SET ROLE pg36_owner
ch04-v1 marker exists
pg36_app is constrained LOGIN

运行:

cd static/labs/ch12
export PG36_EVIDENCE_DIR="$PWD/evidence/ch12/all-$(date -u +%Y%m%dT%H%M%SZ)"
./task.sh all

可选 action:

setup | build | run | verify | review | reset | all

setupall 会重建 shop_ch12;不要在未确认的目标运行。

Fixture

初始库存:

SKU available version price
PG36-SKU-001 10 0 12900 CNY minor
PG36-SKU-002 5 0 8900 CNY minor

所有业务表为空。identity 从 1200001 开始,让 API assertion 稳定;identity gap 在真实系统合法。

创建订单

curl --fail-with-body \
  -H 'Content-Type: application/json' \
  -H 'X-Request-ID: trace-order-001' \
  --data '{
    "request_key": "order-001",
    "customer_ref": "customer-001",
    "sku": "PG36-SKU-001",
    "quantity": 2
  }' \
  http://127.0.0.1:18012/v1/orders

返回:

{
  "order_id": 1200001,
  "state": "placed",
  "total_minor": 25800,
  "currency_code": "CNY"
}

提交关系:

SKU-001 10:v0 → 8:v1
order 1200001 placed
one order item quantity=2
order_request order-001 complete
outbox order:order-001:placed

重放与冲突

相同 body、相同 request_key

HTTP 201
Idempotency-Replayed: true
body exactly equals first persisted response
inventory remains 8:v1
order count remains 1
outbox count remains 1

相同 key、quantity 改成 3:

{
  "error": {
    "code": "idempotency_conflict",
    "retryable": false,
    "trace_id": "trace-order-conflict"
  }
}

HTTP 409,状态不变。

此外:

quantity=999 → 409 insufficient_inventory
valid-shape missing SKU → 404 sku_not_found
unknown JSON field → 400 invalid_json
quote/SQL-shaped SKU → 400 invalid_order

前两条已经进入 transaction,但 domain error 会 rollback request ledger;不存在“失败 key 占住以后永远不能重试”的半成品。

第二笔订单通过 40001 retry

request_key=order-retry
SKU-002 quantity=1
lab header X-PG36-Fault=retry-once

结果:

attempt 1 → 40001 / rollback
attempt 2 → 201 / order 1200002
SKU-002 5:v0 → 4:v1 exactly once
outbox adds exactly one event

fault header 只有 PG36_ENABLE_FAULTS=1 时接受;生产 unit 不设置该变量。

支付

先用错误金额:

pay-wrong / amount=1
→ 422 amount_mismatch
→ payment_request rolled back

正确请求:

curl --fail-with-body \
  -H 'Content-Type: application/json' \
  -H 'X-Request-ID: trace-payment-001' \
  --data '{
    "idempotency_key": "pay-001",
    "order_id": 1200001,
    "amount_minor": 25800
  }' \
  http://127.0.0.1:18012/v1/payments

响应:

{
  "payment_id": 1200001,
  "order_id": 1200001,
  "state": "captured",
  "amount_minor": 25800,
  "currency_code": "CNY"
}

提交:

payment 1200001
order 1200001 placed → paid
payment_request pay-001 complete
outbox payment:pay-001:captured

同 key/body 重放返回相同 payment;同 key/different amount 返回 409;另一个 key 再支付同 order 返回 409 already_paid。最终数据库 UNIQUE(order_id) 仍是最后防线。

查询

详情:

GET /v1/orders/1200001

返回:

{
  "order_id": 1200001,
  "state": "paid",
  "total_minor": 25800,
  "trace_id": "trace-order-001",
  "items": [
    {
      "line_no": 1,
      "sku": "PG36-SKU-001",
      "quantity": 2,
      "unit_price_minor": 12900,
      "line_total_minor": 25800
    }
  ],
  "payment": {
    "payment_id": 1200001,
    "state": "captured",
    "amount_minor": 25800
  }
}

时间字段每次不同,review 检查关系而不是固定 timestamp。

Keyset page:

limit=1, after absent
  → order 1200001 / next_cursor=1200001

limit=1, after=1200001
  → order 1200002 / next_cursor=null

12.7.2 注入数据库超时、重试与连接耗尽

语句超时必须零提交

fault 在任何业务写入前执行:

SET LOCAL statement_timeout = '50ms';
SELECT pg_catalog.pg_sleep(0.2);

观察:

HTTP=504
code=database_timeout
SQLSTATE=57014
retryable=true under the idempotent request contract
state before == state after

没有 retry 57014;request budget 已经被明确消耗。客户端若重试,必须带原 idempotency key。

40001 只重试整 transaction

metric:

pg36_db_errors_total{sqlstate="40001"} 1
pg36_transaction_retries_total 1

HTTP 对 client 仍是一个 201。最终:

order-retry ledger=1
order=1
item=1
outbox=1
inventory decrement=1

审查器不接受“返回成功但库存扣两次”。

Client cancellation

测试发起 2 秒 DB sleep,HTTP transport 在 400 ms 退出。独立 admin observer 轮询:

active pg36-ch12-api sleeper observed=1
client timeout occurs
active sleeper after cancel=0

服务日志:

{
  "error_code": "client_cancelled",
  "status": 499,
  "trace_id": "trace-client-cancel"
}

这里的验收对象是 PostgreSQL worker 与 connection lifecycle,不是 client 是否收到 499。

Pool exhaustion

服务固定:

PG36_MAX_CONNS=2

两个并发 /debug/hold?ms=1000

pg_stat_activity sleepers=2
pool acquired=max

随后:

请求 deadline 结果
/health/live none needed 200
/health/ready internal 150 ms 503 pool_unavailable
order GET lab request 100 ms 503 pool_unavailable
holder 1/2 3 s client both 200
ready after release 150 ms 200

最终 metrics:

pool empty acquire=2
pool canceled acquire=2
pool acquired=0
pool idle=2

这验证 overload shedding 与恢复,不代表 MaxConns=2 能承载真实流量。

Error matrix

case database work HTTP state
invalid JSON none 400 unchanged
missing SKU transaction rollback 404 unchanged
insufficient transaction rollback 409 unchanged
idem payload mismatch ledger read/rollback 409 unchanged
amount mismatch row lock/rollback 422 unchanged
statement timeout 57014/rollback 504 unchanged
serialization 40001 then full retry 201 one commit
pool unavailable no SQL acquired 503 unchanged
client canceled SQL canceled/rollback client gone worker zero

Raw evidence directory

最终 rebuild 至少有:

manifest.txt
setup.txt
startup-ready.json
service.log
service-lab.txt
api-results.json
trace-correlation.json
client-cancel.json
pool-saturation.json
metrics.txt
db-final.json
verify.txt
model-verify-after.txt
review.txt

顶层还保存三条 reset negative path 与正确 reset 输出。

12.7.3 汇总 ch07–ch11 的证据,发布规约 v1.0

不是把 proposal 文件拼成大 JSON

ch07–ch11 分别增加:

ch07:
  plan/statistics/parameter evidence

ch08:
  hypothesis-led diagnosis and negative controls

ch09:
  workload-bound index decision and write cost

ch10:
  concurrency invariant, retry and idempotency

ch11:
  expand/migrate/validate/switch/contract release state

本章补上 driver/service/pool/health/observability,使规约第一次覆盖从 query 到可运行 application 的闭环。

新规则

DEFAULT-APP-011

服务必须把连接池预算、请求截止时间、语句超时、整事务重试、
幂等键、外部副作用边界、健康检查和可观测关联作为同一交付合同;
进程存活、一次成功请求或直连测试均不能单独证明服务可发布。

POOL-STATE-012

使用 transaction pooling 时,业务正确性不得依赖跨事务会话状态;
驱动 query mode、协议级 prepared 能力和 PgBouncer 配置必须按实际
版本组合验证;DDL 发布还要验证缓存计划失效后的恢复路径。

Release candidate,不是 release

artifact

candidate_baseline=1.0.0
status=release-candidate
depends_on=v0.6 candidate
canonical checksum=
c85a930af366a9e96be7a0e166d3d0c04faace778208743718af51f633d8044d

当前已证:

PostgreSQL 18.6 direct endpoint
pgx v5.10.0 / QueryExecModeExec
pgxpool MaxConns=2 failure fixture
pg36_app without DDL/DELETE

晋级 blockers:

  1. 原样通过 Pigsty primary/PgBouncer transaction path;
  2. PostgreSQL 14–18 compatibility matrix;
  3. L1 负载下保存 app/PgBouncer/DB/WAL/replica/tail evidence;
  4. 先晋级 v0.2–v0.6 依赖并取得 app/database owner sign-off。

如果这些条件没有运行,正确结果就是 RC。不能为了让章节看起来“闭环”而伪造 release。

评审器检查什么

review.py 不检查某次毫秒数,而检查:

exact API case inventory and status/code
replay header + same body
fixed final business cardinality
inventory decremented once
client-cancel worker cleared
pool saturation relationship
40001/57014/retry/replay metrics
trace/outbox/application_name relation
structured logs and secret absence
direct/pooler validation boundary
v0.6 dependency canonical checksum
v1.0 RC checksum and blockers

12.7.4 冻结服务样例,后续改用 SQL 与工作负载脚本

冻结什么

本章结束后冻结:

API routes and JSON shape
database contract v1
Go module and pgx version
query mode
transaction/idempotency/outbox implementation
fault matrix
evidence schema
release-candidate checksum

后续章节可以引用:

  • shop_ch12 SQL pattern;
  • workload/query shape;
  • connection class;
  • metrics/error vocabulary;
  • frozen binary/source checksum。

但不继续给它增加 ORM、framework、authentication、message broker、UI 或 deployment platform。否则读者会被迫同时追踪应用框架演进,偏离 PostgreSQL/Pigsty 主线。

后续如何复用

ch13 functions/triggers:
  use isolated SQL fixtures; compare with ch12 boundary

extensions/search/vector chapters:
  use workload scripts, not new API endpoints

ch19+ operations:
  use pgbench/SQL/fault workloads against Pigsty

ch22 pooling:
  reuse the frozen connection/error matrix

ch23 security:
  reuse runtime role and add RLS-specific fixture

若发现 ch12 真正 defect:

  1. 记录 breaking/non-breaking;
  2. 新增 failing regression evidence;
  3. 修复并重跑两轮 reset/rebuild;
  4. 更新 checksum 与正文;
  5. 不把无关 feature 当作“顺手改进”。

Reset

显式 reset:

export PG36_RESET_TOKEN=RESET_CH12_SERVICE_LAB
export PG36_RESET_TARGET=pg36_shop/shop_ch12
./task.sh reset

它拒绝:

wrong action token
wrong target
unmarked schema
unknown relation/function
unmarked relation/function
any pg36-ch12-api database session

成功后:

schema_remaining=0
ch04 checksum=f8a7bfae59c6d16cd323abecfefe1014

all 会在 reset 后重建并再跑一次,所以最终工作区保留的是已验证完整状态。

最终输出

status=ok
business=orders:2/payments:1/outbox:3
contract=idempotency+atomic-reservation+outbox
failure=57014/40001/client-cancel/pool-exhaustion
observability=trace+json-log+pool-metrics
validation=pg18.6-direct/pgx-v5.10.0/pooler:not-run
release=1.0.0-rc
release_candidate_checksum=
c85a930af366a9e96be7a0e166d3d0c04faace778208743718af51f633d8044d

这份输出之所以可信,不是因为有一行 status=ok,而是 raw evidence、独立 SQL observer、negative reset 与 second rebuild 共同支持它。

本节验收

  • 在确认的 disposable L1 运行;
  • runtime connection 的 current_user 是 pg36_app;
  • 两次完整 suite 之间执行真实 exact reset;
  • 下单原子扣库存并写 outbox;
  • order/payment replay 返回持久首响应;
  • different payload 同 key 拒绝;
  • failed domain request 不留下 incomplete ledger;
  • payment amount/state/uniqueness 都有护栏;
  • 57014 前后 state snapshot 相同;
  • 40001 完整事务只重试一次并只提交一次;
  • client cancel 后 active worker=0;
  • pool saturation 下 live/ready/business 语义不同;
  • trace 关联 order/payment/outbox;
  • logs 无 secret/URL;
  • app 无 schema CREATE 与 table DELETE;
  • ch04 checksum 不变;
  • reset 三个 negative path 都以 exit 3 拒绝;
  • v1.0 状态保持 RC,blocker 未被删改;
  • 后续章节只复用 frozen contract/workload。

上一节:部署与接入 pg36_shop · 返回本章目录 · 下一章:言出法随:函数、触发器与存储过程 · 查看全书目录 · 查看索引中心