跳转到主要内容

6 立木取信:开发规约与交付基线

前五章已经留下了一批反复出现的工程判断:连接前先确认目标,运行角色不能等于对象所有者,关键不变量要进入约束,事务失败后必须显式恢复,分页需要稳定全序,实验结束要证明状态复原。它们此时还散落在不同章节里;如果只把这些句子摘成一张“最佳实践清单”,读者很快就会遇到两个问题:规则为什么成立,以及遇到例外时该听谁的。

本章不追求一份永远正确的规范,而是建立一条可持续的规则生产线:

事故 / 评审 / 测量
  → 候选规则
  → 失败机制与适用范围
  → 正例、反例和运行证据
  → 自动检查 / 运行验证 / 人工评审
  → active baseline
  → waiver、修订或废弃

最终产物是 pg36_shop 的开发规约 baseline v0.1。它既有人可以阅读的指南,也有机器可以校验的 JSON registry;既声明 PostgreSQL 对象与查询合同,也展示如何把角色、数据库和接入路径映射到 Pigsty。更重要的是,它公开记录目前尚未自动化的缺口,而不把“写进文档”冒充“已经落实”。

本章目标

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

  • 从失败机制出发写规则,而不是从个人偏好出发写口号;
  • 为每条规则补齐 owner、scope、rationale、evidence、exception 和 checks;
  • 区分 safety、default 与 preference,知道三类规则采用不同的阻断和例外机制;
  • 把连接目标、凭据、会话参数和 application_name 组成可验证的连接合同;
  • 解释 search_path 为什么同时是便利机制与信任边界;
  • 用 schema、NOLOGIN owner、命名约束和注释表达对象边界;
  • 审查数据类型、默认值、标识键和时间语义,而不是机械套用类型表;
  • 把“可恢复迁移”理解为兼容演进、停止线和 forward repair,而不是承诺任意 DDL 都能自动 down
  • 为查询建立显式投影、稳定排序和分页合同;
  • 把事务超时、整体重试、幂等与外部副作用放在一个失败模型里审查;
  • 区分静态检查、catalog 验证、负向测试、并发实验与计划证据;
  • 写出包含风险、停止条件、恢复路径和验收证据的数据库变更说明;
  • 区分 Pigsty inventory 中的期望状态、PostgreSQL catalog 中的实际状态与 service 的实际路由;
  • 运行 v0.1 质量门,读懂每个通过项和目前唯一的 safety 自动化缺口;
  • 给后续 ch07–ch11 的计划、索引、并发与发布证据预留可追踪的追加位置。

baseline v0.1 的边界

本章基线含 25 条 active 规则:

等级 数量 默认处置 允许的例外
safety 10 阻断 merge/deploy none 或受控 breakglass
default 10 团队默认 有 owner、expiry 与补偿检查的 waiver
preference 5 场景评审 reviewer 根据证据决定

规则数量不是成熟度指标。v0.1 的证据只来自 ch01–ch05,范围限定为 pg36_shop 教学应用及 Pigsty L1 工作流;PostgreSQL 兼容目标为 14–18,当前真实验证版本为 18.6,Pigsty 说明以 v4.5.0 为准。Ubuntu 24.04 是 L1 目标平台,本章同时在 macOS/Homebrew 的 PostgreSQL 18.6 本地实验实例上验证 SQL 与 gate。任何内核分支、驱动、连接池模式和组织安全要求都可能收紧或改写规则边界。

v0.1 还故意保留一个可见缺口:10 条 safety 中,SAFE-RETR-008“只按 SQLSTATE 整体重试且副作用幂等”目前只有 review check,尚无自动或运行时检查。第 10 章完成并发与重试实验前,它不能被宣称为自动闭合;质量门会输出:

safety_count=10
safety_non_review_count=9

这里的 9 不是失败,而是一张不可被悄悄抹掉的债务凭证。若到 ch12 仍未补齐,baseline 不得升为 v1.0。

人、机器与运行时三份合同

flowchart LR
  A["baseline-guide.md<br/>人类阅读与评审"] --> D["同一组 Rule ID"]
  B["baseline-v0.1.json<br/>机器可读 registry"] --> D
  C["evidence-ledger.md<br/>来源与未来证据"] --> D
  D --> E["check_baseline.py<br/>结构 / 引用 / 安全扫描"]
  E --> F["quality-gate.sh static"]
  G["PostgreSQL catalog<br/>session / model / query contract"] --> H["quality-gate.sh live"]
  I["故意错误上下文<br/>wrong session / target"] --> J["quality-gate.sh negative"]
  F --> K["gate-summary.txt"]
  H --> K
  J --> K

三份合同各自回答不同问题:

  • 人类指南说明规则是什么意思、为何存在以及怎样申请例外;
  • JSON registry 固定字段、ID、版本和证据引用,防止文档与自动化各说各话;
  • 运行时 gate 连接真实 PostgreSQL,证明当前 target、session、catalog 与 query contract 符合预期。

静态通过不证明数据库已经部署;inventory 已提交不证明 playbook 已应用;catalog 正确也不证明连接流量经过了预期的 HAProxy/PgBouncer 服务。只有把配置、运行和路由证据放在一起,才能声称这条交付链已经闭合。

实验资产

下载并审查以下资产:

这些资产不包含密码,也不会创建或删除数据库。livenegative 只在已经通过 ch04-v1 验收的可写 L1 上运行:正向检查全部只读,负向检查只故意设置错误会话参数或错误 expected database,并要求以精确 SQLSTATE 拒绝。

本章目录

6.1 规约不是口号

先建立“规则也需要证据”的方法,再定义一条规则从 candidate 到 active、waived、deprecated 的生命周期。

6.2 连接与会话候选规则

把“能连上”升级为包含目标、身份、会话语义、超时预算和可归因性的连接合同,并让错误连接真的失败。

6.3 模式与 DDL 候选规则

把前两章的逻辑模型与物理合同收敛为 DDL 评审问题,并准确界定 rollback、forward repair 和兼容发布的关系。

6.4 查询与事务候选规则

查询规约约束的是外部合同和失败语义,不是 SQL 风格偏好;高级语法也不因“高级”而自动正确或错误。

6.5 交付物与质量门

一项数据库变更只有同时携带代码、验证、失败路径、风险和 owner 才是可接手的交付物。

6.6 将规约接入统一实验环境

Pigsty 提供可复现的基础设施入口,PostgreSQL catalog 和实验后验负责证明实际状态;本节把两种证据接起来。

6.7 实战:发布规约 baseline v0.1

最后运行 static、live 与 negative 三层 gate,发布带 checksum、证据范围和已知缺口的 v0.1,而不是一份没有版本的规范文档。

章节验收

  1. 能把一条口号改写成有 scope、rationale、evidence、exception、checks 和 owner 的规则;
  2. 能解释 safety/default/preference 的差异,不用大写“必须”冒充风险分级;
  3. baseline guide 与 JSON registry 的 Rule ID 一一对应;
  4. registry 引用的 ch01–ch05 资产都存在,且五章均被实际证据覆盖;
  5. source 与 evidence 中没有明文凭据、credential URI 或 PGPASSWORD
  6. 连接 gate 能确认 database、effective role、primary、模型版本和 session profile;
  7. 错误会话固定以 P0601 失败,错误 target 固定以 P0001 失败;
  8. 查询 gate 能验证 view shape、显式稳定排序、keyset 两页不重叠以及业务键/幂等键唯一;
  9. 能解释 Pigsty primary:5433default:5436 的不同使用边界;
  10. 能从 catalog、pg_settings 和 service 路由分别验证 inventory 声明;
  11. 能说明为什么“可恢复”通常依赖 expand/contract 与 forward repair,而非通用 down migration;
  12. 能指出 v0.1 的 9/10 safety enforcement 缺口及其预定闭合章节;
  13. quality-gate.sh all 生成 status=ok,且保存 baseline 与关系模型 checksum。

下一章 ch07《追本溯源:执行计划与统计信息》 将开始给 PREF-PLAN-005 和查询成本审查补充第一批专门证据。

参考资料


上一章:运筹帷幄:查询、事务与锁的核心心智模型 · 返回上卷导读 · 下一章:追本溯源:执行计划与统计信息 · 查看全书目录 · 查看索引中心

6.1 规约不是口号

数据库规约最容易写,也最容易失效。“SQL 必须高效”“事务尽量短”“禁止复杂查询”都很像正确的话,却没有告诉执行者:什么叫高效,什么情况下必须阻断,怎样证明事务已经足够短,复杂是语法复杂还是计划代价高。这样的句子不能被机器检查,评审者之间也无法稳定复现判断,最后只剩资历和语气在决定结果。

可执行规约必须把判断过程显式化。本节先不急着罗列 PostgreSQL 技巧,而是定义规则本身的工程合同。

6.1.1 从事故、评审和测量中形成规则

一条规则应当从可描述的失败机制出发。输入通常来自三类渠道:

输入 它提供什么 常见误区
事故与险情 真实损失、传播路径、原有控制为何失效 用一次事故无限外推所有场景
代码/变更评审 重复争议、接口漂移、维护成本 把 reviewer 个人风格写成安全要求
测量与实验 计划、等待、WAL、容量、错误码、耗时分布 用一次样本或单一环境宣称普遍规律

例如,“脚本连接数据库后应先做 context guard”不是因为显式检查看起来严谨,而是因为 ch02 已经展示:同一组合法 SQL 可以成功连接到错误 database、错误 role 或 standby。失败机制是目标身份未被证明,后果是对错误对象执行正确动作,检测信号则是 current_database()current_userpg_is_in_recovery() 与预期不符。由此才能形成 SAFE-CONN-001

statement:
  自动化在执行 SQL 前验证 database、effective role、
  read/write 状态与预期 search_path

scope:
  scripts, migrations, operations

failure:
  wrong-target execution

check:
  wrong-target probe 必须非零退出;运行证据保存连接事实

反过来,若团队只是觉得 textvarchar(n) 更“PostgreSQL”,它最多是候选偏好。第 4 章给出的证据是:没有长度业务合同的时候,varchar(n) 多引入一个并不属于模型的不变量;但若字段协议确实规定最大长度,或者跨系统交换需要在数据库边界拒绝超长值,varchar(n) 或显式 CHECK 都可能合理。因此本章把它记为 PREF-TEXT-001,而不是 safety。

从现象到规则的六步推导

遇到一个值得写进规范的现象时,依次问:

  1. 现象是什么:保存 query、SQLSTATE、catalog snapshot、时间窗和输入,而不是只写“数据库异常”;
  2. 失败机制是什么:名称解析、权限、快照、锁、计划估算、资源耗尽,还是外部系统语义;
  3. 影响是什么:数据错误、越权、不可用、性能退化,还是可读性成本;
  4. 范围在哪里:只约束 migration,还是所有 application query;只适用于 OLTP,还是也适用于批处理;
  5. 可检查信号是什么:source pattern、catalog fact、负向测试、运行指标或人工证明;
  6. 反例和例外是什么:在哪些前提下原失败机制不存在,偏离时用什么补偿控制。

只有完成这六步,候选规则才值得进入试行。一次事故可以提高优先级,却不能跳过适用范围;一次 benchmark 可以提供证据,却不能自动把结论变成组织底线。

证据有层次,但没有“万能证据”

本书按问题选择证据:

  • SQL 语义与数据库行为优先用 PostgreSQL 官方文档、SQLSTATE、catalog 和可重复实验;
  • 性能判断需要 EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS)、数据分布和多次测量,不能只贴计划节点名;
  • Pigsty 声明参考版本化配置文档,生效状态再回到 inventory、playbook 输出、service 路由和 PostgreSQL 运行事实;
  • 业务不变量由领域 owner 说明,数据库证据只能证明它怎样被实现,不能替业务定义真相。

证据也有有效期。数据规模、统计分布、PostgreSQL 大版本、扩展和 Pigsty 配置变更后,原测量需要重跑。baseline 的 evidence 因此记录 chapter、artifact 和 observation,而不是只保存一个“已验证”布尔值。

6.1.2 每条规则记录动机、证据、例外和检查方式

本章 registry 的每条 rule 使用同一最小结构:

