一气呵成:从数据库契约到后端服务
12 一气呵成:从数据库契约到后端服务
前十一章分别建立了模型、查询、事务、诊断、索引、并发与模式发布能力。本章把这些能力压进一个真实服务边界:
这里最重要的不是 Go 框架,而是接口两侧能否对同一事实达成可执行合同:
任何一层都不能替另一层“猜”。数据库约束不能替 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 合同的四类接口:
另外提供:
数据库对象全部位于独立模式 shop_ch12。运行角色 pg36_app:
- 有 schema
USAGE; - 只获得必要的
SELECT、INSERT、UPDATE与 identity sequence 权限; - 没有 schema
CREATE; - 没有表
DELETE; - 不能复位或修改模式;
- 不能通过服务调用任意 SQL。
实验模式保存:
setup 与 reset 都先检查对象 marker。reset 还要求精确动作令牌、精确目标、对象白名单以及零 pg36-ch12-api 会话;它不删除 database、role、extension 或 shop.*。
下载资产
- 实验合同
- 上下文 guard
- 隔离模式与最小权限
- 最终 SQL 验收
- 双 token reset
- Pigsty v4.5 声明示例
- systemd 服务单元示例
- HTTP 与故障矩阵
- 证据审查器
- v1.0 release candidate
- 任务入口
- Go 参考服务说明与源码索引
服务源码固定为一个独立 Go module:
本章验证的 frozen 组合是:
这不是“当前所有环境的默认版本”,而是证据绑定的实际组合。
本章目录
12.1 数据库契约与应用边界
12.2 为服务设计查询接口
12.3 Go 服务中的连接与事务
12.4 会话状态与连接池陷阱
- 12.4.1 session、transaction 与 statement pooling
- 12.4.2 预备语句行为必须绑定 PgBouncer 与驱动版本
- 12.4.3
SET LOCAL、事务边界与 RLS 上下文 - 12.4.4 在 ch22、ch23 分别深化池化与权限
12.5 服务级可观测性
12.6 部署与接入 pg36_shop
12.7 实战:交付应用闭环与规约 v1.0
- 12.7.1 跑通下单、扣库存、支付幂等与查询
- 12.7.2 注入数据库超时、重试与连接耗尽
- 12.7.3 汇总 ch07–ch11 的证据,发布规约 v1.0
- 12.7.4 冻结服务样例,后续改用 SQL 与工作负载脚本
实测摘要
task.sh all 先运行完整服务矩阵,再证明错误 token、错误 target 和 active service 都不能 reset;随后执行精确 reset,从空模式重建并再次运行同一套矩阵。第二轮 PostgreSQL 18.6 证据:
连接获取次数、持续时间、端口、PID 和时间戳不是 golden。稳定结论是状态基数、只发生一次的扣减/支付、SQLSTATE、取消清理、池耗尽时三种健康语义以及业务 checksum。
v1.0 artifact 的 canonical checksum 为:
状态仍是 release-candidate。当前没有在真实 Pigsty primary/PgBouncer 路径、PostgreSQL 14–18 矩阵和 L1 生产型负载上完成晋级证据;截止时间与本地成功都不能把未执行条件改成 PASS。
章节验收
- schema、query、error、compatibility 与 operations contract 都有版本身份;
- 约束负责可强制执行的持久不变量,应用负责协议与外部协调;
- 库存通过条件
UPDATE ... RETURNING原子预留,不先查后写; - idempotency key 同时绑定 payload fingerprint 与持久响应;
- 不同 payload 复用 key 必须拒绝;
- order/payment/outbox 在同一事务提交;
- 事务内不调用远程支付或消息系统;
- 所有值使用参数,动态 identifier 只能来自封闭白名单;
- 结果显式列出字段,聚合内部有稳定
ORDER BY; - 分页使用 keyset cursor,不以 OFFSET 扫描替代;
- SQLSTATE 和命名 constraint 是错误映射证据,不解析 message;
- request deadline 传播到 pool acquisition 与 SQL;
statement_timeout与 client cancellation 被区分并各自留证;- 只对列明的完整事务错误执行有限重试;
- 应用 pool 与 PgBouncer server pool 共同纳入连接预算;
- liveness 不获取数据库连接;
- readiness 验证 database、role、writable target 与 schema marker;
application_name低基数稳定,trace ID 不塞进连接名;- 日志不含 connection string、密码或完整敏感参数;
- pool wait 与 PostgreSQL wait 分开度量;
- transaction pooling 下不依赖跨事务 session state;
SET LOCAL位于显式事务内;- pgx query mode 与 PgBouncer prepared-statement 配置按实际版本验证;
- Pigsty primary 与 direct service 的职责没有混用;
- reset 的 target、token、object marker 和 active-session guard 全部生效;
- direct evidence 不被标成 pooler evidence;
- v1.0 未满足晋级条件时保持 release candidate;
- 参考服务在本章冻结,后续不演变成框架教程。
下一章 ch13《言出法随:函数、触发器与存储过程》 将从“服务与数据库如何分工”继续深入数据库端逻辑:何时值得把规则放进函数或触发器,以及如何避免隐藏副作用。
参考资料
- PostgreSQL 18:Table Expressions 与 LATERAL
- PostgreSQL 18:WITH Queries
- PostgreSQL 18:Window Functions
- PostgreSQL 18:Transaction Isolation
- PostgreSQL 18:Error Codes
- PostgreSQL 18:Client Connection Defaults
- PostgreSQL 18:SET
- pgx v5.10.0:pgxpool
- pgx v5.10.0:QueryExecMode
- PgBouncer:Feature map
- PgBouncer:Configuration
- Pigsty:Service / Access
- Pigsty:PgBouncer Administration
上一章:守正出奇:模式变更与安全发布 · 返回上卷导读 · 下一章:言出法随:函数、触发器与存储过程 · 查看全书目录 · 查看索引中心
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,不写成成功。
返回本章目录 · 下一节:为服务设计查询接口 · 查看全书目录 · 查看索引中心
12.2 为服务设计查询接口
服务中的 SQL 不是藏在字符串里的实现细节,而是一组版本化接口。好的 query contract 让评审者不看 Go 也能回答:
这一节使用 store.go 中实际运行过的 SQL,不另外发明一套“正文专用”伪代码。
12.2.1 参数化 SQL 与稳定结果语义
绑定参数只解决 value
pgx 使用 $1、$2:
参数化的主要价值:
- value 不再与 SQL grammar 拼接;
- 类型编码由 driver/protocol 处理;
- query text 稳定,便于 query identity 与计划复用;
- 日志可以记录 query identity,而不必记录敏感 value;
- 测试可以把恶意输入当数据,不会改变语法。
但参数不能代替 table、column、direction 或 operator:
动态 identifier 需要:
- 尽量改成几条固定 SQL;
- 若确实需要,输入先映射到封闭 enum;
- 使用 driver 提供的 identifier quoting;
- value 仍然单独参数化。
不要把用户字符串传入 fmt.Sprintf("ORDER BY %s", input),再声称其他 value 已参数化所以安全。
把原子决策放进语句结果
库存预留:
这条 SQL 的 contract 包含:
RETURNING 避免一次 UPDATE 后再读“可能已经被别人改过”的当前值。本章不把剩余库存放进响应,因此代码只用 price/currency 完成订单;但证据保留 final inventory。
显式列优于 SELECT *
SELECT * 会把 schema 顺序变成 query contract。新增列后:
- positional scanner 可能列数不符;
- result description cache 可能失效;
- API 无意暴露新字段;
- 大字段可能突然进入热路径;
- 同名列 join 后难以辨认;
- rolling deployment 的 old decoder 可能失败。
服务查询逐列列出:
“显式”不表示永不改变;它让改变发生在可 review 的 query diff,而不是 table diff 的隐式副作用。
稳定 JSON 必须定义内部顺序
聚合 items:
没有 aggregate 内部的 ORDER BY,上层查询排序不能保证数组元素顺序。空集合用 [],不是 SQL NULL;payment 没有行则返回 JSON null。这些都是 API contract,不是格式喜好。
不要用 JSON 文本字节逐字符比较 jsonb object key order。稳定语义是字段和值;array order 才由 ORDER BY 明确定义。
Keyset pagination
第一页:
下一页:
核心 predicate:
相比高 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 时使用:
而不是假设任意字节都会被数据库自动理解为 JSON。参数化解决 injection,不替你解决不明确的类型映射。
12.2.2 CTE、窗口函数和 LATERAL 的工程用法
这些构造不是“高级 SQL 展示”。它们分别解决:
LATERAL 生成每个订单的嵌套结果
订单详情先取得一个 order,再为这一行计算 items:
LATERAL 允许右侧引用 orders.order_id。这里 aggregate 即使没有 item 也返回一行,所以 CROSS JOIN 不会丢掉 order。另一种常见形态:
适合“每个 parent 的 top-N/latest”。风险是外层行很多时,右侧可能反复执行;仍要用 EXPLAIN (ANALYZE, BUFFERS) 检查实际 loops、index 与行数,不能因 SQL 简洁就假设代价小。
CTE 表达分页阶段
本章列表查询:
阶段关系清楚:
如果先 join/aggregate 所有 items,再 LIMIT parent,会做无谓工作,甚至把 LIMIT 作用到 join rows 而不是 orders。
CTE 不是永久 materialized temp table。PostgreSQL 会根据引用次数、side effect 与 MATERIALIZED / NOT MATERIALIZED 选择折叠边界。需要性能结论时看计划;不要拿“CTE 一定是优化屏障”这种旧经验当跨版本规则。
Window 不改变行基数
row_number():
给 page 内每个 order 编号,但不把多行聚合成一行。窗口函数逻辑上在 WHERE/GROUP BY/HAVING 后执行,所以不能直接写:
需要再包一层 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 至少保存:
外部响应:
不返回 raw SQL、connection string、table internals 或 PostgreSQL DETAIL。内部结构化日志保留:
一个建议映射表
| 条件 | 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。
本章故障矩阵分别注入:
只看到 SQLSTATE 57014 时不要武断写“数据库慢”。需要同时看 application cancellation cause、timeout 配置、database log 与 request timeline。
Constraint name 是可版本化 API
如果服务要把某个 23514 细分为 invalid_state_transition,约束名就成为 error contract:
重命名、拆分或合并约束都可能改变映射。发布时应:
- 所有重要约束显式命名;
- 映射 unknown constraint 到安全通用错误;
- 在 app/schema coexistence 期接受 old/new 名称;
- 测试 SQLSTATE + name,不测试英文 message;
- 记录 PostgreSQL version difference。
不要吞掉未知错误
最危险的映射:
它会把权限失败、连接断开、取消、decode bug 和 schema drift 全伪装成业务缺失。正确的默认分支应:
未知错误不是“用户体验问题”,而是你发现合同不完整的信号。
本节检查表
- value 使用
$n,identifier 来自封闭白名单; - query 显式列出结果,不依赖
SELECT *; - row cardinality、no-row 与 null 已定义;
- array aggregate 内部有
ORDER BY; - pagination 有唯一完整 sort key;
- CTE 各阶段有明确基数,性能结论来自计划;
-
LATERALloops 与索引在真实规模评估; - window 的 partition/order/frame 语义明确;
- query mode 与 Go/PostgreSQL 类型映射已测试;
- SQLSTATE/constraint identity 在 error 中保留;
- 外部错误不泄露 SQL、凭据或敏感参数;
- retryable 只在幂等和预算条件下成立;
- unknown DB error 不被错误映射成 404/409。
参考资料
- PostgreSQL 18:LATERAL Subqueries
- PostgreSQL 18:WITH Queries
- PostgreSQL 18:Window Functions
- PostgreSQL 18:Error Codes
- pgx v5.10.0:QueryExecMode
上一节:数据库契约与应用边界 · 返回本章目录 · 下一节:Go 服务中的连接与事务 · 查看全书目录 · 查看索引中心
12.3 Go 服务中的连接与事务
driver 把 SQL 发给 PostgreSQL;它不会替你决定连接预算、deadline、事务重试和健康语义。真正的服务可靠性来自这些边界能否闭合:
12.3.1 连接池大小、超时与上下文取消
NewWithConfig 不等于已连接
pgxpool 的构造可以在没有建立连接时返回。启动 gate 必须主动 Ping 或 Acquire:
这只能证明 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 后还要分两层:
不要把“每个 pod 50 × 100 pods”直接当成 PostgreSQL 5000 条 backend,也不要因此完全忽略 app pool。前一层决定本进程排队、fd 和 PgBouncer client 压力;后一层决定数据库真实并发。两层都过大只会把拥塞从一个队列搬到另一个。
池大小不是 CPU 数量公式
需要从 workload 推导:
然后用负载实验观察:
- 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 后:
它证明排队与健康语义,不测容量。
Deadline 要形成预算瀑布
不同 timeout 回答不同问题:
常见设计是让数据库 timeout 早于最外层 deadline,给 rollback、错误映射和响应留出时间:
数字必须来自 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 离开后要验证整条链:
实验:
499 是内部 observability 分类,不是 PostgreSQL 或 HTTP 标准必须返回的业务 contract;客户端已经断开,通常收不到该响应。
连接错误与查询错误要分开
在 pool.Acquire(ctx) 处失败,SQL 还没开始。本章映射:
取得连接后 statement_timeout:
这一区分能快速回答“请求慢在应用 pool,还是慢在 PostgreSQL”。若只记一个 db_error_total,诊断又退回猜测。
12.3.2 事务函数、失败重试与外部副作用
Wrapper 必须拥有完整 transaction lifecycle
本章 wrapper 每次 attempt:
不能只重试最后一条 SQL:
PostgreSQL 对 serialization failure 的要求是 abort 并从 transaction 起点重新执行。40P01 也使 transaction 失败;若选择重试,同样要重放完整业务单元。
Retry allowlist
本章只允许:
最多三次 attempt,attempt 之间有限 backoff,并受 request context 约束。以下错误不应被 wrapper 盲重试:
| error | 原因 |
|---|---|
23505 / 23514 |
通常是确定性业务/contract 冲突 |
42501 |
权限发布缺陷 |
42P01 / 42703 |
schema/version 缺陷 |
57014 |
预算已耗尽或显式取消 |
| unknown connection loss after COMMIT sent | commit outcome 可能未知 |
连接断开尤其危险:
不等于:
这就是 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 不回滚:
最终是 order 1200002,库存只减 1,outbox 只多 1,metric retry=1。测试的是完整 retry 关系,不是生产中用函数制造错误。
下单 transaction
逻辑顺序:
任何 domain error 都 rollback,包括:
所以 failed request 不留下一个 response=NULL 的 committed ledger。
支付 transaction
数据库还用:
保护“一单一笔 captured payment”。应用的 state/amount 检查提供领域错误;unique 是竞态或其他 writer 下的最终护栏。
defer tx.Rollback() 不是完整策略
常见 Go pattern:
它可以作为防漏,但仍要回答:
- 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 在这些错误目标也会成功:
本章 readiness 返回:
公开生产 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 证明:
它不证明:
- 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。
实测连接指标
第二轮全量证据结束时:
acquire_total=24 会随 probe 和测试步骤改变,不是 golden。关系断言是:
本节检查表
- 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。
参考资料
- pgx v5.10.0:pgxpool
- PostgreSQL 18:Transaction Isolation
- PostgreSQL 18:Error Codes
- PostgreSQL 18:Client Connection Defaults
上一节:为服务设计查询接口 · 返回本章目录 · 下一节:会话状态与连接池陷阱 · 查看全书目录 · 查看索引中心
12.4 会话状态与连接池陷阱
应用看到的“一个数据库连接”可能依次经过:
当 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:
这非常适合把业务状态装进显式 transaction 的服务,也意味着跨 transaction 的 session assumption 会破坏。
statement pooling 连一个多语句事务都不能正常表达,不适合作为本章业务主线。
Transaction pooling 中哪些东西会坏
PgBouncer 官方 feature map 明确指出 transaction pooling 下不能依赖:
- 普通
SET/RESET的跨事务效果; LISTEN;- SQL-level
PREPARE/DEALLOCATE; WITH HOLDcursor;- preserve/delete rows temp tables;
- session-level advisory locks;
LOAD。
例:
第二个 transaction 不应假定 app.tenant_id 仍存在。更糟的是,拿到的 backend 可能曾服务另一个 client;可靠 pooler 会 reset/track 一部分参数,但应用不能把未知 session residue 当作隔离机制。
一个 transaction 内的状态仍然有意义
transaction pooling 在 BEGIN 到 COMMIT/ROLLBACK 期间固定 backend,所以:
语义成立。关键不是“永远不用 SET”,而是:
且每个 transaction 都重新建立需要的 context。
Autocommit 是 transaction
一条没有显式 BEGIN 的 SQL 在 PostgreSQL 中也运行于一个 transaction;在 transaction pooling 中,statement 完成后 backend 就可能归还。
所以这种代码不成立:
SET LOCAL 必须与受保护查询在同一个显式 transaction object 上执行。
不要用 session advisory lock 做跨请求 ownership
第 10 章已区分 transaction/session advisory lock。transaction pooling 下:
这既会泄漏锁,也会让 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 不兼容”过于粗糙
需要至少区分:
PgBouncer 1.21 起可以在 transaction pooling 中跟踪 protocol-level named prepared statements,但必须:
它会在 client/server name 之间重写,并确保目标 backend 已准备该 query。SQL-level PREPARE/DEALLOCATE 仍不受 transaction pooling 支持。
因此不能从“PgBouncer 版本够新”直接推导“所有 driver 默认都安全”。要验证:
pgx 的默认行为
pgx v5.10.0 默认 QueryExecModeCacheStatement:
如果 schema 或 search_path 在缓存后变化,第一次重新执行可能失败,例如 SELECT * 列数变化或 result type 变化。pgx 文档也提示默认 prepared statements 可能与 proxy/PgBouncer 不兼容,建议按环境选择 QueryExecModeExec 或在必要时 simple protocol。
本章保守固定:
该模式:
- 仍使用 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 可能报:
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 上下文
SET 与 SET LOCAL
PostgreSQL:
transaction pooling 的主线应是:
set_config(..., true) 的第三个参数表示 transaction-local,便于参数化 value。不要拼:
RLS context 要 fail closed
若 ch23 使用:
policy 要明确 missing context 是:
而不是 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 method 可能 acquire 不同连接,也一定是不同 autocommit transaction。正确:
若 query 是单条并且能把 tenant 作为普通 $1 predicate,就优先显式参数;RLS context 用于数据库必须统一执行的访问策略,不是减少一个参数的技巧。
search_path 也不要依赖 session residue
本章所有对象 schema-qualified:
SECURITY DEFINER function 固定:
然后引用 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 章将深入连接治理:
第 23 章将深入安全与访问:
本章保留的 cross-chapter contract:
- 在线服务默认可以在 transaction pooling 下正确运行;
- 正确性不依赖跨 transaction session state;
- runtime role 最小权限、不是 object owner、没有 BYPASSRLS;
- connection/query mode 与 pooler 版本配置绑定;
- 特殊 session workload 使用独立 service,不污染在线主线;
- 权限或 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 LOCAL或set_config(..., true)fail closed; - tenant context 不是 authorization 的唯一应用侧证据;
- SQL schema-qualified,SECURITY DEFINER path 固定;
- 特殊 session workload 使用独立连接路径。
参考资料
- PgBouncer:SQL feature map
- PgBouncer:max_prepared_statements 与 pool_mode
- PgBouncer FAQ:transaction pooling prepared statements
- pgx v5.10.0:QueryExecMode
- PostgreSQL 18:SET
- Pigsty:PgBouncer Administration
上一节:Go 服务中的连接与事务 · 返回本章目录 · 下一节:服务级可观测性 · 查看全书目录 · 查看索引中心
12.5 服务级可观测性
一次请求跨过 HTTP、应用 pool、PgBouncer、PostgreSQL transaction 与 outbox。每层都有自己的 identity:
把它们全塞进一个字符串会失去可聚合性;一个都不关联又无法从用户症状走到数据库证据。
12.5.1 请求、事务、查询指纹与 application_name
Identity 分层
本章的关联关系:
trace_id 告诉你一次调用从哪里来;request_key 告诉数据库它是否与先前命令是同一个业务意图。两者不能互换:
- retry 可以有新 trace,但保留同 idempotency key;
- 同一 trace 可能跨多个内部调用;
- trace 通常有保留期,idempotency ledger 是业务状态;
- trace 不应承担 uniqueness/authorization。
application_name 保持低基数
本章所有业务 backend:
不要每请求改成:
否则:
- metrics cardinality 爆炸;
pg_stat_activity分组失去意义;- PgBouncer tracking/reset 行为更复杂;
- 日志与 dashboard 成本上升;
- 可能泄露 tenant/user。
合理粒度:
必要时附固定 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:
metrics 同时需要:
如果只看 HTTP 201,会漏掉 serialization pressure;如果把每次 attempt 都算一个请求,会夸大业务流量。
结构化日志
正常请求:
数据库 timeout:
日志不包含:
不是所有错误都要打 stack;预期 domain conflict 应按可聚合 code 记录,真正未知 defect 才需要更深内部 cause。
12.5.2 延迟、错误、连接等待和数据库等待
一个慢请求至少有四段
[ T_{request} = T_{app}
- T_{pool}
- T_{db}
- T_{response} ]
T_db 还可拆:
仅看总延迟无法决定加 index、加 connection 还是减并发。
应用 pool wait
pgxpool 暴露:
本章转换成:
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 后,观察:
典型解释:
| 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
本章同时记录:
一次运行:
SQLSTATE cardinality 是 bounded vocabulary;constraint name 通常也相对 bounded,但 table/tenant/raw error message 不适合无审查直接做 label。
Rate、error、duration 与 saturation 同看
最小服务面板:
平均值会隐藏 tail。pool wait 若只占 1% 请求,平均可能很小,p99 已经超时。
12.5.3 从一次请求追到数据库证据
一条可执行调查路径
用户报告下单超时,先固定:
然后沿层次走:
最后一步不是“SQL 后来成功了吗”,而是:
实测 trace 关系
本章 observer 独立于 app 查询:
这让 operator 能从 request 走到 committed fact,又保持 database session label 可聚合。
诊断一次 pool exhaustion
实验时间线:
证据组合:
这排除了“PostgreSQL query 本身超时”,并证明 admission queue 按预算失败。
诊断一次 statement timeout
若只看 504,会与 gateway timeout 混淆;若只看 57014,又会与 client cancel 混淆。timeline + source timeout + state comparison 才是完整证据。
Unknown commit 的调查
客户端 timeout 后不要先删 key 再重试。先用相同 idempotency key 查询/重放:
本章 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。
参考资料
- PostgreSQL 18:Monitoring Database Activity
- PostgreSQL 18:pg_stat_activity
- PostgreSQL 18:Error Codes
- pgx v5.10.0:pgxpool Stat
- Pigsty:PostgreSQL Dashboards
- Pigsty:PgBouncer Administration
上一节:会话状态与连接池陷阱 · 返回本章目录 · 下一节:部署与接入 pg36_shop ·
查看全书目录 · 查看索引中心
12.6 部署与接入 `pg36_shop`
部署数据库应用不是把一条 URL 放进环境变量。完整交付关系是:
Pigsty 提供 role/database 声明、PgBouncer、HAProxy service 与监控;应用团队仍要声明自己使用哪个入口、依赖什么 pool mode、允许多少并发,以及如何证明业务合同成立。
12.6.1 角色、数据库、服务与凭据声明
Owner 与 runtime 分离
本书延续第 4、6 章角色:
应用绝不能用 owner 连接。否则:
- schema injection/DDL defect 的 blast radius 扩大;
- owner 可能绕过 RLS;
- migration 与业务 activity 无法区分;
- secret 泄露可修改所有 objects;
- readiness 的
current_user失去保护价值。
本章 setup 最终断言:
“最小权限”必须由 catalog 证明,不能只看 YAML。
Pigsty declaration
声明示例 中的关键部分:
这些数字是教学起点,不是容量答案。需要按:
重新计算。
Pigsty declaration 负责创建/管理 cluster-level identity;对象级:
仍应进入 versioned SQL 与 code review。不要把所有权限散落在临时 psql 历史里。
Credential 是输入,不是源码
样例只要求:
但不记录它。生产建议:
- 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 同步路径已验证。
读取变量。示例 URL 故意没有密码;部署系统负责填充 secret。不要把:
写进 process list。
Connection string 也要版本化非秘密部分
应记录:
秘密值单独管理。发布 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 |
因此:
不是“应用永远只能用 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 开放,设置:
应用运行角色不需要 direct 管理权限。
应用侧仍然使用 pgxpool
PgBouncer 不是 Go 并发安全 connection handle 的替代。应用侧 pool:
- 复用 client connections;
- 限制每个 process 同时进入数据库路径的请求;
- 暴露 acquisition queue;
- 管理 connection lifetime/health;
- 将 request context 传播到 acquire。
但两层池不要无限叠加:
即使 PostgreSQL 只有 64 server connections,app、network、PgBouncer fd/memory 和排队仍可能过载。
晋级时使用不变的 service suite
本地验证连接:
manifest 明确:
Pigsty 晋级不能把字段手改成 run。task.sh 保留 admin
PGSERVICE 用于 setup/observer,并允许用独立的
PG36_APP_DATABASE_URL 指向实际 primary service:
不设置该变量时,本地教学路径从 named admin service 派生
user=pg36_app 的 direct connection。设置后,manifest 只会声明:
这表示完整行为矩阵确实经过该 endpoint,但 endpoint 自称是 5433 仍不能证明其内部 pool mode/config。promotion job 应显式接收两个独立目标:
并在证据中保存:
- 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
部署后按层验证:
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:
应设合同关系和 SLO:
其中最后两项必须在目标环境实测。
发布后观察而非立即 contract
应用 100% 新版本不等于旧连接/worker 已退出。保留观察窗口:
满足后才让第 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。
参考资料
- Pigsty:Service / Access
- Pigsty:PgBouncer Administration
- Pigsty:PostgreSQL User
- Pigsty:PostgreSQL Database
- Pigsty:PostgreSQL Dashboards
- PostgreSQL 18:libpq Connection Parameters
上一节:服务级可观测性 · 返回本章目录 · 下一节:实战:交付应用闭环与规约 v1.0 · 查看全书目录 · 查看索引中心
12.7 实战:交付应用闭环与规约 v1.0
本节把交付做成一条两次运行的证据链:
它证明 reference implementation 在当前直连组合中的机制;不把本地几秒钟实验写成 Pigsty HA、PgBouncer 或生产容量证据。
12.7.1 跑通下单、扣库存、支付幂等与查询
确认目标是可重建 L1
只在已确认的开发/测试目标继续。脚本还会 fail closed:
运行:
可选 action:
setup 和 all 会重建 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 在真实系统合法。
创建订单
返回:
提交关系:
重放与冲突
相同 body、相同 request_key:
相同 key、quantity 改成 3:
HTTP 409,状态不变。
此外:
前两条已经进入 transaction,但 domain error 会 rollback request ledger;不存在“失败 key 占住以后永远不能重试”的半成品。
第二笔订单通过 40001 retry
结果:
fault header 只有 PG36_ENABLE_FAULTS=1 时接受;生产 unit 不设置该变量。
支付
先用错误金额:
正确请求:
响应:
提交:
同 key/body 重放返回相同 payment;同 key/different amount 返回 409;另一个 key 再支付同 order 返回 409 already_paid。最终数据库 UNIQUE(order_id) 仍是最后防线。
查询
详情:
返回:
时间字段每次不同,review 检查关系而不是固定 timestamp。
Keyset page:
12.7.2 注入数据库超时、重试与连接耗尽
语句超时必须零提交
fault 在任何业务写入前执行:
观察:
没有 retry 57014;request budget 已经被明确消耗。客户端若重试,必须带原 idempotency key。
40001 只重试整 transaction
metric:
HTTP 对 client 仍是一个 201。最终:
审查器不接受“返回成功但库存扣两次”。
Client cancellation
测试发起 2 秒 DB sleep,HTTP transport 在 400 ms 退出。独立 admin observer 轮询:
服务日志:
这里的验收对象是 PostgreSQL worker 与 connection lifecycle,不是 client 是否收到 499。
Pool exhaustion
服务固定:
两个并发 /debug/hold?ms=1000:
随后:
| 请求 | 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:
这验证 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 至少有:
顶层还保存三条 reset negative path 与正确 reset 输出。
12.7.3 汇总 ch07–ch11 的证据,发布规约 v1.0
不是把 proposal 文件拼成大 JSON
ch07–ch11 分别增加:
本章补上 driver/service/pool/health/observability,使规约第一次覆盖从 query 到可运行 application 的闭环。
新规则
DEFAULT-APP-011:
POOL-STATE-012:
Release candidate,不是 release
当前已证:
晋级 blockers:
- 原样通过 Pigsty primary/PgBouncer transaction path;
- PostgreSQL 14–18 compatibility matrix;
- L1 负载下保存 app/PgBouncer/DB/WAL/replica/tail evidence;
- 先晋级 v0.2–v0.6 依赖并取得 app/database owner sign-off。
如果这些条件没有运行,正确结果就是 RC。不能为了让章节看起来“闭环”而伪造 release。
评审器检查什么
review.py 不检查某次毫秒数,而检查:
12.7.4 冻结服务样例,后续改用 SQL 与工作负载脚本
冻结什么
本章结束后冻结:
后续章节可以引用:
shop_ch12SQL pattern;- workload/query shape;
- connection class;
- metrics/error vocabulary;
- frozen binary/source checksum。
但不继续给它增加 ORM、framework、authentication、message broker、UI 或 deployment platform。否则读者会被迫同时追踪应用框架演进,偏离 PostgreSQL/Pigsty 主线。
后续如何复用
若发现 ch12 真正 defect:
- 记录 breaking/non-breaking;
- 新增 failing regression evidence;
- 修复并重跑两轮 reset/rebuild;
- 更新 checksum 与正文;
- 不把无关 feature 当作“顺手改进”。
Reset
显式 reset:
它拒绝:
成功后:
all 会在 reset 后重建并再跑一次,所以最终工作区保留的是已验证完整状态。
最终输出
这份输出之所以可信,不是因为有一行 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 · 返回本章目录 · 下一章:言出法随:函数、触发器与存储过程 ·
查看全书目录 · 查看索引中心