字段 必须回答的问题
id / title 怎样稳定引用,标题能否准确概括
level / status 风险等级是什么,当前处于什么生命周期
owner 谁解释、修订并承担误报/漏报
scope 约束哪些代码、对象、环境与动作
statement 执行者必须做什么或证明什么
rationale 试图阻止哪条失败链
evidence 哪个可复核产物支持判断
exception 如何合法偏离,需要哪些补偿控制
checks 由 automation、runtime 还是 review 验收

可在 baseline-v0.1.json 中查看完整记录,并由 baseline-schema.json 约束结构。JSON 是权威机器源;baseline-guide.md 面向人类阅读,但其 25 个 Rule ID 必须与 registry 恰好一一对应。check_baseline.py 会拒绝 ID 缺失、重复或悄悄新增。

statement 要可执行,rationale 要可反驳

比较两种写法:

坏:所有查询都要设置超时。

可执行:
所有 application/migration session 必须声明 statement_timeout、
lock_timeout 和 idle_in_transaction_session_timeout;
预算由调用场景给出,禁止依赖服务器无限默认值。

第二句仍不替团队决定“所有查询必须 30 秒”,但给出了受约束对象、需要声明的参数和禁止状态。它允许批处理用更长 statement_timeout,同时要求批处理 owner 对更长预算负责。

rationale 也不能写成“这是最佳实践”。应该写出可被证伪的机制:没有 lock_timeout 时,一个本应毫秒完成的 DDL 可能无限等待兼容锁;没有 idle_in_transaction_session_timeout 时,遗忘事务可能长期持有 snapshot/lock;没有 application_name 时,同一 user/database 的会话难以归因。如果后续证明某个环境已经用等价机制完全消除风险,就有讨论例外的基础。

exception 不是后门

四种例外模式对应不同风险:

模式 含义 最低要求
none 不允许在当前设计内偏离 改变设计,或提出规则修订
breakglass 紧急、限时地跨过 safety control 精确身份、owner、时间窗、补偿控制、撤销和事后复核
waiver 有证据地偏离团队默认 原因、范围、owner、expiry、验证与回归条件
review 本来就是场景偏好 reviewer 记录为什么该场景选择此方案

例外必须是显式对象,而不是聊天里的一句“这次特殊”。waiver-template.md 要求记录补偿控制、到期时间与关闭条件。过期 waiver 没有自动变成永久例外;它应阻断下一次相关变更,直到回归默认或续期。

check 要证明风险被控制

检查方式分三层:

  • automated:不依赖人类解释的结构、source 或确定性输出,例如 Rule ID、JSON shape、禁止 secret pattern;
  • runtime:连接目标后读取 session、catalog、SQLSTATE、checksum 或真实查询行为;
  • review:领域语义、代价取舍、外部副作用等目前不能可靠自动判断的证明。

自动化覆盖率高不等于规则正确。一个错误的正则可以稳定地产生误报;一个 catalog check 只能证明检查时刻的数据库状态。相反,只有 review 也不等于“无法改进”:重复评审结论应推动 fixture、lint、catalog assertion 或运行指标出现。

owner 对规则本身负责

owner 不只是审批人,还要持续回答:

  • 这条规则最近阻止了什么真实问题;
  • false positive 是否让团队开始绕过 gate;
  • 哪类 incident 暴露了 false negative;
  • 检查成本是否与风险相称;
  • PostgreSQL/Pigsty 升级后证据是否仍有效;
  • 例外是否按时关闭;
  • 规则应该收紧、降级还是废弃。

没有 owner 的规则只会不断累积。没人有权修改,就意味着没人对错误负责。

6.1.3 区分安全底线、团队默认与场景偏好

规则等级不是“强烈推荐、推荐、可选”的措辞游戏,而是由失败后果与可接受处置决定:

flowchart TD
  A["违反后会不会直接造成<br/>数据错误、越权、不可恢复动作<br/>或不可归因事故?"] -->|是| B["Safety"]
  A -->|否| C["团队是否需要统一默认<br/>以降低组合与维护成本?"]
  C -->|是| D["Default"]
  C -->|否| E["是否只是多个正确方案间<br/>的可读性或成本选择?"]
  E -->|是| F["Preference"]
  E -->|否| G["不进入 baseline<br/>保留为知识或局部设计"]

Safety:要求明确停止线

SAFE-DEFR-004 要求 SECURITY DEFINER function 固定可信 search_path 并收回默认 PUBLIC 执行权,因为高权限名称解析可形成提权路径。这里不能用“团队一般喜欢 schema-qualified name”来解释;风险是权限边界被绕过,不能满足时应改用 invoker function 或重新设计。

Safety 不代表所有检查都必须在 v0.1 自动化,但未自动化必须可见。当前 SAFE-RETR-008 只有 review:第 5 章证明了 failed transaction 和外部副作用边界,却尚未构造第 10 章的并发 retry harness。把它列入 safety 是风险判断;输出 safety_non_review_count=9 是成熟度判断。两者不能混为一谈。

Default:减少无意义差异

DEFAULT-CONT-002 固定 UTF-8、UTC 和受控 search_path。这不意味着 PostgreSQL 只支持这一套组合,而是 pg36_shop 需要一个跨环境稳定默认,使 timestamp、文本和名称解析不随开发者机器变化。若某个报表必须用特定会话时区,可以申请范围明确的 waiver,仍需保存输入/输出时区并验证夏令时边界。

Default 的价值往往是降低认知和测试矩阵,而不是避免灾难。它可以被证据推翻,也应该允许不同产品线建立自己的默认。

Preference:保留工程判断

PREF-ASQL-004 不禁止 CTE、窗口函数或 LATERAL,也不强迫使用。它要求高级 SQL 让关系语义更清晰且可测试。一个一次扫描完成的窗口查询可能比多次 round trip 更易维护;一个嵌套过深、估算失真的单条 SQL 也可能应该拆开。这里需要查询合同与计划证据,不适合以关键字 lint 阻断。

偏好若被伪装成 safety,会制造大量无意义例外;真正的 safety 若被降成偏好,则让高影响风险依赖 reviewer 当天是否注意到。分级本身就是规约质量的一部分。

生命周期与版本

本章采用以下状态演进:

candidate
  → trial(在 L1/测试环境记录成本与误报)
  → active(进入版本化 baseline)
  → revised / deprecated(证据改变或被更好控制替代)

规则 statement、level、scope 或 exception 发生语义变化时必须升级 baseline 版本;只增加同一判断的证据可以追加 ledger,但仍应留下变更记录。任何 active rule 被废弃都要说明:风险已经消失、被哪个控制替代,以及旧检查何时移除。

本章的 evidence-ledger.md 把 ch01–ch05 记为 v0.1 输入,把 ch07–ch11 作为预留追加区。到 ch12,只有规则 ID、证据、自动化、例外和兼容说明共同稳定,才发布 v1.0。

本节检查清单

拿团队现有任意一条规范,若无法回答下列问题,就先降级为 candidate:

  1. 它阻止的具体失败机制是什么;
  2. 它适用于哪些对象、动作和环境;
  3. 哪个 artifact 或运行事实支持它;
  4. 哪些反例说明不能无限外推;
  5. 违反时是阻断、waiver 还是 review;
  6. 谁负责处理误报、例外与版本变化;
  7. 怎样知道控制已真正生效;
  8. 什么条件下应该修订或废弃。

这套问题比规则数量更重要。一个有证据、能检查、允许被修订的 25 条 baseline,远胜一份没人敢删也没人真正执行的 250 条“最佳实践”。


返回本章目录 · 下一节:连接与会话候选规则 · 查看全书目录 · 查看索引中心

6.2 连接与会话候选规则

应用拿到一个 PostgreSQL connection 时,业务代码通常把它当作“数据库”。实际上,它是一组会改变 SQL 含义和失败方式的上下文:host/service 把流量送到某个 instance,database 决定 catalog 边界,role 决定权限,GUC 决定名称解析、时间展示与超时,连接池还可能让同一个 server connection 被多个 client request 复用。

因此,连接规约不能只检查 TCP 和认证成功。它必须同时约束目标、身份、语义、预算与归因

6.2.1 连接上下文、超时与 application_name

本书把连接上下文拆成两层:

连接前声明
  service / host / port / dbname / user
  connect_timeout / application_name / TLS policy

连接后验证
  current_database()
  session_user / current_user
  pg_is_in_recovery()
  current_setting(...)
  schema/model version

第一层表达意图,第二层证明意图落在了正确对象上。只做其中一层都不够:连接字符串写对了仍可能遇到 DNS、service 或 failover 配置错误;连接后只看 SELECT 1 又无法知道自己是谁、在哪个库、是否落到只读副本。

用 service name 固定目标身份

libpq service file 把一组连接参数绑定到一个稳定名称:

[pg36-admin]
host=<L1_HOST>
port=5436
dbname=pg36_shop
user=dbuser_dba
application_name=pg36-ch06
connect_timeout=5
options=-c statement_timeout=30s -c lock_timeout=5s

客户端以 service=pg36-adminPGSERVICE=pg36-admin 连接。用户级 service file 默认为 ~/.pg_service.conf,也可以用绝对路径 PGSERVICEFILE 指定;直接连接字符串中的同名参数又会覆盖 service file 值。因此 service 是集中声明,不是不可绕过的安全边界,gate 仍须检查运行事实。

自动化入口使用:

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

psql -X -w "service=$PGSERVICE application_name=pg36-ch06-review"

-X 不读取用户 psqlrc,避免本地宏、变量或 SET 改变脚本;-w 禁止在无人值守任务里退回交互密码提示。第 2 章的 service file 示例 没有 secret,密码应来自 mode 0600 的 passfile 或组织 secret provider。Unix 上权限过宽的 password file 会被 libpq 忽略;这是一项客户端保护,不等于凭据已经完成轮换、审计和最小授权。

四类超时不是同一个旋钮

预算 控制的阶段 典型失败
connect_timeout 建立连接 网络、DNS、endpoint 不可达
statement_timeout 单条 statement 执行 查询/写入超过请求预算
lock_timeout 等待任意单次 lock acquisition DDL/DML 被冲突锁长期阻塞
idle_in_transaction_session_timeout transaction 中无客户端活动 遗忘事务长期持锁或 snapshot

本章实验值为 5 秒连接、30 秒 statement、5 秒 lock、60 秒 idle-in-transaction。它们是 L1 教学 workload 的默认,不是生产通用答案。在线请求、报表、批处理、migration 应分别从 SLO、锁风险和恢复方式推导预算。

statement_timeout=0lock_timeout=0 表示禁用超时,不表示“立刻超时”。lock_timeout 若等于或大于 statement_timeout,通常没有独立触发机会。超时也不是资源隔离:30 秒内仍可能消耗大量 CPU/I/O,后续章节还要加入并发、连接数、work memory 和 workload routing。

application_name 是归因键,不是认证身份

application_name 会出现在 pg_stat_activity,配置允许时也可进入日志。命名至少区分:

product / component / workload / environment

实验脚本还加入 action,例如 pg36-ch06-all。生产 trace 应在应用侧把 request/trace ID 与 backend PID、backend_start、transaction/query start 和 dashboard 时间窗关联;不要把每个 request ID 都塞进一个高基数、长度受限的 application_name

客户端可以自行声明这个值,所以它不能替代 session_user、证书身份或审计主体。它的作用是把等待、日志和指标归到正确 workload/owner;安全判断必须使用服务器验证的身份。

由此形成两条规则:

  • SAFE-CONN-001:写入前验证 database、effective role、primary、search_path 和模型版本;
  • DEFAULT-SESS-001:每类 workload 声明可归因 application name 与连接/statement/lock/idle transaction 预算。

6.2.2 时区、编码、search_path 与会话状态

SQL 文本相同,不保证会话语义相同。下面这些 session state 都可能改变结果:

  • client_encoding 决定客户端字节怎样转换为数据库字符;
  • TimeZone 改变 timestamptz 的输入解释和输出展示;
  • DateStyle 影响含歧义的日期文本;
  • search_path 决定未限定 table、function、type 与 operator 名称解析;
  • transaction isolation/read-only/deferrable 改变并发观察;
  • role 与 row security 设置改变可见对象和数据;
  • timeout、planner GUC 与 locale/collation 影响失败或计划选择。

本章默认固定:

SET client_encoding = 'UTF8';
SET TimeZone = 'UTC';
SET search_path = pg_catalog, shop;
SET statement_timeout = '30s';
SET lock_timeout = '5s';
SET idle_in_transaction_session_timeout = '60s';

UTC 是存储/接口默认,不妨碍 UI 按用户时区展示;UTF-8 是跨系统文本默认,不替代 collation 设计。若业务输入使用本地时区,接口必须同时携带 zone/offset 并测试 DST 重叠与跳跃,不能依赖 application server 的系统时区。

search_path 是信任边界

search_path 不只用于缩短表名。PostgreSQL 也按它解析 function、type 和 operator;把某个可被不受信用户 CREATE 的 schema 放入 path,就等于信任该用户可以影响未限定名称的解析。

对 application session,本书采用:

pg_catalog, shop

同时从 public 撤销 PUBLIC CREATE。需要注意版本和升级历史:PostgreSQL 15 新建数据库的默认权限与从 PostgreSQL 14 或更早升级的数据库可能不同,不能靠“大版本默认应该安全”代替 catalog 检查:

SELECT has_schema_privilege('public', 'CREATE');

SECURITY DEFINER function 要更严格:只保留可信 schema,把 pg_temp 放到最后或明确排除不可信解析路径,敏感对象使用 schema-qualified name,并在创建 function 的同一事务中 REVOKE ALL ... FROM PUBLIC 后按需 GRANT EXECUTE。这是 SAFE-DEFR-004 的安全边界,第 4 章已有 catalog 反证。

会话默认与每次请求声明

配置可以有多个层次:

postgresql.conf / ALTER SYSTEM
  < ALTER DATABASE / ALTER ROLE
  < ALTER ROLE ... IN DATABASE
  < startup options / connection parameters
  < session SET
  < transaction SET LOCAL

具体生效值应由 current_setting()pg_settings 观察,不应只查看某一层配置文件。对稳定的 database/role 默认,可由 Pigsty pg_databases.parameters 或版本化 SQL 声明;对单次事务预算,优先在 transaction 开始后 SET LOCAL,让它随 commit/rollback 自动恢复。

在 PgBouncer transaction pooling 下,client session 与 PostgreSQL backend 不是永久一一对应。应用不能假设上一请求的 SET、临时对象、prepared statement 或 session lock 会在下一事务仍然存在,也不能让状态泄漏给后续请求。需要 session affinity 的 workload 应选择 session pooling 或 direct service,并把理由写进 waiver;普通 OLTP 则应把事务所需状态显式放进 startup/role default 或每个 transaction。

因此 DEFAULT-CONT-002 的真正要求不是“所有地方硬编码同一串 SET”,而是:

  1. 定义 canonical session profile;
  2. 选择一个可重复应用的层次;
  3. 在取得连接后验证关键语义;
  4. 对 pool reuse 不做隐式假设;
  5. 在 evidence 中保存实际值。

6.2.3 用错误连接案例验证规则价值

只有正例的 gate 可能永远绿色,即使检查本身已经失效。本章提供两个故意失败的 fixture:

错误会话

wrong-session.sql 主动设置:

SET TimeZone = 'Asia/Shanghai';
SET search_path = public;
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;

然后要求 baseline 以自定义 SQLSTATE P0601 拒绝。gate 不是笼统检查“命令失败”,而是同时断言:

psql exit = 3
stderr 中恰好一个 ERROR: P0601

若脚本因为语法错误、认证失败或其他 SQLSTATE 退出,negative gate 仍失败;否则一个与规则无关的故障也会被误报为“安全控制成功”。

错误目标

session-profile.sql 默认期待 pg36_shop。negative action 将 expected database 改为一个确定不存在于合同中的名字:

psql ... \
  --set=expected_db=definitely_not_pg36_shop \
  --set=VERBOSITY=sqlstate \
  --file=session-profile.sql

context.sql 在任何业务读取前拒绝,预期 exit 3、SQLSTATE P0001。这证明 target guard 确实参与路径,而不是写在文件里却从未被调用。

正确会话

quality-gate.sh live 运行相同 profile,要求输出:

status=ok
database=pg36_shop
effective_role=pg36_owner
application_name=pg36-ch06-<action>
client_encoding=UTF8
timezone=UTC
search_path=pg_catalog, shop
statement_timeout=30s
lock_timeout=5s
idle_in_transaction_session_timeout=1min

注意输出格式可能把 60s 规范化为 1min;验收 SQL 用 ::interval 比较语义,不比较展示文本。类似地,host、PID、server version 和 timestamp 是本次证据,不应做 golden value。

仍然没有证明什么

这个 gate 证明检查时刻的 PostgreSQL session 与 ch04-v1 模型符合预期,但没有证明:

  • TLS、证书和 HBA 满足生产安全策略;
  • 所有应用连接都使用同一个 profile;
  • HAProxy endpoint 在 failover 后仍按预期路由;
  • passfile/secret provider 的生命周期与轮换正确;
  • 30 秒预算适合真实 SLO;
  • PgBouncer reset 与 driver 行为没有其他差异。

这些边界必须明确写出,否则一个绿色实验会被错误扩大为生产认证。正确做法是把相邻控制接入同一证据链,而不是让单个脚本背负它无法观察的结论。

运行本节验证

静态检查不连接数据库:

cd static/labs/ch06
./quality-gate.sh static

在已经确认的 ch04-v1 L1 上运行 session 正反例:

export PGSERVICEFILE=/absolute/private/path/pg_service.conf
export PGSERVICE=pg36-admin
export PG36_EVIDENCE_DIR="$PWD/evidence/ch06/session-$(date -u +%Y%m%dT%H%M%SZ)"

./quality-gate.sh live
./quality-gate.sh negative

检查 session-profile.txtnegative-summary.txt 和各自 stderr。只有正确上下文通过、两个错误上下文按精确原因失败,连接规约才同时拥有正向和负向证据。

参考资料


上一节:规约不是口号 · 返回本章目录 · 下一节:模式与 DDL 候选规则 · 查看全书目录 · 查看索引中心

6.3 模式与 DDL 候选规则

DDL 不是把 ER 图翻译成 CREATE TABLE,而是在数据库中发布一组长期合同:名称怎样解析、谁拥有对象、什么输入被拒绝、什么标识保持稳定、旧应用与新 schema 能否共存。表一旦有数据和调用方,类型、约束和默认值就同时影响写入语义、锁、WAL、复制和恢复。

本节把 ch03 的逻辑问题与 ch04 的物理实现收敛成三组评审规则。具体的在线 schema change 编排留到第 11 章;这里先建立每个变更都必须携带的前提、停止线和证据。

6.3.1 命名、所有权、注释与对象边界

pg36_shop 用三个 schema 表达不同稳定性边界:

Schema 内容 调用约束
shop 核心关系模型与业务写入对象 application 通过精确 GRANT 读写
shop_api 对外稳定 view/query interface application/reader 只依赖发布列
shop_private migration 元数据与内部函数 不向普通 runtime role 暴露

schema 不是项目文件夹。它同时参与名称解析、USAGE/CREATE 权限与对象归属。边界成立至少要检查四件事:

SELECT
    n.nspname,
    pg_get_userbyid(n.nspowner) AS owner,
    has_schema_privilege('pg36_app', n.oid, 'USAGE') AS app_usage,
    has_schema_privilege('pg36_app', n.oid, 'CREATE') AS app_create
FROM pg_catalog.pg_namespace AS n
WHERE n.nspname IN ('shop', 'shop_api', 'shop_private', 'public');

预期不是“schema 名字存在”,而是 owner、USAGE、CREATE 和 search_path 共同符合合同。shop_private 即使名字带 private,若 application 有 USAGE/EXECUTE,仍不私有。

owner、migration identity 与 runtime identity 分离

本书采用:

pg36_owner  NOLOGIN  持有 database/schema/table/function
pg36_app    LOGIN    只获得应用所需 DML/USAGE/EXECUTE
pg36_ro     LOGIN    只获得对外读取权限
dbuser_dba  LOGIN    通过受审计 direct service 执行 migration,
                     必要时 SET ROLE pg36_owner

对象所有者可以修改或删除自己拥有的对象,也能改变授权;把 owner 直接作为 application login,会让 SQL injection 或应用缺陷越过显式 GRANT。SAFE-ROLE-003 因此是 safety,而不是命名偏好。

NOLOGIN owner 也不等于“不需要保护”:能 SET ROLE 到 owner 的 membership、migration identity 和 SECURITY DEFINER function 都是进入该权限域的路径,必须在 catalog 中验证。日常应用不能为了省事获得 owner、superuser、CREATEDBCREATEROLEBYPASSRLS

名称要支持定位,不要假装表达全部语义

默认使用不需双引号的小写 snake_case,原因是客户端、migration 和 catalog 查询更稳定,而不是 PostgreSQL 不支持其他命名。名称应回答:

  • relation 表达什么事实,不以当前 UI 页面命名;
  • column 的单位或时间语义是否可见,例如 _minor_at_date
  • constraint 出错时能否定位业务不变量;
  • index 名能否关联 key/order/predicate;
  • function 名是否表明它是 command、calculation 还是 trigger implementation。

例如:

CONSTRAINT sales_order_order_no_key UNIQUE (order_no)
CONSTRAINT sales_order_total_minor_nonnegative
  CHECK (total_minor >= 0)

稳定的 constraint name 使负向测试可以同时断言 SQLSTATE 与 CONSTRAINT_NAME,不会把任何 23505 都误认为目标唯一约束。命名本身不保证正确,但让错误、catalog、migration 和 incident evidence 可以指向同一对象。

PostgreSQL identifier 最长受 NAMEDATALEN 限制,默认最多存储 63 bytes;过长名称会被截断。规约应保证关键语义在截断前仍可辨识,并用 catalog 检查真实名称,而不是依赖生成器在内存中的原始字符串。

comment 是运行目录的一部分

COMMENT ON 应覆盖关键 database、role、schema、relation、column 与非显然约束,至少说明:

owner / purpose / unit or semantic / external contract / lifecycle

comment 不是放完整设计文档,也不能包含 secret、个人数据或随请求变化的值。它的优势是跟对象一起出现在 catalog、\d+ 和元数据工具中。设计文档说明“为什么”,comment 则帮助值班者快速确认“这是什么、谁负责”。

由此形成 DEFAULT-NAME-003:边界由 schema、owner、稳定名称和 comment 共同表达;legacy 例外必须有 mapping、owner、迁移计划和 expiry。

6.3.2 类型、约束和默认值的审查问题

“应该用哪个 PostgreSQL 类型”不能只靠一张类型对照表。评审时先完成语义句,再选类型:

维度 要回答的问题 pg36_shop 示例
单位 数值代表什么,能否相加 amount_minor + currency_code
范围/精度 是否允许负数、上限、舍入点在哪里 nonnegative named CHECK
时间 瞬间、民事时间、日期还是持续时间 placed_at timestamptz
文本 identity、大小写、Unicode、排序和长度合同 order_no text + format/unique
标识 谁生成、唯一范围、稳定期、是否公开 internal ID / order no / request key
缺失 NULL 是未知、不适用还是尚未发生 paid_at only after payment
演进 新值、新状态和旧客户端怎样共存 status transition contract

类型名不等于业务合同

numeric 不知道币种和舍入规则,text 不知道 Unicode identity,timestamptz 不保存原始时区名称,jsonb 不自动提供领域约束。DDL 需要组合 type、column name、NOT NULL、default、named constraint、reference 与 comment。

本书把金额写成 minor unit integer 并显式保存 currency。这适合当前教学订单,但并非所有财务系统通用:支持任意精度计量、汇率、税务舍入或多币种分摊时,应重新建模,不能从“整数没有浮点误差”推出“整数能表达所有货币语义”。

若业务没有真实长度上限,PREF-TEXT-001 倾向 text,而不是习惯性 varchar(255)。若外部协议明确限制 64 个字符,则必须说明计数单位是字符、bytes 还是规范化后的 code points,并定义超长输入的错误合同。

约束优先保证单库可表达的不变量

适合进入数据库约束的包括:

  • 值域与行内一致性:CHECKNOT NULL
  • 候选键和幂等键:UNIQUE
  • 引用完整性:FOREIGN KEY
  • 时间/空间排斥:EXCLUDE
  • 需要 transaction 末尾成立的关系:可延迟 constraint。

每个关键约束至少有一个反例,且验证“因预期约束失败”:

SQLSTATE=23514
CONSTRAINT_NAME=sales_order_total_minor_nonnegative

只检查“INSERT 失败”可能掩盖权限错误、类型解析错误或另一个约束先触发。只测试正向 seed 则无法证明数据库真的拒绝坏状态。

跨 database、外部支付系统或时间变化事实通常不能靠单个 declarative constraint 完整表达。此时 SAFE-CONS-005 允许受控 breakglass,但必须记录 application control、reconciliation、owner 和到期复查;“数据库做不了”不是“不需要保证”。

四种标识不要混成一列

DEFAULT-KEYS-005 区分:

order_id       内部 join/physical identity
order_no       用户可见业务编号
provider_ref   外部系统引用
request_key    请求幂等键

它们的 authority、生命周期、隐私和唯一范围不同。把可变外部字符串同时当 primary key、公开 URL 和 retry key,会让 provider 变化、内部迁移和 API 合同互相绑死。并非每个模型都需要四列;若用一个标识,review 必须证明这些责任确实一致。

default 是写入规则,不是历史真相

default 回答“调用方省略列时写入什么”,不回答旧行原本是什么。常见审查点包括:

  • now() 是 transaction start time;是否需要 statement/clock time;
  • identity/sequence 生成的是唯一候选值,不承诺无间隙或按 commit 排序;
  • 空字符串、空 JSON 与 NULL 是否真的同义;
  • volatile default 是否触发表重写或让重跑结果不稳定;
  • 新增 default 后,旧应用显式发送 NULL 时会发生什么;
  • backfill 值能否从已有事实确定,还是在伪造历史。

默认值方便不应覆盖领域语义。一个字段若在业务上必须由调用方明确选择,省略 default 反而能尽早暴露错误。

JSON、array、enum 与 partition 都要证据

PREF-SEMI-002 只把 shape 可变、整体读写、有明确 owner/validation/retention 的附属值放进 JSONB/array;需要独立引用、唯一、局部更新或生命周期的事实优先拆成 relation。这不是“永远范式化”,而是让数据库能对核心事实提供统计和约束。

分区同理。PREF-PART-003 要求 retention/drop lifecycle、可测规模瓶颈或稳定 pruning 证据先出现,再设计 partition key、unique/PK、FK 和迁移。预计未来会有一亿行不是设计完成;分区会立刻增加约束、索引和运维复杂度,收益却可能多年不出现。

6.3.3 可逆迁移与版本化 DDL

“所有 migration 都必须可回滚”听起来安全,实际上容易产生虚假承诺。删除列后,down 可以重新创建空列,却不能恢复已经丢失的业务语义;旧应用已经按新格式写入后,数据库回滚也不保证旧代码能理解数据。

更准确的目标是服务可恢复

兼容发布 + transaction rollback(尚可时)
  + 明确停止线
  + application rollback window
  + forward repair
  + backup/PITR 作为灾难恢复底线

expand—backfill—validate—switch—contract

跨 application release 的变更按五阶段设计:

  1. expand:添加旧代码可以忽略的新对象,避免立即收紧;
  2. backfill:分批填充,限制 lock/WAL/replica lag,过程可重入;
  3. validate:检查无坏值、约束成立、读写双路径一致;
  4. switch:先切写路径,再切读路径,保留观测和回退窗口;
  5. contract:确认旧代码/旧数据路径退出后再删旧对象。

PostgreSQL 的 transaction DDL 很强,但并非所有命令都能放在同一个事务,锁取得时机与强度也不同。CREATE INDEX CONCURRENTLYVACUUM 等有自己的事务限制;大型 backfill 即使可回滚,也可能产生巨大 WAL、dead tuples 和 replica lag。不能用“BEGIN 包住了”替代容量与锁评估。

对新 CHECK/FK,可以在合适场景先 NOT VALID,使新写入受约束,再单独 VALIDATE CONSTRAINT 扫描旧数据;这不自动适用于 UNIQUE/PK,也不消除所有 lock。具体 lock mode、版本差异与 workload 影响必须在 ch11 用当前目标版本实测。

破坏动作之前先证明可表示

SAFE-MIGR-006 要求在类型收窄、列删除、表重写或约束收紧前完成:

precheck bad rows / dependencies
lock_timeout + statement_timeout
兼容 application 版本范围
WAL / temporary space / lag 预算
最大批次与停止线
失败后 transaction rollback 或 forward repair
post-state catalog + checksum

precheck 必须在真正写入前失败。例如把 text 转成 integer,先找出所有无法转换的值并固定 conversion rule;不要让 ALTER TABLE ... TYPE 运行数十分钟后才撞到最后一个坏值。precheck 与执行之间仍可能有竞态,所以迁移还需限制并发写、使用兼容约束或在同一受控边界重新确认。

高风险 contract 不因为有 backup 就可以随时执行。backup/PITR 是最大故障恢复证据,不是低成本 undo;恢复时间、数据丢失窗口和对其他 database 的影响都要进入风险说明。

fresh install 与 upgrade 只有一条权威链

两套脚本最容易漂移:

create-latest.sql     # 新环境
V001...V042.sql       # 旧环境升级

如果两者由人独立维护,很快会产生“同版本不同 schema”。DEFAULT-VERS-010 要求 fresh install 也消费同一条 versioned migration chain,或者由这条链可重复生成并验证 latest snapshot。数据库内要有可查询的 schema version 和 migration identity;重复执行要么幂等成功,要么在修改状态前明确拒绝。

本书使用 shop_private.schema_version 标记 ch04-v1,并由每章 context guard 检查。版本号本身不证明 schema 正确,所以还需要 catalog assertions 与稳定 relation checksum;checksum 又不能覆盖全部权限、function body 和运行参数,因此验收必须是多项事实,不是单个魔法哈希。

变更说明先于执行

使用 change-template.md 填写:

  • target、owner、窗口与 application release;
  • 当前 schema version、relation size、write rate 和依赖方;
  • 五阶段迁移与重跑行为;
  • lock/WAL/temporary space/lag 预算;
  • transaction rollback、application rollback 与 forward repair;
  • precheck、负向 SQLSTATE、post-state checksum;
  • 最大可信故障、停止条件和审批。

无法填写的字段不是“文档以后补”,而是设计尚未完成。真正执行在线 DDL 前,还要在第 11 章为具体 PostgreSQL/Pigsty 环境补齐锁实验、监控窗口和发布编排。

本节验收问题

评审任意一个 DDL change,应能回答:

  1. schema、owner、runtime role 和 API boundary 是否明确;
  2. 名称/注释能否从 catalog 定位领域语义和 owner;
  3. 每个类型是否闭合单位、范围、时间、文本与 NULL 语义;
  4. 关键约束是否命名,并有精确 SQLSTATE/constraint 反例;
  5. 内部、业务、外部和幂等标识是否被有意区分;
  6. JSON/array/partition 的收益与边界是否有 workload 证据;
  7. fresh install 与 upgrade 是否进入同一 version authority;
  8. destructive step 前是否有可表示性 precheck、timeout 和停止线;
  9. application rollback 时 schema 是否仍兼容;
  10. 丢失语义时是否诚实声明只能 forward repair 或 restore。

如果其中任何高影响问题只能回答“应该没事”,该变更仍是 candidate,不应进入生产发布队列。

参考资料


上一节:连接与会话候选规则 · 返回本章目录 · 下一节:查询与事务候选规则 · 查看全书目录 · 查看索引中心

6.4 查询与事务候选规则

查询规约要保护的是调用合同,事务规约要保护的是失败后的正确性。两者都不适合简化成 SQL 风格检查:SELECT * 在交互诊断中很方便,在持久 API 中却会制造列漂移;CTE 可能清晰表达关系步骤,也可能引入不必要 materialization;短事务通常更友好,但把本应原子的一组写入拆开只会得到更快的错误结果。

这一节先固定语义合同,再讨论代价。第 7–10 章会继续为计划、索引和并发规则补证据。

6.4.1 明确列、稳定排序与分页语义

持久 query interface 至少声明五件事:

input:
  参数名、类型、NULL、范围和授权上下文

output:
  列名、类型、NULL、单位和兼容策略

cardinality:
  0/1/N 行,是否允许重复

order:
  排序键、方向、NULL、collation、tie-breaker

consistency:
  单条语句 snapshot,还是跨页/跨查询一致视图

DEFAULT-QUER-006 因此要求稳定接口显式投影:

SELECT
    o.order_id,
    o.order_no,
    o.order_status,
    o.currency_code,
    o.placed_at
FROM shop.sales_order AS o
WHERE o.customer_id = $1;

这不是因为 SELECT * 在服务器内部必然更慢,而是因为隐式列集合会随 DDL 变化,扩大网络与权限面,破坏 positional decoder,并让调用方不知不觉依赖内部列。短期 psql 探索可以使用 *;稳定 view consumer、API query 和 migration copy contract 不应使用。

没有 ORDER BY 就没有顺序合同

PostgreSQL 文档明确指出,不指定 ORDER BY 时,返回顺序未定义。一次执行看起来按 primary key 或 heap 顺序返回,只是当前 plan、数据布局和并发状态的结果。加 LIMIT 也不会把偶然顺序变成合同:

-- 不稳定:同一价格之间没有 tie-breaker
ORDER BY total_minor DESC
LIMIT 20;

-- 稳定全序:最后一个键唯一且方向明确
ORDER BY total_minor DESC, order_id DESC
LIMIT 20;

唯一 tie-breaker 是 SAFE-PAGE-010 的底线。若排序列可为 NULL,API 还要固定 NULLS FIRST/LAST;若排序受 collation 影响,要固定 collation/normalization,或用稳定 binary/normalized key。否则 cursor 编码相同值时,不同环境可能得到不同边界。

keyset cursor 必须编码完整排序键

本章样例按:

ORDER BY placed_at DESC, order_id DESC

向后取下一页:

WHERE placed_at IS NOT NULL
  AND (placed_at, order_id) < ($cursor_placed_at, $cursor_order_id)
ORDER BY placed_at DESC, order_id DESC
LIMIT $page_size;

成立前提是两个键都非 NULL、比较语义与排序一致,最后的 order_id 唯一。cursor 至少编码两个值、sort version/direction 和必要的 filter identity;对外暴露时通常还需要签名或完整性保护,避免调用方伪造超范围条件。

若混用 ASC/DESC、NULL 或不同 collation,不能机械复制 row comparison;应展开为与排序完全等价的 predicate,并写边界测试。反向翻页也不是把 < 改成 > 就结束,还要反转内部 order、取得一页后恢复 API 顺序。

OFFSET 不是永远禁止:小型后台界面、稳定 snapshot 内的有限页数可以接受。但大 offset 仍要计算并丢弃前面的行;在 Read Committed 下跨页查询之间发生 insert/delete 时,还可能重复或遗漏。keyset 避免按位置跳过,却不能自动提供跨页 snapshot 一致性;排序键被更新时也可能移动。API 必须声明自己提供“实时游标”还是“固定快照导出”。

用结果合同而不是 SQL 文本做验收

query-contract.sql 不要求 application 复制某一段 SQL 字符串,而是验证:

  • shop_api.order_summary 恰好包含 11 个发布列;
  • 排序显式为 placed_at DESC, order_id DESC
  • 第一页和第二页 cursor 严格前进且不重叠;
  • order_no business key 与 request_key idempotency key 仍唯一。

典型输出:

status=ok
query_contract=explicit-columns+stable-keyset
view_column_count=11
cursor_order=placed_at-desc,order_id-desc
page_1_order_id=1002
page_2_order_id=1001
pages_do_not_overlap=t
business_key_unique=t
idempotency_key_unique=t

教学 fixture 只有两笔订单,所以这不是性能 benchmark,也没有覆盖 NULL、同 timestamp、大页数和并发移动。它证明 baseline 的最小语义;API 上线前还要添加这些边界用例。

6.4.2 事务大小、超时、重试与幂等

“事务越短越好”缺少一个关键限定:事务必须先覆盖保持不变量所需的完整正确性单元,然后才在这个边界内缩短。

以“创建订单并预占库存”为例:

BEGIN
  validate request key
  insert order
  insert order lines
  reserve inventory
  record durable event/outbox intent
COMMIT

如果这些数据库事实必须共同成立,就不能为了缩短 transaction 把它们拆成无补偿的独立 commit。真正应该移出去的是用户输入、HTTP 调用、邮件发送、长时间计算和无边界 sleep。DEFAULT-TXNN-007 要求 transaction diagram 标出:

BEGIN → first lock → database work → COMMIT
                    ↘ external wait?  应移出或重构

对大批处理则分批 commit,但必须定义 partial progress、restart cursor、幂等与最终 reconciliation。分批不是放弃原子性,而是把正确性单元重新定义为可恢复的小批次。

首个错误才是根因

显式 transaction 中第一条 statement error 会使 transaction 进入 failed state;后续普通 SQL 通常只返回 25P02 in_failed_sql_transaction。应用必须保存第一个 SQLSTATE,然后:

  • 整体 ROLLBACK;或
  • 回到事先建立、且业务语义允许的 savepoint。

不能在收到 25P02 后继续发业务 SQL,也不能把 failed/idle-in-transaction connection 原样归还 pool。driver/framework 的 cleanup 必须在归还连接前 rollback,并检查 transaction 状态。

第 5 章实验已经证明:

22012 → 25P02
23514 → ROLLBACK TO SAVEPOINT → valid statement → outer ROLLBACK

savepoint 是局部恢复工具,不是“忽略错误继续”。若失败改变了后续决策所依赖的业务语义,最安全的边界仍是整体重试。

timeout 是失败合同的一部分

statement/lock timeout 触发后,当前 statement 失败;若处于显式 transaction,transaction 同样需要 rollback/savepoint 恢复。应用必须区分:

  • query 被 server 明确取消;
  • 获取 lock 超时;
  • client deadline 先到并关闭/取消连接;
  • 网络断开导致 commit outcome 不明确。

它们不能统一成“再执行一次”。数据库可能明确回滚 statement,也可能已经 commit 但 ACK 丢失。

重试整个正确性单元

SAFE-RETR-008 目前定义:

SQLSTATE allowlist(例如 40001 / 40P01)
  → 丢弃旧 transaction/snapshot
  → bounded exponential backoff + jitter
  → 在总 deadline 内从 BEGIN 重跑完整单元
  → 超限后向调用方返回可归因错误

40001 serialization_failure40P01 deadlock_detected 常常可以整体重试,但“可以”仍依赖操作幂等、时间预算和 contention。不能只重放最后一条 SQL:前面的读取与判断来自已经失效的 snapshot。也不能把所有 08xxx connection exception 无条件重试,因为 commit 可能已经成功。

allowlist 要按 driver 暴露的 SQLSTATE/class 检查,不能按本地化 message substring。最大次数之外还要有总 deadline,避免数据库过载时 retry storm;jitter 用于打散竞争者,不保证消除热点。

幂等要闭合 ambiguous outcome

创建订单使用独立 request_key

INSERT INTO shop.sales_order (..., request_key)
VALUES (..., $request_key)
ON CONFLICT (request_key) DO NOTHING
RETURNING order_id;

DO NOTHING 只是起点。冲突后必须查询权威结果,并验证同一个 idempotency key 对应的业务 payload 是否一致;否则客户端错误复用 key 会被误当成成功。key 的作用域、保留时间和并发行为都要写入合同。

外部支付、HTTP、消息和邮件不随 PostgreSQL rollback 自动撤销。常见方案是先在同一 database transaction 内写 durable intent/outbox,再由独立 worker 幂等投递;或者由外部系统提供相同 idempotency key 和可查询 outcome。无论采用哪种方案,都要回答:

commit ACK 丢失后查谁?
重复投递怎样识别?
数据库成功、外部失败怎样补偿?
外部成功、数据库未知怎样 reconciliation?

本章只把这些问题固化为 review rule。真正的自动重试、deadlock/serialization fixture 与 ambiguous outcome 演练安排在 ch10。因此 v0.1 诚实输出 safety 自动/运行覆盖 9/10。

6.4.3 CTE、窗口函数与 LATERAL 的可读性门槛

高级 SQL 的评审不能变成关键字黑名单。WITH、window 和 LATERAL 都能让关系责任更直接,也都可能在错误数据分布下产生高成本。PREF-ASQL-004 的门槛是:reviewer 能用一句话说明每个构造负责什么,并且样例、边界测试与 plan evidence 支持它。

CTE:命名关系步骤,也可能改变优化边界

CTE 适合给复杂关系步骤命名:

WITH paid_orders AS (
    SELECT o.customer_id, o.order_id, o.total_minor
    FROM shop.sales_order AS o
    WHERE o.order_status = 'paid'
)
SELECT customer_id, sum(total_minor)
FROM paid_orders
GROUP BY customer_id;

在当前支持版本中,一个无副作用、非递归、只引用一次的 CTE 通常可折叠进父查询;多次引用通常会 materialize。MATERIALIZEDNOT MATERIALIZED 可以显式影响决策,但不是性能咒语:materialization 可能避免重复昂贵计算,也可能阻止父查询 predicate 下推。含 volatile function 或数据修改的 CTE 又有不同语义。

因此,不能继续沿用“PostgreSQL 的 CTE 永远是优化栅栏”这类跨版本口号。每个显式 materialization 都要说明是为了稳定语义、避免重复工作,还是经过计划对照后的成本选择。

Window:在同一行集上分析,不替代输出排序

window function 保留输入行,同时计算 partition/order/frame 内的值:

SELECT
    customer_id,
    order_id,
    placed_at,
    row_number() OVER (
        PARTITION BY customer_id
        ORDER BY placed_at DESC, order_id DESC
    ) AS customer_order_rank
FROM shop.sales_order;

window 的 ORDER BY 决定窗口计算顺序,不保证最终 result order;对外返回仍需顶层 ORDER BYlast_value 等函数还受默认 frame 影响,必须显式审查 frame。多个不同 window order 可能引入多次 sort;计划与 work_mem/spill 证据留到第 7、8 章。

LATERAL:表达逐行依赖,也可能放大外层基数

LATERAL 允许 FROM item 引用左侧 item,适合“每个 customer 最近两笔订单”:

SELECT
    c.customer_id,
    recent.order_id,
    recent.placed_at
FROM shop.customer AS c
CROSS JOIN LATERAL (
    SELECT o.order_id, o.placed_at
    FROM shop.sales_order AS o
    WHERE o.customer_id = c.customer_id
    ORDER BY o.placed_at DESC, o.order_id DESC
    LIMIT 2
) AS recent;

它可以把 application N+1 合并为一次 SQL,也常对应按外层每行执行的参数化路径。外层基数、内层索引和 loops 决定它是高效 top-N 还是放大器。评审不能因为“只有一条 SQL”就判断更快。

计划证据不做节点名 golden test

PREF-PLAN-005 明确:

  • Seq Scan 不自动错误,小表/低选择性时可能最优;
  • Nested Loop 不自动错误,参数化小结果与合适索引时可能最优;
  • planner cost 不是毫秒;
  • 一次 EXPLAIN ANALYZE 不是未来预测;
  • 强制 planner GUC 或新增索引前,先看 estimate/actual、loops、buffers、wait、参数和数据分布。

本章 gate 只验证 query semantics,不固定 plan node。精确 plan evidence 在 ch07 引入,慢查询闭环在 ch08,索引写放大与收益在 ch09。这样的章节边界防止 baseline v0.1 提前把尚未实验的性能偏好升级成 safety。

本节验收问题

  1. 稳定 query 是否显式列出输入、输出和 cardinality;
  2. 对外 result 是否显式排序,并以唯一键形成全序;
  3. cursor 是否编码全部 sort keys、direction、NULL/collation 与失效语义;
  4. 是否明确需要实时分页还是跨页一致 snapshot;
  5. transaction 是否覆盖完整不变量,同时排除用户/远程等待;
  6. 首个 SQLSTATE 是否保留,失败连接是否在回 pool 前 rollback;
  7. retry 是否重跑完整 transaction,带 allowlist、backoff、jitter、次数和总 deadline;
  8. ambiguous commit 是否能通过 idempotency key 与权威查询闭合;
  9. 外部副作用是否有 durable intent、幂等或 reconciliation;
  10. 每个 CTE/window/LATERAL 是否有一句话职责、边界用例和计划证据;
  11. 是否避免用节点名、cost 或一次耗时做 blanket rule。

当这些答案进入 query contract 和变更证据后,SQL 才从“现在能跑”升级为“失败后仍可推理”。

参考资料


上一节:模式与 DDL 候选规则 · 返回本章目录 · 下一节:交付物与质量门 · 查看全书目录 · 查看索引中心

6.5 交付物与质量门

数据库代码通过 review 只是交付的一部分。接手者还需要知道它针对哪个状态、怎样重跑、错误时停在哪里、是否可以恢复、运行后怎样证明没有漂移。没有这些信息,一段正确 DDL 仍可能在错误 database 上、错误窗口里,以错误的应用版本执行。

本节定义“一个可交付数据库变更”需要携带的产物和质量门。它不是要求每个小改动都写几十页,而是让风险越高的动作拥有越强的前验、后验和接管信息。

6.5.1 DDL、迁移、数据生成与回滚

一个完整交付包按职责拆分,而不是把所有内容塞进 deploy.sql

产物 责任 必须避免
contract/ADR 目标、非目标、业务不变量、兼容边界 只写实现,不写为什么
migration 从已知 version 到下一 version 同时猜测多个未知起点
fresh install 复用/生成自 migration authority 独立维护另一套 latest schema
seed/fixture 构造确定性最小场景 隐式当前时间、无 seed 随机数
precheck 在写入前证明输入可表示、依赖可控 迁移中途才发现坏值
verify catalog、权限、不变量、checksum 后验 只看脚本 exit 0
negative cases 证明错误状态被正确规则拒绝 捕获所有异常后宣称通过
reset/cleanup 仅用于明确可销毁范围 把 reset 冒充生产 rollback
runbook/evidence 输入、命令、版本、stdout/stderr、结果 只保存截图或手工摘要

并非每项变更都需要 seed 或 reset。例如只读诊断没有持久对象,不应为了“模板完整”添加 destructive cleanup。生产 migration 通常也不提供一键 reset;它需要兼容回退和 forward repair。交付矩阵的价值是要求作者明确“适用/不适用及原因”,不是追求文件数量。

migration 的起点必须可识别

执行前至少检查:

database / effective role / primary
schema version / migration history
关键对象 shape 与 ownership
application compatibility window
source artifact hash

若起点未知,应在修改任何状态前拒绝。所谓“幂等”不应等价于到处写 IF EXISTS 后吞掉漂移;当对象存在但 shape、owner 或语义不同,安全行为是报错并交给 owner 判断。

migration 记录唯一 identity,成功后原子推进 schema version。重跑时:

  • 已以同一 checksum 成功:可以明确报告 no-op;
  • 尚未开始:从确定起点执行;
  • 中途失败但 transaction rollback:确认起点仍成立;
  • 包含非事务步骤或 outcome 不明:进入专门 reconcile/repair,不能盲重放。

fixture 要可重建、可比较

DEFAULT-FIXT-008 要求教学/测试数据使用稳定业务值、显式 timestamp 和受控序列策略。随机数据可以用于 property/load test,但必须记录 seed、generator version 与规模参数。

验收不要依赖易变物理标识:

稳定:row count、命名约束、业务 fingerprint、relation checksum
动态:PID、XID、LSN、ctid、sequence gap、当前 timestamp

动态值可以保存在 evidence 中帮助取证,却不能硬编码成跨运行 golden value。第 5 章 rollback 实验前后比较业务 fingerprint/checksum,同时允许 WAL LSN 前进,就是这一原则。

reset 是独立的破坏动作

reset 的目标是清理教学/测试状态,不是自动恢复生产。SAFE-DEST-009 要求 DROP/reset/terminate:

  1. 先把 service、database、schema/relation 或 PID+identity 解析成精确目标;
  2. 拒绝空变量、通配符、workspace root 与 broad target;
  3. 要求与目标绑定的独立确认 token;
  4. 执行时保存 before state;
  5. 执行后验证目标消失、预期保留对象仍存在、实验 worker 清零;
  6. target identity 不再精确时停止自动清理。

例如终止 backend 不能只凭 PID,因为 PID 会复用;至少结合 database、user、application_namebackend_start 与当前 query。文件清理不能把未解析环境变量交给递归删除。成功 exit 只说明命令执行,不能证明范围正确。

本章自己的 quality gate 全部只读或故意失败,不创建持久对象,所以没有 reset action。这是设计结论,不是交付缺失。

6.5.2 自动测试、静态检查与计划证据

数据库质量门应逐层增加成本与环境依赖:

flowchart TD
  A["Static<br/>schema / syntax / source safety"] --> B["Catalog contract<br/>shape / owner / grants / GUC"]
  B --> C["Positive + Negative SQL<br/>result / SQLSTATE / constraint"]
  C --> D["Integration<br/>driver / pool / service / application"]
  D --> E["Concurrency<br/>blocking / isolation / retry"]
  E --> F["Plan + workload<br/>estimate / actual / buffers / WAL"]
  F --> G["Release observation<br/>SLO / lag / error / rollback window"]

不是每次提交都同步运行最昂贵层,但进入下一环境前必须知道哪些层已通过、哪些仍待验证。用“CI 绿了”概括所有层会丢失决策信息。

Static:无数据库也能拒绝结构漂移

本章 check_baseline.py 只使用 Python 标准库,检查:

  • JSON 没有重复 key,registry 满足固定 shape;
  • 25 个 Rule ID 唯一,level 与 ID prefix 一致;
  • evidence 只指向 ch01–ch05,且 artifact 实际存在;
  • 人类指南中的 Rule ID 恰好各出现一次;
  • delivery manifest 的 artifact 与 action 完整;
  • ch01–ch06 受管 SQL/shell/JSON/YAML/config 中没有 PGPASSWORD、带凭据 PostgreSQL URI 或明文 password assignment;
  • DROP DATABASE/ROLE 只出现在带 token 的专用 reset.sql

quality-gate.sh static 还对 Python 做 bytecode compile,对全部 lab shell 做 bash -n。这些检查不连接 PostgreSQL,所以适合每次提交;它们能证明结构和已知危险模式,没有证明 SQL 在目标版本执行正确。

正则 secret scan 也不是 DLP。编码、模板展开、二进制或未知 secret 形式仍可能漏过;source reviewer 和 CI artifact policy 继续负责。检查器应报告自己的扫描文件数,使范围缩小时不会静默绿色。

Catalog 与正反例:验证数据库真正拒绝什么

Catalog contract 比解析 DDL 文本可靠,因为它看到服务器已经解释后的对象:

pg_class / pg_attribute / pg_constraint
pg_namespace / pg_roles / privileges
pg_proc.prosecdef / proconfig
pg_settings source / pending_restart

但 catalog 是检查时刻事实,不能自动证明迁移路径曾经安全。正向 case 证明有效输入工作;负向 case 必须断言稳定 SQLSTATE、constraint name 或自定义 error contract。不要用本地化 message 全文,也不要 EXCEPTION WHEN OTHERS THEN pass

本章 wrong-session/wrong-target fixture 的意义就在于验证 gate 本身:若 guard 被意外删除,负向 case 会“错误成功”,CI 随即失败。

Integration 与 concurrency:跨边界验证

SQL 在 psql 中通过,不代表 driver、pool 或 application transaction management 正确。Integration test 要覆盖:

  • 参数绑定与类型/OID;
  • NULL、encoding、timezone 和 decoder;
  • pool mode、connection reset 与 transaction cleanup;
  • timeout/cancel 如何映射为应用错误;
  • service failover/route 与 read-only 行为;
  • idempotency、ambiguous outcome 和 trace attribution。

并发正确性不能由单 session 单元测试推出。lost update、write skew、deadlock 和 retry 必须使用多个可识别 session、明确同步点、前后 checksum 与失败清理。第 10 章会加入这层;在那之前 SAFE-RETR-008 只能保留 review check。

计划证据验证关系,不冻结节点名

计划测试应保存:

SQL + bound parameters
schema/statistics/settings/version
row distribution / relation size
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS)
多次运行与 warm/cold 条件
写路径成本和新增索引大小

稳定断言通常是:

  • estimate/actual 误差是否越过调查阈值;
  • buffers/temp/WAL 是否超预算;
  • 参数范围内 p95/p99 是否满足 SLO;
  • 变更是否让目标 workload 改善且写入代价可接受。

不要把“必须出现 Index Scan”“总 cost 小于 1234”作为跨版本 golden。planner、统计、数据量和 cache 变化都可能选择另一条同样正确的 plan。第 7–9 章会把 PREF-PLAN-005 扩展为可操作流程。

Evidence directory 是可复核输入输出

DEFAULT-EVID-009 要求每次任务写入独立目录,至少包含:

UTC captured_at / action / service name
client + server version
source SHA-256 / config fingerprint
stdout + stderr 分离
verify-before + verify-after
machine-readable summary

证据包不能包含展开后的 secret。service name 可以保存,password、credential URI、private key 和含 token 的环境 dump 不可以。生产 evidence 还应有访问控制与保留策略;“为了审计”不是永久复制敏感数据的理由。

6.5.3 变更说明、所有者与风险等级

一份变更说明的首要作用是让另一个合格操作者可以在压力下判断:继续、停止、回退还是升级,而不是证明作者写过文档。

change-template.md 将信息分为七组:

  1. 身份:Change ID、owner、reviewer、target、窗口、application release;
  2. 目标/非目标:改变什么可观察事实,明确不解决什么;
  3. 当前事实:版本、对象大小、写入率、schema version、依赖方;
  4. 迁移设计:expand/backfill/validate/switch/contract 与重跑行为;
  5. 资源预算:lock mode、timeout、WAL、temp、lag、old snapshot;
  6. 失败恢复:哪一步可 rollback,哪一步只能 repair,outcome ambiguous 怎样确认;
  7. 验证风险:precheck、正反例、post-state、最大故障、停止条件与审批。

风险等级由影响与恢复共同决定

本书实验使用四级标签:

等级 含义 典型动作
R0·观察 只读、无主动状态改变 catalog/query/metric 采集
R1·可逆变更 范围精确,可低成本恢复 创建专属 fixture、可验证配置
R2·受控演练/破坏 会写入、持锁、取消或删除实验对象 rollback write、reset 专属 schema
R3·生产敏感 影响真实流量/数据、恢复昂贵或范围较大 failover、contract DDL、restore/cutover

风险不是由 SQL 关键字单独决定。同一个 ALTER TABLE 在空 L1 和高写入生产表上不是同一级;只读 EXPLAIN ANALYZE 也会真实执行查询,可能成为 R2/R3。评估至少考虑 blast radius、可逆性、锁/WAL/容量、持续时间、权限和环境价值。

R3 不进入自动教学 harness。它必须使用生产 runbook、实时观测、双人/组织审批和明确 incident authority;本书后续章节可以演练机制,不会因为用户会运行实验就默认获得生产处置授权。

owner 与 reviewer 责任不同

  • change owner 对设计、前提、执行证据和结果负责;
  • service owner 确认业务窗口、兼容与 SLO;
  • database/platform reviewer 复核 PostgreSQL/Pigsty 机制;
  • operator 有权在停止条件命中时终止;
  • incident commander 只在预先声明的 breakglass 条件下扩大权限。

“DBA 批准”不能替代业务 owner 对数据语义负责,“应用团队说可以”也不能替代平台对恢复与容量负责。责任要落到具名角色和时间窗,而不是群聊。

停止条件必须在开始前写

可执行停止线使用可观察量:

lock 未在 5s 内取得
replica lag 超过预算
WAL/temporary space 增长越界
oldest transaction/snapshot 超阈值
bad-row precheck 非零
application error/SLO 越界
catalog identity 或 source checksum 不一致
无法精确判断 outcome

“感觉不对就停”不能在压力下形成一致行为。停止也要对应下一步:rollback current transaction、停止新批次、切回旧 application path、进入 forward repair,还是升级 incident。

waiver 也要进入交付链

default 或 preference 被偏离时,使用 waiver-template.md 记录:

  • 哪条 Rule ID、在哪个 target/scope 偏离;
  • 为什么失败机制在此场景不同;
  • 剩余风险与补偿控制;
  • owner、reviewer、expiry;
  • 怎样验证、怎样回归默认。

Safety breakglass 不是普通 waiver。它要求更严格的身份、时限、撤销和事后复核;标记为 exception.mode=none 的规则则必须重新设计,不能靠审批覆盖。

本节最小质量门

进入下一环境前,交付包至少应能回答:

What:   改什么合同?
Where:  精确 target 和起始版本是什么?
Who:    谁负责语义、平台、执行与停止?
Why:    哪个失败机制/需求推动变更?
How:    migration、兼容、timeout 和资源预算是什么?
Fail:   哪些 outcome 可 rollback,哪些只能 repair?
Proof:  正例、反例、catalog、checksum 和运行指标是什么?
Clean:  是否需要 cleanup,范围与 token 是什么?

任何一个高风险答案缺失,都不应通过“先上线再观察”。质量门的目的不是增加仪式,而是在变更仍便宜时暴露未知。


上一节:查询与事务候选规则 · 返回本章目录 · 下一节:将规约接入统一实验环境 · 查看全书目录 · 查看索引中心

6.6 将规约接入统一实验环境

规约若只存在于 repository,就无法约束实际环境;平台若只负责“把 PostgreSQL 装起来”,又无法知道业务对象是否满足合同。Pigsty 与版本化 SQL 在这里承担不同职责:

Pigsty inventory
  ├─ cluster / instance / service / HBA / pool
  ├─ role、database、schema 的基础声明
  └─ database/role GUC 默认

Versioned SQL
  ├─ object privileges / default privileges
  ├─ table / type / constraint / view / function
  ├─ migration history / schema version
  └─ fixture / positive / negative / post-state

Runtime verification
  ├─ catalog / pg_settings / session
  ├─ service route / pool behavior
  └─ metrics / logs / evidence checksum

这三层共同组成统一实验环境。平台声明不能替代业务 migration,migration 成功也不能证明 HAProxy/PgBouncer 路由正确。

6.6.1 角色、数据库与服务声明

Pigsty 是配置驱动平台:inventory 的 global、cluster、host 层按覆盖顺序形成最终参数,再由 playbook 生成并应用 Patroni、PostgreSQL、PgBouncer、HAProxy 与相关配置。pg_userspg_databases 允许在 cluster vars 中声明业务身份和数据库。

本章提供一个不含凭据的 pigsty-declaration.example.yml。它是应合并到目标 cluster vars 的片段,不是完整 inventory:

pg_users:
  - name: pg36_owner
    login: false
    superuser: false
    createdb: false
    createrole: false
    replication: false
    bypassrls: false

  - name: pg36_app
    login: true
    superuser: false
    createdb: false
    createrole: false
    pgbouncer: true
    pool_mode: transaction

  - name: pg36_ro
    login: true
    superuser: false
    createdb: false
    createrole: false
    pgbouncer: true
    pool_mode: transaction

pg_databases:
  - name: pg36_shop
    owner: pg36_owner
    encoding: UTF8
    locale: C
    revokeconn: true
    pgbouncer: true
    pool_mode: transaction
    schemas:
      - { name: shop, owner: pg36_owner }
      - { name: shop_api, owner: pg36_owner }
      - { name: shop_private, owner: pg36_owner }

完整样例还包含连接池预算和 database-level timeout/UTC 默认。数值是教学起点,必须按真实 connection budget 与 workload 调整。

为什么先声明 role,再声明 database

PostgreSQL role 属于整个 cluster,不属于单个 database;database owner 在创建 database 时必须已经存在。Pigsty 的 pg_users 又按数组顺序创建,所以样例先创建 NOLOGIN owner,再创建 application/read-only LOGIN role,最后创建由 owner 持有的 database。

LOGIN role 的 credential 没有进入样例。实际 inventory 必须从受控 secret overlay 注入 SCRAM secret 或采用组织认证方案;不能把展开后凭据提交到本书 repository。若直接应用这份无密码片段,role 可以创建,但不能靠密码认证登录——这是有意的 fail-closed,不是可直接上线的完整安全配置。

revokeconn: true 会撤销 PUBLIC CONNECT,并保留 owner/管理/监控等受控入口。pg36_apppg36_ro 的精确 CONNECT、schema USAGE、table/sequence privilege 和 default privilege 仍由 ch01 versioned SQL 授予。这里故意不把所有业务授权改成 Pigsty 内置全局 dbrole_readwrite:本书要验证 pg36_shop 的对象级最小权限,而不是让跨库角色隐式扩大范围。

为什么不把业务 schema 塞进一次性 baseline

Pigsty pg_databases.baseline 会在 database 首次创建时执行 SQL,已有 database 会跳过;encoding、locale、template 等字段又具有创建时不可变的边界。它适合明确的一次性引导,但不能单独承担持续 schema migration。

本书让:

Pigsty: database/role/service 基础存在
SQL chain: ch01 → ch03 → ch04 → 后续版本

fresh install 与 upgrade 因此复用同一 migration authority。即使 schemas 已由 Pigsty 创建,SQL 使用 CREATE SCHEMA IF NOT EXISTS 后仍验证 owner/privilege;若同名 schema 形状或 owner 不符合合同,后验会失败,而不是因为“存在”就默认正确。

使用默认 service,而不是再造一个名字

Pigsty v4.5 每个 PostgreSQL cluster 默认提供:

Service Port 本章用途
primary 5433 production read/write,经 primary PgBouncer
replica 5434 production read-only,经 replica PgBouncer
default 5436 admin/ETL/direct primary PostgreSQL
offline 5438 OLAP/ETL/个人只读类 direct workload

pg36_app 的日常 OLTP 连接应使用 primary:5433;受审计 migration、catalog 诊断和本章 SET ROLE gate 使用 default:5436 direct path。两者都指向当前 primary,但 pool/session 语义不同。read-only role 也不能仅凭名字就发送到 replica:调用方要选择 replica service,并接受复制延迟与 read-after-write 语义。

本章无需自定义 pg_services。只有默认 selector、health check、destination 或端口不能表达 workload 时才增加 service,并同时说明 failover、fallback 与容量边界。多一个 service 名不是更安全;没有调用合同的 service 只会增加误路由。

6.6.2 初始化、验证与重置入口

统一环境需要把“基础设施声明”和“书中 SQL”排成可重复顺序。

第一次初始化

先在 Pigsty repository 中把样例片段合并到已确认的目标 cluster。不要照抄 cluster 名;先查看 inventory graph、最终 host vars 和 diff。对于已有 cluster,官方 v4.5 的精确入口是:

bin/pgsql-user <cluster> pg36_owner
bin/pgsql-user <cluster> pg36_app
bin/pgsql-user <cluster> pg36_ro
bin/pgsql-db   <cluster> pg36_shop

这些命令只是说明 apply 顺序。真正执行前必须:

  • -l/wrapper 的 cluster 参数限制到单一已确认目标;
  • 确认 secret overlay 已生效但不会打印到 evidence;
  • 确认同名 role/database 没有另一业务含义;
  • 对 immutable database 字段检查现状,不用 state: recreate 强制收敛;
  • 保存 inventory commit、resolved target 和 playbook result。

新 cluster 可以在受控 pgsql.yml -l <cluster> 初始化中创建这些对象;已有 cluster 应用专用 pgsql-user/pgsql-db,不要为了新增一个 database 重新运行无范围的全局 playbook。

然后通过 default:5436 的私有 libpq service 执行书中版本链:

ch01 setup        → role/database/schema/privilege baseline
ch03 setup + seed → logical model v0
ch04 migrate      → reliable physical model v1
ch04 verify       → catalog + data checksum
ch06 all          → session + query + baseline quality gate

实际目录中各章的 task.sh 固定 action 与 evidence。不要把这些步骤复制成一条不检查中间状态的长 shell command;每个 version boundary 成功后保存 summary,失败时停在已知状态。

每次任务只有一个 action 合同

action 名应表达风险和后置状态:

setup / migrate / seed
verify / observe / negative / review
reset(仅专属可销毁 target)

统一入口负责:

  1. 解析 action,未知值以 usage/exit 64 拒绝;
  2. 检查依赖工具与 PGSERVICEFILE
  3. 创建 mode 0700/umask 077 evidence directory;
  4. 写 source manifest;
  5. 运行 context guard 和 verify-before;
  6. 执行 action;
  7. 即使预期报错,也核对精确 exit/SQLSTATE;
  8. 写 verify-after 与 machine-readable summary;
  9. 清理本次启动的精确 worker。

脚本不应根据“这是开发机”自动猜测 database 可以删除。环境分类可以决定是否允许 R1/R2,但 destructive target 与 token 仍要精确。

reset 不属于正常升级路径

ch01/ch03/ch04 的 reset 用于放弃整个教学模型并重建,属于 R2,必须使用章节定义的双重令牌。它不能用于:

  • 清理未知生产漂移;
  • 让失败 migration 看起来重新成功;
  • 在保留价值不明时重建 database;
  • 替代 application/schema 兼容回退。

本章没有持久写入,所以不提供 reset。成功的 all 应保证 relation checksum 不变;若 checksum 漂移,正确动作是停下来调查,不是自动调用上一章 reset。

应用流量还要单独验收 pooled path

本章 quality gate 使用 pg36-admin direct service,因为它需要稳定 session、catalog visibility 与 SET ROLE pg36_owner。它没有证明 application 经 primary:5433 的行为。应用交付前还应使用 pg36_app service 测试:

frontend endpoint = primary:5433
effective identity = pg36_app
read/write privilege = exact contract
owner/DDL privilege = denied
transaction pool reuse = no leaked session state
timeout/cancel = driver contract
application_name = attributable

这层将在 ch12 的“从数据库到服务”中成为 v1.0 验收项。

6.6.3 配置事实与运行事实分开审查

一次平台变更至少有四类事实:

层次 证据 能证明什么 不能证明什么
Git/inventory reviewed YAML + commit 期望状态和变更意图 已应用到哪个 target
apply playbook target/diff/result 某次动作在某批 host 执行 所有运行事实持续正确
PostgreSQL catalog、GUC、SQLSTATE、checksum 当前数据库实际对象与语义 客户端经过哪个 frontend service
routing/observability HAProxy/PgBouncer state、连接 endpoint、dashboard/log service 路由、pool 与时间趋势 业务不变量全部正确

“配置里写了”只能回答第一行。一次严谨审查同时保留 desired、apply 和 actual。

从 YAML 回到 PostgreSQL catalog

pg_users 的后验不是搜索配置文本,而是:

SELECT
    rolname,
    rolcanlogin,
    rolsuper,
    rolcreatedb,
    rolcreaterole,
    rolreplication,
    rolbypassrls,
    rolconnlimit
FROM pg_catalog.pg_roles
WHERE rolname IN ('pg36_owner', 'pg36_app', 'pg36_ro');

database/schema 后验包括 owner、encoding、locale/collation、CONNECT、schema owner/USAGE/CREATE。role membership 在 Pigsty 中可能是 additive;从 inventory 删除一个 role name 不一定等于数据库里自动撤销已有 membership,必须用显式 absent/revoke 和 catalog 后验。

database immutable 参数若与 inventory 不同,不应自动 state: recreate。先把漂移记录为 change,评估数据保留、backup/PITR 和 application downtime,再决定迁移或接受有 expiry 的 waiver。

从参数声明回到生效值和来源

ALTER DATABASE/ROLE SET 通常只影响新 session。检查:

SELECT
    name,
    setting,
    unit,
    source,
    sourcefile,
    pending_restart
FROM pg_catalog.pg_settings
WHERE name IN (
    'statement_timeout',
    'lock_timeout',
    'idle_in_transaction_session_timeout'
);

再在目标 role/database 的新连接SHOW/current_setting()pg_settings 的当前 backend 值与 source 能解释本会话,但不能仅凭 postgresql.conf 文件推断覆盖后的结果。pending restart、reload 与新连接边界也必须区分。

从 service 名回到真实路由

连接 primary:5433 时保存:

client requested host/port/service
current_database / session_user / current_user
pg_is_in_recovery()
inet_server_addr / inet_server_port
application_name / backend_start
HAProxy/PgBouncer service state and timestamp

pg_is_in_recovery()=false 证明当前 backend 可写 primary,不证明客户端一定经过预期 HAProxy port;客户端 endpoint 证明请求入口,不证明 selector 在未来 failover 始终正确。要将两类事实与 PGSQL Service/Proxy/PgBouncer dashboard 或 HAProxy state 对齐。

连接 replica:5434 也不能只检查 default_transaction_read_only:健康 selector、实际 recovery state、replication lag 与 fallback policy共同决定读语义。对 read-after-write 敏感的请求通常应继续走 primary,或显式等待/携带一致性标记。

漂移处理不是“以谁为准”一句话

发现 inventory 与 actual 不同时,先分类:

尚未 apply
apply failed/partial
manual hotfix 未回写
运行时临时 SET/override
版本/不可变属性导致不能收敛
检查器读错 target

然后选择:

  • 重新 apply 并验证;
  • 把合法 hotfix 回写 inventory/migration;
  • 撤销未经授权的手工漂移;
  • 为不可变差异设计迁移;
  • 修正检查 target;
  • 在有 owner/expiry 的 waiver 中暂时接受。

不能机械地让自动化“配置覆盖运行”,也不能把实际状态反向复制进 Git 就算解决。权威来源取决于对象:cluster/service desired state 通常在 Pigsty inventory,业务 schema version 在 migration ledger,当前故障处置可能暂时以 incident hotfix 为准,但结束后必须回写。

本节验收

把样例接入一个已确认 L1 后,应能提供三组独立证据:

desired:
  inventory commit + resolved cluster vars(secret redacted)

applied:
  exact cluster target + pgsql-user/db playbook result

actual:
  pg_roles / pg_database / schemas / grants / GUC
  direct admin quality gate
  pooled application service probe

只有三组吻合,才能说“规约已经接入环境”。本章实验只完成 direct admin 和 PostgreSQL actual 部分;真正 Pigsty cluster 的 apply 与 pooled application probe必须在读者自己的 L1 中完成并保存 target-specific evidence。

参考资料


上一节:交付物与质量门 · 返回本章目录 · 下一节:实战:发布规约 baseline v0.1 · 查看全书目录 · 查看索引中心

6.7 实战:发布规约 baseline v0.1

本节把方法落到一个可发布对象:25 条 active rules、13 个交付资产、三类质量门、一份未来证据账本。发布的含义不是宣布“以后永不修改”,而是固定版本、范围、checksum、已知缺口和升级条件,让任何读者都能复核 v0.1 当时究竟承诺了什么。

实验风险:

  • staticR0·观察,只读 repository,不连接 PostgreSQL;
  • liveR0·观察,连接已确认 ch04-v1 L1,只读 session/catalog/data;
  • negativeR0·受控失败,只改变本 session 参数或 expected target,要求精确失败;
  • review / all:组合上述三类,不创建持久对象、不写业务数据。

即使是 R0,也必须指向已确认 target:catalog 与 query text 可能包含业务信息,过宽监控身份也可能越权。本章使用教学 L1 的 direct admin service。

6.7.1 审查 ch01–ch05 已出现的候选规则

第一轮不从空白页“想 25 条最佳实践”,而是回看前五章的可重复证据:

来源 已验证事实 收敛出的规则族
ch01 target、role/schema ownership、危险 reset CONN / ROLE / DEST
ch02 service file、session context、脚本 evidence CONN / SECR / SESS / EVID
ch03 业务不变量、关系/标识边界、fixture NAME / KEYS / FIXT
ch04 type、named constraint、migration、partition ADR CONS / MIGR / TYPE / PART
ch05 query path、failed transaction、lock/retry evidence QUER / PAGE / TXNN / RETR / PLAN

baseline-v0.1.json 中每个 evidence item 都包含:

{
  "chapter": "ch05",
  "artifact": "/labs/ch05/transaction-errors.sql",
  "observation": "首个错误后观察 25P02,并由显式恢复闭合。"
}

artifact 必须存在于当前 repository,chapter 必须属于 v0.1 source set,observation 必须说出从资产观察了什么。checker 还要求五章都至少贡献 active evidence,防止版本说明宣称覆盖 ch01–ch05,实际只引用其中两章。

从候选到 25 条 active rule

审查按四步进行:

  1. 合并同一失败机制的重复句子;
  2. 把同时包含多个独立风险的句子拆开;
  3. 评估后果,定为 safety/default/preference;
  4. 为每条规则指定最小 check 与 exception mode。

最终分组如下:

Safety(10) Defaults(10) Preferences(5)
target、secret、role、definer session、context、name、type text
constraint、migration、txn failure key、query、txn size semi-structured
retry、destructive action、pagination fixture、evidence、version partition、advanced SQL、plan

完整标题在 baseline-guide.md 中。指南只出现一次每个 Rule ID;详细 statement/rationale/evidence/exception/checks 以 JSON registry 为准。若人类指南与 registry 分叉,static gate 失败。

对等级做一次对抗性复核

每条 safety 都反问:

  • 违反是否真的可能直接导致数据错误、越权、不可恢复动作或不可归因事故;
  • 是否存在同样安全但不满足当前 statement 的合理方案;
  • nonebreakglass 是否被正确选择;
  • 当前 check 是否能看见失败机制,而不是只检查格式。

每条 default 都反问:

  • 统一默认真正减少了什么测试/维护矩阵;
  • 哪些 workload 合理偏离;
  • waiver 是否有可验证补偿和 expiry。

每条 preference 都反问:

  • 它是否只是作者品味;
  • 是否存在多个同样正确的方案;
  • review 需要什么 workload/计划/维护证据。

这一步把“稳定分页”从普通 SQL 风格提升为 safety,因为无全序会直接破坏 API 跨页正确性;同时把 text、分区与高级 SQL 保留为 preference,避免把场景判断伪装成数据库铁律。

已知缺口必须进入输出

SAFE-RETR-008 的失败后果足以成为 safety,但 ch01–ch05 尚未提供完整多会话 retry harness。它的 check 仍只有:

review:
  SQLSTATE allowlist、最大次数、deadline、idempotency key、
  ambiguous outcome 查询

因此 checker 计算“至少一个 automated/runtime check 的 safety”时输出 9,不允许作者手工写成 10。ch10 必须补入 40001/40P01 整体重试、bounded backoff 与 outcome reconciliation 的运行证据。这就是版本化 baseline 与普通文档清单的差别:未知被编码进验收,而不是藏在脚注里。

6.7.2 为 pg36_shop 建立最小质量门

先下载/进入资产目录:

cd static/labs/ch06

第一道门:static

不设置任何数据库环境变量也能运行:

export PG36_EVIDENCE_DIR="$PWD/evidence/ch06/static-$(date -u +%Y%m%dT%H%M%SZ)"
./quality-gate.sh static

baseline-check.txt 的稳定摘要应为:

status=ok
baseline_version=0.1.0
rule_count=25
safety_count=10
default_count=10
preference_count=5
safety_non_review_count=9
source_chapter_count=5
artifact_reference_count=23
delivery_artifact_count=13
scanned_source_count=45
baseline_checksum=bb1404e2b2e47624b17f3a1b0de63a5371f382b67906f1a8ba1cf08e92895a1c

baseline_checksum 是按规范化 JSON 计算的 registry 内容指纹,不等于文件原始 bytes 的 sha256sum。改变缩进不会改变 canonical checksum,改变规则、兼容范围或证据会改变。manifest.txt 另行保存 13 个文件的原始 SHA-256,以便复现本次具体输入。

数量会在新版本有意变化;v0.1 内若静默变化,必须先解释 registry/manifest diff 并更新本文验收,不能为了让 CI 绿而改 expected count。

static action 还输出:

  • shell-syntax.txt:本书受管 lab shell 的 bash -n 结果与数量;
  • baseline-check.stderr:成功时为空;
  • evidence-local Python bytecode:不污染 source tree;
  • gate-summary.txtstatic=pass,live/negative 为 skipped。

第二道门:live

先确认 ch04-v1:

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

psql -X -w "service=$PGSERVICE" \
  -c '\conninfo' \
  -c "SELECT current_database(), pg_is_in_recovery();"

service 必须指向 pg36_shop 的 direct writable primary,登录身份可受控 SET ROLE pg36_owner。然后:

export PG36_EVIDENCE_DIR="$PWD/evidence/ch06/live-$(date -u +%Y%m%dT%H%M%SZ)"
./quality-gate.sh live

执行路径:

session-profile
  → ch05 verify(复用 ch04 完整模型后验)
  → query-contract
  → server facts

session-profile.txt 应证明 UTF8、UTC、pg_catalog, shop、三类 timeout 和 application name;model-verify.txt 应保留:

status=ok
model_version=ch04-v1
lab_state=rollback-only
active_lab_workers=0
relation_checksum=f8a7bfae59c6d16cd323abecfefe1014

query-contract.txt 应证明 11 列 view shape、稳定 keyset 顺序、两页不重叠、business/idempotency key 唯一。live 全部是 read-only;如果 relation checksum 改变,说明前置状态已经漂移,不能用本章脚本修复。

第三道门:negative

在同一已确认 L1:

export PG36_EVIDENCE_DIR="$PWD/evidence/ch06/negative-$(date -u +%Y%m%dT%H%M%SZ)"
./quality-gate.sh negative

稳定摘要:

status=ok
wrong_session_exit=3
wrong_session_sqlstate=P0601
wrong_target_exit=3
wrong_target_sqlstate=P0001

这里 status=ok 表示两个错误都按预期被拒绝,不是错误 SQL 成功。stdout/stderr 分开保存,可以复核 SQLSTATE 恰好出现一次。

发布候选:review/all

reviewall 当前执行同一条完整路径;前者强调人工发布语义,后者适合作为自动任务 action:

export PG36_EVIDENCE_DIR="$PWD/evidence/ch06/review-$(date -u +%Y%m%dT%H%M%SZ)"
./quality-gate.sh review

cat "$PG36_EVIDENCE_DIR/gate-summary.txt"

预期:

status=ok
gate_version=ch06-v0.1
action=review
static=pass
live=pass
negative=pass
baseline_checksum=bb1404e2b2e47624b17f3a1b0de63a5371f382b67906f1a8ba1cf08e92895a1c
relation_checksum=f8a7bfae59c6d16cd323abecfefe1014

只有同时检查 source diff、baseline canonical checksum、model checksum、wrong-session/target 和 evidence manifest,才批准 v0.1。动态 server version、timestamp、PID 不做 golden。

Gate 的失败边界

完整 action 未设置 PGSERVICEFILE 时必须在连接前以 usage/exit 64 拒绝;未知 action 同样退出 64。缺少工具退出 69。数据库或断言失败保留非零 psql/script exit,不改写成绿色 summary。

gate 不负责:

  • 自动安装 PostgreSQL/Pigsty;
  • 自动创建/修复 ch04 模型;
  • 自动注入 credential;
  • 自动 apply Pigsty inventory;
  • 自动批准 waiver/breakglass;
  • 自动对生产执行 migration/reset。

这些边界让错误前置条件尽早暴露,也防止“质量脚本”获得超出检查所需的修改权限。

6.7.3 预留 ch07–ch11 的证据追加区

evidence-ledger.md 已固定下一阶段的证据路线:

版本候选 章节 必须新增的运行证据 主要影响
v0.2 ch07 estimate/actual、statistics、plan settings PREF-PLAN-005
v0.3 ch08 workload attribution、wait taxonomy、慢查询闭环 DEFAULT-EVID-009
v0.4 ch09 index benefit/cost、write amplification、concurrent build PREF-PLAN-005
v0.5 ch10 lost update、write skew、deadlock、40001 retry SAFE-RETR-008
v0.6 ch11 expand/contract、lock budget、application compatibility SAFE-MIGR-006 / DEFAULT-VERS-010

版本号是候选节奏,不要求每章机械升级。若新证据只重复原结论,可以追加 ledger 而不改 statement;若发现 scope、level、exception 或 check 需要变化,则发布新 minor version,并说明:

added / changed / deprecated Rule ID
old → new semantics
compatibility impact
waiver migration
gate/evidence changes

不要改写 v0.1 文件后仍称 v0.1。最简单的历史保护是保留不可变 release artifact/tag 与 checksum;主干上的“current”可以指向最新版本。

新证据既可能收紧,也可能撤销规则

例如 ch07 可能证明某种统计问题才是估算失真的根因,因而 PREF-PLAN-005 应增加 statistics check,而不是升级成“禁止 Seq Scan”。ch10 可能发现某类 transaction 因外部副作用无法自动 retry,于是 safety statement 需要收紧 idempotency/reconciliation,而不是只加重试次数。

规则体系的价值不在于永远维护最初判断,而在于让反例可以有秩序地改变判断。

每章回写的最小格式

追加证据至少记录:

chapter + artifact + exact observation
PostgreSQL/Pigsty/OS compatibility
positive + negative result
rule impact(confirm / narrow / expand / deprecate)
check automation change
new exception/waiver impact

生产 incident 可以成为证据,但必须去除敏感数据并保留足够机制信息;不能只写 incident ticket URL,让离线读者无法理解结论。

6.7.4 在 ch12 汇总为 v1.0 的验收条件

ch12 不是把 v0.6 改名为 v1.0。它要在一个真实后端服务交付中贯通:

flowchart LR
  A["Pigsty desired state"] --> B["角色 / DB / services"]
  B --> C["versioned schema"]
  C --> D["application query + transaction"]
  D --> E["pool / routing / observability"]
  E --> F["failure + retry + release"]
  F --> G["v1.0 evidence bundle"]

v1.0 必须同时满足:

  1. 每条 active rule 至少有一个可重复实验或已脱敏生产事件证据;
  2. 所有 safety 都有 automated 或 runtime gate,不只依赖文字 review;
  3. 每个 active exception 有 owner、expiry、补偿控制与复核结果;
  4. query/transaction/DDL 规则在同一个后端服务交付中实际走完;
  5. static、live、negative 可在统一 L1 重跑;
  6. v0.x 的 false positive、false negative、waiver 与 incident 已回写 rationale;
  7. compatibility matrix 对 PostgreSQL 14–18 与当前 Pigsty baseline 有明确结果或限制;
  8. pooled application path、direct migration path 与 failover route 均有证据;
  9. v1.0 有从 v0.x 迁移说明,不静默改变既有 Rule ID 语义;
  10. release artifact、source manifest、canonical checksum 与签署 owner 完整。

若 ch10 未把 safety 覆盖从 9/10 提升到 10/10,或者 ch12 只在 direct admin session 验证而没有 application/pool path,v1.0 必须推迟。deadline 不能改变验收事实。

v0.1 发布记录

当前发布候选:

baseline_version=0.1.0
published_on=2026-07-29
postgresql=14-18
validated_postgresql=18.6
pigsty=4.5.0
target_os=Ubuntu 24.04 L1
local_validation=PostgreSQL 18.6/Homebrew on macOS
rules=25
safety_enforced_by_auto_or_runtime=9/10
canonical_checksum=bb1404e2b2e47624b17f3a1b0de63a5371f382b67906f1a8ba1cf08e92895a1c

这个记录只在完整 review gate 与全书 structure/link/build 检查通过后成立。任何 registry 语义变更都应产生新 checksum 和新版本;任何运行环境变化都应产生新的 evidence bundle,而不是覆盖旧证据。

到这里,我们没有得到一本万能的 PostgreSQL 风格指南,而是得到了一套可以被验证、质疑、例外、升级和审计的规则系统。下一章开始,性能与并发专题会不断用新证据挑战它。


上一节:将规约接入统一实验环境 · 返回本章目录 · 下一章:追本溯源:执行计划与统计信息 · 查看全书目录 · 查看索引中心