跳转到主要内容

PG 三十六计

从 SQL 到生产:PostgreSQL 与 Pigsty 实战

PG 三十六计

从 SQL 到生产:PostgreSQL 与 Pigsty 实战

《PG 三十六计》面向已经掌握 Linux 与通用 SQL、希望系统完成 PostgreSQL 应用开发和生产落地的读者。PostgreSQL 是核心知识对象;Pigsty 是统一实验载体、 观察窗口与生产参考实现。

全书分为应用开发与运维管理两卷,共六篇三十六章。正文从对象、模型、查询与并发 出发,经过发布、扩展、部署、HA、备份、可观测、容量与维护,最终进入事故响应、 恢复、取证和复盘改进。

阅读与查找

  • 全书导读说明读者假设、安全公约、版本边界与实验合同。
  • 完整编号目录定位全部章节、独立节页和稳定三级锚点。
  • 索引中心按角色、任务、技术边界、事故症状与分区触点查找内容。
  • 第 0 章准备可销毁的 Pigsty 实验环境;已有合适环境时可以跳过。

当前复现基线

正文与实验以 PostgreSQL 18.6、Pigsty v4.5.0 为当前基线。涉及其他受支持版本的 结论会就地说明适用范围。示例命令、示例输出和单次跑分都不能脱离目标、前提、 风险、证据、验收与复位链条单独使用。

全书导读

D.1 本书解决什么问题

  • 从“会写 SQL”走到“能交付 PostgreSQL 应用”;
  • 从“装好了数据库”走到“能运营 PostgreSQL 服务”;
  • 从“看见告警”走到“能安全恢复并防止复发”;
  • 用 Pigsty 把分散的 PostgreSQL 能力组合成可重复、可观察的生产实践。

D.2 读者假设与知识边界

正文默认读者已经掌握:

  • Linux、Shell、SSH、文件与进程的基本操作;
  • DDL、CRUD、连接、聚合、子查询、CTE 与基础事务;
  • 至少一种后端编程语言;
  • 基本的软件工程、版本控制与测试概念。

本书会解释 PostgreSQL 特有的语义与工程方法,但不系统补授 Linux、通用 SQL 或编程基础。第 0 章只解决实验环境,不承担基础课职能。

D.3 PostgreSQL 与 Pigsty 的关系

  • PostgreSQL 是全书的核心知识对象;
  • Pigsty 是全书统一的实验载体、观察窗口与生产参考实现;
  • PostgreSQL 原生层解释数据库自身提供的机制与证据;
  • 平台层解释任何生产数据库平台都必须承担的职责;
  • Pigsty 层展示这些职责的一种具体组合与实现;
  • Pigsty 特有操作若容易被误解为 PostgreSQL 通用行为,会附一行跨平台职责映射,但不扩写成其他平台教程。

作者在序言中披露与 Pigsty 的关系。“36 计”只代表 36 个递进的实战单元,不把每章强行附会为古代计策。

D.4 安全公约与风险标记

  • R0·观察:只读查询、状态采样、计划分析,可在明确授权的环境中执行;
  • R1·可逆变更:会改变对象、配置或流量,但有经过验证的回退路径;
  • R2·破坏性演练:故障注入、切换、恢复、数据损坏,只能在可销毁的隔离环境执行;
  • 全书示例使用专用实验凭据,不公开真实密码,不把数据库或管理入口裸露到公网;
  • AI 辅助操作遵循最小权限、先预览、再验证原则,不允许“自动批准一切”;
  • 任何生产命令都必须结合版本、拓扑、数据量和组织授权重新评估。

D.5 版本契约与勘误机制

当前版本冻结以下复现基线:

项目 复现基线 兼容与取证规则
PostgreSQL 18.6 示例在 18.6 验证;核心概念面向当前受支持的 14–18,版本差异就地说明
Pigsty v4.5.0 正式版 不以 main 开发分支代替发布版;配置、端口和 CLI 行为绑定 v4.5
L1 操作系统 Ubuntu 24.04.4 LTS 参考环境 同时允许 Pigsty v4.5 支持的发行版与架构;证据必须记录实际版本、内核和架构
Patroni、PgBouncer、HAProxy、pgBackRest 由 Pigsty v4.5 对应发行版仓库提供 不假设不同发行版安装出完全相同的小版本;每次实验从实际节点采样
实验拓扑 L1 单节点;L2 一台控制 VM + 三成员数据库;L3 隔离事故克隆 正式 sandbox 规格、例外与限制见附录 E

PostgreSQL 19 beta 只可用于兼容性观察,不作为生产结论基线。所有容易随版本变化的结论必须标注适用范围。面板讲指标语义,不依赖易漂移的点击路径;Pigsty 结论尽量回到 SQL、配置或原生组件验证。

每章的证据包记录客户端与服务端 PostgreSQL 版本、Pigsty 发布标识、操作系统与关键组件版本。定稿前执行一次“版本增量通过”,出版后通过附录 A:版本矩阵与差异注记维护勘误,而不是悄悄改写旧结论。

D.6 实验契约

每章按需提供以下产物,而不是机械凑齐固定数量:

  • setup:建立确定的实验起点;
  • exercise:主实验或故障注入;
  • verify:state:机器验证系统是否达到预期状态;
  • checklist:evidence:验证是否收集了足够决策证据;
  • expected:关键输出及解释;
  • reset:sql:重置数据库对象与数据;
  • reset:cluster:恢复集群、服务与配置;
  • reset:host:重建或回滚主机级环境。

实验数据生成器固定随机种子、规模档位与校验和。小规格虚拟机用于证明方法,不用于宣称绝对性能数字。

D.7 贯穿案例与五个阶段

主线案例统一为 pg36_shop

  1. ch01–ch12:持续演进、可运行的电商与支付应用;
  2. ch13–ch18:复用核心模式的独立能力场景,不强迫一个后端服务承载所有扩展;
  3. ch19–ch30:复用确定性数据与工作负载,把应用建设成生产服务;
  4. ch31–ch35:从快照克隆独立事故现场,避免演练相互污染;
  5. ch36:将事故证据重新汇总到治理与平台演进。

D.8 三层实验拓扑

  • L1:单节点 Pigsty 开发沙箱,用于 ch01–ch18;
  • L2:一台控制/客户端 VM 加三节点数据库、可销毁的生产仿真环境,用于 ch19–ch30;
  • L3:由已知快照克隆的故障演练环境,用于 ch31–ch35。

随书实验已经冻结 L1/L2/L3 的资源档位、网络、例外与复位边界,见 附录 E。性能章节仍逐次记录硬件、数据量、并发、缓存状态与噪声, 避免把玩具环境结果外推到生产。

D.9 推荐阅读路线

  • 完整路线:ch01 → ch36;
  • 应用开发:ch01–ch18,再读 ch22、ch23、ch25;
  • DBA/SRE:ch01、ch02、ch05–ch10,再读 ch19–ch36;
  • 架构与平台:ch01、ch06、ch12、ch14、ch17–ch24、ch36;
  • 故障处置:先读 ch31,再按症状索引进入 ch32–ch35,不建议脱离前置知识直接照抄命令。

核心依赖关系:

flowchart LR
  A["ch01–ch06<br/>对象、模型、事务与规约"] --> B["ch07–ch12<br/>诊断、并发、发布与服务"]
  B --> C["ch13–ch18<br/>扩展能力与平台边界"]
  B --> D["ch19–ch24<br/>生产服务规划"]
  D --> E["ch25–ch30<br/>运营与演进"]
  E --> F["ch31–ch36<br/>事故恢复与改进"]
  C --> D

D.10 按任务查找

任务 首选章节 必要前置
设计可靠模式 ch03–ch04 ch01–ch02
查慢 SQL / 设计索引 ch07–ch09 ch05
处理并发错误 ch10 ch05
安全改表与发布 ch11–ch12 ch06–ch10
选择扩展 ch14–ch18 ch07–ch12
建设高可用与备份 ch19–ch22 ch01、ch05
建立安全与治理 ch23–ch25 ch19–ch22
压测、调优与维护 ch26–ch30 ch07–ch11、ch25
误操作恢复 ch31–ch32 ch21
主库或 DCS 故障 ch31、ch33 ch20
连接风暴与资源耗尽 ch31、ch34 ch22、ch25–ch28
数据损坏与抢救 ch31、ch35 ch21、ch28、ch30

序言

从 SQL 到生产:PostgreSQL 与 Pigsty 实战

为什么写这本书

PostgreSQL 并不缺功能说明,也不缺零散教程。真正稀缺的是一条完整的工程链:业务规则 怎样落成可靠的数据模型,SQL 为什么在并发下仍然正确,数据库怎样从一个进程变成可交付 的服务,出现故障时又怎样在保住数据和证据的前提下恢复。

很多问题正是断在这些接缝上:会写 SQL,却说不清连接落到了哪个实例;会建索引,却没有 证明慢时间花在执行而非等待;有高可用,却没有定义数据丢失边界;有备份,却从未在隔离 环境恢复;事故结束后写了复盘,却没有把教训固化成可验证控制。

这本书要补的是这些接缝。它面向已经掌握 Linux 与通用 SQL、希望系统完成 PostgreSQL 应用开发和生产落地的读者,从对象与模型开始,经过查询、并发、扩展、部署、运营和恢复, 最终形成三种能力:

  1. 解释:能用 PostgreSQL 原理说明现象为什么发生;
  2. 行动:能把目标拆成有前提、风险、步骤和停止线的操作;
  3. 证明:能用 SQL、目录、日志、指标和实验后验说明结果确实成立。

这本书怎样展开

全书不是命令大全,也不按功能菜单平铺。每章围绕一个工程问题,依次回答:

为什么值得解决
  → 需要建立什么心智模型
  → PostgreSQL 提供了什么机制
  → 应当怎样操作
  → 用什么证据验收
  → 结论在哪些边界外不再成立

贯穿案例 pg36_shop 会持续演进。前几章建立对象、角色和业务模型;中段加入查询、 并发、发布与扩展;下卷再把同一套数据和工作负载放进高可用、备份、监控、容量、升级 与事故恢复中。这样做的价值不是让一个玩具应用假装覆盖所有生产场景,而是让前一章的 决定成为后一章可检查的输入。

实验也不是正文的装饰。凡是可以验证的结论,尽量给出正例、反例、运行证据和复位边界。 性能结果只在记录的硬件、数据、并发和时间窗内成立;破坏性动作只在明确可销毁的隔离 环境执行。读者最终应学会迁移方法,而不是背下某次示例输出。

PostgreSQL 与 Pigsty

PostgreSQL 是全书的核心知识对象;Pigsty 是统一实验载体、观察窗口,也是把 PostgreSQL、 高可用、备份、接入与监控组合成生产服务的一种参考实现。

正文始终区分三层:PostgreSQL 原生机制、任何生产数据库平台都要承担的职责、Pigsty 对这些职责的具体实现。理解原生机制,才能判断平台自动化做对了什么;理解平台职责, 才能把书中的方法迁移到托管数据库、Operator 或其他自建方案。

作者关系与利益披露

作者是 Pigsty 的作者与维护者,因此对其设计、能力与使用方式拥有直接经验,也天然存在偏好。全书要求 Pigsty 结论尽量回到 PostgreSQL 原生 SQL、配置或组件证据验证,并在可能造成迁移误解的位置说明跨平台职责映射。

关于“36 计”

“36 计”表示 36 个递进的实战单元,是目录与教学节奏的品牌表达,不把章节强行附会为古代计策。事故篇优先使用功能标题检索,成语仅作为副标题。

怎样阅读

第一次系统学习,按 ch01 → ch36 顺序阅读;应用开发者可先完成上卷,再补 ch22、ch23 和 ch25;DBA、SRE 与平台工程师应先建立 ch01、ch02、ch05 的共同语言,再进入下卷。 事故现场不要从搜索结果中直接复制恢复命令:先读 ch31 建立分级、现场保护和决策边界, 再进入 ch32–ch35 对应分支。

每章开头给出目标、前置与版本边界,末尾给出验收。最有效的读法是先写下自己的判断, 再运行实验,最后用证据修正判断。能复述术语不等于掌握;能在陌生环境里重新确认前提、 执行步骤并解释结果,才是本书所说的工程能力。

第 0 章(可跳过)扬帆起航——准备实验环境

可跳过说明:本章只为尚未拥有实验环境的读者准备 Pigsty L1 沙箱,并完成首次 PostgreSQL 连通。已有符合版本契约、且确认没有生产数据和流量的 独立环境,可以直接进入 第 1 章

正文默认读者已经会 Linux、SSH 与 SQL;这里不补授操作系统、网络或 SQL 基础。本章 只建立后续实验共同需要的六项事实:

exact Pigsty/PostgreSQL/OS version
non-production environment authority
node and service identity
safe network boundary
working PostgreSQL connection
redacted baseline evidence

本书采用的复现基线是 Pigsty v4.5.0、PostgreSQL 18.6 与 Ubuntu 24.04 LTS reference environment。安装时固定 Pigsty release 与 PostgreSQL major,随后记录仓库 实际提供的 minor/build;不要为了伪造一致性降级已经修复安全问题的 minor release。

完成本章的标准

进入 ch01 前,读者应能:

  1. 明确目标是可销毁、无生产数据与流量的 L1;
  2. 记录 CPU、内存、磁盘、OS、架构、网络和成本边界;
  3. 从官方来源获取并固定 Pigsty v4.5.0
  4. 在执行前审查生成的 pigsty.yml,保护其中的凭据与 CA key;
  5. 完成单节点部署或验证一个等价的已有环境;
  6. 分开 PostgreSQL、PgBouncer、HAProxy service 与 Web UI 入口;
  7. 从 SQL 内部确认版本、数据库、角色、地址、端口与 recovery state;
  8. 保存不含密码、token、private key 的环境摘要;
  9. 知道安装失败时应保留日志、修复前置条件或重建 L1,而不是删除 PGDATA 猜测恢复。

本章目录

0.1 选择实验环境

0.2 安装单节点 Pigsty 沙箱

0.3 完成首次连通

安全边界

  • 单节点 L1 不提供生产高可用证明;
  • 云端不把 PostgreSQL、PgBouncer、Patroni、DCS、监控或管理入口裸露到公网;
  • pigsty.ymlfiles/pki/ca/ca.key 视为敏感材料;
  • 本章不要求关闭 firewall/SELinux、使用默认密码或以 root 长期运行;
  • 重装、清空数据和删除 cluster 都不是普通排错动作。

当前官方参考


返回全书导读 · 下一章:PostgreSQL 与 Pigsty 全局地图 · 查看全书目录 · 查看索引中心

0.1 选择实验环境

L1 的目标不是模拟生产,而是给 ch01~ch18 一个身份稳定、可以反复创建对象、能够 观察 PostgreSQL 与 Pigsty 的实验落点。选型优先保证隔离与可恢复,再追求便利。

0.1.1 本地虚拟机、开发服务器与已有 Pigsty 环境

三种可接受入口

入口 适合 优点 必须处理
本地 Linux VM 个人学习、可反复重建 隔离清楚、snapshot 方便 内存/磁盘、宿主休眠、端口转发
独立开发服务器 团队共享、长时间运行 资源稳定、远程可达 owner、并发实验、配额、清理
已有 Pigsty dev/test 已有标准环境 快速进入正文 版本、权限、数据/流量、作用域

macOS/Windows 宿主推荐在 Linux VM 中部署。Pigsty 管理 Linux 服务、package、filesystem 和 network;把它直接改写成容器教程会改变本书实验边界。WSL 可用于体验,但涉及 systemd、网络、存储与多节点演练时要重新验证。

环境接受表

environment_id: pg36-l1-...
owner: ...
purpose: book-labs
production_data: false
production_traffic: false
shared_users: [...]
rebuild_source: vm-image-or-inventory
snapshot_or_backup: ...
expires_at: ...
network_boundary: ...

已有 Pigsty 环境只有在以下问题都得到肯定回答后才可复用:

我能指认 exact node/cluster/database/role 吗?
它没有生产数据、生产流量和真实 secret 吗?
我有权创建和清理本书对象吗?
实验负载不会伤害其他用户吗?
失败后能恢复或重建吗?
版本差异已记录吗?

任一答案不明确,就新建 L1。能登录一台机器不等于获得数据库故障注入、服务重启或 数据清理授权。

命名与 snapshot

为 VM、hostname、SSH alias 和 evidence 使用 pg36-l1 前缀,避免与生产式名字混淆。 在全新 OS、Pigsty 安装完成、重要 extension 安装后分别保留 snapshot;记录 snapshot identity 和创建时间。snapshot 不是 backup 的替代,但适合恢复教学环境。

0.1.2 L1 沙箱的最低资源、网络与磁盘要求

Pigsty 官方单节点 quick start 可以从 1 vCPU / 2 GiB 起步;这只表示能完成基础部署。 本书 L1 同时运行 PostgreSQL、监控栈并执行查询/扩展实验,采用更保守档位:

档位 CPU 内存 可用磁盘 用途
安装下限 1 vCPU 2 GiB 20 GiB 只验证 quick start,可能发生内存/等待压力
本书最低 2 vCPU 4 GiB 40 GiB ch01~ch14 的小规模 fixture
推荐 4 vCPU 8 GiB 80 GiB 搜索、时空、分析 PoC 与舒适监控

这些是教学资源档,不是生产 sizing。不同架构、磁盘、extension 和宿主 overcommit 会 改变表现;第 26 章 才讨论有证据的容量结论。

安装前采样

uname -a
cat /etc/os-release
getconf _NPROCESSORS_ONLN
awk '/MemTotal/ {print}' /proc/meminfo
df -hT /
ip -brief address
ip route
timedatectl status

不要只看虚拟磁盘标称容量;确认 filesystem 可用空间和 inode。扩展 package、监控 retention、WAL、backup、fixture 与 evidence 都会增长。L1 的磁盘满也可能让 PostgreSQL 无法安全写入。

网络合同

stable hostname and address inside the lab
SSH from the operator workstation
package/repository reachability or exact offline package
working DNS and time synchronization
no overlap with production/private routes
no public database exposure

NAT 模式通常足够;只有需要从宿主访问 Web UI/数据库时才配置明确端口转发或 host-only 网络。记录从哪个 source CIDR 允许访问,避免使用 0.0.0.0/0

0.1.3 云主机的防火墙、入口与费用边界

最小暴露

云 security group 默认 deny inbound,只从操作者固定 IP、VPN 或 bastion 允许 SSH。 Web UI 优先通过 VPN/SSH tunnel;确需 443 时限制来源并配置 TLS。PostgreSQL/服务端口 只对明确应用网络开放。

不要向公网开放:

5432 / 6432 / 5433 / 5434 / 5436 / 5438
Patroni API and DCS
Grafana/Victoria/Alertmanager/admin endpoints
node/exporter/backup metrics

端口表随配置变化,以本地 inventory/rendered config 为准。只关闭 OS firewall 而依赖 云 security group,或反过来,都会制造单层防护。

凭据与主机

  • 使用普通管理用户 + sudo,不以 root/postgres 作为日常 SSH 用户;
  • SSH 使用 key,限制 source,关闭不需要的密码登录;
  • ./configure -g 生成随机密码,保护 pigsty.yml
  • 保护 Pigsty CA private key,不复制进聊天、工单或 Git;
  • 教学数据保持合成,不上传客户 dump;
  • 设置资源 tag、owner、expiration 和预算告警。

费用清单

compute uptime
system/data/backup disk and snapshots
public IPv4
egress and cross-zone traffic
object storage/API requests
retained image and unattached volume

停止 VM 可能仍收磁盘、snapshot 与 IP 费用;删除 VM 可能同时删除唯一 evidence。实验 结束先导出所需的去敏小型 evidence,再按云平台的 exact resource ID 清点。不要在 本书中提供一条通用递归删除命令。

进入安装前的 stop line

以下情况先解决,不继续:

目标可能承载生产数据/流量
OS/architecture 不在当前支持矩阵
磁盘或内存低于接受档
时间/DNS/package source 异常
公网入口无法限制
没有恢复或重建路径
无法保护 inventory 与 CA key

返回本章目录 · 下一节:安装单节点 Pigsty 沙箱 · 查看全书目录 · 查看索引中心

0.2 安装单节点 Pigsty 沙箱

本节以全新、可销毁 Linux 节点为前提,冻结 Pigsty v4.5.0,使用默认单节点 meta 模板和 PostgreSQL 18。官方命令与模板会继续演进;复现本书时固定 release,不追随 main 或“latest”漂移。

0.2.1 获取与核对版本

前置身份

id
hostnamectl
cat /etc/os-release
uname -m
sudo -n true
ssh localhost sudo -n true

Pigsty 需要 Linux、SSH 和 sudo。sudo -n/localhost SSH 失败时先修管理前置,不要把 脚本改成长期 root 运行。对照当前支持矩阵确认 发行版 minor 与架构。

固定 release

官方 bootstrap 方式:

curl -fsSL https://repo.pigsty.io/get | bash -s v4.5.0
cd ~/pigsty

若组织禁止 pipe-to-shell,先下载、记录 SHA-256、人工/安全工具审查,再执行;或:

git clone https://github.com/pgsty/pigsty.git
cd pigsty
git checkout v4.5.0
git status --short
git rev-parse HEAD
./bootstrap

tag 提供版本选择,不自动证明供应链可信。高要求环境应按组织规则验证 release artifact、来源、签名/摘要、package repository 与代理。offline package 必须匹配 OS minor/architecture,并核对对应 release 页面摘要。

保存不含 secret 的 acquisition manifest

{
  date -Is
  git describe --tags --always --dirty 2>/dev/null || true
  git rev-parse HEAD 2>/dev/null || true
  uname -a
  cat /etc/os-release
} > pg36-acquisition.txt

不要把 shell environment、完整 inventory 或 credential 写入该文件。

0.2.2 配置、部署与幂等重跑

生成,再评审

在 Pigsty source directory:

./configure -g -v 18
chmod 0600 pigsty.yml

-g 生成随机密码;默认 meta 是单节点模板。执行前评审:

inventory host/IP is this L1
pg_cluster and instance identity are expected
PostgreSQL major is 18
package/repository source is accepted
data/config/log/backup paths fit the disk
listen/access rules do not expose public networks
generated passwords are not defaults
modules and apps are actually needed

pigsty.yml 含访问和密码信息,不提交本书仓库、不粘贴到公开 issue。files/pki/ca/ca.key 生成后同样限制权限并备份到受控位置。

执行部署

先确认 target:

./install.yml --list-hosts
./install.yml --list-tags

再在 L1 执行:

./install.yml

保留:

start/end time
Pigsty revision
redacted inventory digest
Ansible exit code
failed task and host
package versions

不要把“play recap 全 green”当数据库验收;下一目从 service、component 和 SQL 三侧 验证。

幂等的正确含义

修复临时网络/package 前置后,可以对同一 desired state 重跑部署;幂等意味着系统应 收敛到声明状态,不意味着:

每次没有任何 changed
任何删除 playbook 都安全
运行中手改不会被覆盖
失败后可以删除 PGDATA 再试

若重跑持续出现 unexpected changes,比较 inventory、rendered file 与 runtime, 定位 non-idempotent task 或 drift。不要为“全绿”关闭安全控制。

失败与复位

package/download failed -> 修 repo/DNS/TLS,保留失败输出后重跑
inventory/host wrong    -> 停止;不要在错误目标继续
disk/memory exhausted   -> 扩容或重建 L1,不清理未知 PG/WAL 文件
partial database state  -> 先取证和确认是否已有数据,再决定恢复/重建

对全新、确认无数据的 disposable VM,回到安装前 snapshot 往往比手工拆半套服务可靠。 已有数据的机器不属于本节重建授权。

0.2.3 检查 PostgreSQL、连接池与观察组件状态

四层健康

process
  service active / no crash loop

endpoint
  port accepts and route selects expected backend

protocol
  PostgreSQL authentication and SQL work

semantic
  exact cluster/database/role/version and expected read-write state

检查环境/package:

pig status
postgres --version
psql --version

检查本机 unit(模板禁用某组件时,inactive 不自动等于失败):

systemctl is-active patroni
systemctl is-active pgbouncer
systemctl is-active haproxy
systemctl --failed

检查 PostgreSQL readiness:

pg_isready -h 127.0.0.1 -p 5432

pg_isready 不验证业务角色、数据库、查询或正确 backend,只是 protocol-level availability。

分清端口

默认 Pigsty service 通常包括:

端口 入口 教学语义
5432 PostgreSQL instance 本实例直连
6432 PgBouncer 本实例 pool
5433 HAProxy primary service 读写,经 pool
5434 HAProxy replica service 只读目标,经 pool
5436 HAProxy default service primary 直连
5438 HAProxy offline service offline/分析直连

以本地 pigsty.yml 与 rendered HAProxy config 为准。单节点仍可暴露 replica/offline service 名称,但没有第二故障域,不应宣称 HA。

观察系统

从受限网络访问 Web UI,确认:

PostgreSQL target discovered
host target discovered
Patroni/etcd/PgBouncer/HAProxy as configured
metrics have recent timestamps
logs and alert evaluation available

dashboard 可见不证明采集语义正确;记录 target identity 和 timestamp。不要公开默认 UI 密码或将管理 UI 暴露到公网。


上一节:选择实验环境 · 返回本章目录 · 下一节:完成首次连通 · 查看全书目录 · 查看索引中心

0.3 完成首次连通

最后一步不是“能打开 psql”,而是从连接内部确认操作落点,并保存一份不泄露凭据的 baseline。第 1 章会在此基础上创建本书专用数据库、模式和角色。

0.3.1 找到服务端点、数据库与实验凭据

五元连接身份

host/service
port/route semantics
database
role
TLS/session parameters

默认单节点通常提供 pg-meta cluster、meta database 与管理/业务/只读角色;实际值 从你的 pigsty.yml 读取。该文件含 secret,只在受控终端查看,不把整行 URI 或密码 复制进 shell history、截图和文档。

首次连接可让 psql 单独提示密码:

psql -h 127.0.0.1 -p 5432 -U dbuser_dba -d meta -W

不要把密码写在命令行 URI。需要非交互任务时使用 mode 0600.pgpass、service file 或组织 secret injection,并确保日志不打印环境。

先直连,后比较 service

先用 5432 直连确认 PostgreSQL,再按本地配置比较 6432/5433/5436。对每个 endpoint 执行同一 identity query,记录它是否经过 pool、route 到哪个 backend、支持什么会话 状态。不要把“端口可连”推断成“读写语义正确”。

常见失败

症状 首查 不要先做
connection refused host/port/listener/unit 放开全部 firewall
timeout route/security group/监听地址 重启所有组件
password failed exact role、HBA、secret source 把密码打印到日志
database missing database identity/inventory 连默认库后误以为成功
read-only endpoint 与 pg_is_in_recovery() 强行改 transaction_read_only

0.3.2 执行 SELECT version() 与只读状态查询

psql

\set ON_ERROR_STOP on
\pset pager off

SELECT
    current_setting('server_version') AS server_version,
    current_setting('server_version_num') AS server_version_num,
    current_database() AS database,
    current_user AS role,
    session_user,
    inet_server_addr() AS server_addr,
    inet_server_port() AS server_port,
    pg_is_in_recovery() AS in_recovery;

SELECT
    current_setting('transaction_read_only') AS transaction_read_only,
    current_setting('default_transaction_read_only')
        AS default_transaction_read_only;

Unix socket 连接时 inet_server_addr()/inet_server_port() 可以是 NULL,这是 transport 语义,不是 PostgreSQL 缺地址。service endpoint 可能返回 backend instance 地址, 不等于客户端连接的 HAProxy 地址。

再检查 search path 与身份:

SHOW search_path;
SELECT current_schemas(true);
SELECT
    pg_catalog.pg_has_role(current_user, 'USAGE') AS current_role_usable;

version() 包含 build 信息,适合证据;程序判断用 server_version_num,不要解析展示 字符串。

保存去敏基线

psql -X -v ON_ERROR_STOP=1 \
  -h 127.0.0.1 -p 5432 -U dbuser_dba -d meta \
  -A -F $'\\t' -t \
  -c \"SELECT current_setting('server_version_num'),
             current_database(), current_user,
             coalesce(inet_server_addr()::text, 'unix-socket'),
             coalesce(inet_server_port()::text, 'unix-socket'),
             pg_is_in_recovery();\" \
  > pg36-l1-identity.tsv

该输出不含 password;仍按内部环境资料保护 host/role。检查退出码与文件非空,不要因 redirect 创建空文件就认为成功。

0.3.3 确认编码、时区和扩展清单后进入 ch01

locale/time/encoding

SELECT
    datname,
    pg_encoding_to_char(encoding) AS encoding,
    datlocprovider,
    datcollate,
    datctype
FROM pg_catalog.pg_database
WHERE datname = current_database();

SHOW TimeZone;
SHOW DateStyle;

SELECT
    now() AS transaction_time,
    statement_timestamp() AS statement_time,
    clock_timestamp() AS wall_time;

后续时间、collation、索引和升级实验依赖这些事实。不要为了“统一”直接修改 cluster; 先记录差异,在对应章节决定是否需要重建或迁移。

installed 与 available extension 分开

当前数据库已安装:

SELECT
    extname,
    extversion,
    extnamespace::pg_catalog.regnamespace AS schema
FROM pg_catalog.pg_extension
ORDER BY extname;

操作系统/仓库可提供但未安装的扩展是另一集合,不能用 pg_available_extensions 冒充 installed。扩展是 database-local object;同一 instance 的另一 database 不会自动继承。

最终 baseline

book_baseline:
  environment: pg36-l1
  production_data: false
  production_traffic: false
  pigsty_release: v4.5.0
  pig_cli: ...
  postgresql_server: ...
  os_kernel_arch: ...
  cluster_database_role: ...
  connection_endpoint: ...
  encoding_locale_timezone: ...
  installed_extensions: [...]
  monitoring_observed_at: ...
  secret_values_recorded: false

进入 ch01 的 gate:

identity query succeeds
server major = 18, exact minor recorded
database and role are expected
L1 is writable and not in recovery
encoding/timezone/collation recorded
installed extension list recorded
monitoring has current target data
no production data/traffic or exposed management endpoint

不满足时留在第 0 章修环境。满足后进入 第 1 章:PostgreSQL 与 Pigsty 全局地图,在那里创建 pg36_shop,不要把默认 meta database 当作全书业务模型。


上一节:安装单节点 Pigsty 沙箱 · 返回本章目录 · 进入第 1 章 · 查看全书目录 · 查看索引中心

完整目录

本页列出全书的章、节、目。36 个正文章直接位于顶层;节标题链接到独立页面,目标题链接到页面内的稳定锚点。

前置内容

36 章正文

上卷:应用开发

从 PostgreSQL 工程认知到应用交付与能力扩展

第一篇:筑基——建立 PostgreSQL 工程认知

ch01 盲人摸象:PostgreSQL 与 Pigsty 全局地图
ch02 手到擒来:psql 与可复现工作流
ch03 正本清源:从业务规则到关系模型
ch04 量体裁衣:数据类型、约束与可靠数据表达
ch05 运筹帷幄:查询、事务与锁的核心心智模型
ch06 立木取信:开发规约与交付基线

第二篇:应用——从 SQL 正确走向稳定交付

ch07 追本溯源:执行计划与统计信息
ch08 抽丝剥茧:慢 SQL 诊断方法论
ch09 巧夺天工:索引设计与效果验证
ch10 顾此失彼:并发控制与隔离异常
ch11 守正出奇:模式变更与安全发布
ch12 一气呵成:从数据库契约到后端服务

第三篇:扩展——扩大 PostgreSQL 的能力边界

ch13 言出法随:函数、触发器与存储过程
ch14 博采众长:内核分支与扩展生态
ch15 见微知著:全文、模糊与向量检索
ch16 经天纬地:时序、空间与时空查询
ch17 合纵连横:分析加速与分布式选型
ch18 万法归宗:PostgreSQL 数据平台与替代边界

下卷:运维管理

从生产服务规划到日常运营、事故恢复与改进

第四篇:规划——建设可交付的 PostgreSQL 服务

ch19 开天辟地:环境规划与部署基线
ch20 狡兔三窟:高可用拓扑与容灾目标
ch21 未雨绸缪:备份体系与恢复演练
ch22 四通八达:服务接入、连接池与路由
ch23 固若金汤:认证、授权与数据安全
ch24 纲举目张:SLO、SOP 与组织治理

第五篇:运营——用证据驱动日常维护与演进

ch25 望闻问切:监控体系与可观测诊断
ch26 胸有成竹:容量规划与压测基线
ch27 精益求精:参数调优与资源治理
ch28 除旧布新:VACUUM、冻结与膨胀治理
ch29 移花接木:逻辑复制、迁移与异构同步
ch30 推陈出新:版本升级与回滚策略

第六篇:出山——按响应目标演练恢复与改进

ch31 事件分级、现场保护与应急决策——枕戈待旦
ch32 PITR 与误操作恢复——妙手回春
ch33 故障切换与集群重建——力挽狂澜
ch34 过载保护与资源故障判型——李代桃僵
ch35 数据抢救与工程取证——起死回生
ch36 事故复盘、控制固化与平台演进——举一反三

附录

上卷:应用开发

本卷导读:上卷面向应用开发者与数据库工程实践者,沿着“认识系统—可靠建模—正确查询—性能与并发—安全交付—能力扩展”的路径,建立从 PostgreSQL 原理到 Pigsty 实验闭环的完整开发能力。

本卷定位

从 PostgreSQL 工程认知到应用交付与能力扩展

第一次系统学习,建议按三篇顺序推进;已有明确问题时,可从下方索引直接进入相应章节。

本卷索引

第一篇:筑基——建立 PostgreSQL 工程认知

ch01 盲人摸象:PostgreSQL 与 Pigsty 全局地图

回答“我连到了什么、数据对象在哪里、一次查询经过什么、Pigsty 又管理了什么”,并固化后续章节共同使用的实验基线。

ch02 手到擒来:psql 与可复现工作流

掌握后续 34 章反复使用的最小工具链,把临时手工操作变成可审查、可验证、可重跑的任务。

ch03 正本清源:从业务规则到关系模型

先表达业务事实、不变量与所有权,再把逻辑模型 v0 跑进真实数据库;本章模式是过渡版本,可靠物理模式在 ch04 闭合。

ch04 量体裁衣:数据类型、约束与可靠数据表达

利用 PostgreSQL 类型系统、约束与分区决策,把 ch03 的逻辑模型逐项落成可靠物理模式。

ch05 运筹帷幄:查询、事务与锁的核心心智模型

建立执行计划、并发控制和故障诊断共同依赖的原理地图,不在本章穷举后续专题。

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

不提前宣判“最佳实践”,而是建立“候选规则—证据—适用范围—例外—验证”的规约生成方法,产出 baseline v0.1。

第二篇:应用——从 SQL 正确走向稳定交付

ch07 追本溯源:执行计划与统计信息

学会读计划、验证估算、解释计划变化,并建立分区裁剪和自动计划采样的正确边界。

ch08 抽丝剥茧:慢 SQL 诊断方法论

从“用户说慢”出发,建立从范围界定、证据收集、假设排序到受控验证的诊断闭环,并集中示范全书反复使用的故障诊断方法。

ch09 巧夺天工:索引设计与效果验证

从访问模式而不是字段直觉设计索引,并用读取收益、写入代价与维护成本共同验收。

ch10 顾此失彼:并发控制与隔离异常

在明确隔离级别和业务不变量的前提下重现并发异常,选择锁、条件更新、重试与幂等策略。

ch11 守正出奇:模式变更与安全发布

先判断锁、重写、扫描和兼容性,再设计可观察、可中止、可回退的模式发布;完成分区能力的第三个触点。

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

把前十一章收束成一个可运行、可观测、可部署的最小服务;后续章节不再长期维护同一套 Go 代码。

第三篇:扩展——扩大 PostgreSQL 的能力边界

ch13 言出法随:函数、触发器与存储过程

判断逻辑应该位于 SQL、数据库函数、触发器还是应用中,并能测试、观测和安全发布数据库端逻辑。

ch14 博采众长:内核分支与扩展生态

不从“能安装”推导“该使用”,建立扩展发现、选型、供应链、升级与退出的统一 ADR。

ch15 见微知著:全文、模糊与向量检索

从检索质量与业务语义出发,完成全文、模糊、向量和混合检索的最小可复现 PoC,并知道生产代价。

ch16 经天纬地:时序、空间与时空查询

分别建立时间与空间数据的正确模型,最终用时空联合查询证明两者为何值得在同一章出现。

ch17 合纵连横:分析加速与分布式选型

先用证据证明单机边界,再比较单机分析加速与分布式方案,完成最小 PoC;不预演下卷的复制、路由和多集群运维。

ch18 万法归宗:PostgreSQL 数据平台与替代边界

把上卷能力组合成一张数据平台地图,同时明确 PostgreSQL 不应该承担的工作,为下卷的服务建设建立边界。

前后衔接

1 盲人摸象:PostgreSQL 与 Pigsty 全局地图

会写 SQL,并不等于知道 SQL 落在了哪里。一个连接 URI 里同时出现主机、端口、数据库和角色;连接成功后又会遇到实例、模式、关系、后端进程、WAL、服务端点与集群等词。它们属于不同层次,却经常被笼统地叫作“数据库”。许多误操作、权限错误和接入故障,都始于这张地图没有画清楚。

本章不急着介绍 PostgreSQL 的所有功能。我们只完成一件事:建立一套此后能够反复使用的坐标系。读者将从一条真实连接出发,逐层确认连接落点、对象边界、查询路径与服务拓扑,最后创建全书贯穿案例 pg36_shop 的最小基线。

版本基线:本章按 PostgreSQL 18.6、Pigsty v4.5.0 和单节点 L1 沙箱编写。核心 PostgreSQL 概念适用于当前受支持的大版本;Pigsty 的端口、组件和配置入口以 v4.5.0 为准。所有命令都先查询实际运行版本,书中示例输出只展示需要判断的字段。

本章目标

回答“我连到了什么、数据对象在哪里、一次查询经过什么、Pigsty 又管理了什么”,并固化后续章节共同使用的实验基线。

读者前置

开始本章前,你应当已经:

  • 掌握 Linux 终端、环境变量和基本文件操作;
  • 会写常用 SQL,但不要求熟悉 PostgreSQL 的系统目录;
  • 拥有一个可连接的 PostgreSQL 环境;推荐使用第 0 章准备的 Pigsty L1 沙箱;
  • 知道实验管理员连接信息存放在哪里,但不会把密码写进书稿、脚本或 Git。

如果已经有其他 PostgreSQL 环境,也可以完成 1.1–1.3 与 1.6;1.4、1.5 和 1.7 中的服务拓扑与平台证据需要 Pigsty。

学习完成标准

完成本章后,你应当能够拿出证据完成以下任务,而不是凭名称猜测:

  1. 从连接 URI 中指出主机、端口、数据库和登录角色,并用 SQL 确认服务器、数据库、会话角色、模式搜索路径与读写状态;
  2. 解释实例、database cluster、数据库、模式和关系对象的包含关系,说明角色为什么不隶属于某一个数据库;
  3. 画出“客户端 → 服务入口 → PostgreSQL 后端 → 共享内存/数据文件/WAL”的最小查询路径;
  4. 区分 PostgreSQL 原生能力、生产平台必须承担的职责和 Pigsty 的具体实现;
  5. 使用最少的一组 psql 命令探索对象、切换数据库、执行脚本、保存证据和安全中断;
  6. 创建并验证 pg36_shop 数据库、shop 模式与最小角色,生成后续章节可复用的环境快照。

贯穿场景

假设应用团队交给你下面这样的连接入口:

postgresql://pg36_app@pg-meta:5433/pg36_shop

这串字符并没有告诉你所有事实。pg-meta 可能是主机名,也可能是随主节点漂移的集群域名;5433 在 Pigsty 中通常是读写服务,而不是 PostgreSQL 进程直接监听的 5432pg36_shop 是数据库名,不是实例名;pg36_app 是数据库角色,也不是 Linux 用户。只有把连接参数与服务器返回的证据合在一起,才能确认操作落点。

本章沿着同一条连接向内、再向外展开:

flowchart LR
  C["客户端与连接 URI"] --> S["Pigsty 服务入口<br/>HAProxy / PgBouncer"]
  S --> B["PostgreSQL 后端进程<br/>一个连接对应一个会话"]
  B --> O["数据库中的对象<br/>模式、表、索引、函数"]
  B --> M["实例共享资源<br/>共享内存、数据文件、WAL"]
  P["Pigsty 配置与控制面"] --> S
  P --> B
  E["日志、系统目录与指标"] -.复核.-> S
  E -.复核.-> B
  E -.复核.-> O

这张图不是完整架构图,而是本章的读图顺序:先确认连接参数,再让服务器说明自己是谁,然后才讨论平台如何把实例组合成服务。

本章路线

1.1 从连接串识别操作落点

先把 URI 中的五个名字拆开,并通过一条上下文快照查询确认“我到底连到了哪里”。这一节还会第一次区分实例端点、读写服务端点和只读服务端点。

1.2 PostgreSQL 对象与术语坐标

建立实例、database cluster、数据库、模式与关系对象的层级图,特别处理 PostgreSQL 中 “cluster” 与 Pigsty 集群容易混淆的问题。

1.3 一条查询经过了什么

用一个会话和一条查询观察客户端、后端进程、共享内存、数据文件、WAL、系统目录与统计视图各自扮演的角色。

1.4 从数据库实例到数据库服务

从单个 postgres 进程向外扩展,说明复制组、稳定入口、控制面、计算、存储、网络与可观测性为什么属于“服务”问题。

1.5 Pigsty 的资源模型

把通用职责映射到 Pigsty 的节点、实例、集群和服务,以及 PostgreSQL、Patroni、PgBouncer 与 HAProxy 的分工。

1.6 最小 psql 生存卡

只学习完成后续实验所需的最小命令集。更系统的连接保护、变量、脚本与可复现工作流留到 ch02《psql 与可复现工作流》。

1.7 实战:建立 pg36_shop 地图与实验基线

创建最小对象,采集连接、对象、服务与版本证据,建立 verify:state 和三档复位边界,并从 SQL 与 Pigsty 两侧指认同一对象。

本章交付物

完成实验后,至少保留以下内容:

  • 一份不含密码的连接上下文快照;
  • 一份 pg36_shop 对象树;
  • 一份 L1 节点、实例、集群、服务与端口映射;
  • pg36_shop 数据库、shop 模式和三类最小角色;
  • 一次通过的 verify:state 输出;
  • 明确的 reset:sqlreset:clusterreset:host 适用边界。

这些产物从 ch02 开始会被直接复用。不要为了得到“好看”的输出而手工修改证据;环境差异本身也是需要记录的事实。

复习与迁移问题

  1. postgresql://alice@db.example:5433/shop 中,哪一部分由客户端决定,哪一部分必须由服务器返回才能确认?
  2. 为什么同一个角色可能连接多个数据库,而同一个普通表不能跨数据库直接访问?
  3. 直连实例与连接稳定服务端点,各自暴露了什么假设?
  4. pg_is_in_recovery() 能证明什么,不能证明什么?
  5. 如果配置清单写着某实例是主库,而 SQL 显示它正在恢复,你会把哪一项当作当前运行事实?为什么?
  6. 在托管数据库或 Kubernetes Operator 中,Pigsty 的“节点、实例、服务、控制面”分别可能映射成什么职责?

下一章如何使用本章

ch02《psql 与可复现工作流》不再解释这些对象是什么,而会把本章的临时命令整理成安全、可审查、可重跑的工作流。届时会加入服务文件、环境保护、失败即停、变量、确定性数据与机器可读输出。

如果此刻你仍不能在不查看答案的情况下画出连接到对象的完整路径,请先重做 1.7 的验收;后面的每一章都会默认这张地图已经建立。

参考基线


返回上卷导读 · 下一章:手到擒来:psql 与可复现工作流 · 查看全书目录 · 查看索引中心

1.1 从连接串识别操作落点

一条连接字符串表达的是客户端的连接意图,不是服务器的自我证明。主机名可能经过 DNS 或 VIP,端口可能属于代理,登录角色还可能在会话内切换。可靠的第一步不是看到提示符就开始执行,而是把“我打算连到哪里”与“服务器说我落在哪里”对上。

本节全部操作属于 R0·观察。请使用第 0 章提供的实验凭据,不要把密码写入命令历史、书稿或 Git。pg36_shop 尚未创建,因此先用 Pigsty L1 已有的管理数据库观察;将 <L1_HOST> 替换为实际域名或 IP:

export PG36_BOOTSTRAP_URL='postgresql://dbuser_dba@<L1_HOST>:5436/postgres?application_name=pg36-ch01'
psql -X "$PG36_BOOTSTRAP_URL"

-X 表示暂不读取个人 psqlrc,避免本地定制改变示例行为。安全保存凭据、服务文件和连接保护会在 ch02《psql 与可复现工作流》中展开。

1.1.1 主机、端口、服务、数据库与角色

全书最终要交给应用的是类似下面的 URI。此刻先把它当作待解释的目标,而不是可以立即连接的成品:

postgresql://pg36_app@pg-meta:5433/pg36_shop?application_name=pg36-ch01
             └──角色──┘ └主机─┘└端口┘└─数据库──┘ └────连接参数─────┘
部分 它回答的问题 由谁解释 不能据此断言什么
pg-meta 客户端先去哪里建立网络连接? 客户端 DNS、/etc/hosts、Unix socket 或地址列表 它不一定是一台固定主机,也不证明最终 PostgreSQL 实例
5433 目标主机上的哪个 TCP 入口? 监听该端口的进程或代理 它不一定是 PostgreSQL;在 Pigsty 中通常是 HAProxy 读写服务
pg36_shop 认证成功后进入哪个数据库? PostgreSQL 它不是模式、实例或集群名
pg36_app 以哪个数据库角色发起认证? PostgreSQL 认证规则 它不必与 Linux 用户同名,也不等于对象所有者
application_name 这条连接在活动视图和日志中叫什么? 客户端传入,PostgreSQL 记录 它是可伪造标签,不是安全身份

URI 支持 postgresql://postgres:// 两种 scheme。用户名、密码或数据库名含有 @:/?# 等保留字符时必须进行百分号编码。更重要的是,不要为了省事把密码直接写入可被 shell 历史、进程列表或日志记录的 URI;本章让 psql 交互式询问密码。

“服务”在这里是平台语义,而不是 URI 中额外的一段。它通常由“可访问的主机或域名 + 端口 + 路由规则”共同构成。Pigsty 的 pg-meta:5433 是读写服务入口;同样的 pg-meta 配上 5432,通常变成对当前 VIP 所在节点的 PostgreSQL 直连。端口改变,路径与故障语义也随之改变。

连接成功后,先执行一份上下文快照:

SELECT
    version()                         AS server_version,
    current_database()               AS database_name,
    session_user                     AS session_user,
    current_user                     AS current_user,
    inet_server_addr()                AS server_addr,
    inet_server_port()                AS server_port,
    pg_backend_pid()                  AS backend_pid,
    pg_is_in_recovery()               AS in_recovery;

关键判断不是输出长什么样,而是每一列证明了什么:

  • version() 来自服务端,可以揭示服务器版本与构建信息;它不等于本机 psql --version
  • inet_server_addr()inet_server_port() 是 PostgreSQL 后端接受连接的地址与端口。经过 HAProxy、PgBouncer 后,它们通常显示最后一跳 PostgreSQL 的地址与 5432,而不是客户端最初访问的 5433
  • 通过 Unix socket 连接时,inet_server_addr()inet_server_port() 会是 NULL,这不是故障;
  • pg_backend_pid() 是当前 PostgreSQL 后端进程号,只在该实例当前生命周期内有意义;
  • pg_is_in_recovery()false 表示当前实例不在恢复状态,通常是可写主库;为 true 表示处于恢复或热备状态。它不单独证明整套高可用系统健康。

客户端意图与服务器证据必须同时保留。只记 URI,会丢失实际落点;只记 SQL 输出,又会丢失客户端究竟通过哪个入口到达。

1.1.2 current_database()current_usersearch_path

进入服务器以后,还要确认三个会直接改变 SQL 含义的上下文:当前数据库、当前角色与模式搜索路径。

SELECT
    current_database()       AS database_name,
    session_user             AS authenticated_as,
    current_user             AS effective_as,
    current_setting('search_path') AS configured_path,
    current_schemas(true)    AS effective_path;

current_database() 返回当前连接所在数据库。PostgreSQL 的一个普通会话一次只连接一个数据库;\c 看似在会话内“切库”,实际是 psql 断开后重新建立连接。数据库之间不是类似 MySQL database.table 那样可以随意跨库限定访问的命名空间。

session_user 是最初通过认证的角色,通常在连接期间保持不变;current_user 是当前权限检查使用的有效角色。执行 SET ROLE 或进入使用 SECURITY DEFINER 的函数时,两者可能不同:

SELECT session_user, current_user;
-- 只有在当前角色有权切换时才能执行:
SET ROLE pg36_owner;
SELECT session_user, current_user;
RESET ROLE;

因此,审计“谁连进来”时看 session_user,判断“当前 SQL 以谁的权限运行”时看 current_user。两者都不等于操作系统账号。

search_path 决定没有写模式限定符的对象名如何解析,也决定未显式指定模式时新对象创建在哪里。假设有效路径是:

pg_catalog, shop

那么系统对象优先从 pg_catalog 解析,业务对象再从 shop 查找。current_setting('search_path') 返回配置文本;current_schemas(true) 返回去除不存在或不可访问项后的有效路径,并按参数决定是否包含隐含的系统模式。

不要把 search_path 当成界面便利设置。若不可信用户可以在搜索路径靠前的模式中创建对象,未限定名称的函数或操作符可能解析到攻击者提供的对象。应用与迁移脚本应采用受控路径,安全敏感 SQL 则显式写出模式名,例如 pg_catalog.set_config(...)shop.orders

本书为运行角色约定:

ALTER ROLE pg36_app IN DATABASE pg36_shop
SET search_path = pg_catalog, shop;

这条语句是 R1·可逆变更,只对角色 pg36_app 连接数据库 pg36_shop 时生效。回退方法是:

ALTER ROLE pg36_app IN DATABASE pg36_shop RESET search_path;

执行位置、权限与对象创建将在 1.7 一并处理。

1.1.3 实例端点、服务端点与只读端点

端点可以指向固定实例,也可以表达一种稳定服务意图。两者都能建立连接,但承诺不同。

入口类型 Pigsty v4.5 默认示例 典型路径 适合做什么 隐含假设
PostgreSQL 实例直连 pg-meta-1:5432 客户端 → PostgreSQL 本地管理、精确诊断单一实例 实例身份不会自动随故障切换变化
PgBouncer 实例直连 pg-meta-1:6432 客户端 → PgBouncer → PostgreSQL 精确访问某实例上的连接池 仍绑定固定实例
primary 服务 pg-meta:5433 客户端 → HAProxy → 主库 PgBouncer → PostgreSQL 应用读写 平台会根据当前角色路由到主库
replica 服务 pg-meta:5434 客户端 → HAProxy → 备库 PgBouncer → PostgreSQL 可容忍复制延迟的读取 没有合格备库时可能按配置回退
default 服务 pg-meta:5436 客户端 → HAProxy → 主库 PostgreSQL 管理、迁移、需要会话语义的直连 绕过连接池,但仍跟随主库

这些是 Pigsty 的默认配置,不是 PostgreSQL 标准端口;用户可以修改。5432 才是 PostgreSQL 常见默认端口,6432 是 PgBouncer 常见默认端口。

最容易犯的错误,是把“replica 服务”理解成数据库层面的强制只读。服务名首先表达路由策略,不等同于授权策略。在单节点 L1 中没有专用备库,replica 服务可能没有可用后端,或者按具体配置回退到主库;即使连接到了备库,未来故障切换也可能改变承载实例。应用是否有写权限,仍应由角色授权、事务只读属性与数据库策略共同约束。

每次需要判断读写能力时,至少采集:

SELECT
    pg_is_in_recovery()                    AS in_recovery,
    current_setting('transaction_read_only')::boolean AS transaction_read_only,
    has_database_privilege(
        current_user,
        current_database(),
        'CREATE'
    )                                     AS can_create_in_database;

三个结果分别回答“实例是否在恢复”“当前事务是否只读”“角色是否拥有数据库级 CREATE 权限”,它们不是同一个问题。has_database_privilege 也不能穷举写入能力:表级权限、行级安全策略、函数权限和对象所有权仍可能改变结果。

一个两端互证练习

分别通过实例直连与 primary 服务建立连接,运行相同快照,并对比客户端入口与后端证据:

export PG36_INSTANCE_URL='postgresql://dbuser_dba@<INSTANCE_HOST>:5432/postgres?application_name=pg36-ch01-instance'
export PG36_PRIMARY_URL='postgresql://dbuser_dba@<L1_HOST>:5433/postgres?application_name=pg36-ch01-primary'

psql -X "$PG36_INSTANCE_URL" -c \
  "SELECT inet_server_addr(), inet_server_port(), pg_backend_pid(), pg_is_in_recovery();"

psql -X "$PG36_PRIMARY_URL" -c \
  "SELECT inet_server_addr(), inet_server_port(), pg_backend_pid(), pg_is_in_recovery();"

在单节点环境中,两次查询可能落到同一个 PostgreSQL 实例,但路径仍不同;在高可用环境中,primary 服务应随主库角色变化,而固定实例端点不会。不要为了让示例输出与书中一致而忽略差异,把实际结果写进环境清单。

本节验收

关闭终端前,确认你能回答:

  • URI 中哪个字段选择数据库角色,哪个字段选择数据库?
  • 为什么访问 5433 后,inet_server_port() 常常仍返回 5432
  • current_user 在什么情况下会与 session_user 不同?
  • replica 服务、恢复状态、事务只读和角色权限为什么是四个不同判断?

若任何一个答案仍依赖“端口名字看起来像……”,重新执行上下文快照,用查询结果作答。

参考资料


返回本章目录 · 下一节:PostgreSQL 对象与术语坐标 · 查看全书目录 · 查看索引中心

1.2 PostgreSQL 对象与术语坐标

PostgreSQL 的对象不是装在一只名叫“数据库”的大盒子里。数据库和角色处在同一套服务器范围内,模式与关系对象则处在某一个数据库内部;一条普通连接只能进入其中一个数据库。只要层级画错,权限、命名、备份和迁移的判断就会跟着错。

先记住这张最小坐标图:

flowchart TD
  I["一个 PostgreSQL 实例<br/>进程 + 配置 + 数据目录"] --> D1["数据库 postgres"]
  I --> D2["数据库 pg36_shop"]
  I --> D3["数据库 template1"]
  I --> R["共享角色集合"]
  D2 --> S1["模式 shop"]
  D2 --> S2["模式 public"]
  S1 --> O1["表 / 分区表 / 序列"]
  S1 --> O2["索引 / 视图 / 物化视图"]
  S1 --> O3["函数 / 类型 / 其他对象"]

箭头表达包含或作用域,不表达磁盘上的目录结构。系统目录才是验证这些关系的权威入口。

1.2.1 实例、数据库、模式与关系对象

PostgreSQL 文档更常使用“服务器”或“服务器进程”描述运行实体。工程语境中的一个 PostgreSQL 实例,通常指一套正在运行的服务器进程,以及它们共同使用的配置、共享内存和数据目录。实例是运行边界,不是可以用 CREATE INSTANCE 创建的 SQL 对象。

一个实例管理多个数据库。数据库是连接边界:客户端在启动连接时选定数据库,普通 SQL 名称不能直接写成 另一个数据库.模式.表 跨库访问。确有跨库需求时,需要应用发起另一条连接,或显式使用 postgres_fdwdblink 等机制;那是后续章节的内容。

每个数据库内部有多个模式(schema)。模式是数据库内的命名空间,shop.ordersshop 是模式,orders 是关系名。不同模式可以有同名对象,例如 shop.ordersarchive.orders

“关系”(relation)是 PostgreSQL 中一个比“表”更宽的家族。普通表、分区表、索引、序列、视图和物化视图等,都在 pg_class 中占有记录。先用系统目录观察层级:

-- 这张共享目录列出实例管理的数据库
SELECT oid, datname, datallowconn
FROM pg_catalog.pg_database
ORDER BY datname;

-- 以下目录只描述当前数据库中的对象
SELECT oid, nspname
FROM pg_catalog.pg_namespace
WHERE nspname !~ '^pg_temp_'
ORDER BY nspname;

SELECT
    n.nspname AS schema_name,
    c.relname AS relation_name,
    c.relkind
FROM pg_catalog.pg_class AS c
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = 'shop'
ORDER BY c.relname;

oid 是 PostgreSQL 为许多对象分配的内部标识。把它用于取证和目录连接很方便,但不要把 OID 当成跨数据库、跨重建仍稳定的业务标识。应用数据应有自己的键。

可以用一个简单实验确认数据库边界:分别连接 postgrespg36_shop,查询 pg_namespace。你会看到两个数据库各自拥有一套模式目录;而查询 pg_database 时,两边都能看到同一组数据库。

1.2.2 角色为何跨数据库存在

角色(role)属于整个 PostgreSQL database cluster,而不是某一个数据库。原因很直接:客户端必须先用角色通过实例级认证,服务器才能允许它进入目标数据库。若角色本身藏在目标数据库里,认证顺序就会形成循环。

使用普通可见的 pg_roles 视图观察角色:

SELECT
    rolname,
    rolcanlogin,
    rolsuper,
    rolcreatedb,
    rolcreaterole
FROM pg_catalog.pg_roles
WHERE rolname LIKE 'pg36_%'
ORDER BY rolname;

postgrespg36_shop 中执行这条查询,会看到相同的角色集合。底层的共享系统目录是 pg_authid,其中包含敏感认证信息,普通用户不应直接依赖;pg_roles 会隐藏密码字段。

角色跨数据库存在,不代表权限也自动跨数据库生效。需要分开看三件事:

  1. 角色身份与成员关系:在整个 database cluster 中存在;
  2. 进入数据库的资格:由数据库的 CONNECT 权限和认证规则控制;
  3. 使用数据库内对象的权限:由模式、表、序列、函数等各级授权控制。

例如,pg36_app 可以同时存在于所有数据库,却只被授予进入 pg36_shop 和使用 shop 模式的权限。角色名称与 Linux 用户也相互独立;本地 peer 认证可以建立两者的映射,但那是认证配置,不是二者天然相同。

用 PostgreSQL 自带的权限函数验证,不要只读 GRANT 脚本猜测最终结果:

SELECT
    has_database_privilege('pg36_app', 'pg36_shop', 'CONNECT')
        AS can_connect,
    has_schema_privilege('pg36_app', 'shop', 'USAGE')
        AS can_use_shop;

第二个函数必须在包含 shop 模式的数据库中执行。这个差异本身正好说明:角色是共享的,模式对象是数据库本地的。

1.2.3 表、索引、序列、视图、函数与扩展

psql\d 不是“describe table”的缩写式替代,而是进入 PostgreSQL 对象体系的一扇门。不同对象在目录中的位置和生命周期不同:

对象 主要目录证据 关键边界
表、分区表 pg_classrelkindrp 保存逻辑行;分区表本身与分区是不同关系
索引、分区索引 pg_class + pg_index 是独立关系对象,依赖被索引关系
序列 pg_classrelkind = 'S' 有独立状态;不是“表中自增列”的同义词
视图、物化视图 pg_classrelkindvm 普通视图保存查询定义,物化视图保存结果
函数、过程 pg_proc 名称可能重载,身份包含参数类型
扩展 pg_extension + 成员对象 是安装、升级和卸载一组对象的打包边界

使用下面的查询把 relkind 翻译成可读类型:

SELECT
    n.nspname,
    c.relname,
    CASE c.relkind
      WHEN 'r' THEN 'table'
      WHEN 'p' THEN 'partitioned table'
      WHEN 'i' THEN 'index'
      WHEN 'I' THEN 'partitioned index'
      WHEN 'S' THEN 'sequence'
      WHEN 'v' THEN 'view'
      WHEN 'm' THEN 'materialized view'
      WHEN 'f' THEN 'foreign table'
      ELSE c.relkind::text
    END AS object_type
FROM pg_catalog.pg_class AS c
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = 'shop'
ORDER BY object_type, c.relname;

扩展尤其容易被误解。CREATE EXTENSION 不是启动一个数据库外部插件进程,而是让 PostgreSQL 按扩展控制文件和 SQL 脚本创建并登记一组对象;某些扩展另外需要预加载共享库或外部服务,但不能据此概括所有扩展。查看当前数据库已安装扩展:

SELECT extname, extversion, extnamespace::regnamespace
FROM pg_catalog.pg_extension
ORDER BY extname;

扩展按数据库安装。软件包在操作系统上“可用”,不等于已经在每个数据库中执行了 CREATE EXTENSION。ch14《内核分支与扩展生态》会系统处理安装、启用、升级和退出成本。

1.2.4 PostgreSQL “database cluster”的特殊含义

PostgreSQL 官方文档中的 database cluster,是“由一套服务器实例管理、存放在共同数据区域中的数据库集合”。initdb 的工作就是初始化这样一个 database cluster。它不天然表示多节点、高可用或分布式。

Pigsty 文档中的 PGSQL 集群,则是一个平台级业务单元:由一个主实例和零个或多个复制实例组成,通过服务暴露能力。每个实例都有自己的 PostgreSQL 数据目录;物理备库的数据来自主库复制,但仍是独立运行的服务器实例。

术语 本书中的含义 常见数量关系
PostgreSQL database cluster 一个实例所管理的一组数据库与共享角色 每个 PostgreSQL 实例一套
PostgreSQL 实例 一套服务器进程、配置、共享内存和数据目录 一台节点通常一个,技术上可以多个
Pigsty PGSQL 集群 作为自治服务单元的一组主备实例 一个或多个实例
数据库 客户端连接进入的逻辑边界 一个实例内多个
模式 某个数据库内的命名空间 一个数据库内多个

下面两条观察命令能把概念落到当前实例:

SHOW data_directory;

SELECT oid, datname
FROM pg_catalog.pg_database
ORDER BY oid;

data_directory 是当前实例的数据区域;pg_database 列出该实例所管理的数据库集合。生产环境不应依靠直接浏览数据目录理解对象,更不能手工移动或删除其中的文件。数据目录路径属于运维信息,公开证据包时应按环境敏感度处理。

若需要识别物理复制成员是否来自同一 PostgreSQL database cluster,可由有权限的管理员查询控制文件信息:

SELECT system_identifier, pg_control_version, catalog_version_no
FROM pg_catalog.pg_control_system();

物理主备通常共享 system_identifier;逻辑复制目标不会因此自动相同。这个值是取证线索,不是业务主键,也不等于 Pigsty 的 pg_cluster 名称。

本节验收:不用“数据库”糊弄过去

请为下面每项写出完整限定描述:

  • pg36_shop:一个数据库;
  • shoppg36_shop 数据库中的模式;
  • pg36_app:当前 PostgreSQL database cluster 中的角色;
  • pg-meta-1:Pigsty 管理的 PostgreSQL 实例名;
  • pg-meta:Pigsty PGSQL 集群名,也可能被配置为集群服务域名。

随后在 postgrespg36_shop 各执行一次 pg_rolespg_databasepg_namespace 查询。验收标准是:你能够根据结果解释哪些目录共享、哪些目录随数据库改变,而不是只说“两个库看起来差不多”。

参考资料


上一节:从连接串识别操作落点 · 返回本章目录 · 下一节:一条查询经过了什么 · 查看全书目录 · 查看索引中心

1.3 一条查询经过了什么

现在沿着已经确认的连接继续向内走。客户端发送的不是“直接操作磁盘”的命令,而是 PostgreSQL 前后端协议中的消息;服务器端后端进程在一个会话上下文中解析、规划并执行 SQL,再通过共享资源读写数据。

本节只建立全景路径。查询优化器、事务、锁和执行器的细节会在 ch05《查询、事务与锁的核心心智模型》和 ch07《执行计划与统计信息》中展开。

1.3.1 客户端、后端进程与会话

PostgreSQL 采用客户端/服务器模型。psql、应用驱动和 GUI 都是客户端;postgres 服务器进程接受连接,并为每个直接连接创建一个后端进程(backend)。这个后端在连接存续期间保存会话状态,例如当前角色、当前数据库、参数、临时对象和事务状态。

连接池会改变观察方式。直连 PostgreSQL 时,一个客户端连接稳定对应一个后端;经过 PgBouncer 的事务池时,客户端连接与 PostgreSQL 后端只在事务期间绑定,下一个事务可能换到另一个后端。不要把某次观察到的 PID 永久记作“这个应用的进程”。

在当前连接中查看自己的会话:

SELECT
    pid,
    backend_type,
    datname,
    usename,
    application_name,
    client_addr,
    backend_start,
    state,
    wait_event_type,
    wait_event
FROM pg_catalog.pg_stat_activity
WHERE pid = pg_backend_pid();

这里有三类事实:

  • pidbackend_start 描述当前后端生命周期;
  • datnameusenameapplication_name 描述会话上下文;
  • statewait_event_typewait_event 描述采样时刻正在做什么。

state = 'active' 只表示该后端当时正在执行查询,不等于它“健康”或“很忙”;idle 也不等于可以随意终止,因为客户端可能正在两次请求之间等待。活动视图是现场快照,解释它必须结合时间、事务状态和应用协议。

一条普通查询的最小路径如下:

  1. 客户端建立连接并完成认证;
  2. 客户端发送 SQL 或带参数的协议消息;
  3. 后端解析语法,解析对象名称与权限;
  4. 重写器处理规则和视图,规划器选择执行方案;
  5. 执行器读取或修改关系页,必要时等待锁、I/O 或其他资源;
  6. 后端把行、命令状态或错误返回客户端。

并行查询可能临时使用并行工作进程,但会话仍由领导后端承接;后台还有 autovacuum、WAL writer 等进程。它们都出现在进程体系中,却不是“一位用户连接一个会话”的反例。

双会话观察

打开终端 A,使用直连端点运行:

SELECT pg_backend_pid(), pg_sleep(10);

立刻在终端 B 中查询:

SELECT
    pid,
    application_name,
    state,
    wait_event_type,
    wait_event,
    left(query, 60) AS query_sample
FROM pg_catalog.pg_stat_activity
WHERE datname = current_database()
  AND query LIKE '%pg_sleep%'
  AND pid <> pg_backend_pid();

预期能看到终端 A 的后端处于 active,并等待 Timeout/PgSleep。这是受控 L1 实验,不要在共享生产连接池中用 pg_sleep 制造占用。

1.3.2 共享内存、数据文件与 WAL

不同后端进程需要看到同一份数据库状态,因此 PostgreSQL 使用共享内存协调缓存、锁、WAL 缓冲区和其他全局结构。最常被提到的 shared_buffers 是 PostgreSQL 管理的数据页缓存,但它不是数据库使用的全部内存;后端私有内存、操作系统页缓存和外部组件都在同一台主机上争用资源。

SELECT name, setting, unit, context
FROM pg_catalog.pg_settings
WHERE name IN (
    'shared_buffers',
    'wal_buffers',
    'work_mem',
    'maintenance_work_mem'
)
ORDER BY name;

context 提示参数需要怎样生效,但不直接说明“最佳值”。参数机制与资源预算留到 ch27《参数调优与资源治理》。

表和索引最终以页面形式保存在数据文件中。系统目录知道对象对应的相对路径:

SELECT
    'pg_catalog.pg_class'::regclass AS relation,
    pg_relation_filepath('pg_catalog.pg_class') AS relative_path;

返回的是相对于数据目录的路径线索,不是让用户绕过 PostgreSQL 直接读取或修改文件的接口。数据文件可能分段、使用表空间,并受到版本、存储管理器和关系类型影响。手工编辑数据目录几乎从来不是合格的应用操作。

WAL(Write-Ahead Log,预写式日志)记录数据库变化所需的重做信息。“先写”指的是:相关 WAL 必须在脏数据页落盘之前持久化;事务提交通常要等待其提交记录达到所要求的持久性级别,而数据页可以稍后由后台进程写出。这个顺序使崩溃恢复能够从一致检查点继续重放。

观察当前实例的 WAL 位置:

SELECT
    CASE
      WHEN pg_is_in_recovery()
        THEN pg_last_wal_replay_lsn()
      ELSE pg_current_wal_lsn()
    END AS observed_lsn,
    pg_is_in_recovery() AS in_recovery;

LSN(Log Sequence Number)是 WAL 流中的位置,不是墙上时钟,也不能脱离时间线和实例角色直接比较。WAL 也不是“把每条 SQL 原文记下来”的审计日志,更不能替代经过验证的备份;这些边界会在 ch20、ch21 和 ch32 分别用于复制、高可用与恢复。

可以先形成一个高层心智模型:

sequenceDiagram
  participant C as 客户端
  participant B as 后端进程
  participant M as 共享缓冲区
  participant W as WAL
  participant D as 数据文件
  C->>B: INSERT / UPDATE / DELETE
  B->>M: 修改内存中的数据页
  B->>W: 生成 WAL 记录
  W-->>B: 按持久性要求刷新
  B-->>C: COMMIT 成功
  M-->>D: 脏页稍后写回

图中省略了锁、检查点、全页镜像、复制和存储栈等细节;它只用来记住 WAL 与数据页写出的先后约束。

1.3.3 系统目录与统计视图如何描述自身

PostgreSQL 很大一部分可观测性来自“用 SQL 描述自己”,但系统目录与统计视图回答的是两类不同问题。

系统目录保存数据库的结构事实,例如:

  • pg_database:有哪些数据库;
  • pg_namespace:当前数据库有哪些模式;
  • pg_class:有哪些关系对象;
  • pg_attribute:关系有哪些列;
  • pg_proc:有哪些函数与过程。

目录变化参与事务。例如,在事务中创建表后,当前事务能立即从 pg_class 看到它;回滚后记录消失。应用不应直接修改系统目录,而应使用 CREATEALTERDROPGRANT 等 SQL 接口。

统计视图描述运行活动与累计现象,例如:

  • pg_stat_activity:当前会话、状态与等待;
  • pg_stat_database:按数据库累计的事务、读写与冲突计数;
  • pg_stat_user_tables:用户表扫描、修改与维护计数;
  • pg_stat_replication:主库看到的复制发送状态。

统计信息可能因采样时刻、权限、统计重置和事务快照而变化。0 表示在当前统计口径下没有观测到,不等于历史上从未发生。监控系统从这些视图采集并保存时间序列,正是为了补上“当前快照没有历史”的缺口。

下面的查询把结构事实、运行事实和配置事实放在一张快照里:

SELECT jsonb_pretty(
    jsonb_build_object(
        'database', current_database(),
        'database_oid', (
            SELECT oid
            FROM pg_catalog.pg_database
            WHERE datname = current_database()
        ),
        'user', current_user,
        'backend_pid', pg_backend_pid(),
        'in_recovery', pg_is_in_recovery(),
        'server_version_num', current_setting('server_version_num'),
        'search_path', current_setting('search_path')
    )
) AS connection_snapshot;

JSON 只是便于保存的输出格式,不改变证据强度。对每个字段仍要问:

  • 它来自客户端标签、配置、系统目录还是运行统计?
  • 它描述当前连接、当前数据库、当前实例还是整套平台?
  • 它会不会在重连、故障切换、统计重置或升级后改变?

本节验收:复述一条查询的旅程

不看本页,画出以下路径并为每一段写出一个可观察证据:

客户端 →(可选代理)→ 后端进程 → 对象目录/执行器
                         ├→ 共享内存
                         ├→ WAL
                         └→ 数据文件

最低验收结果:

  • pg_backend_pid()pg_stat_activity 从两个会话观察到同一个后端;
  • pg_settings 记录共享内存相关配置,而不是猜测;
  • pg_relation_filepath() 找到关系路径线索,但没有直接修改数据文件;
  • 能解释系统目录的结构事实与统计视图的运行事实为什么不能混为一谈。

参考资料


上一节:PostgreSQL 对象与术语坐标 · 返回本章目录 · 下一节:从数据库实例到数据库服务 · 查看全书目录 · 查看索引中心

1.4 从数据库实例到数据库服务

一个 PostgreSQL 实例可以接受连接、执行事务并持久化数据,却还不自动等于一项可交付的数据库服务。生产用户关心的是稳定入口、可用性、恢复目标、容量、安全与责任人,而不是某个进程今天恰好运行在哪台主机上。

从实例走向服务,不是贬低 PostgreSQL“功能不全”,而是把数据库内核与平台工程放在正确边界上。这个边界也是本书后半卷的总地图。

1.4.1 单实例、复制组、服务入口与控制面

先把四个层次分开:

  1. 单实例:一套 PostgreSQL 服务器进程与数据目录。它可以是完整、可用的开发数据库,但主机或实例故障会直接中断服务;
  2. 复制组:主实例产生 WAL,一个或多个备实例接收并重放。复制提供数据副本与追赶机制,不自动回答何时提升、如何避免双主、客户端去哪里重连;
  3. 服务入口:用稳定的域名、VIP、代理端口或服务发现名称表达“读写”“只读”“管理直连”等访问意图,并把流量路由到当前合格实例;
  4. 控制面:保存期望状态、观察实际状态,并协调初始化、配置、选主、切换、备份、扩缩和维护等动作。

可以把请求路径与控制路径分开看:

flowchart LR
  A["应用"] --> E["稳定服务入口"]
  E --> P["当前主实例"]
  P --> R["备实例"]
  P -- "WAL 流" --> R

  C["控制面"] -. "观察角色与健康" .-> P
  C -. "观察角色与健康" .-> R
  C -. "更新路由或期望状态" .-> E

实线是数据面:应用查询和复制数据真实流动的路径。虚线是控制面:决定谁有资格承载流量,以及系统应当处于什么状态。控制面发生故障,不一定让当前 PostgreSQL 事务立即停止;但它可能让后续切换、配置变更或成员管理失效。

PostgreSQL 原生提供物理复制、同步提交、恢复、时间线和角色状态等基础机制,却有意不规定唯一的高可用编排方案。你可以用 Patroni,也可以使用托管数据库控制面、Kubernetes Operator 或组织自建系统。ch20《高可用拓扑与容灾目标》会根据故障模型重新审视这些选择。

在单节点 L1 中,复制组退化为一个主实例,服务入口与控制面仍然存在。这很适合学习组件关系,却不能证明多节点高可用已经成立。不要从“我能访问 5433”推导出“这套环境能容忍主机故障”。

1.4.2 计算、存储、网络、配置与可观测职责

数据库服务需要同时管理五类资源。每一类都要有“期望状态、运行事实、变更入口和失败边界”。

职责 PostgreSQL 能看到或控制的部分 平台还必须承担的部分 最小证据
计算 后端进程、并行度、内存参数、后台任务 CPU/NUMA、内存限额、OOM、操作系统调度、资源隔离 pg_stat_activitypg_settings、主机指标
存储 数据页、WAL、表空间、检查点、I/O 统计 磁盘与卷、文件系统、容量、冗余、快照、备份仓库 pg_stat_io、目录容量、备份清单
网络 监听地址、连接、TLS、HBA、复制协议 DNS、VIP、负载均衡、防火墙、跨区链路与证书分发 pg_hba_file_rules、socket、路由和探测
配置 GUC、角色和数据库级设置、重载与重启语义 模板、差异、密钥、分批变更、审计、回退 pg_settings、配置清单、变更记录
可观测 系统目录、统计视图、日志、EXPLAIN 指标与日志采集、长期存储、告警、面板、值班流程 原生查询、时间序列、告警事件

表格中的“平台”不是某个特定产品,而是任何生产方案都绕不开的责任集合。托管数据库把很多责任交给云厂商;自建系统则必须明确由谁实现、谁值守,以及产品边界之外还剩什么。

一个常见错误是只记录配置文件中的目标值。例如清单写着 shared_buffers: 8GB,实际实例可能尚未重启,SHOW shared_buffers 仍是旧值;清单写着实例角色为 primary,运行中的 PostgreSQL 却可能已经因为切换成为备库。声明、落地和运行事实是三层证据,不能互相替代。

另一个错误是把监控面板当成事实源本身。面板是对指标和日志的解释界面;当图表异常时,应当能够追到采集查询、标签、时间范围和 PostgreSQL 原生证据。反过来,只查询当前系统目录也没有历史,因此不能取代时间序列监控。

1.4.3 PostgreSQL 原生能力与平台组合能力的边界

全书采用三层叙述,避免把某个平台的按钮写成数据库原理:

第一层:PostgreSQL 原生机制

包括 SQL 语义、事务与锁、MVCC、WAL、复制协议、备份与恢复接口、角色权限、配置参数、系统目录、统计视图和扩展机制。关键结论优先回到 PostgreSQL SQL、日志、配置或官方文档验证。

第二层:数据库平台通用职责

包括主机置备、软件分发、拓扑编排、故障检测、选主与防脑裂、稳定接入、连接池、备份调度、密钥管理、监控告警、变更审计和恢复演练。这些责任客观存在,但实现方式不唯一。

第三层:Pigsty 参考实现

Pigsty 使用声明式配置与自动化,把 PostgreSQL、Patroni、etcd、HAProxy、PgBouncer、pgBackRest 和可观测组件组合起来。它提供一套可以拆开验证的具体答案,而不是 PostgreSQL 唯一的运行方式。

面对任意平台操作,都按下面的证据链追问:

问题 例子
用户要实现的服务目标是什么? “写请求在主库故障后恢复”,而不是“运行一个切换命令”
PostgreSQL 提供了哪些原生状态? pg_is_in_recovery()、复制 LSN、时间线、事务只读状态
平台根据什么规则采取动作? 健康检查、租约、故障阈值、候选优先级和 fencing
Pigsty 由哪些组件实现? Patroni 决策角色,HAProxy 根据健康接口路由
如何从另一侧复核? SQL 角色、Patroni 状态、代理后端和指标应相互一致
失效时如何停止与回退? 停止自动动作、保护现场、恢复路由或重建成员

这套追问可以迁移到其他平台。即使界面、CLI 和组件名称完全不同,“服务目标—原生状态—控制规则—运行证据—回退路径”的结构仍然成立。

分类练习

把下面动作分别归入“PostgreSQL 原生机制”“平台通用职责”“Pigsty 具体实现”,允许一项跨两层,但要说明边界:

  • CREATE ROLE
  • 为主库提供稳定域名;
  • pg_basebackup 复制基础数据;
  • 发现主实例故障后选择备实例提升;
  • HAProxy 在 5433 暴露读写服务;
  • pg_stat_activity 观察会话;
  • 保存 30 天指标并在 SLO 违约时告警。

参考判断:CREATE ROLEpg_stat_activity 是原生接口;稳定域名、故障切换和长期告警是通用职责;“HAProxy + 5433”是 Pigsty 的具体实现。pg_basebackup 是原生工具,但“何时运行、保存在哪里、如何校验”仍属于平台工作流。

本节验收

选取你当前连接的 pg36_shop 服务,写出一条完整证据链:

服务目标
→ 客户端入口
→ 路由组件
→ PostgreSQL 实例
→ 原生 SQL 证据
→ 平台状态证据
→ 不一致时的停止条件

合格答案必须包含至少一个 PostgreSQL 原生查询,且不能只引用面板颜色或配置文件。下一节会把这条抽象链具体映射到 Pigsty。

参考资料


上一节:一条查询经过了什么 · 返回本章目录 · 下一节:Pigsty 的资源模型 · 查看全书目录 · 查看索引中心

1.5 Pigsty 的资源模型

上一节把数据库平台拆成一组通用职责。本节只做一件事:把这些职责映射到 Pigsty v4.5 的实体、组件和证据源。记住映射比背命令重要,因为端口、界面和组件版本会变化,而“谁负责数据、谁负责角色判断、谁负责路由”必须始终说得清。

1.5.1 节点、集群、实例与服务

Pigsty 的 PGSQL 模块使用四个核心实体组织 PostgreSQL:

实体 定义 pg-meta 单节点示例 不能混淆为
节点(node) 运行 Linux 与 systemd 的计算资源 一台 VM 或裸机 PostgreSQL 数据库
实例(instance) 节点上的一套 PostgreSQL 服务器与数据目录 pg-meta-1 整个业务集群
集群(cluster) 由主备关系组织的自治业务单元 pg-meta PostgreSQL 官方语义中的 database cluster
服务(service) 按角色和用途选择实例的稳定访问抽象 pg-meta-primary 固定某台实例

Pigsty 默认采用节点与 PostgreSQL 实例 1:1 的独占部署模型,因此节点名经常借用实例名;这是 Pigsty 的部署约定,不是 PostgreSQL 限制。pg_clusterpg_seqpg_role 三个身份参数构成最小声明:

pg-meta:
  hosts:
    10.10.10.10: { pg_seq: 1, pg_role: primary }
  vars:
    pg_cluster: pg-meta

由此可以推导:

节点:10.10.10.10(默认命名可为 pg-meta-1)
实例:pg-meta-1
集群:pg-meta
服务:pg-meta-primary / pg-meta-replica / pg-meta-default / pg-meta-offline

配置中的 pg_role: primary 表示初始化或编排意图,不是永远不变的运行角色。多节点集群发生切换后,主库可以从 pg-meta-1 变成其他实例,而实例编号不变。服务名表达访问意图,也不应跟着当前主实例改名。

在 Pigsty 管理节点的安装目录中,用只读命令观察解析后的配置范围:

cd ~/pigsty

# 只显示分组与主机关系,不输出包含密码的完整变量
ansible-inventory --graph

# 只从源文件定位非敏感身份字段;动态清单环境应改查相应 CMDB
grep -nE 'pg-meta:|pg_cluster:|pg_seq:|pg_role:' pigsty.yml

不要把完整 ansible-inventory --list 直接贴进工单或书稿,它可能包含密码、令牌和内部地址。证据包只采集解决当前问题所需的字段。

单节点 L1 会让四种服务最终落到同一台节点甚至同一个 PostgreSQL 实例,但实体仍然不同。就像一位工程师可以兼任开发、值班和发布审批,职责名称相同不意味着角色边界消失。

1.5.2 PostgreSQL、Patroni、PgBouncer 与 HAProxy 的职责

一条默认生产读写连接的路径是:

客户端
  → HAProxy :5433
  → 当前主实例上的 PgBouncer :6432
  → PostgreSQL :5432

组件之间不是相互替代,而是逐层收窄职责:

组件 核心职责 它不负责什么 本章证据
PostgreSQL SQL、事务、存储、WAL、复制、权限和原生状态 不提供跨主机的唯一高可用控制面或统一服务入口 SQL、系统目录、日志
Patroni 管理 PostgreSQL 生命周期,以 DCS 协调角色、配置和故障转移,并提供健康接口 不执行应用 SQL,不承担连接池 pg list、REST 健康状态、Patroni 日志
PgBouncer 复用客户端到 PostgreSQL 的连接,限制和缓冲连接压力 不保存业务数据,不决定谁应成为主库 管理控制台、连接池指标、日志
HAProxy 暴露 TCP 服务端口,根据健康检查把流量路由到合格后端 不理解 SQL 事务,不复制数据 后端状态、端口、HAProxy 指标与配置

在高可用集群中,Patroni 通常使用 etcd 之类的分布式配置存储(DCS)协调领导者信息。HAProxy 请求 Patroni 健康接口判断实例角色,再把 5433 流量送往主库的 PgBouncer。PgBouncer 最后通过本地连接进入 PostgreSQL。

Pigsty v4.5 的默认入口如下,均可配置:

端口 入口 默认目标
5432 PostgreSQL 当前这台实例,直连
6432 PgBouncer 当前这台实例的连接池
5433 primary 服务 主库 PgBouncer,生产读写
5434 replica 服务 备库 PgBouncer,生产只读路由
5436 default 服务 主库 PostgreSQL,管理直连
5438 offline 服务 离线备库 PostgreSQL

“默认目标”必须结合配置阅读。例如 pg_default_service_dest 可以让 primary/replica 服务绕过 PgBouncer。不要仅凭端口号推断实际路径。

在管理节点上查看控制面状态:

# R0:列出集群成员、角色和复制状态
pg list pg-meta

在数据库中从另一侧复核:

SELECT
    pg_is_in_recovery() AS in_recovery,
    current_setting('port') AS postgres_port,
    inet_server_addr() AS server_addr,
    inet_server_port() AS accepted_port;

pg list 报告 Leader,而 SQL 的 pg_is_in_recovery()true,不要挑一个自己喜欢的结果继续操作。先停止角色相关变更,确认两条命令是否观察了同一集群、同一实例和同一时刻,再检查 Patroni 与 PostgreSQL 日志。配置标签、控制面判断和内核运行状态不一致,本身就是需要处理的事件。

本节暂不演练切换、连接池模式或代理重载;它们分别属于 ch20《高可用拓扑与容灾目标》和 ch22《服务接入、连接池与路由》。

1.5.3 配置清单、运行状态与监控事实分别来自哪里

Pigsty 环境至少存在三类事实源:

配置清单:系统应当是什么

默认静态清单是 ~/pigsty/pigsty.yml,也可以使用动态 inventory 或 CMDB。它声明节点、集群、初始角色、数据库、用户、服务和参数。清单适合回答“期望如何配置”,不能单独证明变更已经执行并生效。

运行状态:系统现在是什么

运行状态分散在各组件中:

  • PostgreSQL:SQL、系统目录、统计视图和日志;
  • Patroni:成员与角色状态、DCS 信息、健康接口和日志;
  • PgBouncer:连接池状态、管理控制台和日志;
  • HAProxy:服务后端、健康检查、运行配置和日志;
  • systemd 与主机:进程、端口、文件、资源和服务状态。

例如,“当前谁是主库”首先要看 PostgreSQL 与 Patroni 的实时状态,而不是初始化清单中的 pg_role 标签。

监控事实:系统在一段时间内发生了什么

Pigsty 的采集系统把 PostgreSQL、主机、组件和日志事实转换成带标签的时间序列与日志流。常见身份标签包括:

  • cls:集群;
  • ins:实例;
  • ip:节点地址;
  • job:采集任务或日志来源;
  • datnamerelnameidxname:数据库内部对象。

监控适合回答趋势、持续时间和事件先后,但它仍可能受采集间隔、标签错误、查询权限和数据保留影响。面板显示“主库”时,应能追到指标标签与原生 SQL;SQL 显示瞬时正常时,也不能据此否认五分钟前的告警。

建立一张证据优先级表:

要回答的问题 首选运行证据 配置证据 历史证据
当前连接进入哪个数据库和角色? current_database()session_user 连接配置 连接日志
当前实例是主库还是备库? pg_is_in_recovery() + Patroni 状态 初始 pg_role 角色指标、切换日志
某参数现在是否生效? pg_settings pigsty.yml/Patroni 配置 配置变更与重启记录
某服务把流量送到哪里? HAProxy 后端 + SQL 落点 服务定义 代理指标与日志
过去是否发生连接尖峰? 当前活动只能辅助 连接上限配置 连接时间序列、日志

实战:生成 L1 资源快照

在管理节点执行:

cd ~/pigsty

{
  printf 'captured_at=%s\n' "$(date -Is)"
  printf 'host=%s\n' "$(hostname -f 2>/dev/null || hostname)"
  printf 'pigsty_source=%s\n' "$(git describe --tags --always 2>/dev/null || printf unknown)"
  printf '%s\n' '--- inventory graph ---'
  ansible-inventory --graph
  printf '%s\n' '--- cluster runtime ---'
  pg list pg-meta
} > pg36-l1-platform.txt

这段脚本只采集身份与拓扑,不输出完整变量。若环境不是 Git 安装,pigsty_source=unknown 是有效结果,随后应从发行包或发布记录补充版本,而不是编造标签。

在数据库端另存原生快照:

psql -X "$PG36_BOOTSTRAP_URL" -A -t -c "
SELECT jsonb_build_object(
  'captured_at', clock_timestamp(),
  'database', current_database(),
  'user', current_user,
  'server_addr', inet_server_addr(),
  'server_port', inet_server_port(),
  'version', current_setting('server_version'),
  'in_recovery', pg_is_in_recovery()
);" > pg36-l1-postgres.json

两份文件共同构成证据:一份描述平台,一份描述 PostgreSQL。提交或分享前检查是否含有内部地址、用户名或其他不应公开的信息;密码和令牌在任何情况下都不应进入证据包。

本节验收

你应当能够从 L1 环境指出:

  • 哪个名字是节点、实例、集群和服务;
  • 543264325433 各由哪个组件接收;
  • 配置清单、pg list、SQL 与监控分别回答什么问题;
  • 当这些证据冲突时,为什么“重新运行自动化让它一致”不是安全的第一动作。

参考资料


上一节:从数据库实例到数据库服务 · 返回本章目录 · 下一节:最小 psql 生存卡 · 查看全书目录 · 查看索引中心

1.6 最小 psql 生存卡

psql 同时是交互式终端、脚本执行器和 PostgreSQL 取证工具。本节只保留完成第 1 章所需的最小操作;变量、条件、服务文件、失败即停、批量输入输出和可靠脚本会在 ch02《psql 与可复现工作流》中系统展开。

先区分两种输入:

  • 以反斜线开头的是 psql 元命令,由客户端解释,通常不加分号;
  • SQL 发送给 PostgreSQL 服务器,以分号结束,受事务与权限约束。

看到一个命令时先问“它由客户端还是服务器执行”,很多困惑会自动消失。

1.6.1 用 URI 连接,用 \l\dn\d 看对象

使用连接 URI 可以让终端、应用驱动和文档共享同一种参数表达:

psql -X "$PG36_BOOTSTRAP_URL"

连接成功后,第一条元命令应是:

\conninfo

它显示当前数据库、角色、主机或 socket、端口以及 TLS 等连接信息。随后按从大到小的顺序探索对象:

\l+
\dn+
\d
\dt shop.*
\d+ shop.orders
\du+
\dx

它们依次列出数据库、当前数据库中的模式、可见关系、shop 模式中的表、指定对象详情、角色和已安装扩展;+ 表示请求更详细的信息。对象尚未创建时,\d+ shop.orders 会明确报错。

\d 系列支持 psql 自己的对象模式匹配,不是 SQL 的 LIKE。例如 shop.* 表示模式 shop 下的对象;大小写与引号仍遵循 PostgreSQL 标识符规则。

元命令适合人类快速探索,系统目录查询适合明确筛选、保存和自动验证。两者应互相复核:

SELECT nspname
FROM pg_catalog.pg_namespace
ORDER BY nspname;

\dn 与查询结果看起来不同,先检查 \dn 是否过滤系统模式、用户是否有可见性权限,以及是否连接了同一个数据库,不要立即断言工具出错。

1.6.2 用 \c 切库,用 -c-f 执行

在交互会话中切换数据库:

\c pg36_shop
\conninfo

\c 实际上会断开当前连接并建立新连接。未显式指定的主机、端口和角色通常沿用当前值;所以切换后必须再次执行 \conninfo 或上下文快照。若切换失败,psql 在交互模式下通常保留原连接,不要误以为已经进入目标库。

从 shell 执行一条 SQL:

psql -X "$PG36_BOOTSTRAP_URL" \
  -c 'SELECT current_database(), session_user, current_user;'

执行一个 SQL 文件:

psql -X "$PG36_BOOTSTRAP_URL" \
  -v ON_ERROR_STOP=1 \
  -f setup.sql

-c 适合短小、可见的一次性观察;-f 让错误消息包含文件与行号,适合可审查脚本。ON_ERROR_STOP=1 要求 psql 遇到脚本错误后停止,避免第一步失败后继续执行一串建立在错误前提上的语句。

不要把多行复杂 SQL 塞进 shell 的 -c 参数:shell 引号、SQL 引号和变量展开叠在一起,很容易产生与屏幕看起来不同的实际输入。复杂内容放入版本控制的 .sql 文件,并在执行前查看差异。

交互会话内也可以执行文件:

\i setup.sql

但自动化与验收更适合从 shell 使用 -f,因为调用方可以读取退出码并保存标准输出、标准错误。

1.6.3 用 \o-A -t 保存输出

人读的表格与机器读的结果需要不同输出形式。

交互式保存随后产生的查询输出:

\o pg36-connection.txt
SELECT current_database(), current_user, pg_is_in_recovery();
\o

第二个不带文件名的 \o 恢复到终端输出。忘记恢复时,后续查询“没有输出”往往只是仍在写文件。

从 shell 生成机器友好的单值或逐行结果:

psql -X "$PG36_BOOTSTRAP_URL" \
  -A -t \
  -v ON_ERROR_STOP=1 \
  -c 'SELECT current_database();'
  • -A 使用不对齐输出,去掉表格边框;
  • -t 只输出元组,去掉列名与行数提示;
  • -X 避免个人 psqlrc 改写格式;
  • ON_ERROR_STOP 让失败产生可判断的非成功退出。

若有多列,显式选择分隔符和空值表示,或者直接输出 JSON;不要让下游脚本解析为人类排版的表格:

psql -X "$PG36_BOOTSTRAP_URL" -A -t -c "
SELECT jsonb_build_object(
  'database', current_database(),
  'user', current_user,
  'in_recovery', pg_is_in_recovery()
);"

保存输出不等于保存证据上下文。文件旁还应记录采集时间、客户端入口、服务端版本和命令来源,否则一行 false 很快会失去解释价值。

1.6.4 用 \qCtrl-C 安全退出与中断

正常退出:

\q

如果正在输入但尚未发送一条 SQL,Ctrl-C 会清空当前查询缓冲区并回到提示符。可以先用 \p 查看缓冲区内容,用 \r 主动清空:

\p
\r

前者显示尚未发送的查询,后者重置查询缓冲区。

如果服务器正在执行查询,Ctrl-C 会请求取消当前语句,而不是粗暴终止服务器进程。取消可能需要等待服务器到达可中断位置;网络中断时,客户端也未必能确认取消请求是否送达。

取消事务中的语句通常会让当前事务进入失败状态。此时后续 SQL 会收到“current transaction is aborted”,必须明确回滚:

ROLLBACK;

不要连续按键后在不知道状态的情况下继续操作。中断后立即执行:

SELECT
    current_database(),
    current_user,
    pg_is_in_recovery();

若查询能正常执行,说明连接仍可用且不在失败事务中;若连接已经断开,由 psql 明确重连后再重新采集上下文。

Ctrl-Z 只是把本地 psql 挂起,服务器连接和可能的事务仍然存在。它不是安全退出手段。遗留的 idle in transaction 会话可能长期持有快照和锁,是后续并发与膨胀问题的常见来源。

一张够用的生存卡

目标 命令
看当前连接 \conninfo
看数据库/模式/关系 \l+\dn+\d
看角色/扩展 \du+\dx
切换数据库 \c <database>,随后再次 \conninfo
执行短 SQL/脚本 shell 中 -c-f
脚本失败即停 -v ON_ERROR_STOP=1
保存交互输出 \o <file>,完成后 \o
输出机器可读单值 -X -A -t
取消/退出 Ctrl-C\q

本节验收

从一个新终端完成以下闭环:

  1. 用 URI 进入 postgres,执行 \conninfo
  2. \l+ 查看实例中的数据库,再用 \c postgres 明确重连;
  3. \dn+ 找到 public 与系统模式,用系统目录查询复核;
  4. 把当前数据库名以无表头单值形式保存到文件;
  5. 运行 SELECT pg_sleep(10);,用一次 Ctrl-C 取消;
  6. 执行上下文查询确认连接可用,最后用 \q 退出。

验收文件中数据库名必须精确为 postgres,且终端中没有遗留失败事务提示。1.7 创建 pg36_shop 后,再用同一组命令完成章级验收。

参考资料


上一节:Pigsty 的资源模型 · 返回本章目录 · 下一节:实战:建立 pg36_shop 地图与实验基线 · 查看全书目录 · 查看索引中心

1.7 实战:建立 `pg36_shop` 地图与实验基线

现在把前六节的地图落到一个真实对象上。本实验会在 L1 沙箱创建 pg36_shop 数据库、shop 模式和三个专用角色,随后从 PostgreSQL 与 Pigsty 两侧收集证据。

实验分为两个风险级别:

  • setup 与快照采集:R1·可逆变更,只创建以 pg36_ 命名的教学对象;
  • reset:sqlR2·破坏性演练,会删除整个 pg36_shop 数据库,只能在确认可销毁的 L1 中执行。

准备两个不含密码的连接 URI。将 <L1_HOST> 替换为实际域名或 IP;密码由 psql 询问或使用 ch02 将介绍的安全凭据机制:

export PG36_BOOTSTRAP_URL='postgresql://dbuser_dba@<L1_HOST>:5436/postgres?application_name=pg36-ch01-admin'
export PG36_SHOP_ADMIN_URL='postgresql://dbuser_dba@<L1_HOST>:5436/pg36_shop?application_name=pg36-ch01-admin'

这里使用 5436 default 服务,目的是跟随主库且绕过事务连接池执行管理脚本。若你的环境修改了 Pigsty 默认服务,请根据实际配置替换,不能照抄端口猜路径。

1.7.1 创建数据库、业务模式和最小角色

本章只建立权限骨架,不创建订单、商品或支付表:

角色 是否登录 责任
pg36_owner 拥有数据库和模式;迁移时由受控管理会话 SET ROLE 使用
pg36_app 应用运行角色;只获得 shop 中未来业务对象的读写权限
pg36_ro 只读角色;只获得 shop 中未来业务对象的读取权限

对象所有者使用 NOLOGIN,避免应用直接以所有者身份绕过授权边界。两个登录角色在本章故意不设置密码;这既避免在教程中分发固定密码,也使它们在默认密码认证规则下暂时无法远程登录。ch02 会为连接与凭据建立正式工作流。

下载或打开三份伴随实验文件:

  • setup.sql:幂等创建角色、数据库、模式和默认权限;
  • verify.sql:机器验证状态并输出摘要;
  • reset.sql:带确认口令的实验清理。

执行初始化:

psql -X "$PG36_BOOTSTRAP_URL" \
  -v ON_ERROR_STOP=1 \
  -f setup.sql

执行身份为 dbuser_dba 或等价的实验管理员;目标必须是 L1 的 postgres 数据库。脚本会:

  1. 仅在缺失时创建三个 pg36_ 角色,并收敛高风险属性;
  2. 仅在缺失时从 template0 创建 UTF-8 数据库;
  3. 创建由 pg36_owner 拥有的 shop 模式;
  4. 撤销 public 模式的公共建对象权限;
  5. 为两个运行角色授予模式使用权和未来对象的默认权限;
  6. pg36_shop 中的两个运行角色设置 pg_catalog, shop 搜索路径。

成功末尾应出现:

[setup] complete: roles intentionally have no password in this chapter

这条消息只证明脚本执行完毕,不能替代下一目的状态验证。

与 Pigsty 声明式配置对齐

SQL 能证明 PostgreSQL 对象机制,但 Pigsty 管理的长期环境还应把期望状态写入 inventory,避免下次自动化执行时出现配置漂移。最小声明可采用下面的结构;不要把实际明文密码直接写进公开配置:

pg_users:
  - { name: pg36_owner, login: false, pgbouncer: false, comment: pg36_shop object owner }
  - { name: pg36_app,   login: true,  pgbouncer: false, comment: pg36_shop runtime role }
  - { name: pg36_ro,    login: true,  pgbouncer: false, comment: pg36_shop read-only role }

pg_databases:
  - name: pg36_shop
    owner: pg36_owner
    encoding: UTF8
    pgbouncer: true
    schemas:
      - { name: shop, owner: pg36_owner }

本章先将 pgbouncer: false 用于尚无凭据的两个登录角色;数据库本身可以加入连接池。设置正式认证材料后,再把用户加入 PgBouncer。若在既有集群中应用声明,应先评审差异,然后使用:

cd ~/pigsty
bin/pgsql-user pg-meta pg36_owner
bin/pgsql-user pg-meta pg36_app
bin/pgsql-user pg-meta pg36_ro
bin/pgsql-db pg-meta pg36_shop

这些是 R1 操作,目标集群名不一定是 pg-meta。运行前用 ansible-inventory --graph 确认限制范围;若清单里已经存在同名但含义不同的对象,立即停止,不要让自动化强行“收敛”。

1.7.2 生成连接快照、对象树、服务拓扑与环境清单

建立证据目录:

mkdir -p evidence/ch01

连接快照

psql -X "$PG36_SHOP_ADMIN_URL" -A -t -v ON_ERROR_STOP=1 -c "
SELECT jsonb_build_object(
  'captured_at', clock_timestamp(),
  'database', current_database(),
  'session_user', session_user,
  'current_user', current_user,
  'server_addr', inet_server_addr(),
  'server_port', inet_server_port(),
  'backend_pid', pg_backend_pid(),
  'server_version', current_setting('server_version'),
  'search_path', current_setting('search_path'),
  'in_recovery', pg_is_in_recovery()
);" > evidence/ch01/connection.json

这里以管理员会话采集,因此 search_path 不会冒充 pg36_app 的角色级设置。角色设置由 verify.sql 直接查询目录验证。

对象树

psql -X "$PG36_SHOP_ADMIN_URL" > evidence/ch01/objects.txt <<'PSQL'
\pset pager off
\conninfo
\dn+
\du+ pg36_*
\d shop.*
PSQL

此时 shop 模式尚无业务关系,\d shop.* 返回“没有找到任何关系”是正确结果。对象树的目标是证明数据库、模式和角色边界,不是提前制造表。

服务拓扑与环境清单

在 Pigsty 管理节点执行:

cd ~/pigsty

{
  printf 'captured_at=%s\n' "$(date -Is)"
  printf 'node=%s\n' "$(hostname -f 2>/dev/null || hostname)"
  printf 'pigsty=%s\n' "$(git describe --tags --always 2>/dev/null || printf unknown)"
  printf '%s\n' '--- inventory ---'
  ansible-inventory --graph
  printf '%s\n' '--- runtime ---'
  pg list pg-meta
  printf '%s\n' '--- listening ports ---'
  ss -lnt | awk 'NR == 1 || $4 ~ /:(5432|5433|5434|5436|5438|6432)$/'
} > evidence/ch01/platform.txt

pg-meta 替换为实际集群名。ss 只证明端口正在监听,不证明后端角色、路由正确或 SQL 可用;它必须与 pg list 和连接快照合读。

版本清单

在客户端记录客户端与服务器版本:

{
  psql --version
  psql -X "$PG36_SHOP_ADMIN_URL" -A -t -c \
    "SELECT 'server=' || current_setting('server_version');"
} > evidence/ch01/versions.txt

客户端与服务端小版本不同不一定是错误,但必须留痕。涉及协议、元命令或版本特性的实验以实际版本为准。

1.7.3 建立 verify:state 与三档 reset

verify:state 不是“脚本没报错”的同义词。它从系统目录重新读取最终状态,检查对象所有者、角色、权限和数据库级搜索路径:

psql -X "$PG36_BOOTSTRAP_URL" \
  -v ON_ERROR_STOP=1 \
  -f verify.sql \
  | tee evidence/ch01/verify.txt

成功输出的值会因环境不同而变化,但必须包含:

status=ok
database=pg36_shop
database_owner=pg36_owner
schema=shop
schema_owner=pg36_owner
in_recovery=false

在备库或错误路由上执行时,脚本不应被“修到能过”;先回到 1.1 确认为什么管理连接没有进入可写主库。

本书使用三档复位,它们按影响范围命名,不代表都要在每章执行:

复位 影响范围 ch01 的实现 风险与使用条件
reset:sql 教学数据库、模式、角色和数据 删除 pg36_shop 与三个 pg36_ 角色 R2;仅限无保留价值的 L1
reset:cluster PostgreSQL 集群配置、成员和服务 本章不修改集群级状态,因此应为 no-op 后续章节按变更提供;不能用“重装集群”替代诊断
reset:host 整台实验主机 回到第 0 章重建 L1 R2;仅当主机基线已不可相信

执行 reset:sql 前必须同时满足:

  • 当前是明确标识的可销毁 L1;
  • pg36_shop 中没有需要保留的数据;
  • 三个 pg36_ 角色没有被其他数据库使用;
  • PG36_BOOTSTRAP_URL 指向预期集群的主库管理服务;
  • 已阅读 reset.sql,确认它没有被本地修改。

然后使用完整确认口令:

psql -X "$PG36_BOOTSTRAP_URL" \
  -v ON_ERROR_STOP=1 \
  -v confirm_reset=RESET_PG36_SHOP \
  -f reset.sql

脚本会先终止连接到 pg36_shop 的会话,再删除数据库与角色。这不是可回滚事务。未提供精确口令时脚本会拒绝执行。复位后重新运行 setup.sqlverify.sql,应得到新的数据库 OID;OID 改变正好说明它不能作为业务稳定标识。

1.7.4 验收:从 SQL 与 Pigsty 两侧指认同一对象

最后把所有名字放回一张表。下面是结构,不是要求实际值与示例相同:

层次 示例 证据来源
客户端入口 <L1_HOST>:5436 PG36_SHOP_ADMIN_URL\conninfo
平台服务 pg-meta-default Pigsty 服务定义、HAProxy 后端
Pigsty 集群 pg-meta inventory、pg list
Pigsty 实例 pg-meta-1 inventory、pg list
节点 <IP 或主机名> inventory、主机事实
PostgreSQL 后端 某个 pid、地址、5432 pg_backend_pid()inet_server_*()
PostgreSQL 数据库 pg36_shop current_database()pg_database
模式 shop pg_namespace\dn+
角色 pg36_ownerpg36_apppg36_ro pg_roles\du+

从 PostgreSQL 侧运行最终快照:

SELECT
    current_database() AS database_name,
    (
      SELECT oid
      FROM pg_catalog.pg_database
      WHERE datname = current_database()
    ) AS database_oid,
    inet_server_addr() AS server_addr,
    inet_server_port() AS server_port,
    pg_backend_pid() AS backend_pid,
    pg_is_in_recovery() AS in_recovery;

从 Pigsty 侧用 pg list <cluster> 找到同一 server_addr 对应的实例与角色,再检查 HAProxy 的 default 服务是否把 5436 路由到该实例的 PostgreSQL 5432。这就是“双侧指认”:平台名字最终落到 PostgreSQL 原生事实,SQL 地址也能反查回平台实体。

章级验收清单

只有以下各项全部成立,第 1 章才算完成:

  • verify.sql 输出 status=ok,并以非零退出码报告任何不满足项;
  • 能解释客户端访问 5436 而服务器接受端口显示 5432 的原因;
  • 能区分 pg-metapg-meta-1pg36_shopshoppg36_app
  • pg36_owner 不可登录,数据库与模式均由它拥有;
  • pg36_apppg36_ro 没有超级用户、建库、建角色、复制或绕过 RLS 权限;
  • 能从 SQL 判断当前实例是否处于恢复状态,并从 pg list 找到对应平台角色;
  • connection.jsonobjects.txtplatform.txtversions.txtverify.txt 已生成;
  • 证据文件不包含密码、SCRAM verifier、令牌或不必要的完整 inventory;
  • 知道三档 reset 的影响范围,但没有为了“练习”执行无关的集群或主机重建;
  • 复位演练后可以重新运行 setup → verify,且知道数据库 OID 会改变。

交付给 ch02

下一章将接收本章的五样东西:两个管理员连接 URI、三类角色、pg36_shop.shop 命名约定、证据目录和可运行的 setup/verify/reset 脚本。ch02 会给应用角色配置安全凭据与服务入口,把这些手工命令改造成可审查、可重跑的任务。

本章刻意没有创建业务表。下一步不是凭直觉开始堆 DDL,而是先把工具链和失败语义固定下来。

参考资料


上一节:最小 psql 生存卡 · 返回本章目录 · 下一章:手到擒来:psql 与可复现工作流 · 查看全书目录 · 查看索引中心

2 手到擒来:psql 与可复现工作流

会执行一条 SQL,不等于能可靠地完成一次数据库任务。真正可交付的操作必须知道自己连到了哪里,遇错立即停止,留下足够证据,允许安全重跑,并能证明失败没有留下半成品。本章把这些要求压缩成后续 34 章共同使用的最小工作流。

这里不会把 psql 写成命令手册,也不会假装一套脚本能消除所有变更风险。我们的目标更具体:把“登录服务器后临时敲几条命令”改造成一个输入明确、行为可审查、结果可验证、退出码可信的任务。

本章目标

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

  • 用 URI、service file 与 passfile 分离目标、行为和秘密,并理解连接参数的覆盖顺序;
  • 让提示符、application_name 与上下文快照共同暴露当前数据库、角色、端点和事务状态;
  • psql 元命令探索对象,同时回到系统目录验证其来源;
  • 编写遇错即停、退出码可信、变量引用安全、事务边界明确的 SQL 脚本;
  • 用确定性夹具、校验摘要与机器可读输出建立可复现输入;
  • 运行一个最小 pgbench 工作负载,只验证执行链路,不偷渡性能结论;
  • 完成一次可检查、可恢复、可验证的逻辑备份闭环;
  • 把上述动作组合成带证据包的可重跑任务,并证明错误注入不会留下半成品。

开始之前

本章承接 1.7 实战 创建的 pg36_shopshop 模式和三个角色。若环境尚未具备这些对象,先完成 ch01;若对象已经承载其他数据,不要用本章实验覆盖它们。

示例基线是 PostgreSQL 18.6 与 Pigsty v4.5。核心 psql 工作流适用于 PostgreSQL 14–18;COPY ... ON_ERRORREJECT_LIMIT 等版本敏感能力会单独标出。实验使用 L1 的 Pigsty default 服务(默认端口 5436)执行管理任务,因为它跟随主库并直连 PostgreSQL;这不是 PostgreSQL 标准端口,也不代表所有平台都必须这样命名。

本章提供的可下载资产位于:

本章主线

flowchart LR
  A["连接参数<br/>目标与凭据"] --> B["上下文快照<br/>确认落点"]
  B --> C["探索与取证<br/>人读 + 机读"]
  C --> D["可靠脚本<br/>失败即停"]
  D --> E["确定性数据<br/>校验摘要"]
  E --> F["最小负载<br/>验证链路"]
  F --> G["逻辑备份<br/>恢复验证"]
  G --> H["综合任务<br/>证据 + 复位"]

这条路径有意先解决“正确地执行”,再碰性能与备份。一个无法可靠停止、重跑和复位的实验,即使偶然得到漂亮结果,也没有教学或工程价值。

本章目录

2.1 可靠连接与上下文保护

本节把连接信息拆成非秘密目标、秘密凭据与会话行为,并建立所有写操作之前都要执行的上下文保护。

2.2 用 psql 探索与取证

本节区分交互式探索与自动化取证:前者追求快速理解,后者要求稳定字段、明确排序与可保存证据。

2.3 编写可靠 SQL 脚本

本节建立本书的脚本协议:-XON_ERROR_STOP、安全变量引用、显式事务边界、标准输出与错误输出分离。

2.4 输入、输出与确定性数据

本节让实验输入可重建、输出可比较,并解释 PostgreSQL 18 容错导入与早期版本工作流的差异。

2.5 最小 pgbench 工作负载

本节只证明工作负载能以确定输入完成预期事务数。硬件比较、容量模型与正式压测留给 ch26《胸有成竹:容量规划与压测基线》

2.6 最小逻辑备份闭环

本节不以“生成了一个文件”为成功,而以“能检查清单、恢复到隔离目标并验证状态”为闭环。

2.7 实战:把人工操作变成可重跑任务

本节把前六节组合成 task.sh all:创建夹具、验证状态、运行最小负载、注入语法错误并检查回滚。重置从不包含在默认路径中。

本章产物与验收

完成综合实验后,证据目录至少应包含:

文件 证明什么 不证明什么
manifest.txt 客户端版本、服务端版本、数据库、脚本哈希与采集时间 不证明运行期间没有外部噪声
verify.txt 夹具行数、边界值和校验和符合预期 不证明业务模型正确
pgbench.txt 指定脚本完成 20/20 个事务且无失败 不证明环境具有某个生产 TPS
broken.status 错误脚本返回 3,标记行数量为 0 不证明所有失败模式都会自动回滚
*.stderr NOTICE、警告和错误没有被标准输出吞掉 空文件不等于服务端日志无事件

章节验收不是背命令,而是能解释以下问题:

  1. 为什么 service file 适合保存端点,却不应默认保存密码?
  2. 为什么漂亮的提示符不能替代执行前的 SQL 上下文断言?
  3. 为什么没有设置 ON_ERROR_STOPpsql -f 可能在 SQL 报错后仍继续?
  4. 幂等为什么不等于“所有语句前都加 IF EXISTS”?
  5. 为什么固定随机种子仍不能固定延迟和 TPS?
  6. 为什么 dump 文件只有在恢复并验证后才构成一次有效演练?

下一章会把本章生成的确定性夹具当作输入样本,但不会把它误当成业务模型。我们将从业务语言提取实体、事件与不变量,开始 ch03《正本清源:从业务规则到关系模型》

参考资料


上一章:盲人摸象:PostgreSQL 与 Pigsty 全局地图 · 返回上卷导读 · 下一章:正本清源:从业务规则到关系模型 · 查看全书目录 · 查看索引中心

2.1 可靠连接与上下文保护

第 1 章已经说明:连接串表达客户端意图,SQL 快照才是服务端证据。本节把这条原则固化成一个可重复入口。连接参数负责“去哪里”,凭据负责“我是谁”,上下文保护负责“这里是否允许执行这项任务”;三者不能因为都出现在一次连接里就混成一件事。

2.1.1 连接 URI、服务文件与环境变量

libpq 客户端——包括 psqlpg_dumppg_restorepgbench——共享一套连接参数。参数可以来自命令行、连接 URI、service file、环境变量与内置默认值。工程上的关键不是选出唯一写法,而是让覆盖关系和秘密边界可见。

载体 适合保存 不适合保存 典型用途
URI / keyword string 本次调用的明确覆盖项 长期明文密码 临时交互、日志中可脱敏的任务参数
service file 主机、端口、数据库、用户、超时与会话选项 默认不放密码 给稳定端点一个可迁移名称
passfile 按主机、端口、数据库、用户匹配的密码 非秘密连接配置 非交互客户端认证
PG* 环境变量 进程级默认值、service file 路径 PGPASSWORD 等可被继承或观察的秘密 CI 任务与短生命周期 shell
命令行选项 本次运行必须显式覆盖的参数 会进入 shell 历史的密码 -d-v-f-X 等执行契约

给端点命名

下载连接服务文件示例,复制到当前用户的私有路径并替换 <L1_HOST>

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

然后设置:

chmod 600 "$PWD/pg_service.conf"
export PGSERVICEFILE="$PWD/pg_service.conf"
psql -X "service=pg36-admin"

pg36-admin 是 libpq service 名称,不是 Pigsty 服务名。这里把它映射到 Pigsty default 服务的默认端口 5436:HAProxy 跟随当前主库,并把连接直接交给 PostgreSQL。若平台修改过服务定义,以实际配置和 ch01 的端点快照为准。

service file 使用 INI 语法。用户级默认路径是 ~/.pg_service.confPGSERVICEFILE 可以指定另一文件。显式连接参数会覆盖 service file 中的同名参数,service file 的值又会覆盖相应环境变量。例如:

PGPORT=9999 psql -X \
  "service=pg36-admin port=5436 application_name=pg36-override"

最终端口是 URI 中显式给出的 5436,而不是环境变量的 9999。不要靠记忆猜覆盖结果;连接后用 \conninfo 和 SQL 快照验证。

把秘密留在秘密载体

不要把密码写入本书配置、Git、命令行 URI 或 PGPASSWORD。Unix 上的 passfile 默认是 ~/.pgpass,也可由 PGPASSFILE 指定;每行格式是:

hostname:port:database:username:password

文件权限必须限制为 0600 或更严格,否则 libpq 会忽略它。匹配按从上到下的第一条记录决定,过早出现的 * 通配行可能把错误凭据应用到意外目标。密码中的 :\ 还要按 passfile 规则转义。

自动化任务使用 -w--no-password):

psql -X -w "service=pg36-admin" -c 'SELECT current_database();'

它不会弹出交互式密码提示;若非交互凭据缺失,任务会立即失败。这比 CI 卡在不可见的密码提示上更可靠。交互探索时可以去掉 -w,让客户端主动询问。

service file 与 passfile 解决的是客户端配置管理,不是权限设计。角色授权、SCRAM、 证书和 pg_hba.conf 会在 ch23《固若金汤:认证、授权与数据安全》 系统展开。

2.1.2 application_name、提示符与上下文快照

可靠连接需要同时照顾人、服务器和证据系统:

  • application_name 让服务端活动视图与日志知道“这条连接自称在做什么”;
  • 提示符让操作者持续看见用户、主机、端口、数据库和事务状态;
  • 上下文快照用服务端 SQL 证明实际数据库、角色、后端和读写状态。

三者互补,但都不是安全身份。客户端可以伪造 application_name,提示符可以被本地配置改坏,快照也只证明采集时刻的会话状态。

可观察的连接标签

service file 已经设置 application_name=pg36-ch02。也可按任务覆盖:

psql -X \
  "service=pg36-admin application_name=pg36-ch02-inspect"

在另一条有权查看活动会话的连接中验证:

SELECT
    pid,
    usename,
    datname,
    application_name,
    client_addr,
    backend_start,
    state
FROM pg_catalog.pg_stat_activity
WHERE application_name LIKE 'pg36-ch02%'
ORDER BY backend_start, pid;

标签应包含系统或任务名,而不是工单中的秘密、客户数据或完整 SQL。后续监控会使用它聚合会话,但不会把它当作授权条件。

让提示符暴露危险上下文

下载psqlrc 示例,其中核心设置是:

\set PROMPT1 '%n@%m:%>/%/%R%x%# '
\set PROMPT2 '%n@%m:%>/%/%R%x%# '

常用转义含义如下:

转义 显示内容 操作价值
%n 数据库用户名 暴露登录角色
%m 服务器主机名(去域后缀) 暴露网络目标
%> 端口 区分实例、连接池与服务入口
%/ 当前数据库 \c 后立即可见
%R 提示符状态 区分新语句、续行等输入状态
%x 事务状态 暴露空闲、事务中或失败事务
%# 超级用户 #,普通用户 > 给高权限会话醒目标记

本书的可复现实验仍统一使用 psql -X,因为 -X 会跳过用户与系统 psqlrc,避免个人格式、变量或自动 SQL 改变脚本行为。交互会话可以使用提示符增强,人读体验与机器复现不应争用同一隐含配置。

进入会话后的标准快照

连接后先执行:

\conninfo

SELECT
    current_database()                    AS database_name,
    session_user                          AS authenticated_as,
    current_user                          AS effective_as,
    current_setting('search_path')        AS configured_path,
    current_schemas(false)                AS effective_path,
    inet_server_addr()                    AS server_addr,
    inet_server_port()                    AS server_port,
    pg_backend_pid()                      AS backend_pid,
    pg_is_in_recovery()                   AS in_recovery,
    current_setting('transaction_read_only')::boolean
                                             AS transaction_read_only,
    current_setting('application_name')   AS application_name;

\conninfo 展示客户端已知的连接信息;SQL 列来自当前 PostgreSQL 后端。经过 Pigsty 5436 进入后,\conninfo 会保留客户端访问的服务入口,而 inet_server_port() 通常显示最后一跳 PostgreSQL 的 5432。把两侧一起保存,才能重建连接路径。

同一快照不要只拍一次。任务开始前用于阻断错误目标,任务结束后用于证明结果属于哪个会话;长任务还应在证据中记录开始和结束时间。

2.1.3 防止连错库、用错角色和改错模式

颜色鲜艳的提示符只能提醒人,不能保护无人值守任务。真正的保护必须在第一条有副作用的 SQL 之前验证数据库、恢复状态、有效角色与搜索路径,并在不符合预期时产生可信的非零退出码。

本章的上下文保护脚本按以下顺序执行:

  1. 默认期望数据库为 pg36_shop、对象所有者为 pg36_owner
  2. 验证当前数据库正确且实例不在恢复;
  3. 才执行 SET ROLE pg36_owner
  4. 设置并验证 search_path = pg_catalog, shop
  5. 输出一行可保存的上下文摘要。

关键片段是:

\set ON_ERROR_STOP on

SELECT
    current_database() = :'expected_db' AS database_ok,
    NOT pg_is_in_recovery()             AS writable_instance
\gset

\if :database_ok
\else
  \warn '[context] refused: unexpected database'
  DO $guard$
  BEGIN
      RAISE EXCEPTION 'context guard rejected the current database';
  END
  $guard$;
\endif

\ifpsql 的客户端条件,不是 PL/pgSQL。查询通过 \gset 把一行结果写入 psql 变量;不满足条件时,固定的 DO 块抛出服务端异常,ON_ERROR_STOP 再让脚本以退出码 3 停止。

这里特意不写 \quit 3:PostgreSQL 18 的 psql\quit 不接受自定义状态码,多余参数会使该元命令被忽略。保护脚本若只打印警告而没有可靠失败机制,最危险的结果就是“看起来拒绝,实际上继续”。

为什么先验数据库,再切换角色

如果先以高权限角色执行 SET ROLE,再发现连接到了错误数据库,权限提升动作已经发生。当前示例先执行两个只读断言,确认目标可写且数据库名称正确,之后才切换到无登录对象所有者。任何一步失败都由 ON_ERROR_STOP 截断。

角色验证不能只看 session_user

SELECT session_user, current_user;

session_user 证明谁完成认证,current_user 证明此刻权限检查采用谁。对象迁移通常要求前者是受控管理员、后者是专用 owner;运行时查询则不应随意成为 owner。

搜索路径要验证有效结果

脚本显式设置:

SET search_path = pg_catalog, shop;

然后比较:

SELECT current_schemas(false)
       = ARRAY['pg_catalog', 'shop']::name[] AS path_ok;

因为 pg_catalog 被显式写入路径,即使 current_schemas(false) 的参数表示不额外包含隐式模式,结果仍会保留它。不要根据函数参数名称想当然地断言结果;在目标版本上观察实际数组。

安全敏感或机器生成的 SQL 仍应显式限定对象名。受控 search_path 降低误解析风险,但不把 shop.orders 写成 orders 的所有上下文都变得安全。

负向验证

保护脚本必须证明“错误目标会失败”。先对正确连接运行:

psql -X -w "service=pg36-admin" \
  -v ON_ERROR_STOP=1 \
  -f context.sql

预期看到类似:

[context] database=pg36_shop session_user=dbuser_dba \
current_user=pg36_owner search_path=pg_catalog, shop

再显式覆盖到 postgres 数据库:

set +e
psql -X -w \
  "service=pg36-admin dbname=postgres" \
  -v ON_ERROR_STOP=1 \
  -f context.sql
status=$?
set -e
test "$status" -eq 3

第二次运行应在任何写操作之前返回 3。若返回 0,不要继续后续章节;先修复保护脚本或调用方式。

本节验收

  • service file 不含密码,passfile 权限符合要求;
  • psql -X -w "service=pg36-admin" -c '\conninfo' 可以非交互完成;
  • 能同时保存客户端入口与服务端后端快照;
  • 错误数据库测试返回 3
  • 能解释为什么 application_name、提示符和 SQL 断言都不能互相替代。

参考资料


返回本章目录 · 下一节:用 psql 探索与取证 · 查看全书目录 · 查看索引中心

2.2 用 psql 探索与取证

psql 同时服务两种不同任务:人在终端里快速理解数据库,以及脚本稳定采集证据。交互探索可以接受对齐表格、分页与版本相关的展示;自动化取证则需要明确字段、排序、格式和退出码。把两种输出混用,是很多脆弱运维脚本的起点。

本节操作均为 R0·观察。先完成 2.1 的上下文检查,再在 pg36_shop 中执行。

2.2.1 对象、权限和会话元命令

psql 元命令以反斜杠开头,由客户端解释,不会作为 SQL 发给服务器。它们最适合回答“这里大概有什么”和“下一步该查哪个目录”,不应被误认为独立于 PostgreSQL 的另一套元数据。

一张够用的探索表

元命令 主要问题 推荐用法 常见误读
\conninfo 当前客户端连接参数是什么? 每次进入会话先看 只显示客户端视角,不替代服务器快照
\l+ 有哪些数据库及其属性? 观察 owner、编码、权限和大小 大小统计可能慢,也不等于磁盘总占用
\dn+ 有哪些模式,谁拥有? 确认 shop 与权限 模式不是数据库
\dt+ shop.* shop 中有哪些普通表? 用模式限定模式匹配 不会列出所有关系类型
\d+ shop.ch02_fixture 一个关系如何定义? 看列、索引、约束、存储等 输出格式会随版本变化
\df+ shop.* 有哪些函数? 限定模式和名称模式 函数重载需要参数签名区分
\du+ 有哪些角色及属性? 识别 LOGIN、SUPERUSER 等角色属性 不完整呈现所有成员关系语义
\dp shop.* / \z shop.* 表、序列等对象的 ACL 是什么? 快速找显式授权 空 ACL 与默认权限不能只看字面猜测
\encoding 当前客户端编码是什么? 与服务端编码一并记录 客户端编码不等于数据库编码

命令中的 shop.*psql 模式匹配,不是 shell glob。仍建议放在交互会话内输入,或在 shell 中用单引号保护:

psql -X "service=pg36-admin" \
  -c '\dt+ shop.*'

若对象名包含大写字母、空格或特殊字符,模式规则与 SQL 标识符引用会变得更难读。这是本书坚持小写 snake_case 标识符的一个工程原因,而不是 PostgreSQL 的强制限制。

权限需要从三个角度看

shop.ch02_fixture 为例:

\d+ shop.ch02_fixture
\dp shop.ch02_fixture
\du+ pg36_app

它们分别展示对象定义、对象 ACL 与角色属性。实际能否执行某项操作,还可能受对象所有权、角色成员关系、模式 USAGE、行级安全和列级权限影响。最终判断应使用权限函数验证具体动作:

SELECT
    has_schema_privilege('pg36_app', 'shop', 'USAGE') AS schema_usage,
    has_table_privilege(
        'pg36_app',
        'shop.ch02_fixture',
        'SELECT'
    ) AS can_select,
    has_table_privilege(
        'pg36_ro',
        'shop.ch02_fixture',
        'UPDATE'
    ) AS ro_can_update;

期望前两项为 true,最后一项为 false。这仍不是“模拟一次完整 SQL”的万能授权检查,但比肉眼解释 ACL 字符串更适合验收。

2.2.2 扩展显示、分页、计时与查询缓冲区

探索效率常常取决于“怎样看”,而不是“还能背多少元命令”。以下设置只影响当前 psql 客户端:

\x auto
\pset null '∅'
\pset pager on
\timing on
  • \x auto 在结果太宽时自动切换为逐字段显示;
  • 自定义空值标记能区分 SQL NULL 与空字符串;
  • pager 便于人在终端阅读长结果;
  • \timing 显示客户端观察到的每条语句耗时。

这些设置不适合原样带进自动化。分页器可能等待键盘输入,装饰性空值会污染机器解析,客户端计时还包含网络传输与结果渲染。脚本应显式使用:

\pset pager off
\pset tuples_only on
\pset format unaligned

或者直接采用命令行 --no-align --tuples-only

查询缓冲区是交互式安全带

psql 会把尚未发送的 SQL 保存在查询缓冲区。常用动作是:

元命令 动作 何时使用
\p 打印当前缓冲区 执行前复核长 SQL
\e 用编辑器修改缓冲区 多行查询比终端编辑更安全
\r 清空缓冲区 放弃误输入且尚未发送的 SQL
\g 发送缓冲区 明确执行
\gx 发送并用扩展格式显示 宽结果的一次性查看
\gdesc 只描述结果列,不执行结果获取 预览查询输出形状

例如先写一个查询但不输入分号:

SELECT fixture_id, sku, amount
FROM shop.ch02_fixture
ORDER BY fixture_id
LIMIT 5

随后依次输入:

\p
\gdesc
\gx

\gdesc 可以检查结果列的名称和类型;它不是通用 SQL 干运行工具,更不能证明一个写语句没有副作用。不要把“描述结果形状”扩展成“可以安全预演任何 SQL”。

\gexec 会把查询结果逐单元格当作 SQL 执行,后续章节偶尔用它创建可计算的 DDL。它的默认风险很高:执行顺序取决于结果排序,生成内容按字面发送,单条失败后是否继续又受 ON_ERROR_STOP 控制。使用前必须先把同一生成查询以普通 \g 输出审查,再在受控事务或隔离环境执行。

计时、重复观察与取消

\timing on
SELECT count(*) FROM shop.ch02_fixture;
\watch 2

\watch 2 每两秒重复当前查询,适合短时间观察计数或活动状态;按 Ctrl-C 取消当前查询或 watch 循环,而不是关闭整个终端。执行写语句前要先清空缓冲区,避免把它误交给 \watch

\timing 是快速反馈,不是基准测试。第一次执行的缓存状态、返回行数、终端渲染、网络与并发噪声都会改变结果。第 2.5 节会建立最小负载协议,ch26 再讨论正式测量。

发生错误后可输入:

\errverbose

它会重新显示最近一个服务端错误的完整诊断,包括 SQLSTATE、DETAIL、HINT 和错误位置(若可用)。保存证据时应同时保留标准错误,而不是只截终端最后一行。

2.2.3 元命令与系统目录查询互相验证

元命令通常在内部查询 pg_catalog。用 psql -E 启动,或在会话中设置:

\set ECHO_HIDDEN on
\d+ shop.ch02_fixture

psql 会打印它为当前服务器版本生成的目录查询。这是学习系统目录的好入口,也揭示一个重要事实:\d 的展示和内部 SQL 都可能随 PostgreSQL 版本变化,不应被 shell 脚本按列位置解析。

用目录查询复核对象

下面的查询稳定地列出本章夹具的用户列:

SELECT
    a.attnum                                      AS ordinal,
    a.attname                                     AS column_name,
    pg_catalog.format_type(a.atttypid, a.atttypmod)
                                                   AS data_type,
    a.attnotnull                                  AS not_null
FROM pg_catalog.pg_attribute AS a
WHERE a.attrelid = 'shop.ch02_fixture'::regclass
  AND a.attnum > 0
  AND NOT a.attisdropped
ORDER BY a.attnum;

\d+ 相比,它的优势不是更“原生”,而是调用方明确选择了字段、含义和顺序。regclass 转换还能在对象不存在或解析错误时直接失败;如果希望“对象缺失返回 NULL”,则用 to_regclass('shop.ch02_fixture')

再复核 owner 与关系类型:

SELECT
    n.nspname                                  AS schema_name,
    c.relname                                  AS relation_name,
    c.relkind,
    pg_catalog.pg_get_userbyid(c.relowner)     AS owner
FROM pg_catalog.pg_class AS c
JOIN pg_catalog.pg_namespace AS n
  ON n.oid = c.relnamespace
WHERE n.nspname = 'shop'
  AND c.relname = 'ch02_fixture';

pg_catalog 暴露 PostgreSQL 的完整内部元数据,字段会随版本演进;information_schema 提供更标准化、通常受当前用户可见性过滤的视图,但不会覆盖全部 PostgreSQL 特性。跨数据库工具优先考虑后者,PostgreSQL 运维与深度取证通常需要前者。

人读输出与机器证据分开

交互探索:

psql -X "service=pg36-admin" \
  -c '\d+ shop.ch02_fixture'

机器采集:

mkdir -p evidence/ch02
psql -X -w "service=pg36-admin" \
  --set=ON_ERROR_STOP=1 \
  --csv \
  --command="
    SELECT a.attnum, a.attname,
           pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
           a.attnotnull
    FROM pg_catalog.pg_attribute AS a
    WHERE a.attrelid = 'shop.ch02_fixture'::regclass
      AND a.attnum > 0
      AND NOT a.attisdropped
    ORDER BY a.attnum;
  " > evidence/ch02/columns.csv

机器输出应有固定列、显式排序和失败即停;文件名、采集时间、连接上下文与版本则写入清单。CSV 解决字段引用,不会自动赋予字段长期兼容承诺。

本节验收

  • 能用元命令找到 shop.ch02_fixture、owner 和 ACL;
  • 能用 has_*_privilege 验证 pg36_apppg36_ro 的实际权限;
  • 能解释 \timing 为什么不是正式基准;
  • 能用 -E 找到元命令背后的目录查询;
  • 自动化证据不解析 \d 的人读表格,而是查询明确的目录字段。

参考资料


上一节:可靠连接与上下文保护 · 返回本章目录 · 下一节:编写可靠 SQL 脚本 · 查看全书目录 · 查看索引中心

2.3 编写可靠 SQL 脚本

可靠脚本不是“把终端历史保存成 .sql”。它必须定义输入,验证上下文,遇错停止,选择事务边界,区分可重跑与可回退,并把结果传给调用者。这里建立的约定会贯穿全书实验。

2.3.1 ON_ERROR_STOP、退出码与失败即停

psql 默认面向交互使用:一条 SQL 失败后,它通常报告错误并继续读取后续输入。对人来说便于修正,对自动化来说却可能把“步骤二失败、步骤三成功”误报为任务完成。

所有本书脚本都在文件内设置:

\set ON_ERROR_STOP on

调用方仍显式传入:

psql -X -w \
  "service=pg36-admin" \
  --set=ON_ERROR_STOP=1 \
  --file=setup.sql

双重设置不是为了炫技:文件自带安全默认,调用方又表明自己依赖失败即停语义。-X 去掉隐含 psqlrc-w 禁止无人值守任务等待密码。

四类退出状态

PostgreSQL 18 的 psql 约定:

状态码 含义 调用方应怎样解释
0 正常完成 仍需执行状态验证,不能只看返回码
1 psql 自身致命错误,如文件不存在 先检查客户端输入与运行环境
2 非交互会话的服务器连接中断 状态未知,先取证再决定是否重跑
3 脚本内发生错误,且启用了 ON_ERROR_STOP 按预期中止;检查事务是否回滚

状态码 3 依赖 ON_ERROR_STOP。没有它时,脚本可能在服务端报错后继续,最终甚至返回 0。因此不能用 grep ERROR 代替退出码,也不能只看退出码而省略状态验证。

Shell 中应保留原始状态:

set +e
psql -X -w \
  "service=pg36-admin" \
  -v ON_ERROR_STOP=1 \
  -f broken.sql \
  >broken.stdout \
  2>broken.stderr
status=$?
set -e

printf 'exit_code=%s\n' "$status"
test "$status" -eq 3

不要写成:

psql ... | tee task.log

若 shell 未启用 pipefail,管道状态可能来自成功的 tee,从而吞掉 psql 失败。可以启用 set -o pipefail,或像综合任务那样分别重定向标准输出与标准错误。

失败即停不等于原子回滚

ON_ERROR_STOP 只控制客户端是否继续发送后续命令,不会自动回滚之前已经提交的语句。若每条语句都处于自动提交模式,第一条 INSERT 成功、第二条语法错误时,第一条仍可能永久存在。

对可放进同一事务的脚本,使用:

psql -X -w \
  --single-transaction \
  "service=pg36-admin" \
  -v ON_ERROR_STOP=1 \
  -f broken.sql

--single-transaction-1)会在所有 -c/-f 输入之前发送 BEGIN,成功后 COMMIT,失败且启用 ON_ERROR_STOPROLLBACK。本章故障注入正是用它证明标记行数量保持为零。

并非所有命令都允许在事务块内执行。CREATE DATABASEVACUUMCREATE INDEX CONCURRENTLY 等动作需要单独设计阶段、前置断言与补偿路径。遇到这类命令,不能为了追求“一个事务”而忽略 PostgreSQL 的语义。

2.3.2 变量、条件、包含文件与事务包装

psql 变量是客户端文本替换机制,不是服务端绑定参数。正确引用方式取决于变量代表“值”还是“标识符”:

psql -X "service=pg36-admin" \
  -v expected_db=pg36_shop \
  -v owner_role=pg36_owner \
  -f context.sql

脚本内:

SELECT current_database() = :'expected_db';
SET ROLE :"owner_role";
写法 语义 例子展开 安全边界
:'name' SQL 字符串字面量 'pg36_shop' psql 正确引用值
:"name" SQL 标识符 "pg36_owner" psql 正确引用对象或角色名
:name 原样文本替换 pg36_shop 只适用于完全受控的 SQL 片段

不要把用户输入拼进原样变量:

-- 危险:变量可改变 SQL 结构
SELECT * FROM shop.ch02_fixture WHERE fixture_id = :raw_input;

应用程序应使用驱动的绑定参数;psql 脚本至少用 :'value' 后再由服务端转换为目标类型:

SELECT *
FROM shop.ch02_fixture
WHERE fixture_id = :'fixture_id'::integer;

默认值与客户端条件

检测变量是否存在:

\if :{?expected_db}
\else
  \set expected_db pg36_shop
\endif

\if 接受可以解释为布尔值的结果,未执行分支中的 SQL 不会发送给服务器。它适合控制脚本装配,不应承担复杂业务逻辑。需要数据库事务、异常和类型系统时,使用 SQL 或 PL/pgSQL。

相对包含保证可搬迁

\ir context.sql

\ir\include_relative)相对于当前脚本所在目录寻找文件;\i 通常相对于 psql 的当前工作目录。一个从任意目录调用的实验包,应优先用 \ir 组织内部依赖。

本章文件关系是:

setup.sql ─┐
verify.sql ├──> context.sql
broken.sql ┘

每个入口都独立设置 ON_ERROR_STOP,再包含同一上下文保护,避免复制三份逐渐漂移的断言。

两种事务包装

文件内部显式包装:

\set ON_ERROR_STOP on
BEGIN;
-- 一组允许在事务块内的变更
COMMIT;

调用方包装:

psql -X -1 -v ON_ERROR_STOP=1 -f task.sql "service=pg36-admin"

前者让事务意图跟随文件,后者便于对故障注入或多个 -f 输入统一包裹。不要混用嵌套 BEGIN 来制造虚假的双重保险;PostgreSQL 没有普通嵌套事务,只有保存点。若脚本本身控制事务,就不再额外传 -1

一旦脚本主动执行 COMMIT\connect 或事务块外命令,调用方就不能再假设 -1 提供全局原子性。事务边界必须是任务接口的一部分,而不是隐藏实现。

2.3.3 幂等、重入与执行前预览

三个常被混用的目标需要分开:

  • 幂等:对同一起点重复执行,最终状态不因执行次数改变;
  • 可重入:上次在某个中间点失败后,能识别现状并安全继续或重新开始;
  • 可回退:有明确动作恢复到先前状态,且已经验证其适用范围。

一条 CREATE TABLE IF NOT EXISTS 只能避免“同名关系已经存在”的错误,并不证明现有表的列、类型、约束和 owner 正确。若错误对象占用了名称,它反而会掩盖漂移。

本章 setup.sql 采用“收敛 + 断言”:

CREATE TABLE IF NOT EXISTS shop.ch02_fixture (...);

DO $shape_guard$
BEGIN
    -- 从 pg_attribute 计算实际列形状;
    -- 若与期望数组不同,RAISE EXCEPTION。
END
$shape_guard$;

TRUNCATE TABLE shop.ch02_fixture;
INSERT INTO shop.ch02_fixture ...

这使脚本在形状正确时可以重复生成同一夹具,在形状漂移时失败,而不是偷偷接受未知对象。TRUNCATE 会删除本章夹具的现有行,因此整个 setup 是 R1·可逆变更,只允许作用于明确的教学表。

SQL 没有通用 dry-run

可靠预览应针对动作设计:

动作 可用预览 局限
目录变更 查询当前定义并生成计划清单 清单正确不代表执行时没有并发变化
UPDATE / DELETE 用同一谓词先 SELECT 主键、数量和样本 预览与执行间可能发生状态变化
事务性 DDL 在隔离环境或 BEGIN 后执行再 ROLLBACK 锁、序列、外部副作用等不一定完全消失
查询 EXPLAIN 查看计划 某些函数在规划期仍可能执行;不证明结果正确
生成式 DDL 先输出生成 SQL,再人工审查后 \gexec 审查和执行之间仍需控制漂移

“先 BEGIN,最后 ROLLBACK”不是万能模拟器。序列值不会因事务回滚自动收回,通知可能在提交时发送,外部程序和远程系统更有自己的事务边界。正式变更应在预生产或可销毁克隆中演练,而不是在生产上借 ROLLBACK 试胆量。

让计划与应用分阶段

一个成熟任务通常分为:

  1. inspect:只读采集现状;
  2. plan:根据现状生成明确变更集合;
  3. apply:再次检查前置条件后执行;
  4. verify:独立查询目标状态;
  5. resetrollback:只处理任务拥有的对象。

本章的规模很小,setup 内部合并了 plan 与 apply,但仍保留形状断言;下卷涉及切换、备份和事故处理时会把阶段拆得更细。

2.3.4 日志、清单与机器可读结果

一个任务至少有四类输出:

输出 受众 推荐格式
进度与人读结果 操作者 对齐文本,保留上下文
错误与警告 调用方、排障者 独立 stderr,保留 SQLSTATE 与位置
状态摘要 自动验收 key=value、CSV 或 JSON
运行清单 审计与复现 时间、版本、端点名、脚本哈希、参数和退出码

不要把所有内容重定向到一个文件后再靠正则猜哪一行是结果。本章综合任务分别生成:

manifest.txt
setup.stdout
setup.stderr
verify.txt
verify.stderr
pgbench.txt
pgbench.stderr
broken.status
broken.stdout
broken.stderr

机器输出要主动收窄

最简单的单值:

row_count="$(
  psql -X -w "service=pg36-admin" \
    -v ON_ERROR_STOP=1 \
    --tuples-only \
    --no-align \
    -c 'SELECT count(*) FROM shop.ch02_fixture'
)"
test "$row_count" = "100"

多列结果使用:

psql -X -w "service=pg36-admin" \
  -v ON_ERROR_STOP=1 \
  --csv \
  -c '
    SELECT fixture_id, sku, amount
    FROM shop.ch02_fixture
    ORDER BY fixture_id
  ' >fixture.csv

无论哪种格式,都要显式 ORDER BY。关系结果没有默认顺序;一次输出“碰巧稳定”不能成为校验依据。

清单记录复现所需条件

至少保存:

date -u +%Y-%m-%dT%H:%M:%SZ
psql --version
pgbench --version
sha256sum context.sql setup.sql verify.sql workload.sql

再从服务端记录:

SELECT current_setting('server_version');
SELECT current_database(), session_user, pg_is_in_recovery();

只写 PostgreSQL 18 不够:客户端与服务器可以是不同版本,端点也可能经过 Pigsty 服务路由。清单应保存 service 名称和脱敏后的连接上下文,不保存密码或完整 passfile。

若 Shell 开启 set -x,展开后的连接 URI、变量和命令可能进入日志。处理秘密前应关闭跟踪,或者从设计上确保命令行根本不含秘密。

本节验收

  • 任意 SQL 错误都会使脚本停止并返回非零;
  • 能解释状态码 123 的差异;
  • 值变量使用 :'name',标识符变量使用 :"name"
  • 内部文件使用 \ir,不依赖调用者当前目录;
  • setup 重跑得到相同状态,形状漂移则明确失败;
  • 标准输出、标准错误、状态摘要与运行清单彼此分离。

参考资料


上一节:用 psql 探索与取证 · 返回本章目录 · 下一节:输入、输出与确定性数据 · 查看全书目录 · 查看索引中心

2.4 输入、输出与确定性数据

可复现实验需要确定的输入,也需要能被另一工具重新读取的输出。这里先解决小型数据交换和教学夹具;大规模装载、在线迁移、外部表和生产数据管道会在各自章节展开。

2.4.1 COPY\copy 的权限和执行边界

COPY 是 PostgreSQL SQL 命令,\copypsql 元命令。两者可以传输相同数据格式,但文件由哪台机器、哪个操作系统用户读写完全不同。

写法 文件所在位置 文件访问身份 数据通道 典型用途
COPY ... TO '/path/file' 数据库服务器 PostgreSQL 服务进程用户 服务端直接访问文件 受控服务器侧批量作业
COPY ... TO STDOUT 无固定文件 客户端接收 PostgreSQL 连接 应用或工具流式处理
\copy ... TO 'file' psql 客户端 当前 Linux 用户 客户端发起 COPY ... STDOUT 开发机导入导出、小型迁移

服务端文件版 COPY 需要超级用户,或 pg_read_server_filespg_write_server_filespg_execute_server_program 等高权限角色;路径从数据库服务器视角解析。不要为了方便给应用角色授予这些权限,它们可能读写数据库服务账号可访问的任意文件。

\copy 不需要服务端文件角色,因为 psql 自己打开本地文件,再通过标准输入/输出传输。导出本章夹具:

mkdir -p evidence/ch02
psql -X -w "service=pg36-admin" \
  -v ON_ERROR_STOP=1 <<'PSQL'
\copy (SELECT fixture_id, sku, label, amount, payload FROM shop.ch02_fixture ORDER BY fixture_id) TO 'evidence/ch02/fixture.csv' WITH (FORMAT csv, HEADER true, NULL '\N')
PSQL

这里的相对路径属于运行 psql 的客户端当前目录,不是 L1 数据库节点的 $PGDATA\copy 对整行参数采用自己的解析规则,命令必须写在一条逻辑行内,也不进行普通 psql 变量替换;动态文件路径更适合由受控 Shell 生成完整命令,且必须正确处理空格与引号。

服务器侧 COPY PROGRAM 会以 PostgreSQL 服务账号启动命令。即使当前角色有权使用,也不能把不可信输入拼进命令字符串;Shell 元字符可能升级成服务器命令执行。第 2 章不使用它。

长时间 COPY 的进度可以从另一会话观察:

SELECT
    pid,
    datname,
    relid::regclass AS relation,
    command,
    type,
    bytes_processed,
    tuples_processed,
    tuples_excluded
FROM pg_catalog.pg_stat_progress_copy
ORDER BY pid;

视图中的计数是运行中证据,不替代完成后的行数、边界值和业务校验。

2.4.2 CSV、文本与错误隔离

PostgreSQL COPY 支持 text、CSV 和 binary。选择标准不是“哪个最快”:

格式 优势 风险与限制
text PostgreSQL 原生、转义明确、适合工具链 不是普通 TSV;反斜杠与 \N 有专门语义
CSV 易与表格工具和其他系统交换 CSV 是约定族;换行、引号、编码、NULL 与空串需明确
binary 类型保真、解析开销较低 类型和版本耦合更强,不适合作为长期可读交换格式

CSV 默认用未加引号的空字段表示 NULL,用 "" 表示空字符串。这两个业务含义不同。实验显式写 NULL '\N',让证据文件更容易肉眼审查;导入时必须使用同一约定。

默认策略:一错即停

\copy shop.ch02_fixture FROM 'fixture.csv'
  WITH (FORMAT csv, HEADER true, NULL '\N')

默认 ON_ERROR stop。任一输入转换错误会使整条 COPY 失败;若外层事务也失败,目标状态可以保持不变。错误发生前处理过的行虽然不可见,却可能暂时占用表空间,后续由 vacuum 回收,因此“大文件试错”仍应先在隔离 staging 中演练。

PostgreSQL 18 的受限容错导入

基线版本支持:

COPY shop.import_stage
FROM STDIN
WITH (
    FORMAT csv,
    HEADER true,
    ON_ERROR ignore,
    REJECT_LIMIT 3,
    LOG_VERBOSITY verbose
);
  • ON_ERROR ignore 只忽略 text/CSV 输入转换错误,不是“忽略所有约束和触发器错误”;
  • REJECT_LIMIT 3 表示第 4 个转换错误使命令失败;
  • LOG_VERBOSITY verbose 为被丢弃行输出更详细的 NOTICE;
  • 若不设置 reject limit,ignore 可能跳过任意数量错误,形成“任务成功、数据大面积消失”的假象。

ON_ERROR 在 PostgreSQL 17 引入,REJECT_LIMIT 属于 PostgreSQL 18 能力。面向 14–16 的可移植方案不是删掉验收,而是先导入全 text staging 表,再用显式验证查询区分:

  1. 可转换且满足业务规则的行;
  2. 原始内容与错误原因;
  3. 无法识别或需要人工裁决的行。

最后在一个事务里把通过验证的数据转换进目标表。生产数据管道还要保存原始文件哈希、来源批次、拒绝行数量与处理决策。

一个错误隔离练习

在临时表中测试,不污染夹具:

CREATE TEMP TABLE amount_stage (
    source_line bigint GENERATED ALWAYS AS IDENTITY,
    sku text,
    amount_text text
);

INSERT INTO amount_stage (sku, amount_text)
VALUES
    ('SKU-0001', '1.23'),
    ('SKU-0002', 'not-a-number'),
    ('SKU-0003', '-4.00');

SELECT
    source_line,
    sku,
    amount_text,
    CASE
      WHEN amount_text ~ '^[0-9]+(\.[0-9]{1,2})?$'
      THEN amount_text::numeric(10,2)
    END AS parsed_amount,
    CASE
      WHEN amount_text !~ '^[0-9]+(\.[0-9]{1,2})?$'
      THEN 'invalid non-negative decimal'
    END AS rejection_reason
FROM amount_stage
ORDER BY source_line;

正则这里只服务受控教学格式,不是国际化金额解析器。重要的是保留原值与拒绝理由,再决定是否导入,而不是让 NULL 悄悄代表所有错误。

2.4.3 固定随机种子、规模档位与校验和

“重新生成 100 行”还不够;行内容、顺序和摘要也必须可解释。本章夹具不用真正随机数,而是从行号计算:

SELECT
    n AS fixture_id,
    'SKU-' || lpad(n::text, 4, '0') AS sku,
    'fixture-' || substr(md5('label:' || n), 1, 12) AS label,
    (((n * 37) % 10000)::numeric / 100)::numeric(10,2) AS amount,
    md5('pg36:' || n) AS payload
FROM generate_series(1, 100) AS g(n)
ORDER BY n;

相同 PostgreSQL 语义下,输入行号唯一决定输出。它比调用 random() 后希望种子“差不多一样”更容易审查。

随机种子固定什么

PostgreSQL 会话可以:

SELECT setseed(0.36);
SELECT random()
FROM generate_series(1, 5);

同一会话重新设置相同种子,会重启伪随机序列。pgbench 也支持 --random-seed=20260729。但种子只约束随机数流,不会固定:

  • 多线程或多客户端的调度顺序;
  • 并发事务的交错与锁等待;
  • 缓存命中、CPU 频率、网络和后台任务;
  • 不同主要版本对未承诺实现细节的变化;
  • 没有显式 ORDER BY 的结果顺序。

因此,小型语义夹具优先用可计算哈希;需要随机分布时记录生成器、种子、线程数、版本和规模档位。

规模档位要有名字

本书后续使用:

档位 目的 是否允许性能外推
tiny 快速验证语法与状态机
small L1 完整功能实验
medium L2 观察计划、维护和容量趋势 只解释方法
benchmark ch26 明确硬件与噪声后的正式运行 仅在记录的边界内

本章 100 行是 tiny。它只让错误注入、COPY、dump 和 pgbench 快速完成。

校验和必须先定义序列化

verify.sql 采用:

SELECT md5(
         string_agg(
           fixture_id || '|' || sku || '|' || payload,
           E'\n'
           ORDER BY fixture_id
         )
       ) AS checksum
FROM shop.ch02_fixture;

在 PostgreSQL 18.6 实测期望值是:

00ed4599a6ed75e4441f5211909480fa

显式字段、分隔符与排序共同定义了序列化。若字段可以含 | 或换行,就要采用长度前缀、JSON、binary 或其他无歧义编码。对大表也不应把全部内容聚合成一个内存字符串;应按稳定键分块或使用面向数据管道的校验工具。

校验和证明“按这套序列化得到相同字节”,不证明业务正确。验收同时保留:

  • 行数 100
  • 最小/最大 ID 为 1/100
  • 每行能由确定公式重新计算;
  • 校验和匹配。

本节验收

  • 能解释服务器文件 COPY 与客户端 \copy 的路径和权限差异;
  • CSV 中 NULL 与空字符串有明确约定;
  • 容错导入保存拒绝数量与原因,不静默跳过无限错误;
  • 能说明 ON_ERROR/REJECT_LIMIT 的版本边界;
  • 夹具重建后的行数、边界、逐行公式和校验和全部一致。

参考资料


上一节:编写可靠 SQL 脚本 · 返回本章目录 · 下一节:最小 pgbench 工作负载 · 查看全书目录 · 查看索引中心

2.5 最小 pgbench 工作负载

pgbench 既能运行内置类 TPC-B 工作负载,也能执行自定义事务脚本。本节只用它验证“连接—变量—事务—查询—结果采集”链路;20 次 tiny 查询不足以评价 PostgreSQL、Pigsty、硬件或参数。

2.5.1 初始化自定义脚本与参数

pgbench -i 会创建它自己的 pgbench_accountspgbench_branches 等内置基准表。本章不使用这些对象;我们的“初始化”是先运行 setup.sql,得到 100 行确定性夹具,再运行自定义工作负载

\set fixture_id random(1, 100)
BEGIN;
SET LOCAL ROLE pg36_owner;
SELECT payload
FROM shop.ch02_fixture
WHERE fixture_id = :fixture_id;
COMMIT;

pgbench 脚本的 \setpsql 元命令不是同一套完整语言。这里调用 pgbench 的 random(min, max) 表达式,把结果存为变量;SQL 中 :fixture_id 再被替换为整数。

自定义脚本不会自动包裹事务。显式 BEGIN/COMMIT 让“一次 pgbench transaction”对应一次数据库事务。SET LOCAL ROLE 只在该事务内切换为对象 owner,提交后自动恢复;它服务于本章管理员直连实验,不是应用运行时的推荐身份。

先确认夹具:

psql -X -w "service=pg36-admin" \
  -v ON_ERROR_STOP=1 \
  -f verify.sql

再运行:

pgbench \
  --random-seed=20260729 \
  --no-vacuum \
  --client=1 \
  --jobs=1 \
  --transactions=20 \
  --report-per-command \
  --file=workload.sql \
  "service=pg36-admin application_name=pg36-ch02-pgbench"

参数意图:

参数 本章取值 原因
--random-seed 20260729 固定单线程随机选择序列;放在前面确保覆盖所有随机用途
--no-vacuum 开启 不去处理不存在的内置 pgbench 表
--client 1 避免并发调度干扰教学输入
--jobs 1 保持一个工作线程
--transactions 20 让验收快速、计数精确
--report-per-command 开启 观察脚本各命令是否执行
--file 自定义脚本 不运行默认内置业务

这里通过 Pigsty default 服务 5436,路径是 HAProxy 到当前主库 PostgreSQL,不经过 PgBouncer。若改用 primary 服务 5433,必须先确认登录角色已安全配置密码并进入 PgBouncer 用户清单。两次结果属于不同连接路径,不能直接混为一个基准。

pgbench 客户端版本应与清单一并保存。自定义脚本语法和输出字段会随 PostgreSQL 版本演进;不要从另一台机器拿一个未知版本客户端就假设完全等价。

2.5.2 区分吞吐、延迟、错误与环境噪声

一次正确运行的关键输出类似:

number of clients: 1
number of threads: 1
number of transactions per client: 20
number of transactions actually processed: 20/20
number of failed transactions: 0 (0.000%)
latency average = ...
initial connection time = ...
tps = ... (without initial connection time)

本章真正验收的是:

  • 客户端、线程与事务数符合命令;
  • 20/20 个事务实际完成;
  • failed transactions 为 0
  • 标准错误中没有连接、SQL 或变量错误;
  • 运行前后夹具校验和一致,因为工作负载只读。

其余数字需要先理解口径:

指标 回答的问题 不能单独回答什么
TPS 该脚本在当前运行条件下每秒完成多少事务 单条业务请求能力、生产容量
平均延迟 客户端观察到的平均事务时间 尾延迟、每条 SQL 的服务端执行时间
initial connection time 建立测试连接所需时间 长连接应用的稳态延迟
per-command latency 脚本每类命令的客户端耗时 并发下每次调用的完整分布
failed transactions pgbench 判定失败的事务数 所有业务错误和数据正确性

固定事务数时,测试持续时间很短,任何一次调度抖动都可能大幅改变 TPS。平均值还会隐藏最慢请求;正式测试至少需要时长、延迟分布、错误分类、预热和重复运行。

噪声从哪里来

即使脚本与种子完全相同,结果仍可能受到:

  • 客户端 CPU、时钟与 pgbench 版本;
  • DNS、网络、HAProxy 与是否经过 PgBouncer;
  • PostgreSQL 数据页和操作系统页缓存;
  • autovacuum、检查点、日志、备份与其他会话;
  • 虚拟机 steal、CPU 频率、NUMA 和磁盘队列;
  • 监控采样与终端输出;
  • 第一次连接和第一次执行的初始化成本。

“第二次更快”经常只是缓存变热;“经连接池更慢”可能只是路径、认证和测量窗口不同。没有对照、重复和环境清单时,不应把相关性写成因果。

错误是一级指标

不要为追求 TPS 把错误行藏起来。若使用 --latency-limit,晚于阈值的事务会单独计数;若启用可重试错误处理,还要区分原始失败、重试和最终失败。一个吞吐更高但超时或失败更多的结果通常更差。

本章没有注入并发错误,因此只要求零失败。ch10 会制造隔离与冲突,ch26 才建立完整性能报告。

2.5.3 本节只建立可复现基线,不做性能结论

这里的“基线”指可重放的工作负载定义,不是可外推的性能基线。我们冻结了:

  • 数据集:100 行、确定公式、固定校验和;
  • 查询:按 1–100 的 ID 读取一行 payload;
  • 事务:显式 BEGIN、一次查询、COMMIT
  • 随机输入:单客户端、单线程、固定 seed;
  • 运行量:20 个事务;
  • 连接路径:清单中命名的 Pigsty service;
  • 成功条件:20/20、零失败、数据摘要不变。

我们没有冻结操作系统调度、CPU、缓存、网络或后台活动,因此绝不写“应达到 N TPS”。读者在本地实测看到几百、几千或几万 TPS,都只能说明这个 tiny 任务在那个瞬间的观察值。

做一次反证

连续运行两次同一命令:

for run_id in 1 2; do
  pgbench \
    --random-seed=20260729 \
    -n -c 1 -j 1 -t 20 -r \
    -f workload.sql \
    "service=pg36-admin application_name=pg36-ch02-run-${run_id}" \
    >"evidence/ch02/pgbench-${run_id}.txt" \
    2>"evidence/ch02/pgbench-${run_id}.stderr"
done

两个文件应具有相同事务数与零失败,但 latency 和 TPS 通常不会完全相同。这正好证明“确定输入”与“确定耗时”是两件事。

若两次选择的 fixture ID 也需要逐项核对,可以让工作负载把 ID 写入单独的审计结果;但写日志本身会改变测量。任何观测都会有成本,测试设计要说明成本是否在比较双方中一致。

何时才允许谈性能

到 ch26,至少补齐:

  1. 明确问题:容量、回归、极限还是组件对比;
  2. 代表性数据量与事务比例;
  3. 预热、持续时间、并发阶梯与重复次数;
  4. 硬件、内核、容器/虚拟化和存储清单;
  5. 客户端是否成为瓶颈;
  6. 平均值、分位数、错误、饱和指标和置信边界;
  7. 数据库、系统与 Pigsty 监控证据;
  8. 测试后状态和可复位性。

本章的小负载只是让未来这些测试拥有一个已经验证的入口。

本节验收

  • 能解释为什么自定义脚本仍需显式事务;
  • 能说明 --random-seed 固定什么、不固定什么;
  • 运行结果为 20/20 且零失败;
  • 运行前后 verify.sql 校验和一致;
  • 报告不把某次 TPS 当作 PostgreSQL 或 Pigsty 性能承诺。

参考资料


上一节:输入、输出与确定性数据 · 返回本章目录 · 下一节:最小逻辑备份闭环 · 查看全书目录 · 查看索引中心

2.6 最小逻辑备份闭环

生成一个 dump 文件只是开始。最小闭环必须回答:文件里有什么、由哪个版本生成、能否在隔离目标恢复、恢复后的状态是否符合预期、哪些生产恢复目标仍未覆盖。

2.6.1 pg_dump 的对象、模式与自定义格式

pg_dump 连接一个数据库,从一致快照读取逻辑对象定义与数据。它不会阻塞普通读写,但会持有访问共享锁来防止被导出的表在过程中遭到破坏性 DDL;长时间快照也可能影响 vacuum 回收。生产上不能因为“在线 dump”就忽略运行窗口和监控。

先验证夹具,再创建目录:

mkdir -p evidence/ch02/backup
psql -X -w "service=pg36-admin" \
  -v ON_ERROR_STOP=1 \
  -f verify.sql \
  >evidence/ch02/backup/source-verify.txt \
  2>evidence/ch02/backup/source-verify.stderr

使用自定义格式:

pg_dump -w \
  --format=custom \
  --verbose \
  --file=evidence/ch02/backup/pg36_shop.dump \
  "service=pg36-admin application_name=pg36-ch02-dump" \
  >evidence/ch02/backup/dump.stdout \
  2>evidence/ch02/backup/dump.stderr

-w 禁止密码提示;凭据来自 passfile。不要把 stdout 和 stderr 合并,因为 pg_dump --verbose 的进度与警告写入 stderr,警告必须进入验收。

为什么选择 custom

格式 恢复工具 选择对象 并行能力 本章用途
plain psql 生成后可读但难安全重排 审查简单 SQL
custom (-Fc) pg_restore 可列清单、过滤、重排 恢复可并行 本章默认
directory (-Fd) pg_restore 可列清单、过滤、重排 dump 与 restore 可并行 大库与并行任务
tar (-Ft) pg_restore 可选择 不支持并行 dump 兼容特定归档流程

custom 默认压缩且是单文件,适合小型教学闭环。真正的大库可能选择 directory format 与并行 worker,但并行数必须结合服务器 CPU、存储、网络和锁评估。

dump 的边界

普通 pg_dump 只处理一个数据库。它不包含跨数据库共享的角色与表空间定义;这类全局对象可由:

pg_dumpall -w --globals-only \
  --database="service=pg36-admin dbname=postgres" \
  >evidence/ch02/backup/globals.sql

单独导出。globals 文件可能含有角色口令哈希与敏感 ACL,应按秘密材料保护。本章恢复演练使用 --no-owner --no-privileges,故不依赖它;这也意味着恢复结果有意不保留原 owner/ACL,不能冒充完整灾备演练。

pg_dump -t--schema 只选择匹配对象,不会自动保证所有依赖都包含。一个“成功生成”的局部 dump 可能无法恢复到空数据库。除非任务明确处理依赖,本章先 dump 整个 pg36_shop

客户端版本也是输入

先记录:

pg_dump --version
psql -X -w "service=pg36-admin" -Atc \
  "SELECT current_setting('server_version')"

pg_dump 不能导出主要版本高于自己的服务器;较新的 pg_dump 可以读取较老服务器,但输出通常面向较新工具链。逻辑 dump 常用于升级,却没有“向旧版本降级必然成功”的承诺。扩展、排序规则和 SQL 语义仍需单独验证。

最后为归档文件计算客户端哈希:

sha256sum evidence/ch02/backup/pg36_shop.dump \
  >evidence/ch02/backup/pg36_shop.dump.sha256

哈希证明文件字节未变化,不证明内容完整、可信或可恢复。

2.6.2 pg_restore 的清单、选择性恢复与验证

第一步不是恢复,而是检查:

pg_restore \
  --list \
  evidence/ch02/backup/pg36_shop.dump \
  >evidence/ch02/backup/pg36_shop.list

清单列出 pre-data、data、post-data 阶段的模式、表、数据、约束、索引和 ACL 等条目。可以复制清单,按行前加分号排除对象,再用 --use-list 恢复;但手工删除依赖条目可能得到不完整数据库。

还可以把归档展开为 SQL 供审查:

pg_restore \
  --file=evidence/ch02/backup/preview.sql \
  evidence/ch02/backup/pg36_shop.dump

这一步非常重要:恢复来自不可信服务器的 dump,会在目标执行源端超级用户能够植入的任意代码。局部过滤不会消除这一风险。未知来源归档必须先审查,并在严格隔离与最小权限环境处理。

恢复到隔离数据库

不要对源数据库使用 --clean 试验恢复。创建一个名称固定、用途明确的临时数据库:

psql -X -w \
  "service=pg36-admin dbname=postgres" \
  -v ON_ERROR_STOP=1 <<'PSQL'
SELECT NOT EXISTS (
  SELECT 1
  FROM pg_catalog.pg_database
  WHERE datname = 'pg36_restore'
) AS restore_name_available
\gset

\if :restore_name_available
  CREATE DATABASE pg36_restore TEMPLATE template0;
\else
  \warn '[restore] refused: database pg36_restore already exists'
  DO $restore_error$
  BEGIN
      RAISE EXCEPTION 'restore target name is already in use';
  END
  $restore_error$;
\endif
PSQL

本章要求从干净 template0 新建。若同名数据库已经存在,上面的保护会返回非零;先确认它是否属于早先演练,再选择单独清理或更换名称,绝不自动覆盖。

恢复:

pg_restore -w \
  --exit-on-error \
  --single-transaction \
  --no-owner \
  --no-privileges \
  --dbname="service=pg36-admin dbname=pg36_restore application_name=pg36-ch02-restore" \
  evidence/ch02/backup/pg36_shop.dump \
  >evidence/ch02/backup/restore.stdout \
  2>evidence/ch02/backup/restore.stderr
  • --exit-on-error 避免默认“继续恢复、最后报告错误数量”的行为;
  • --single-transaction 保证本次小型恢复要么全部提交、要么全部回滚,并隐含 exit-on-error;
  • --no-owner --no-privileges 让实验不依赖源角色,把对象归当前恢复角色所有;
  • 大型归档可能因锁数量、事务长度而不适合单事务,生产方案必须实测。

--jobs 能并行装载数据和创建部分对象,但不能与 --single-transaction 同用。并行恢复的成功条件仍是状态验证,不是“worker 都退出了”。

用语义摘要验证

分别在源与恢复库执行:

SELECT
    count(*) AS row_count,
    min(fixture_id) AS min_id,
    max(fixture_id) AS max_id,
    md5(
      string_agg(
        fixture_id || '|' || sku || '|' || payload,
        E'\n'
        ORDER BY fixture_id
      )
    ) AS checksum
FROM shop.ch02_fixture;

期望两边均为 100110000ed4599a6ed75e4441f5211909480fa。再验证列、约束和索引,而不是只查行数:

\d+ shop.ch02_fixture

因为本次使用 --no-owner --no-privileges,owner 与 ACL 应与源库不同;这不是失败,而是任务选择的恢复语义。验证报告必须明确哪些属性要求相同、哪些有意重映射。

完成后,删除 pg36_restoreR2·破坏性演练。必须先确认它只属于本实验、终止范围仅限这个数据库,再携带精确令牌执行:

psql -X -w \
  "service=pg36-admin dbname=postgres" \
  -v ON_ERROR_STOP=1 \
  -v confirm_drop=DROP_PG36_RESTORE <<'PSQL'
SELECT :'confirm_drop' = 'DROP_PG36_RESTORE' AS drop_confirmed
\gset

\if :drop_confirmed
  SELECT pg_terminate_backend(pid)
  FROM pg_stat_activity
  WHERE datname = 'pg36_restore'
    AND pid <> pg_backend_pid();
  DROP DATABASE pg36_restore;
\else
  DO $drop_error$
  BEGIN
      RAISE EXCEPTION 'drop confirmation is required';
  END
  $drop_error$;
\endif
PSQL

不要把清理动作附在默认备份命令后;保留恢复目标供人工验收,确认后再独立清理。

2.6.3 与 ch21《未雨绸缪:备份体系与恢复演练》的边界

本节证明的是“逻辑对象可以导出、检查、恢复和验证”,不是“生产数据已经安全”。两者之间至少还差:

生产问题 本节是否覆盖 ch21 要补什么
整个实例的物理恢复 基础备份、WAL 归档、pgBackRest
任意时间点恢复 时间线、恢复目标、PITR 演练
角色、表空间与配置 部分 全局对象、参数、扩展包和基础设施清单
RPO / RTO 业务目标、备份频率、恢复计时与容量
保留、异地和不可变副本 仓库、生命周期、加密、访问控制
自动验证与告警 仅手工样例 周期性恢复演练、失败告警、证据归档
大库恢复性能 并行度、网络、磁盘、锁与资源预算
高可用拓扑重建 Patroni、复制槽、服务与成员恢复

Pigsty 的生产备份参考实现以 pgBackRest 物理备份与 WAL 归档为核心;逻辑 dump 更适合对象级迁移、审查和辅助恢复。两者不是互相替代的单选题。

一份文件不是备份结论

至少经历以下状态,才能把本节称为一次演练:

flowchart LR
  A["源状态已验证"] --> B["dump 成功"]
  B --> C["归档哈希已记录"]
  C --> D["清单与 SQL 已检查"]
  D --> E["隔离目标恢复成功"]
  E --> F["数据与结构验证通过"]
  F --> G["耗时、错误和差异已记录"]

链路中任一步失败都应保留证据,而不是删除文件重新跑到“看起来成功”为止。

本节验收

  • dump 客户端与服务端版本都进入清单;
  • custom 归档有 SHA-256,并能由 pg_restore --list 读取;
  • 恢复前查看了清单或展开 SQL;
  • 恢复目标与源数据库隔离;
  • pg_restore 遇错即停,恢复后验证结构、行数和校验和;
  • 报告明确 owner/ACL 是否保留;
  • 能列出本节没有覆盖的 RPO、RTO、WAL、保留和异地问题。

参考资料


上一节:最小 pgbench 工作负载 · 返回本章目录 · 下一节:实战:把人工操作变成可重跑任务 · 查看全书目录 · 查看索引中心

2.7 实战:把人工操作变成可重跑任务

现在把连接保护、可靠脚本、确定性数据、最小负载和证据清单组合成一个任务。它会创建并覆盖 shop.ch02_fixture,因此只能在明确的 L1 教学数据库运行,不能把“表名前缀看起来安全”当作生产授权。

风险分级:

  • setupR1·可逆变更,创建或重建本章专属 100 行夹具;
  • verifybaselineR0·观察,其中 pgbench 只读;
  • inject-errorR2·破坏性演练,故意制造语法错误,但由单事务回滚隔离;
  • resetR2·破坏性演练,只删除 shop.ch02_fixture,需要双重确认令牌。

2.7.1 生成 pg36_shop 初始数据与校验摘要

下载本章全部实验文件到同一目录,至少包括:

context.sql
setup.sql
verify.sql
workload.sql
broken.sql
reset.sql
task.sh

复制service file 示例,替换主机并设置私有权限:

chmod 600 "$PWD/pg_service.conf"
export PGSERVICEFILE="$PWD/pg_service.conf"
export PGSERVICE=pg36-admin

dbuser_dba 准备 passfile 或等价的非交互凭据。本书不提供真实密码,也不要求把密码写入 service file。先人工确认落点:

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

必须是 pg36_shop、受控管理员且 pg_is_in_recovery() = false

setup 怎样收敛

setup.sql先包含 context.sql,然后在事务内:

  1. 创建 shop.ch02_fixture(若不存在);
  2. pg_attribute 计算五列的名称、类型与非空形状;
  3. 发现同名表形状漂移则抛出异常;
  4. 截断本章专属表并按确定公式生成 100 行;
  5. pg36_app 写权限、给 pg36_ro 只读权限;
  6. 提交事务。

夹具故意不是电商领域模型:

用途
fixture_id 稳定排序键与 pgbench 选择范围
sku 可读、可计算的唯一字符串
label 哈希派生文本
amount 确定的 numeric(10,2)
payload 校验与读取负载

ch03 会从业务规则重新设计正式模型;本表只训练工作流,避免在建模之前偷渡随意业务约束。

运行:

chmod +x task.sh
export PG36_EVIDENCE_DIR="$PWD/evidence/ch02/setup-$(date -u +%Y%m%dT%H%M%SZ)"
./task.sh setup
./task.sh verify

也可直接运行 SQL:

psql -X -w "service=pg36-admin" \
  -v ON_ERROR_STOP=1 \
  -f setup.sql

psql -X -w "service=pg36-admin" \
  -v ON_ERROR_STOP=1 \
  -f verify.sql

基线版本实测摘要:

status=ok
database=pg36_shop
effective_role=pg36_owner
row_count=100
min_id=1
max_id=100
checksum=00ed4599a6ed75e4441f5211909480fa

再次执行 setup 与 verify,应得到相同状态。若校验和不同,先检查脚本版本哈希、服务端主要版本与本地是否修改过生成公式;不要更新“期望值”来迁就未知漂移。

形状漂移为什么要失败

假如已有 shop.ch02_fixture 只是同名、列却不同,CREATE TABLE IF NOT EXISTS 会发 NOTICE 后继续。形状保护会随后抛出异常,整个事务不再 TRUNCATE。这才是可重入:认识并拒绝未知中间状态,而不是把所有错误压成“对象已存在”。

2.7.2 从 Pigsty 服务端点执行并保存证据

综合入口是 task.sh。它采用 Linux Shell 的严格模式和 umask 077,检查 psqlpgbenchsha256sum,再把每类输出写入独立文件。

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

./task.sh all

all 的顺序固定:

sequenceDiagram
  participant T as task.sh
  participant H as Pigsty 5436 / HAProxy
  participant P as PostgreSQL primary
  T->>H: service=pg36-admin
  H->>P: direct primary connection
  T->>P: capture manifest
  T->>P: setup deterministic fixture
  T->>P: verify state
  T->>P: pgbench 20 read-only transactions
  T->>P: run broken.sql in one transaction
  P-->>T: syntax error; rollback
  T->>P: verify fixture_id=999 is absent
  T-->>T: write exit code and evidence path

任务使用的是 Pigsty default 服务,而不是固定实例 5432。服务层提供“当前主库直连”意图,PostgreSQL 仍负责事务、角色、目录和数据。若 5436 在你的配置中含义不同,必须修改 service file 并在清单中记录,不要改书中预期输出来掩盖端点差异。

清单与证据

成功后目录应包含:

manifest.txt
setup.stdout
setup.stderr
verify.txt
verify.stderr
pgbench.txt
pgbench.stderr
broken.stdout
broken.stderr
broken.status

manifest.txt 记录 UTC 时间、任务动作、service 名、客户端版本、七个执行文件的 SHA-256,以及服务端版本、数据库、登录角色和恢复状态。它有意不打印 host、密码或 passfile 内容;如组织审计需要记录脱敏端点,可在外层清单增加。

验证重点:

sed -n '1,120p' "$PG36_EVIDENCE_DIR/verify.txt"
sed -n '1,160p' "$PG36_EVIDENCE_DIR/pgbench.txt"
sed -n '1,80p'  "$PG36_EVIDENCE_DIR/broken.status"

期望:

row_count=100
checksum=00ed4599a6ed75e4441f5211909480fa
number of transactions actually processed: 20/20
number of failed transactions: 0 (0.000%)
exit_code=3
rollback_marker_count=0

不验收具体 latency 或 TPS。它们会随环境变化,保留在证据中供观察,不作为通过条件。

分动作重跑

./task.sh setup
./task.sh verify
./task.sh baseline
./task.sh inject-error

每次最好给 PG36_EVIDENCE_DIR 一个新路径,防止覆盖上次失败证据。verifybaseline 假设夹具已经存在;inject-error 会先验证正常基线,再注入错误。

task 的目标是把协议做显式,并不替代通用工作流平台。生产上的 CI、Ansible、Kubernetes Job 或调度器仍应保留同样语义:输入、目标保护、超时、失败状态、证据、重试策略和回退边界。

2.7.3 注入脚本错误,验证停止、修复与复位

broken.sql先插入一行标记,再故意把 SELECT 写成 SELEC

INSERT INTO shop.ch02_fixture
    (fixture_id, sku, label, amount, payload)
VALUES
    (999, 'SKU-0999', 'must-be-rolled-back', 9.99, md5('broken'));

SELEC 'intentional syntax error';

任务调用:

psql -X -w \
  --single-transaction \
  "service=pg36-admin" \
  -v ON_ERROR_STOP=1 \
  -f broken.sql

必须同时满足三项:

  1. stderr 含明确语法错误与位置;
  2. psql 返回状态 3
  3. 新连接查询 fixture_id = 999 得到 0 行。

只满足前两项不够。若忘记 --single-transactionON_ERROR_STOP 会停止后续发送,却无法撤销已经自动提交的 INSERT。错误退出与状态回滚是两个独立性质。

修复并不自动等于正确

broken.sql 复制成临时 repaired.sql,将 SELEC 改为 SELECT 后再次以单事务运行,标记行会成功提交。此时语法已修复,但 verify.sql 会因为行数变成 101、确定公式不匹配而失败。

这说明:

  • 修复执行错误,只证明脚本能跑完;
  • 状态验证才证明结果符合任务契约;
  • 幂等 setup 可以把本章拥有的夹具重新收敛到 100 行;
  • 未经定义的数据不能因为“是成功 SQL 写进去的”就留在基线。

运行:

./task.sh setup
./task.sh verify

确认校验和恢复。不要在有业务价值的表上用 TRUNCATE + 重建 套用这个教学复位模式。

显式 reset

默认 all 不删除夹具。若要回到 ch01 末尾状态,需要两个一致令牌:

export PG36_RESET_TOKEN=RESET_CH02_FIXTURE
./task.sh reset
unset PG36_RESET_TOKEN

Shell 先检查环境变量,SQL 文件再检查 confirm_reset。脚本只执行:

DROP TABLE IF EXISTS shop.ch02_fixture;

它不会删除 pg36_shopshop 模式或 ch01 的角色。完成后:

SELECT to_regclass('shop.ch02_fixture') IS NULL AS removed;

应返回 true。若下一章继续使用案例,重新执行 ./task.sh setup,不要 reset。

本章最终验收

逐项打勾:

  • service file 与 passfile 分离,Git 中没有密码;
  • 正确目标通过 context,错误数据库返回状态 3
  • setup 连续执行两次仍得到 100 行和固定校验和;
  • 人读探索使用元命令,机器证据查询明确目录字段;
  • 所有脚本设置 ON_ERROR_STOP,调用方保存原始退出码;
  • CSV 的 NULL、编码、顺序和错误策略明确;
  • pgbench 完成 20/20、零失败,且未宣称固定 TPS;
  • custom dump 能列清单、恢复到隔离数据库并验证;
  • 故障注入返回 3,标记行回滚;
  • reset 需要令牌且只删除本章表。

达到这些条件后,读者拥有的不只是几个命令,而是一套后续 34 章都能复用的执行语法:先验证上下文,再应用动作;用状态而不是屏幕感觉验收;把失败当作需要设计的正常路径。

下一章进入 ch03《正本清源:从业务规则到关系模型》ch02_fixture 只作为确定性输入与反例,正式业务表将从业务不变量重新推导。

参考资料


上一节:最小逻辑备份闭环 · 返回本章目录 · 下一章:正本清源:从业务规则到关系模型 · 查看全书目录 · 查看索引中心

3 正本清源:从业务规则到关系模型

表不是字段清单的容器,关系模型也不是把接口 JSON 原样搬进数据库。建模首先要辨认系统承诺保存哪些事实、谁拥有这些事实、哪些组合状态绝不允许出现;表、键和外键只是把这些判断变成可验证结构。

本章建立 pg36_shop 逻辑模型 v0。它会真实部署到 PostgreSQL,并用正反样例审查,但它有意保留四项未决:金额、时间、状态和标识的可靠物理表达。v0 是通往 ch04 的设计证据,不是可以复制进生产的最终 DDL。

本章目标

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

  • 从业务语言区分实体、事件、状态、命令与不变量;
  • 识别一项事实的权威所有者,而不是让多个服务共享写入责任;
  • 分开内部主键、业务键、外部标识、幂等键和追踪标识;
  • 用主键、唯一约束与外键表达关系的最小完整性;
  • 根据父子生命周期选择 RESTRICTCASCADE 等外键动作;
  • 识别简单约束能维护的行内/引用规则,以及需要事务或流程维护的跨表规则;
  • 用模式、owner 与 runtime role 建立对象边界,不依赖不受控 search_path
  • 区分重复事实、历史快照、派生结果与缓存;
  • 部署五表逻辑模型 v0,生成关系图、状态摘要和四项未决清单。

开始之前

本章假设已经完成 ch01 的 pg36_shop 基线和 ch02 的可重跑工作流。实验仍使用 L1 的 Pigsty default 服务进入当前主库,但建模能力完全属于 PostgreSQL;Pigsty 只提供统一端点、运行环境和后续观测载体。

下载资产:

本章案例

范围限定为五个概念:

erDiagram
  CUSTOMER ||--o{ SALES_ORDER : places
  SALES_ORDER ||--|{ SALES_ORDER_ITEM : contains
  PRODUCT ||--o{ SALES_ORDER_ITEM : snapshotted_as
  SALES_ORDER ||--o{ PAYMENT : receives
  • customer 保存当前客户档案;
  • product 保存当前商品目录事实;
  • sales_order 保存被接受的下单命令及买家快照;
  • sales_order_item 保存订单组成与购买时商品快照;
  • payment 保存支付尝试与外部提供方标识。

发货、库存、退款、优惠、税务和多币种不是被遗忘,而是明确排除在 v0 之外。一个小而闭合的模型比一个列很多却没有边界的“万能订单表”更适合演进。

教学路径

flowchart LR
  A["业务语言<br/>事实与不变量"] --> B["标识<br/>主键与业务键"]
  B --> C["关系<br/>外键与生命周期"]
  C --> D["对象边界<br/>schema 与 owner"]
  D --> E["规范化<br/>快照与冗余"]
  E --> F["v0 实战<br/>部署 + 反例"]
  F --> G["ch04<br/>可靠物理模式"]

本章目录

3.1 从业务语言提取数据库事实

先写事实句、所有权和失败条件,再决定表。

3.2 标识、主键与业务键

同一行可以同时拥有多个不同目的的标识;只有一个承担内部引用主键。

3.3 关系与引用完整性

把基数与生命周期写进外键,同时承认普通 CHECK 无法维护跨行、跨表真相。

3.4 模式、所有权与对象边界

shopshop_apishop_private 分别承载规范事实、查询接口与内部实现;schema 名称本身不自动构成安全边界。

3.5 规范化与有意识的冗余

商品当前名称只保存一次,订单行上的名称则是购买时快照;两者字面重复,事实语义不同。

3.6 实战:建立逻辑模型 v0

v0 强制无争议的键、引用和正值规则,同时用事务内反例证明任意状态、任意金额小数位和“paid 但没有行/付款”仍可能穿透。

章节产物

运行 task.sh all 后,证据目录至少包括:

文件 证明什么
manifest.txt 客户端/服务端版本与十个输入文件哈希
setup.stdout 五表、两个辅助 schema 与一个查询 view 成功建立
verify.txt 行数、权限、引用与关系摘要符合 v0
review.txt 应拒绝的三类错误被拒绝,三项开放规则仍可穿透
review.stderr 预期 unique、foreign key、check violation 被明确捕获

基线数据摘要为:

model_version=ch03-v0
customer_count=2
product_count=3
order_count=2
item_count=3
payment_count=2
open_decision_count=4
relation_checksum=cd7daa66543a6b5e0a5d7fc269558a6c

章节验收

  1. 能把“用户下单”改写成至少五条可判断真假的事实;
  2. 能解释 order_idorder_norequest_keytrace_id 为什么不能互换;
  3. 能为每条外键说明父子生命周期与删除动作;
  4. 能指出“订单至少一行”“paid 必须足额付款”为什么不是普通行级 CHECK
  5. 能区分订单行商品名称快照与无依据缓存;
  6. 能证明 runtime role 不是对象 owner,且无权使用 shop_private
  7. 能从负向实验读出 v0 的已闭合与未闭合边界;
  8. 不把 v0 宣称为可靠物理模式。

下一章 ch04《量体裁衣:数据类型、约束与可靠数据表达》 将逐项关闭金额、时间、状态和标识决策,并用类型、约束、反例与分区 ADR 产出可靠 DDL。

参考资料


上一章:手到擒来:psql 与可复现工作流 · 返回上卷导读 · 下一章:量体裁衣:数据类型、约束与可靠数据表达 · 查看全书目录 · 查看索引中心

3.1 从业务语言提取数据库事实

建模会议最容易从“订单表有哪些字段”开始,最后得到一个容纳所有词汇的大表,却没人能说明哪种状态算错。本节反过来:先把业务陈述改写成可判断真假的事实,再找出必须永久成立的不变量。

3.1.1 实体、事件、状态与业务不变量

四类词回答不同问题:

概念 问题 pg36_shop 例子 常见误区
实体 哪个事物拥有持续身份? customer、product、sales order 看到名词就机械建表
事件 在什么时刻发生了什么? order placed、payment attempted 只保留当前状态,失去历史事实
状态 某实体当前处于什么条件? order is placed/paid/cancelled 把一个 text 字段当成完整状态机
不变量 哪些命题在每次提交后都必须为真? order 指向已有 customer 只写“通常”“应该”,没有失败语义

表与业务概念不是一一映射。一个实体可能需要多个关系保存当前事实和历史;多个小值对象也可能嵌入同一关系。判断依据是身份、生命周期、基数、更新原子性和查询责任,而不是面向对象类图。

把叙事改写成事实句

“Alice 买了咖啡和杯子并支付成功”至少包含:

  1. customer CUST-ALICE 在下单时存在;
  2. order ORD-20260729-0001 属于该 customer;
  3. order 接受了两个不同 line;
  4. 每个 line 记录数量与购买时单价;
  5. line 引用的 product 在接受订单时存在;
  6. payment provider 接受了一个金额为 167.80 的尝试;
  7. provider reference 在该 provider 范围内唯一;
  8. captured payment 总额与订单接受金额相符;
  9. “paid” 状态只有在满足支付规则后才能出现。

前七项可由本章五个关系直接表达;第八、九项是跨表和状态转换规则,v0 会故意暴露它们尚未关闭。

不变量要写出四个维度

不要只写“订单号唯一”,而要写:

规则:order_no 在 pg36_shop 订单域内唯一。
时点:每条 INSERT/UPDATE 语句结束时成立。
权威:PostgreSQL unique constraint。
违反:整条语句失败;调用方收到 unique_violation。

再看“订单至少有一行”:

规则:进入 accepted/paid 等已接受状态的订单至少有一个 line。
时点:状态转换事务提交时成立。
权威:订单命令边界;实现方式待 ch04/ch13 决定。
违反:状态转换失败,订单不得部分提交。

这条规则不能要求每次刚插入 order 行后就成立,否则同一事务尚未来得及插入第一个 line。时点是模型的一部分。

事件和状态不要互相伪装

payment 在 v0 中代表一次有身份的支付尝试,包含 provider reference、request fingerprint、状态和发生时间。它不是完整的支付事件流;若未来要回答每次授权、捕获、撤销和退款的顺序,需要追加事件关系或对账记录,而不是在一行上反复覆盖后声称历史仍然存在。

同样,order_status = 'paid' 只是一个断言。只有定义允许值、转换、终态、并发规则和金额条件后,它才成为可依赖状态机。ch04 负责值域表达,ch10 处理并发转换,ch13 讨论数据库逻辑边界。

3.1.2 命令模型、查询模型与数据所有权

命令模型回答“怎样接受一个合法变化”,查询模型回答“消费者怎样读取所需形状”。它们可以共享一套规范事实,但不必共享一张宽表。

规范事实与读取形状

v0 的命令侧关系是:

  • customer:当前客户档案;
  • product:当前商品目录;
  • sales_order:订单头、客户引用、幂等输入和下单快照;
  • sales_order_item:组成关系与购买时商品快照;
  • payment:支付尝试与外部标识。

查询侧提供 shop_api.order_summary

SELECT
    order_id,
    order_no,
    order_status,
    item_count,
    item_subtotal,
    captured_amount
FROM shop_api.order_summary
ORDER BY order_id;

item_count 与金额汇总按需计算,不在订单头重复保存。现在两行三项数据,普通 view 足够;未来是否物化、缓存或拆到读取服务,要由查询量、延迟和新鲜度目标证明。

这不是要求每个系统都采用 CQRS。核心原则更朴素:写模型首先维护事实与不变量,读接口可以投影、连接和聚合;不要为了一个列表页面,让五处写入共同维护一张含所有派生字段的表。

数据所有权不是数据库 owner

“谁拥有数据”至少有三层含义:

层次 pg36_shop 例子 责任
业务权威 订单域拥有已接受订单事实 决定语义、修改接口与生命周期
PostgreSQL 对象 owner pg36_owner 执行 DDL、授权、迁移
运行角色 pg36_apppg36_ro 按最小权限执行命令或读取

三者不能混为“这个服务有数据库密码,所以它拥有一切”。

业务事实清单把本章范围写成:

事实 本章权威 外部依赖
当前客户档案 pg36_shop 身份系统可能提供外部标识
当前商品目录 pg36_shop 教学范围 真实组织可能有独立商品服务
已接受订单与购买快照 pg36_shop 下单客户端只提交命令
本地支付尝试记录 支付边界 provider 才是资金处理外部权威

数据库能保证 provider reference 在本地不重复,却不能证明第三方真的扣款。外部响应必须经过认证、重试、对账与补偿;跨系统一致性不能伪装成一个本地外键。

一个事实只应有一个写入责任

多个服务可以消费订单摘要,但不应绕过订单命令随意更新 order_status。否则每个写者都带着不同规则,数据库最后只能保存“谁最后提交”的结果。

若组织确实需要多写者,必须共享同一数据库不变量、并发协议与发布契约。更常见的选择是一个权威写入口,其他服务通过 API、消息或受控数据库接口提出命令。

所有权还包括删除与保留。客户档案删除不意味着历史订单必须消失;商品下架不意味着订单快照应级联删除。关系动作要服从业务生命周期,而不是代码生成器默认值。

3.1.3 哪些规则必须由数据库兜底

“都放应用”与“都放数据库”同样偷懒。选择执行层时逐条问:

  1. 违反后是否会形成永久无效数据?
  2. 是否可能由并发写者同时触发?
  3. 是否存在多个写入入口、批处理或人工 SQL?
  4. PostgreSQL 能否在正确时点原子判断?
  5. 规则是否依赖外部系统或人工裁决?
  6. 错误需要怎样映射给调用方?

数据库应兜底的最小集合

规则 v0 PostgreSQL 表达 原因
每行有稳定身份 PRIMARY KEY 所有写入口共享
SKU/order_no 等业务键不重复 UNIQUE 并发下应用“先查再插”会竞态
order 必须有 customer FOREIGN KEY 防止孤儿引用
line 必须有 order 与 product 两条 FK 关系事实必须真实
line_no、quantity 为正 CHECK 单行、确定、无外部依赖
关键列必须存在 NOT NULL 让“未知”成为显式建模决定

应用仍应提前验证并返回友好错误,但数据库约束是最后防线。它覆盖后台任务、迁移脚本、并发请求和未来尚未出现的写者。

普通约束不适合什么

PostgreSQL 不支持让 CHECK 引用该行之外的表数据并承诺持续一致。下面的想法是错误方向:

-- 不要这样设计跨表 CHECK
CHECK (
  amount <= (
    SELECT sum(unit_price * quantity)
    FROM shop.sales_order_item
    WHERE order_id = payment.order_id
  )
)

CHECK 按新行或更新行验证,并假设表达式对同一行输入保持不变。其他表以后变化时,它不会自动重检;dump/restore 顺序也可能让这种伪约束失败。

跨表规则的候选实现包括:

  • 同一事务中的原子命令与显式锁;
  • 唯一、外键、排他约束等真正受支持的关系约束;
  • 受严格设计的触发器或延迟约束触发器;
  • 由状态转换把“草稿不完整”和“已接受必须完整”分开;
  • 外部工作流的对账、补偿和人工裁决。

选择触发器不自动让规则正确;并发、递归、批量导入、错误语义和恢复都要验证。ch10 与 ch13 会继续。

v0 的刻意空缺

本章负向实验会证明以下状态仍能提交到事务内:

  • numeric 接受 9 位小数;
  • order_status 接受 teleported
  • 一个 status 为 paid 的 order 可以没有 line 和 payment。

实验随后 ROLLBACK,不会污染基线。暴露空缺比用 prose 声称“以后应用会注意”更诚实;它们进入四项未决登记并在 ch04 验收。

本节产物

为每条业务规则建立最小登记:

rule_id
fact / invariant
scope
validation_time
authoritative_owner
enforcement_layer
error_semantics
evidence_query
open_questions

如果一条规则没有权威 owner 或验证时点,先不要写 DDL。技术不能替组织替你决定事实。

本节验收

  • 能把一个业务故事拆成实体、事件、状态和至少五条事实;
  • 每条不变量都写出范围、时点、权威与违反结果;
  • 命令模型与查询投影职责分开;
  • 业务 owner、对象 owner 与 runtime role 不再混用;
  • 能解释哪些规则适合约束,哪些需要事务或外部协调;
  • 所有未闭合规则进入登记,而不是藏在代码注释。

参考资料


返回本章目录 · 下一节:标识、主键与业务键 · 查看全书目录 · 查看索引中心

3.2 标识、主键与业务键

“这个对象叫什么”没有一个通用答案。一行可能同时需要数据库内部引用、业务沟通、外部系统对账、请求去重和链路追踪标识。把它们都塞进一个 id,会让任何一次格式或业务规则变化沿所有外键扩散。

3.2.1 自然键、代理键与外部标识

三类键各有职责:

类型 定义 pg36_shop 例子 设计问题
自然/业务键 业务已经赋予的唯一事实 SKU、order_no、customer_ref 作用域、稳定性、大小写、回收规则
代理键 数据库模型人为引入的内部行身份 customer_id、product_id、order_id 类型、生成位置、生命周期
外部标识 另一个权威系统赋予 provider_payment_ref 必须连同提供方/租户保存作用域

使用代理主键不意味着可以丢掉业务唯一约束:

CREATE TABLE shop.product (
    product_id bigint PRIMARY KEY,
    sku text NOT NULL UNIQUE,
    ...
);

product_id 让内部外键紧凑、稳定;sku 的唯一约束防止数据库保存两个业务上同一商品。若只保留代理键,两行不同 ID、同一 SKU 会同时“技术合法”。

哪些自然属性不适合做主键

客户 email 在 v0 中唯一,但仍不作为主键:

  • 用户可能改邮箱;
  • 大小写与规范化规则尚未决定;
  • 邮箱可能由身份系统合并或重新分配;
  • 它包含个人信息,会传播进子表、日志和缓存;
  • 所有引用表都被迫携带一个较宽可变字符串。

因此使用 customer_id 作内部身份、customer_ref 作业务引用、email 作当前可联系属性并暂时唯一。ch04 会审查文本比较与大小写语义。

SKU 也可能变更,但订单行需要“购买时看到的 SKU”。v0 同时保存:

product_id      -> 当前商品实体
sku_snapshot    -> 下单时业务快照

商品改 SKU 不应重写历史订单。两个字段字面上曾经相同,语义和生命周期不同。

外部标识必须带命名空间

支付提供方的 pay-ref-1001 只在 provider 自己的命名空间内有意义:

UNIQUE (provider, provider_payment_ref)

若系统是多租户,还可能需要 (tenant_id, provider, provider_payment_ref)。不要根据测试数据“看起来全局唯一”省掉作用域;权威方没有承诺的唯一性不是事实。

外部标识也不是认证凭据。能够猜到 order_no、bigint ID 或 provider reference,不应赋予读取和修改权限。

3.2.2 主键稳定性、键宽度与传播范围

主键一旦被外键、消息、缓存、URL 和数据仓库引用,就变成传播最广的设计决定之一。评审至少看:

  1. 稳定性:业务是否会要求修改它?
  2. 作用域:数据库、租户、服务还是全球唯一?
  3. 宽度:每个引用、索引和 join 要携带多少字节?
  4. 生成:数据库、应用还是外部权威负责?
  5. 顺序性:是否暴露规模,是否影响写入局部性?
  6. 展示:客户支持与 API 是否需要可读标识?

v0 选择 bigint 只是为了让关系可运行,值由 seed 显式提供。ch04 会在 identity、UUID 与应用生成之间做正式决定。

不把可变业务键传播到所有关系

关系传播图:

父关系 内部主键 业务/外部键 子关系保存什么
customer customer_id customer_ref、email order 保存 customer_id,另保存下单 email 快照
product product_id SKU line 保存 product_id 与 SKU/name 快照
sales_order order_id order_no line/payment 保存 order_id
payment payment_id provider reference 对账通过 provider + reference 查找

内部关系使用稳定代理键,边界接口仍可用 order_no、customer_ref 等业务标识。这样修改展示格式不会要求重写每个外键。

复合主键何时合理

sales_order_item 使用:

PRIMARY KEY (order_id, line_no)

line 的身份只在一张订单内成立,没有脱离 order 的独立生命周期。复合键准确表达“订单中的第 N 行”,并让重复 line_no 直接失败。

若未来 line 需要在多个系统独立引用、跨订单移动或拥有大量子关系,可以再评估独立 order_item_id;不要因为所有表模板都含 id 就提前添加。

复合键也有传播成本。子表若引用 order item,必须携带两列,唯一索引和 join 也更宽。模型应在语义准确与操作成本之间明确取舍。

主键更新通常意味着身份混乱

v0 外键显式使用 ON UPDATE RESTRICT。内部主键原则上不可变;如果业务要求“把 customer_id 从 1 改为 2”,更可能是在合并实体,需要迁移引用、冲突决策、审计和补偿,而不是普通级联更新。

ON UPDATE CASCADE 是可用机制,但不应替代身份语义。一个能级联修改的键仍会影响锁、索引、复制和外部消费者。

键宽度与写入局部性的物理代价在 ch04/ch09 量化,本节先冻结职责。

3.2.3 幂等键、去重键与审计标识

网络超时后,客户端不知道服务器是否提交,最安全的重试依赖幂等协议,而不是“希望第一次没成功”。幂等键表达:

在约定作用域和保留期内,同一个 key 代表同一个逻辑命令。

v0 对下单使用:

UNIQUE (customer_id, request_key)

对支付使用:

UNIQUE (provider, idempotency_key)

作用域不同,因为两类命令的权威和重试边界不同。

唯一键只挡重复,不验证同一请求

如果攻击者或客户端错误地用相同 key 发送不同商品列表,UNIQUE 只会告诉你已有一行。它不会判断新旧 payload 是否等价。因此 v0 还保存 request_fingerprint

key 相同 + fingerprint 相同 -> 返回既有结果
key 相同 + fingerprint 不同 -> 明确冲突,不能当成功
key 不同                     -> 尝试新命令

典型流程:

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

若没有返回行,在同一事务的后续语句读取既有记录并比较 fingerprint。并发语义、锁等待和 ON CONFLICT 快照细节会在 ch10 展开;本节只确定协议事实。

fingerprint 的序列化必须规范:字段顺序、编码、NULL、数值与 JSON 规范化都要固定。直接 md5(raw_http_body) 可能让语义相同但格式不同的请求被判为冲突,也可能遗漏不应忽略的字段。本章 hash 只是确定性样例。

去重键与幂等键

“去重”常从已有数据推测两条记录像不像,例如相同 email 与时间窗口;“幂等”是调用方和服务事先约定同一命令身份。前者可能需要概率与人工判断,后者应有确定作用域和唯一约束。不要把模糊相似度当支付幂等。

trace ID 不应唯一

created_by_trace_idpayment.trace_id 用于把数据库事实关联到日志、消息和调用链。同一个 trace 可能创建 order、多个 line 和 payment,因此它们通常不是唯一键,也不决定重试结果。

审计标识还不能替代审计内容。至少要知道动作、主体、时间、目标和结果;单独一串 trace ID 只有在外部日志仍可用时才有意义。

保留期与删除

幂等键如果被删除并重用,迟到重试可能创建第二笔业务。设计时必须定义:

  • key 由谁生成;
  • 在什么作用域唯一;
  • 保留多久;
  • 过期后迟到请求怎样处理;
  • 数据归档/分区是否仍保留去重索引;
  • 跨地域或多主写入怎样协调。

本章不删除订单和支付,因此 key 与事实同生命周期。

本节验收

  • 能为每个标识写出权威、作用域、稳定性、生成方与是否公开;
  • 代理主键与业务唯一约束同时存在;
  • 外部标识包含 provider/tenant 等真实命名空间;
  • 幂等唯一键有 fingerprint 冲突语义;
  • trace ID 不被误设为主键或唯一键;
  • bigint 只是 v0 选择,生成策略明确留给 ch04。

参考资料


上一节:从业务语言提取数据库事实 · 返回本章目录 · 下一节:关系与引用完整性 · 查看全书目录 · 查看索引中心

3.3 关系与引用完整性

外键不是为了让 ER 图好看,而是让“这条引用指向真实对象”在并发与所有写入入口下持续成立。建模还要回答可选性、基数、父子生命周期和删除后果;只写两列同名 ID 并没有建立关系。

3.3.1 一对一、一对多与多对多

关系基数要落成列、NOT NULLUNIQUE 和 foreign key 的组合。

一对多:外键放在“多”侧

一个 customer 可以有零到多个 order,每个 order 必须属于一个 customer:

customer(customer_id PRIMARY KEY)

sales_order(
  order_id PRIMARY KEY,
  customer_id NOT NULL
    REFERENCES customer(customer_id)
)

NOT NULL 关闭“订单暂时没有客户”的可能,FK 关闭“客户 ID 不存在”的可能。父侧仍可以暂时没有订单;普通 FK 不保证至少存在一个子行。

一对一:外键再加唯一

假设每个 customer 至多有一份独立 profile:

CREATE TABLE customer_profile (
    customer_id bigint PRIMARY KEY
        REFERENCES shop.customer(customer_id),
    ...
);

让 FK 本身成为子表主键即可保证每个 customer 最多一行 profile。若 profile 与 customer 总是同时创建、同生命周期且没有独立权限/更新原因,拆表反而可能增加 join 与一致性成本;“一对一”不是自动拆表指令。

多对多:关联关系也是事实

订单与商品看似多对多:一个 order 有多个 product,一个 product 出现在多个 order。sales_order_item 不是只有两列的机械桥表,因为这段关系还有自己的事实:

line_no
quantity
purchase-time SKU/name
purchase-time unit_price

它是有属性的关联实体,主键为 (order_id, line_no),另用 product_id 指向当前商品身份。

如果只是“用户收藏商品”,关联表可能是:

PRIMARY KEY (customer_id, product_id)

一旦需要收藏时间、来源、排序或状态,这些也是关系本身的属性。

可选性要说出业务含义

可空 FK 表示“关系可能不存在或未知”,两者不是同一语义。例如 payment 可以没有 provider reference 吗?

  • 若记录代表已发送给 provider 的尝试,reference 应 NOT NULL
  • 若要先创建本地 pending 请求,再异步获得 reference,需要单独状态和可空时点;
  • 若 provider 永远不返回 reference,应使用另一稳定外部键,而不是把 NULL 解释成所有情况。

v0 选择 provider reference NOT NULL,把“尚未调用 provider”的命令放在 payment 行创建之前。这是范围选择,不是通用支付设计。

3.3.2 外键动作与生命周期

ON DELETE/ON UPDATE 不是语法偏好,而是父事实改变时子事实怎样存活。

动作 父行删除时 适用直觉 风险
NO ACTION 默认在约束检查时拒绝 引用必须先处理;可与 deferred constraint 配合 名称易被误读为“什么都不做”
RESTRICT 立即拒绝相关删除 父子身份都应保留 清理必须显式按顺序
CASCADE 自动删除子行 子行完全是父的组成部分 一次误删放大成整棵树
SET NULL 清空可空 FK 子事实可独立存活且“原父已无”有意义 丢失直接引用,列必须允许 NULL
SET DEFAULT 写入默认值 存在真实“默认父”且 FK 仍成立 默认哨兵行常掩盖业务错误

NO ACTION 是默认值,在可延迟约束中可以等到稍后检查;RESTRICT 不允许把该引用动作延后。v0 约束都是非 deferrable,本章仍显式写 RESTRICT,让生命周期决定可见。

v0 的动作说明

父 → 子 动作 业务理由
customer → sales_order ON DELETE RESTRICT 历史订单不能因档案删除消失
product → sales_order_item ON DELETE RESTRICT 订单保留对原商品身份的引用
sales_order → sales_order_item ON DELETE CASCADE line 没有脱离 order 的独立身份
sales_order → payment ON DELETE RESTRICT 支付尝试是审计/对账事实,不随订单静默删除

这里存在一个值得审查的张力:order line 级联、payment 限制,意味着有 payment 的 order 不能删除;没有 payment 的 order 删除会带走 line。若业务要求订单一经接受永不物理删除,可以把 order 删除权限整体收紧,而不依赖 FK 动作区分。

CASCADE 不能代替授权和保留政策。对根表执行一条 DELETE 前,仍要预览作用域、锁和子行数量。

更新动作

v0 使用 ON UPDATE RESTRICT,因为内部身份不应作为普通业务修改。业务键如 email、SKU、order_no 不承担外键,因此可以在各自规则下演进而不级联所有关系;历史快照保持原值。

FK 的物理边界

PostgreSQL 会为主键和 unique constraint 创建唯一 B-tree 索引,但不会自动为外键的引用列创建索引。删除或更新父行时,数据库需要在子表检查引用;大表缺少合适索引会产生昂贵扫描与锁等待。

本章只冻结逻辑 FK。ch09 会根据查询和父表变更路径设计引用侧索引;生产建模评审不能永远把它留空。

外键也会参与并发锁定。插入子行和删除父行竞争时,正确性由 PostgreSQL 保证,但延迟、死锁顺序和批量操作仍需设计。

3.3.3 聚合边界与跨表不变量

聚合边界回答“哪些事实必须在一个命令和事务里一起保持一致”。这里不要求套用某种领域驱动设计术语,而是给事务边界一个业务理由。

order 与 line

下单命令至少涉及:

  1. 创建 order 头;
  2. 创建一到多条 line;
  3. 固化商品标识、名称和单价快照;
  4. 计算请求 fingerprint;
  5. 将 order 置为已接受初始状态。

这些动作应在同一事务内完成。外键保证 line 不会指向不存在的 order,但它不能保证每个 order 至少有一条 line。可行策略是:

  • 先以 draft 状态创建不完整 order;
  • 插入 line;
  • 在同一事务中验证至少一行;
  • 只有验证通过才转为 placed
  • 对外查询不把 draft 当已接受订单。

状态值和转换机制在后续闭合。

payment 是相邻边界

支付 provider 是外部系统,网络调用不能加入 PostgreSQL 本地事务。订单与支付记录通过 order_id 关联,但“外部扣款 + 本地状态”需要幂等、重试和对账,而不是保持一个数据库事务数秒等待第三方。

本地可以原子记录一次 provider 响应并更新订单状态;若提交结果未知,依赖 provider reference 与幂等键恢复。资金真相还要与 provider 对账。

跨表不变量要有检测查询

即使暂时没有强制机制,也要能发现违反:

SELECT
    order_id,
    order_status,
    item_count,
    item_subtotal,
    captured_amount
FROM shop_api.order_summary
WHERE (order_status = 'paid' AND item_count = 0)
   OR (order_status = 'paid' AND captured_amount <> item_subtotal)
ORDER BY order_id;

基线应返回零行。review.sql 在事务内插入一个没有 line/payment 的 paid order,上述查询就能发现它,然后回滚。

检测查询不是强制约束。它缩短发现时间,却仍允许错误状态短暂或永久存在。对“绝不能提交”的规则,应设计原子命令、锁与约束/触发器;对外部最终一致规则,则定义容忍窗口、告警和补偿。

不把总额重复写进 order v0

item_subtotal 可由 line 快照的 unit_price * quantity 推导。若 v0 又在 order 保存 total,就产生两个可独立更新的事实。没有性能证据和同步责任前,view 按需计算。

未来若订单金额是法律/支付契约中的独立快照,可能需要保存经明确舍入、币种和折扣规则计算的 accepted total。那时它不是随便的缓存,而是新的权威事实;ch04 的金额决策必须先完成。

聚合评审表

规则 单表约束 本地事务 外部协调
quantity > 0 不需要额外
line 引用真实 product FK 不需要额外
order 至少一行后才 placed
paid 金额等于 accepted total provider 对账
provider 实际扣款一次 本地只能记账

本节验收

  • 每条关系都写出基数、可选性和父子生命周期;
  • 一对一用 unique FK 表达,而不是双方互相引用;
  • 多对多关联的自身属性没有塞回任一父表;
  • 每个 FK 动作都有业务理由;
  • 知道引用侧索引不会由 FK 自动创建;
  • 跨表不变量有检测查询、强制层和外部协调边界。

参考资料


上一节:标识、主键与业务键 · 返回本章目录 · 下一节:模式、所有权与对象边界 · 查看全书目录 · 查看索引中心

3.4 模式、所有权与对象边界

关系模型还需要命名与权限边界。PostgreSQL schema 是数据库内的 namespace,也参与权限解析;它不是独立数据库、租户隔离或微服务边界的自动实现。边界成立要靠所有权、USAGE、对象权限和受控名称解析共同支持。

3.4.1 业务模式、接口模式与内部模式

v0 使用三个 schema:

schema 内容 谁需要 USAGE 承诺
shop customer、product、order、line、payment 规范关系 app、只读角色 业务事实与命令模型
shop_api order_summary 等显式查询接口 app、只读角色 面向消费者的读取形状
shop_private 未来内部 helper、staging 或实现对象 owner 无外部兼容承诺

public 仍存在,但 ch01 已撤销 PUBLICCREATE。本书不把业务对象默认堆进 public,以免命名、权限与扩展对象混杂。

schema 是 namespace

同一数据库可以同时存在:

shop.customer
crm.customer
archive.customer

未限定的 customersearch_path 决定。显式 shop.customer 同时表达对象和边界,迁移、审计和安全敏感 SQL 应优先使用。

schema 不能提供:

  • 独立 WAL、备份或故障域;
  • 独立连接、资源隔离或主要版本;
  • 自动跨租户行隔离;
  • 自动阻止拥有更高数据库权限的角色访问。

需要这些性质时,应评估数据库、集群、RLS、资源治理或服务边界,而不是给 schema 换一个更宏大的名字。

接口 view 不是天然安全 view

shop_api.order_summary 把五表投影为读取形状。v0 的 app 和只读角色本来就有底表 SELECT,因此该 view 只表达接口与派生逻辑,不承担隐藏敏感行的安全责任。

PostgreSQL 普通 view 默认按 view owner 的底层权限检查。若想用 view 作为安全边界,还要审查 security_barriersecurity_invoker、函数是否 leakproof、RLS 与调用者可创建对象的权限。ch23 专门处理;本章绝不因为“只 grant 了 view”就宣称数据已隔离。

接口演进需要兼容契约

CREATE OR REPLACE VIEW 不能任意改变既有列名称、顺序和类型;新查询必须保留现有列,最多在末尾增加列。消费者依赖哪些列、空值和排序,应在 ch12 的服务契约中管理。

本章 view 查询不承诺默认排序。调用方必须显式 ORDER BY

3.4.2 对象所有者与运行角色分离

对象 owner 可以修改、删除对象和转授权限,是结构控制身份;应用只应拥有完成运行任务所需的 DML。v0 角色:

角色 LOGIN 责任
pg36_owner 拥有 schema、table、view,执行受控迁移
pg36_app 读取与修改规范业务关系
pg36_ro 只读规范关系和接口 view
dbuser_dba L1 管理入口;经授权 SET ROLE pg36_owner

DDL 脚本先以管理员认证,再:

SET ROLE pg36_owner;

于是新对象直接归 pg36_owner,而不是先由个人管理员拥有再批量改 owner。NOLOGIN owner 没有可泄露的直接登录凭据。

owner 权力不是普通 ACL

对象 owner 隐含拥有改变、删除和授权对象的能力。即使撤销 owner 的普通 SELECT,owner 仍能重新 grant。因此安全设计不能把“owner 账号”当作日常应用角色。

超级用户又能绕过绝大多数权限边界。L1 的 dbuser_dba 只用于教学管理;生产迁移应采用受审计、短时授权和明确发布流程。

显式权限与默认权限

setup 对当前五表执行:

GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE shop.customer, shop.product, shop.sales_order,
         shop.sales_order_item, shop.payment
TO pg36_app;

GRANT SELECT ON TABLE ...
TO pg36_ro;

并为 owner 在 shop_api 的未来关系设置默认 SELECT。ALTER DEFAULT PRIVILEGES 只影响以后由指定 owner 创建的对象,不会回填现有对象,也不会自动适用于另一创建角色或 schema。

验证实际边界:

SELECT
    has_table_privilege(
      'pg36_app', 'shop.sales_order', 'INSERT'
    ) AS app_can_insert,
    has_table_privilege(
      'pg36_ro', 'shop.sales_order', 'SELECT'
    ) AS ro_can_select,
    has_table_privilege(
      'pg36_ro', 'shop.sales_order', 'UPDATE'
    ) AS ro_can_update,
    has_schema_privilege(
      'pg36_app', 'shop_private', 'USAGE'
    ) AS app_can_use_private;

期望 true, true, false, false。权限函数是当前状态证据,仍要结合角色成员关系、owner 和 RLS 解释。

v0 给 app 直接表 DML 是教学范围,未来 ch12 可能把部分命令收敛到更窄接口;权限演进必须与应用发布一起设计。

3.4.3 避免依赖不受控的 search_path

search_path 是名称解析规则,也是信任列表。若某角色能在路径靠前的 schema 中创建对象,未限定函数、操作符或关系名可能解析到攻击者对象。

本书运行角色默认:

ALTER ROLE pg36_app IN DATABASE pg36_shop
SET search_path = pg_catalog, shop;

迁移脚本每次又显式:

SET search_path = pg_catalog, shop;

随后验证 current_schemas(false)。双重设置让正常会话有安全默认,也让脚本不依赖数据库外部配置。

为什么 pg_catalog 放在前面

即使没有写入路径,PostgreSQL 也会隐式搜索 pg_catalog;显式把它放在前面,能防止同名用户对象抢在系统函数/操作符之前解析。临时 schema 对关系与类型还有特殊搜索规则,安全敏感代码仍应写出限定名。

v0 DDL 使用:

pg_catalog.md5(...)
shop.sales_order
shop_api.order_summary

不是所有普通查询都必须把每个内置函数写全,但迁移、SECURITY DEFINER 代码和生成 SQL 应采用更严格限定。

不把 $user, public 当无害默认

默认 search path 通常包含 "$user", public。若同名用户 schema 存在,或者 PUBLIC 仍能在 public 创建对象,解析结果可能与预期不同。是否危险取决于权限,但最简单的基线是:

  • 撤销不必要的 public CREATE;
  • runtime 不拥有路径中的 schema;
  • 路径只含受信 namespace;
  • 关键对象使用显式限定;
  • 每个函数设置安全 search path,不继承调用者环境。

函数安全在 ch13/ch23 展开。

schema 权限分两层

拥有 schema USAGE 才能解析其中对象;仍需相应表/view 权限才能访问。反过来,表上有 SELECT 但无 schema USAGE,也不能通过普通限定名访问。

shop_private 对 app 撤销 USAGE,且其中未来对象不授予 runtime。owner 和超级用户仍可访问,所以它是实现边界,不是对管理员的保密区。

本节验收

  • 每个 schema 有内容、消费者与兼容承诺;
  • 不把 schema 宣称为独立数据库或租户隔离;
  • owner 是 NOLOGIN,runtime 不拥有业务对象;
  • 当前权限与 default privileges 分开验证;
  • app/ro 不能使用 shop_private
  • search path 只含受信 schema,迁移对象显式限定;
  • 不把普通 view 当成未经审查的安全屏障。

参考资料


上一节:关系与引用完整性 · 返回本章目录 · 下一节:规范化与有意识的冗余 · 查看全书目录 · 查看索引中心

3.5 规范化与有意识的冗余

规范化不是把任何重复字符串都拆掉,而是让每个事实由正确的键决定并只有一个权威写入位置。冗余也不是禁词:历史快照、独立契约事实与有证据的缓存都可能重复字节,但必须有明确语义和一致性责任。

3.5.1 函数依赖与重复事实

函数依赖 X → Y 表示:在模型承诺的范围内,给定 X 就唯一决定 Y。它是业务语义,不是根据当前十行样例猜出的相关性。

v0 中:

customer_id              → customer_ref, current email, display_name
product_id               → current SKU, current name, current price
order_id                 → order_no, customer_id, placed_at, current status
(order_id, line_no)      → product_id, snapshot fields, quantity
(provider, provider_ref) → payment_id, order_id, amount, status

这些依赖帮助识别事实应该放在哪里。

一张宽表的异常

假设每个订单行重复:

customer_current_email
product_current_name
product_current_price
order_status
line_quantity

若这些字段都声称是“当前值”,会产生:

  • 更新异常:商品改名必须更新所有历史 order line;
  • 插入异常:没有订单时无法保存新 product;
  • 删除异常:删除最后一条 line 可能丢掉 product 事实;
  • 矛盾状态:同一 product_id 在不同 line 上显示两个当前价格。

拆成 customer、product、order、line 后,当前商品事实只由 product_id 决定并保存在一处。

规范化不是按表数评分

把每个字段放一张表会制造无意义 join;把相关字段放在同一行也不自动违反规范化。评审问:

  1. 这列描述的是哪一个事实?
  2. 由哪组键决定?
  3. 更新它是否需要同步其他行?
  4. 删除一行会不会意外删除另一类事实?
  5. 同名字段是同一事实,还是不同时间/契约的快照?

PostgreSQL 数组、JSONB 和复合类型也不是天然“不规范”。若值是一个有边界整体、无需独立引用与约束,嵌入可能正确;若其中元素有独立身份、基数和查询生命周期,把它藏进 JSON 只会把关系规则移到应用。ch04 再讨论半结构化边界。

用唯一和外键验证依赖

关系理论上的依赖需要实际约束支撑。product_id 主键保证一行身份,SKU unique 保证另一业务候选键,order line 复合主键保证每个行号只有一条事实。

但数据库看到的约束只是模型声明。若业务允许 SKU 在多个市场重复,单列 unique 就过强;真正键可能是 (market_id, sku)。建模错误不能靠更快索引修复。

3.5.2 派生数据、快照数据与缓存列

字节重复前先判断它属于哪一类:

类别 含义 v0 例子 更新责任
规范事实 当前权威值 product.product_name product 命令
历史快照 某时点被接受的独立事实 sales_order_item.product_name_snapshot 下单时写一次,之后不随 product 改
派生数据 可由权威事实确定计算 item_subtotal view 查询时计算
缓存列 为性能复制派生结果 v0 没有 需要同步、重建与校验
外部事实副本 另一系统权威的本地记录 provider reference/status webhook、查询和对账

快照不是缓存

购买后 product 从 “Coffee Beans” 改名为 “Summer Coffee”,历史 order line 仍应显示用户购买时接受的名称与单价。它们不是等待刷新到最新值的缓存,而是订单契约的一部分。

同理,buyer_email 保存下单时联系快照,customer 表保存当前 email。两者相同只是初始状态:

BEGIN;

UPDATE shop.customer
SET email = 'alice.new@example.test'
WHERE customer_id = 1;

SELECT o.buyer_email, c.email AS current_email
FROM shop.sales_order AS o
JOIN shop.customer AS c USING (customer_id)
WHERE o.order_id = 1001;

ROLLBACK;

示例在事务末回滚。查询中历史与当前值分离是预期,不应启动“修复任务”把订单快照更新掉。

派生值先计算

订单行小计:

unit_price * quantity

订单 item subtotal:

SELECT sum(unit_price * quantity)
FROM shop.sales_order_item
WHERE order_id = $1;

v0 由 shop_api.order_summary 计算,不在 order 存第二份。金额舍入和币种尚未决定,因此现在存 total 还会提前固化错误语义。

ch04 可能使用生成列保存纯行内派生结果;跨行聚合无法用普通生成列维护。物化 view、缓存表或应用缓存需要性能证据。

外部副本要承认权威差异

本地 payment_status='captured' 表示系统记录了 provider 响应,不自动等于资金最终结算。对账可能发现撤销、拒付或漏单。字段名称和文档应说明它是本地观察还是最终财务事实。

3.5.3 接受冗余前先定义一致性责任

增加缓存列前,设计文档至少回答:

canonical_source:
copied_value:
why_needed:
writer:
update_trigger:
consistency_window:
transaction_boundary:
failure_behavior:
rebuild_procedure:
reconciliation_query:
monitoring:
removal_condition:

如果没有 rebuild_procedurereconciliation_query,团队实际上选择了“永远相信它不会错”。

三种一致性责任

同一行、同一事务

例如 line_total = unit_price * quantity。若确有保存价值,可用生成列或同一 SQL 写入并约束;最容易提供强一致。ch04 决定。

跨行、同一数据库

例如 order total 是所有 line 之和。触发器、事务命令或物化汇总都要处理:

  • insert/update/delete line;
  • 批量语句;
  • 并发修改;
  • 事务回滚;
  • 历史数据回填;
  • 触发器禁用与恢复;
  • 重新计算和差异检测。

没有性能证据时,查询时聚合通常更简单。

跨系统、最终一致

例如搜索索引、缓存、分析仓库。必须定义:

  • 事件或 CDC 的投递语义;
  • 可接受延迟;
  • 重复、乱序与丢失处理;
  • 全量重建;
  • 源端删除与保留;
  • 差异告警和人工修复。

“异步同步”不是一致性设计的完整句子。

一个冗余决策示例

假设订单列表 P99 因聚合 line 变慢,提出在 order 保存 item_count

  1. 先用执行计划和负载证明聚合是瓶颈;
  2. 定义源为 sales_order_item
  3. 决定由同一订单命令事务更新;
  4. 禁止其他入口直接改计数;
  5. 提供:
SELECT o.order_id, o.item_count, count(i.*) AS actual_count
FROM shop.sales_order AS o
LEFT JOIN shop.sales_order_item AS i USING (order_id)
GROUP BY o.order_id, o.item_count
HAVING o.item_count <> count(i.*);
  1. 建立回填与告警;
  2. 记录若优化收益消失则删除缓存列。

v0 没有 item_count 列,view 已提供正确基线,未来优化才能有对照。

本节验收

  • 能为每列写出决定它的键;
  • 宽表中的更新、插入、删除异常可以用具体事实解释;
  • 快照、派生、缓存和外部副本不再统称“冗余”;
  • 历史快照不会随当前主数据刷新;
  • 新缓存有权威源、写者、窗口、重建与对账;
  • 没有性能证据时不提前存跨行聚合。

参考资料


上一节:模式、所有权与对象边界 · 返回本章目录 · 下一节:实战:建立逻辑模型 v0 · 查看全书目录 · 查看索引中心

3.6 实战:建立逻辑模型 v0

本实验把前五节的判断落入真实 PostgreSQL。它不是“写一遍 CREATE TABLE 就算建模完成”,而是同时交付业务事实、DDL、样例、正反规则、关系图和未决登记。

风险:

  • setupR1·可逆变更,创建五表、两个 schema 和一个 view;
  • seedR1·可逆变更,会清空并重建五表的教学数据;
  • verifyR0·观察
  • reviewR2·破坏性演练,事务内插入反例,最后强制回滚;
  • resetR2·破坏性演练,删除全部 ch03 对象,要求双重令牌。

3.6.1 用户、商品、订单、订单项与支付

先审阅业务事实清单。五表各自只保存有明确所有权的事实:

关系 主键 业务/外部键 关键关系 快照或派生
customer customer_id customer_ref、email 当前档案
product product_id SKU 当前目录
sales_order order_id order_no、customer-scoped request key customer buyer email 快照
sales_order_item order_id + line_no order、product SKU/name/unit price 快照
payment payment_id provider reference、provider-scoped idempotency key order provider 响应记录

完整关系图:

erDiagram
  CUSTOMER ||--o{ SALES_ORDER : places
  SALES_ORDER ||--|{ SALES_ORDER_ITEM : contains
  PRODUCT ||--o{ SALES_ORDER_ITEM : snapshotted_as
  SALES_ORDER ||--o{ PAYMENT : receives

  CUSTOMER {
    bigint customer_id PK
    text customer_ref UK
    text email UK
  }
  PRODUCT {
    bigint product_id PK
    text sku UK
    numeric current_unit_price
  }
  SALES_ORDER {
    bigint order_id PK
    text order_no UK
    bigint customer_id FK
    text request_key
    text order_status
  }
  SALES_ORDER_ITEM {
    bigint order_id PK,FK
    integer line_no PK
    bigint product_id FK
    text sku_snapshot
    numeric unit_price
    integer quantity
  }
  PAYMENT {
    bigint payment_id PK
    bigint order_id FK
    text provider
    text provider_payment_ref
    text payment_status
    numeric amount
  }

可下载源文件是 model.mmd

v0 已经决定什么

  • 所有关系有主键;
  • customer ref、email、SKU、order no 有业务唯一约束;
  • order request key 在 customer 范围唯一;
  • payment provider ref 与 idempotency key 在 provider 范围唯一;
  • 所有引用由 FK 维护;
  • line_no、quantity、price/amount 的基本正值规则由 CHECK 维护;
  • order line 保存购买时商品快照;
  • runtime 与 owner 分离;
  • 查询摘要由 view 派生,不重复写入 order。

v0 故意没有决定什么

DDL 使用手工 bigint、无指定 precision/scale 的 numerictext 状态和 timestamptz。它们让逻辑关系可以运行,但没有回答:

  • ID 由 identity、UUID 还是应用生成;
  • 金额是否用 minor units、如何表达币种和舍入;
  • 状态允许值与转换;
  • 业务时间 zone、precision、clock source;
  • paid/order-line 等跨表不变量如何原子强制。

因此所有对象 comment 和输出都标记 ch03-v0

3.6.2 在 Pigsty L1 的真实数据库中部署并用样例规则审查

沿用 ch02 私有 service file:

export PGSERVICEFILE="$PWD/pg_service.conf"
export PGSERVICE=pg36-admin
psql -X -w "service=$PGSERVICE" -c '\conninfo'

确认目标是 L1 的 pg36_shop 主库。下载 ch03 文件到同一目录并运行:

chmod +x task.sh
export PG36_EVIDENCE_DIR="$PWD/evidence/ch03/all-$(date -u +%Y%m%dT%H%M%SZ)"
./task.sh all

综合顺序:

manifest → setup → seed → verify → review → review rollback

Pigsty 5436 负责把管理连接送到当前主库 PostgreSQL;应用 DDL 由版本化 SQL 迁移负责,不应塞进 Pigsty 集群拓扑配置。Pigsty 可以声明 database、role 和 service,业务表模式仍属于应用发布物。

setup 的漂移保护

setup.sql使用 CREATE ... IF NOT EXISTS 支持重入,但随后从 pg_attributepg_constraint 验证:

  • 五表的列名、类型、NOT NULL 与列集合精确匹配;
  • 21 个命名 PK/UK/FK/CHECK 存在、类型正确且已验证;
  • 没有额外用户约束;
  • 相关对象 owner 是 pg36_owner

若人为增加 product.drift_probe,setup 在事务中返回:

ERROR: logical model has unexpected columns: product.drift_probe

psql 状态为 3,不会用“relation already exists”掩盖漂移。不要在有价值环境为了测试随意改表;这项负向验证只在可销毁 L1 做。

seed 与状态摘要

seed.sql用固定 ID、金额与 UTC 时间生成:

  • 2 customer;
  • 3 product;
  • 2 order;
  • 3 line;
  • 2 payment。

verify.sql检查行数、孤儿、order 1001 subtotal/captured amount、app/ro 权限和 private schema 边界。基线输出:

status=ok
model_version=ch03-v0
customer_count=2
product_count=3
order_count=2
item_count=3
payment_count=2
open_decision_count=4
relation_checksum=cd7daa66543a6b5e0a5d7fc269558a6c

连续执行两次 all,摘要相同。setup 的 NOTICE 属于“对象已存在并经过后续验证”,不是静默成功。

规则审查必须有正反两面

review.sql在一个事务内先尝试三类非法写入:

反例 预期 SQLSTATE 类别
重复 SKU-COFFEE unique_violation
line 引用不存在 product foreign_key_violation
quantity = 0 check_violation

脚本只捕获预期异常,若错误类型不同或写入意外成功,整个 review 失败。

随后插入三项当前 DDL允许、业务尚未批准的状态:

arbitrary_money_scale_still_allowed=true
arbitrary_order_status_still_allowed=true
paid_without_items_or_payment_still_possible=true

事务末 ROLLBACK,再次 verify 仍得到原校验和。这组输出是 v0 的边界证据:约束有效,但模型尚未完整。

分步运行:

./task.sh setup
./task.sh seed
./task.sh verify
./task.sh review

每次给 evidence 新目录,保留失败现场。

3.6.3 产出金额、时间、状态、标识四项未决清单

未决登记不是随手记下的待办列表,而是 ch04 的输入合同:

决策域 已知事实 仍需决定 关闭证据
金额 line 保存购买价;payment 保存尝试金额 表示、币种、scale、rounding、refund 非法精度/币种有明确失败
时间 下单与支付发生时间是瞬间 业务 zone、precision、clock、范围 DST/客户端 zone 往返样例
状态 order/payment 是不同状态域 允许值、转换、终态、实现 非法值与非法转换分别失败
标识 internal/business/idempotency/provider/trace 含义不同 类型、生成方、公开性、顺序 并发生成与重复用例

决策不是选一个类型名

“金额用 numeric”仍缺少:

  • 是否每行携带 currency;
  • 同币种 scale;
  • 税费、折扣与汇率何时舍入;
  • 负数表示退款还是另建事实;
  • API/JSON 如何序列化;
  • index 和聚合代价。

“时间用 timestamptz”仍缺少:

  • 字段表示发生瞬间还是业务日;
  • 哪个时钟产生;
  • 允许多远未来/过去;
  • 展示用哪个 zone;
  • 精度与外部系统对齐。

“状态用 enum”也没有定义转换;“ID 用 UUID”也没有定义版本、生成位置和暴露范围。ch04 必须把语义、DDL、错误与验证一起交付。

关闭条件

每项 decision 只有同时具备以下内容才从 open 变成 accepted:

  1. 业务语义与反例;
  2. PostgreSQL 表达;
  3. 约束/生成与并发行为;
  4. 旧数据迁移;
  5. API 与错误契约;
  6. 验证查询;
  7. 回退或前滚路径;
  8. 版本适用范围。

只在会议中口头说“应该两位小数”不算关闭。

3.6.4 生成逻辑关系图并链接 ch04 的可靠版本

图必须能与真实目录互证。列出所有 FK:

SELECT
    c.conname,
    c.conrelid::regclass AS child_relation,
    c.confrelid::regclass AS parent_relation,
    pg_catalog.pg_get_constraintdef(c.oid, true) AS definition
FROM pg_catalog.pg_constraint AS c
WHERE c.contype = 'f'
  AND c.connamespace = 'shop'::regnamespace
ORDER BY (c.conrelid::regclass)::text, c.conname;

应得到四条边:

sales_order.customer_id        -> customer.customer_id
sales_order_item.order_id      -> sales_order.order_id
sales_order_item.product_id    -> product.product_id
payment.order_id               -> sales_order.order_id

Mermaid 中 SALES_ORDER ||--|{ SALES_ORDER_ITEM 表达已接受订单应至少一行,但当前 FK 目录只能保证每个 line 有 order,不能反向保证 order 有 line。图表达目标模型,目录查询表达现有强制能力;两者差异必须进入未决登记,而不是让图冒充约束。

v0 到可靠版本的交接

ch04 将建立 v1,至少产生:

schema-v1.sql
migrate-v0-to-v1.sql
verify-v1.sql
negative-cases.sql
partition-adr.md

v1 关系图需要标出类型/状态决策变化,并保留 v0 作为迁移起点。不能直接改写 ch03 文件让读者失去演进过程。

reset 边界

默认 all 不清理。如果必须回到 ch02:

export PG36_RESET_TOKEN=RESET_CH03_MODEL
export PG36_EVIDENCE_DIR="$PWD/evidence/ch03/reset-$(date -u +%Y%m%dT%H%M%SZ)"
./task.sh reset
unset PG36_RESET_TOKEN

SQL 内部还要求 confirm_reset=RESET_CH03_MODEL。reset 只删除:

  • 五个 ch03 表;
  • shop_api.order_summary
  • 空的 shop_apishop_private schema。

它不删除 pg36_shopshop、角色或 ch02 fixture。schema 使用默认 RESTRICT 删除;若出现未知额外对象,事务失败并整体回滚,避免把他人对象级联带走。

本章最终验收

  • 业务事实清单有 owner、命令和不变量;
  • 五表职责没有重叠的当前权威事实;
  • 每类标识的作用域与用途明确;
  • 四条 FK 与关系图互证;
  • owner/runtime/schema 权限符合预期;
  • setup 重跑收敛,额外列会返回状态 3
  • seed 摘要与 checksum 匹配;
  • 三类非法写入触发准确约束;
  • 三项开放规则被实验性证明且完全回滚;
  • 四项 decision register 已链接 ch04 验收;
  • 团队明确 v0 不是生产 DDL。

满足这些条件后进入 ch04《量体裁衣:数据类型、约束与可靠数据表达》

参考资料


上一节:规范化与有意识的冗余 · 返回本章目录 · 下一章:量体裁衣:数据类型、约束与可靠数据表达 · 查看全书目录 · 查看索引中心

4 量体裁衣:数据类型、约束与可靠数据表达

逻辑模型说明“系统要保存什么事实”,物理模式则必须回答“这些事实允许以什么二进制表示、何时判错、怎样迁移、付出多少读写成本”。把 price numericstatus textcreated_at timestamptz 写进表,只是选了类型类别;若没有精度、值域、时区、状态转换和失败语义,它们仍不是可靠的数据合同。

本章把 ch03 的逻辑模型 v0 原地迁移为 ch04-v1。范围内四项未决被关闭:人民币金额改用“分”的整数表示,事件瞬间统一为 timestamptz(3),内部键接入 identity 序列,订单与支付状态改由查找表、迁移图和伴随字段共同约束。与此同时,本章明确留下两条边界:支付总额与订单总额等跨表不变量将在后续事务/数据库逻辑章节处理;当前没有体量和生命周期证据,因此不预先分区。

本章目标

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

  • 根据业务精度、范围、运算与序列化合同选择整数、numeric 或其他数值类型;
  • 区分文本的存储、比较、排序、大小写折叠与 Unicode 正规化语义;
  • 区分瞬间、当地民事时间、业务日期与持续时间,正确使用 timestamptz
  • 为内部键、业务键、外部引用和公开标识选择不同生成策略;
  • 在布尔、枚举、CHECK、查找表和状态机之间作有证据的选择;
  • 明确 SQL NULL、JSON null、缺席与不适用的差别;
  • 正确使用 default、identity、sequence 与 stored generated column;
  • 用命名 PK/UK/FK/CHECK/EXCLUDE 表达不变量,并读懂对应 SQLSTATE;
  • 只对支持的约束使用 DEFERRABLE,理解延迟检查与 ON CONFLICT 的冲突;
  • 从行宽、TOAST、索引和写放大评估类型/约束的物理成本;
  • 通过体量、生命周期或裁剪证据进入分区决策,而不是把分区当默认模板;
  • 在 Pigsty L1 完成 v0→v1 事务迁移、反例审查、复位和 verify:state 取证。

开始之前

本章的升级路径要求已经运行 ch03 的 setup + seed,当前摘要应为:

model_version=ch03-v0
relation_checksum=cd7daa66543a6b5e0a5d7fc269558a6c

沿用 ch02 的私有 PGSERVICEFILEpg36-admin service。实验基线是 PostgreSQL 18.6、Pigsty v4.5.0、Ubuntu 24.04;本章实际使用的 DDL 保持 PostgreSQL 14–18 可用。PG18 新增但旧版本没有的能力会单独标注,不能倒推成全版本事实。

下载资产:

从 v0 到 v1

决策面 ch03-v0 ch04-v1 仍未解决
金额 unconstrained numeric currency_code='CNY' + bigint 多币种、退款、税费
时间 无显式精度的 timestamptz 事件 timestamptz(3);UTC 验证;状态时间一致性 地方日程/长期时区规则
内部标识 手工 bigint BY DEFAULT AS IDENTITY + PK 多写者公开 UUID
状态值 任意 text owner-only catalog + FK 新状态发布流程
状态转换 任意覆盖 transition table + guard trigger 金额等跨表转换前置条件
派生事实 view 中临时相乘 stored line_total_minor 跨行聚合
文本键 只做唯一 ASCII 格式与规范写入 全球化身份匹配
分区 未决定 ADR 接受“暂不分区” ch26 量级复查
flowchart LR
  A["ch03-v0<br/>逻辑关系可运行"] --> B["迁移前证明<br/>可表示、无越界"]
  B --> C["事务 DDL<br/>类型 + 约束 + 序列"]
  C --> D["反例<br/>错误类型与约束名"]
  D --> E["verify:state<br/>ch04-v1"]
  E --> F["ADR<br/>暂不分区"]

v1 的“可靠”是有范围的:它保证本章登记的行内、值域、引用与状态边规则;它不声称一个本地约束能够证明外部支付真实发生,也没有把“订单至少一行、已付金额等于应付金额”伪装成普通 CHECK

本章目录

4.1 金额、文本与时间

先定义单位、比较和时间语义,再选类型名;本章案例把 CNY 金额闭合到整数“分”,把事件瞬间闭合到毫秒精度。

4.2 标识、状态与半结构化数据

内部引用键、外部标识和状态码不是同一种数据;半结构化类型也不能代替需要独立约束与生命周期的关系事实。

4.3 NULL、默认值与生成值

默认值、identity 与生成列分别回答“缺省输入”“键生成”和“行内派生”,不能互换。

4.4 用约束表达不变量

命名约束既是数据库防线,也是稳定的错误定位和目录审计接口;实验会实际验证 EXCLUDE 与延迟唯一检查。

4.5 类型与约束的物理代价

类型与约束会改变行宽、索引数量、TOAST 访问和每次写入的工作量;可靠性设计必须把这些成本显式记账。

4.6 分区决策门

分区是一项针对大表生命周期和访问路径的物理决策。当前模型选择不分区,并保留可触发复查的证据门槛。

4.7 实战:把逻辑模型落成可靠物理模式

升级路径从真实 v0 数据出发,迁移前拒绝无法无损转成“分”的金额,迁移后逐项验证错误语义、应用角色路径、可重入和安全复位。

章节产物

task.sh all 在已经存在 ch03-v0 时执行:

manifest → migrate → verify → negative → constraint-lab

新环境可用 task.sh install 通过同一条迁移链建立空 v1、加载确定性 v1 样例并执行全部审查。两条路径最终必须得到同一摘要:

status=ok
model_version=ch04-v1
money_unit=CNY-fen
session_timezone=UTC
customer_count=2
product_count=3
order_count=2
item_count=3
payment_count=2
order_transition_count=4
partition_decision=not-now
relation_checksum=f8a7bfae59c6d16cd323abecfefe1014

迁移已在 PostgreSQL 18.6 实测以下路径:首次升级、重复升级跳过、复位后从 v0 重建、空库 fresh install、fresh install 重跑、应用角色生成 identity 与执行状态转换、两位以上金额迁移前拒绝且事务完整回滚。

章节验收

  1. 能解释为什么本案例选择整数“分”,也能说出何时应改用 numeric(p,s)
  2. 能证明 timestamptz 保存瞬间但不保存原始 zone name,并复现 DST 双重 01:30;
  3. 能区分 identity、sequence 与 PK 的责任,迁移后不会产生键碰撞;
  4. 非法状态值、非法转换和缺少伴随时间分别由不同规则拒绝;
  5. 能说明 array、range、JSONB 与拆表的边界,而不是统一套用“灵活”;
  6. 能从 pg_constraint 识别约束类型、是否验证和是否可延迟;
  7. 能复现排他冲突和事务内唯一值交换;
  8. 能列出 v1 新增的隐式/显式索引及写放大;
  9. 分区决定有数据、生命周期和查询证据门,而不是凭行数拍脑袋;
  10. allinstall、拒绝路径与 reset 都有独立证据,最终 checksum 一致;
  11. 明确 v1 尚未关闭的跨表/外部事实,不把本章 DDL夸大为完整电商生产模型。

下一章 ch05《运筹帷幄:查询、事务与锁的核心心智模型》 将在这套可靠类型合同上建立查询执行、并发可见性与锁等待的共同原理地图;ch10 与 ch13 再处理并发状态转换和跨表数据库逻辑。

参考资料


上一章:正本清源:从业务规则到关系模型 · 返回上卷导读 · 下一章:运筹帷幄:查询、事务与锁的核心心智模型 · 查看全书目录 · 查看索引中心

4.1 金额、文本与时间

类型选择不是从 PostgreSQL 类型表中挑一个“看起来像”的名字。先写单位、允许范围、比较规则、输入输出协议和舍入时点,类型才有答案。本节先关闭三个最容易产生静默歧义的合同:金额的单位、文本的相等性、事件时间的瞬间语义。

4.1.1 整数、numeric 与金额精度

“精确金额”至少包含币种、最小单位、范围和舍入规则。88.00 这个字面量没有告诉数据库它是人民币元、美元,还是精度为两位的比率。

PostgreSQL 的主要选择是:

表达 精确性 适用条件 主要风险
bigint 最小单位 十进制合同下精确、固定 8 字节 单位固定,乘加范围可证明 忘记单位;乘法溢出;多币种 scale 不同
numeric(p,s) 任意精度十进制,按声明 scale 强制 计量、汇率、多币种或法规要求小数 超 scale 输入会舍入;运算/存储成本高于整数
unconstrained numeric 精确但不限制 scale 中间计算或输入暂存 不能表达业务精度;还能保存 NaN/Infinity
real / double precision 二进制近似 科学计算、容忍误差的测量 十进制金额不能保证精确相等
PostgreSQL money 固定小数的货币格式 少数受控、locale 固定场景 输入输出受 lc_monetary 影响,币种语义仍不完整

numeric 是正确工具,但“金额一律 numeric”仍然太粗。声明 numeric(12,2) 时,超出两位的小数会先被舍入,而不是天然拒绝:

CREATE TEMP TABLE amount_probe (v numeric(12,2));
INSERT INTO amount_probe VALUES (1.239);
SELECT v FROM amount_probe;  -- 1.24

如果业务要求“客户端不得提交超过两位”,应在 API/域层先拒绝,并在迁移中证明可表示性;不能把数据库舍入误读为输入验证。unconstrained numeric 还允许特殊值。尤其 PostgreSQL 为了可排序,把 NaN 视为等于自身且大于普通数,因此 CHECK (amount > 0) 不是排除 NaN 的可靠方法。

本案例为什么用整数“分”

pg36_shop v1 明确限定单币种人民币:

currency_code = CNY
storage unit   = fen
100 fen        = 1 yuan
refund         = a separate future fact

于是:

current_unit_price_minor bigint
unit_price_minor         bigint
amount_minor             bigint
line_total_minor         bigint

88.00 元迁移为 8800 分,39.90 × 2 精确得到 7980 分。列名带 _minor,避免调用方把整数误当元;每个订单聚合又携带 currency_code。订单行与支付通过 (order_id, currency_code) 复合外键引用订单,不能在同一订单下悄悄混入另一币种。

这项选择不是普遍定律。若一个系统同时支持 JPY、CNY、KWD,最小单位的小数位并不相同;若保存汇率、利率或高精度计量,numeric(p,s) 往往更清楚。正确问题是“这一列的量纲与运算合同是什么”,不是“哪种类型更快”。

迁移必须先证明,而不是直接 cast

从 numeric 转 bigint 有一个危险细节:1.5::numeric::bigint 会舍入成 2。因此 migrate-v0-to-v1.sql先检查:

value * 100 = trunc(value * 100)

它验证“以分表示时没有残余”,又允许 88.000 这种只有尾随零的输入;只检查 scale(value) <= 2 会错误拒绝后者。迁移还显式拒绝 numeric 特殊值和越界值,然后才做:

(value * 100)::bigint

列约束把商品/订单行单价限制在 0..10^12 分、quantity 限制在 1..10^6,从而让生成乘积最多 10^18,仍在 signed bigint 的范围内。即使极端输入先在生成表达式中溢出,PostgreSQL 也会失败而不是环绕;边界的价值是让批准范围可读、可测试。

负金额也不是自动等于退款。payment v1 要求正数;退款需要自己的 provider reference、状态与生命周期,将在业务范围扩展时另建事实。用 -amount 复用 payment 会把两个不同事件压进一列符号。

金额验收

SELECT
    order_id,
    item_subtotal_minor,
    captured_amount_minor,
    currency_code
FROM shop_api.order_summary
WHERE order_id = 1001;

预期两项金额均为 16780、币种为 CNY。再把 v0 某价格改成 88.001 后运行迁移,脚本应以状态 3 返回,错误为:

product price cannot be represented as bounded integer minor units

整个事务回滚,v0 view 仍存在,v1 version marker 不存在。这才叫无损迁移门。

4.1.2 text、排序规则与大小写语义

text 解决的是可变长字符串存储,不会自动解决“两个字符串是否代表同一业务身份”。相等、排序、大小写转换和正则字符分类都会受 collation 影响。

PostgreSQL 中 textvarchar 与无长度限制的 varchar 都能保存变长字符串;varchar(n) 额外强制字符数上限。不要为了“数据库优化”给所有列随意加 varchar(255)。只有协议或业务确实存在上限时,长度才是不变量;否则 text 加针对语义的 CHECK 更直接。

把机器标识与人类文本分开

v1 对两类文本采用不同策略:

类别 例子 语义
机器业务键 CUST-ALICESKU-MUGORD-... ASCII、大小写固定、字节稳定
人类展示文本 display/product name Unicode,不把自然语言排序写进身份

机器键使用 COLLATE "C" 的正则检查,例如:

CHECK (sku COLLATE "C" ~ '^SKU-[A-Z0-9-]+$')

C 采用传统字节/ASCII 行为,适合这里刻意受限的标识。自然语言列表若需要中文拼音、德语或重音规则,应在查询/列上选择经批准的 ICU collation;不能让某台 OS 的默认 locale 偶然决定全局业务键。

“大小写不敏感”不是一个完整需求

至少要回答:

  • 只覆盖 ASCII,还是完整 Unicode?
  • 重音、全半角、Unicode 不同正规形是否等价?
  • 比较等价是否也要影响排序、LIKE 与正则?
  • collation provider/版本升级后怎样重建受影响索引?
  • API 返回原始写法,还是规范写法?

v1 的 email 只是教学范围内的小写 ASCII 联系地址:

CHECK (
  email = lower(email COLLATE "C")
  AND email COLLATE "C"
      ~ '^[a-z0-9][a-z0-9._+%-]*@[a-z0-9][a-z0-9.-]*$'
)

这不是 RFC 完整 email 验证,更不是全球通用账户身份算法。它只确保样例系统的所有写入口先规范化,并让原有 exact unique constraint 足以拒绝重复。UpperCase@example.test 会触发 customer_email_canonical

真正的 Unicode case-insensitive 唯一性可以考虑 ICU nondeterministic collation、citext,或规范化生成键;三者的比较、索引、pattern matching 和升级代价不同。PostgreSQL 文档明确指出 nondeterministic collation 会带来性能成本、关闭 B-tree deduplication,并限制部分模式匹配。没有写清这些取舍时,不要只加一个 lower(email) 索引就宣称问题解决。

unique 继承相等性

unique constraint 依赖列/索引采用的相等语义。若 collation 认为两个不同字节串相等,唯一约束也会据此冲突。相反,在确定性默认 collation 下,Alicealice 通常是不同值。业务必须先决定相等,再让 constraint 与 API 使用同一规则。

检查当前数据库可用 collation:

\dOS+

SELECT
    collname,
    collprovider,
    collisdeterministic,
    collversion
FROM pg_catalog.pg_collation
ORDER BY collname
LIMIT 20;

可用名称依赖数据库编码、构建选项和系统/ICU 环境。DDL 不应引用只在开发机存在的 locale 而没有部署前置检查。

4.1.3 datetimestamptz、时区与业务时间

“2026-11-01 01:30”可能是一个日期上的当地钟表读数,也可能是某个已经发生的全球瞬间。在纽约夏令时回拨日,这个读数甚至对应两个不同瞬间。类型必须反映要保存的事实:

事实 合适起点 说明
生日、账期、营业日 date 没有时刻与 zone
已发生的下单/支付瞬间 timestamptz 全球时间轴上的点
每天 09:00 的当地日程模板 time + 业务 zone 还不是具体瞬间
当地民事日期时间 timestamp + zone name 解析后才能得到瞬间
持续时间 interval 或明确单位整数 月、日、秒不是同一长度

只写 timestamp 在 SQL/PostgreSQL 中表示 timestamp without time zonetimestamptztimestamp with time zone 的 PostgreSQL 别名。

timestamptz 保存瞬间,不保存原始时区

timezone-aware 时间在内部按 UTC 瞬间保存,查询输出时再按会话 TimeZone 转换。下面两个显示不同,但值相等:

SET TimeZone = 'UTC';
SELECT '2026-07-29 17:00:00+08'::timestamptz;

SET TimeZone = 'Asia/Shanghai';
SELECT '2026-07-29 09:00:00+00'::timestamptz;

因此 timestamptz 不会记住输入使用 Asia/ShanghaiCST 还是 +08。如果业务必须保留“用户选择的 IANA zone”,另存并验证 zone name;不要从显示偏移反推。

v1 的验证脚本固定:

SET TimeZone = 'UTC';

这让证据输出与执行机器无关。展示给用户时可以:

SELECT placed_at AT TIME ZONE 'Asia/Shanghai'
FROM shop.sales_order;

AT TIME ZONE 的结果类型取决于输入类型;应用边界要明确输出是否仍携带 offset。

精度也是合同

PostgreSQL 时间精度 p 允许 0–6 位秒后小数。v0 没写,v1 明确使用 timestamptz(3),与常见毫秒 API 对齐。更高精度不是免费“更准确”:上游时钟可能根本没有微秒真实性,跨系统序列化也可能截断。若审计要求微秒,改合同并验证每个生产者,而不是只改数据库列。

字段语义被拆开:

  • created_at:数据库接受记录的事务时间,默认 transaction_timestamp()
  • placed_at:订单被业务接受的瞬间;draft 时为 NULL;
  • paid_at / cancelled_at:状态伴随事件;
  • payment.occurred_at:支付 provider 事件时间。

transaction_timestamp()(亦即事务中的 now())在同一事务内保持不变;statement_timestamp() 在每条语句开始变化,clock_timestamp() 才读取实际墙钟。创建时间默认值使用事务时间可让同一原子命令一致;外部事件时间则必须显式传入,不能用插库时钟覆盖 provider 事实。

DST 必须用反例验证

negative-cases.sql创建纽约回拨日的两个显式 offset:

'2026-11-01 01:30:00-04'::timestamptz
'2026-11-01 01:30:00-05'::timestamptz

两者相差一小时。若只传无 offset 的 01:30,解析依赖会话 zone 规则并产生歧义。事件 API 应接受带 offset 的 ISO 8601,或者同时接收受验证的当地时间和 IANA zone,并定义 DST gap/overlap 策略。

不要写 CHECK (occurred_at <= now()) 来维护“不能来自未来”。当前时间会变化,restore/replay 时语义也不同;时钟漂移和允许窗口属于命令验证/运营策略。数据库适合维护同一行中稳定的关系,例如:

paid_at >= placed_at
cancelled_at >= placed_at (如果已经 placed)

v1 的 sales_order_state_time_consistent 同时约束状态与这三个时间。非法的 paid 但无 paid_at 会被明确拒绝。

本节验收

  • 金额单位、币种、范围和退款语义均有书面合同;
  • 迁移先验证可表示性,不依赖会舍入的 numeric→bigint cast;
  • 机器键的 ASCII 语义与人类文本的 Unicode 语义分开;
  • 大小写不敏感需求包含正规化、collation、索引和升级策略;
  • 能说明 timestamptz 保存什么、没有保存什么;
  • DST 双重时间和 UTC/Shanghai 投影均由 SQL 反例验证;
  • 不使用 volatile 当前时间伪装成永久 CHECK

参考资料


返回本章目录 · 下一节:标识、状态与半结构化数据 · 查看全书目录 · 查看索引中心

4.2 标识、状态与半结构化数据

标识回答“是哪一个”,状态回答“现在允许处于什么条件”,半结构化类型回答“一个值内部允许有多灵活”。它们都容易被一个技术名词替代设计:UUID 不自动成为好 API,enum 不自动成为状态机,JSONB 也不自动成为可演进模式。

4.2.1 bigint、UUID 与标识生成

先沿用 ch03 的标识分类:

标识 例子 作用域与承诺
内部主键 order_id 数据库关系内稳定引用
业务键 order_no、SKU 业务可识别,规则可能演进
外部引用 provider payment ref 必须连 provider 一起解释
幂等键 request/idempotency key 特定命令与调用方作用域
追踪标识 trace ID 可观测关联,不承担实体身份

“用 UUID 还是 bigint”只涉及第一行的一部分。把 trace ID 当唯一键、把可重复使用的 request key 当主键,类型再高级也救不了作用域错误。

bigint identity 的取舍

v1 在一个 PostgreSQL 主写者内运行,引用多、样例迁移需要保留旧键,因此选择:

order_id bigint GENERATED BY DEFAULT AS IDENTITY
         PRIMARY KEY

bigint 是固定 8 字节,B-tree 和外键较紧凑;identity 将隐式 sequence 与列关联,并以 SQL 标准语法表达“省略时生成”。但要分清三层责任:

  • identity:定义默认生成机制;
  • sequence:分配候选数值;
  • PK/UNIQUE:真正保证不重复。

identity 文档明确说明它不会自动保证唯一性,所以仍需 PK。sequence 也不承诺无缝连续:nextval 分配的值不会因事务回滚而归还,缓存、故障转移和手工 setval 都会产生洞。ID 是身份,不是行数、会计序号或“绝对提交顺序”。

ALWAYSBY DEFAULT

GENERATED ALWAYS 默认拒绝显式值,除非 OVERRIDING SYSTEM VALUEBY DEFAULT 允许显式值覆盖生成值。本章用 BY DEFAULT,因为:

  1. v0 已经有历史 customer_id/product_id/order_id/payment_id
  2. 确定性实验需要固定样例键;
  3. 普通应用 INSERT 仍省略 ID,走 sequence。

代价是有权写表的调用方可以显式提交 ID,PK 只能拒绝重复,不能禁止“越权选号”。生产接口若不需要导入历史键,可以改成 ALWAYS,或只给应用列级 INSERT 权限。不要把教学迁移便利当成所有系统的默认选择。

为已有列添加 identity 后,隐式 sequence 不知道表中已经有 order_id=1002。迁移脚本用 pg_get_serial_sequence 找到实际 sequence,再把它推进到现有 max(id);反例随后以 pg36_app 省略 ID 插入,证明新值越过历史最大值。应用角色还必须拥有 sequence 的 USAGE,只有 table INSERT 不够。

目录证据:

SELECT
    c.relname,
    a.attname,
    a.attidentity,
    pg_catalog.pg_get_serial_sequence(
        format('%I.%I', n.nspname, c.relname),
        a.attname
    ) AS sequence_name
FROM pg_catalog.pg_attribute AS a
JOIN pg_catalog.pg_class AS c ON c.oid = a.attrelid
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = 'shop'
  AND a.attidentity <> '';

attidentity='d' 表示 BY DEFAULT

什么时候选 UUID

PostgreSQL uuid 是 128-bit 原生类型,适合多写者离线生成、跨系统合并或需要不可顺序猜测的公开标识。不要用 36 字符 text 代替原生 uuid;后者输入会规范化,存储与比较也有明确类型。

还要选择 UUID 版本和生成位置:

  • v4 随机,分布式生成简单,但 B-tree 写入局部性较弱;
  • v7 带时间顺序特征,通常改善索引局部性,但时间信息可被提取,且仍不是数据库提交顺序;
  • 客户端生成可在入库前拿到 ID,数据库生成则集中规则。

PostgreSQL 18 原生提供 uuidv4()/gen_random_uuid()uuidv7()uuidv7() 不能写进本书 PG14–18 的共同 DDL。若要兼容 PG14–17,应明确使用可用的 v4 函数、扩展或应用生成,并在部署前探测。版本条件不应藏在“PG 支持 UUID”这句话里。

本案例保留紧凑内部 bigint,把 order_no 等业务键作为外部接口候选。未来增加 public UUID 是新增一项合同,不需要把现有全部外键重写。

4.2.2 布尔、枚举、查找表与状态机

boolean 适合真正只有两个稳定状态的命题,例如 product 是否 active。若开始出现 pending、reason、时间和转换,增加 is_paidis_cancelledis_failed 会制造互相矛盾的布尔组合;这已经是状态域。

常见值域表达各有边界:

方式 优点 代价 适合
CHECK (status IN (...)) 就地、简单、无 join 改值域需改表约束;无元数据 小而稳定的行内值域
PostgreSQL enum 强类型、4 字节、固定顺序 删除值或重排需重建类型;跨域不可直接比较 真正静态、顺序有意义的集合
lookup table + FK 可附带 terminal/description;可审计 多一条引用与发布顺序 需要元数据或可演进值域
无约束 text 发布最轻 任意拼写永久进入数据 暂存原始外部输入,不适合规范状态

PostgreSQL enum 是静态、有序集合;可增加或改名,但不能直接删除既有值,也不能在不重建类型的情况下重排。状态频繁演进、需要 terminal flag 或运营说明时,lookup table 更合适。本章因此不是宣称“enum 不好”,而是根据订单/支付状态的元数据与演进需求选择查找表。

允许值不等于允许转换

订单值域:

draft, placed, paid, cancelled

允许边:

draft  -> placed
draft  -> cancelled
placed -> paid
placed -> cancelled

payment 则是:

pending -> captured
pending -> declined

FK 只能证明目标状态存在,无法阻止 paid -> draft。v1 用四层表达:

  1. *_status_catalog 保存允许值和 terminal 元数据;
  2. status 列 FK 限制值域;
  3. *_status_transition 保存有向边;
  4. BEFORE UPDATE OF status trigger 查询边并拒绝非法转换。

状态伴随字段由行级 CHECK 继续维护:

draft       placed_at/paid_at/cancelled_at 全空
placed      placed_at 非空,其余空
paid        placed_at、paid_at 非空且 paid_at >= placed_at
cancelled   cancelled_at 非空,paid_at 为空
declined    failure_code 非空

于是三种错误被分开定位:

错误 防线
插入未知 status FK 或状态/时间 CHECK
已有行走一条图外边 transition trigger,约束名 *_status_transition
走合法边但缺伴随字段 sales_order_state_time_consistent 等 CHECK

错误语义比笼统的“状态不合法”更能支持 API 映射和排障。

为什么触发函数是 SECURITY DEFINER

pg36_app 被刻意禁止 USAGE shop_private,却要通过 trigger 读取私有 transition table。函数因此由 pg36_owner 拥有,以 SECURITY DEFINER 执行,并固定:

SET search_path = pg_catalog, shop_private

函数内部仍使用 schema-qualified 名称,且撤销 PUBLIC 的直接 EXECUTE。若 definer 函数沿用调用者可控 search_path,攻击者可能放置同名对象劫持解析。这里的安全边界由 owner、固定路径、最小函数体和真实 app-role 测试共同成立,不是看到 SECURITY DEFINER 四个字就自动安全。

negative-cases.sql会切换到 pg36_app,依次执行 draft→placed→paidpending→captured;应用角色不具 private schema 权限仍能成功。反向 placed→draft 则捕获 SQLSTATE 23514sales_order_status_transition

本章仍没有强制“paid 必须有足额 captured payment”。那是跨表、并发敏感不变量,普通 CHECK 做不到;ch10/ch13 会在锁、事务和数据库逻辑语境中处理。状态图解决的是边,不应被夸大成完整支付正确性。

4.2.3 数组、范围、JSONB 与拆表边界

PostgreSQL 的丰富类型可以把多个值放进一列,但“能存”不是“应该存”。判断边界时问:内部元素是否有独立身份、约束、引用、更新、权限、生命周期或高频查询?

array:一个值里的同类序列

array 适合有限、整体拥有、通常整体读写的同类值,例如固定传感器通道或一次计算输出。它不是多对多关系的快捷替代。官方文档直接提醒“arrays are not sets”;如果不断按元素搜索、去重、引用或更新,单独的 child table 通常更易约束和扩展。

还有两个容易误读的点:

  • DDL 中写 integer[3] 并不会强制长度 3;
  • 维数声明也不形成运行时限制。

若长度是业务不变量,需要 CHECK (cardinality(v)=3);若元素有身份/外键,拆表。

range:把区间当成原子值

range 能同时表达下界、上界、开闭和空区间,适合预约、有效期和价格带。tstzrangetimestamptz 为 subtype;&& 表示重叠,@> 表示包含。

constraint-lab.sql在临时表中定义:

slot tstzrange NOT NULL,
EXCLUDE USING gist (slot WITH &&)

插入 [09:00,10:00) 后,[09:30,10:30) 触发 exclusion_violation,而 [10:00,11:00) 因半开边界可以相邻。若要求“同一房间内不重叠”,还需:

EXCLUDE USING gist (
  room_id WITH =,
  slot    WITH &&
)

普通 bigint/text 的 GiST equality operator class 可由 btree_gist 提供。它是 PostgreSQL 随附的 trusted contrib extension,Pigsty 扩展仓库覆盖 PG14–18;但 extension 仍是数据库对象,应先查 pg_available_extensions、声明 owner/schema/升级策略,再 CREATE EXTENSION。本章单列 range 的实验不需要安装它。

JSONB:灵活文档,不是免模式

jsonb 在写入时解析为二进制结构,支持运算符与 GIN 索引;通常比保留原始文本格式的 json 更适合查询。但它仍有模式,只是默认不由列定义完全强制。官方设计建议 JSON 文档保持可预测结构,并提醒更新大文档仍会锁整行。

合适候选包括:

  • 第三方 provider 的原始响应快照;
  • 随版本演进但整体拥有的配置;
  • 很少查询、无需独立引用的稀疏扩展属性。

应该拆表/列的信号包括:

  • 字段参与 PK/UK/FK 或金额/时间约束;
  • 子项有独立身份、权限或生命周期;
  • 需要按子项频繁更新、连接或统计;
  • 每个写者都要靠不同 JSON path 才能维护规则;
  • 已经为大量固定 key 建 expression index。

还要区分 SQL NULL 与 JSON null

SELECT
    NULL::jsonb IS NULL,       -- true:SQL 值缺席
    'null'::jsonb IS NULL;     -- false:存在一个 JSON null 值

把二者混用会让“字段缺席、字段为 null、列为 NULL”出现三种状态而无人负责。

v1 的决定

订单行、状态和支付都是独立关系事实,v1 不把它们塞进 array/JSONB;核心五表也不增加“以后备用”的 metadata jsonb。范围类型只用于独立排他约束实验。未来若保存 provider payload,应另外定义大小上限、敏感字段脱敏、结构版本、索引预算和保留期。

本节验收

  • identity、sequence 与 PK 的责任明确,迁移后 sequence 已对齐;
  • 能基于写者拓扑与公开性选择 bigint/UUID,而不是按潮流;
  • 允许状态值、允许转换和伴随字段由三种机制分别表达;
  • app role 的 definer-trigger 路径与非法反向路径都被实测;
  • 能说出 array 应拆表、range 应使用、JSONB 应拒绝的各三条信号;
  • 不把 core JSONB 视为“以后总能兼容”的免费保险。

参考资料


上一节:金额、文本与时间 · 返回本章目录 · 下一节:NULL、默认值与生成值 · 查看全书目录 · 查看索引中心

4.3 NULL、默认值与生成值

NULL、default、identity 和 generated column 都会让 INSERT 语句“少提供一些东西”,但它们表达四种不同事实:值缺席、缺省输入、键分配和行内派生。混用后最常见的结果是“不知道”被一个假默认覆盖,或者可以计算的值被多个写者分别维护。

4.3.1 “未知”“不存在”与空值语义

SQL NULL 不是空字符串、0、false,也不是一个能用 = 比较的普通值。它表示该列在这一行没有一个已知 SQL 值。缺席的业务原因可能不同:

原因 例子 应否合并为 NULL
尚未发生 draft 的 placed_at 可以,状态给出原因
不适用 captured payment 的 failure_code 可以,status 给出原因
未知但应该知道 遗失的 provider timestamp 往往应拒绝或单独标记
被删除/保密 用户请求隐藏字段 通常需要独立审计语义
空集合 订单没有 line 关系中是零行,不是某列 NULL

只要不同原因会导致不同命令、权限、统计或展示,就不要把它们都压成无法区分的 NULL。

三值逻辑

涉及 NULL 的普通比较产生 UNKNOWN:

SELECT
    NULL = NULL,        -- NULL / UNKNOWN
    NULL <> 1,          -- NULL / UNKNOWN
    NULL IS NULL,       -- true
    NULL IS DISTINCT FROM NULL;  -- false

WHERE 只保留条件为 TRUE 的行,FALSE 与 UNKNOWN 都被过滤。因此:

WHERE status <> 'paid'

不会包含 status 为 NULL 的行。需要把 NULL 当一个可比较分支时,显式使用 IS NULLIS [NOT] DISTINCT FROM。ch05 会在查询与并发语境中继续三值逻辑,本章先把它当模式设计合同。

CHECK 不会自动拒绝 NULL

PostgreSQL 的 CHECK 在表达式为 TRUE 或 NULL 时都视为通过。下面仍允许 NULL:

price bigint CHECK (price > 0)

若值必须存在,还要 NOT NULL。若 nullable 列与状态联动,应把所有分支写完,而不是指望 UNKNOWN 代替业务语义。

v1 的订单规则近似:

CHECK (
  (order_status = 'draft'
   AND placed_at IS NULL
   AND paid_at IS NULL
   AND cancelled_at IS NULL)
  OR
  (order_status = 'placed'
   AND placed_at IS NOT NULL
   AND paid_at IS NULL
   AND cancelled_at IS NULL)
  OR ...
)

这让每个状态的空值形状是封闭集合。payment 同样规定 declined 才有且必须有 failure_code,pending/captured 必须为 NULL。NULL 不再是“调用方忘了填也没关系”,而是由另一列解释的合法状态。

SQL NULL 与 JSON null

SELECT
    NULL::jsonb IS NULL AS sql_value_absent,
    'null'::jsonb IS NULL AS json_value_absent,
    '{"x":null}'::jsonb ? 'x' AS key_exists;

结果是 true、false、true:列值缺席、存在 JSON null、对象中存在一个值为 null 的 key 是三件事。若 API PATCH 还把 key 缺席解释为“不修改”,就有第四种命令语义。接口层必须显式映射,不能依赖驱动猜测。

NULL 设计清单

对每个 nullable column 写下:

  1. 哪些业务状态允许 NULL;
  2. NULL 表示尚未发生、不适用还是未知;
  3. 谁能把它从 NULL 改为非 NULL,能否改回;
  4. unique、join、aggregate 与 API 如何处理;
  5. 是否需要伴随 reason/status 才能解释。

答不出来时优先 NOT NULL。PostgreSQL 官方也建议多数列应为 not null;允许 NULL 应是一项积极设计,而不是省略约束的默认。

4.3.2 默认值、身份列与序列

default 是“INSERT 省略该列或显式写 DEFAULT 时使用的表达式”,不是缺失业务信息的修复器:

created_at timestamptz(3)
           NOT NULL
           DEFAULT transaction_timestamp()

如果调用方显式传 NULL,default 不会替换它;NOT NULL 会拒绝。default 也不会持续维护列值,后续其他列变化时它不重新计算。

默认值的权威时钟

customer.created_atproduct.created_at 表示数据库记录创建时间,因此可以由数据库默认产生。placed_at 与 provider occurred_at 表示业务/外部事件,必须由相应命令显式提交,不能用 default 掩盖事件时间遗失。

PostgreSQL 允许 default 使用 volatile 表达式。transaction_timestamp() 在整个事务内固定,适合同一事务产生一致的 recorded-at;clock_timestamp() 会在语句执行期间变化。选择哪一个是审计语义,不是风格偏好。

identity 是有生命周期的 default 机制

identity 列背后有隐式 sequence。INSERT 省略 ID 时等价于请求 sequence 的下一个值,但 sequence 状态与普通表事务不同:

  • nextval() 的值即使事务回滚也不会归还;
  • 并发会交错分配;
  • sequence cache 和故障切换可能留下空洞;
  • 手工 setval 可改变后续位置;
  • identity 本身不替代 PK。

因此不应从连续 ID 推算“没有删除”、订单数量或严格提交先后。需要法定连续票号时,要单独建模分配、作废和审计,接受对应串行化成本。

数据迁移中的 sequence 对齐

从已有手工 bigint 添加 identity 时,下面操作还不够:

ALTER TABLE shop.sales_order
  ALTER COLUMN order_id
  ADD GENERATED BY DEFAULT AS IDENTITY;

新 sequence 通常从 1 开始,下一次自动 INSERT 会撞历史 PK。本章迁移对四张 identity 表执行:

SELECT pg_catalog.setval(
  pg_catalog.pg_get_serial_sequence(
    'shop.sales_order', 'order_id'
  ),
  (SELECT max(order_id) FROM shop.sales_order),
  true
);

空表要使用 setval(seq, 1, false),这样下一次返回 1;非空表用 max 与 is_called=true,下一次返回 max+1。脚本通过循环同时处理空/非空情况。

还要授权:

GRANT USAGE, SELECT
ON ALL SEQUENCES IN SCHEMA shop
TO pg36_app;

table INSERT 和 sequence USAGE 是不同权限。verify-v1.sqlhas_sequence_privilege 检查,反例脚本再以真实 app role 插入,防止“owner 测得通,应用却报 permission denied”。

确定性种子

seed-v1.sqlTRUNCATE ... RESTART IDENTITY,显式插入固定 ID,再把 sequence 对齐到最大值。它只适用于隔离教学数据;生产数据库不应为了重放 fixture 重置 identity。BY DEFAULT 让这种导入可行,但也意味着运行权限设计要阻止不受信调用方自行选号。

4.3.3 生成列与数据库派生事实

生成列是“由同一行其他列永远计算出来”的事实。本章订单行:

line_total_minor bigint
GENERATED ALWAYS AS (
  unit_price_minor * quantity::bigint
) STORED

应用不能直接给它赋值;base column 插入或更新时,PostgreSQL 重新计算。本章反例显式提交 line_total_minor=1,应得到 SQLSTATE 428C9,证明不存在第二个写者。

default、generated、view 的边界

机制 何时计算 可引用什么 是否存储 适合
default INSERT 缺省时一次 不能引用同一行其他列 created_at、缺省配置
stored generated 每次写入行 当前行、immutable 表达式 高频读取的确定行内派生
virtual generated 读取时 受更严格表达式限制 PG18 新能力,需版本门
view expression 查询时 可 join/aggregate 跨行投影与接口
materialized view refresh 时 可 join/aggregate 可接受陈旧的查询结果

PostgreSQL 14–17 只支持 stored generated column;PG18 增加 virtual,并把省略 kind 的默认行为改为 virtual。本书共同基线因此始终显式写 STORED,不依赖版本默认。

generation expression 只能使用 immutable 函数,不能含 subquery,也不能引用另一 generated column;它适合 unit_price_minor * quantity,不适合:

sum(all lines of this order)
current product price
captured payments
now()

这些值依赖其他行、其他表或时间。订单 subtotal 继续放在 shop_api.order_summary view 中;若未来缓存,必须有独立一致性与刷新合同。

存储不是免费

stored generated column占行空间,并在 base field 更新时增加计算与 WAL/写入。它可能被索引,读取也不必重复计算;是否值得由读写比例和行宽证明。本例是教学上的小而确定派生:金额整数相乘便宜,结果被 summary 使用,并用边界约束防溢出。

注意生成列 attnotnull 不会因为表达式看起来非空而自动变 true。本例由 unit_price_minor 和 quantity 的 NOT NULL 保证结果非 NULL,再由 bounds CHECK 保证批准范围。验证同时检查:

a.attgenerated = 's'
line_total_minor = unit_price_minor * quantity::bigint

什么时候不保存派生值

优先查询时计算,除非至少有一项证据:

  • 表达式昂贵且读远多于写;
  • 需要对派生值建立索引;
  • 派生值是经批准的写时快照,而非随源事实变化;
  • 性能测试证明存储收益超过行宽与写放大。

不要以“以后查询方便”为理由复制 subtotal、captured amount 到 order 头。每多一个存储副本,就要回答谁在并发、失败与恢复后维护一致。

本节验收

  • 每个 nullable 列都有状态解释,CHECK 分支不会被 UNKNOWN 穿透;
  • 能区分 SQL NULL、JSON null、JSON key 缺席与 PATCH 不修改;
  • default 只用于权威可缺省输入,不覆盖外部事件事实;
  • identity sequence 在迁移与 seed 后都对齐,app 拥有精确权限;
  • generated column 只维护 immutable 行内派生,跨行聚合仍在 view;
  • PG18 virtual generated 没有误写成 PG14–18 共同行为。

参考资料


上一节:标识、状态与半结构化数据 · 返回本章目录 · 下一节:用约束表达不变量 · 查看全书目录 · 查看索引中心

4.4 用约束表达不变量

约束把一句业务命题变成所有写入口共享、并发下原子执行的失败条件。它的价值不止是“挡脏数据”:命名、类型、检查时点、支持索引与 SQLSTATE 共同组成可审计合同。本节从常用约束走到排他与延迟检查,同时严格说明每种机制不能做什么。

4.4.1 主键、唯一、外键与检查约束

选择约束先看规则的作用域:

不变量 PostgreSQL 机制 自动支持结构
一行有唯一且非空身份 PRIMARY KEY unique B-tree + NOT NULL
一个/一组值在表中唯一 UNIQUE unique B-tree
引用必须存在 FOREIGN KEY 被引用端必须有合格 unique;引用端不自动建索引
当前行布尔命题成立 CHECK 无索引
列值存在 NOT NULL 专用高效检查
任意两行不能满足一组冲突运算 EXCLUDE 指定 access method 的索引

命名约束就是命名失败

v1 不依赖 PostgreSQL 自动生成名:

CONSTRAINT product_price_minor_bounds
  CHECK (current_unit_price_minor BETWEEN 0 AND 1000000000000)

调用方可以按 SQLSTATE 类别处理,又能记录 constraint name 定位具体不变量:

SQLSTATE condition 本章例子
23505 unique_violation 重复 order_no
23503 foreign_key_violation 不存在的引用
23514 check_violation 非法币种/状态时间
23502 not_null_violation 必填列为空
23P01 exclusion_violation 时间段重叠

不要依赖英文 error message 文本,它会随版本、locale 和上下文变化。也不要假设多项同时违反时必定先报告某一个:约束检查顺序不是 API 合同。本章反例每次只制造一个目标错误,并验证 constraint name。

pg_constraint.conname 不是数据库全局唯一;定位时至少带 relation/schema。目录查询:

SELECT
    c.conrelid::regclass AS relation,
    c.conname,
    c.contype,
    c.convalidated,
    c.condeferrable,
    c.condeferred,
    pg_catalog.pg_get_constraintdef(c.oid, true) AS definition
FROM pg_catalog.pg_constraint AS c
WHERE c.conrelid IN (
    'shop.sales_order'::regclass,
    'shop.sales_order_item'::regclass,
    'shop.payment'::regclass
)
ORDER BY relation::text, c.conname;

PK、unique 与 identity 不是同义词

identity 生成候选值,PK 维护唯一/非空并声明主要行身份。一个表只能有一个 PK,却可以有多个业务 unique:

sales_order_pkey                    (order_id)
sales_order_order_no_key            (order_no)
sales_order_request_key             (customer_id, request_key)
sales_order_order_currency_key      (order_id, currency_code)

最后一个复合 unique 看似冗余,因为 order_id 已唯一;它是复合 FK 的合法目标,使 line/payment 必须携带与 order 相同的 currency。这个语义换来一个额外 unique index,成本在 4.5 明确记账。

默认情况下,unique constraint 把多个 NULL 视为互不相等,所以 nullable unique column 仍可有多个 NULL。PG15+ 提供 NULLS NOT DISTINCT,但本书 PG14–18 共同行为不能无条件使用。最简单的业务键通常应 NOT NULL

FK 既是存在性,也是生命周期

本章延续:

  • order→customer:ON DELETE RESTRICT
  • line→product:ON DELETE RESTRICT,历史快照仍保留引用;
  • line→order:ON DELETE CASCADE,line 是 order 的组成部分;
  • payment→order:ON DELETE RESTRICT,支付记录有独立保留要求。

FK 自动要求被引用列可唯一查找,却不会自动为 child referencing columns 建索引。删除/更新 parent 时 PostgreSQL 需要在 child 找引用;数据大时通常要为 child FK 设计索引,但索引列序和其他查询可以合并考虑,不能由 FK 机械生成器盲加。

CHECK 只维护当前行的稳定命题

PostgreSQL 明确不支持把其他行/表查询塞进 CHECK 并承诺持续一致。下面不是合法方向:

CHECK (
  amount_minor <= (
    SELECT sum(line_total_minor)
    FROM shop.sales_order_item
    WHERE order_id = payment.order_id
  )
)

其他行后来变化时不会自动重检,dump/restore 也可能破坏假设。跨行“互不重叠”可用 EXCLUDE,引用用 FK,唯一用 UNIQUE;真正跨表业务命令留给事务/trigger/constraint trigger 等受验证实现。

CHECK 还把 NULL 结果视为通过,所以必填列另加 NOT NULL。v1 的 money bounds、ASCII 格式和状态时间都是只引用当前行、对同一输入稳定的表达式,符合边界。

4.4.2 排他约束与 btree_gist 的适用条件

UNIQUE 只能表达“这些键不能相等”。许多业务冲突是“时间段不能重叠”“圆不能相交”“同笼不能出现不同动物”。EXCLUDE 接受一组 operator,要求任意两行比较时,至少有一个 operator 结果为 false 或 NULL。

单资源预约:

CREATE TEMP TABLE booking_window (
  booking_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  slot       tstzrange NOT NULL,
  CONSTRAINT booking_window_nonempty
    CHECK (NOT isempty(slot)),
  CONSTRAINT booking_window_no_overlap
    EXCLUDE USING gist (slot WITH &&)
);

&& 为 range overlap。排他约束自动建立 GiST index,两个重叠 slot 触发 23P01。把边界统一为 [) 很重要:09:00–10:00 与 10:00–11:00 不重叠,避免相邻时段同时包含 10:00。

多资源为什么需要 btree_gist

若每个 room 分别不能重叠:

EXCLUDE USING gist (
  room_id WITH =,
  slot    WITH &&
)

GiST 原生理解 range overlap,但普通 bigint/text equality 需要相应 GiST operator class。btree_gist 为常见标量类型提供类似 B-tree 的 GiST operator classes,适合这种“标量相等 + 空间/范围运算”的多列 GiST。

它不是“更快的 B-tree”:

  • 官方文档明确说通常不会优于标准 B-tree;
  • 它不能像 B-tree unique index 那样维护普通唯一性;
  • 它的价值是让不同运算共存于 GiST/EXCLUDE。

在 Pigsty L1 先观察:

SELECT
    name,
    default_version,
    installed_version
FROM pg_catalog.pg_available_extensions
WHERE name = 'btree_gist';

本章实测 btree_gist_available=true,但单列 slot 实验无需创建 extension,因此不改变数据库 extension 状态。若真实模式需要它,再由配置/迁移明确:

CREATE EXTENSION btree_gist;

Pigsty 负责把所需软件包/内核扩展交付到节点;CREATE EXTENSION 仍是目标 database 中的 DDL,要纳入 owner、schema、版本和备份恢复合同。

并发正确性

EXCLUDE 的优势不是语法短,而是把冲突交给 access method 与约束在并发写入中仲裁,避免应用“先查没有重叠,再插入”之间的竞态。应用仍可预查给友好提示,但最终以数据库 exclusion_violation 为准。

选择前确认:

  • 冲突关系可由可索引 operator 精确表达;
  • NULL/空 range 是否允许;
  • 边界是 [) 还是其他形式;
  • 是否需要按资源、租户等额外等值维度隔离;
  • 索引/锁竞争在目标写入量下可接受。

4.4.3 仅对支持类型使用 DEFERRABLE,并说明事务末校验代价

DEFERRABLE 不是“让所有约束最后再查”的通用开关。PostgreSQL 只允许:

  • UNIQUE;
  • PRIMARY KEY;
  • EXCLUDE;
  • REFERENCES / FOREIGN KEY。

NOT NULL 与 CHECK 不可延迟。当前文档若写出 CHECK (...) DEFERRABLE,DDL 本身就是错的。

两个维度

DEFERRABLE / NOT DEFERRABLE
    能否由事务改变检查时点

INITIALLY IMMEDIATE / INITIALLY DEFERRED
    每个事务开始时的默认检查时点

NOT DEFERRABLE 是默认,不能用 SET CONSTRAINTS 推迟。deferrable 且 initially immediate 默认在每条语句后检查,可以在事务中改为 deferred;initially deferred 默认到提交时检查。

实验中有两个唯一 slot:

('A', 1), ('B', 2)

要在一条/一组操作中交换为 A=2、B=1,中间状态可能碰到唯一值。定义:

CONSTRAINT display_slot_slot_key
UNIQUE (slot_no)
DEFERRABLE INITIALLY IMMEDIATE

事务内:

SET CONSTRAINTS display_slot_slot_key DEFERRED;
UPDATE display_slot
SET slot_no = CASE slot_no WHEN 1 THEN 2 WHEN 2 THEN 1 END;
SET CONSTRAINTS display_slot_slot_key IMMEDIATE;

最后一条会立即检查当前状态;若仍冲突,就在此处失败,不必等 COMMIT。这既是实验验收,也是长事务中提早暴露错误的方法。

代价与限制

延迟检查会把失败推到更远位置,事务可能做了大量工作后才回滚,并在事务期间保留用于待检查状态的资源。官方还指出:

  • deferrable constraint 不能作为 INSERT ... ON CONFLICT 的 conflict arbiter;
  • deferrable uniqueness 可能显著慢于 immediate uniqueness;
  • FK 被引用的 unique/PK 必须是 non-deferrable 合格键。

所以“以后批量导入方便”不足以把所有键改成 deferrable。先有一个需要事务内暂时不一致、提交时恢复的具体流程,再付成本。

v1 所有业务约束保持 immediate。状态转换也在每次 UPDATE 时立即拒绝;订单 command 没有证明需要暂时进入图外状态。隔离的 constraint-lab.sql只演示正确适用点,不改变核心模式。

目录验收:

SELECT
    conrelid::regclass,
    conname,
    contype,
    condeferrable,
    condeferred,
    convalidated
FROM pg_catalog.pg_constraint
WHERE conrelid = 'display_slot'::regclass;

临时 unique 应为 deferrable=true、deferred-by-default=false;临时 CHECK 不应出现 deferrable=true。

本节验收

  • 每条不变量根据行内、唯一、引用或跨行冲突选对约束;
  • error handling 使用 SQLSTATE + constraint identity,不匹配英文全文;
  • 知道 unique/PK/EXCLUDE 会建索引,FK child 不自动建;
  • 排他实验真实拒绝 overlap,且没有无谓安装 extension;
  • 能列出四类可延迟约束与两类不可延迟约束;
  • 能解释延迟检查对 ON CONFLICT、失败时点和性能的影响;
  • 核心 v1 没有为假想需求滥用 DEFERRABLE。

参考资料


上一节:NULL、默认值与生成值 · 返回本章目录 · 下一节:类型与约束的物理代价 · 查看全书目录 · 查看索引中心

4.5 类型与约束的物理代价

可靠性不是无成本的,但“为了性能去掉约束”也不是成本分析。类型决定每行布局和可用运算,PK/UK/EXCLUDE 带来索引,FK/CHECK/trigger 增加写时检查;这些成本必须测量并与它们阻止的错误一起评估。

4.5.1 行宽、对齐、TOAST 与更新成本

一行不等于各列声明大小简单相加。heap tuple 还有 header、NULL bitmap 与对齐 padding;textnumeric、JSONB、array 等 varlena 值有长度头,足够宽时可能压缩或移到 TOAST table。列顺序、空值分布和具体内容都会改变实际大小。

先用 PostgreSQL 测,而不是凭类型名猜:

SELECT
    pg_column_size(8800::bigint) AS bigint_bytes,
    pg_column_size(88.00::numeric) AS small_numeric_bytes,
    pg_column_size(
      12345678901234567890.1234567890::numeric
    ) AS wide_numeric_bytes;

本章 PG18.6 样例分别得到 8、8、22。它说明“小 numeric 有时与 bigint 同样紧凑”,不说明两者物理/运算成本等价;numeric 是变长、按四位十进制一组存储并带额外开销,值越宽占用越多。选择 integer minor unit 的首要理由仍是单位与范围合同,固定宽度只是可预期的附带收益。

测完整行:

SELECT
    round(avg(pg_column_size(t))) AS avg_row_payload
FROM shop.sales_order AS t;

在当前两行确定性 fixture 上约为 187 bytes;这不是生产容量估算。生产要取有代表性的长文本、NULL 比例和状态,结合:

pg_relation_size(...)
pg_table_size(...)
pg_indexes_size(...)
pg_total_relation_size(...)

区分 heap、TOAST、索引与总占用。pg_column_size(row) 也不包含页面空闲、dead tuple、FSM/VM 和索引。

TOAST 解决页限制,不消除宽值成本

PostgreSQL 常见 page size 为 8 KiB,单个 tuple 不能跨页。TOAST 会对可 TOAST 类型压缩和/或拆成外置 chunk;触发阈值通常约 2 KiB。主 heap 只留 pointer,查询不读取宽列时可少拉取数据。

但:

  • 宽值仍占磁盘、WAL、备份和网络;
  • 读取它需要 detoast/decompress;
  • 更新宽值会产生新版本及新的 TOAST 数据;
  • 一个 table 有 toast relation 不代表当前已经有值被外置。

本章五表都有 text,因此目录显示 reltoastrelid;固定短样例并未因此“免费存储无限文本”。给 provider payload 一个无限 JSONB 列,会把更新竞争、保留期与敏感数据一起带进主行。

更新会创建新行版本

PostgreSQL MVCC 的 UPDATE 通常写一个新 tuple version。若没有修改 indexed column 且同页有空间,可能使用 HOT 降低索引更新;列变宽、索引过多或页面太满会降低机会。stored generated line_total_minor 又增加 8 bytes,并在单价/数量变化时重算。

不要为省几个 padding byte 就随意重排成熟表的列:重写表、应用兼容和迁移锁的代价常远大于收益。新表可以把固定宽、常用非空列放在合理位置,但最终仍用真实数据测量。

4.5.2 隐式转换、操作符与索引可用性

SQL 中的 = 不是一个能比较任意两值的万能函数。PostgreSQL 根据两边类型、可见 operator、implicit cast 和 preferred type 选择具体实现。unknown string literal 常能借另一边类型推断:

WHERE order_id = '1001'

这里 literal 可以解析为 bigint。但 driver parameter 一旦被声明成 text,就不再是 unknown:

PREPARE bad(text) AS
SELECT * FROM shop.sales_order WHERE order_id = $1;
-- operator does not exist: bigint = text

正确做法是让 driver 绑定 bigint,或在确定输入已经验证时显式 cast parameter:

WHERE order_id = $1::bigint

不要为了“兼容所有输入”cast indexed column:

WHERE order_id::text = $1

普通 sales_order_pkey(order_id) 索引保存 bigint operator class;对列包一层 text cast 后,表达式不同,除非另有 matching expression index,否则通常不能用原 PK index 作为相同条件。

三件事必须一致

索引可用性取决于:

  1. query expression;
  2. 解析出的 operator 与类型;
  3. index key expression、collation 与 operator class。

文本大小写查询若写 lower(email),普通 UNIQUE(email) 不是该表达式的索引。若创建 expression index,查询又必须使用可匹配的表达式与 collation。一个隐式 collation 或 cast 的变化,既可能改变语义,也可能改变计划。

检查 parameter 类型:

SELECT
    name,
    parameter_types,
    statement
FROM pg_catalog.pg_prepared_statements;

检查 cast 策略:

SELECT
    castsource::regtype,
    casttarget::regtype,
    castcontext
FROM pg_catalog.pg_cast
WHERE castsource IN ('text'::regtype, 'bigint'::regtype)
   OR casttarget IN ('text'::regtype, 'bigint'::regtype);

castcontext 区分 implicit、assignment 和 explicit;不是目录里存在 cast 就能自动应用。

由计划验证,不靠规则口诀

在小 fixture 上 planner 选择 seq scan 很正常,不能据此判定索引“失效”。ch07 会用有规模的数据与 EXPLAIN (ANALYZE, BUFFERS)。本章先保留方法:

  • 确认 column/parameter 精确类型;
  • 查看 predicate 中是否对 indexed column 做函数/cast;
  • 查看实际 operator 和 index definition;
  • 在代表性数据量、统计信息与配置下比较计划;
  • 不用长期关闭 enable_seqscan 来“逼出答案”。

金额 API 同理:把 amount_minor 作为整数传输,不能在 SQL 中反复 amount_minor / 100.0 再与 numeric 参数比较并期待原索引语义不变。展示单位转换放投影层,过滤/连接使用存储单位。

4.5.3 约束、索引与写放大的关系

每个 INSERT/UPDATE 不只写 heap:

  • PK/UNIQUE 要维护 B-tree 并检查冲突;
  • EXCLUDE 要维护指定 index 并检查 operator 冲突;
  • FK 要查询 referenced key,parent 更新/删除还要查 child;
  • CHECK 计算表达式;
  • transition trigger 查询私有边表;
  • WAL、replica、backup 和 cache 都会承受更多字节。

v1 的五张业务表合计只有 12 行 fixture,却已经有 13 个 constraint-backed indexes:

SELECT
    tablename,
    indexname,
    pg_size_pretty(
      pg_relation_size(
        format('%I.%I', schemaname, indexname)::regclass
      )
    ) AS size
FROM pg_catalog.pg_indexes
WHERE schemaname = 'shop'
ORDER BY tablename, indexname;

在本章 PG18.6 空间分配下,每个小 index 即使只有数行也显示 16 KiB。这是页面级最低分配的演示,不应线性外推;但它直观说明“多一个 unique”永远不是零成本。

给每个索引一个理由

index 来源 本章理由
五表 PK 行身份、FK target、点查
customer_ref/email、SKU、order_no 已批准业务唯一性
customer + request_key 并发幂等命令
provider + provider ref/idempotency 外部/命令作用域唯一
order_id + currency 复合 FK 保证订单聚合单币种

最后一个是有意冗余 index:order_id 已全局唯一,但 PostgreSQL 要求复合 FK 指向合格 unique key。我们用额外索引换取数据库可声明的跨表币种一致性。若生产写入证明它太贵,可重审多币种建模或约束实现,不能只删索引后假装规则仍在。

FK child columns没有自动 index。本章 fixture 很小,暂不为每条 FK 增加可能重复的索引;ch07 根据查询与 parent delete/update 路径统一设计。漏建和盲建同样是问题。

约束的收益也要计量

一次 23505 可能阻止两个并发请求生成重复订单,一次 FK 可能避免数月后才暴露的孤儿,一次 migration precheck 可能阻止静默舍入历史金额。把它们只归类为“写性能开销”会漏掉修复、对账和事故成本。

优化顺序应当是:

  1. 证明具体写路径受哪个检查/索引限制;
  2. 检查冗余 index、错误列序和不必要更新;
  3. 批量写入遵守事务/锁/WAL预算;
  4. 在不改变不变量时优化表达;
  5. 若必须改变合同,走业务 ADR,而不是 DBA 私删约束。

后续用 pg_stat_user_indexespg_stat_all_tables、WAL 与 latency 指标验证长期成本。刚创建的 index “scan count=0”也不能立即判废,它可能只为 rare integrity path 或 FK parent delete 服务。

本节验收

  • 能用 pg_column_size 与 relation size 函数区分值、heap、index、TOAST 和总量;
  • 不把 TOAST 误解为宽字段免费,也不从 toast relation 存在推断已经外置;
  • driver parameter 使用列的真实类型,indexed column 不被无谓 cast;
  • 能从 expression/operator/collation/opclass 四层解释索引匹配;
  • 列出 v1 的 13 个索引及每一个不变量理由;
  • 明确复合币种 unique 的可靠性收益与写放大;
  • 性能优化以证据为入口,不用删约束代替建模。

参考资料


上一节:用约束表达不变量 · 返回本章目录 · 下一节:分区决策门 · 查看全书目录 · 查看索引中心

4.6 分区决策门

分区把一个逻辑关系拆成多组物理存储。它能让按生命周期整批删除、冷热分层和特定查询裁剪非常有效,也会把唯一键、外键、索引、统计信息和运维对象成倍展开。正因为改造晚了有成本,团队常想“先分了再说”;本节用决策门阻止这种没有收益证据的确定复杂度。

4.6.1 先证明生命周期、体量或裁剪需求再决定分区

分区解决的典型问题是:

  • 按月/日保留期需要快速 DROPDETACH PARTITION,避免海量 DELETE 与 VACUUM;
  • 热查询稳定命中少数分区,planner 能裁剪其余分区;
  • 单表/索引维护窗口、冷热存储或批量加载已经不可接受;
  • 数据分布天然按 list/hash 隔离,并有清楚路由与对象数量上限。

“以后数据会很多”不在其中。行数本身也不是充分证据:一亿条窄 append-only 记录和一千万条宽、频繁更新记录的物理问题不同;内存、索引、查询选择性和保留策略都会改变拐点。官方给出的只能是宽泛经验——通常要表非常大才值得——不是一个可复制的固定阈值。

决策输入

至少收集:

current heap/index/TOAST size
daily/monthly growth
retention and legal hold
largest maintenance window
representative slow queries
predicates that can carry partition key
candidate key cardinality and null behavior
expected partition count over 3 years
backup/restore and failover objectives

查询裁剪必须用计划证明:

EXPLAIN (ANALYZE, BUFFERS)
SELECT ...
FROM candidate_partitioned_table
WHERE placed_at >= $1
  AND placed_at <  $2;

看实际 scanned partitions、planning time、execution buffers;不要只看“有 Partition Pruning 字样”。prepared statement、parameter、函数包装和 join 条件都可能影响静态/运行时裁剪。enable_partition_pruning 还必须开启。

生命周期比“查询更快”更强

按月保留三年是可操作合同:36 个活跃分区、每月建立一个、过期时 detach/archive/drop。相比之下,“大多数查询最近数据”若没有 predicate 和计划样本,只是一句愿望。

本章订单没有保留期、法定删除批次、生产增长和慢查询数据。普通表的 PK/unique/FK 清楚且样本极小,因此不满足进入门。先不分区不是缺少架构,而是证据导向的物理决策。

4.6.2 分区键与主键、唯一约束必须共同设计

PostgreSQL 分区表上的 unique/PK 由各 partition 的本地索引实现。为了保证不同 partition 之间不重复,unique/PK 的列必须包含全部 partition key columns,而且 partition key 不能是 expression/function。

假设按 placed_at 月分区:

CREATE TABLE shop.sales_order_p (...)
PARTITION BY RANGE (placed_at);

当前约束:

PRIMARY KEY (order_id)
UNIQUE (order_no)
UNIQUE (customer_id, request_key)

不能原样成为 partitioned parent 的全局约束,因为它们都不含 placed_at。可选方向各有语义代价:

  1. 改成 (order_id, placed_at) 等复合键;
  2. 接受 only-per-partition uniqueness;
  3. 另建未分区 registry 表维护全局键;
  4. 由应用/trigger 维护跨 partition 唯一性,并承担并发正确性;
  5. 换一个能同时服务生命周期与唯一性的 partition key;
  6. 不分区。

placed_at 机械加入每个 unique 不等于问题解决。订单号原本承诺全局唯一,加入时间后两个 partition 可拥有同一个 order_no;除非 API 唯一性合同也改为 (order_no, placed_at),语义已经变了。

NULL 与草稿

v1 的 draft order placed_at IS NULL。range partitioning 对 NULL 没有普通 range 归属,需要 default partition 或不同 key。若用 created_at 路由,保留期可能与业务 placed/paid 生命周期不一致。分区键必须同时满足:

  • 每行插入时可用、稳定;
  • 业务生命周期/删除批次;
  • 高频查询 predicate;
  • unique/PK 与 FK 形状;
  • 更新是否会导致跨 partition row movement。

一个“时间列存在”远不足以当分区键。

4.6.3 外键、引用方式与未来在线改造代价

若 order PK 从 (order_id) 变成 (order_id, placed_at),引用它的 order item 与 payment 通常也要携带 placed_at

sales_order_item(order_id, order_placed_at) -> sales_order(...)
payment(order_id, order_placed_at)          -> sales_order(...)

这增加键宽、索引宽、写入参数和更新路由;draft 的 NULL 又使引用更复杂。保留一个未分区 key registry 可避免传播时间键,却新增一张强一致写入热点与生命周期协调表。两种都不是免费。

PostgreSQL 已支持针对 partitioned table 的外键,但被引用键仍要满足 partitioned unique/PK 限制。应用 ORM “支持分区”也不能绕过数据库这一事实。

普通表不能原地变成分区表

官方文档明确:不能把 regular table 直接切换成 partitioned table,反之亦然。常见迁移需要:

  1. 新建 partitioned parent 与 partitions;
  2. 建立等价列、约束、索引、权限、trigger 与注释;
  3. backfill 历史数据;
  4. 捕获 backfill 期间增量(短暂停写、dual-write、trigger 或逻辑复制);
  5. 验证行数、checksum、FK 与查询计划;
  6. 短锁窗口切换名称/view/service;
  7. 保留前滚/回退与旧表清理门。

ATTACH PARTITION 可复用已经装载的普通表,但需要证明 partition constraint;没有匹配 CHECK 时会扫描验证并持有相应锁。partitioned index 也有自己的并发创建/attach 流程。ch11 会演练安全发布,ch28 再处理完整生命周期;本章只估算设计后果。

现在不分区,也要为未来保留边界

不应把应用 SQL 绑定具体 child table;所有读写面向逻辑 relation/view。业务标识不要编码当前 partition 名。持续记录时间分布、表/索引体积和保留期,让未来迁移有数据。

但不要为了“方便未来”现在就把 partition key 传播到所有 API:这会立刻锁定尚未证明的设计。可演进的关键是清楚接口与可验证迁移,不是提前暴露物理细节。

4.6.4 产出“现在分区 / 暂不分区”的可复查 ADR

partition-adr.md记录:

ADR-004
decision: ch04-v1 remains unpartitioned
status: accepted
review chapter: ch26

主要理由不是“数据还小”一句话,而是:

  • 没有体量、增长、保留期或慢查询证据;
  • 当前 order_id/order_no/request key 要求全局唯一;
  • order item/payment 通过 FK 引用订单;
  • 候选 placed_at 对 draft 为 NULL;
  • 预分区会立即增加对象、维护和恢复复杂度。

触发复查

任一条件由真实证据满足时复查:

  1. heap/index 已使单表维护窗口不可接受;
  2. 有稳定、可按候选 key 整批执行的过期/归档政策;
  3. representative query 携带 key,计划证明 pruning 收益;
  4. 普通表无法满足写入、备份恢复或冷热分层目标。

复查包必须包含增长率、关系/索引大小、保留期、慢查询计划、候选键、预期 partition count,以及一次迁移/回退演练。结论可以仍是“暂不分区”;ADR 的价值是让新证据能推翻旧决定。

数据库验收

verify-v1.sql 不只在文档中说“不分区”,还检查五表都没有 pg_partitioned_table entry:

SELECT
    c.oid::regclass,
    c.relkind
FROM pg_catalog.pg_class AS c
WHERE c.oid IN (
  'shop.customer'::regclass,
  'shop.product'::regclass,
  'shop.sales_order'::regclass,
  'shop.sales_order_item'::regclass,
  'shop.payment'::regclass
);

预期 relkind='r',状态摘要输出:

partition_decision=not-now

如果有人私自把某表换成 partitioned hierarchy,verify 失败,迫使代码与 ADR 一起评审。

ADR 模板

context
measured evidence
candidate keys and alternatives
PK/UK/FK consequences
query/lifecycle benefits
object and operation costs
decision and owner
review triggers/date
migration and rollback outline

不要记录“PostgreSQL 支持 range partition”这类产品事实;记录为什么这个模型在这个时点选择什么,以及什么证据会让决定失效。

本节验收

  • 没有使用单一行数阈值替代体积、生命周期与计划证据;
  • 候选 partition key 同时审查 NULL、稳定性、查询与保留期;
  • 能解释为什么 PG partitioned unique 必须包含全部 key;
  • order_no、request key 与 child FK 的语义后果已列出;
  • 知道 regular→partitioned 不是原地 ALTER,迁移需新结构和切换;
  • ADR 有 owner、反对方案、复查触发条件和数据库 verify;
  • “暂不分区”被当成可复查的积极决定。

参考资料


上一节:类型与约束的物理代价 · 返回本章目录 · 下一节:实战:把逻辑模型落成可靠物理模式 · 查看全书目录 · 查看索引中心

4.7 实战:把逻辑模型落成可靠物理模式

本实验不是在空白数据库重抄一遍最终 CREATE TABLE,而是从 ch03-v0 的真实行与约束出发,先证明旧值可无损表示,再在一个事务中升级,最后用应用角色、反例和目录状态证明结果。新环境入口也复用同一迁移链,避免“新装 DDL”和“升级 DDL”长期分叉。

风险分级:

  • verifyR0·观察,只读目录与数据;
  • migrateR2·受控迁移演练,持表锁、删除 v0 money columns、重建 view;
  • negative / constraintsR2·破坏性演练,事务内写入后强制回滚;
  • seedR2·破坏性演练,TRUNCATE 五表并重建固定 fixture;
  • resetR2·破坏性演练,删除全部 ch03/ch04 模型对象,要求双重令牌。

本章 migrate/seed/reset 只在已确认可销毁的 Pigsty L1 教学库执行。生产迁移必须增加兼容发布、备份/PITR、锁时长、容量和回退评审。

4.7.1 闭合金额与时间表达

先确认上下文,不把“连得上”误当“目标正确”:

export PGSERVICEFILE="$PWD/pg_service.conf"
export PGSERVICE=pg36-admin

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

预期 database=pg36_shoppg_is_in_recovery=false。再运行 ch03 verify,确认 v0 checksum:

cd static/labs/ch03
./task.sh verify
relation_checksum=cd7daa66543a6b5e0a5d7fc269558a6c

回到 ch04 资产目录:

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

all 顺序是:

manifest → migrate-v0-to-v1 → verify-v1
         → negative-cases   → constraint-lab

迁移前门

migrate-v0-to-v1.sql先确认六个 v0 relation/view 存在、旧 money columns 仍是 v0 形状,然后拒绝:

  • numeric NaN / Infinity
  • value * 100 仍有小数残余;
  • 转换后超出批准 bigint bounds;
  • quantity 使行金额越界;
  • 非规范 email;
  • 不在 v1 catalog 的旧状态;
  • paid order 找不到 captured payment 时间。

这些检查发生在事务内、DROP VIEW 之前。任一失败会回滚全部 DDL。已实测把商品价格改为 88.001 时,psql 状态为 3,v0 view 仍存在且 v1 marker 不存在。

expand、convert、constrain、contract

成功路径按顺序:

  1. 创建 private schema version、status catalog 与 transition graph;
  2. 添加 nullable currency_code / *_minor
  3. 用旧 numeric 精确换算并回填;
  4. 改为 NOT NULL,增加 bounds/currency/复合 FK;
  5. 删除旧 numeric columns;
  6. 把事件列改为 timestamptz(3),补 paid/cancelled time;
  7. 重建 shop_api.order_summary
  8. 写入 version marker 后 COMMIT。

DDL 事务设置:

SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '30s';

它让 L1 演练不会无限等待;不是生产通用值。ALTER TABLE 会取锁,数据回填会产生写入/WAL,DROP old column 会打破仍在读取旧列的应用。真正在线发布应拆成多次兼容迁移:先新增+双写/回填,发布新读路径,观察,再删除旧列。这里单事务 contract 是为了在隔离环境展示完整物理决定。

时间闭合

迁移把所有事件列显式改为毫秒精度。paid order 的 paid_at 从现有 captured payment 最早 occurred_at 推导;若缺失就拒绝,而不是用当前时间编造历史。验证固定 UTC,反例另外检查:

2026-11-01 01:30-04
2026-11-01 01:30-05

是相差一小时的两个瞬间,并确认 09:00+00 AT TIME ZONE Asia/Shanghai = 17:00

4.7.2 闭合状态与标识生成

物理决定的可下载记录是 physical-decisions.md,v1 图源是 model-v1.mmd

erDiagram
  CUSTOMER ||--o{ SALES_ORDER : places
  SALES_ORDER ||--|{ SALES_ORDER_ITEM : contains
  PRODUCT ||--o{ SALES_ORDER_ITEM : snapshotted_as
  SALES_ORDER ||--o{ PAYMENT : receives
  ORDER_STATUS_CATALOG ||--o{ SALES_ORDER : permits
  PAYMENT_STATUS_CATALOG ||--o{ PAYMENT : permits

identity 关闭生成责任

四个内部键变为:

customer.customer_id
product.product_id
sales_order.order_id
payment.payment_id
    bigint GENERATED BY DEFAULT AS IDENTITY

迁移通过 pg_get_serial_sequence 找到隐式 sequence,空表设置 (1,false),非空表设置 (max_id,true);随后授权 app USAGE, SELECT。verify 从 pg_attribute.attidentity='d' 和 sequence privilege 双重检查。

固定 ID 的历史/fixture 仍可导入,普通 app INSERT 省略 ID。反例脚本以 pg36_app 新建 order/payment,实际证明 sequence 不碰撞。不要用 owner 成功代替 runtime 成功。

状态关闭值、边与伴随事实

列外键到 owner-only catalog:

FOREIGN KEY (order_status)
REFERENCES shop_private.order_status_catalog(status_code)

transition trigger 对 UPDATE 的 old/new 查表,图外边抛 23514 并设置稳定 constraint identity。行级 CHECK 再要求 paid/cancelled/failure 字段与状态一致。

查看图:

SELECT from_status, to_status
FROM shop_private.order_status_transition
ORDER BY from_status, to_status;

预期:

draft|cancelled
draft|placed
placed|cancelled
placed|paid

函数为 definer 是因为 app 无权使用 private schema。验证要求:

prosecdef = true
proconfig contains "search_path=pg_catalog, shop_private"
PUBLIC direct EXECUTE revoked
trigger tgenabled = O

然后 app 实走 draft→placed→paid。安全不是静态 DDL 扫描和动态测试二选一,两者都要。

新装入口不复制 DDL

schema-v1.sql是 canonical fresh-install entrypoint:

  • 已是 v1:幂等跳过;
  • 有完整 v0:走同一 migration;
  • 无模型:先建立空 ch03-v0,再走同一 migration。

这样 constraint/function/view 只有一条权威升级定义。随后 seed-v1.sql加载最终列形状的 fixture。新环境完整验证:

export PG36_EVIDENCE_DIR="$PWD/evidence/ch04/install-$(date -u +%Y%m%dT%H%M%SZ)"
./task.sh install

fresh install 与 v0 upgrade 必须产生相同 checksum;只验证“两个脚本各自不报错”不足以证明收敛。

4.7.3 用反例验证类型、约束与错误语义

正向 seed 只能说明某些合法值能写入,不能证明边界存在。negative-cases.sql在一个事务里逐项制造:

反例 预期 condition / constraint
product currency=USD 23514 product_currency_supported
大写 email 23514 customer_email_canonical
order placed→draft 23514 sales_order_status_transition
placed→paid 但无 paid_at 23514 sales_order_state_time_consistent
重复 order_no 23505 sales_order_order_no_key
显式写 generated line total 428C9 generated_always

PL/pgSQL block 只捕获预期 condition,并用 GET STACKED DIAGNOSTICS ... CONSTRAINT_NAME 比较。若写入意外成功、SQLSTATE 类别不对或另一个约束先失败,review 整体失败。

随后是正向边界:

  • 省略 customer ID,identity 值必须大于历史 max;
  • pending payment 合法转 captured;
  • placed order 同一 UPDATE 带 paid_at 转 paid;
  • shop_api.order_summary captured minor total正确;
  • 切换成 pg36_app 再完整走一次 identity + 状态路径;
  • 两个显式 offset 的 DST 瞬间保持一小时差。

所有写入最后:

ROLLBACK;

再次 verify 的行数与 checksum 不变。

排他与延迟约束独立实验

constraint-lab.sql也完全在事务/temporary tables 内:

  1. tstzrange EXCLUDE USING gist (slot WITH &&) 拒绝 overlap;
  2. UNIQUE(slot_no) DEFERRABLE 在事务中交换 1/2;
  3. pg_constraint 证明 unique 可延迟而 CHECK 不可;
  4. 只探测 btree_gist availability,不创建 extension;
  5. ROLLBACK。

预期摘要:

exclusion_overlap_rejected=ok
deferrable_unique_swap=ok
btree_gist_available=true

最后一项依赖安装环境;如果是 false,单列 range lab 仍应通过,多资源 example 则要先交付 extension package。不要把“扩展不可用”混成 exclusion 语义失败。

分步运行并保存独立现场:

./task.sh verify
./task.sh negative
./task.sh constraints
./task.sh review

negative 会先 verify;review 执行 verify + 两类实验。stderr 为空是本章脚本的期望,预期异常已经在 SQL 内精确捕获。

4.7.4 在 Pigsty L1 输出可靠 DDL、分区决策与 verify:state

Pigsty 在本章提供:

  • PostgreSQL 18.6 主库和统一 service endpoint;
  • owner/app/readonly 运行角色与后续可观测环境;
  • contrib/扩展软件交付能力;
  • L1 可复现的实验边界。

类型、表、约束、trigger 和应用 migration 仍属于 PostgreSQL/应用模式发布。不要把业务 DDL塞进 Pigsty cluster topology,也不要因为 Pigsty 有 HA/PITR 就省略应用迁移的兼容性设计。

证据目录

task.sh 要求 private PGSERVICEFILE,使用 psql -X -w 避免个人 rc 和交互密码影响。manifest 记录:

  • UTC capture time、action、service;
  • psql client/server version、database、session user、recovery state;
  • 12 个输入资产与 task script 的 SHA-256。

动作输出分别进入:

migrate.stdout / migrate.stderr
schema.stdout  / schema.stderr
seed.stdout    / seed.stderr
verify.txt     / verify.stderr
negative.txt   / negative.stderr
constraints.txt / constraints.stderr

最终 verify:state

status=ok
model_version=ch04-v1
money_unit=CNY-fen
session_timezone=UTC
customer_count=2
product_count=3
order_count=2
item_count=3
payment_count=2
order_transition_count=4
partition_decision=not-now
relation_checksum=f8a7bfae59c6d16cd323abecfefe1014

该 checksum覆盖三条 order line 的 order/line/product/currency/unit price/quantity/generated total。它不是数据库备份校验和,只是固定 fixture 的快速漂移信号。

可重入与失败原子性

连续再执行:

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

migration 应输出:

ch04 physical model v1 is already installed

verify/negative/constraints 仍通过、checksum 相同。若 version marker 存在但对象漂移,migration 会跳过,严格 verify 必须失败;marker 不是“相信我已经正确”的免检标签。

reset 与重建

无令牌:

./task.sh reset

必须返回 64。确认要删除整个模型:

export PG36_RESET_TOKEN=RESET_CH04_MODEL
export PG36_EVIDENCE_DIR="$PWD/evidence/ch04/reset-$(date -u +%Y%m%dT%H%M%SZ)"
./task.sh reset
unset PG36_RESET_TOKEN

SQL 内还验证 confirm_reset。它显式删除五表、view、两个 transition function、五个 private tables 和空的 shop_api/shop_private schema;保留 database、roles、shop schema 和 Pigsty 集群。schema drop 使用默认 RESTRICT:若出现未知对象,事务整体失败,不会 CASCADE 带走。

复位后可以:

# 重演升级
../ch03/task.sh all
./task.sh all

# 或重演新装
./task.sh install

两条路径都应回到同一 f8a... checksum。

生产前不能省略

本章迁移在 L1 真实通过,不等于可直接复制到繁忙生产。至少补齐:

  • 当前 PG/Pigsty 版本与 extension/collation inventory;
  • 可用 PITR/backup 与实际 restore drill;
  • 表大小、回填 WAL、replica lag 和锁等待预算;
  • old/new application 双向兼容矩阵;
  • expand/backfill/validate/switch/contract 分阶段脚本;
  • ALTER TABLE lock 的预演与 kill/timeout 策略;
  • checksum、业务对账、监控与明确 rollback/forward-only 决定;
  • 变更窗口、owner、审批与终止条件。

在生产删旧列通常是最后一个独立发布,不与第一次回填放在同一事务中。L1 的单事务脚本证明语义与原子性,生产 choreography 证明可用性;二者问题不同。

本章最终验收

  • v0 checksum 与 prerequisite 正确;
  • 不可表示金额在任何 contract DDL 前被拒绝且完整回滚;
  • 成功迁移输出 v1 checksum;
  • fresh install 与 upgrade 收敛到同一状态;
  • migration/install 重跑稳定;
  • identity catalog、sequence 对齐和 app privilege 均通过;
  • 非法值、非法边、缺伴随时间分别失败;
  • app 无 private USAGE 仍可安全走合法 transition;
  • generated value 不能由应用覆盖;
  • DST、range exclusion、deferrable unique 都有反例;
  • partition ADR 与数据库实际状态一致;
  • reset 无令牌拒绝、有令牌只删除声明范围;
  • 清楚记录跨表金额不变量与生产在线迁移仍属后续工作。

通过后进入 ch05《运筹帷幄:查询、事务与锁的核心心智模型》

参考资料


上一节:分区决策门 · 返回本章目录 · 下一章:运筹帷幄:查询、事务与锁的核心心智模型 · 查看全书目录 · 查看索引中心

5 运筹帷幄:查询、事务与锁的核心心智模型

前四章已经把连接、工具、逻辑模型与物理数据合同固定下来。接下来遇到的三个问题看似分散:为什么同一条 SQL 会选择不同路径,为什么一个会话看不见另一个会话刚写的值,为什么一个“正在运行”的请求其实在等锁。它们必须放在同一张图中理解:SQL 被转换为执行计划,执行节点在某个 MVCC 快照上访问 tuple version,写操作再通过事务状态、锁和 WAL 协调并发与恢复。

本章建立这张原理地图,但刻意不把后续专题挤进一章。这里只要求读者能把现象归入正确层次、找到第一组权威证据,并知道下一步去哪里;第 7 章深入计划和估算,第 8 章建立慢 SQL 诊断流程,第 9 章验证索引设计,第 10 章再系统实验隔离异常与并发控制。

本章目标

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

  • 按 raw parsing、semantic analysis、rewrite、planning、execution 解释 SQL 的处理路径;
  • 区分 SQL 的关系语义与 Seq Scan、Hash Join、Sort 等物理执行节点;
  • 把 planner cost 理解为基于统计与成本参数的比较量,而不是运行时间预言;
  • 区分逻辑行与 heap tuple version,知道 xminxmaxctid 只适合诊断;
  • 读懂 pg_snapshotxmin:xmax:xip_list,但不手工仿造完整可见性算法;
  • 说明普通读为何通常不等待行级写锁,以及这种并发性的存储与维护代价;
  • 正确处理 autocommit、显式事务、failed transaction 与 savepoint;
  • 区分数据库原子性、WAL 持久化条件、复制确认与外部副作用;
  • 区分 table lock、row lock、regular lock manager、LWLock 与 wait event;
  • pg_stat_activitypg_blocking_pids()pg_locks 还原一条阻塞边;
  • 准确描述 PostgreSQL 四个隔离级别名称对应的三个实现级别;
  • 把 lost update 绑定到具体隔离级别和 SQL 写法,而不是背一句口号;
  • 在 Pigsty 的 Activity、Session、Xacts、Persist 与 PGCAT Locks 面板中提出可验证的问题;
  • 完成一次 rollback-only 多会话实验,并证明业务状态没有漂移。

开始之前

本章沿用 ch02 的私有 PGSERVICEFILEpg36-admin service,要求 ch04-v1 已验收:

model_version=ch04-v1
order_count=2
relation_checksum=f8a7bfae59c6d16cd323abecfefe1014

实验基线为 PostgreSQL 18.6、Pigsty v4.5.0、Ubuntu 24.04;本章 SQL 和 shell 路径保持 PostgreSQL 14–18 可用。页面讲到 PostgreSQL 18 当前行为时,以 18 版官方文档为准;Pigsty 面板名以 v4.5 文档为准。不同大版本、内核分支或定制 dashboard 必须重新核对,不能仅凭截图类推。

下载资产:

这些实验不创建 ch05 持久对象,也不提交业务写入,所以没有 reset 动作;这不等于“零代价”。回滚的 UPDATE 仍会取得锁、创建 tuple version、产生 WAL 和统计活动,blocking 动作还会取消一个精确识别的实验 backend。只能在已确认可演练的 L1/测试库运行。

一张贯通全章的图

flowchart LR
  A["SQL 文本<br/>参数与会话上下文"] --> B["解析与语义分析<br/>query tree"]
  B --> C["重写<br/>views / rules"]
  C --> D["规划<br/>paths + estimates + cost"]
  D --> E["执行计划树<br/>executor"]
  F["MVCC 快照<br/>XID + tuple versions"] --> E
  G["锁与等待<br/>冲突对象 + wait graph"] --> E
  E --> H["数据页与 WAL<br/>可见结果 / 持久化"]
  I["pg_stat_activity<br/>pg_blocking_pids / pg_locks"] -.观测.-> G
  J["Pigsty dashboards<br/>趋势与上下文"] -.观测.-> E
  J -.观测.-> H

这张图也给出诊断顺序。结果错误先问语义、快照与事务边界;查询慢先区分“在执行”还是“在等待”;计划异常先检查估算,不要看到 Seq Scan 就先建索引;提交延迟则需要区分本地 WAL、同步复制、锁与客户端网络。

本章目录

5.1 SQL 从文本到结果

先把“声明想要什么”与“服务器怎样得到它”分开,再理解统计估算为何是计划选择的输入而非未来运行时间。

5.2 MVCC 与可见性

逻辑行通过多个物理版本演进;快照与事务状态决定当前语句看到哪个版本,VACUUM 则负责在安全之后回收代价。

5.3 事务边界与失败语义

一个错误不仅是某条语句失败,还会改变整个事务的可用状态;数据库回滚也不会撤销已经发送的邮件、HTTP 请求或消息。

5.4 锁与等待

锁名、锁对象、冲突模式、持有时长和业务扇出共同决定影响;本章用一条真实的 transaction-ID wait 说明怎样从 waiter 回到 blocker。

5.5 隔离现象与后续路线

PostgreSQL 的 Read Uncommitted 等同 Read Committed,Repeatable Read 又比标准最低要求更强;隔离级别名称必须与具体 SQL 形状一起讨论。

5.6 实战:观察一笔订单事务

综合实验先建立前验,再观察快照和失败事务,最后用两个唯一命名会话制造阻塞、采集锁链、受控释放并执行后验。

章节产物

在 ch05 资产目录运行:

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

./task.sh all

执行路径是:

manifest
  → verify-before
  → observe
  → expected errors + savepoint
  → WAL-producing rollback
  → blocker / reader / waiter / observer
  → verify-after

一次 PostgreSQL 18.6 实测中,关键证据为:

assigned_xid_before_write=<none>
transaction-errors: 22012 → 25P02
savepoint error:     23514 → ROLLBACK TO → valid write → ROLLBACK
wal_insert_advanced=t
reader_saw_previous_committed_version=true
waiter_wait_event_type=Lock
waiter_wait_event=transactionid
cancel_exact_blocker=t
state_restored=true
remaining_workers=0

XID、PID、LSN、ctid 和 WAL 字节数每次都会变化;它们是本次证据,不是 golden value。稳定验收是错误类别、阻塞关系和最终不变量:

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

章节验收

  1. 能说明 syntax parse 与 catalog-backed semantic analysis 不是同一步;
  2. 能从 SQL 语义、plan node 与运行时状态三个层次解释一次查询;
  3. 不把 cost 当毫秒,不把一次 EXPLAIN ANALYZE 当未来预测;
  4. 能说明 snapshot 的边界含义,不把 xip_list 当全部已提交事务清单;
  5. 不把 xminxmax、XID、LSN 或 ctid 当长期业务标识;
  6. 能复现普通读看到旧已提交版本、同一行写者等待的差异;
  7. 遇到 25P02 会回滚或回到 savepoint,而不是继续发送业务 SQL;
  8. 能解释 rollback 后数据未变但 WAL insert LSN 仍可能前进;
  9. 能说清 synchronous_commit=off 改变的是最近提交的持久性保证,而不是原子性;
  10. 知道 ROW EXCLUSIVE 是表锁名称,且普通 SELECT 只与 ACCESS EXCLUSIVE 表锁冲突;
  11. 能用 pg_blocking_pids() 建立 blocker 边,再用 activity 与 locks 补上下文;
  12. 能区分长等待与死锁环,知道死锁受害事务需整体重试;
  13. 能准确填出 PostgreSQL 的隔离现象矩阵;
  14. 能分别判断原子 UPDATE、应用 read-modify-write、version predicate 和 FOR UPDATE
  15. all 前后 checksum 一致,且没有残留 lab backend。

下一章 ch06《立木取信:开发规约与交付基线》 会把 ch01–ch05 已经验证的连接、命名、类型、错误、事务、超时和取证规则收敛为团队可执行的开发基线。

参考资料


上一章:量体裁衣:数据类型、约束与可靠数据表达 · 返回上卷导读 · 下一章:立木取信:开发规约与交付基线 · 查看全书目录 · 查看索引中心

5.1 SQL 从文本到结果

SQL 是声明式语言:调用者描述需要的关系结果和允许的变更,PostgreSQL 决定怎样执行。这个抽象让应用不必把“先扫哪张表、用哪个索引”写死,却也带来一个常见误区——把 SQL 文本、逻辑语义、执行计划和某次运行表现混成同一件事。本节先把四层拆开。

5.1.1 解析、重写、规划与执行

一条 SQL 从客户端到结果并不是“解析后立刻跑”。对普通查询,可以用下面的主干理解:

SQL text
  → raw parsing
  → parse analysis / transformation
  → rewrite
  → planning / optimization
  → execution

raw parsing 只知道语法形状

lexer 把关键字、标识符、常量和运算符切成 token,grammar 再建立 raw parse tree。这个阶段能判断括号、关键字位置和语法结构是否成立,却不查询系统目录,因此还不知道 shop.sales_order 是否存在、amount_minor 是什么类型,也无法判定 sum 最后解析为哪个具体函数。

接下来的 parse analysis / transformation 才在事务上下文中解析:

  • schema、relation、column、function 与 operator;
  • 未限定名称所受的 search_path 影响;
  • literal、parameter 与 expression 的数据类型;
  • aggregate、window function、target list 与权限所需的语义信息。

所以“解析”在口语中常被用作总称,但诊断时要更精确。少一个右括号通常是 42601 syntax_error;表名不存在是 semantic analysis 期间的 42P01 undefined_table;整数除零则可能直到 executor 求值时才产生 22012。错误出现在哪一层,决定应该检查文本、catalog/类型,还是运行数据。

rewrite 不是字符串替换

rewriter 接受和输出的都是 query tree。它最常见的用途是展开 view:查询 shop_api.order_summary 时,服务器不是把 view 当预先保存的一批行,而是把其定义纳入重写后的查询树。rules 也在这一层处理;row-level trigger 则不是 rewriter,它在执行期间按触发时点工作。

这一区分有两个工程后果:

  1. view 后面仍要规划、执行和做 MVCC 可见性判断,普通 view 本身不是结果缓存;
  2. EXPLAIN SELECT ... FROM view 展示的是重写之后形成的计划,不等于展示 raw parse tree 或每一步 rewrite 记录。

不要为了观察生产查询而打开 debug_print_parsedebug_print_rewrittendebug_print_plan 之类的全局调试输出;它们会改变日志量并可能暴露 SQL。教学上知道阶段即可,生产证据优先来自安全的 EXPLAIN、catalog、统计视图与受控日志。

planner 选择路径,executor 消费计划

planner/optimizer 接收 rewritten query tree,为扫描、连接、排序与聚合生成候选 path,用统计估算行数,再用成本模型比较候选。选中的 cheapest path 被展开成 plan tree。

executor 递归执行这棵树。PostgreSQL 的主体模型是 demand-pull:父节点需要下一行时向子节点索取,直到返回 tuple 或结束。对写语句,ModifyTable 等节点取得目标 tuple,再执行 insert/update/delete/merge、约束、trigger 与 WAL 相关工作。计划是可执行说明,不是运行结果;真正读取哪些 block、遇到哪些可见版本和等待,只有执行时才知道。

协议与计划生命周期也会改变上下文

应用通过 simple query protocol 发送整段 SQL,或通过 extended query protocol 执行 Parse/Bind/Execute。prepared statement 可以把参数绑定与 statement 定义分开,还可能在 custom plan 和 generic plan 之间选择。连接池又可能让同一逻辑请求落到不同 backend。

本章只固定一个原则:保存 SQL 文本还不够,至少还要关联 database、role、search_path、参数类型/值范围、配置、server version 与计划时点。第 7 章会专门验证 prepared statement 的计划选择,不在这里提前给“预编译一定更快”之类错误结论。

用当前订单查询做一个边界观察:

EXPLAIN (VERBOSE, COSTS OFF)
SELECT order_no, order_status, item_subtotal_minor
FROM shop_api.order_summary
WHERE order_id = 1001;

它可以证明 planner 最终交给 executor 的树,也能看到 view 展开后的 base relation;它不能证明估算准确、实际耗时稳定、当前没有锁等待。要回答后面的问题,需要 EXPLAIN (ANALYZE, BUFFERS) 或运行时视图,而带 ANALYZE 会真实执行语句,绝不能对有副作用的 SQL 随意使用。

5.1.2 关系代数直觉与执行节点

理解 plan tree 最有效的方式不是背节点列表,而是先问 SQL 需要哪些关系操作,再问 PostgreSQL 用什么物理算法实现。

逻辑意图 SQL 表达 可能的物理节点
选择行 WHERE scan 中的 index condition / filter
投影列/表达式 SELECT list scan、Result 或上层节点计算
连接关系 JOIN Nested Loop、Hash Join、Merge Join
分组聚合 GROUP BY HashAggregate、GroupAggregate
排序 ORDER BY Sort、Incremental Sort,或有序 index path
去重 DISTINCT Unique、aggregate、排序/哈希组合
限制结果 LIMIT Limit,但子节点可能已经做了大量工作

逻辑操作与物理节点不是一一对应。例如,一个 B-tree index path 可以同时提供筛选和顺序;HashAggregate 可能同时承担分组与去重;planner 也可能把 predicate 下推到更低节点。反过来,SQL 文本中的一个 join 可能因 view 展开而变成多层 join tree。

用树而不是“执行步骤清单”阅读计划

假设计划形状为:

Sort
  → HashAggregate
      → Hash Join
          → Seq Scan on sales_order_item
          → Hash
              → Seq Scan on sales_order

缩进表示父子关系,不表示“第一行先完整执行,第二行再完整执行”。父节点通常向子节点拉取 tuple;Seq Scan 可以边读边交付,Hash 必须先构建内表,Sort 通常要取得足够输入后才能输出有序行。很多节点可 pipeline,一些节点会阻塞或 materialize;“executor 是 pull model”不等于全计划只保留一行内存。

读计划时来回走两遍:

  1. 自下而上:base relation 如何进入 join/aggregate,数据量怎样放大或缩小;
  2. 自上而下:最终排序、LIMIT 和输出要求向子树施加了什么 property。

第 7 章会加入 estimated rows、actual rows、loops、buffers、memory、I/O timing 等证据。此处只要求先能指出“哪个节点实现哪个逻辑责任”。

SQL 文本顺序不保证物理顺序

inner join 在满足语义等价时可以重排;predicate 可以下推;subquery 可能被 pull up;CTE 是否 materialize 取决于语义、引用方式和显式关键字。不要把:

FROM a
JOIN b ...
JOIN c ...

理解成服务器必然先 a→b→c。也不要用随意设置 enable_seqscan=offjoin_collapse_limit=1 作为长期“修计划”方案;这些开关最多用于诊断假设,会改变整个 search space。

另一个必须从关系语义继承的规则是:没有 ORDER BY 就没有结果顺序合同。某次 Seq Scan 看似按 heap 位置返回、某次 Index Scan 看似按 key 返回,都不是 API 可以依赖的排序。VACUUM、并行执行、plan change 或一次普通 UPDATE 都可能改变观测顺序。

节点名也不是性能判决

  • 小表 Seq Scan 往往比 index traversal 更便宜;
  • Nested Loop 在外表很小、内表有高选择性索引时很好;
  • Hash Join 不是天然“吃内存的坏节点”,是否 spill 才需要证据;
  • Sort 可能完全在内存,也可能写 temporary files;
  • Limit 1 若没有可利用的顺序或选择性,下面仍可能扫描很多行。

节点只描述算法和责任。性能结论必须同时看输入规模、估算误差、loops、filter 丢弃量、buffer/I/O、等待与并发环境。

5.1.3 优化器为什么做估算而不是预言

planner 必须在执行前作选择,因此只能利用当时可得的信息估算候选 path。核心链条是:

table cardinality + column statistics + predicates
  → selectivity estimate
  → rows / width estimate at every node
  → CPU + page + parallel + startup/total cost
  → choose expected cheapest path

cost=0.42..8.44 是按配置成本单位计算的比较量,不是 0.42–8.44 毫秒。estimated rows 也不是承诺返回的行数,而是影响 join order、join algorithm、scan path、parallelism 和 memory assumptions 的关键输入。

统计是有意近似的

pg_class.reltuplesrelpages 不随每行写入实时更新;pg_stats 的 most common values、histogram、null fraction 与 distinct estimate 来自 ANALYZE 样本。即使刚分析完,它们仍是近似。planner 还常对多个条件采用独立性假设;城市和邮编、订单状态和支付时间这类相关列可能让乘法选择率严重失真,需要有证据地引入 extended statistics。

常见估算偏差来源包括:

  • bulk load 后尚未 ANALYZE,或分布最近发生突变;
  • 极端 skew 被有限 MCV/histogram 粒度抹平;
  • 多列相关性没有 dependencies / MCV extended statistics;
  • expression、function 或 cast 让现有统计不对应实际谓词;
  • prepared statement 的未知参数与 generic plan 无法代表特定值;
  • 跨表相关、数据新鲜度和未来并发本来就不在单列统计中。

成本模型也不知道未来

cost 参数表达 planner 对 sequential page、random page、CPU tuple/operator、parallel setup 等工作的相对假设。它不知道查询真正运行时:

  • 数据页会在 shared buffers、OS cache 还是存储设备;
  • 同一磁盘是否正被 checkpoint、backup 或其他查询占用;
  • 会不会等待 row lock、LWLock、buffer pin、WAL flush 或客户端;
  • 当前主机是否 CPU throttling;
  • 结果集会不会因刚提交的数据而改变。

所以 plan 可以“按已知信息做了正确选择”但运行仍慢;也可以因估算错误选错 path。前者应调查资源与等待,后者才进入统计、SQL 形状或索引修正。

用误差定位,不用节点偏好代替诊断

第 7 章会用:

estimate ratio = max(actual rows / estimated rows,
                     estimated rows / actual rows)

逐层寻找第一次显著偏离,并把它与统计和 predicate 对上。第 8 章则先判断 wall time 消耗在 CPU、I/O、lock、WAL、client 还是连接队列。现在只记住三条:

  1. estimated cost 只在同一 planning context 下比较候选,不跨服务器当 benchmark;
  2. EXPLAIN ANALYZE 是一次真实样本,不是未来流量的预言;
  3. 修复顺序是语义正确 → 数据/统计正确 → 估算合理 → 成本假设校准,最后才考虑 hint-like 强制手段。

把 optimizer 当成使用不完整信息的工程决策器,比把它人格化为“聪明/愚蠢”更有用。计划异常通常意味着输入证据或成本假设与现实不匹配,而不是数据库在随机选择。


返回本章目录 · 下一节:MVCC 与可见性 · 查看全书目录 · 查看索引中心

5.2 MVCC 与可见性

应用说“订单 1002 这一行”,heap 中却可能先后存在多个 tuple version。PostgreSQL 不让读取者与写入者围绕同一份可变字节互相排斥,而是让语句用 snapshot 判断哪些版本对自己可见。这就是 MVCC 的核心;它换来的并发性并非免费,旧版本最终必须由 VACUUM 安全回收。

5.2.1 元组版本、事务 ID 与快照

对 heap table,UPDATE 的基本效果是建立新版本并让旧版本退出未来可见范围,不是原地覆盖所有字段。逻辑主键仍指向“同一订单”,但物理 tuple header 携带版本信息:

系统列 诊断含义 不能怎样用
xmin 插入此 tuple version 的 transaction ID 不能当创建时间或全局唯一业务 ID
xmax 删除/更新/锁定相关 XID 或 MultiXact 信息 非零不自动等于“已提交删除”
ctid 当前 tuple version 的物理 page/slot UPDATE、VACUUM FULL 等会改变,不能当主键
cmin/cmax 同一事务内 command ordering 的内部信息 不应写进应用协议

官方文档把 xmin 定义为“插入这个 row version 的事务”,并明确说每次更新会形成新的 row version。xmax 的语义更复杂:删除事务未提交、已经回滚,或行锁形成 MultiXact 时都可能非零。因此:

SELECT order_id, xmin, xmax, ctid
FROM shop.sales_order
WHERE order_id = 1002;

是很好的实验探针,却不是可靠的“这行是否存活”判断器。让 PostgreSQL 的 visibility machinery 返回普通 SELECT 结果,才是应用应使用的接口。

XID 是版本排序工具,不是永久编号

普通内部 XID 是 32 bit 循环空间,依靠 modulo 比较和 freezing 维持可见性。它会 wrap around,也可能因只读事务尚未写入而暂未分配。pg_current_xact_id_if_assigned() 正是为了在不强行分配 XID 的情况下观察当前事务。

本章一次只读观察得到:

assigned_xid_before_write=<none>
backend_snapshot=<none>|959|<none>|<none>

第一项是当前 backend 尚无 write XID;第二项依次是 backend_xidbackend_xmin、wait type、wait event。它不是“没有事务”:BEGIN ... READ ONLY 已经建立事务与 snapshot,只是尚不需要普通写 XID。

PostgreSQL 13 起提供的 xid8/pg_snapshot 函数给诊断和逻辑解码工具更适合的 64-bit 表达;它仍不是跨 cluster、跨恢复周期的订单 ID。业务标识继续使用 ch04 的 PK/业务键。

snapshot 是边界加活跃集合

pg_current_snapshot() 的文本形状为:

xmin:xmax:xip_list

例如 959:959: 表示:

  • snapshot xmin=959:当时最老的活跃 top-level XID 边界;
  • snapshot xmax=959:大于或等于这个上界的 XID 在快照时还不能视为已完成可见;
  • xip_list 为空:在两个边界之间没有额外列出的活跃 top-level XID。

这不是“所有已提交事务清单”。对某个 tuple,系统还要结合插入/删除 XID 的提交状态、当前事务自身、command ID、MultiXact 与 hint/frozen 状态判断。snapshot 也只列 top-level XID,不直接列每个 subtransaction。

可下载的 observe.sql把同一 snapshot 保存后分别用:

pg_snapshot_xmin(snapshot)
pg_snapshot_xmax(snapshot)
pg_snapshot_xip(snapshot)

解析,避免在应用里用字符串切割重造规则。snapshot 导出/导入还有严格的事务与隔离级别条件,本章不把它扩展成分布式一致性方案。

5.2.2 活跃、提交、中止与可见性判断

tuple header 记录“谁创建/结束版本”,transaction status 记录这些事务最终是 in progress、committed 还是 aborted,snapshot 则记录观察时刻的并发边界。可以先用一张非实现代码的判断图建立直觉:

flowchart TD
  A["候选 tuple version"] --> B{"插入者是自己?"}
  B -- "是" --> C["结合 command ID 判断<br/>本事务内是否已经发生"]
  B -- "否" --> D{"插入 XID 已提交<br/>且在 snapshot 可见?"}
  D -- "否" --> X["不可见"]
  D -- "是" --> E{"该版本是否被<br/>已提交且可见的事务结束?"}
  E -- "是" --> X
  E -- "否" --> V["可见"]

真实实现还要处理 aborted XID、subtransaction、MultiXact、frozen tuple 和多种 infomask;这张图只用于解释责任分工,不能复制成应用端 visibility algorithm。

三种事务状态会留下不同结果

  • in progress:普通并发读不能看到它尚未提交的 tuple version;
  • committed:是否可见还取决于 snapshot 是否足够新;
  • aborted:它写出的版本不成为正常可见数据,但空间和 WAL 工作不会凭空消失。

同一事务总能在适当 command boundary 后看到自己的写入,否则无法执行“插入后查询、再更新”。这由 command ID 与特殊的 self-visibility 规则配合完成,不意味着别的会话也可见。

在默认 Read Committed 中,每个 command 获取新 snapshot。因此事务 A 的两个普通 SELECT 之间若事务 B 提交,第二个查询可以看到新版本。在 Repeatable Read/Serializable 中,普通读取使用 transaction snapshot,不会因 B 后来提交而更新自己的视图。隔离级别改变的是 snapshot 生命周期和冲突处理,不是把 heap 换成另一套存储。

不要从 header 单字段猜提交状态

下面这些推论都不成立:

  • xmin 小,所以一定 committed;
  • xmax=0,所以永远没有锁;
  • xmax<>0,所以该行已经删除;
  • ctid 没变,所以没有并发更新;
  • 当前 XID 比 tuple XID 大,所以可见。

例如行锁也可能写 xmax/MultiXact;aborted 删除会留下非零信息;VACUUM/freezing 与 hint bits 又会改变内部表示。需要调查某个 XID 时,可以在受控诊断中使用 pg_xact_status(xid8) 等官方函数并注明版本和保留窗口,但应用正确性不能依赖旧 XID 状态永远可查。

另一个边界是 sequence:nextval 的推进不随调用事务 rollback。这是为了并发与唯一分配效率,因此 identity/sequence 允许 gap。不要拿“订单行回滚了但 ID 跳号”反驳事务原子性;序列值本就有专门的非事务语义。

5.2.3 读不阻塞写的条件与代价

PostgreSQL 文档常用“reading never blocks writing and writing never blocks reading”概括 MVCC。工程上应把它展开为:在 primary 上,普通 heap SELECT 不与同一行的 row-level write lock 冲突;读取者可见旧的 committed version,写者创建新版本。

这不意味着任意读永远不会等:

  • SELECT ... FOR UPDATE/SHARE 主动成为 row locker;
  • 普通 SELECTACCESS SHARE table lock 会被 ACCESS EXCLUSIVE DDL/maintenance 阻塞;
  • query 可能等待 I/O、LWLock、buffer pin、WAL/IPC、parallel worker 或客户端;
  • standby 查询可能与 recovery cleanup 冲突;
  • CPU、buffer cache 和存储带宽仍会形成资源争用。

因此“读不阻塞写”是 lock compatibility 的精确性质,不是无延迟承诺。

本章实验中的两个并发结果

blocker 对订单 1002 执行未提交 UPDATE 后停在 pg_sleep。此时:

blocker:
  backend_xid=962
  wait_event_type=Timeout
  wait_event=PgSleep

ordinary reader:
  statement_timeout=1s
  result=旧的已提交 request_fingerprint

second writer:
  state=active
  wait_event_type=Lock
  wait_event=transactionid
  pg_blocking_pids={blocker_pid}

普通 reader 没去读取 blocker 的脏版本,而是从版本链找到旧 committed tuple;second writer 不能同时决定同一逻辑行的下一版本,所以等待 blocker XID 完成。这正是“read concurrency 高、write conflict 仍需排序”的组合。

代价落在空间、WAL 与清理

更新旧版本不会立即消失,因为仍可能有旧 snapshot 需要它。结果包括:

  • heap 中产生 obsolete/dead tuple,需要 VACUUM 标记空间可重用;
  • indexes 可能增加新 entry,取决于 HOT 条件和 indexed columns;
  • 写入与回滚都可能产生 WAL、dirty buffers 和统计计数;
  • 长事务/长 snapshot 提高全局 xmin horizon,延迟 dead tuple 清理;
  • autovacuum 既要回收空间,也要维护 visibility map 和防止 XID wraparound;
  • index-only scan 是否免 heap fetch 还依赖 visibility map。

所以生产上“没有锁等待但表越来越大”并不矛盾。MVCC 把读写冲突转成版本管理工作; 第 28 章会专门治理 VACUUM、freeze 和 bloat。

用三个问题判断所谓 MVCC 问题

  1. 当前会话在等什么?statewait_event_typewait_event,不要先猜锁。
  2. 谁阻碍了清理 horizon? 看 transaction age、backend_xmin、replication slot/standby feedback 等证据。
  3. 版本制造速度和回收速度是否失衡? 看 tuple change、dead tuple、autovacuum、WAL 与 relation size 趋势。

把所有性能问题笼统叫“MVCC 膨胀”不会产生动作。现象必须落到 version churn、oldest snapshot、vacuum progress、lock/wait 或 I/O 中的一项。


上一节:SQL 从文本到结果 · 返回本章目录 · 下一节:事务边界与失败语义 · 查看全书目录 · 查看索引中心

5.3 事务边界与失败语义

事务不是给一组 SQL 加上 BEGINCOMMIT 的排版形式,而是数据库正确性的最小失败与可见单位。应用必须知道事务何时开始、一个 error 会把它变成什么状态、哪类失败可以重试,以及数据库边界之外的动作为什么不会自动回滚。

5.3.1 自动提交、显式事务与中止状态

PostgreSQL 中每条语句都在事务里。若客户端没有显式 BEGIN,服务器会为单条语句建立隐式 transaction 并在成功后提交;psql 把这种行为暴露为 AUTOCOMMIT=on。显式 transaction block 则把多条语句放进一个原子边界:

BEGIN;
UPDATE ...;
INSERT ...;
COMMIT;

“autocommit”常由客户端/driver 再包装一层。某些 driver 默认自动提交,某些 ORM 打开 request-scoped transaction,连接池还可能在归还连接时 rollback。排查边界时不能只看业务代码有没有 BEGIN,还要检查 driver 配置、middleware 和 server 端的 xact_start

一条错误会让显式事务进入 failed state

本章脚本故意执行:

BEGIN;
SELECT 1 / 0;        -- 22012 division_by_zero
SELECT 'continue';   -- 25P02 in_failed_sql_transaction
ROLLBACK;

第一个 error 不是只撤销一个函数调用然后“照常继续”。当前 transaction block 被标记 aborted,后续普通 SQL 收到 25P02,直到:

  • ROLLBACK 结束整个事务;或
  • 之前建立过 savepoint,使用 ROLLBACK TO SAVEPOINT 回到可用 subtransaction 边界。

25P02 通常是二次错误。真正根因是同一连接更早的第一个 SQLSTATE;日志与 tracing 应保留 first error,而不是把最后大量 current transaction is aborted 当根因。

应用端正确骨架是:

checkout:
  acquire connection
  BEGIN
  try:
      perform all database work
      COMMIT
  catch:
      ROLLBACK
      classify original SQLSTATE
  finally:
      return a clean connection

若 connection 在 failed transaction 状态被放回 pool,下一位请求会接到 25P02;若连接停在 idle in transaction,它还可能长期持锁、持 snapshot、阻碍 VACUUM。连接池归还前的 rollback 是卫生线,不代替业务代码正确结束事务。

timeout 也属于失败语义

至少区分:

设置 限制什么 触发后要做什么
statement_timeout 单条 statement 总时长 当前 statement 被取消;显式事务通常进入 failed state
lock_timeout 等待 lock 的时长 只在等待锁期间计时;仍需恢复/结束事务
idle_in_transaction_session_timeout transaction 内无客户端 SQL 的空闲 server 终止 session,保护锁与 xmin horizon
客户端 request timeout 调用方等待预算 不保证 server 已停止,必须有 cancel/幂等/结果确认策略

不要在 global 层随意把 statement_timeout 设成一个小值并宣称“解决慢 SQL”。它是预算控制,不会告诉你时间消耗在哪,也不会自动让被取消事务恢复可用。

5.3.2 原子性、持久性与 WAL

原子性回答“事务的数据库效果是全部可见或全部不可见”;持久性回答“服务器确认 commit 后,在承诺的故障模型下能否恢复”。MVCC、transaction status 和 WAL 各自承担不同责任,不能缩写成“PostgreSQL 会写日志所以 ACID”。

WAL 是 redo 前提,不是业务事件流

Write-Ahead Logging 的核心顺序是:描述数据页变更的 WAL 必须先达到要求的持久位置,相关 dirty data page 才能安全落盘。这样 crash recovery 可以从 checkpoint 之后重放 WAL,把未及时写回的数据页恢复到一致状态。

它带来几个边界:

  • WAL 记录面向物理/内部恢复,不是稳定的订单事件 API;
  • rollback 不是从 WAL 中“删除刚才几条记录”;
  • heap/index 写入可以先产生 WAL,最终 transaction 却 aborted;
  • LSN 是实例某条 timeline 上的位置,不是 transaction ID 或业务 offset;
  • pg_wal_lsn_diff 观察的是两个全局位置之差,窗口中可能混入其他 backend 的 WAL。

wal-rollback.sql在一个事务中改写订单指纹,取得 write XID 后 rollback。一次实测为:

write_xid=961
wal_lsn_before=0/2E96560
wal_lsn_after=0/2E96630
wal_bytes_observed=208
wal_insert_advanced=t
state_restored=t

208 不是该 UPDATE 的可移植精确尺寸;full-page image、checkpoint、HOT、版本和并发都会改变差值。稳定结论只有:本窗口 WAL insert location 前进,而最终业务值恢复。这反证“LSN 前进 = 事务提交”。

commit acknowledgement 有配置前提

默认 synchronous_commit=on 时,普通本地提交等待 commit WAL flush 到 durable storage 后再向客户端成功;若配置了 synchronous standbys,具体等待还受 synchronous_commit 模式和 synchronous_standby_names 影响。

synchronous_commit=off 允许服务器在 WAL durable flush 之前返回成功。crash 窗口内最近事务可能丢失,但数据库通过已 flush WAL 恢复到一致状态;它改变 durability guarantee,不把已成功返回的事务“半提交”成损坏行。fsync=off 风险更大,可能导致 crash 后不可恢复的不一致/损坏,不能与 async commit 混为一谈。

因此一次关键业务提交至少要记录:

SHOW synchronous_commit;
SHOW fsync;
SHOW full_page_writes;

并把“本地 durable”“同步副本 durable/已应用”“归档可用于 PITR”分成三个承诺。第 19、20 章再把这些承诺落实到 Pigsty HA 和 backup topology。

commit 不等于调用方一定知道结果

客户端在发送 COMMIT 后断线,可能发生两种都合理的现实:

  1. server 尚未 commit,事务因连接消失而 rollback;
  2. server 已 commit,但成功响应没到客户端。

调用方只知道 outcome ambiguous,不能盲目重放非幂等订单。ch03/ch04 已为 order/payment 建立 request/idempotency key,就是为这种边界提供查询与重试依据。事务原子性保护数据库内部状态,不保证网络把最终答案可靠送达一次且仅一次。

5.3.3 保存点、重试边界与外部副作用

savepoint 把 transaction 切成 subtransaction 边界:

BEGIN;
SAVEPOINT risky_statement;

-- 可能触发一个允许恢复的数据库错误
UPDATE ...;

ROLLBACK TO SAVEPOINT risky_statement;
-- transaction 再次可用
...
COMMIT;

本章实验先用非法 currency_code='USD' 触发 23514,再 ROLLBACK TO,随后成功执行另一条 UPDATE,最后整体 rollback。它证明的是“局部数据库失败可在预设边界恢复”,不是鼓励捕获所有错误后继续提交。

只恢复预期、局部、已理解的失败

适合 savepoint 的例子是:一个明确可选的子操作、已知 constraint exception、恢复后主事务不变量仍成立。以下情况通常应 rollback 整体 transaction:

  • 连接丢失、admin shutdown、crash recovery;
  • deadlock victim (40P01);
  • serialization failure (40001);
  • statement/query cancel 后应用不清楚执行进度;
  • 违反关键业务约束,后续操作依赖失败结果;
  • 任意未知 SQLSTATE。

大量逐行 savepoint 还会制造 subtransaction 管理开销;bulk ingest 更适合 staging、set-based validation、ON CONFLICT 的明确策略或分批 transaction。

锁也遵循 savepoint 边界:在 savepoint 之后取得的 table/row lock,回滚到该 savepoint 时释放。savepoint 之前取得的锁仍持有到 outer transaction 结束。看到一次 ROLLBACK TO 不能假定“这个会话已经不持锁”。

重试必须覆盖整个正确性单元

40001 serialization_failure40P01 deadlock_detected 的安全策略通常是:

rollback whole transaction
discard values read inside it
apply bounded backoff + jitter
start from BEGIN with fresh snapshot

只重放最后一条 UPDATE 会复用旧决策和旧读取,不再是同一个正确性证明。重试还必须有总 deadline、最大次数、metrics,并使用 idempotency key 处理 ambiguous commit。constraint violation、syntax error、权限错误通常是确定性失败,盲目 retry 只会制造负载。

数据库 rollback 不会撤销外部世界

下面的顺序有危险:

BEGIN
charge payment provider       ← 外部副作用已发生
INSERT payment row
COMMIT fails

数据库无法“回滚 HTTP”。反过来先 COMMIT 再发消息,也可能 commit 成功而进程在发送前崩溃。常见闭环是:

  • 外部接口使用稳定 idempotency key;
  • 数据库事务同时写业务事实与 outbox row;
  • 独立 publisher 至少一次发送 outbox;
  • consumer 用 inbox/dedup key 幂等处理;
  • 对 ambiguous result 先查询权威状态,不直接重复副作用。

本书在 ch12 把这套合同接入后端服务,在 ch13 讨论哪些逻辑适合留在数据库。当前最重要的分界是:savepoint 和 transaction 只控制同一 PostgreSQL transaction 内的效果;文件、邮件、支付、Kafka 和另一个数据库都需要额外协议。


上一节:MVCC 与可见性 · 返回本章目录 · 下一节:锁与等待 · 查看全书目录 · 查看索引中心

5.4 锁与等待

“数据库被锁了”通常把至少四件事混在一起:对象上的 regular lock、heap tuple 中的 row lock、共享内存内部的 lightweight lock,以及当前 backend 的 wait event。可靠诊断先确定等待类型,再建立谁等待谁的边,最后才评估是否需要取消或终止。

5.4.1 表锁、行锁与轻量级锁的职责

PostgreSQL 用不同同步机制保护不同层次:

层次 保护对象/责任 主要证据 应用能否显式取得
table-level lock relation 与 DDL/DML 的兼容性 pg_locks,locktype=relation LOCK TABLE 或命令自动取得
row-level lock 同一 tuple 的更新、删除、显式 locker 冲突 tuple header、transaction-ID wait、部分 pg_locks SELECT ... FOR ... 或 DML
regular lock manager 其他对象 XID、virtual XID、object、extend、advisory 等 pg_locks 部分可以
predicate lock Serializable read/write dependency 跟踪 pg_locksSIReadLock 由 SSI 自动管理,不阻塞
page/buffer pin buffer 中页面访问的短期协调 wait event / 内部状态 不能作为业务锁 API
LWLock shared-memory data structure 的短期互斥 wait_event_type='LWLock' 不能
advisory lock 应用自定义的整数 key 协调 pg_locks + advisory functions 可以,但数据库不懂业务对象

“heavyweight lock”常被用来指 regular lock manager 中会入 lock table、支持等待队列和 deadlock detection 的对象;它不意味着一定很慢或锁住大范围。LWLock 的“lightweight”也不意味着可以忽略:高并发下某个共享结构的 LWLock contention 完全可能成为主要延迟,只是解决方式不是 SELECT FOR UPDATE

table lock 和 row lock 同时存在

一次:

UPDATE shop.sales_order
SET request_fingerprint = ...
WHERE order_id = 1002;

至少要保护:

  • relation 上的 ROW EXCLUSIVE table-level lock,防止冲突 DDL;
  • 目标 row version 的 row-level update lock;
  • 当前 transaction ID 的状态与等待者;
  • buffer/WAL 等内部结构的短期同步。

ROW EXCLUSIVE 名字中有 ROW,却是 table-level mode。row lock 的四种 SQL 语义则是:

FOR KEY SHARE
FOR SHARE
FOR NO KEY UPDATE
FOR UPDATE

强度与冲突矩阵不同。普通 UPDATE 若不改变可用于 foreign key 的 key columns,通常取得较弱的 FOR NO KEY UPDATE 语义;修改 key 或 DELETE 会更强。应用不应根据一个通用单词“exclusive”推断所有冲突。

为什么 pg_locks 里看不到 blocker 的“行锁”

PostgreSQL 不把所有已锁行维护成一张无限增长的 shared-memory 清单;row lock 信息写在 tuple header。发生同一行 update conflict 时,waiter 常先取得一个 tuple lock 以排队,然后等待 blocker 的 transaction ID 完成。

本章现场恰好展示:

blocker:
  transactionid | ExclusiveLock | granted=true | xid=962

waiter:
  tuple         | ExclusiveLock | granted=true  | sales_order page=0 tuple=4
  transactionid | ShareLock     | granted=false | xid=962

真正未获准的是 waiter 对 XID 962 的 ShareLock,所以 activity 的:

wait_event_type=Lock
wait_event=transactionid

与 locks 证据一致。若只搜索 locktype='tuple' AND granted=false,会错误得出“没有行锁等待”。这也是为什么权威 blocker 边优先使用 pg_blocking_pids(waiter_pid)

5.4.2 等待图、阻塞链与死锁检测

把每个正在等锁的 backend 画成节点,waiter → blocker 画成有向边:

W2 ──waits for──> W1 ──waits for──> B0

这是 blocking chain;只要 B0 最终 commit/rollback,链可以继续推进。若形成环:

T1 → T2 → T1

才是 deadlock。等待很久不自动等于 deadlock,deadlock 也不要求等待很久才在逻辑上成立。

从 waiter 出发,而不是拼一条万能 self-join

第一组只读证据:

SELECT
    pid,
    application_name,
    state,
    wait_event_type,
    wait_event,
    xact_start,
    query_start,
    pg_blocking_pids(pid) AS blocking_pids,
    query
FROM pg_stat_activity
WHERE datname = current_database()
  AND state <> 'idle';

pg_blocking_pids()知道 lock conflict matrix、wait queue 和 parallel worker 映射,比手写 pg_locks self-join可靠。它既可能返回持有冲突锁的 hard blocker,也可能返回排在队列前面的 soft blocker;parallel query 可能出现重复 client-visible PID,prepared transaction blocker 用 PID 0 表示。高频调用还会短暂独占 lock manager shared state,所以它是诊断函数,不应被应用每毫秒轮询。

找到 edge 后再补:

SELECT *
FROM pg_locks
WHERE pid = ANY (ARRAY[waiter_pid, blocker_pid]);

用于解释对象、mode、granted、fastpath、waitstart。pg_locks 是瞬时切片;fast-path、regular 和 predicate lock 的采集并非一个全局冻结时刻,不要把两个相隔数秒的查询拼成绝对一致的历史。

active 不等于正在消耗 CPU

pg_stat_activity.statewait_event 独立:

  • state='active' AND wait_event IS NULL:正在执行,但仍需结合 CPU/I/O 证据;
  • state='active' AND wait_event IS NOT NULL:query 在执行生命周期中,却卡在某个 wait point;
  • idle in transaction:当前没跑 query,但 transaction 仍开着,可能持锁和 snapshot;
  • idle:等待客户端下一条命令,通常不持 transaction locks。

本章 waiter 是 active + Lock + transactionid,blocker 却是 active + Timeout + PgSleep。后者不是在等待 waiter,而是在按实验设计睡眠并持有未提交事务。只按 state='active' 排序会把二者都叫“活跃 SQL”,丢失因果关系。

deadlock detector 解决环,不替应用设计顺序

PostgreSQL 检测到锁等待环后会 abort 其中一个 transaction,以 40P01 deadlock_detected 让其他成员继续;不能依赖固定谁当 victim。应用要 rollback 并从事务开头重试。

最有效的预防是所有代码按一致顺序取得多个对象的锁。例如转账总按较小 account ID 后较大 ID;批量更新先排序主键。还应:

  • transaction 尽量短,不在持锁时等待用户或远程 API;
  • 第一次取得对象时就选择实际需要的 mode,避免难以推理的升级;
  • 为 lock wait 设置业务预算并保留原始 SQLSTATE;
  • 在受控环境启用合适的 log_lock_waits/deadlock_timeout 取证;
  • 监控连接池排队与 database lock 两种不同的“等待”。

本章不主动制造 deadlock,因为一次单边阻塞已经足以建立证据链;第 10 章会用确定性双事务场景验证 40P0140001 和重试边界。

5.4.3 锁模式名称不等于业务影响

table-level mode 的关键不是英文听感,而是 conflict matrix。常用子集如下:

命令示例 自动取得的 relation mode 对普通 SELECT
SELECT ACCESS SHARE 可并发
SELECT ... FOR UPDATE 目标表 ROW SHARE,另有 row lock 普通读仍可并发
INSERT/UPDATE/DELETE/MERGE 目标表 ROW EXCLUSIVE 普通读仍可并发
VACUUMANALYZECREATE INDEX CONCURRENTLY 常见为 SHARE UPDATE EXCLUSIVE 普通读可并发,但各命令还有阶段/资源代价
CREATE INDEX 非 concurrently SHARE 普通读可并发,写入受阻
TRUNCATEVACUUM FULL、许多 rewrite DDL ACCESS EXCLUSIVE 阻塞

官方矩阵中,普通 SELECTACCESS SHARE 只与 ACCESS EXCLUSIVE 冲突。这不表示 DDL 只有 ACCESS EXCLUSIVE 才有业务影响:一个等待取得强锁的 DDL 可能排在队列中,让它后面的请求形成 convoy;CREATE INDEX 还可能争用 I/O/CPU;长 transaction 会让短暂 lock 变成长事故。

同一个 mode,影响可以相差几个数量级

评估锁风险至少要回答:

  1. 对象:哪张 relation、哪一行、哪个 XID 或 advisory key?
  2. mode 与 conflict:谁与谁冲突,不是名字有多吓人?
  3. 范围:命中一行、百万行、所有 partition,还是 catalog object?
  4. 持有期:statement 结束还是 transaction 结束?事务已经多老?
  5. 扇出:有多少 waiter、上游连接池和同步请求?
  6. 可恢复性:cancel statement 足够,还是 backend 必须终止?commit outcome 是否 ambiguous?

对一行的 ROW EXCLUSIVE table lock 可以持续 2 ms,也可以因应用调用支付接口持续 30 s;mode 相同,业务影响完全不同。反之,一个瞬时 ACCESS EXCLUSIVE 若能立即取得并在毫秒内完成,可能比排队十分钟的普通写影响小。DDL 发布必须用真实锁时长、table size、long transaction 和 timeout 演练,而不是静态给 mode 贴“安全/危险”标签。

取消与终止是最后一步

生产处置顺序应是:

确认采样时刻
→ 定位 waiter 与 blocker edge
→ 核对 application/user/database/xact age/query
→ 评估 blocker 是否正在做不可中断业务
→ 优先让 owner 正常结束
→ 必要时 pg_cancel_backend(query)
→ 明确授权后 pg_terminate_backend(session)
→ 验证锁链、业务状态与重试结果

pg_cancel_backend 只请求取消当前 query,不自动关闭 session。本章 blocker 之所以随后释放事务,是因为 psql 设置 ON_ERROR_STOP,收到 57014 query_canceled 后退出连接,server 因断连 rollback。生产 application 可能捕获错误后停在 failed/idle transaction;不能照抄实验把 cancel 当作 transaction cleanup。

pg_terminate_backend 会断开精确 session,影响更大;连接池还可能立刻重连并重放负载。任何处置都要保存 PID、backend start、application name、XID、query/transaction start 与 blocking edge,避免 PID 重用或误伤无关工作。


上一节:事务边界与失败语义 · 返回本章目录 · 下一节:隔离现象与后续路线 · 查看全书目录 · 查看索引中心

5.5 隔离现象与后续路线

隔离级别不是从“弱一致”到“强一致”的四档万能开关。SQL 标准用禁止哪些并发现象来规定最低保证,PostgreSQL 再用 MVCC、snapshot isolation 与 SSI 给出自己的具体实现。讨论任何异常时,都必须同时写出数据库、隔离级别、SQL 形状和最终提交结果。

5.5.1 脏读、不可重复读、幻读与序列化异常

先把四种现象定义准确:

  • dirty read:读到并发 transaction 尚未提交的值;
  • nonrepeatable read:同一 transaction 再读同一逻辑行,看到另一个已提交 transaction 的修改;
  • phantom read:同一 transaction 重跑同一 predicate query,满足条件的 row set 因并发提交而变化;
  • serialization anomaly:一组成功提交事务的总体结果无法等价于任何串行顺序。

PostgreSQL 18 的实际矩阵是:

请求的 isolation dirty read nonrepeatable phantom serialization anomaly
Read Uncommitted 不会发生 可能 可能 可能
Read Committed 不会发生 可能 可能 可能
Repeatable Read 不会发生 不会发生 不会发生 可能
Serializable 不会发生 不会发生 不会发生 不会让异常事务全部成功提交

第一处 PostgreSQL 特性是:虽然接受四个标准名称,内部只有三个不同级别,Read Uncommitted 按 Read Committed 执行。第二处是 PostgreSQL Repeatable Read 比标准最低要求更强,不允许 phantom,但仍可能发生 serialization anomaly。

snapshot 生命周期解释大部分差异

Read Committed 是默认级别。每个 command 使用 statement-start snapshot,因此:

BEGIN;
SELECT ...;  -- snapshot S1
-- concurrent transaction commits
SELECT ...;  -- snapshot S2,可能看到新值/新行
COMMIT;

单条普通 SELECT 内部仍看到一致 snapshot,也不会读 dirty tuple。UPDATE/DELETE/locking SELECT 遇到并发更新时会等待,并在 Read Committed 规则下对最新版本重新判断条件;这让一条 command 的行为比“先固定全表 snapshot,再机械写入”更细致。

Repeatable Read 在 transaction 的第一个非 transaction-control statement 时取得 transaction snapshot,此后普通查询保持同一视图。如果它准备更新的目标已被 snapshot 之后的并发事务真正修改并提交,会收到 40001,必须整体重试。只读 Repeatable Read 不会因这种 row update conflict 失败,但仍可能观察到不满足任何串行顺序的跨行组合。

Serializable 在 Repeatable Read 的 snapshot 行为上增加 SSI dependency tracking。predicate lock(SIReadLock)用于发现危险的 read/write dependency,不像普通 row lock 那样阻塞 writer;若无法证明一组并发事务可串行化,至少一个以 40001 失败。因此“Serializable”承诺的是成功提交集合可串行化,不是所有 transaction 都无等待、无 abort。

isolation 不能替代错误处理

更强隔离通常把 silent anomaly 转成可见 abort,而不是让 application 省掉重试。Serializable 环境必须:

  • 40001 统一执行 whole-transaction retry;
  • 只在 commit 成功后信任 transaction 内读到的结果;
  • 限制 active connection 与 transaction 时长;
  • 将只读事务声明 READ ONLY
  • 对适合的长只读任务考虑 SERIALIZABLE READ ONLY DEFERRABLE,理解它可能在开始时等待安全 snapshot。

sequence 仍有特殊非事务行为;外部 API 仍不受 isolation 管理。把 isolation 调高不能修复缺失 idempotency key、跨库原子性或错误的业务 predicate。

5.5.2 lost update 必须绑定具体隔离级别与写法

“Read Committed 会丢更新”只说了一半。下面两个流程都想把库存从 10 减 1,结果不同。

原子相对更新

两个 session 都执行:

UPDATE inventory
SET stock = stock - 1
WHERE sku = 'SKU-GIFT'
  AND stock > 0
RETURNING stock;

在 Read Committed 下,第一个 writer 锁住目标行;第二个等待,随后在已更新版本上重新检查 stock > 0 并计算 stock - 1。若初始为 10,正常结果依次为 9、8,不会因两者都先拿到常量 10 而覆盖。

这仍需检查 affected row count:库存为 0 时返回零行,应用必须解释为 sold out,而不是假定成功。row-level CHECK (stock >= 0) 可以成为最后防线。

应用层 read-modify-write

两个 session 都先:

SELECT stock FROM inventory WHERE sku = 'SKU-GIFT'; -- 都读到 10

应用各自在内存算出 9,再执行:

UPDATE inventory
SET stock = 9
WHERE sku = 'SKU-GIFT';

第二个 writer 仍会等待第一个,但等待后把最新 9 又覆盖成常量 9;两次业务扣减只留下一个效果。这才是典型 lost update。锁确实排序了物理写入,却不知道常量 9 是由旧 snapshot 推导的。

可选控制方式:

方式 SQL 合同 失败/等待语义 适用边界
原子相对 UPDATE SET stock=stock-1 WHERE stock>0 row wait;零行表示条件失效 单行可表达运算,首选
optimistic version WHERE id=? AND version=? 零行表示冲突,应用重新读/决策 UI/API 更新、冲突不频繁
pessimistic lock SELECT ... FOR UPDATE 后计算 提前等待,事务持锁变长 必须读取多列后决定同一行
Repeatable Read transaction snapshot 并发改同一行时常以 40001 失败 应用已有 whole-tx retry
Serializable SSI 验证整体 serial order 可能 40001 跨行 predicate invariant

optimistic 例子:

UPDATE inventory
SET stock = :new_stock,
    version = version + 1
WHERE sku = :sku
  AND version = :seen_version
RETURNING stock, version;

返回零行不是 database outage,而是“决策前提已经过期”。应用可返回 conflict 或在新值上重新执行业务逻辑,不能只把同一个常量 UPDATE 无限重试。

lost update 与 write skew 不是同一异常

lost update 竞争同一逻辑值;write skew 往往更新不同行。两个医生各自看到“至少还有另一人值班”,随后分别把自己的行改为 off-call;没有同一行 write/write conflict,两者在 Repeatable Read 可能都提交,却破坏“至少一人值班”的跨行不变量。

处理优先级是:

  1. 能用 PK/UK/FK/CHECK/EXCLUDE 等 declarative constraint 表达,就让数据库无条件拒绝;
  2. 能收敛为同一 counter/guard row 的 atomic update,就避免分散 predicate;
  3. 否则用 Serializable + whole-transaction retry,或明确、顺序一致的 predicate/row locking;
  4. 用并发测试证明成功提交集合满足不变量。

只说“加 FOR UPDATE”也不完整:必须锁到所有能改变 predicate 的对象;若满足条件的 row 尚不存在,普通 row lock 没有一行可锁。第 10 章会用 write skew、phantom/predicate 和 retry harness 把这些边界逐项跑出来。

5.5.3 ch07–ch10 如何分别展开计划与并发

本章的作用是建立分诊,不是在第一次遇见概念时把所有旋钮讲完。后续四章各回答一种不同问题:

第 7 章:计划为什么这样选

执行计划与统计信息会深入:

  • EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS) 的安全使用;
  • estimated/actual rows、loops 与第一次估算偏差;
  • MCV、histogram、correlation 与 extended statistics;
  • custom/generic prepared plans、parallel plan 与 JIT;
  • planner cost calibration 和实验对照。

入口问题是“backend 没有明显 wait,但 plan 的工作量/估算哪里异常?”

第 8 章:慢时间到底花在哪里

慢 SQL 诊断方法论会先做 workload attribution,再区分 CPU、I/O、lock、WAL、temp spill、client backpressure 和连接排队;结合 pg_stat_statements、auto_explain、logs、OS/Pigsty metrics 建立时间线。

入口问题是“用户说慢,先用什么证据把 wall time 拆开?”

第 9 章:索引是否真正改善目标 workload

索引设计与效果验证会从 equality/range/order/join pattern 设计 B-tree、GIN、GiST、BRIN、partial/expression/covering index,并同时验证写放大、空间、visibility map 与并发创建风险。

入口问题是“已经证明访问路径缺口,哪种 index contract 能改善且值得成本?”

第 10 章:并发提交是否仍满足不变量

并发控制与隔离异常会用多个真实 session 复现 nonrepeatable read、lost update、write skew、deadlock、serialization failure,比较 atomic SQL、optimistic version、row lock、advisory lock 与 Serializable retry。

入口问题是“单事务看起来正确,多事务交错后哪些成功提交结果不再正确?”

当前应能完成的四向分诊

flowchart TD
  A["请求慢或结果异常"] --> B{"结果/业务不变量错误?"}
  B -- "是" --> C["事务边界、snapshot、SQL 写法<br/>进入 ch10"]
  B -- "否" --> D{"pg_stat_activity 有 wait event?"}
  D -- "Lock" --> E["建立 blocking edge<br/>进入 ch10 / 运维诊断"]
  D -- "IO/LWLock/WAL/Client" --> F["按等待类型取证<br/>进入 ch08"]
  D -- "无明显等待" --> G["计划工作量与估算<br/>进入 ch07"]
  G --> H{"已证明访问路径缺口?"}
  H -- "是" --> I["设计并验证索引<br/>进入 ch09"]
  H -- "否" --> F

一条 SQL 同时可能有多个问题,但动作顺序仍要可证伪。例如 waiter 的 EXPLAIN 再漂亮,也不会解除 blocker;给一个错误的 read-modify-write 加索引,也不会消除 lost update。先判层,再深入,是本章希望形成的习惯。


上一节:锁与等待 · 返回本章目录 · 下一节:实战:观察一笔订单事务 · 查看全书目录 · 查看索引中心

5.6 实战:观察一笔订单事务

这个实验不追求制造最大并发,而是把一条最小 blocking edge 观察完整:blocker 写入未提交版本,普通 reader 读旧版本,waiter 写同一行并等待;observer 同时采集 activity、blocking PID 与 locks,最后取消精确实验 query,让两个事务都回滚并验证状态。

风险分级:

  • verify / observeR0·观察,只读 catalog、sample row 和 WAL positions;
  • transactionR2·受控演练,触发三个预期 error,并 rollback 一次真实 UPDATE;
  • blockingR2·受控演练,两个 session 对订单 1002 UPDATE,调用 pg_cancel_backend 取消精确 blocker;
  • all / reviewR2·受控演练,执行前验、全部实验和后验。

即使不提交,写入仍产生 tuple/WAL/lock。只在已确认可演练的 Pigsty L1 或本地测试库运行;生产只能复用只读取证方法,不能复用“主动注入阻塞”。

5.6.1 从 SQL 观察会话、快照、锁和 WAL 位置

先使用绝对路径指向自己的私有 service file,不把 password 写进命令历史:

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

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

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

context guard 要求 database=pg36_shop、primary/writable、可 SET ROLE pg36_owner,且 shop_private.schema_version 是 ch04-v1。verify.sql复用完整 ch04 验收,再额外要求:

active_lab_workers=0
order_1002_fingerprint=2bfa6eac30b9a1cfa2d51e98c4e98332
relation_checksum=f8a7bfae59c6d16cd323abecfefe1014

观察一个没有 write XID 的 transaction

运行:

./task.sh observe
sed -n '1,120p' "$PG36_EVIDENCE_DIR/observe.txt"

observe.sql显式开启:

BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED READ ONLY;

然后只取一次 pg_current_snapshot(),解析其边界,并从 pg_stat_activity 反查自己的 backend_xid/backend_xmin。典型片段:

transaction_isolation=read committed
transaction_read_only=on
assigned_xid_before_write=<none>
snapshot=959:959:
snapshot_xmin=959
snapshot_xmax=959
snapshot_in_progress_count=0
backend_snapshot=<none>|959|<none>|<none>

XID 数值随实例推进,不能 hard-code。要观察的是:transaction/snapshot 已存在,read-only backend 却可以没有 assigned write XID;backend_xmin 暴露它对清理 horizon 的影响。

同一脚本输出:

tuple_diagnostic=1002|xmin|xmax|ctid|request_fingerprint
wal_positions=insert_lsn|write_lsn|flush_lsn

xmin/xmax/ctid只用于版本取证。反复运行 rollback 实验后,一个仍可见 tuple 甚至可能有非零 xmax,这正说明不能从 xmax<>0 直接推断“已删除”。三个 WAL position 分别表示 insert、write、flush 进度;短暂相等也不证明未来始终没有 pending WAL。

观察 failed transaction 与 savepoint

./task.sh transaction
sed -n '1,120p' "$PG36_EVIDENCE_DIR/transaction-errors.stderr"
sed -n '1,160p' "$PG36_EVIDENCE_DIR/wal-rollback.txt"

task 要求 error stream 中每个 SQLSTATE 恰好一次:

22012  division_by_zero
25P02  in_failed_sql_transaction
23514  check_violation

前两项证明 error 后普通 SQL 不能继续;第三项发生在 savepoint 后,ROLLBACK TO 恢复 transaction,再完成一条合法 UPDATE,最终 outer rollback。脚本不靠本地化错误消息判断,而用稳定 SQLSTATE。

WAL probe 要同时满足:

wal_insert_advanced=t
state_restored=t

LSN 是实例全局位置,差值中可能包含其他 backend;本实验在隔离 L1 中只用它证明“回滚路径仍有 WAL 活动”,不把字节数当单条 SQL benchmark。

5.6.2 从 Pigsty 观察连接、事务与等待指标

SQL catalog 是当前瞬时状态,Pigsty/Grafana 提供时间序列、层级导航和跨组件上下文。两者不是替代关系:

问题 PostgreSQL 原生证据 Pigsty v4.5 入口
cluster 是否出现 session/load/lock 波峰 pg_stat_activity、database stats PGSQL Activity
某 instance 的 active/idle/idle-in-tx 演变 activity + backend timestamps PGSQL Session
TPS/QPS、transaction 与 lock 趋势 database/xact stats、locks PGSQL Xacts
WAL、XID、checkpoint、archive、I/O 是否异常 WAL/admin/stats views PGSQL Persist
当前 database 的 activity 与 lock wait 明细 activity、pg_blocking_pidspg_locks PGCAT Locks

官方 v4.5 dashboard 索引把 PGSQL Activity 定义为 cluster 级 session/load/QPS/TPS/locks,把 Persist 定义为 WAL/XID/checkpoint/archive/I/O,把 PGCAT Locks 定义为 catalog-derived activity 与 lock wait。部署若定制 dashboard、collector 或版本,面板与 metric 可能变化,所以正文依赖的是问题映射,不是像素位置。

让连接可归因

所有 worker 都带唯一 application_name

pg36-ch05-blocker-<UTC timestamp>-<shell pid>
pg36-ch05-waiter-<UTC timestamp>-<shell pid>

真实应用也应给 service/driver 设置稳定 application name,并在 tracing 中关联:

cluster / instance
database / user / application
request trace ID
backend PID + backend_start
transaction/query start
dashboard time range + timezone

只记录 PID 不够,PID 会重用;只记录 SQL 也不够,同一 statement 可由大量租户并发执行。涉及权限时还要知道:普通角色在 pg_stat_activity 中只能完整看到自己的 session,跨用户 query text/细节需要 pg_read_all_stats 等受控监控权限或 superuser。不要为了 dashboard 方便给业务账号 superuser。

处理采样与瞬时现场的差异

catalog query 能在 waiter 正等待时看到精确 edge;Prometheus/exporter 按采集周期采样,短于一个 scrape interval 的实验可能根本不出现在图上。默认 blocking harness 取完 SQL 证据便立即释放,不靠固定 sleep 同步。

若只在 L1 教学库中需要让 dashboard 有机会采到,可显式延长观察窗:

export PG36_DASHBOARD_HOLD_SECONDS=20  # 只允许 0..25
./task.sh blocking

此变量只在已经确认 edge 后 sleep,不能参与 worker 同步;它会人为延长订单行等待,不允许用于生产。打开 Pigsty Web UI 后,在同一 UTC 时间窗依次看 PGSQL Activity、PGSQL Session/Xacts、PGCAT Locks,再回到 evidence 的 PID/app name 对照。若 panel 没采到,SQL evidence 仍是实验验收依据,不应继续延长生产锁来“等图变漂亮”。

dashboard 擅长回答“何时开始、范围多大、是否反复、同时还有什么资源变化”;catalog 擅长回答“现在这条边究竟是谁阻塞谁”。事故诊断通常先由告警/趋势定位时间窗,再用原生视图和日志确认现场。

5.6.3 注入阻塞并解释“现象—证据—原理”

单独执行 blocking:

unset PG36_DASHBOARD_HOLD_SECONDS
./task.sh blocking

sed -n '1,160p' "$PG36_EVIDENCE_DIR/blocking/summary.txt"
sed -n '1,160p' "$PG36_EVIDENCE_DIR/blocking/activity.csv"
sed -n '1,220p' "$PG36_EVIDENCE_DIR/blocking/locks.csv"

blocking-lab.sh不靠“sleep 两秒大概启动好了”同步。它轮询 blocker 直到:

state=active
wait_event_type=Timeout
wait_event=PgSleep

这证明未提交 UPDATE 已经完成并正持有 transaction。随后 ordinary reader 带 1 秒 statement timeout 读取旧 fingerprint;再启动 waiter,直到 pg_blocking_pids(waiter) 精确等于 blocker PID 且 wait type 是 Lock,才采集 CSV。

sequenceDiagram
    participant B as Blocker
    participant R as Ordinary reader
    participant W as Waiter
    participant O as Observer

    B->>B: BEGIN; UPDATE order 1002
    Note over B: uncommitted new tuple version
    R->>B: plain SELECT
    B-->>R: no row-lock wait; old committed version
    W->>B: UPDATE same logical row
    Note over W: waits for Blocker's XID
    O->>O: activity + blocking_pids + locks
    O->>B: pg_cancel_backend(exact PID)
    Note over B: psql exits on 57014; transaction rolls back
    B-->>W: lock released
    W->>W: UPDATE succeeds; explicit ROLLBACK
    O->>O: verify baseline and no workers

一次实测 summary:

status=ok
reader_saw_previous_committed_version=true
waiter_blocked_by=<blocker_pid>
waiter_wait_event_type=Lock
waiter_wait_event=transactionid
cancel_exact_blocker=t
blocker_expected_nonzero_exit=3
waiter_exit=0
state_restored=true
remaining_workers=0

从三组证据回到原理

现象 直接证据 可以得出的原理 不能过度推出
reader 成功返回旧指纹 1 s timeout 内结果等于 baseline 普通读使用 MVCC 旧 committed version,不等 row update lock 所有 SELECT 永不等待
waiter 停住 active + Lock + transactionid 同一行 writer 必须等待前一 XID outcome 表被“全锁死”
pg_blocking_pids 单边 edge waiter → blocker PID 当前 regular lock queue 的直接 blocker 已识别 数秒前/后的历史仍完全相同
locks 中 waiter XID ShareLock 未 granted transactionid=blocker XID 等待的是 blocker transaction completion 必须找到 blocker 的 ungranted tuple lock
cancel 后 waiter 前进 blocker 收到 57014 并断连 rollback conflicting transaction 结束会释放 lock cancel 任意生产 query 都安全
前后 checksum 相同 verify-before/after 两个业务写入最终都未提交 没产生 WAL/dead tuple/统计代价

这种“现象—证据—原理—边界”四列,比只保存一张 dashboard 截图更可审计。它允许后来者复核当时看见什么、为什么得出结论、结论没有覆盖哪些情况。

失败清理和停止线

正常路径只 pg_cancel_backend 精确 blocker query;blocker psql 因 ON_ERROR_STOP 收到 SQLSTATE 57014 后退出,server rollback connection transaction。waiter 获锁后显式 rollback。

若 harness 中途失败,EXIT trap 只按本次唯一 application names 终止它启动的 blocker/waiter,并等待本地 psql process;不会扫描或清理其他会话。随后仍应运行:

./task.sh verify

active_lab_workers<>0、fingerprint/checksum 漂移,或 blocker identity 不再精确,停止自动处置并人工核对。这个实验没有 reset,因为成功路径不应留下需要 reset 的对象或数据;“写一个 reset 抹掉异常”反而会掩盖事务边界错误。

最后做全章验收:

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

只有 verify-after 恢复稳定摘要,且 blocker cancellation、waiter exit、SQLSTATE 和 WAL/rollback 断言全部通过,才能把本章标记为完成。


上一节:隔离现象与后续路线 · 返回本章目录 · 下一章:立木取信:开发规约与交付基线 · 查看全书目录 · 查看索引中心

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 风格指南,而是得到了一套可以被验证、质疑、例外、升级和审计的规则系统。下一章开始,性能与并发专题会不断用新证据挑战它。


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

7 追本溯源:执行计划与统计信息

第 5 章已经说明:优化器比较的是基于统计与成本参数的候选路径,不是在预言未来耗时;第 6 章又把“看到 Seq Scan 或高 cost 不直接判错”写进 PREF-PLAN-005。本章开始为这条规则补运行证据。

读计划的核心不是认节点图标,而是沿一棵数据流树回答:

关系语义
  → planner 估计每一步会输出多少行
  → 候选路径怎样消费这些行
  → cost model 选择总成本较低者
  → executor 实际产生多少行、循环多少次、访问多少 buffer/WAL
  → 偏差来自统计、参数、条件表达、资源还是等待

本章用 100000 行确定性 fixture 制造两种经典偏差:regionorder_status 完全相关,但普通统计分别观察两列;tenant_id=1 有 90000 行,其余 tenant 各 10 行,使 custom 与 generic plan 面对完全不同的选择率。另一个四分区 fixture 对照规划时裁剪、执行初始化裁剪和包裹分区键导致的失效。

本章目标

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

  • 从叶子到根读懂 scan、join、sort、aggregate、materialize 的数据流;
  • 区分 startup cost、total cost、estimated rows、width 与实际时间;
  • 解释 cost 是相对比较单位,父节点 cost 已包含子树;
  • 正确使用 EXPLAINANALYZEBUFFERSWALSETTINGS 与机器可读格式;
  • 知道 EXPLAIN ANALYZE 会真实执行 SQL,写语句必须有受控事务和副作用边界;
  • 区分 planning、executor、server/client/network 与排队时间;
  • pg_stats 理解 null fraction、n_distinct、MCV、histogram 与 correlation;
  • 用 dependency/MCV 扩展统计修复跨列估算,而不把统计当约束;
  • 识别陈旧统计、数据倾斜、采样误差和表达式不匹配;
  • 区分 plan-time、initialization-time 与 execution-time partition pruning;
  • 知道 partition parent 不会由 autovacuum 自动分析,何时要显式 ANALYZE
  • 对照 custom/generic plan,理解参数敏感查询;
  • 把 plan change 当调查信号,而不是自动回归;
  • 正确使用 pg_stat_statements 的归一化聚合视角;
  • 评估 auto_explain 的阈值、采样、参数泄露与 per-node timing 成本;
  • 从 Pigsty 时间窗关联 query、database/user/application、wait、plan 与资源;
  • 产出 baseline v0.2 proposal,为 PREF-PLAN-005 增加 runtime evidence。

实验边界

实验基线为 PostgreSQL 18.6、Pigsty v4.5.0、Ubuntu 24.04 L1;SQL 保持 PostgreSQL 14–18 可用。fixture 只创建:

  • shop_private.ch07_plan_probe(100000 行);
  • shop_private.ch07_event_probe(3650 行、四个季度分区);
  • shop_private.ch07_region_status_stats

setup 会在 marker 完全匹配后重建这些专属对象,属于 R1;reset 删除它们,属于 R2,要求 action/target 双 token。EXPLAIN ANALYZE 即使查询只读也会真实执行并消耗资源,所以只在已确认 L1 运行。

下载资产:

本章目录

7.1 优化器如何选择路径

7.2 正确使用 EXPLAIN

7.3 统计信息与估算偏差

7.4 分区裁剪的两种时机

7.5 参数、缓存计划与计划漂移

7.6 建立计划证据基线

7.7 实战:解释订单查询的计划变化

实测摘要

一次 PostgreSQL 18.6 运行得到:

correlated_estimate≈6300 → 25000 / actual=25000
impossible_estimate≈6300 → 1 / actual=0
custom_hot=Seq Scan / estimate=90000 / actual=90000
custom_cold=Index Scan / estimate=10 / actual=10
generic_estimate=100 / hot_actual=90000 / cold_actual=10
partition_counts=constant:1, wrapped:4, generic:1
partition_parent_stats=0→4

ANALYZE 使用统计抽样,所以修复前的约 6300 每次可能略变;node type、cost、buffers 和时间也不是 golden。稳定断言是偏差方向、修复幅度、参数敏感性、裁剪集合与前后业务 checksum。

章节验收

  1. 能从叶子到根解释计划树,正确使用 loops × rows
  2. 不把 cost 当毫秒,不把 estimated rows 当扫描行数;
  3. 采集计划时同时保存 SQL、参数、版本、统计、settings 和 buffer/WAL;
  4. 对写计划知道怎样 rollback,并明确 sequence/外部副作用例外;
  5. 能读 pg_stats,知道 MCV/histogram 是 sample summary;
  6. 能说明单列统计为什么把相关条件近似独立相乘;
  7. 能选择 dependencies、ndistinct、MCV 的适用问题;
  8. 能证明 ANALYZE 后 estimate 改善,而不是只说“统计更新了”;
  9. 能区分三种 partition pruning 时机;
  10. 能从 Subplans Removed、loops 与 never executed 识别执行期裁剪;
  11. 能说明为何 partition parent 需显式 ANALYZE;
  12. 能对照 custom/generic plan 并识别参数倾斜;
  13. plan 变化时先检查结果、SLO、估算、数据、统计、settings 和 wait;
  14. 不把 pg_stat_statements.queryid 当跨大版本永久 ID;
  15. 不在生产无评估开启 auto_explain.log_analyze/timing
  16. task.sh all 和双令牌 reset 均通过,ch04-v1 checksum 不变。

下一章 ch08《抽丝剥茧:慢 SQL 诊断方法论》 将把单条计划 放回真实 workload、等待与时间序列中。

参考资料


上一章:立木取信:开发规约与交付基线 · 返回上卷导读 · 下一章:抽丝剥茧:慢 SQL 诊断方法论 · 查看全书目录 · 查看索引中心

7.1 优化器如何选择路径

SQL 描述结果关系,planner 则要在等价实现中选择一棵可执行树。选择发生在当前 catalog、statistics、parameter visibility、planner GUC 和 cost constants 下;环境改变,最便宜路径也可能改变。

7.1.1 扫描、连接、排序、聚合与物化节点

叶子节点产生基础行:

  • Seq Scan 顺序访问 relation page,并在节点上应用 filter;
  • Index Scan 按 index 找 tuple,再访问 heap 取可见行/列;
  • Index Only Scan 仍需 visibility map 证明可跳过 heap;
  • Bitmap Index + Heap Scan 先收集 TID,再按 page 批量访问;
  • Function/Values/CTE/Subquery Scan 从非普通表来源产行。

没有“高级节点一定更快”。小表或低选择性查询用 Seq Scan 很合理;返回大量 heap row 时,随机 index fetch 可能更贵。Rows Removed by Filter 说明读到但未输出的行,不能与节点 rows 混为一谈。

中间节点转换数据流:

责任 常见节点 关键观察
join Nested Loop / Hash Join / Merge Join outer rows、inner loops、hash/sort 输入
order Sort / Incremental Sort key、method、memory、disk spill
aggregate Aggregate / HashAggregate / GroupAggregate group estimate、batches、memory/disk
reuse Materialize / Memoize 重复读取是否被缓存,命中/溢出
combine Append / Merge Append child/partition 数与裁剪
parallel Gather / Gather Merge planned/launched workers、每 worker rows

Nested Loop 的 inner child 通常执行 outer row 次数,所以要看 loops;Hash Join 先构建 hash 再 probe,关注 build side、batches 与内存;Merge Join 要求两侧有序,排序可能由 index 或显式 Sort 提供。节点名只说明算法,不说明它在本次 cardinality 上是否正确。

7.1.2 成本、选择率、行数与路径竞争

计划行:

(cost=startup..total rows=N width=W)
  • startup 是开始输出前的估算成本;
  • total 假设节点完整执行;
  • rows 是节点输出行,不是读取/比较的全部行;
  • width 是平均输出 bytes;
  • 父节点 cost 包含其子树成本;
  • cost 使用由 seq_page_cost 等参数构成的相对单位,不是毫秒。

planner 先估 predicate selectivity,再估每个节点 cardinality。一个底层 100 倍误差进入 join 后可能乘成更大误差,改变 join order、algorithm、memory 与 parallelism。因此排查计划常先找“最早出现的大 estimate/actual 偏差”,而不是先看顶层总时间。

路径竞争还受目标影响。带 LIMIT 时,低 startup 的路径可能优于完整执行 total 更低的路径;ORDER BY 与 index order 匹配时可以省 Sort;参数化 inner path 可让 Nested Loop 每次精确 index lookup。planner 选择的是其估计下的最低 cost,不保证统计错误时仍选到真实最快方案。

禁用 enable_seqscan 等 GUC 是诊断对照,不是永久修复。多数 enable flag 只是强烈抬高该路径成本,甚至在没有正确替代时仍会使用并标记 Disabled。对照的价值是回答“如果走另一条路径会怎样”,随后仍要修 SQL、统计、index 或 cost calibration 的根因。

7.1.3 计划树的阅读顺序与数据流

一套稳定阅读顺序:

  1. 先复述 SQL 的结果与参数,不看节点猜业务;
  2. 看顶层输出 rows、总时间与是否有 LIMIT/order/aggregate;
  3. 从叶子向根追每条数据流;
  4. 在每个 node 对比 estimated rows 与 actual rows × loops
  5. 找第一处显著偏差与随后放大点;
  6. 看 filter/index cond/join filter 分别在哪层生效;
  7. 看 buffers、temp、WAL、sort/hash memory 与 worker;
  8. 最后结合 wait、客户端时间和并发判断瓶颈。

文本计划的缩进表示 parent/child,不表示实际先后时间。一个节点的 actual time=a..b 是每 loop 平均的 first/last row 时间;不能把所有节点时间简单相加,因为父时间包含子时间,pipeline 也会重叠。并行计划的 rows/loops 又可能按 worker 聚合或显示每循环平均,必须回到当前版本字段定义。

以本章参数实验为例,先不评价 Seq/Index:

custom hot:  estimate=90000 actual=90000
custom cold: estimate=10    actual=10
generic:     estimate=100   hot actual=90000 / cold actual=10

首先成立的结论是 generic estimate 对 hot 参数错了 900 倍;Seq Scan / Index Scan 的差异是这个 cardinality 与成本模型共同产生的结果。把结论写成“90% 选择率用 Seq Scan、0.01% 用 Index Scan”比“禁止 Seq Scan”更有解释力,但仍只对本表宽度、cache、index 和硬件有效。

计划树最终要翻译成一句因果链:

planner 看见什么统计/参数
  → 估了多少行
  → 为什么认为某路径便宜
  → executor 实际发生什么
  → 哪个可回退改变能验证假设

缺少其中任一段,都只是计划描述,不是诊断。


返回本章目录 · 下一节:正确使用 EXPLAIN · 查看全书目录 · 查看索引中心

7.2 正确使用 EXPLAIN

EXPLAIN 是观测工具,也会改变观测成本。普通 EXPLAIN 只规划;加 ANALYZE 后真实执行;加 timing、buffers、WAL 后又增加不同程度的采集工作。先明确问题,再选择选项。

7.2.1 EXPLAINANALYZEBUFFERSWAL

推荐的机器证据:

EXPLAIN (
  ANALYZE,
  BUFFERS,
  WAL,
  SETTINGS,
  SUMMARY,
  FORMAT JSON
)
SELECT ...;

各选项回答:

  • ANALYZE:真实 rows、loops、time,且真的执行;
  • BUFFERS:shared/local/temp hit/read/dirtied/written;
  • WAL:records、FPI、bytes,主要对写路径有意义;
  • SETTINGS:影响 planner 且偏离 built-in default 的设置;
  • SUMMARY:planning/execution summary;
  • JSON/YAML/XML:给程序解析,文本留给人读。

buffer hit 不是“没有 I/O”,只表示 page 已在 PostgreSQL shared buffers;它可能刚由另一 backend 或操作系统读入。单次 warm run 不能代表 cold cache。WAL bytes 也不等于磁盘最终写入 bytes,FPI、compression、并发与 checkpoint 都会影响。

若只验证估算与树形,可先 EXPLAIN (FORMAT JSON),避免执行高风险/高成本 SQL;若要 actual,先用生产等价的只读副本、L1 或受控参数范围。不要在事故高峰对未知查询直接加 ANALYZE。

7.2.2 规划时间、执行时间与客户端时间

EXPLAIN 的 Planning Time 与 Execution Time 都是服务器视角,通常不包含:

  • 连接建立、pool queue;
  • 客户端序列化/反序列化;
  • 网络传输与 result consumption;
  • application thread/event-loop 排队;
  • transaction 中前后其他 SQL;
  • 在开始采集前已经发生的重试。

而 planner cost 连服务器毫秒也不是。诊断至少对齐四个时间:

application span
  = pool/connect + server round trip + decode + application work

server statement duration
  = parse/plan(可能缓存)+ lock/wait + executor + output

EXPLAIN planning/execution
  = 本次受 instrument 影响的服务器测量

dashboard sample
  = 采样时间窗中的聚合/近似

TIMING off 仍保留 actual rows/loops 与总 execution time,可降低逐节点读时钟开销;当目标是 cardinality 而非每节点时间时更合适。反复执行要记录次数、warmup、参数和并发,报告分布而不是最佳一次。

7.2.3 对写语句使用 ANALYZE 的事务保护

EXPLAIN ANALYZE UPDATE/DELETE/INSERT/MERGE 会真实改数据、触发 trigger、约束、WAL 与锁。最小演练模式:

BEGIN;
SET LOCAL statement_timeout = '30s';
SET LOCAL lock_timeout = '5s';

EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, FORMAT JSON)
UPDATE ...;

ROLLBACK;

rollback 能恢复同一 PostgreSQL transaction 内的数据变化,却不能撤销 sequence 值、某些外部副作用、通知接收方已经看到的消息,或 volatile function 对外部系统的动作。trigger/function 在演练环境也要审查。DDL、VACUUM 与不能在 transaction block 运行的命令又有不同边界。

生产写计划优先从同分布 L1、脱敏 clone 或 read-only EXPLAIN 开始;确需在线 ANALYZE 时限定精确 key、窗口、owner、timeout、before/after fingerprint,并确认复制/WAL预算。不要用 ROLLBACK 三个字把 R2/R3 动作伪装成 R0。

本章所有 ANALYZE 都是专属 fixture 上的 SELECT。task 保存原始 JSON,再由 Python 读取语义字段;它不 grep 文本节点,也不固定动态 cost/time。若 JSON 无效或 actual 行数漂移,分析立即失败。


上一节:优化器如何选择路径 · 返回本章目录 · 下一节:统计信息与估算偏差 · 查看全书目录 · 查看索引中心

7.3 统计信息与估算偏差

planner 不逐行检查数据再选计划;那相当于先执行查询。它读取 relation size、单列统计和可选扩展统计,近似选择率。理解近似来源,才能判断该 ANALYZE、提高 target、建扩展统计,还是改条件表达。

7.3.1 直方图、高频值、空值率与相关性

pg_stats 是对 pg_statistic 的可读视图,常用字段:

字段 含义
null_frac NULL 比例
n_distinct 正值为估计 distinct 数;负值表示与行数比例
most_common_vals/freqs 高频值与频率
histogram_bounds 排除 MCV 后近似等频桶边界
correlation 列逻辑顺序与物理行序的相关程度

MCV 适合倾斜 equality;histogram 估 range;correlation 影响 ordered index scan 的 heap page 成本预期。它们是 ANALYZE sample 的摘要,不是约束或精确 count。statistics target 提高会扩大 sample/MCV/histogram 细节,同时增加 ANALYZE、planning 与 catalog 成本。

本章 tenant_id 中 1 占 90%,应成为 MCV;cold tenant 各 10 行。custom plan 看见具体参数时可用 MCV,估出 90000/10。generic plan不知道 $1,只能使用平均选择率,约估 100。

7.3.2 扩展统计解决跨列相关

单列统计分别知道:

region='east'       ≈ 25%
order_status='paid' ≈ 25%

若假设独立,AND 约为 6.25%,即 6250 行;fixture 实际把 east 完全对应 paid,所以是 25000。反之 east+cancelled 实际为 0,单列仍估约 6250。

扩展统计类型解决不同问题:

  • dependencies:列之间近似函数依赖,修正组合条件;
  • ndistinct:列组合 distinct 数,帮助 GROUP BY 等;
  • mcv:记录常见值组合,能表达常见或不可能组合;
  • expression statistics:为表达式本身收集统计。

实验执行:

CREATE STATISTICS shop_private.ch07_region_status_stats
  (dependencies, mcv)
ON region, order_status
FROM shop_private.ch07_plan_probe;

ALTER STATISTICS shop_private.ch07_region_status_stats
  SET STATISTICS 1000;
ANALYZE shop_private.ch07_plan_probe;

CREATE STATISTICS 只定义对象,ANALYZE 才填数据。修复后 present estimate 为 25000,impossible estimate 降到 planner 的非零下限 1。不能据此说扩展统计“证明 east 绝不会 cancelled”;约束负责拒绝,统计只服务估算,而且数据变化后会过期。

7.3.3 数据倾斜、陈旧统计与采样误差

估算偏差按顺序排查:

  1. query parameter/类型/cast 是否与生产一致;
  2. last_analyze、修改量与分布是否变化;
  3. MCV/histogram 是否能容纳关键倾斜;
  4. 多列是否相关却被独立估计;
  5. 条件是否包裹列、使用 expression/函数而无统计;
  6. join/partition parent 是否缺统计;
  7. sample randomness 是否让边界值波动;
  8. planner 是否因 generic plan 看不到参数。

只运行 ANALYZE 而不对比 estimate/actual,不能证明问题已修。提高全库 default_statistics_target 也通常太粗;先对问题 column/statistics object 局部提高,再衡量 planning/catalog 成本。

本章每次 setup 后普通 ANALYZE 的相关组合估算在约 6250 周围小幅变化,这是随机采样的预期;analyzer 断言修复前误差至少 2 倍、修复后不超过 1.5 倍,而不固定 6250。稳定测试检查关系,动态证据保存具体值。

统计正确也不保证计划最快:cost parameters、cache、并发、I/O 与 executor 能力仍参与。它只让 planner 对“会有多少行”拥有更好输入。


上一节:正确使用 EXPLAIN · 返回本章目录 · 下一节:分区裁剪的两种时机 · 查看全书目录 · 查看索引中心

7.4 分区裁剪的两种时机

分区裁剪依据 partition bounds 与可证明的条件移除无关 child,不依赖分区键上存在 index。裁剪减少要规划/执行的子计划,却不会自动让分区内访问高效,也不消除启动时取得的 relation lock。

7.4.1 规划时裁剪与常量条件

四个季度分区上:

WHERE occurred_on = date '2025-05-15'

planner 在规划时知道常量,证明只有 Q2 可能匹配;JSON 计划只出现 ch07_event_probe_2025q2。适合裁剪的条件通常直接用 partition key 与符合 bounds operator class 的等值/范围比较。

语义等价不代表 planner 能证明。例如:

WHERE date_trunc('month', occurred_on::timestamp)
      = timestamp '2025-05-01'

函数包住 partition key,当前实验计划出现四个 child;每个分区再 filter。正确性不变,裁剪合同丢失。修复通常把 API 月份转换成明确半开范围:

WHERE occurred_on >= date '2025-05-01'
  AND occurred_on <  date '2025-06-01'

不要为追求裁剪擅自改写时区/边界语义;先证明新 predicate 与业务时间合同等价。

7.4.2 执行时裁剪、参数化节点与通用计划

generic prepared plan 在 planning 时不知道 $1,仍可在 executor initialization 获得参数后裁剪:

SET plan_cache_mode = force_generic_plan;
PREPARE p(date) AS
SELECT event_id
FROM shop_private.ch07_event_probe
WHERE occurred_on = $1;

EXPLAIN (ANALYZE, FORMAT JSON)
EXECUTE p(date '2025-05-15');

本章结果只执行 Q2,并报告 Subplans Removed: 3。初始化期移除的 partition 不再显示为完整 child;真正 execution parameter(例如 nested loop inner 参数)改变时还可重复裁剪,此时要看各 child loops 与 (never executed)

“计划文本里有 Append”不等于所有分区都被扫描。要联合看:

  • plan 中保留的 child;
  • Subplans Removed
  • each child loops/actual rows/buffers;
  • planning time 与 partition 数;
  • 执行开始时仍可能取得的 locks。

7.4.3 分区父表统计需要显式 ANALYZE

autovacuum 会处理普通 leaf partition,却不处理不直接存 tuple 的 partitioned parent;child 变化也不会触发 parent 的 inheritance statistics 更新。查询父表若依赖整体统计,应在首次装载及分布显著变化后显式:

ANALYZE shop_private.ch07_event_probe;

默认会同时递归分析 partitions。大型 hierarchy 要评估采样、lock 与窗口;可用 ONLY 控制范围,但要理解自己放弃了什么统计。

实验先只 ANALYZE 四个 leaf,父表 pg_stats 行数为 0;显式分析父表后变为 4。数字 4 对应当前四列,不是通用 golden;稳定结论是 0→非零。监控 leaf last_autoanalyze 不能替代检查 parent statistics。

7.4.4 产出裁剪生效与失效的计划对照

运行:

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

./task.sh setup
./task.sh partition
cat "$PG36_EVIDENCE_DIR/plan-summary-partition.txt"

注意两个 action 若复用同一 evidence directory,后者会更新 manifest;正式 release 建议直接 all 保持单一 bundle。稳定摘要:

partition_counts=constant:1,wrapped:4,generic:1
partition_parent_stats=0->4

原始三个 JSON 才是审计证据。analyzer 递归收集实际 relation nodes,并要求 generic Subplans Removed>=3;不 grep 本地化文本。

这个实验不证明业务表应该分区。它只证明已有 partition design 下,条件形状、参数可见性与统计维护怎样影响 planner。是否分区仍要回到 retention、规模、约束和运维收益,遵守 PREF-PART-003


上一节:统计信息与估算偏差 · 返回本章目录 · 下一节:参数、缓存计划与计划漂移 · 查看全书目录 · 查看索引中心

7.5 参数、缓存计划与计划漂移

同一 query shape 可能面对完全不同参数选择率。每次用具体值规划可得到针对性路径,却支付 planning 成本;复用 generic plan 可省 planning,却看不到具体参数。缓存计划是成本取舍,不是“prepared statement 必然更快”。

7.5.1 自定义计划与通用计划

custom plan 把本次参数代入后规划,可使用 MCV、partition bounds 等具体信息;generic plan 保留 $1,适合参数分布相近或 planning 昂贵的重复语句。

plan_cache_mode=auto 下,带参数的 prepared statement 前五次使用 custom plan;之后服务器生成 generic plan,并把它的估算成本与前五次 custom plan 的平均估算成本比较。只有 generic plan 并未贵到值得反复重新规划时,后续执行才会复用它。这个启发式使用规划器成本,不等于实际执行时延;因此还要用代表性参数分别测量 planning time、execution time 与结果行数。force_custom_plan / force_generic_plan 只适合诊断对照,不应在缺少 workload 证据时全局强制。

prepared statement 是 session 对象。DDL、统计变化和影响解析/规划的设置可触发 re-plan;search_path 变化也会重新解析。transaction pooling 下 client 与 server session 生命周期不同,driver/PgBouncer 的 prepared statement 支持与配置必须单独验证,不能从裸 PostgreSQL session 外推。

7.5.2 预备语句、数据倾斜与参数敏感

fixture:

tenant 1    = 90000 rows
tenant 2..1001 = 10 rows each
index(tenant_id)

强制 custom:

hot:  estimate=90000 actual=90000 → 实测 Seq Scan
cold: estimate=10    actual=10    → 实测 Index Scan

强制 generic:

estimate=100 for both
hot actual=90000
cold actual=10

具体 node type 受表宽、cache、cost constants 与版本影响,不作为硬断言;真正的参数敏感证据是 custom estimate 能区分 90000/10,而 generic 必须使用同一估计。hot generic 路径即使本次仍足够快,也已经暴露风险:平均延迟可能掩盖少数大 tenant 的尾延迟。

处理方案按证据选择:

  • 保持 auto,并观察 generic/custom 计数与参数分布;
  • 对真正参数敏感的 workload 分 query shape 或显式选择策略;
  • 改 schema/index/partition,使不同选择率下都可接受;
  • 对 hot tenant 单独路由/批处理;
  • 局部 force custom,并量化 planning 开销。

不要把 tenant literal 拼进 SQL 来“骗 custom plan”;这会扩大 query shape、失去安全参数绑定并污染统计。

7.5.3 计划变化是症状,不自动等于回归

计划会因以下因素合理变化:

数据量/分布与 ANALYZE sample
参数值与 custom/generic 决策
index/constraint/partition/schema
planner GUC/cost/JIT/parallelism
PostgreSQL/extension 版本
relation page/visibility/correlation

回归判断顺序:

  1. result contract 是否仍正确;
  2. 同 workload 的 latency/throughput/resource/SLO 是否变差;
  3. wait 还是 executor 在耗时;
  4. estimate/actual 从哪里开始偏;
  5. 数据、statistics、settings 与参数是否可比;
  6. 新 plan 是否只是节点不同但成本更好;
  7. 改动是否增加写放大、WAL、memory 或尾延迟。

对计划做 canonical fingerprint 可以帮助发现变化,但不能把完整 JSON bytes 当稳定 API;版本会新增字段,cost/time 天生动态。保留原始计划,同时抽取 query identity、node responsibilities、cardinality error、buffers/spill/WAL 与环境 facts。

应急时可以用 planner GUC 证明替代路径,长期方案仍要回到统计、SQL、index、参数策略或版本 bug。第 8 章会把这里的计划证据放进慢查询闭环,第 9 章再审查 index 收益和写成本。


上一节:分区裁剪的两种时机 · 返回本章目录 · 下一节:建立计划证据基线 · 查看全书目录 · 查看索引中心

7.6 建立计划证据基线

手工 EXPLAIN 回答“这个参数现在怎样”,生产基线还要回答“哪些 query shape 消耗最多、何时变化、影响谁”。pg_stat_statements、日志/auto_explain 与 Pigsty 时间序列分别提供聚合、样本与上下文。

7.6.1 pg_stat_statements 的归一化视角

pg_stat_statements 按 database、user、toplevel 与 normalized query identity 聚合 calls、rows、planning/execution time、buffers、WAL、JIT/parallel 等累计量。literal 通常归一为 $1,所以适合找高总耗时、高均值/方差、高 I/O 或高调用频率的 query family。

使用时保留:

dbid + userid + queryid + toplevel
stats_since / minmax_stats_since
calls / rows
total/min/max/mean/stddev exec time
shared/local/temp blocks + WAL
representative query(权限受控)

queryid 是 hash,不保证无碰撞,也不保证跨 major version、不同架构或重建对象后永久稳定;相同文本还可能因 search_path 解析到不同对象而分开。把它作为某实例/版本时间窗内的关联键,不作全球业务 ID。

track_planning 默认关闭且可能带来并发更新开销;planscalls 也不必相等,因为 cached plan、规划成功但执行失败等路径不同。reset 会破坏累计基线,生产只由受控 owner 在记录旧窗口后执行,不能为了实验清空全局统计。

7.6.2 auto_explain 的阈值、采样与日志成本

auto_explain 能在 query 超过 log_min_duration 时写计划,补上“事后再 EXPLAIN 已无法复现”的样本。但默认不做任何事;至少要设置阈值。上线前审查:

  • threshold 与 sample_rate 是否覆盖目标尾部且可控日志量;
  • log_analyze 是否需要 actual rows;
  • log_timing 的逐节点时钟成本;
  • buffers/WAL/triggers/nested statements 是否必要;
  • log_parameter_max_length 是否会泄露 PII/secret;
  • log format、保留、访问与脱敏;
  • preload/load 权限和配置变更方式。

尤其 log_analyze=on 时,per-node instrumentation 对所有被考虑的 statement 生效,即使最终没达到日志阈值;log_timing=off 可降低成本但失去节点时间。不要直接在繁忙生产设 threshold 0、sample 1、完整参数。

先在 L1 用代表 workload 测量开销和日志体积,再小比例/较高阈值 canary,最后从实际 signal 调整。auto_explain 样本不是全量分布,仍需 pg_stat_statements/metrics 提供 denominator。

7.6.3 从 Pigsty 时间窗保存 SQL、参数、统计、计划与环境上下文

Pigsty 的 PGSQL Query/Database/Activity、PGCAT Query、Session/Xacts、Persist 等入口把 query 统计与 cluster/instance/database 资源放在同一时间轴。排查时先固定:

UTC start/end
cluster / instance / primary-replica role
database / user / application
queryid + representative query
calls/latency/rows/buffers/WAL
CPU/I/O/load/connection/wait/lock/replica lag
PostgreSQL/Pigsty/config/schema/statistics version

然后选具体参数在等价 L1 采集 machine-readable EXPLAIN。dashboard screenshot 只能证明画面,最好同时导出 query/metric value、过滤条件和 timezone。短查询可能落在 scrape interval 之间;瞬时 blocker 要回到 catalog/log。

参数可能含个人数据,query text 也可能暴露 literal。证据包使用最小权限、脱敏和访问控制,不能把 pg_read_all_stats 给业务角色,也不能把 Grafana datasource credential 导出。

一次完整计划证据应能回答:

这是哪个 workload 的哪个时间窗?
聚合上影响多大?
选择了哪个代表参数,为什么?
当时统计、schema、settings 和数据分布是什么?
estimate/actual、buffers/wait 的根因假设是什么?
变更前后结果/SLO/写成本怎样?
怎样回退,何时复查?

这套证据将在第 8 章变成慢查询诊断模板;本章暂不把 dashboard panel 名或 metric label 当跨版本稳定接口。


上一节:参数、缓存计划与计划漂移 · 返回本章目录 · 下一节:实战:解释订单查询的计划变化 · 查看全书目录 · 查看索引中心

7.7 实战:解释订单查询的计划变化

综合实验不追求把某个 node 调成最快,而是建立一条可审计推理链:确定性数据制造已知分布,保存修复前 JSON 计划,只改变一个统计/条件因素,再保存修复后计划并由程序检查关系。

7.7.1 用固定数据种子制造估算偏差

确认 service 指向可写 pg36_shop L1:

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

./task.sh all

all 的顺序:

ch04-v1 verify-before
  → marker collision guard + deterministic setup
  → correlated plans before
  → CREATE STATISTICS + ANALYZE
  → correlated plans after
  → custom/generic parameter plans
  → constant/wrapped/generic partition plans
  → parent ANALYZE
  → Python semantic assertions
  → fixture verify + ch04-v1 verify-after

fixture 分布固定:

rows=100000
east+paid=25000
east+cancelled=0
tenant 1=90000
tenant 1001=10

region/status 分别四等分但完全对应。普通单列统计近似把两个 25% predicate 相乘,所以两种组合都估约 6250。具体值随 ANALYZE sample 波动,不能 hard-code;raw stats-before-*.json 保存本次 estimate、actual、buffers、settings 和时间。

参数实验选择 128-byte payload,确保 hot tenant 的 heap fetch 与 cold tenant 有明显路径取舍。analyzer 不强制节点必须是 Seq/Index,只强制 custom estimate 接近实际、generic 对两个参数使用同一 estimate,且 hot generic 偏差至少 100 倍。

7.7.2 修复统计与条件表达后重新比较

扩展统计只改变 planner knowledge,不改变数据、index 或 SQL:

present:    ~6300 → 25000 / actual 25000
impossible: ~6300 → 1     / actual 0

这证明问题属于跨列相关,不需要先建组合 index;若查询性能仍不达标,再用第 9 章方法评估 index。扩展统计改善 estimate 也可能不改变 node,因为本查询没有 region/status index 且需要 seq scan;“plan shape 没变”不代表修复无效。

分区实验只改变 predicate:

occurred_on = constant          → one child at plan time
date_trunc(... occurred_on ...) → four children, child filters
generic $1 equality             → one executed child, 3 removed

修复 wrapped 条件应在应用边界先算月初/月末,再直接写 partition-key 半开范围。创建 expression index 可能帮助分区内 filter,却不保证 partition bounds 能由该表达式裁剪;index 与 pruning 是两种机制。

父表统计单独验证 0→4。若只看 child autoanalyze,会遗漏 parent。生产维护频率由分布变化而非固定日历决定,并观察 pg_stat_progress_analyze、锁与资源。

查看结果:

cat "$PG36_EVIDENCE_DIR/plan-summary-all.txt"
python3 -m json.tool "$PG36_EVIDENCE_DIR/plan-summary-all.json"

一次实测:

status=ok
correlated_estimate=6352->25000/actual=25000
impossible_estimate=6275->1/actual=0
custom_hot=Seq Scan/estimate=90000/actual=90000
custom_cold=Index Scan/estimate=10/actual=10
generic_estimate=100/hot_actual=90000/cold_actual=10
partition_counts=constant:1,wrapped:4,generic:1
partition_parent_stats=0->4

时间与 buffers 用来比较同一次受控实验,不进入稳定 summary。要比较性能,应重复运行并记录 cache/并发条件;本章只验 cardinality 与裁剪机制。

安全复位

实验对象保留供观察。确认不再需要后:

export PG36_RESET_TOKEN=RESET_CH07_PLAN_LAB
export PG36_RESET_TARGET=pg36_shop/shop_private/ch07
export PG36_EVIDENCE_DIR="$PWD/evidence/ch07/reset-$(date -u +%Y%m%dT%H%M%SZ)"
./task.sh reset

reset 先复核 database、primary、ch04-v1、两个 relation marker 与两个 token,再只删除 ch07 relation。错误 token 必须 exit 3,且对象仍存在;成功后 remaining_ch07_relations=0,ch04-v1 relation checksum 仍为:

f8a7bfae59c6d16cd323abecfefe1014

7.7.3 把一条有证据的计划规则追加到规约

v0.1 的 PREF-PLAN-005 已声明:

看到 Seq Scan、Nested Loop 或高 cost 不直接判错;
先定位 estimate/actual、loops、buffers、wait 与 workload,
再用可回退对照验证 statistics、SQL 或 index 变更。

baseline-v0.2-proposal.json 不改写不可变 v0.1,而是基于其 canonical checksum 提案:

base=0.1.0
candidate=0.2.0
rule=PREF-PLAN-005
statement_change=none
change=evidence-and-runtime-check

新增 runtime check:

保存机器可读 plan,比较 estimate/actual、参数计划与裁剪范围;
不得以节点名或 cost 单独判定回归。

Python analyzer 会重新计算 v0.1 canonical checksum,拒绝 proposal 绑定到被悄悄修改的 base;也验证三项 evidence artifact 存在。当前 proposal 仍是 candidate,因为 promotion 还要求:

  1. PostgreSQL 14–18 兼容矩阵通过;
  2. 至少一个不同硬件或规模复测;
  3. 合并为独立、不可变的 v0.2 release artifact。

这体现规则证据的正确节奏:单次 PostgreSQL 18.6 实验足以形成候选 runtime check,不足以宣称跨版本普遍阈值。后续复测即使 node type 不同,只要 cardinality 与 partition pruning 的因果关系仍成立,规则就得到进一步确认;若机制变化,就修正 scope/compatibility,而不是让测试强行 grep 旧节点。

完成本章后,读者应提交的不是一张漂亮计划图,而是:

manifest + source hashes
raw plans before/after
machine summary
fixture distribution
PostgreSQL/session facts
business model verify-before/after
rule proposal + known promotion gap

下一章会从 Pigsty 的真实慢查询时间窗选择 query family,再沿本章方法进入单条计划。


上一节:建立计划证据基线 · 返回本章目录 · 下一章:抽丝剥茧:慢 SQL 诊断方法论 · 查看全书目录 · 查看索引中心

8 抽丝剥茧:慢 SQL 诊断方法论

“数据库慢”不是根因,甚至还不是一个足够好的问题。它可能表示某个请求在连接池排队、某条 SQL 被事务锁住、一个参数命中了错误的通用计划、结果集已经算完却写不进慢客户端,也可能只是用户把一次偶发抖动概括成了整体退化。

本章把第 5 章的事务与锁、第 7 章的计划与统计放回真实请求链路,建立一条可复核的诊断闭环:

定义症状与时间窗
  → 界定服务、实例、数据库、查询族与参数
  → 观察 activity、wait 与 blocking edge
  → 关联查询统计、日志、计划、主机资源和变更事件
  → 按证据排列可证伪假设
  → 只改变一个解释变量
  → 比较效果、正确性与副作用
  → 修复、回退、复位并沉淀证据

顺序很重要。先跑 EXPLAIN 会漏掉锁与客户端背压;先建索引会把相关性当因果;只看面板截图则容易丢失指标定义、时间范围与原始身份。正确做法是从用户可见 SLI 向内收敛,再从平台视图回到 PostgreSQL 原生证据。

本章目标

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

  • 用时间窗、样本数、p50/p95/p99、吞吐、并发和错误率定义“慢”;
  • 区分单次慢、参数簇慢、持续退化、实例退化与全链路退化;
  • 把端到端时间拆成排队、应用、数据库执行、传输和客户端消费;
  • 正确联合解释 pg_stat_activity.statewait_event_typewait_event
  • pg_blocking_pids() 证明阻塞边,不把等待者误当根因;
  • pg_stat_statements 按总预算、调用数、均值、最大值和资源量排序;
  • 知道累计查询统计没有原生延迟分位数,且不能跨边界滥用 queryid
  • 建立带会话、查询、时间和变更身份的日志最小基线;
  • 关联 SQL 指标、主机资源、锁、日志、部署和配置变更;
  • 把“计划、锁、I/O、CPU、内存、客户端、网络、连接池”写成可证伪假设;
  • 设计单变量、可回退、记录冷热缓存与参数分布的实验;
  • 在 Pigsty 中定位范围,并用 SQL、日志和机器可读计划复核;
  • 独立区分估算/计划、锁等待与客户端慢消费三种相似的“请求不返回”;
  • 产出证据包、假设树、修复对照、负对照与复位结果。

实验边界

实验基线为 PostgreSQL 18.6、Pigsty v4.5.0、Ubuntu 24.04 L1;SQL 与诊断原则保持 PostgreSQL 14–18 可用。实验复用:

  • ch07 的 90000/10 tenant skew,制造 generic/custom estimate 对照;
  • ch05 的 rollback-only 行锁编排,制造一条可证明的 blocker edge;
  • generate_series 派生结果与受控慢 reader,制造 Client/ClientWrite

本章不创建持久对象。锁实验最终回滚,客户端实验只生成结果流,estimate 实验只读 ch07 fixture。若 ch07 fixture 缺失,task.sh setup/all 会通过 marker guard 受控重建专属对象,属于 L1/R1;不会自动清理业务对象、全局重置 pg_stat_statements、修改日志/连接池参数或取消非本章会话。

每个并发 worker 都有唯一 application_name。取消动作必须同时匹配 PID、backend_start、database 与 application identity;最终验收要求 active_lab_workers=0,并重新计算 ch04-v1 业务 checksum。

下载资产:

本章目录

8.1 先定义“慢”

8.2 从会话到语句定位范围

8.3 关联日志、指标与计划

8.4 建立而不是猜测假设

8.5 设计受控实验

8.6 从可观测面板回到原生证据

8.7 实战:三种“慢”只修真正瓶颈

实测摘要

一次 PostgreSQL 18.6 全量验收得到:

estimate:
  generic estimate=100 / actual=90000 / error=900x
  custom  estimate=90000 / actual=90000 / error=1x
lock:
  state=active / wait=Lock/transactionid / blockers=1
client:
  state=active / wait=Client/ClientWrite / blockers=0
mystery:
  diagnosis=client-slow-consumer / reveal matched=true
  wrong guess rejected=true / answer mode=0600
final:
  same seed reproducible=true / remaining workers=0
  relation checksum=f8a7bfae59c6d16cd323abecfefe1014

节点类型、cost、buffers、PID 和时间不是 golden。稳定断言是证据关系:generic estimate 严重偏离且 custom 对照改善;Lock wait 必须存在 blocker edge;ClientWrite 必须没有数据库 blocker;错误盲测答案必须失败;所有会话与业务状态最终恢复。

章节验收

  1. 事件描述包含 UTC 时间窗、样本数、分位数、吞吐、并发、错误率和影响范围;
  2. 不平均不同窗口的 p99,不用单次最大值冒充分位数;
  3. 能解释端到端慢为什么可能完全发生在 PostgreSQL 之外;
  4. 联合读取 state 与 wait,不把普通 idle/ClientRead 当慢查询;
  5. 取消会话前验证 PID + backend_start + database + application;
  6. 只用 pg_blocking_pids()/锁证据确认 blocker,不按最长 SQL 猜;
  7. pg_stat_statements 排序至少覆盖总预算、均值、调用数和资源;
  8. 知道累计统计的 reset/采样边界,不为一次调查全局 reset;
  9. 日志含时间、PID/session、用户、数据库、application 与必要 query identity;
  10. 参数日志有脱敏、长度、成本与保留策略;
  11. 假设写明预测、反证、最小实验与回退条件;
  12. 对比实验控制 cache、参数、数据量、并发与重复次数;
  13. 面板发现必须能落回 SQL、日志、计划或 exporter 指标语义;
  14. 三类 case 均能在不读取 answer artifact 时正确分类;
  15. 错误 diagnosis 的 reveal 返回失败;
  16. task.sh all 通过,answer mode 为 0600,worker 为 0,业务 checksum 不变。

下一章 ch09《巧夺天工:索引设计与效果验证》 将在诊断已证明访问路径是主要瓶颈后,再讨论该不该建、建什么、怎样验证和怎样安全发布索引。

参考资料


上一章:追本溯源:执行计划与统计信息 · 返回上卷导读 · 下一章:巧夺天工:索引设计与效果验证 · 查看全书目录 · 查看索引中心

8.1 先定义“慢”

一次有效诊断从可检验的事件定义开始。若工单只有“数据库今天很慢”,不同的人会任意选择时间、实例和指标,最后得到彼此矛盾却都无法反驳的故事。

最小事件边界可以写成:

2026-07-29 12:04:00Z–12:09:00Z,
checkout/create 路由 18432 次请求,
p99 从同星期基线 180 ms 升至 2.4 s,
吞吐从 70 req/s 降至 61 req/s,错误率从 0.1% 升至 1.8%,
影响租户 A/B;只发生在写流量,读流量与其他区域正常。

这段话尚未宣称 PostgreSQL 有问题,却已经给出了可在 trace、应用、连接池、代理和数据库各层复核的同一范围。

8.1.1 延迟分位数、吞吐、并发与错误率

延迟不是一个数,而是一组事件的分布。p99 的含义是:在给定样本集合中,约 99% 的观测不超过该值;它必须和以下限定一起出现:

  • 测量对象:HTTP 路由、业务动作、数据库 query family,还是单个 backend;
  • 起止时间与时区;
  • 成功请求、全部请求还是某类错误;
  • 样本数与采样方式;
  • histogram bucket、客户端 timer 或 tracing span 的来源;
  • 是否包含重试、排队、结果传输和超时。

没有样本数的 p99 很危险。一个窗口只有 40 个请求时,所谓 p99 几乎就是最大样本;一个窗口有 100 万请求时,尾部才有足够事件可供分层。也不要把十个一分钟 p99 求平均来冒充十分钟 p99:分位数一般不可加、不可平均。只有保留可合并的原始分布或边界一致的 histogram bucket,才可能重新计算整体分位数。

分位数还必须和吞吐、并发、错误率联合阅读:

现象 可能解释 还缺什么证据
p99 上升,吞吐稳定 少数参数/租户退化、锁尖峰、下游抖动 慢样本标签、参数、wait
p50/p99 同时上升,吞吐下降 普遍排队或资源饱和 队列长度、CPU/I/O、连接占用
延迟下降,错误率上升 请求更早失败或超时,不是性能改善 SQLSTATE、HTTP 状态、取消来源
QPS 上升,均值稳定,总数据库时间上升 单次没变但预算消耗变大 calls、total_exec_time、容量余量
并发上升,吞吐不再增长 到达瓶颈后开始排队 active sessions、pool wait、服务率

在近似稳定系统中,Little 定律给出:

L=λW L = \lambda W

其中 $L$ 是系统内平均并发,$\lambda$ 是吞吐,$W$ 是平均停留时间。它不是用来从三个噪声瞬时值“算根因”的,而是做一致性检查:若吞吐近似不变、停留时间增大,系统内请求数理应上升;若监控没有上升,可能是测量边界不同、采样漏掉队列,或请求已在上游被拒绝。

一个适合告警和诊断的 SLI 组合通常至少包括:

event count
success/error/timeout count
latency histogram or quantiles
throughput
in-flight/queue depth
measurement scope and labels

平均值仍有价值,它适合预算、容量和与累计 query time 对齐;但它不能替代尾延迟。反过来,p99 能说明尾部体验,却不能告诉你该 query family 消耗了多少总 CPU/执行时间。

8.1.2 单次慢、持续慢与整体退化

“慢”至少要从时间范围和影响范围两个轴分类:

时间形态 局部范围 全局范围
单次/尖峰 特定参数、一次锁等待、一次冷读 checkpoint、主机抖动、网络事件
周期性 报表、定时任务、批量发布 定时备份、周期流量、共享资源争用
持续性 query plan/数据分布变化 容量不足、配置/版本变更、长期膨胀

单次慢样本适合做法证:保存 trace、PID、参数、日志与当时 wait,但不能据此证明系统长期退化。持续慢需要同口径的对照窗口,例如:

incident:  12:04Z–12:09Z
baseline:  前七天同星期、同五分钟、相近 QPS
control:   同集群未受影响的路由/租户/只读实例

“昨天平均 10 ms,今天一次 2 s”混合了统计粒度;“所有数据库都慢”也常把一个共享连接池、一个可用区或一个应用版本误写成数据库全局。诊断前依次收窄:

  1. 哪个用户动作或后台任务;
  2. 哪个路由、租户、参数簇与返回规模;
  3. 哪个应用版本、实例、可用区和服务入口;
  4. 哪个 PostgreSQL cluster、instance、database、user、application;
  5. 哪个查询族与事务;
  6. 是执行慢、等待慢、排队慢,还是消费结果慢。

持续退化也不等于“从某次发布起就一定由发布造成”。发布是高优先级假设,因为时间顺序与作用范围吻合;它仍需机制证据,例如 SQL shape 改变、calls/rows 改变、plan estimate 偏离、连接池并发改变,或错误重试放大负载。

建议把事件分为三个状态:

  • 未确认:用户报告存在,但监控口径或范围尚未复现;
  • 已确认:同一时间窗的 SLI 证明退化,根因未知;
  • 已归因:存在机制、对照与反证,修复后同口径指标恢复。

这样能避免在“已确认慢”和“已证明 PostgreSQL 根因”之间偷换概念。

8.1.3 应用时间、排队时间与数据库时间

端到端时间可以用核账式分解:

用户等待
≈ 网关/应用排队
 + 业务代码与外部调用
 + 等待连接池 slot
 + 建连/认证/路由
 + PostgreSQL 内规划、执行与数据库等待
 + 结果传输与客户端消费
 + 序列化/响应发送

这不是说各组件一定能无缝相加。计时器可能使用不同 clock,span 可能重叠,重试会创建多次数据库调用,连接池也可能没有 trace。分解的价值是找出“缺失时间”:例如应用记录 2.4 s,而数据库完成日志与 server-side EXPLAIN ANALYZE 都约 80 ms,那么剩余 2.32 s 不能继续用索引解释。

PostgreSQL planner 的 cost 不包含把值转换为文本以及把结果传给客户端的时间;EXPLAIN ANALYZE 的服务器执行时间也不等于用户看到的完整取数时间。相反,连接池排队发生在 backend 分配之前,pg_stat_activity 根本看不到这批尚未进入 PostgreSQL 的请求。

要让时间可以关联,最低限度应统一:

  • 日志与展示使用 UTC,并保留原始 timezone;
  • duration 使用 monotonic clock,wall clock 只做跨系统关联;
  • trace/request ID 贯穿应用,database span 记录 database/service;
  • PostgreSQL application_name 能映射应用/worker;
  • 日志保存 PID 与 session identity,而不是只保存 SQL 文本;
  • 明确数据库 span 是“从发起驱动调用到返回”,还是 server duration。

一个常见对照:

request span                       2400 ms
  pool acquire                     1700 ms
  database driver call              620 ms
    server log duration              85 ms
    ClientWrite observed            500 ms
  application work                  80 ms

这里 PostgreSQL 只用了约 85 ms 计算,但连接池排队与客户端接收共同制造了“SQL 调用 620 ms”。给查询加索引几乎不会改变 1700 ms 排队,也不能修复 500 ms 的慢消费;正确方向是分别调查 pool saturation 与返回规模/客户端读速。

本节的产出不是一张性能图,而是一份写入诊断记录模板的事件边界:

谁慢、哪里慢、何时慢、慢多少、多少样本、
当时流量/并发/错误怎样、与哪个基线相比、
端到端时间已分解多少、尚有多少无法解释。

有了这条边界,下一节才开始从会话和查询统计定位 PostgreSQL 内部范围。


返回本章目录 · 下一节:从会话到语句定位范围 · 查看全书目录 · 查看索引中心

8.2 从会话到语句定位范围

事件边界确定后,先回答“backend 此刻在做什么”,再回答“过去一段时间哪个查询族消耗最大”。前者来自动态 activity snapshot,后者来自累计查询统计;把两者混为一谈,会拿一个瞬时会话解释一小时预算,或拿一小时均值解释当前阻塞。

8.2.1 活跃、等待、阻塞与空闲事务

下面的只读查询保留会话身份、事务年龄、当前状态、等待与直接 blocker:

SELECT
    clock_timestamp() AT TIME ZONE 'UTC' AS captured_at_utc,
    pid,
    backend_start,
    datname,
    usename,
    application_name,
    client_addr,
    state,
    CASE WHEN state = 'active'
         THEN clock_timestamp() - query_start
    END AS active_for,
    CASE WHEN xact_start IS NOT NULL
         THEN clock_timestamp() - xact_start
    END AS xact_age,
    wait_event_type,
    wait_event,
    pg_blocking_pids(pid) AS blocking_pids,
    query_id,
    left(regexp_replace(query, '[[:space:]]+', ' ', 'g'), 240)
        AS query_excerpt
FROM pg_stat_activity
WHERE backend_type = 'client backend'
  AND datname = current_database()
ORDER BY
    (state = 'active') DESC,
    query_start NULLS LAST;

读取时必须联合解释 state 与 wait:

state / wait 证据含义 不能直接推出
active / NULL 正在执行,采样瞬间未报告等待 一定消耗 CPU;一定健康
active / Lock/* 查询执行中,正在等 heavyweight lock 当前等待者就是根因
active / IO/* 正在某个 I/O wait point 磁盘一定坏;全部时间都在 I/O
active / Client/ClientWrite server 正等待把数据写给客户端 查询计算本身慢
idle / Client/ClientRead backend 等客户端发下一条命令 一条“慢 SQL”正在跑
idle in transaction 事务打开但当前没有语句执行 没有影响;必须立刻终止

PostgreSQL 明确把 state 与 wait 定义为相互独立的维度。采样也可能遇到短暂不一致,所以重要结论应跨数个短间隔采样,而不是冻结一行就下结论。

idle in transaction 尤其需要看 xact_startbackend_xmin、锁和业务上下文。它可能保留锁、阻碍 vacuum 清理旧版本、延长事务边界,却不是“运行很久的当前 query”;query 字段此时是上一条语句。治理措施应优先修复应用事务边界,并使用经过评估的 idle_in_transaction_session_timeout,而非定时粗暴终止所有 idle session。

阻塞关系使用:

SELECT
    waiting.pid AS waiting_pid,
    waiting.backend_start AS waiting_backend_start,
    blocker_pid,
    blocker.application_name AS blocker_application,
    blocker.state AS blocker_state,
    blocker.xact_start AS blocker_xact_start,
    blocker.wait_event_type AS blocker_wait_type,
    blocker.wait_event AS blocker_wait_event
FROM pg_stat_activity AS waiting
CROSS JOIN LATERAL unnest(pg_blocking_pids(waiting.pid))
    AS edge(blocker_pid)
JOIN pg_stat_activity AS blocker
  ON blocker.pid = edge.blocker_pid
WHERE waiting.datname = current_database();

等待最久的 PID 未必是 root blocker;它可能也是链中受害者。先建立边,再沿边找到不再被别人阻塞的上游会话。第 5 章已给出锁模式与事务语义,本章强调诊断身份。

若必须缓解,先保存证据,再精确重查:

SELECT pg_cancel_backend(pid)
FROM pg_stat_activity
WHERE pid = :captured_pid
  AND backend_start = :'captured_backend_start'::timestamptz
  AND datname = :'captured_database'
  AND application_name = :'captured_application';

PID 会复用,单凭截图里的数字取消有伤及无关会话的风险。pg_cancel_backend 取消当前 query,pg_terminate_backend 结束 session;后者影响事务和客户端重连,不能当默认按钮。生产动作还要经过本地权限、SOP 和风险分级。

普通用户只能完整看到自己的会话;调查角色通常需要内置角色 pg_read_all_stats,但这也会暴露 SQL 与活动信息。权限应授予受控诊断角色,不应为了面板方便把业务用户升为 superuser。

最后注意视图一致性:累计统计可能延迟刷新,并在事务内缓存;activity 信息也会在同一事务首次读取后形成一致快照。交互调查若持续开着事务重复查询,先结束事务或按需调用 pg_stat_clear_snapshot(),否则可能把旧快照当实时状态。

8.2.2 按调用、总时长、均值和尾延迟排序

pg_stat_statements 把结构相同、常量不同的语句归一化,适合回答“哪些查询族消耗了累计预算”。先确认扩展已加载、目标数据库有 view,并记录 reset 起点:

SELECT stats_reset
FROM pg_stat_statements_info;

不要为一次调查执行全局 pg_stat_statements_reset():它会破坏其他人正在使用的基线。更好的做法是保存两个时点的快照并计算 counter delta,或让监控系统持续抓取 counter。

一次基础排序:

SELECT
    userid,
    dbid,
    queryid,
    calls,
    total_exec_time,
    total_exec_time / NULLIF(calls, 0) AS mean_from_total_ms,
    mean_exec_time,
    max_exec_time,
    stddev_exec_time,
    rows,
    rows::numeric / NULLIF(calls, 0) AS rows_per_call,
    shared_blks_hit,
    shared_blks_read,
    temp_blks_written,
    wal_bytes,
    left(query, 240) AS query
FROM pg_stat_statements
WHERE calls > 0
ORDER BY total_exec_time DESC
LIMIT 30;

至少从四种视角排序:

  • total_exec_time:谁吃掉最多执行时间预算,适合容量与总体收益;
  • mean_exec_time / max_exec_time / stddev_exec_time:谁单次慢或波动大;
  • calls:谁极高频,单次少量改善也可能有大收益;
  • blocks、temp、WAL、rows:谁制造 I/O、spill、写放大或大结果。

一个总时间第一的查询可能只是每次 2 ms、调用数巨大;一个均值第一的查询可能每天只跑一次;一个调用数第一的查询可能返回 0 行且预算很小。索引、缓存、批处理、限流和 SQL 改写针对的是不同问题,不能只保留一个“Top SQL”榜。

pg_stat_statements 记录 min/max/mean/stddev,但不保存每次执行的完整分布,也没有原生 p95/p99 列。因此:

  • max_exec_time 不是 p99;
  • 不能从 mean/stddev 假定任意分布再可靠推算 p99;
  • 尾延迟要来自请求 histogram、trace、采样日志或保存单次事件的系统;
  • 累计 max 可能来自很久以前,必须结合 stats_reset/快照窗口。

执行统计只覆盖 PostgreSQL server 侧的一部分时间。连接池等待不在其中;结果传输造成的 server wait 与驱动计时也未必与 total_exec_time 完全同口径。把查询榜与应用 SLI 对齐是下一步,不是直接宣布榜首为根因。

8.2.3 查询指纹、参数与时间窗口

查询身份不是只有一段 SQL 文本。建议至少保存:

cluster/instance/database
userid/dbid/toplevel/queryid
normalized query text
application/release/route
representative parameter class
UTC window and stats reset boundary

queryid 来自 parse analysis 后的结构 hash。它比字符串更适合在同一环境中关联,但保证有限:

  • 同一文本可能因 search_path 或对象含义不同而分成多个 ID;
  • 常量通常被归一化,hot tenant 与 cold tenant 可能落在同一 query family;
  • drop/recreate 对象、catalog OID 与平台架构会影响身份;
  • 不应假设跨 PostgreSQL major version 稳定;
  • 物理复制节点通常可对应,逻辑复制环境不能据此保证对应;
  • 极低概率仍可能 hash collision。

因此长期证据键通常是 (cluster identity, major version, dbid, userid, toplevel, queryid) 加归一化文本 hash,而不是一个裸 queryid。升级、重建或迁移后应建立新的 epoch。

归一化是优点也是盲区。第 7 章的 tenant 实验中,同一语句:

... WHERE tenant_id = $1

参数 1 返回 90000 行,参数 1001 只返回 10 行。累计均值可以同时掩盖两端。要恢复参数语义,使用经过脱敏的 trace tag、业务参数分桶、受控日志采样或可重放 fixture;不要把 token、密码、个人数据和任意 payload 倾倒进日志。

时间窗口也必须对齐三种数据:

  1. pg_stat_activity 是采样瞬间;
  2. pg_stat_statements 是自 reset/entry 起的累计量;
  3. Prometheus/日志/trace 是各自 scrape、采样或保留窗口。

例如事故发生五分钟,直接查询累计三个月的 mean 会稀释变化。正确做法是从监控 counter 计算事故窗口 delta/rate,或在事故前后保存两份 snapshot:

calls_delta
total_exec_time_delta
rows_delta
blocks/temp/WAL delta
mean_in_window = total_exec_time_delta / calls_delta

counter reset、entry eviction、实例重启和 failover 都会造成不连续,计算前应检查 reset/instance identity,不能把负 delta 当真实负负载。

本节最终应得到一个优先调查列表,而不是一个榜首判决:

query family + parameter class + affected window
current state/wait/blocker evidence
window calls/total/mean/resource deltas
identity and reset boundaries
two or three competing explanations

下一节把这份列表与日志、主机指标、计划和变更事件放到同一时间轴。


上一节:先定义“慢” · 返回本章目录 · 下一节:关联日志、指标与计划 · 查看全书目录 · 查看索引中心

8.3 关联日志、指标与计划

activity 告诉你“此刻”,查询累计量告诉你“这段 epoch”,日志保存离散事件,指标保存时间序列,计划解释 executor 怎样处理数据。四者只有共享时间、实例、会话和查询身份,才能形成证据链。

8.3.1 日志最小基线与慢语句记录

诊断日志的第一个目标不是“把所有 SQL 打出来”,而是让一条事件可定位:

timestamp + timezone
cluster/instance
PID/session identity
user/database/application/client
severity/SQLSTATE
query or query identity
duration
transaction/session context

PostgreSQL 的 log_line_prefix 可以提供这些身份。一个需结合环境评估的示意:

log_line_prefix = '%m [%p] %c %q%u@%d/%a '

其中 %m 是带毫秒时间,%p 是 PID,%c 由 backend start time 与 PID 组成近似唯一的 session ID,%u/%d/%a 分别是用户、数据库和 application。若启用 compute_query_id,还可评估 %Q;但官方明确指出 log_statement 产生日志时 query ID 可能尚未计算,因此不能把某一类日志里的 %Q=0 当作真实 query identity。

生产日志更适合使用 csvlogjsonlog 等结构化目标,由采集器保留字段,而不是依赖脆弱正则拆纯文本。无论格式如何,都要实测 rotation、磁盘上限、采集延迟、丢弃策略与敏感字段。

记录慢完成语句的核心参数是:

log_min_duration_statement = '...ms'

它在语句完成且 duration 达阈值时记录,因此能发现“已结束的慢”,却不能解释当前一直没结束的 blocker。阈值不是通用常数:OLTP、批处理与维护任务的正常时长不同。先根据 SLO、流量和日志预算设基线,再用 role/database/session 或采样策略控制范围。

相关开关解决不同问题:

设置 能回答 主要代价/边界
log_min_duration_statement 哪些完成语句越过阈值 高流量下日志量;不记录未完成
log_min_duration_sample + sample rate 采样慢/常规语句 样本不等于全量;需保留采样率
log_lock_waits 等锁超过 deadlock_timeout 的事件 阈值与日志量;不是所有短锁等待
log_temp_files 超阈值临时文件 只证明 spill/临时文件,不自动证明根因
auto_explain 被采样语句的执行计划 ANALYZE/timing 成本、日志量与参数暴露
log_statement 某类语句文本 all 通常过量且可能泄密;不是性能万能开关

参数值比 SQL shape 更敏感。extended query protocol 的日志可能包含 bind parameters;log_parameter_max_lengthlog_parameter_max_length_on_error 可限制或禁止记录,但非零错误参数记录也会增加保存文本表示的开销。策略至少应覆盖:

  • token、密码、密钥、个人数据和业务 payload 的禁止/脱敏;
  • 每值长度和整条消息长度;
  • 谁能读取、传输是否加密、保留多久;
  • exporter/log pipeline 是否会复制到更多系统;
  • 调查结束后如何恢复临时配置。

不要在事故中即兴全局打开 log_statement=allauto_explain.log_analyze=on 和完整参数。先估算事件率、单条字节、磁盘余量与性能成本;能按单一 role/database/session 小范围复现时,就不要扩大到全局。

8.3.2 SQL 指标、主机资源与部署事件

慢查询证据通常跨四层:

代表信号 用途
请求/应用 route latency、errors、retries、pool wait、in-flight 确认用户影响与 PostgreSQL 外时间
PostgreSQL query/session calls/time/rows、wait、locks、plans、temp/WAL 锁定查询族与数据库机制
PostgreSQL instance connections、xacts、checkpoints、WAL、vacuum、I/O 判断共享资源与后台活动
主机/平台 CPU、run queue、memory pressure、disk latency/queue、network 解释系统资源与邻居效应

再叠加一条“变化流”:

application release
schema/index/statistics change
PostgreSQL/Pigsty/configuration change
failover/restart
data load/backfill/maintenance
traffic or tenant mix change

正确关联不是看到两条曲线同时升高,而是提出机制:

发布改变 SQL predicate
  → query family calls 与 rows/call 上升
  → plan estimate/actual 偏离
  → shared blocks 与 CPU 同范围上升
  → endpoint p99 上升
  → 回滚 SQL shape 后上述指标按预期恢复

反例也同样重要:

  • 主机 CPU 高,但目标 query 在 Lock wait:CPU 可能是另一 workload;
  • shared block hit 高:只说明 PostgreSQL buffer hit,不代表没有 CPU 或 kernel page-cache I/O;
  • disk latency 上升,但目标 plan buffers 几乎全 hit:时间相关不等于该 query 被磁盘拖慢;
  • temp files 上升,但来自报表 user,而事故来自 OLTP application;
  • 发布与事故同时发生,但未发布的 control instance 也同样退化:优先调查共享依赖。

PostgreSQL 的 pg_stat_iopg_statio_* 能补充数据库 I/O 视角,但官方提醒它们不能区分数据是从物理介质还是 kernel page cache 取得;仍需与操作系统工具联合。track_io_timing 能提供时间,但有平台计时开销,是否常开需基准验证。

计划证据沿用第 7 章的机器可读格式:

EXPLAIN (
    ANALYZE,
    BUFFERS,
    WAL,
    SETTINGS,
    SUMMARY,
    FORMAT JSON
)
SELECT ...;

它只应在安全、代表性环境执行。ANALYZE 会真实运行 SQL;写语句即使包在 rollback 中也可能有 sequence、外部函数等不可回滚副作用。事故时已有一条正在阻塞的生产写语句,不应为了“看计划”再执行一遍。

8.3.3 用同一时间轴排除巧合

把证据转换成 UTC 事件表,比叠十张截图更容易看出顺序:

UTC 事件 身份/范围 证据
12:03:42 release 2026.07.29-3 开始 app-a deploy log
12:04:01 route p99 越过 SLO checkout/create histogram + count
12:04:05 pool wait 上升 app-a pool metric
12:04:07 Lock wait 出现 db/shop, query X activity snapshot
12:04:07 blocker edge X→Y PID + backend_start pg_blocking_pids()
12:04:31 精确取消 Y approved action audit log
12:04:32 X 前进,pool queue 回落 same scope session/pool metrics
12:04:45 p99 恢复 same route histogram

这条链同时满足:

  1. 先后:候选原因发生在结果之前;
  2. 同范围:实例、database、user、application/query 能对应;
  3. 剂量/机制:blocker 存在时等待积累,释放后 waiter 前进;
  4. 负对照:未受影响路由或无 blocker 窗口不呈现同样现象;
  5. 可逆性:受控动作后结果按预测变化。

时间对齐时记录数据本身的分辨率:

  • Prometheus scrape interval 与 recording rule window;
  • Grafana 查询 step、rate window、timezone;
  • 日志采集/索引延迟;
  • trace sampling rate;
  • PostgreSQL 累计统计刷新延迟与事务内 snapshot;
  • 应用与数据库主机的 clock synchronization;
  • failover 后 instance identity/计数器是否改变。

不要把面板像素对齐当毫秒级因果。某条一分钟 rate 曲线的点代表一个区间,日志 timestamp 代表事件,activity 是一次采样,它们的语义不同。先把每项转换成“事件或区间 + 误差/分辨率”,再讨论顺序。

一个实用的反巧合问题集:

候选原因是否先于症状?
是否只出现在受影响范围?
它通过什么数据库机制产生该症状?
该机制应留下什么额外证据?
未受影响对照是否缺少这些证据?
只移除这一原因后,哪些指标应先后恢复?
若未恢复,这个假设怎样被判错?

输出时保留原始 artifact,而非只有解释。原始 CSV/JSON plan、日志行、Prometheus expression、时间范围、source hash 和查询参数分桶使其他人能够重算;截图最多是导航附件。

下一节将这些关联结果整理为一个有优先级、可反驳的假设树。


上一节:从会话到语句定位范围 · 返回本章目录 · 下一节:建立而不是猜测假设 · 查看全书目录 · 查看索引中心

8.4 建立而不是猜测假设

假设不是“可能是磁盘”“可能缺索引”的清单。它必须把机制写成一条可被事实推翻的预测:

因为 tenant 1 的真实选择率为 90%,generic plan 仍估 0.1%,
所以 hot parameter 会读取远多于估算的行;
若强制 custom plan 且其他条件不变,estimate/actual 应接近,
访问路径或资源量应随之改善。

优先级由“现有证据支持度 × 用户影响 × 最小验证成本 ÷ 风险”决定,而不是由团队最熟悉什么决定。

8.4.1 计划与估算问题

计划假设应在 wait 排查之后进入。一个正在 Lock 等待的 backend,即使计划里有 Seq Scan,当前不返回的直接原因仍是锁;一个 ClientWrite backend 可能已经算出大量结果,planner cost 又不包含把结果传给客户端的时间。

确认计划方向时,从第一个显著偏差节点而非根节点名称开始:

query semantics and representative parameter
  → estimated rows vs actual rows × loops
  → filter/recheck rows and join multiplicity
  → buffers/WAL/temp/settings
  → custom vs generic plan
  → statistics age/distribution/extended statistics
  → predicate/index/partition expression match

可量化 cardinality error:

E=max(estimateactual,actualestimate) E = \max\left( \frac{\text{estimate}}{\text{actual}}, \frac{\text{actual}}{\text{estimate}} \right)

若 actual 为 0,应单独描述“估算 N、实际 0”,不要用无穷大排序掩盖业务含义。高误差是调查入口,不是固定阈值自动修复;它是否影响路径选择、内存分配、join order 或响应目标,还要看对照。

常见假设与反证:

假设 预测 最小对照 反证
统计陈旧 estimate 偏离当前分布 安全副本/fixture ANALYZE 前后 estimate 与路径不变且数据分布本就一致
跨列相关缺失 多 predicate 近似独立相乘 extended statistics 前后 单列条件就已偏离,或相关统计不改善
参数敏感 generic plan hot/cold 共用 estimate/shape force generic/custom 对照 两类参数 estimate/资源均相近
predicate 不可用于索引/裁剪 条件落到 Filter,扫描范围扩大 语义等价、可 sargable 的表达式 扫描范围未变化
index 缺失 选择性高且 heap/blocks 成本主导 第 9 章 hypo index/安全建索引实验 路径已合适,时间主要在 wait/client

禁用 planner 方法(如 enable_seqscan=off)最多是受控诊断探针,不是生产修复;它也不能绝对禁止所有路径。不要把“强迫 Index Scan 后这一次更快”直接推广为长期结论,必须覆盖参数分布、cache、并发、写成本和磁盘占用。

计划 change 同样不是根因。统计、参数、配置、数据量或版本变化可能让 planner 合理换路;判断回归要比较结果正确性、SLO、estimate、资源与 workload,而不是 diff 节点名。

8.4.2 锁、I/O、CPU、内存与临时文件

wait event 是“backend 在采样瞬间等待哪里”,不是完整时间账本。把它与 blocker、查询计划、累计资源和 OS 指标组合:

候选机制 PostgreSQL 证据 外部/对照证据 常见误判
heavyweight lock Lock/*pg_blocking_pids()pg_locks blocker 事务/应用身份 只取消 waiter;把长 SQL 当 blocker
I/O wait IO/*、plan buffers、I/O timing、pg_stat_io device latency/queue、kernel/cache 一次 IO sample 就断言磁盘故障
CPU 饱和 多次 active 且无稳定 wait,calls/rows/plan 工作量 CPU、run queue、steal/throttle “无 wait”等于 CPU;CPU 高就归目标 SQL
temp/spill plan sort/hash temp、temp_blks_*、temp file log memory pressure、并发 直接全局增大 work_mem
shared memory contention LWLock/*BufferPin 并发/版本/具体 wait 名 把所有 Lock/LWLock 当行锁
checkpoint/WAL pressure WAL/checkpoint/I/O 指标、query WAL storage 与写 workload 只凭时间相关归因某个 query

锁诊断必须保存等待边两端的:

PID + backend_start
user/database/application/client
xact_start/query_start/state
wait_event and lock modes
current/last query
transaction owner and business action

真正修复通常是缩短事务、统一锁顺序、避免事务中等外部 I/O、减少过宽写集合,或把冲突转为显式业务协议。增加 statement timeout 只是限制损失,不能替代根因修复。

I/O

EXPLAIN (ANALYZE, BUFFERS) 的 shared read/hit 是 executor 访问证据;track_io_timing 开启后可补 read/write time;pg_stat_io 给出 backend type/context/target 维度。它们都不能单独证明物理盘读取:PostgreSQL miss 仍可能由 kernel page cache 满足。应与设备层 latency、queue、吞吐和同主机 control workload 对照。

CPU

CPU 没有一个叫 CPU 的 wait event。backend 在执行用户态工作时往往 active 且 wait 为 NULL,但短采样也可能恰好落在两个 wait 之间。证明 CPU 瓶颈需要:

  • 多次采样而非一行 activity;
  • query calls/rows/plan work 与 CPU 时间窗同范围;
  • 主机 run queue、利用率、throttling/steal;
  • 限制并发或减少工作量后吞吐/延迟按模型变化。

内存与临时文件

sort/hash spill 是“该节点的内存预算与数据规模/并发不匹配”的证据。全局提高 work_mem 很危险,因为它不是整个实例固定池,而可能被一条 query 的多个节点、多个并发 backend 分别使用。优先:

  1. 确认 rows/width estimate 与返回规模;
  2. 减少不必要数据、改善计划;
  3. 对单一 role/session 做对照;
  4. 计算最坏并发内存;
  5. 观察 spill 改善、RSS/pressure 与其他 workload 副作用。

8.4.3 客户端取数、网络与连接池排队

数据库算得快,不代表用户收得快。第 8 章实验用一个大 COPY TO STDOUT 和受控慢 reader 复现:

state=active
wait_event_type=Client
wait_event=ClientWrite
blocking_pid_count=0

这组证据表示 server 正在尝试把数据写给客户端,而 socket backpressure 让它等待。可能原因包括:

  • 客户端逐行做昂贵处理,读取速度低;
  • 客户端线程暂停、GC 或 event loop 被阻塞;
  • 网络丢包、拥塞或带宽受限;
  • 返回行/列过多、payload 过大;
  • 游标/fetch size 与消费方式不合理;
  • 下游已放弃请求但连接尚未及时取消。

ClientRead 则表示 server 等客户端发数据。普通 idle backend 经常在 ClientRead 等下一条命令,这不是慢 SQL;active session 在 COPY FROM、协议交互等场景也可能等客户端。必须结合 state、query、协议阶段与应用 trace。

客户端慢消费的修复候选是限制返回规模、分页/流式语义、修复 consumer、调整驱动读取方式、网络与超时传播,而不是先建索引。索引也许能缩短产生第一批行的时间,却不能让慢 reader 更快接收 400 MB。

连接池排队位于另一个边界:

request arrives
  → waits for application/PgBouncer pool slot
  → obtains PostgreSQL backend/transaction
  → statement becomes visible in pg_stat_activity

未获得 slot 的请求不会出现在 pg_stat_activity。如果应用 p99 高、数据库 active sessions 刚好打满 pool size、server 单条执行仍快,应同时看:

  • application pool acquire duration、waiter count、timeout;
  • PgBouncer client/server active/waiting 与 pool mode;
  • HAProxy/service route、连接拒绝与 backend health;
  • PostgreSQL max_connections、可用连接与角色/database 限额;
  • 事务长度、连接泄漏、重试风暴与并发上限。

“把 pool size 加倍”也是需要实验的假设。若数据库已经 CPU/I/O 饱和,更多并发会增加排队与上下文切换;吞吐不升而尾延迟更差。连接池的作用是排队和保护下游,不是消灭容量边界。

一棵够用的初始假设树可以写成:

请求慢
├─ 尚未进入 PostgreSQL
│  ├─ 应用队列/连接池
│  ├─ 路由/建连/认证
│  └─ 上游重试或限流
├─ backend 正等待
│  ├─ Lock → blocker edge
│  ├─ IO/LWLock/BufferPin → 具体 wait + 资源
│  └─ ClientWrite/Read → client/protocol
├─ backend 正执行
│  ├─ estimate/path/join/scan
│  ├─ CPU/JIT/expression
│  └─ sort/hash/temp/WAL
└─ server 已完成
   ├─ 结果传输/消费
   └─ 应用后处理/下游

这棵树不是固定排障脚本。它的价值是强迫每个解释声明边界和证据,下一节再从中选一个最小、可逆的实验。


上一节:关联日志、指标与计划 · 返回本章目录 · 下一节:设计受控实验 · 查看全书目录 · 查看索引中心

8.5 设计受控实验

诊断实验的目标不是“让一次执行变快”,而是区分竞争解释。一次同时 ANALYZE、建索引、改 SQL、增大内存又重启实例的操作即使奏效,也不知道哪项有效、哪项多余、哪项埋下副作用。

8.5.1 每次只改变一个解释变量

把假设写成实验合同:

现象:
  hot tenant 的同一 query family 资源量异常。

假设:
  generic plan 使用全体 tenant 平均选择率,严重低估 hot tenant。

保持不变:
  PostgreSQL 版本、数据快照、SQL 语义、参数、连接、cache 条件、
  并发、session settings(除 plan_cache_mode)。

唯一改变:
  force_generic_plan → force_custom_plan。

预测:
  estimate/actual error 从 >=100x 降到 <=2x;
  无 Lock/Client wait;可能改变路径和 buffers。

判错:
  custom estimate 仍严重偏离,或主要时间其实属于 wait/client。

回退:
  session 结束即恢复 plan_cache_mode;不修改持久对象。

“每次只改变一个变量”不等于一次只能执行一个命令。重建同一 deterministic fixture、采集 before/after、运行 analyzer 可以是一项完整操作;关键是两组之间只有被检验机制不同。

实验级别应逐级提升:

  1. 只读观察:activity、wait、统计、日志、已有计划;
  2. session-local probe:同一 session 改一个 setting,事务结束恢复;
  3. 隔离 fixture/副本:重放代表数据与并发;
  4. 受控 canary:少量真实流量、明确 SLO 与自动退出;
  5. 生产变更:审批、回退、监控和审计齐全。

不要跳级只是为了快。特别是:

  • 不在生产对未知写 SQL 随意 EXPLAIN ANALYZE
  • 不为对比清空全局 query statistics;
  • 不用 pg_terminate_backend 代替理解事务;
  • 不把 planner debug setting 留在连接池 session;
  • 不在没有磁盘/写放大评估时直接创建大索引;
  • 不把生产 traffic 同时承担探索、验证和上线三个阶段。

若无法控制某个重要变量,应把它记录为混杂因素,而不是从报告中删掉。例如 A/B 两次恰好跨 checkpoint、流量 mix 不同,则结论降级为“支持但未确认”,需要新的对照。

8.5.2 冷热缓存、参数与数据规模控制

数据库性能至少受四种实验条件影响:

缓存

“冷缓存”可能指:

application cache
PgBouncer/prepared state
PostgreSQL shared buffers
kernel page cache
storage controller/device cache

DISCARD ALL 不会清空 shared buffers 或 OS page cache;重连也不等于冷缓存。生产执行 echo 3 > /proc/sys/vm/drop_caches 或重启实例会影响全机 workload,不是普通诊断动作。若真的需要 cold/warm 对照,应在隔离、可重建环境明确 cache 层并记录方法。

更常见也更安全的策略是:

  • 先跑 warm-up,不计入测量;
  • A/B 交替或随机顺序,减少随时间漂移;
  • 重复多次,报告分布和原始值,不只选最快一次;
  • 同时保存 buffers、I/O timing 与 OS 指标;
  • 若无法制造 cold,明确结论只适用于 warm steady state。

参数

均匀随机参数会掩盖业务分布。先按机制分层:

hot/cold tenant
existent/missing key
small/large time range
few/many result rows
common/rare status combination
first/subsequent page

每层使用脱敏、可重现的代表值,并按真实 traffic weight 汇总。一个只让 cold tenant 快 2 ms、却让占 90% 流量的 hot tenant 慢 100 ms 的索引/计划不是总体改善。

数据规模与分布

一万行测试库上的 Index Scan 不能证明十亿行生产行为。fixture 至少应保持:

  • relation/partition 数量级;
  • row count、row width、null fraction;
  • distinct count、MCV、列间相关;
  • index 与 heap physical correlation;
  • dead tuple/bloat 与 statistics 状态;
  • 参数访问倾斜。

无法复制完整规模时,目标是复制决策边界,而不是复制全部数据。例如第 7/8 章用 90000/10 的 tenant skew 让 generic/custom plan 面临清晰选择率差异。

并发

单 session 提速不保证系统吞吐改善。并发会改变:

  • buffer/cache 命中与 I/O queue;
  • CPU run queue;
  • lock contention;
  • 每 query 可用内存与 spill;
  • connection pool queue;
  • background vacuum/checkpoint 干扰。

至少分别回答:

single-query latency 是否改善?
固定并发下 throughput/p95/p99 是否改善?
达到 SLO 的最大可持续吞吐是否改善?
错误、超时、WAL、CPU/I/O、内存副作用怎样?

不要用无限并发压到崩溃后,只比较“谁最后倒下”。容量实验应有 ramp、steady state、 abort threshold 与恢复验证,第 26 章会完整展开。

8.5.3 反证、回退与副作用观察

一个强实验既设计正结果,也预先声明什么会证明自己错。示例:

假设 支持结果 反证/降级
锁是直接原因 blocker 释放后 waiter 立即前进,其他变量不变 无 blocker edge;释放后仍慢
generic estimate 是原因 custom estimate/资源显著改善 两者 estimate 与资源相近
ClientWrite 是原因 无 blocker,慢 reader 恢复后 query 完成 server 内仍有主要 I/O/Lock wait
work_mem 太小 单 session 增大后 spill 消失且端到端改善 spill 消失但延迟不变/内存压力恶化
缺索引 代表参数与并发下 blocks/延迟改善 只冷门参数改善,写放大/SLO 变差

“未能反证”不等于“证明”。尽量加入负对照:

  • 同一 SQL 的 unaffected parameter;
  • 同时间的 unaffected instance/route;
  • 同 seed 重跑;
  • 故意错误的诊断必须被 reveal 拒绝;
  • 恢复原设置后现象按预测返回。

效果报告应同时包含:

correctness/result equivalence
latency distribution + sample count
throughput/concurrency/errors
calls/rows/buffers/temp/WAL
CPU/I/O/memory/locks
plan/estimate/settings
change and rollback duration
known confounders

副作用经常决定一个“快方案”不可上线:

  • 新索引增加写 latency、WAL、磁盘、vacuum 工作;
  • 提高 work_mem 增加并发 OOM 风险;
  • 缩短 timeout 降低资源占用,却提高错误/重试风暴;
  • 增大 pool 提高 backend 并发,却让数据库饱和;
  • 缓存结果改善读取,却引入失效与一致性问题;
  • denormalization 减少 join,却增加写路径和校验复杂度。

回退不是文档最后一行“必要时回滚”。实验前就应验证:

谁有权限回退
回退触发阈值
是否真的可逆
回退耗时和锁影响
回退后怎样证明状态恢复
外部副作用如何补偿

本章三个 case 都把复位写进执行路径:

  • estimate 只改变 session-local plan mode;
  • lock 的 blocker 与 waiter 最终 rollback;
  • client 只生成派生结果并精确取消本实验 backend;
  • final verify.sql 检查 worker=0、ch07 fixture 和 ch04-v1 checksum。

运行全量实验:

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

./task.sh all
cat "$PG36_EVIDENCE_DIR/review.json"

这里 all 是 L1 教学环境动作。它会在必要时受控重建 ch07 专属 fixture、制造短暂锁与 socket backpressure,并精确取消自己的 worker;不会成为生产诊断脚本。

下一节把同一方法映射到 Pigsty:用面板快速发现范围,用原生证据确认,而不依赖某个版本的按钮位置。


上一节:建立而不是猜测假设 · 返回本章目录 · 下一节:从可观测面板回到原生证据 · 查看全书目录 · 查看索引中心

8.6 从可观测面板回到原生证据

Pigsty 的价值之一,是把 PostgreSQL、主机、连接池、代理、日志和 catalog 信息放进同一套时间与标签体系。面板擅长发现“何时、哪里、哪个查询族”异常;最终解释仍要能落回 exporter expression、PostgreSQL view、日志字段和计划 artifact。

8.6.1 用指标语义定位时间、实例与查询

从宽到窄浏览,而不是从某张“慢查询”表直接猜根因:

用户 SLI / 告警时间窗
  → cluster/service 是否整体异常
  → primary/replica/instance 差异
  → database/user/application/session
  → query family/queryid
  → wait/lock/plan/resource
  → 原生证据与受控实验

在当前 Pigsty dashboard 分类中,可按问题选择入口:

问题 参考 dashboard 家族 要带走的身份
全局/cluster 是否退化 PGSQL Overview、Alert、Cluster cluster、instance、role、UTC window
session、负载、锁 PGSQL Activity、Session、Xacts、PGCAT Locks database、application、state/wait、PID/session
query family PGSQL Query、PGCAT Query、Database db/user/queryid、calls/time/rows
代理与连接池 PGSQL Service、Proxy、PgBouncer service route、pool/database/user
WAL/checkpoint/I/O PGSQL Persist、Instance instance timeline、counter/rate
日志事件 PGLOG Overview、Session session/PID、SQLSTATE、timestamp

这些名字是 Pigsty 的当前参考实现,不是永恒导航路径。若版本调整 dashboard,仍按“影响范围 → 实例 → 会话/query → 原生证据”的语义寻找。

打开任何图前,先读变量和 expression:

  • 当前 cluster/service/instance/database/query 变量是什么;
  • timezone 与 absolute start/end 是什么;
  • unit 是 seconds、milliseconds、bytes、rows 还是 ratio;
  • 原始 metric 是 counter、gauge 还是 histogram;
  • rate()/increase() 窗口与 Grafana step 是多少;
  • label 是否在 recording rule 中被聚合掉;
  • primary role/failover 前后 instance identity 是否变化;
  • No data 表示 0、未抓取、权限不足还是 exporter 错误。

颜色是展示规则,不是 PostgreSQL 语义。同一个红色可能代表固定阈值、动态 baseline 或只是主题配置;同一个“QPS”可能是 query counter rate、transaction rate 或 application request rate。下结论前保存 panel query、变量、时间范围与 datasource。

一个可靠的面板收敛过程:

  1. 把用户事故窗口扩大到前后各一段,观察变化点;
  2. 用同星期/相近流量窗口做 baseline;
  3. 对比 primary/replica、受影响/未受影响实例;
  4. 找到 database/user/application/queryid,而非只看 cluster 总量;
  5. 同屏核对 calls/total/rows、wait/locks、CPU/I/O、pool queue;
  6. 保存 absolute UTC window 和 query identity;
  7. 用下一目的 SQL/log/plan 复核。

8.6.2 用 SQL、日志和计划复核面板判断

面板提示 Lock wait 后,原生复核应至少得到一条边:

SELECT
    clock_timestamp() AT TIME ZONE 'UTC' AS captured_at_utc,
    pid,
    backend_start,
    datname,
    usename,
    application_name,
    state,
    wait_event_type,
    wait_event,
    pg_blocking_pids(pid) AS blocking_pids,
    xact_start,
    query_start,
    query_id
FROM pg_stat_activity
WHERE datname = :'database'
  AND state <> 'idle'
ORDER BY xact_start NULLS LAST;

面板提示某 query family 预算上升后,保存累计边界:

SELECT stats_reset
FROM pg_stat_statements_info;

SELECT
    userid, dbid, queryid, calls,
    total_exec_time, mean_exec_time, max_exec_time,
    rows, shared_blks_hit, shared_blks_read,
    temp_blks_written, wal_bytes,
    query
FROM pg_stat_statements
WHERE dbid = (
    SELECT oid FROM pg_database WHERE datname = :'database'
)
  AND queryid = :'queryid'::bigint;

若 panel 来自 Prometheus counter,优先导出事故窗口的 expression/result,而不是把当前累计 SQL 行与历史五分钟 rate 直接比较。记录:

datasource + expression
absolute UTC range + step
all template variables
returned labels
counter reset/failover boundary

日志复核要能串到同一 session 或 query:

timestamp
PID + session id/backend_start
database/user/application/client
SQLSTATE
duration
query/queryid
lock/temp/auto_explain context

计划复核则保存可机器比较的 JSON 与环境:

SELECT version();
SELECT name, setting, unit, source
FROM pg_settings
WHERE name IN (
    'plan_cache_mode',
    'work_mem',
    'random_page_cost',
    'effective_cache_size',
    'track_io_timing',
    'compute_query_id'
)
ORDER BY name;

再在安全环境使用代表参数执行:

EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, SUMMARY, FORMAT JSON)
SELECT ...;

如果生产语句正在 Lock/ClientWrite 等待,activity 与日志可能已经足够证明直接机制;不要为了补一张计划图冒险重跑。计划回答“executor 选择与处理了什么”,wait 回答“采样时为什么没前进”,两者互补。

面板提示“连接用满”也要回到边界:

  • PostgreSQL session count 与 state;
  • PgBouncer client/server active/waiting;
  • application pool acquire duration;
  • HAProxy backend/session;
  • service route 与 primary role。

仅看到 PostgreSQL connections 接近上限,无法区分连接泄漏、长事务、pool 配太大、流量上升或 failover 重连风暴。

8.6.3 不把截图、颜色或当前点击路径当作知识

截图可以证明“当时有人看见某个画面”,却通常缺少:

  • panel expression 与数据源;
  • 变量值与隐藏 filters;
  • absolute start/end、timezone 和 step;
  • unit、legend 聚合与 null handling;
  • dashboard/version/commit;
  • 原始样本与可重算结果;
  • query/session identity;
  • 图外的对照与反证。

因此证据包的优先级应是:

1. 原始 SQL/CSV/JSON/log/Prometheus result
2. query/expression、变量、时间范围、版本与 source hash
3. 机器断言和人工解释
4. 截图作为定位/沟通附件

可长期保留的知识应写成语义:

若 endpoint p99 退化:
  先锁定 absolute UTC window 和 event count;
  比较 cluster/instance/database/application/query scope;
  若 activity 显示 active + Lock,保存 blocker edge;
  若 active + ClientWrite 且 blockers 为空,转查结果消费;
  若无稳定 wait,再进入 plan/CPU/I/O 假设;
  所有结论回到原始 artifact。

而不是:

打开左边第 3 个 dashboard,
点右上角红色方块,
再点第二行蓝色链接。

前一种写法能跨 Pigsty/Grafana 版本、主题和自定义 dashboard;后一种在下一次升级就失效。需要记录当前实现时,附上:

Pigsty version
dashboard UID/title/revision
Grafana/Prometheus datasource
exporter and recording-rule source hash
PostgreSQL major/minor

面板本身也需要验证。常见故障包括 exporter down、scrape timeout、label 重命名、recording rule 计算错误、queryid 类型/符号处理、counter reset 未处理和 dashboard variable 选错实例。若面板与原生 SQL 矛盾,先核对口径与时间,不要自动相信“更漂亮”的一方。

本章的方法把 Pigsty 定位为可观测性工作台:

Pigsty 快速收敛范围
  + PostgreSQL 证明机制
  + 应用/代理补全数据库外时间
  + 受控实验区分竞争解释
  + evidence bundle 供复核

下一节把这条链放进三个外观相似、修复方向完全不同的可复现实验。

参考资料


上一节:设计受控实验 · 返回本章目录 · 下一节:实战:三种“慢”只修真正瓶颈 · 查看全书目录 · 查看索引中心

8.7 实战:三种“慢”只修真正瓶颈

三个 case 都制造“请求没有及时返回”,但修复方向互斥:

case 决定性证据 被排除的直接解释 正确方向
estimate/plan generic error 900x,custom 1x,无 Lock/ClientWrite 当前锁链、慢客户端 参数/统计/计划与访问路径
lock wait active + Lock/transactionid,blocker=1 ClientWrite、正在推进的 plan root blocker 与事务边界
client slow consumer active + Client/ClientWrite,blocker=0 数据库锁链 返回规模、客户端/网络/读取方式

实验不以单次 elapsed time 或节点名作为 golden。它保存机器可读 signal,再由一个不能读取答案文件的分类器只按证据关系判断。

8.7.1 估算错误、锁等待与客户端慢消费

确认 service 指向可写、可演练的 L1:

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

./task.sh all

若 ch07 fixture 已通过,all 直接复用;若缺失,则调用 ch07 marker guard 做受控重建。之后顺序为:

ch04/ch05 preflight
  → estimate case → normalize signals → diagnose
  → lock case     → normalize signals → diagnose
  → client case   → normalize signals → diagnose
  → seeded mystery
  → deliberately wrong diagnosis → reveal must fail
  → answer-blind diagnosis → reveal must pass
  → final worker/model/fixture verify
  → semantic review

一次实测输出:

status=ok
classifications=estimate-plan,lock-wait,client-slow-consumer
mystery=client-slow-consumer/matched=true/wrong-guess-rejected=true
same-seed-reproducible=true/answer-mode=0600
state-restored=true/remaining-workers=0/
relation-checksum=f8a7bfae59c6d16cd323abecfefe1014

case A:estimate/plan

estimate-case.sh 复用 ch07 tenant skew,用同一 SQL、同一 hot parameter 分别捕获:

force_generic_plan:
  estimate=100
  actual=90000
  cardinality error=900x

force_custom_plan:
  estimate=90000
  actual=90000
  cardinality error=1x

本次机器上 generic 是 Index Scan、custom 是 Seq Scan,但分类器不以节点名判定;不同硬件、cost setting 或 PostgreSQL 版本可能选择不同 shape。稳定事实是 generic 用总体平均选择率严重低估 hot parameter,custom 能看见具体参数并改善估算。

这也不自动宣布永久修复应是 force_custom_plan。生产还要比较:

  • hot/cold traffic weight;
  • planning 与 execution 成本;
  • prepared statement/driver/pool 行为;
  • statistics、SQL/index 是否有更好解;
  • 并发下延迟、blocks 与写副作用。

case B:lock wait

lock-case.sh 调用第 5 章的确定性编排:

blocker 在事务内更新 order 1002 并进入受控 hold
  → 普通 reader 仍看见旧 committed version
  → waiter 尝试更新同一行
  → observer 捕获 waiter → blocker 精确边
  → 只取消 exact blocker
  → blocker rollback
  → waiter 前进并 rollback
  → order fingerprint 不变

中性 signals:

{
  "state": "active",
  "wait_event_type": "Lock",
  "wait_event": "transactionid",
  "blocking_pid_count": 1,
  "plan": null,
  "state_restored": true,
  "remaining_workers": 0
}

直接原因是锁等待,不是 waiter 的执行计划。长期修复应回到 blocker 所属事务:为何持锁、是否在事务内睡眠/调用外部服务、锁顺序是否一致、更新集合是否过宽。取消是止血且只允许精确命中实验身份。

case C:client slow consumer

client-write-lab.shgenerate_series 生成大结果,由 slow-reader.py 每次只读少量字节并等待。它不读业务表,也不创建对象。

observer 必须同时看到:

state=active
wait_event_type=Client
wait_event=ClientWrite
blocking_pid_count=0

随后用 PID + backend_start epoch + database + unique application name 精确执行 pg_cancel_backend,等待 pipeline 退出并确认 worker=0。

这个 case 特意证明 planner 的 elapsed/cost 边界:server 可能很快产生数据,却因客户端不读而长时间不返回。修复应查返回规模、分页/流式协议、driver fetch、客户端线程与网络;取消任意 PID或加索引都没有解释证据。

8.7.2 随机隐藏一种根因,先独立诊断再揭晓

为避免“知道脚本名再写结论”,运行 seeded mystery:

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

# 可选:同一 seed 会选择同一 case;不设置则自动生成
export PG36_CASE_SEED='team-drill-2026-07-29'

./task.sh mystery
python3 -m json.tool "$PG36_EVIDENCE_DIR/public/signals.json"

PG36_CASE_SEED 经 SHA-256 后按三类取模。public 目录只给出 metadata、raw evidence 与中性 signals.json;ground truth 写到:

$PG36_EVIDENCE_DIR/.sealed/answer.json

其 mode 为 0600。这防止正常步骤误读,不是同一 OS 用户之间的加密安全边界;知道源代码和 seed 的人可以作弊。盲测的纪律是先不读取 .sealed,独立填写诊断记录模板

观察到的 state/wait/blocker/plan 事实
最可能分类
两个被排除的替代解释
还缺什么证据
最小修复实验与回退

再运行答案盲诊断器:

# diagnose 只读 public/signals.json;离线运行,不需要数据库连接
env -u PGSERVICEFILE ./task.sh diagnose
python3 -m json.tool "$PG36_EVIDENCE_DIR/public/diagnosis.json"

diagnose.py 的三条规则是:

active + Lock + blockers>0
  → lock-wait

active + Client/ClientWrite + blockers=0
  → client-slow-consumer

no Lock/ClientWrite + generic error>=100x + custom error<=2x
  → estimate-plan

它的输出固定声明:

"answer_artifact_read": false

最后揭晓:

env -u PGSERVICEFILE ./task.sh reveal
python3 -m json.tool "$PG36_EVIDENCE_DIR/public/reveal.json"

若 diagnosis 与 sealed ground truth 不一致,revealmatched=false 并返回非零,不能把错误猜测包装成通过。task.sh all 内置一个故意错误的负对照,稳定验收要求该负对照失败。

想提交人工分类时,可在 public 目录创建最小 JSON:

{
  "diagnosis": "lock-wait"
}

然后指定:

export PG36_DIAGNOSIS_FILE="$PG36_EVIDENCE_DIR/public/my-diagnosis.json"
export PG36_REVEAL_FILE="$PG36_EVIDENCE_DIR/public/my-reveal.json"
./mystery.sh reveal

先写假设再 reveal 的顺序比“猜错后改答案”更接近真实事件复盘。

8.7.3 输出证据包、假设树、修复前后对照与复位结果

task.sh all 的 evidence 结构按用途分层:

manifest.txt                  versions + target facts + source hashes
preflight.txt                 ch04/ch05 state before
ch07-fixture.txt              reused or controlled-rebuild
estimate/
  generic-hot.json
  custom-hot.json
  signals.json
  diagnosis.json
lock/
  raw/activity.csv
  raw/locks.csv
  raw/summary.txt
  signals.json
  diagnosis.json
client/
  raw/activity.csv
  raw/summary.txt
  signals.json
  diagnosis.json
mystery/
  public/...
  .sealed/answer.json
verify.txt
review.json

其中 raw artifact 负责可复核,中性 signals 负责教学分类,diagnosis 负责解释和排除项,review 只断言稳定关系。不要删 raw 只保留 status=ok

baseline-v0.3-proposal.json 把本章证据追加到 DEFAULT-EVID-009:慢请求证据必须包含影响范围、 UTC 时间窗、wait/blocking edge、query/parameter identity、竞争假设和 复位结果;盲测诊断不得读答案,错误负对照必须失败。它绑定不可变 v0.1 checksum,并绑定 ch07 v0.2 proposal 的 canonical checksum;在 v0.2 尚未晋升前,它仍只是有依赖的 v0.3 candidate,不冒充已发布规约。

一份生产级证据包还应补齐:

  • 用户 SLI 时间窗、样本数、吞吐、并发、错误;
  • request/trace/application release identity;
  • service/cluster/instance/database/user/application;
  • PID + backend_start(若做实时会话动作);
  • query family、参数分桶与数据敏感处理;
  • Prometheus expression、Grafana variables/step/datasource;
  • 日志 session、SQLSTATE、duration 与采样率;
  • before/after 计划、statistics、settings、版本;
  • 假设排序、反证、审批动作与回退阈值;
  • 修复前后 correctness/SLO/resource 副作用;
  • artifact hash、访问权限与保留期限。

本章没有持久 ch08 对象,所以没有“删库式 reset”。复位是每个 case 的成功条件:

estimate session ends
lock transactions rollback
client worker exactly cancelled
all lab workers disappear
ch07 fixture remains valid
ch04-v1 business checksum unchanged

手动复核:

export PG36_EVIDENCE_DIR="$PWD/evidence/ch08/verify-$(date -u +%Y%m%dT%H%M%SZ)"
./task.sh verify
cat "$PG36_EVIDENCE_DIR/verify.txt"

all 为本章自动重建了 ch07 fixture,它会保留供后续计划/索引实验使用。清理 ch07 属于另一个 R2 动作,只能按第 7 章的双 token reset 执行;本章不会替读者擅自删除。

完成实验后,读者应能在看到“请求卡住”时先问:

它尚未进入数据库、正在执行、正在等谁,还是等客户端?
哪条原生证据能区分?
最小动作怎样让不同假设产生不同结果?
怎样证明动作没有留下新问题?

下一章只有在证据指向访问路径时才进入索引设计,避免把索引当所有慢请求的通用药方。


上一节:从可观测面板回到原生证据 · 返回本章目录 · 下一章:巧夺天工:索引设计与效果验证 · 查看全书目录 · 查看索引中心

9 巧夺天工:索引设计与效果验证

索引不是“给字段加速”的装饰,而是为一组操作符、谓词、排序与返回形状维护的额外数据结构。每增加一个索引,读取路径多一个候选,写入路径也多一份维护、WAL、缓存和生命周期成本。

因此索引设计从第 8 章的已证实 workload 开始:

query family + representative parameters + SLO
  → predicate operators and expression semantics
  → join/order/group/limit and returned columns
  → data distribution, correlation and write pattern
  → access method + operator class + key order/predicate/include
  → before/after read evidence
  → size, HOT, WAL, write and maintenance evidence
  → build/failure/recovery plan
  → retain, merge or reject

“出现 Index Scan”不等于成功,“仍是 Seq Scan”也不等于失败。小表、低选择率、大范围、缓存与成本参数都可能让 Seq Scan 合理;一个被 planner 使用的索引也可能只优化冷门参数,却让所有写入变贵。

本章目标

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

  • 把 access method、data type、operator class 与 query operator 对应起来;
  • 解释 B-tree、Hash、GiST、SP-GiST、GIN、BRIN 与 Bloom 的边界;
  • 从等值、范围、连接、排序、Top-N 与返回列推导 key order;
  • 知道“最具选择性的列放最前”不是通用多列索引算法;
  • 说明 PostgreSQL 18 B-tree skip scan 的能力及 14–17 的版本边界;
  • 正确设计 expression index,并处理函数 volatility、collation 与语义一致性;
  • 解释 partial index 的 predicate implication 与 generic parameter 陷阱;
  • 使用 INCLUDE,同时理解 visibility map、heap fetch 与宽 payload 成本;
  • 量化索引的空间、写放大、WAL、cache、vacuum 与复制代价;
  • 解释 HOT 的两个条件,以及 indexed/update column 如何使 HOT 失效;
  • 区分重复、重叠、未使用、无效与约束支撑索引;
  • 设计数据、cache、参数、并发一致的 before/after 实验;
  • 选择普通或 concurrent build,监控阶段并处理 INVALID 残留;
  • 在 Pigsty 中关联 query、table/index、WAL、锁、I/O 与复制延迟;
  • 为订单、库存、全文搜索与时间序列给出 retain/reject 决策;
  • 把索引收益与代价证据追加到 PREF-PLAN-005

实验边界

实验基线为 PostgreSQL 18.6、Pigsty v4.5.0、Ubuntu 24.04 L1;主体 SQL 保持 PostgreSQL 14–18 可用,PG18 skip scan 单独标注。实验只在 shop_private 建七张带 marker 的 ch09_* fixture:

orders        200000 rows / placed=5% / customer 42 target=10
inventory     300000 rows / 30 warehouses / SKU 4242 target=30
search        100000 rows / full-text target=100
events        400000 rows / physically time-correlated / target=600
write twins   50000 + 50000 rows
unique probe  10000 rows / 5000 duplicate groups

setup 在 marker 完全匹配后重建,属于 R1。reset 删除专属对象,属于 R2,需要 action/target 双 token。实验不为 shop 业务表新增或删除任何索引。

候选索引使用 CREATE INDEX CONCURRENTLY,但这只是 L1 机制演练:生产上线还要独立评估长事务、两次扫描、CPU/I/O、WAL、磁盘峰值、复制延迟、唯一语义与失败恢复。

下载资产:

本章目录

9.1 索引方法与操作符类

9.2 从谓词、连接与排序推导索引

9.3 表达式、部分与覆盖索引

9.4 索引也有写入和生命周期成本

9.5 验证而不是“加完就快”

9.6 实战:为订单、库存与搜索入口设计索引

实测摘要

一次 PostgreSQL 18.6 全量验收得到:

order:
  literal/custom → partial covering index-only / Heap Fetches=0
  generic status parameter → cannot use partial predicate
inventory:
  before → warehouse-first primary key skip scan
  after  → reverse covering index-only / Heap Fetches=0 / rows=30
search:
  generated tsvector GIN / rows=100
event:
  BRIN retained / B-tree comparison rejected and removed
  BRIN/B-tree size fraction=0.00273
write:
  unindexed volatile column HOT ratio=1.0
  indexed volatile column HOT ratio=0
  statement WAL bytes=11257432→11631392
concurrent:
  SQLSTATE 23505 / INVALID observed / exact drop / remaining=0
decisions:
  retain=4 / reject=4
final:
  worker=0 / rejected-or-failed index=0
  relation checksum=f8a7bfae59c6d16cd323abecfefe1014

时间、cost、buffers、WAL 精确值与节点组合不是 golden;它们受硬件、cache、checkpoint、版本和数据布局影响。稳定断言是 partial/generic 语义、结果行数、index-only heap fetch、BRIN 相对空间、HOT 失效方向、WAL 增长方向、INVALID 生命周期和最终 catalog。

章节验收

  1. 每个候选先有 query/parameter/order/return shape 与 SLO;
  2. 能从 operator class 证明某个 clause 可被索引,而非只看列名;
  3. 多列顺序由 equality/range/order/workload 推导;
  4. PG18 skip scan 不被写成 PG14–17 的通用前提;
  5. partial index 的 query predicate 可在 planning time 蕴含 index predicate;
  6. generic parameter 不能证明任意值满足 partial predicate;
  7. covering index 同时检查 projection、VM/heap fetch 与 payload 宽度;
  8. GIN/BRIN 的 lossy/recheck、write 与物理相关边界明确;
  9. 索引评审包含 size、WAL、HOT、write latency 与 cache;
  10. 不以 idx_scan=0 单独删除索引;
  11. constraint、replica identity、rare critical query 与统计 epoch 已排除;
  12. A/B 使用同一数据、统计、参数、cache、并发和重复方法;
  13. concurrent build 的阶段、额外扫描、长事务与磁盘水位已评估;
  14. build 失败后查询 pg_index.indisvalid/indisready 并精确回收;
  15. Pigsty 面板结论能落回 query、catalog、plan 与 WAL/复制证据;
  16. task.sh all 与双 token reset 均通过,业务 checksum 不变。

下一章 ch10《顾此失彼:并发控制与隔离异常》 将验证即使单条查询和索引都正确,并发交错仍可能破坏业务不变量。

参考资料


上一章:抽丝剥茧:慢 SQL 诊断方法论 · 返回上卷导读 · 下一章:顾此失彼:并发控制与隔离异常 · 查看全书目录 · 查看索引中心

9.1 索引方法与操作符类

判断一个 clause 能否使用索引,要同时回答:

index access method
  + indexed data type/expression
  + operator class/family
  + query operator
  + collation/order/predicate

“这个列有索引”不够。payload @> ...name LIKE ...point <-> ... 分别需要与其操作符语义匹配的 operator class;同一数据类型也可能有不止一种索引语义。

可查询当前环境的 operator class:

SELECT
    am.amname AS index_method,
    opc.opcname AS opclass_name,
    opc.opcintype::regtype AS indexed_type,
    opc.opcdefault AS is_default
FROM pg_am AS am
JOIN pg_opclass AS opc
  ON opc.opcmethod = am.oid
ORDER BY index_method, opclass_name;

扩展可新增 type、operator 与 opclass,所以最终答案来自目标环境 catalog 和扩展文档,而不是一张静态“索引类型速查表”。

9.1.1 B-tree 与 Hash 的适用查询

B-tree:默认不是偶然

B-tree 支持有全序关系的数据,核心 operator 为:

<  <=  =  >=  >

BETWEENINIS NULL/IS NOT NULL 等可以转成相应搜索;它还可以按索引顺序输出,支持 uniqueness、多列、expression、partial、INCLUDE 与 index-only scan。因此以下 workload 通常先考虑 B-tree:

WHERE customer_id = $1
WHERE placed_at >= $1 AND placed_at < $2
WHERE customer_id = $1 ORDER BY placed_at DESC LIMIT 20
WHERE lower(email) = lower($1)  -- 前提是表达式/语义匹配

单列 B-tree 能正向或反向扫描,所以仅为了 ORDER BY occurred_at DESC 通常不必再建一个 DESC 单列索引。多列混合顺序才有区别:

CREATE INDEX event_tenant_time_idx
ON event (tenant_id ASC, occurred_at DESC);

它可以直接提供 ORDER BY tenant_id ASC, occurred_at DESC;普通 (tenant_id, occurred_at) 的整体反向扫描会得到两列同时反向,不能产生“一升一降”。

前缀 pattern search 要特别看 collation/operator class。LIKE 'foo%' 有机会变成范围扫描,LIKE '%foo' 不能靠普通 B-tree 从左定位。非 C locale 下,可能需要 text_pattern_ops/varchar_pattern_ops;但 pattern opclass 不替代普通 locale ordering,需要范围比较时可能要保留默认 opclass 索引。不要看到 LIKE 就盲目加普通 B-tree。

Hash:只有等值

Hash index 保存值的 32-bit hash code,只处理简单等值:

CREATE INDEX session_token_hash_idx
ON session USING hash (token_hash);

它不能提供范围、排序、unique、多 key 组合或 INCLUDE。PostgreSQL 14–18 的 Hash index 已是 WAL-logged、crash-safe 的正式能力,不应继续引用早期版本“hash index 不可靠”的旧结论;但 B-tree 也能处理 equality,并有更广能力,所以 Hash 需要实测证明 size/cache/lookup 收益,而不是因字段名叫 hash 就选 Hash。

Hash 适合候选的条件通常很窄:

  • 只有一个宽值的 equality lookup;
  • 不需要排序、范围、unique 或 covering;
  • 真实数据与 cache 下比 B-tree 有明确收益;
  • hash collision recheck 与额外 heap access 可接受;
  • write、WAL、build、backup 与维护成本已比较。

如果 equality 本身已经由 PK/unique B-tree 支持,再建 Hash 多半只是重复成本。

9.1.2 GiST、SP-GiST 与空间、范围、近邻问题

GiST 和 SP-GiST 都是扩展索引策略的基础设施,不是“一种固定的空间树”。是否可用取决于 operator class。

GiST

GiST 可承载平衡树式的广义搜索。核心与扩展生态常见:

  • 几何/空间 overlap、containment;
  • range/multirange overlap 与 containment;
  • btree_gist 提供 B-tree-like GiST opclass;
  • pg_trgm 的相似/模糊匹配;
  • PostGIS geometry/geography 空间 operator;
  • 支持的 opclass 上做 K-nearest-neighbor ordering。

核心 point 示例:

CREATE INDEX place_location_gist_idx
ON place USING gist (location);

SELECT place_id
FROM place
ORDER BY location <-> point '(101,456)'
LIMIT 10;

这里 <-> 是该 operator class 的 distance ordering operator。把表达式改成未经索引支持的自定义距离函数,GiST 不会因“语义看起来一样”自动使用。

GiST 也用于 exclusion constraint:

EXCLUDE USING gist (
    room_id WITH =,
    occupied_during WITH &&
);

它表达“同一房间的时间范围不得重叠”。这是约束语义,不只是性能;删除此类索引可能破坏 constraint,不能按 idx_scan 清理。

SP-GiST

SP-GiST 支持非平衡、space-partitioned 结构,例如 radix tree、quadtree、k-d tree。常见候选:

  • 有前缀/层次分割特征的数据;
  • core point/quadtree;
  • inet prefix;
  • text prefix 与特定 operator class;
  • 支持 distance ordering 的 opclass 上做 KNN。

GiST 与 SP-GiST 谁更好不能由“空间数据”四字决定。要对照:

operator/operator class support
data clustering and skew
query predicate and KNN shape
index size/build/update
lossy recheck
concurrency and vacuum

第 17 章会用 PostGIS 具体讨论 bounding box、distance、SRID 与 exact recheck;本章只固定索引方法的选择方式。

9.1.3 GIN 与数组、JSONB、文本检索

GIN 是 inverted index:把一个值拆成多个 component/token,再从 token 找到包含它的行。典型数据:

  • array elements;
  • tsvector lexemes;
  • JSONB keys/values/path tokens;
  • pg_trgm trigrams;
  • 扩展定义的可分解值。

数组:

CREATE INDEX article_tags_gin_idx
ON article USING gin (tags);

SELECT *
FROM article
WHERE tags @> ARRAY['postgresql'];

全文检索:

CREATE INDEX product_search_gin_idx
ON product USING gin (search_document);

SELECT product_id
FROM product
WHERE search_document @@
      websearch_to_tsquery('simple', 'postgresql observability');

查询必须沿用同一 text search configuration 和 document 构造。索引 to_tsvector('english', title)、查询 to_tsvector('simple', title) 并非同一语义。生产常把 document 做成 stored generated column,使构造、统计与索引合同可见。

JSONB 有两个常用 core GIN opclass:

-- 默认,支持更广的 key/value/existence 类 operator
CREATE INDEX doc_ops_idx ON doc USING gin (payload);

-- 更紧凑、常适合 @> 与 jsonpath,但能力边界不同
CREATE INDEX doc_path_idx
ON doc USING gin (payload jsonb_path_ops);

哪一个更好由实际 operator 与数据决定。给任意大 JSONB 建默认 GIN,可能索引大量从不查询的 token,增加 size、pending list、写入和 vacuum 成本。

GIN 不提供有序输出,也不能 index-only 返回原值,因为 entry 通常只保存 component。结果经常是 Bitmap Index Scan → Bitmap Heap Scan,并对 lossy/候选项 recheck。出现 Recheck Cond 是实现证据,不自动表示索引坏。

GIN 的 fastupdate 默认把更新先放 pending list,以批量合并摊薄写成本;这可能让个别读或清理出现尖峰。评审需观察 pending-list 行为、autovacuum、写入 burst、index size 和 WAL,而不是只测静态查询。

9.1.4 BRIN 与物理相关的大表;Bloom 的扩展边界

BRIN:索引 block range summary

BRIN 不为每行保存精确 key,而为连续 heap block range 保存 min/max 等 summary。因此它依赖“值与物理行顺序相关”:

CREATE INDEX event_occurred_brin_idx
ON event USING brin (occurred_at)
WITH (pages_per_range = 32, autosummarize = on);

适合:

  • append-only/mostly append 时间序列;
  • 单调增长 ID;
  • 极大关系、宽范围查询;
  • 能接受 lossy bitmap + heap recheck;
  • B-tree 空间/cache/write 成本不划算。

不适合:

  • 物理顺序已与值随机化;
  • 每次只查极少行且需要精确 point latency;
  • range 内大量无关行的 recheck 不可接受;
  • 误以为 BRIN 能提供 ORDER BY——有序输出仍只有 B-tree。

pages_per_range 越大,索引通常越小但 summary 越粗;越小则更精确、索引和维护更大。新页范围还需要 summarization,可用 autosummarize、vacuum 或 BRIN 函数管理。

本章 400000 行按 occurred_at 物理写入。相同 600 行范围:

BRIN bytes=24576
B-tree bytes=9003008
fraction≈0.00273

具体字节不是通用阈值;稳定结论是这种分布下 BRIN 以显著更小的空间支持范围,B-tree 的额外精度对声明 workload 不值成本。

BRIN 不是 partitioning。它不会改变 retention、约束、每分区索引或 drop lifecycle;partition pruning 与 BRIN filtering 可以并用,但解决不同层次问题。

Bloom:contrib extension,不是 core 默认方法

bloom 是随 PostgreSQL 提供的 extension access method,需安装扩展后使用。它把多列 equality 特征编码成 lossy signature:

CREATE EXTENSION bloom;

CREATE INDEX asset_bloom_idx
ON asset USING bloom (tenant_id, region, kind, state);

适合“很多列、查询任意 equality 组合、维护所有 B-tree 组合过贵”的特定问题。false positive 必须回 heap recheck;signature 越大,误报少但索引更大。其限制包括:

  • core module 只带有限类型 opclass;
  • 只支持 equality;
  • 不支持 unique;
  • 不支持 NULL lookup;
  • 不能排序、范围或替代约束。

Bloom 与 BRIN 都可能很小且 lossy,但机制不同:BRIN 按物理 block range summary,Bloom 为每行/索引项保存 signature。选择前先写 query operators 和数据布局。


返回本章目录 · 下一节:从谓词、连接与排序推导索引 · 查看全书目录 · 查看索引中心

9.2 从谓词、连接与排序推导索引

索引设计的输入不是表结构,而是查询合同。面对一条 SQL,先把它改写成下面这张工作单:

query family:
  predicates = column/expression + operator + representative value
  joins      = outer/inner side + join key + expected cardinality
  order      = exact key/direction/NULLS + LIMIT
  output     = returned columns and width
  workload   = parameter distribution + frequency + concurrency + SLO
  writes     = INSERT/UPDATE/DELETE columns and rate

然后才提出:

access method (key columns [direction]) [INCLUDE payload]
[WHERE stable predicate]

这样做会自然排除“这个字段经常查,所以单独给它建索引”一类脱离操作符、组合方式和代价的建议。

9.2.1 等值、范围与多列顺序

B-tree 的有效搜索区间

对多列 B-tree (a, b, c),传统且跨 PostgreSQL 14–18 都成立的基本推导是:

  1. 从最左侧开始的等值条件不断缩小连续索引区间;
  2. 第一个没有等值、但有不等式的列确定该区间的起止边界;
  3. 更右侧条件仍可在索引内检查,却不一定进一步减少需要扫描的索引项;
  4. 排序能否直接复用,还取决于等值前缀、列顺序、方向和 NULLS 规则。

例如:

WHERE tenant_id = $1
  AND state = 'open'
  AND created_at >= $2
  AND created_at <  $3
ORDER BY created_at DESC
LIMIT 50

一个自然候选是:

CREATE INDEX ticket_tenant_state_time_idx
ON ticket (tenant_id, state, created_at DESC);

两个 equality key 固定前缀,created_at 同时承担 range 与 ordering。若把 created_at 放到 state 前面,进入时间范围后,右侧的 state 通常只是过滤条件;它仍可能减少 heap visit,却不能像等值前缀那样缩短该时间范围本身。

这不是要求把所有等值列机械放在所有范围列之前。真正的问题是:

  • 哪些条件总是一起出现,哪些只是某个 query family 才有;
  • 哪个条件在 planning time 可见;
  • 是否要支持某个 ORDER BY ... LIMIT
  • 同一个索引还要服务哪些前缀查询;
  • 写入是否频繁改变这些列;
  • 一个较短、可复用索引是否已足够。

“最具选择性的列放最前”不是算法

tenant_id=$1 选出全表 1%,state='open' 选出 10%。对总是同时出现的两个等值条件,(tenant_id, state)(state, tenant_id) 最终都能把搜索收敛到相同组合;不能只凭全局选择率宣布第一种必然更快。顺序更应考虑:

  • 单独按 tenant_id 与单独按 state 的真实 workload;
  • 后续 range/order 列如何衔接;
  • distinct 数、数据倾斜和参数分布;
  • 是否能省掉另一个索引;
  • index tuple、prefix compression/dedup 与 write cost 的实测结果。

“选择率最高在前”最多是一条需要上下文的启发式,不是 PostgreSQL 多列索引的正确性规则。

从订单查询推导,而不是从订单表推导

本章订单 query family 是:

SELECT order_no, amount_minor, placed_at
FROM shop_private.ch09_order_probe
WHERE customer_id = 42
  AND order_status = 'placed'
ORDER BY placed_at DESC
LIMIT 20;

fixture 有 200000 行,placed 占 5%,目标客户恰有 10 行已下单记录。候选不是把所有 WHERE 列都当普通 key,而是:

CREATE INDEX CONCURRENTLY ch09_order_placed_cover_idx
ON shop_private.ch09_order_probe
    (customer_id, placed_at DESC)
INCLUDE (order_no, amount_minor)
WHERE order_status = 'placed';

推导逐项对应:

查询合同 索引设计
order_status 是稳定 literal,且只关心少数 placed rows partial predicate,不再把它重复存成 key
customer_id = 42 第一个 search key
ORDER BY placed_at DESC LIMIT 20 第二个 key,直接输出 Top-N 顺序
返回窄的 order_no, amount_minor 候选 payload,是否保留还要验证 VM 与大小

这只是“有资格”的设计;9.3 会证明 generic parameter 可能无法使用这个 partial predicate,9.4–9.5 还要证明它值得让写入长期维护。

PostgreSQL 18 的 B-tree skip scan:能力,不是默认设计借口

本章库存表已有主键:

PRIMARY KEY (warehouse_id, sku_id)

但查询是:

WHERE sku_id = 4242

在 PostgreSQL 14–17,不能把“后导列也在联合主键里”当成高效定位的通用保证;通常要么扫描大量索引项,要么选择其他路径。因此反向 query family 的自然候选是:

CREATE INDEX ch09_inventory_sku_cover_idx
ON shop_private.ch09_inventory_probe (sku_id, warehouse_id)
INCLUDE (available, reserved, updated_at);

PostgreSQL 18 引入 B-tree skip scan。若前导列 distinct 很少、后导列条件足够有用,planner 可以为若干可能的前导值重复发起 index search,跳过不可能匹配的大段索引。本章 30 个 warehouse、300000 行的 fixture 上,before plan 确实对 warehouse-first 主键使用了 skip scan;这是一条真实的 PG18 路径。

边界必须同时保留:

  • skip scan 是 PostgreSQL 18 新能力,不能倒写成 14–17 的前提;
  • 它由 cost model 选择,不保证每次出现;
  • 前导 distinct 很大时,重复搜索可能不划算;
  • 即使 before 已能 skip scan,专用 (sku_id, warehouse_id) 仍可能更直接、更小或更容易覆盖;
  • 最终保留哪一个由读收益、索引大小和写成本决定,不由节点名决定。

因此章节验收不把“before 必须出现 Skip Scan”设为跨版本 golden,只要求 after 候选能正确支持 declared SKU lookup。

9.2.2 连接键、排序、分组与 Top-N

连接索引建在被反复探测的一侧

“JOIN 列要建索引”同样太粗。以下 nested loop 中,外侧每产生一个 customer,内侧就按 order.customer_id 探测:

SELECT c.customer_id, o.order_no
FROM customer AS c
JOIN orders AS o
  ON o.customer_id = c.customer_id
WHERE c.region = $1;

若外侧很小而内侧很大,orders(customer_id) 可能让每次探测便宜。若两侧都要读很大比例,planner 可能选择 hash join 或 merge join;此时新索引未必有价值。评审至少记录:

outer rows × inner probes
join cardinality estimate vs actual
inner predicate/order/output
available uniqueness
hash/sort memory and spill

主键或 UNIQUE 约束会创建唯一索引,PostgreSQL 不会自动为外键的引用列创建索引。外键索引的理由不是“约束要求”,而是两类真实动作:

  • 从父表删除/更新 key 时,快速检查子表引用;
  • 应用从子表按 parent key 查询或连接。

例如 order_item(order_id) 常常值得索引,但应由 delete/update parent 的风险和查询频率验证。不要重复创建一个已经由复合索引左前缀覆盖的 order_id 单列索引。

排序是一种可被索引提供的属性

B-tree 能按 key order 输出,planner 可在三种路径间权衡:

index path already ordered
bitmap/seq path + explicit Sort
partially ordered path + Incremental Sort

对返回大部分表的查询,顺序 index scan 仍可能产生大量随机 heap access,Seq Scan + Sort 反而更便宜。对 Top-N,索引价值通常更高,因为它可能在找到前 N 行后停止:

SELECT order_no, placed_at
FROM orders
WHERE customer_id = $1
ORDER BY placed_at DESC
LIMIT 20;

候选 (customer_id, placed_at DESC) 能在固定 customer 前缀内直接取前 20 行。若没有 ORDER BYLIMIT 20 只是任意 20 行,不能把偶然的索引输出顺序当业务语义。

方向需要按整组 key 判断:

CREATE INDEX mixed_order_idx
ON metric (tenant_id ASC, recorded_at DESC);

单列 B-tree 可正反扫描;多列索引整体反向会同时翻转各列,因此 (tenant_id ASC, recorded_at ASC) 的反向扫描不能提供 tenant_id ASC, recorded_at DESCNULLS FIRST/LAST 也属于 order contract。只有查询要求的 order 与一种扫描方向吻合,才可省掉 Sort。

分组、去重与窗口不能只看关键字

有序输入可能帮助 GroupAggregateDISTINCT、merge join、窗口函数或 incremental sort,但不保证 planner 一定利用索引:

SELECT tenant_id, count(*)
FROM event
WHERE occurred_at >= $1
GROUP BY tenant_id;

如果时间范围覆盖很多行,按 (occurred_at, tenant_id) 扫描再聚合未必比 Seq Scan + HashAggregate 好;若查询需要按 tenant 分组且只读少量 tenant,另一个 key order 才可能合适。为 GROUP BY 新建索引前,要比较:

  • 过滤后实际行数;
  • 现有输入是否已排序;
  • hash aggregate 的内存与 spill;
  • sort/incremental sort 的内存、磁盘与并行;
  • 最终是否还有 order/limit;
  • 这个 query 的频率是否能抵消写成本。

同样,窗口函数的 PARTITION BY/ORDER BY 是完整序列需求,不是见到某列就建单列索引。

9.2.3 选择率、相关性与访问路径

选择率属于“谓词 + 值”,不只属于列

state='failed' 可能命中 0.01%,state='success' 可能命中 99%。同一 prepared query 的 hot/cold 参数可能对应完全不同的最佳路径。先看统计对 planner 描述了什么:

SELECT
    attname,
    null_frac,
    n_distinct,
    most_common_vals,
    most_common_freqs,
    histogram_bounds,
    correlation
FROM pg_stats
WHERE schemaname = 'shop_private'
  AND tablename = 'ch09_order_probe';

MCV 捕获常见值,histogram 描述其余分布,n_distinct 描述 distinct 规模;多列相关则需要第 7 章的 extended statistics 或更合适的数据模型。统计是抽样模型,不是精确计数,数据漂移后必须 ANALYZE,但也不能把无限提高 statistics target 当第一反应。

判断一个索引路径时,应同时看:

estimated rows vs actual rows
rows removed by filter
loops
heap blocks touched and cache hits/reads
sort/spill
result rows and correctness
parameter bucket

若 cardinality 根本错了,节点选择往往只是后果。

物理相关性改变 heap 访问代价

pg_stats.correlation 近似描述列逻辑顺序与 heap 物理顺序的相关程度。高度相关的 range scan 往往按邻近 heap page 读取;随机分布的相同行数可能触碰更多 page。相关性不是永久属性:

  • append 时间列通常天然相关;
  • UPDATE、乱序导入和长期 churn 会改变布局;
  • CLUSTER 可重写表,但不会自动持续维持物理顺序;
  • BRIN 依赖 block range summary,相关性漂移会扩大 recheck;
  • partitioning 能缩小关系范围,却不等同于每个分区内部有序。

所以不能把另一个环境的 random_page_cost 或 correlation 照搬为本环境真相。

Index、Bitmap 与 Seq Scan 各有合理区间

可以用一个粗略模型理解三类路径:

路径 倾向的 workload 主要风险
plain Index Scan 少量、高选择率;或必须保序/Top-N 随机 heap page 多,低选择率时昂贵
Bitmap Index + Heap Scan 中等命中量;需合并多个 index bitmap 可能 lossy,需要 recheck;丢失 index order
Seq Scan 大比例、表小、顺序读便宜 扫描全部 page,不适合严格 point latency

PostgreSQL 能用 BitmapAnd/BitmapOr 组合多个索引。这有时让两个短索引胜过一个专用复合索引,也可能因丢失 ordering 而需要 Sort。不能据此为每列各建一个索引:组合仍有 bitmap 建立、heap recheck、排序和所有单列索引的写成本。

EXPLAIN 的 cost 是在当前统计、参数、settings 与硬件成本假设下比较候选,不是毫秒。enable_seqscan=off 之类 GUC 可以做“是否存在某路径”的诊断,不得作为让 planner 听话的长期修复。正确闭环是:

  1. 用代表参数捕获 before plan 与结果;
  2. 提出能从 operator/order 证明的 candidate;
  3. 在相同数据与统计下捕获 after;
  4. 比较 read、write、size 与生命周期;
  5. 即使 after 仍选 Seq Scan,也判断其是否符合真实成本;
  6. 只有收益覆盖长期代价才保留。

本章实验正是这个闭环,而不是“让四条查询都出现 Index Scan”的演示。

延伸阅读


上一节:索引方法与操作符类 · 返回本章目录 · 下一节:表达式、部分与覆盖索引 · 查看全书目录 · 查看索引中心

9.3 表达式、部分与覆盖索引

普通索引把表列作为 key;expression、partial 与 covering index 分别回答三个更精确的问题:

expression → 查询真正比较的是否是一个规范化表达式?
partial    → 是否只有一个可在 planning time 证明的稳定子集值得索引?
INCLUDE    → 定位完成后,是否值得复制少量 payload 来避免 heap visit?

三者可以组合,但每加一层都扩大合同:查询语义必须吻合,写入必须维护更多内容,验证必须覆盖更多失效条件。

9.3.1 表达式必须与查询语义一致

索引表达式与查询表达式要能被 planner 对应

大小写无关的登录查找可以写成:

CREATE UNIQUE INDEX account_email_ci_uidx
ON account (lower(email));

SELECT account_id
FROM account
WHERE lower(email) = lower($1);

索引 key 是 lower(email),不是原始 email。下面的查询有不同语义,不能因为“都在处理邮箱”就期待复用:

WHERE email = $1
WHERE upper(email) = upper($1)
WHERE trim(lower(email)) = trim(lower($1))
WHERE lower(email) COLLATE "C" = lower($1) COLLATE "C"

planner 能识别一些等价变换,但不会证明任意业务函数、cast 或字符串处理“效果一样”。设计时应让规范化规则只有一个权威表达:

  • 在 SQL 与索引中复用同一表达式;
  • 或把它做成 generated column,再查询和索引该列;
  • 若它定义身份唯一性,明确原值能否保留多个展示形式;
  • 通过真实 parameter、collation 与 locale 做 correctness 测试。

UNIQUE(lower(email)) 表达“规范化后不得重复”,这已是数据约束,不再只是性能。不能按 unused index 清理。

volatility 是正确性边界

PostgreSQL 要求 index definition 中用到的函数和操作符是 IMMUTABLE。原因很直接:同一行的 index key 必须在未来仍表示同一个值。依赖当前时间、会话时区、配置、外部表或可变环境的函数不能安全成为 key。

典型陷阱是:

-- placed_at 为 timestamptz;结果会受会话 TimeZone 影响
CREATE INDEX bad_daily_idx
ON orders (date(placed_at));

服务器会拒绝非 immutable 表达式。正确方案不是把自定义函数随手标成 IMMUTABLE,而是先固定业务语义:

“自然日”到底是 UTC、租户时区还是订单发生时记录的当地日期?
时区规则未来变化时,历史归属要不要变化?

如果合同是固定 UTC 日,可以用明确、可验证的 UTC 派生值;如果每租户时区不同,往往应在写入时保存业务日期或按租户和 UTC range 查询。错误声明 volatility 会让 planner 相信一个并不成立的不变量,结果可能是漏行,而不仅是变慢。

还要检查:

  • collation 版本升级后的排序/相等语义;
  • ICU/libc locale 差异;
  • extension 或自定义函数升级;
  • implicit cast 是否改变 operator/opclass;
  • expression 的返回类型和长度;
  • 函数 schema qualification 与受控 search_path

表达式通常只在插入及非 HOT 更新时计算,读取可直接用已保存 key;代价因此从读侧转移到写侧。复杂表达式要同时测 CPU、WAL、index size 与 build 时间。

expression 不能修复错误的数据模型

下面这些候选要先问是否应该改模型:

lower(trim(email))
(payload ->> 'tenant_id')::bigint
date_trunc('hour', occurred_at)
coalesce(deleted_at, 'infinity')

若 JSONB key 实际是高频连接键,生成强类型列或普通列通常比反复 cast 更可审计;若“未删除”是稳定热点子集,partial predicate 可能比把 infinity 混入 key 更清晰。expression index 是精确工具,不是把所有 schema 欠账藏进 planner 的办法。

9.3.2 部分索引的谓词蕴含与参数陷阱

查询必须在 planning time 蕴含 index predicate

部分索引只保存满足 predicate 的行:

CREATE INDEX open_ticket_customer_idx
ON ticket (customer_id, created_at DESC)
WHERE state = 'open';

它有资格服务:

WHERE customer_id = $1
  AND state = 'open'

因为查询条件明确蕴含 state='open'。PostgreSQL 能处理完全匹配及少量简单不等式蕴含,例如 x < 1 可蕴含 x < 2;它没有通用定理证明器,也不会在 runtime 取到值以后再重新证明 arbitrary predicate。

因此这些看似接近的条件可能不能使用同一 partial index:

WHERE state IN ('open', 'retry')
WHERE lower(state) = 'open'
WHERE state = current_setting('app.state')
WHERE state = $1                 -- generic plan 时未知

设计 partial index 时,把 predicate 连同 query text、parameterization 和 plan mode 一起写入合同。只保存一个手工 literal 的 EXPLAIN 不够。

generic parameter 为什么是确定性反例

本章候选:

CREATE INDEX ch09_order_placed_cover_idx
ON shop_private.ch09_order_probe
    (customer_id, placed_at DESC)
INCLUDE (order_no, amount_minor)
WHERE order_status = 'placed';

literal query 可以证明 predicate:

WHERE customer_id = 42
  AND order_status = 'placed'

实验随后准备带两个参数的语句:

PREPARE ch09_order_lookup(bigint, text) AS
SELECT order_no, placed_at, amount_minor
FROM shop_private.ch09_order_probe
WHERE customer_id = $1
  AND order_status = $2
ORDER BY placed_at DESC
LIMIT 20;

force_custom_plan 下,planner 为本次 EXECUTE (42, 'placed') 看见具体值,可以证明并使用 partial index;在 force_generic_plan 下,它必须生成适用于任意 $2 的计划,无法假设所有值都是 placed,因此不能使用该 partial index。

psql -X -w \
  --dbname='service=pg36-admin' \
  --set=plan_mode=force_custom_plan \
  --file=static/labs/ch09/order-parameter.sql

psql -X -w \
  --dbname='service=pg36-admin' \
  --set=plan_mode=force_generic_plan \
  --file=static/labs/ch09/order-parameter.sql

force_* 只用于构造确定性 A/B,不是生产修复。生产是否得到 custom/generic plan 还受 prepared statement 执行历史、driver/pool 协议和 planner 判断影响。可选方案按语义权衡:

  • 让稳定状态保留为 SQL literal,只参数化 customer;
  • 使用 custom plan,但要比较 planning cost 和所有参数桶;
  • 建普通索引,接受索引更大、写成本更高;
  • 为不同状态使用明确的 query family;
  • 若状态集合与生命周期已成为数据分区问题,重新审视 schema/partitioning。

不要通过伪造 IMMUTABLE 函数、强制全局 plan mode 或复制大量近似 partial index 绕过合同。

partial index 也会漂移

“只索引 5% 活跃行”今天很划算,若状态分布变成 70%,大小和维护成本会完全不同。定期观察:

SELECT
    c.relname,
    pg_size_pretty(pg_relation_size(c.oid)) AS index_size,
    i.indisvalid,
    pg_get_expr(i.indpred, i.indrelid) AS predicate,
    pg_get_indexdef(i.indexrelid) AS definition
FROM pg_index AS i
JOIN pg_class AS c
  ON c.oid = i.indexrelid
WHERE i.indpred IS NOT NULL;

partial unique index 还能表达“只在满足条件的行中唯一”,例如每个用户最多一个 active token。这是业务约束,必须给并发写入做失败测试。partial index 不是 partition:它不提供 retention、独立 vacuum、partition pruning 或 detach/drop 生命周期。

9.3.3 INCLUDE、index-only scan 与可见性图

covering 是查询与索引的共同属性

考虑:

SELECT order_no, amount_minor, placed_at
FROM orders
WHERE customer_id = $1
ORDER BY placed_at DESC
LIMIT 20;

候选:

CREATE INDEX orders_customer_time_cover_idx
ON orders (customer_id, placed_at DESC)
INCLUDE (order_no, amount_minor);

customer_id, placed_at 是 search/order key;order_no, amount_minor 只是 payload:

  • 它们不参与 B-tree 定位或排序;
  • unique index 的唯一性只作用于 key,不包括 INCLUDE
  • payload 可以是 access method 不理解的类型,因为只需原样保存;
  • query 若再读取一个未保存列,便不再 covered。

PostgreSQL 14–18 中 B-tree 总能支持 index-only scan;GiST/SP-GiST 只在部分 opclass 上能重建原值,GIN 不能。INCLUDE 本身只受支持它的 access method 接受,不能把“有 INCLUDE”与“本次一定 Index Only Scan”等同。

为什么仍可能访问 heap

MVCC 可见性信息不保存在每个 index tuple 中。执行器必须确认当前 snapshot 下 heap tuple 是否可见;只有对应 heap page 的 visibility map all-visible bit 已设置,才能跳过 heap。

所以 index-only scan 有两层条件:

query 所需值都能从 index 得到
AND
目标 heap page 对当前机制可由 visibility map 证明 all-visible

EXPLAIN (ANALYZE, BUFFERS) 中的:

Heap Fetches: 0

是这次执行没有回 heap 的证据,不是索引永久保证。INSERT/UPDATE/DELETE 会清除相关 page 的 all-visible bit,VACUUM 在满足条件后再设置。高 churn 表即使 covered,也可能频繁 heap fetch;为一次 benchmark 手动 VACUUM 只能证明静态上限,不能模拟生产稳态。

本章在候选创建后执行受控 VACUUM (ANALYZE),订单和库存 after plan 都要求 Index Only Scan + Heap Fetches=0。这条断言只属于确定性 fixture;生产验收要在真实写入、autovacuum 和 snapshot 条件下看 heap fetch 比例。

payload 不是免费的

增加 INCLUDE 会:

  • 复制 payload,增大 leaf tuple、index size 与 cache footprint;
  • 增加 INSERT/UPDATE 的 WAL 和维护;
  • 更新 included column 时需要维护该索引,也会影响 HOT;
  • 让 build、backup、restore、replication 和 vacuum 多付成本;
  • 宽值可能超过 index tuple 大小上限,导致写入失败;
  • B-tree 只要有 non-key column,就不会使用 deduplication。

虽然 B-tree upper level 会移除 non-key payload,使导航层保持较小,leaf 层成本仍真实存在。不要 INCLUDE (*),也不要为了“可能以后少一次 heap visit”复制 JSON、正文或频繁变化的状态。

一个可保留的 covering candidate 应同时满足:

  1. declared query 高频且返回列稳定、窄;
  2. 定位 key 与 ordering 已正确;
  3. 实际 plan 使用 index-only,而非只在理论上可用;
  4. 真实 VM/all-visible 状态下 heap fetch 明显减少;
  5. size/cache/write/WAL/HOT 代价可接受;
  6. payload 变化不会让维护成本压过读取收益;
  7. 不与另一个更短索引形成无意义重叠。

如果表频繁更新或 query 本来就要访问 heap 中的宽列,普通短索引往往更好。

延伸阅读


上一节:从谓词、连接与排序推导索引 · 返回本章目录 · 下一节:索引也有写入和生命周期成本 · 查看全书目录 · 查看索引中心

9.4 索引也有写入和生命周期成本

索引把一次读取节省的工作,变成所有相关写入都要长期承担的工作。一份完整收益表至少有两边:

read benefit:
  fewer heap/index blocks
  no sort or earlier LIMIT stop
  better latency/throughput/tail

lifetime cost:
  insert/update/delete CPU and latency
  extra index pages and cache displacement
  WAL, archive, backup and replication
  vacuum/analyze/build/reindex
  lock, disk peak and failed-build recovery
  lost HOT opportunities

只保存 after query 的执行时间,等于只记收益、不记负债。

9.4.1 写放大、缓存占用与 WAL

一次逻辑写会触碰多少物理结构

插入一行时,heap、每个相关 index、visibility/free-space metadata 和 WAL 都可能变化。更新在 MVCC 下创建新 row version;若不满足 HOT,它还要为各索引写新 tuple。删除先留下 dead version,之后 vacuum 才清理 heap/index 可回收空间。

索引越多,常见代价越大:

  • 更多 access method/operator expression 计算;
  • 更多 buffer 被读入、锁定并标脏;
  • B-tree page split、GIN pending list、BRIN summary 等各自维护;
  • 更多 WAL 传到 archive、streaming replica 与 logical decoding;
  • checkpoint 写出更多 dirty page;
  • base backup、restore、pg_upgrade --link 之外的重建与磁盘巡检范围更大;
  • autovacuum/index cleanup 和故障修复窗口更长。

这不是说“索引数量越少越好”,而是每个索引都必须有消费者和证据。

用同一条写路径量化

PostgreSQL 可直接给 data-changing statement 取执行证据:

EXPLAIN (
    ANALYZE,
    BUFFERS,
    WAL,
    SETTINGS,
    FORMAT JSON
)
UPDATE counter
SET value = value + 1
WHERE bucket_id BETWEEN 1 AND 1000;

EXPLAIN ANALYZE 会真的执行写语句。安全实验应使用专属 fixture,或在能够完全回滚且不涉及 sequence/外部副作用的事务中运行。生产不能为了看 plan 对一条未知 DML 随手加 ANALYZE

比较前后至少保存:

result/affected rows
execution time distribution, not one sample
shared/local/temp buffer hits/reads/writes
WAL records/FPI/bytes
table/index sizes
TPS and concurrent read/write latency
replica WAL receive/replay lag
checkpoint and I/O pressure

WAL bytes 会受 full-page image、checkpoint 时点、page 初始状态、compression 与版本影响,不能把本机某个精确数值写成阈值。A/B 的稳定断言通常是方向和相对幅度,并要重复、交替顺序。

索引大小也是 cache 决策

查看关系分解:

SELECT
    relid::regclass AS table_name,
    pg_size_pretty(pg_relation_size(relid)) AS heap,
    pg_size_pretty(pg_indexes_size(relid)) AS indexes,
    pg_size_pretty(pg_total_relation_size(relid)) AS total
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC;

查看单个索引:

SELECT
    indexrelid::regclass AS index_name,
    pg_size_pretty(pg_relation_size(indexrelid)) AS bytes,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relid = 'public.orders'::regclass
ORDER BY pg_relation_size(indexrelid) DESC;

一个 9 GB 索引不等于需要 9 GB shared_buffers,操作系统 page cache 也参与;但热 working set 彼此竞争是真实的。增加大索引可能让某条 query 更快,却把另一条热路径的数据页挤出 cache。Pigsty 的 table/index、buffer、I/O 和 instance 指标应在同一时间窗关联,而不是孤立看 idx_scan

本章事件候选正是空间决策:400000 行物理时间相关数据上,BRIN 为 24576 bytes,对照 B-tree 为 9003008 bytes,比例约 0.00273。字节值只属于本次 fixture;保留 BRIN、拒绝 B-tree 的理由是 declared range workload 不需要为额外精度和 cache footprint 付费。

9.4.2 HOT 更新、页分裂与填充因子

HOT 省掉什么

Heap-Only Tuple update 让新 row version 留在旧 row 所在 heap page,并沿 page 内 HOT chain 查找,因此无需为该更新创建普通 index tuple。它同时减少索引写入和以后清理旧 index entry 的负担。

在 PostgreSQL 16–18,HOT 的关键条件可表述为:

  1. 新 tuple 能放进旧 tuple 所在 heap page;
  2. 更新没有改变任何 non-summarizing index 引用的列。

这里“引用”包括 key、expression、INCLUDE payload 和 partial-index predicate。核心 BRIN 是 summarizing access method;PostgreSQL 16 起,如果只改变 BRIN-indexed key,仍可允许 HOT,但若改变 partial predicate 引用列仍会阻止 HOT。

版本边界:PostgreSQL 14–15 还没有“只更新 BRIN 列仍可 HOT”的改进,应按更保守的规则理解:更新任何索引引用列都会阻止 HOT。它是 PostgreSQL 16 引入的能力,不能回写到整个 14–18 范围。

监控:

SELECT
    schemaname,
    relname,
    n_tup_upd,
    n_tup_hot_upd,
    CASE
      WHEN n_tup_upd = 0 THEN NULL
      ELSE n_tup_hot_upd::numeric / n_tup_upd
    END AS hot_ratio
FROM pg_stat_user_tables
ORDER BY n_tup_upd DESC;

统计是累计观测,先记录 reset epoch 和时间窗;不同写 workload 混在一起时,整体 ratio 不能解释某条 UPDATE。

本章 HOT/WAL 对照

两个 50000 行表结构、数据和 table fillfactor=50 相同,唯一差别是:

CREATE INDEX ch09_write_indexed_counter_idx
ON shop_private.ch09_write_indexed (volatile_counter);

随后分别执行:

UPDATE ... SET volatile_counter = volatile_counter + 1;

一次 PostgreSQL 18.6 实测:

without volatile index:
  HOT ratio = 1.0
  WAL bytes = 11257432

with volatile index:
  HOT ratio = 0
  WAL bytes = 11631392

精确 WAL 会漂移;稳定关系是:

相同更新 + 同样预留 page space
  → 未引用 volatile_counter 的索引集合允许 HOT
  → 把 volatile_counter 放入普通 B-tree 后 HOT 消失
  → 本次 statement WAL 增加

这个 counter 没有 declared read query,所以候选被拒绝。若未来确有关键 point lookup,评审要比较读取收益与 HOT/WAL 代价,而不是把“阻止 HOT”当绝对禁令。

table fillfactor 与 index fillfactor 不同

降低 table fillfactor 会在 heap page 预留空间,提高后续 row version 留在同页、形成 HOT 的机会:

ALTER TABLE hot_account SET (fillfactor = 80);

它不会把现有 page 自动重写成 80% 装载;要等待 churn 或受控重写,并承担表更大、顺序扫描更多 page 的代价。

降低 B-tree index fillfactor 则在 build 时给 leaf page 留空间,可能减少后续 insertion/page split,但会让索引更大、cache density 更低。它不创造 HOT 所需的 heap page 空间。两个同名参数作用在不同结构,不能混为一谈。

page split 不是“索引损坏”,是 B-tree 正常维护;真正要评估的是:

  • insert key 是否随机、单调或集中在热点;
  • page split/WAL 与 tail latency 是否成为问题;
  • 低 fillfactor 的空间成本是否值得;
  • REINDEX CONCURRENTLY/重建是否有真实 bloat 证据;
  • 去重、key width 与 payload 是否可优化。

不要把周期性重建所有索引当保养仪式。

9.4.3 重复、未使用与失效索引的判断

idx_scan=0 只能生成调查清单

pg_stat_user_indexes.idx_scan=0 不能单独授权 DROP INDEX,因为它可能表示:

  • statistics 刚 reset,观察窗太短;
  • rare but critical 月结、故障切换或合规查询尚未发生;
  • 该索引只在 replica 被读,primary 本地统计看不到;
  • planner 用另一条等价路径只是暂态;
  • 它支撑 PRIMARY KEYUNIQUE、exclusion constraint;
  • 它用于 foreign-key parent delete/check 或运维任务;
  • 它是 logical replication 的 replica identity;
  • 应用版本/feature flag 尚未完整覆盖;
  • partition child 各自 workload 不同;
  • 统计语义和计数方式在版本间有差异。

至少把数据库统计 reset 时点一并保存:

SELECT datname, stats_reset
FROM pg_stat_database
WHERE datname = current_database();

然后覆盖一个能代表周、月、批处理和故障流量的时间窗,并查 primary、read replicas 与 query history。

“重复”要比较完整定义与职责

两个索引列名相似,不代表重复。审查结构至少包含:

access method
key expressions and order
operator classes and collations
ASC/DESC and NULLS
partial predicate
INCLUDE payload
unique/nulls-not-distinct/exclusion semantics
valid/ready/live state
partition attachment
constraint and replica-identity ownership

可先取 catalog:

SELECT
    i.indexrelid::regclass AS index_name,
    am.amname,
    i.indisunique,
    i.indisprimary,
    i.indisexclusion,
    i.indisreplident,
    i.indisvalid,
    i.indisready,
    i.indislive,
    pg_get_indexdef(i.indexrelid) AS definition,
    pg_get_expr(i.indpred, i.indrelid) AS predicate
FROM pg_index AS i
JOIN pg_class AS c
  ON c.oid = i.indexrelid
JOIN pg_am AS am
  ON am.oid = c.relam
WHERE i.indrelid = 'public.orders'::regclass;

(a, b) 可以支持一部分 a lookup,但不等价于 (a):它更宽,可能有不同排序/payload/uniqueness,也可能让短索引更适合 cache。反过来,若所有 (a) consumers 都被 (a,b) 等价覆盖,短索引才进入候选合并清单。必须用 before/after workload 验证。

INVALID 是状态,不是自动删除理由

并发创建过程中,catalog 会先出现尚未 valid 的 index。失败可能留下:

indisvalid = false
indisready = true or false depending on failed phase

某些 INVALID index 仍会被写路径维护,却不能被查询采用;它既有成本又没有读收益,需要处置。但先确认:

  • 是否仍有合法 build/reindex 正在运行;
  • index 与 table 的 exact OID/schema/name;
  • 是否由 constraint/partition operation 管理;
  • 失败 SQLSTATE、phase 与原始 DDL;
  • 是否已有人在做恢复;
  • drop/recreate 的锁、磁盘、唯一性与 replica 风险。

本章用故意重复数据执行 CREATE UNIQUE INDEX CONCURRENTLY,要求:

SQLSTATE 23505
  → catalog 观察到 exact INVALID unique index
  → 保存定义、大小与 flags
  → DROP INDEX CONCURRENTLY exact schema-qualified target
  → remaining=0

这不是“定时删除所有 INVALID”的脚本模板。生产应由变更单绑定 exact identity、owner、证据和回退;若失败对象承担约束语义,还要先恢复约束正确性。

删除本身也要 A/B 和回退

安全清理流程是:

  1. 生成 candidate,不执行 drop;
  2. 排除 constraint、replica identity、partition 与 rare critical consumers;
  3. 保存完整 definition、owner、size、usage epoch 和依赖;
  4. 在可代表的 primary/replica workload 中验证替代路径;
  5. 评估 DROP INDEXDROP INDEX CONCURRENTLY 的限制与窗口;
  6. 一次处理少量对象,观察 query latency、CPU/I/O 与 write;
  7. 保留可审计的 recreate DDL 与停止条件。

索引生命周期的终点不是“catalog 更干净”,而是正确性不变、关键 SLO 不退化、写入和空间确有改善。

延伸阅读


上一节:表达式、部分与覆盖索引 · 返回本章目录 · 下一节:验证而不是“加完就快” · 查看全书目录 · 查看索引中心

9.5 验证而不是“加完就快”

“建完出现 Index Scan”只能证明 planner 在一次条件下选了它。索引验收必须回答四组问题:

维度 要证明的事实
正确性 返回集合、排序、唯一/约束语义不变
读取 哪些参数桶、并发与 cache 状态改善,tail 是否达标
写入 INSERT/UPDATE/DELETE、HOT、WAL、CPU/I/O 和 replica 是否可接受
生命周期 build、失败、磁盘峰值、监控、回退与以后清理是否可控

只要其中一列为空,结论就是 candidate,不是可上线变更。

9.5.1 计划、缓冲区、延迟分布与写入代价

保存机器可读 before/after

对可安全执行的只读查询:

EXPLAIN (
    ANALYZE,
    BUFFERS,
    WAL,
    SETTINGS,
    SUMMARY,
    FORMAT JSON
)
SELECT ...;

JSON 便于保留完整 node tree 并自动断言。证据包还要保存:

query identity and exact text
representative parameter bucket
result row count or semantic fingerprint
server version/database/role
relevant settings
table/index definitions and sizes
ANALYZE/statistics timestamp or snapshot
capture UTC time and workload window

阅读计划时按因果顺序:

  1. root actual rows 与业务结果是否正确;
  2. 每个节点 Plan Rows/Actual Rows × Actual Loops 是否偏离;
  3. predicate 是 Index CondRecheck Cond 还是 Filter
  4. 是否有 Rows Removed by Filter
  5. shared/local/temp blocks 的 hit/read/dirtied/written;
  6. sort method、memory、disk spill;
  7. index-only 的 Heap Fetches
  8. planning 与 execution time;
  9. 写语句的 WAL records/FPI/bytes。

节点名不是最终 KPI。Index Scan 读取大量随机 heap page 可能比 Seq Scan 慢;Bitmap Heap Scan 带 recheck 可能正是最合理路径;BRIN 本来就是 lossy。稳定结论来自结果、资源与延迟关系。

EXPLAIN ANALYZE 增加测量开销,且对 DML 会执行真实写入。节点很多时可用 TIMING OFF 降低逐节点计时开销,但不能消除 instrumentation 本身。调查生产 DML 时优先看已有 pg_stat_statements、日志、采样计划和 replica/L1 重放,不要直接执行未知副作用。

单次 elapsed 不是延迟分布

一次 warm-cache、单连接执行无法代表:

p50 / p95 / p99
throughput
queueing under concurrency
hot/cold parameter mix
lock and I/O interference
planning overhead

pg_stat_statements 可提供 query family 的 calls、总/均值执行时间、rows、block 与 WAL 累计;它不是逐请求 percentile 存储。tail latency 应来自应用 tracing、指标 histogram 或负载工具,并与相同 query identity 和时间窗关联。

索引可能把 hot parameter 从 2 s 降到 20 ms,却让占 99% 流量的写入多 10%;也可能只优化 cache 已热的 microbenchmark。评审要用 traffic weight 算总体收益:

weighted read benefit
  = Σ(query frequency × latency/resource delta)

weighted write cost
  = Σ(write frequency × latency/WAL/resource delta)

公式不要求伪装成精确货币值,作用是迫使评审记录频率,而不只比较最好看的样本。

Pigsty 提供时间窗,SQL/catalog 提供语义

在 Pigsty 中,把同一 UTC 窗口的观测串起来:

query family latency/calls/rows
  → table/index scans and tuple fetches
  → instance CPU/load/memory
  → PostgreSQL buffer and system I/O
  → WAL generation/archive
  → replica receive/replay lag
  → locks/long transactions/autovacuum

不同 Pigsty 版本的仪表盘名称和布局会变化,本书不冻结点击路径;以当前 PostgreSQL Dashboard 文档 和实际变量为准。面板负责说明“何时、影响多大”,最终仍要落回:

  • query text/parameters;
  • EXPLAIN 与统计估算;
  • pg_index/pg_class definition 与 validity;
  • WAL、锁和 replica 证据;
  • correctness/SLO 验收。

没有 query identity 的 CPU 曲线不能证明某个索引有效;没有时间窗的 plan 也不能证明它解释了事件。

9.5.2 数据规模和缓存状态一致的 A/B 对照

一次只改变候选索引

一个可复核 A/B:

same PostgreSQL major/minor and settings
same schema/data/statistics
same SQL/parameter/result
same connection protocol and plan mode
same cache category and run order
same concurrency/background workload
only candidate index differs

推荐流程:

  1. 固定 query family、参数分桶和 SLO;
  2. 保存目标 relation checksum/row counts 与 baseline catalog;
  3. ANALYZE 后捕获 before plan;
  4. 创建一个 candidate,等待/执行与生产可比的统计和 vacuum 条件;
  5. 捕获 after plan;
  6. 交替执行 A/B 或在等价环境重复,避免永远 before 冷、after 热;
  7. 验证结果集合、顺序与业务不变量;
  8. 测同样的 read concurrency;
  9. 测代表性的 INSERT/UPDATE/DELETE 与 WAL/HOT;
  10. 给出 retain、merge 或 reject,拒绝对象也要清理并验证。

不能在同一生产表上随意来回 drop/create 只为跑 A/B。可选机制包括独立 L1 clone、可恢复 staging、同数据快照、hypothetical index 作早期筛选,以及受控 shadow workload;真正上线前仍要用真实索引验证 build 和写成本。

cache 不是“清掉才公平”

至少区分:

  • cold-ish:工作集尚未被本次 query 预热;
  • warm:稳定重复访问后;
  • mixed/production:与其他 workload 共同竞争 cache。

不要在共享服务器用 Linux drop_caches,它会全局影响其他进程且仍不能模拟真实 workload;重启 PostgreSQL 也改变连接、checkpoint、background worker 等大量变量。DISCARD ALL 只清会话状态,不清 shared buffers 或 OS page cache。

更可靠的方法是:

  • 独立 disposable 实例做受控 cold 测试;
  • A/B 交替顺序并多轮;
  • 报告 buffers 的 hit/read,而不是只说“冷/热”;
  • 在生产相似 mixed workload 中验证 cache displacement;
  • 不把首次 build 后的缓存副作用算成稳态收益。

数据与参数必须能代表真实分布

小表上 Seq Scan 合理,大表才出现索引价值;全均匀合成数据会掩盖 hot tenant、MCV、相关性与 null skew。fixture 应固定并公开:

row count and width
distinct/MCV/null distribution
physical correlation
representative hot/cold values
target result cardinality
write/update distribution

本章故意设置:

  • 订单 placed=5% 且目标 customer 返回 10 行;
  • 库存只有 30 个 warehouse,暴露 PG18 skip scan;
  • 搜索目标命中 100/100000;
  • 事件按时间物理写入,目标范围 600/400000;
  • 两个 write twin 完全等价,只改变 volatile index。

这些数字让机制可重复,不宣称代表每个生产库。迁移结论前,用真实 pg_stats、query 参数桶和 workload 重做。

optimizer GUC 只能用于反事实

enable_seqscan=offenable_bitmapscan=offforce_custom_plan 可回答:

“候选路径是否存在?”
“若看见具体参数,估算/路径是否改变?”

它们不能证明强制路径在生产更快,更不能作为全局长期修复。实验必须把 SETTINGS 保存到 plan,避免一个被强制出来的节点冒充自然选择。本章只用 plan_cache_mode 构造 partial/generic 的语义反例,最终 candidate plan 仍由正常 cost model 验收。

9.5.3 线上创建、失败回收与监控窗口

普通与 concurrent build 的真实差别

普通:

CREATE INDEX orders_customer_time_idx
ON orders (customer_id, placed_at DESC);

可在一次 table scan 中完成,通常比 concurrent build 更快、更省总工作;构建期间允许普通读取,但会阻塞会修改该表的写入。因此适合维护窗口、空表、新分区或能明确停写的场景。

并发:

CREATE INDEX CONCURRENTLY orders_customer_time_idx
ON orders (customer_id, placed_at DESC);

不会用同样方式阻塞日常 INSERT/UPDATE/DELETE,但它绝不是“无锁、无影响”:

catalog 创建 INVALID index
  → 等待可能修改表的旧事务
  → 第一次 table scan/build
  → index becomes ready for new writes
  → 等待旧 snapshot
  → 第二次 table scan/validate
  → mark valid

它要做两次扫描,持续更久,并产生 CPU、I/O、WAL、磁盘和 replica 压力;长事务/旧 snapshot 可让某个 phase 长时间等待。一个表同一时刻只能有一个 concurrent index build。命令不能放在 transaction block 内。

唯一索引还有额外边界:在第二次扫描开始时,系统已可能对其他事务执行 uniqueness enforcement;其他 session 可能在该索引正式 valid 前收到 uniqueness violation。若 build 最终失败,INVALID 对象仍可能继续执行唯一性检查。上线前必须先做 duplicate preflight,并理解这段时间的应用错误语义。

对 partitioned table,PostgreSQL 18 仍不支持直接 concurrent build 整个 partitioned index。可以在各 leaf partition 上分别 CREATE INDEX CONCURRENTLY,再用短暂的 parent metadata 操作 attach/建立 partitioned index;具体 DDL、锁和失败恢复必须在目标版本演练。

开始前定义水位与停止线

生产变更单至少写:

exact schema/table/index definition and owner
query evidence and expected benefit
table/index current size and growth
free disk plus build/recovery peak
CPU/I/O/WAL/replica-lag ceilings
long transaction and lock preflight
statement/lock timeout policy
connection/session survivability
progress and alert owner
abort criteria
INVALID cleanup/retry plan
after correctness/read/write acceptance

“磁盘够放最终索引”不等于够用:并发 build、WAL、temp、失败对象和 replica 都可能需要峰值空间。也不要让一个普通应用连接在不可控 timeout、pool recycle 或网络中断下承担数小时 DDL。

用 progress view 观察阶段,不猜百分比

SELECT
    pid,
    datname,
    relid::regclass AS table_name,
    index_relid::regclass AS index_name,
    command,
    phase,
    lockers_total,
    lockers_done,
    current_locker_pid,
    blocks_total,
    blocks_done,
    tuples_total,
    tuples_done,
    partitions_total,
    partitions_done
FROM pg_stat_progress_create_index;

不同 phase 只有部分计数有意义,blocks_done/blocks_total 不能代表整个 concurrent lifecycle 的统一完成率。要同时查:

  • pg_stat_activity 的 session identity、state/wait event;
  • pg_locks 与 exact blocker edge;
  • long transaction/snapshot;
  • host and PostgreSQL I/O;
  • WAL/archive/replica lag;
  • target index pg_index flags 与 size。

Pigsty 负责把这些指标放入统一时间轴,catalog/progress view 决定当前语义。取消也只能针对 PID + backend_start + database + application/DDL identity 精确命中;不能看到“建索引慢”就取消任意 backend。

失败后先辨认状态,再精确回收

检查:

SELECT
    i.indexrelid::regclass AS index_name,
    i.indisunique,
    i.indisready,
    i.indisvalid,
    i.indislive,
    pg_relation_size(i.indexrelid) AS bytes,
    pg_get_indexdef(i.indexrelid) AS definition
FROM pg_index AS i
WHERE i.indrelid = 'public.orders'::regclass;

若确认是本次失败遗留且无约束/partition/其他 owner 依赖,按 exact schema-qualified identity 回收:

DROP INDEX CONCURRENTLY public.orders_customer_time_idx;

DROP INDEX CONCURRENTLY 也有约束:不能放进 transaction block,不能配 CASCADE,且 partitioned parent 有额外限制。失败对象是否 drop、reindex 或重新 build 取决于 phase、依赖和变更计划,不能用全库 WHERE NOT indisvalid 自动删除。

本章 failure injection 让 5000 对重复 key 触发 SQLSTATE 23505,先把 INVALID 的 flags/size 保存为证据,再精确 drop,最后断言同名对象为 0。这才是可复核的失败闭环。

延伸阅读


上一节:索引也有写入和生命周期成本 · 返回本章目录 · 下一节:实战:为订单、库存与搜索入口设计索引 · 查看全书目录 · 查看索引中心

9.6 实战:为订单、库存与搜索入口设计索引

本节把前五节合并成一个完整评审:

4 个读取 query family
  → 5 个真实 candidate
  → before/after plans + result assertions
  → HOT/WAL twin
  → concurrent unique failure injection
  → retain/reject ledger
  → final catalog/model/state verification
  → PREF-PLAN-005 candidate evidence

实验只在带 marker 的 shop_private.ch09_* fixture 上执行。它不会触碰 shop 业务表的索引,也不会模拟生产点击动作。

9.6.1 从真实查询清单提出候选索引

先确认目标与身份

准备一个只包含本地 L1 凭据、权限为 0600 的 service file:

[pg36-admin]
host=/absolute/socket/or/host
port=5432
dbname=pg36_shop
user=...

然后:

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

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

只在已确认可写、可重建的 L1/本地数据库继续。脚本还会执行 ch05 model verification,要求数据库、schema、角色、fixture marker 与业务 checksum 均符合前章合同。没有 PGSERVICEFILE、action 非法或 context 不符时 fail closed。

下载并阅读:

setup 在七个同名关系全部缺失,或全部带精确 marker 时重建;任何同名异物都会拒绝。它属于 R1 fixture rebuild,不需要 reset token,但只能在 disposable L1 使用。

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

四份 query contract

实验不是先给答案,而是先保存 before:

family predicate/order/result fixture 特征 候选
order customer equality + literal placed + time DESC Top-N;10 行 200000 行,placed 5% partial B-tree + INCLUDE
inventory SKU equality + warehouse order;30 行 300000 行,30 warehouses,现有 warehouse-first PK reverse covering B-tree
search generated tsvector @@ tsquery;100 行 100000 行,同一 simple config GIN
event 10 分钟 timestamptz range;600 行 400000 行,物理时间相关 BRIN;另建 B-tree 对照

原始查询分别在:

候选由 create-candidates.sh 以独立的 CREATE INDEX CONCURRENTLY 创建:

-- 订单:状态在 predicate,customer/time 为 key,窄返回列为 payload
CREATE INDEX CONCURRENTLY ch09_order_placed_cover_idx
ON shop_private.ch09_order_probe
    (customer_id, placed_at DESC)
INCLUDE (order_no, amount_minor)
WHERE order_status = 'placed';

-- 库存:反转已有 PK 的查询方向
CREATE INDEX CONCURRENTLY ch09_inventory_sku_cover_idx
ON shop_private.ch09_inventory_probe
    (sku_id, warehouse_id)
INCLUDE (available, reserved, updated_at);

-- 搜索:query 与 generated document 使用相同全文语义
CREATE INDEX CONCURRENTLY ch09_search_document_gin_idx
ON shop_private.ch09_search_probe
USING gin (search_document);

-- 事件:对物理相关范围保存 block summary
CREATE INDEX CONCURRENTLY ch09_event_occurred_brin_idx
ON shop_private.ch09_event_probe
USING brin (occurred_at)
WITH (pages_per_range = 32, autosummarize = on);

创建后对专属 fixture 做 VACUUM (ANALYZE),再捕获 after。这里 vacuum 是为了制造稳定的 all-visible 教学条件;生产 index-only 收益必须按真实 autovacuum/churn 复测。

候选要允许被拒绝

event B-tree 也是实际创建的 candidate:

CREATE INDEX CONCURRENTLY ch09_event_occurred_btree_idx
ON shop_private.ch09_event_probe (occurred_at);

脚本保存它的 plan 与 size 后,证明 declared workload 已由小得多的 BRIN 满足,于是按 exact table/index identity 执行:

DROP INDEX CONCURRENTLY
    shop_private.ch09_event_occurred_btree_idx;

“创建成功并被使用”不等于必须保留。能输出 reject 且清理干净,是索引设计实验的重要能力。

9.6.2 在 Pigsty L1 保留、合并或拒绝并记录证据

一键运行完整闭环

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

./task.sh all

执行顺序:

manifest + ch05 preflight
  → marker-guarded fixture rebuild
  → four before plans
  → four retained candidates + VACUUM
  → after + custom/generic plans
  → event B-tree comparison and exact rejection
  → HOT/WAL twin updates
  → concurrent unique failure and exact recovery
  → final fixture/catalog/worker/model verify
  → semantic analyzer + rule-proposal validation

PG36_EVIDENCE_DIR 应使用每次唯一的目录。脚本以 umask 077 创建证据,不覆盖旧结果;manifest.txt 保存 server/client/Python 版本、target identity 与所有 source hash。

一次 PostgreSQL 18.6 实测输出:

status=ok
order=partial-covering/index-only/custom:true/generic:false/rows:10
inventory=reverse-covering/heap-fetches:0/rows:30
search=gin/rows:100
event=brin-retained/btree-rejected/size-fraction:0.00273
write=hot:1.0->0/wal:11257432->11631392
concurrent=23505/invalid-observed/exact-drop/remaining:0
decisions=retain:4/reject:4
proposal=0.1.0->0.4.0/PREF-PLAN-005/depends-on-v0.2+v0.3
final=workers:0/rejected:0/checksum:f8a7bfae59c6d16cd323abecfefe1014

不要把 WAL 字节、cost、elapsed、buffer 个数或 exact node tree 当 golden。分析器只断言:

  • 结果行数与语义不漂移;
  • literal/custom 能用 partial,generic status parameter 不能;
  • 订单/库存 after 是 index-only 且 heap fetch 为 0;
  • 搜索使用专属 GIN;
  • event BRIN/B-tree 都能返回 600 行,BRIN 至少小一个数量级;
  • unindexed volatile update 有高 HOT ratio,indexed twin 为 0 且 WAL 更多;
  • unique concurrent build 以 23505 失败,INVALID 被观察并精确删除;
  • rejected index=0、worker=0、业务 checksum 不变。

PG18 before inventory 在本机使用 warehouse-first PK skip scan。分析器记录该事实但不要求 exact node:PG14–17 没有该能力,PG18 也可能因 cost/data 改选其他路径。

读、写、失败三类 artifact

证据目录包含:

manifest.txt
preflight.txt
setup.txt

order-before.json
order-after.json
order-custom.json
order-generic.json
inventory-before.json
inventory-after.json
search-before.json
search-after.json
event-before.json
event-brin.json
event-btree.json

catalog-before-rejection.csv
catalog-final.csv
write-base.json
write-indexed.json
write-stats.csv

concurrent-failure/
  create.stdout
  create.stderr
  invalid-index.csv
  drop.stdout
  drop.stderr
  summary.txt

verify.txt
index-summary.json
index-summary.txt

raw plan/catalog 用于复核,index-summary.json 用于稳定关系,不能只保留最后一行 status=ok

4 个保留,4 个拒绝

index-decisions.json 为每个结论绑定 query 和 artifact:

candidate 结论 依据
order partial covering retain literal workload 稳定、Top-N、index-only;同时记录 generic 边界
inventory reverse covering retain SKU-first 是 declared access path,返回 30 个 warehouse 且 heap fetch=0
search document GIN retain document/query 使用相同 simple config 与 @@ 语义
event occurred BRIN retain 物理相关范围可用,约为 B-tree 大小的 0.27%
event occurred B-tree reject 对同一 workload 的额外精度/空间不值
warehouse-only B-tree reject 现有 PK 已以 warehouse 为左前缀
generic attributes JSONB GIN reject 没有 declared containment consumer,却会索引大量 token
volatile counter B-tree reject 无读取消费者,HOT 1→0 且 WAL 增加

本 fixture 没有需要 merge 的 pair,但生产 ledger 应允许 merge:例如一个较完整候选在验证后替代两个真正重叠索引。merge 仍需先排除 constraint/replica identity,并验证所有 consumer,不能只做字符串前缀比较。

在 Pigsty 中复核相同关系

实验运行时或生产 shadow 验证时,在同一 UTC 窗口观察:

Query:
  calls, rows, mean/tail, shared/temp blocks, WAL

Table/Index:
  seq/index scans, tuple fetch, relation/index size,
  HOT/update/vacuum behavior

Instance:
  CPU, load, memory, disk IOPS/latency, checkpoint

Replication:
  WAL rate, archive status, receive/replay lag

Session/Lock:
  build application_name, phase, waits, long transactions

面板结论回链 manifest + plan JSON + catalog CSV + query identity。L1 的“retain”是机制验收,不自动授权生产上线;生产必须另建时间窗、磁盘/replica 水位、审批与回退。

9.6.3 将索引审查规则追加到规约

从“应验证计划”升级为可运行规则

baseline-v0.4-proposal.json 不改写第 6 章的不可变 v0.1 baseline,而是为 PREF-PLAN-005 追加 candidate evidence:

索引候选必须绑定真实 query/parameter/order/return shape;
保存 before/after plan、结果、buffers、大小、写/HOT/WAL 代价;
记录 concurrent build 失败回收;
明确 retain/merge/reject。

提案绑定三层 provenance:

base:
  0.1.0 canonical checksum

dependencies:
  ch07 v0.2 proposal canonical checksum
  ch08 v0.3 proposal canonical checksum

candidate:
  0.4.0 / PREF-PLAN-005 / ch09 evidence paths

analyze_indexes.py 每次 all/review 都重新 canonicalize JSON 并核对 checksum、依赖顺序、rule id 和 artifact existence。只改 proposal 中的声明、不同步依赖内容会 fail;这避免一条后续规则悄悄引用已漂移的前章证据。

它仍标记为:

{
  "candidate_baseline": "0.4.0",
  "status": "candidate"
}

只有以下条件完成后才可晋升:

  1. PostgreSQL 14–18 compatibility matrix 通过并保留 PG18 skip-scan 差异;
  2. 至少一个真实 Pigsty workload 窗口复测 read/write/build 水位;
  3. 先审查并晋升依赖的 v0.2/v0.3;
  4. 生成新的不可变 release artifact 和 canonical checksum。

章节成功不等于治理基线已发布。

reset 需要两个独立确认

all 最终保留七个 ch09 fixture 和四个 retained candidate,便于复核。若要删除,只在已确认的 L1 执行 R2:

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

export PG36_RESET_TOKEN=RESET_CH09_INDEX_LAB
export PG36_RESET_TARGET=pg36_shop/shop_private/ch09
./task.sh reset

两个 token 各自防一类误操作:

  • action token 证明调用者明确要求 reset;
  • target token 绑定 database/schema/chapter。

脚本还会逐一核对 marker;同名异物存在时,即使 token 正确也拒绝。成功后必须看到:

status=ok
reset_target=pg36_shop/shop_private/ch09
remaining_ch09_relations=0

并再次运行 ch05 verification,业务 checksum 仍为:

f8a7bfae59c6d16cd323abecfefe1014

负向验收同样重要:空 token、错 action token、错 target、无 service file 和非法 action 都必须非零退出,并保持对象与业务 checksum 不变。

最终复现清单

# shell/Python/JSON 静态检查
bash -n static/labs/ch09/*.sh
PYTHONPYCACHEPREFIX=/tmp/pg36-pycache \
  python3 -m py_compile static/labs/ch09/analyze_indexes.py
python3 -m json.tool static/labs/ch09/index-decisions.json >/dev/null
python3 -m json.tool static/labs/ch09/baseline-v0.4-proposal.json >/dev/null

# 完整机制验收
export PGSERVICEFILE=/absolute/private/path/pg_service.conf
export PGSERVICE=pg36-admin
export PG36_EVIDENCE_DIR="$PWD/evidence/ch09/final-$(date -u +%Y%m%dT%H%M%SZ)"
static/labs/ch09/task.sh all

# 独立状态验收
static/labs/ch09/task.sh verify

验收通过后,团队得到的不是“索引速查表”,而是一套可以迁移到真实 query review 的方法:先证明 operator/predicate/order,再证明收益覆盖写入与生命周期成本,最后让 retain/reject 都有可审计证据。


上一节:验证而不是“加完就快” · 返回本章目录 · 下一章:顾此失彼:并发控制与隔离异常 · 查看全书目录 · 查看索引中心

10 顾此失彼:并发控制与隔离异常

单条 SQL 正确、单个事务顺序执行正确,不代表并发执行仍正确。并发控制的起点不是先选隔离级别,而是写出业务不变量和允许的失败语义:

business invariant
  → concurrent read/write set and possible interleavings
  → snapshot/isolation guarantee
  → atomic SQL, row lock, optimistic CAS, SSI or advisory coordination
  → retryable SQLSTATE + whole-transaction replay
  → idempotency and external-effect protocol
  → lock/error/latency evidence
  → two-connection invariant test

PostgreSQL 的 Read Committed、Repeatable Read 与 Serializable 不是“性能低、中、高”的旋钮。它们允许或拒绝的交错不同;拒绝通常表现为需要应用处理的 40001,不是数据库自动把原事务重新执行。

本章目标

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

  • 区分 Read Committed 的语句快照与 Repeatable Read 的事务快照;
  • 知道 PostgreSQL 的 Read Uncommitted 实际等同 Read Committed;
  • 解释 PostgreSQL Repeatable Read 是 snapshot isolation,仍允许 write skew;
  • 说明 Serializable Snapshot Isolation 如何用 SIReadLock 识别危险依赖;
  • 40001 理解为“整个事务必须重放”,而非重试最后一条 SQL;
  • 用确定性交错重现 read–compute–write lost update;
  • SET col = col - $1 WHERE ... 做原子条件更新;
  • 用 version compare-and-swap 识别并处理乐观冲突;
  • 区分 FOR UPDATEFOR NO KEY UPDATEFOR SHAREFOR KEY SHARE
  • 正确选择 NOWAITSKIP LOCKED,并说明后者为何只适合 queue-like workload;
  • 建立统一 lock order,识别 40P01 并回放整个事务;
  • 区分 session-level 与 transaction-level advisory lock 生命周期;
  • 设计 advisory key namespace、owner、timeout 与释放协议;
  • 使用 pg_stat_activitypg_lockspg_blocking_pids() 保存精确阻塞图;
  • 用 Pigsty 的 Activity/Xacts/PGCAT Locks/日志时间窗量化并发问题;
  • 设计 payment idempotency key、payload fingerprint 与响应复用;
  • 用 transactional outbox 跨越数据库 commit 与外部消息边界;
  • 把隔离、重试、幂等与外部副作用要求追加到 DEFAULT-TXNN-007

实验边界

实验基线为 PostgreSQL 18.6、Pigsty v4.5.0、Ubuntu 24.04 L1;主体机制保持 PostgreSQL 14–18 可用。只创建六张带固定 marker 的表:

ch10_inventory        two SKUs / available=100 / version=0
ch10_doctor           two on-call doctors
ch10_deadlock_probe   two lock-order rows
ch10_job              six queued jobs
ch10_payment_request  idempotency authority
ch10_outbox            committed external-effect intent

协调器使用 advisory 两整数 key space:

(3610, 1001..1016)

它只控制教学 interleaving,不参与业务正确性。每个 case 后必须 worker=0、barrier lock=0。setup 在 marker 匹配后重建,属于 R1;reset 属于 R2,需要 action/target 双 token。

下载资产:

本章目录

10.1 隔离级别与可观察现象

10.2 Lost update 不是一句口号

10.3 悲观锁与锁队列

10.4 乐观控制、重试与幂等

10.5 咨询锁与跨行协调

10.6 观察与诊断并发

10.7 实战:库存扣减与支付幂等

实测摘要

一次 PostgreSQL 18.6 全量验收得到:

lost:
  both read 100 / requested total 30
  serial expected 70 / actual 80 or 90

safe writes:
  atomic → 2 successes / final 70 / version 2
  optimistic → first 1 success + 1 conflict
               whole retry 1 success / final 70 / version 2

isolation:
  Repeatable Read same-row update → 1 commit + one 40001
  Repeatable Read write skew      → 2 commits / on-call 0
  Serializable write skew         → SIReadLock observed
                                    1 commit + one 40001 / on-call 1

locks:
  NOWAIT=55P03
  deadlock=one 40P01 / survivor leaves rows [1,1]
  SKIP LOCKED=two workers × 3 / duplicate 0
  row lock=one blocker edge / waiter sees 90 / final 70

idempotency:
  concurrent requests 2 / inserted 1 / reused 1
  payment 1 / outbox 1 / distinct response 1
  same key different payload=P0001 / state unchanged

final:
  worker=0 / advisory barrier=0
  relation checksum=f8a7bfae59c6d16cd323abecfefe1014

胜出事务、PID、XID、backend_start、lost-update 最终是 80 还是 90 都不是 golden。稳定断言是允许/拒绝的交错、SQLSTATE、多连接关系、业务不变量和最终清理。

章节验收

  1. 每个并发写先声明 invariant、read set、write set 与 failure contract;
  2. Read Committed 的两条普通 SELECT 可见不同已提交状态;
  3. UPDATE SET x=x+... 与 application read–compute–write 的语义差别明确;
  4. optimistic zero-row update 被当冲突,而非成功;
  5. Repeatable Read concurrent row update 以 40001 拒绝;
  6. Repeatable Read write skew 反例被实际重现;
  7. Serializable 的 SIReadLock 和 40001 都有 raw evidence;
  8. 整事务 retry 有 attempt/time budget、backoff/jitter 与新 snapshot;
  9. row lock mode 与 foreign-key key update 边界正确;
  10. NOWAIT 55P03、SKIP LOCKED queue-only 边界明确;
  11. 所有多行写有统一 lock order,40P01 仍被整事务处理;
  12. advisory key namespace/lifetime/owner/timeout 可审计;
  13. 阻塞动作使用 PID + backend_start + database + application identity;
  14. Pigsty 时间窗能落回 exact blocker graph、SQLSTATE 与 transaction age;
  15. idempotency key 绑定 payload fingerprint 与 response;
  16. 相同 key 不同 payload 必须拒绝;
  17. 数据库事务内只写 outbox,不调用远程支付/消息;
  18. task.sh all、双 token reset 与错误 token 反例均通过;
  19. 最终 worker/advisory=0,业务 checksum 不变;
  20. v0.5 仍是有依赖的 candidate,不冒充已发布 baseline。

下一章 ch11《守正出奇:模式变更与安全发布》 将把本章的锁、事务与兼容语义应用到真实 DDL 发布。

参考资料


上一章:巧夺天工:索引设计与效果验证 · 返回上卷导读 · 下一章:守正出奇:模式变更与安全发布 · 查看全书目录 · 查看索引中心

10.1 隔离级别与可观察现象

隔离级别描述“并发事务成功提交后允许出现什么结果”,不是给单条查询加一层缓存。PostgreSQL 的实现关系是:

请求级别 PostgreSQL 实际语义 普通快照 仍可能发生
Read Uncommitted 等同 Read Committed 每条语句 nonrepeatable read、phantom、serialization anomaly
Read Committed 默认级别 每条语句 同上
Repeatable Read snapshot isolation 首个非事务控制语句取得事务快照 serialization anomaly,例如 write skew
Serializable Serializable Snapshot Isolation 事务快照 + read/write dependency 检测 事务可能以 40001 被拒绝

PostgreSQL Repeatable Read 比 SQL 标准最低要求更强:它不允许 phantom read;但“看见稳定快照”仍不等于“所有成功事务可排成某个串行顺序”。

10.1.1 Read Committed 的语句快照

每条普通查询重新取 snapshot

默认 Read Committed 下,一条普通 SELECT 看见:

  • 该语句开始前已提交的数据;
  • 当前事务自己先前的写入;
  • 看不见其他事务未提交的写入;
  • 看不见该语句执行过程中才提交的新版本。

同一事务中的下一条 SELECT 会取得新 snapshot,因此可能看到并发提交:

T1                                      T2
BEGIN;                                  BEGIN;
SELECT available;  -- 100
                                        UPDATE ... SET available=90;
                                        COMMIT;
SELECT available;  -- 90
COMMIT;

这不是“不可重复读 bug”,而是 Read Committed 的合同。需要一个稳定跨语句视图时,要重新设计事务、锁或隔离级别,而不是假设 BEGIN 自动冻结所有读。

查看当前事务设置:

SHOW transaction_isolation;
SELECT current_setting('transaction_isolation');

显式设置应在 transaction 第一条 query 前:

BEGIN ISOLATION LEVEL READ COMMITTED;
-- work
COMMIT;

不能先执行业务查询再把当前事务切到更高隔离级别。

写语句会等待并重新检查目标行

UPDATEDELETESELECT ... FOR UPDATE/SHARE 搜索候选时使用语句 snapshot,但候选行可能已被并发事务修改。PostgreSQL 会等待先行 writer:

先行事务 rollback
  → 后行事务可处理原版本

先行事务 commit update
  → 后行事务在新版本上重新检查 WHERE
  → 仍满足才执行

先行事务 commit delete
  → 后行事务跳过该行

这解释了为什么原子条件更新安全:

UPDATE inventory
SET available = available - $2
WHERE sku_id = $1
  AND available >= $2
RETURNING available;

若另一事务先扣减并提交,后行 UPDATE 会在最新 row version 上重新检查 available >= $2,不会拿语句开始时的旧值硬算。

但它也意味着一条复杂 Read Committed 写语句可能观察到“目标行的新版本”,却看不见同一并发事务在其他行的变化。对预先确定的单行原子写通常正合适;对跨行不变量必须更谨慎。

snapshot 不覆盖所有数据库对象

sequence 的变化立即对其他事务可见,且 abort 不会回滚:

SELECT nextval('order_id_seq');
ROLLBACK;

被取走的值不会“归还”。因此序列 gap 不是事务隔离失败,ID 连续性也不应作为业务不变量。外部 API、文件、消息系统同样不受 PostgreSQL snapshot/rollback 管理。

10.1.2 Repeatable Read 的事务快照与写冲突

snapshot 从首个真正语句开始

Repeatable Read transaction 看见首个非事务控制语句开始前已提交的数据,之后普通查询保持相同视图:

T1 (RR)                                  T2
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT available;  -- snapshot=100
                                         UPDATE ... SET available=90;
                                         COMMIT;
SELECT available;  -- 仍为 100
COMMIT;

事务开始的 wall-clock 时刻不一定是 snapshot 时刻;只执行 BEGIN 后长时间空闲,再执行首个 query,snapshot 才建立。诊断时同时看 xact_startquery_startstatebackend_xmin,不要把它们混成一个时间。

修改 snapshot 后已被改过的行会拒绝

若 RR 事务想更新/锁定一个在 snapshot 建立后被其他事务实际更新或删除并提交的 row,PostgreSQL 不会把旧计算静默覆盖到新版本,而是:

SQLSTATE 40001
could not serialize access due to concurrent update

本章两个 worker 都先读 available=100,再分别准备写 90 和 80。协调屏障放行后:

one transaction commits
the other exits 40001
final = 90 or 80 / version=1

哪个事务赢不是合同;“一提交、一拒绝、无静默覆盖”才是。

应用必须 ROLLBACK 并从 BEGIN 前重放整个逻辑。只重试失败的 UPDATE 会继续使用旧 snapshot、旧决策或旧 application state。

稳定快照仍允许 write skew

两名医生都在值班,规则是“至少一人 on call”。两个 RR 事务分别:

T1 reads count(on_call)=2       T2 reads count(on_call)=2
T1 turns doctor 1 off           T2 turns doctor 2 off
T1 commits                      T2 commits

它们写不同 row,没有 same-row write conflict;各自在自己的 snapshot 中都满足规则,最终却是 0。PostgreSQL RR 阻止 phantom,但仍允许这种 snapshot-isolation serialization anomaly。

因此:

“我的事务里连续两次读一样”
“所有提交结果都等价于事务逐个执行”

跨行不变量可以用锁住共同 guard row、锁定完整决策集合、显式 table lock、可验证的数据约束或 Serializable;选择取决于冲突率和模型。

10.1.3 Serializable、谓词冲突与序列化失败

SSI 不把所有读变成阻塞锁

PostgreSQL Serializable 在 Repeatable Read snapshot 上增加 Serializable Snapshot Isolation(SSI)依赖检测。它跟踪:

transaction A read something
transaction B wrote something that would have changed A's result

并分析这些 read/write dependency 是否组成无法串行化的危险结构。必要时拒绝一个事务:

SQLSTATE 40001
could not serialize access due to read/write dependencies among transactions

它不是传统的“所有 predicate read 都阻塞 writer”。SSI predicate lock 在 pg_locks 中显示为:

mode = SIReadLock

这种锁用于依赖检测,不造成常规 blocking,也不参与 deadlock。锁粒度取决于实际 plan:可能是 tuple、page 或 relation;资源紧张时还会提升到更粗粒度。因此索引/计划会影响 predicate-lock footprint 和 abort rate,但不能为了减少 40001 就盲目强制 index scan。

本章 Serializable 医生 case 在两个事务都读到 on_call=2 后捕获:

pg36-ch10-write-skew-ser-a / relation / SIReadLock / ch10_doctor
pg36-ch10-write-skew-ser-b / relation / SIReadLock / ch10_doctor

随后一事务提交,另一事务 40001,最终仍有一人值班。SSI 保证的是成功提交的集合可串行化;被 abort 事务里读到的任何结果都不能对外生效。

40001 是正常控制流,但不是无限重试许可

正确 handler:

BEGIN new transaction
  → obtain new snapshot
  → re-read every decision input
  → recompute
  → redo only idempotent/database-contained effects
COMMIT

还必须有:

  • max attempts;
  • total elapsed deadline;
  • exponential backoff + jitter;
  • cancellation/request deadline;
  • retry/abort metrics;
  • final error contract;
  • idempotency key;
  • 对外部副作用的隔离。

高 40001 比率不是“把 attempts 调大”。它可能表示事务太长、连接过多、热点冲突、predicate lock 过粗或数据模型缺少更自然的协调点。

除 40001 外,文档建议在某些应用中也把 40P01 deadlock failure 作为 whole-transaction retry 候选;23505/23P01 有时也可能与 serializable interleaving 有关,但它们通常首先是业务冲突,只有应用能基于完整协议判断是否重试。禁止“所有数据库错误都重试”。

read-only deferrable 的特殊用途

长时间一致性报表可以显式:

BEGIN TRANSACTION
ISOLATION LEVEL SERIALIZABLE
READ ONLY
DEFERRABLE;

它可能在开始读取前等待一个已证明安全的 snapshot;一旦取得,便可避免 serialization failure。它适合能接受启动等待的只读批处理,不适合低延迟请求,也不能包含写入。

选级别的顺序

不要从“统一把数据库设成 Serializable”开始。逐个 transaction family 记录:

问题 示例答案
invariant available 不得为负;至少一名医生值班
decision read set SKU row;所有 on-call rows
write set 同一 SKU;各自 doctor row
acceptable blocking 20 ms / 不允许
acceptable abort 可 40001 重试 3 次 / 不可
external effect 无 / payment API + message
chosen mechanism atomic update / Serializable + outbox

隔离级别只有与这张合同绑定,才是工程决定。

延伸阅读


返回本章目录 · 下一节:Lost update 不是一句口号 · 查看全书目录 · 查看索引中心

10.2 Lost update 不是一句口号

“并发 UPDATE 会丢更新”不准确。下面两条 SQL 的并发语义不同:

-- server-side read-modify-write:后行 writer 在最新 row version 上计算
UPDATE counter
SET value = value + 1
WHERE id = $1;

-- application 把旧绝对值写回来:可能覆盖另一个已提交结果
SELECT value FROM counter WHERE id = $1;  -- application computes 101
UPDATE counter SET value = 101 WHERE id = $1;

Lost update 不是看到两个 writer 就贴上的标签;要画出 read、compute、write 及它们之间允许的 interleaving。

10.2.1 读—算—写在 Read Committed 下如何丢更新

最小反例

库存初值 100,两个请求分别扣 10 和 20:

T1                                      T2
BEGIN RC;                               BEGIN RC;
SELECT available;  -- 100               SELECT available;  -- 100
application computes 90                 application computes 80
UPDATE SET available=90;
COMMIT;
                                        UPDATE SET available=80;
                                        COMMIT;

最终 80,T1 的扣减消失;若写入顺序相反,最终 90,T2 的扣减消失。正确串行结果应是:

100 - 10 - 20 = 70

PostgreSQL 确实让第二个 UPDATE 等待第一个 row lock,但第二条 SQL 的意思是“写绝对值 80”,不是“从提交后的当前值再减 20”。数据库忠实执行了错误合同。

常见来源:

  • ORM load entity → 修改字段 → save all columns;
  • HTTP GET 旧 representation → PUT 覆盖;
  • cache 中取旧 aggregate 再写回;
  • 前端 hidden form 带旧 version,却没放进 WHERE
  • worker 先读状态,长时间调用外部 API,再写“成功”;
  • 同一对象多个字段被不同功能全行覆盖。

不能用“最后写入者胜出”掩盖需要累计/合并的业务语义。

确定性实验,而不是靠 sleep

本章 lost-update-worker.sql 让两个真实 backend:

  1. 都在 Read Committed transaction 中读取 100;
  2. 各自计算 90/80;
  3. 都在 (3610,1001) advisory barrier 上等待;
  4. controller 确认两个 wait_event=advisory
  5. 放行后按任意顺序写绝对值并提交。

一次结果:

{
  "both_observed": 100,
  "requested_total": 30,
  "serial_expected": 70,
  "actual": 80,
  "lost_update_observed": true
}

另一次可能为 90。golden 是 actual ∈ {80,90} 且不为 70,不是胜者名字。barrier 只固定“两边都先读旧值”这一关键关系。

行内 CHECK 是最后防线,不会恢复丢失语义

CHECK (available >= 0)

能阻止负库存版本提交,却不能发现“两个合法绝对值中一个覆盖另一个”。如果 90 和 80 都合法,constraint 无从知道业务本想累计扣 30。约束、并发协议和幂等分别解决不同层次:

CHECK          → 单个新 row 是否在值域内
atomic/CAS     → 并发写是否基于正确版本
idempotency    → 同一业务请求是否只产生一次效果

三者经常同时需要。

10.2.2 原子更新与带版本条件的更新

首选把简单不变量压进一条 SQL

库存扣减可写成:

UPDATE inventory
SET available = available - $2,
    version = version + 1,
    updated_at = clock_timestamp()
WHERE sku_id = $1
  AND available >= $2
RETURNING available, version;

Read Committed 下两个 writer 针对同一 row:

  1. 一个取得 row lock 并更新;
  2. 另一个等待;
  3. 先行事务提交后,后行事务在新 row version 上重新判断 available >= $2
  4. 满足则从新值继续扣;不满足则影响 0 行。

本章两个请求都满足,结果:

successful writes=2
final available=70
version=2

若库存不足,row_count=0 是业务结果,不是数据库故障。API 要区分:

row returned     → 扣减成功
zero rows        → SKU 不存在或库存不足,需要再查询/细分合同
SQLSTATE error   → transaction 失败
connection lost  → commit outcome 可能未知

若“SKU 不存在”和“库存不足”必须不同响应,可在同事务中做后续只读,或用 function 返回结构化 outcome;不要先无锁查询再假设状态没变。

version compare-and-swap

当 application 必须基于多个字段/复杂规则计算新状态时,用 version 把旧 snapshot 变成显式前置条件:

SELECT available, version
FROM inventory
WHERE sku_id = $1;

-- application computes new_available

UPDATE inventory
SET available = $new_available,
    version = version + 1
WHERE sku_id = $1
  AND version = $observed_version
  AND available >= $quantity
RETURNING available, version;

两个请求都读 version=0 后,只有一个能影响 1 行;另一个得到 0 行:

first round:
  success=1
  optimistic conflict=1
  final=80 or 90 / version=1

loser starts a new transaction:
  re-read value/version
  recompute
  conditional update=1
  final=70 / version=2

version 列本身不提供保护。Lost-update case 也递增了 version,但没有在 WHERE 比较旧版本,因此两个绝对写都成功。CAS 的关键是:

SET version = version + 1
AND WHERE version = observed_version
AND caller treats zero rows as conflict

不要用 system column xmin 代替长期 API version:它受 vacuum/freeze、wraparound、导入和物理生命周期影响,不是业务稳定 token。显式 bigint version 更容易测试和传入 ETag/If-Match。

选择 atomic、CAS 还是 row lock

机制 适合 冲突表现 主要代价
单条原子条件 UPDATE 单行算术/状态转移能写进 SQL zero rows 或等待后成功 SQL 表达复杂度
version CAS 计算在 application,冲突通常少 zero rows,应用重算 失败工作浪费、重试
SELECT FOR UPDATE 必须在锁住当前版本后做多语句数据库决策 blocking/NOWAIT lock queue、长事务
Serializable 跨行 predicate invariant 40001 whole retry SSI overhead/abort

“乐观一定快”与“悲观一定安全”都不成立。热点冲突下 CAS 反复失败可能比短 row lock 更贵;row lock 包住远程 API 则会把外部延迟放大为数据库队列。

10.2.3 更高隔离级别何时拒绝而不是静默覆盖

Repeatable Read 拒绝 same-row stale write

把 lost-update 两个事务改为 Repeatable Read:

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT available FROM inventory WHERE sku_id = 1001;
-- both snapshots see 100
UPDATE inventory SET available = $computed WHERE sku_id = 1001;
COMMIT;

第一个提交后,第二个不能在旧 snapshot 中修改该 row:

one commit
one SQLSTATE 40001
final 80 or 90 / version=1

这把静默错误转换成显式失败,但业务操作仍未完成。没有正确 retry,用户只会看到 500;若只重试最后一条 UPDATE,旧计算仍不可信。

Serializable 解决 predicate anomaly,不替代重试

RR 对不同 row 的 write skew 不报错;Serializable 才跟踪“双方都读取 on-call predicate,随后各写一行”的危险结构。它会让一个事务 40001,使成功集合保持至少一人 on call。

但 Serializable 不承诺:

  • 没有 blocking;
  • 没有 deadlock;
  • 每个 transaction 都成功;
  • 自动重试;
  • 外部 API 自动幂等;
  • 错误的单事务业务逻辑变正确。

Serializable 只保证:成功提交的事务效果可等价于某个串行顺序。单独运行就会扣错、重复发消息或遗漏条件的 transaction,在 Serializable 中仍会错。

error taxonomy 先于 retry

应用至少分开:

信号 典型语义 默认动作
zero affected rows CAS conflict / predicate no longer true 业务判断,可能重读
40001 serialization failure rollback whole tx;有界重放
40P01 deadlock victim rollback whole tx;修 lock order,也可有界重放
55P03 NOWAIT/lock timeout 类 lock unavailable 快速失败、排队或稍后重试
23505 unique conflict 多为业务冲突/幂等仲裁,读取 owner row
connection lost commit outcome unknown 用业务 request id 查询,不盲目再执行

SQLSTATE 是机器合同,message text 只用于人类诊断。driver 要保留原始 SQLSTATE 和 transaction state,不能把所有异常扁平成同一个 DatabaseError 后无限 retry。

用并发测试验收,而不是单线程单测

对每个策略至少运行:

two independent connections
same deterministic initial state
barrier before contested write
explicit isolation level
captured SQLSTATE/row count
serial oracle
final invariant query
worker/session/lock cleanup
repeat with either winner

只有这样才能证明它处理的是 interleaving,而不是单线程 happy path。

延伸阅读


上一节:隔离级别与可观察现象 · 返回本章目录 · 下一节:悲观锁与锁队列 · 查看全书目录 · 查看索引中心

10.3 悲观锁与锁队列

悲观锁把冲突变成等待或立即失败:

lock current row/version
  → make database-only decision
  → write
  → commit/rollback releases lock

它适合冲突概率高、临界区短、等待可预算的事务。若锁内包含用户输入、HTTP、支付或消息调用,临界区就不再由数据库控制。

10.3.1 FOR UPDATENO KEY UPDATE 与引用关系

四种 row lock mode

SELECT ...
FROM account
WHERE account_id = $1
FOR UPDATE;

PostgreSQL 有四个强度:

模式 阻止的并发 row lock 典型用途
FOR KEY SHARE FOR UPDATE 保护被引用 key 不被删除/改 key
FOR SHARE FOR NO KEY UPDATEFOR UPDATE 多方读并阻止任何 row update
FOR NO KEY UPDATE FOR SHAREFOR NO KEY UPDATEFOR UPDATE 会改非 key 列
FOR UPDATE 其余四种全部 删除或改变引用身份 key

同一 transaction 不与自己冲突;它后续可以升级 lock。row lock 通常持有到 transaction 结束。若 lock 是在 savepoint 后取得,ROLLBACK TO SAVEPOINT 会释放该 savepoint 后的 lock。

FOR UPDATE 不只是“更强所以更保险”。它会与 foreign-key 检查取得的 FOR KEY SHARE 冲突,可能无谓阻塞只改非 key 的事务。

NO KEY UPDATE 中的 key 指什么

普通 UPDATE 自动获取:

  • 若改变了可用于 foreign key 的唯一 key 列,获取 FOR UPDATE
  • 否则获取 FOR NO KEY UPDATE

这让子表插入检查父 key 时的 KEY SHARE 可以与父行的非 key 更新共存,却不能与删除/改 key 共存。

“可用于 foreign key 的唯一索引”有具体条件;partial unique 和 expression unique 不属于普通 FK target。不要按列名猜自动 lock mode,遇到争议可在目标版本用 pg_locks/阻塞实验验证。

锁行不等于锁业务谓词

SELECT *
FROM doctor
WHERE on_call
FOR UPDATE;

只锁本次查询返回的实际 rows。并发事务仍可能插入另一条满足 predicate 的 row;空结果更是“没有 row 可锁”。需要保护“目前不存在”或范围 predicate 时,选择:

  • unique/exclusion constraint;
  • 锁一条稳定 guard row;
  • 更强 table lock;
  • Serializable SSI;
  • 重新建模为单一 authority row。

不能写 SELECT ... FOR UPDATE 后就声称任意跨行不变量已保护。

行锁与普通读

row lock 不阻塞普通 MVCC SELECT;普通 reader 仍读取合适的 committed version。它阻塞的是会修改/删除/取得冲突 row lock 的事务。只有 ACCESS EXCLUSIVE table lock 会阻塞不带 locking clause 的普通 SELECT

因此“读者没有等”不能证明 holder 没持锁;第 5、8 章都已验证普通 reader 与 waiter 的差别。

10.3.2 NOWAITSKIP LOCKED 与任务领取

等待、立即失败还是跳过是 API 决策

默认 locking clause 等待:

SELECT ...
FOR UPDATE;

立即失败:

SELECT ...
FOR UPDATE NOWAIT;
-- SQLSTATE 55P03 lock_not_available

跳过当前无法立即锁定的行:

SELECT ...
FOR UPDATE SKIP LOCKED;

NOWAIT/SKIP LOCKED 只作用于 row-level lock;查询仍会正常取得 ROW SHARE table lock,若 table lock 冲突仍可能等待。需要 table lock 也不等待时,要显式 LOCK ... NOWAIT 并理解更大影响面。

选择由上层合同决定:

需求 机制
必须按顺序完成,允许等待 默认 lock queue + timeout/SLO
用户请求不能排队 NOWAIT,映射为 busy/conflict
多 worker 从可替代任务池领任意下一批 SKIP LOCKED
必须读取逻辑完整集合 不能用 SKIP LOCKED 隐藏行

SKIP LOCKED 明确返回不一致视图,不适合余额、报表、权限或普通分页。

正确的 queue claim 是锁定并更新同一批

一个常见 pattern:

BEGIN;

WITH picked AS (
    SELECT job_id
    FROM job
    WHERE state = 'queued'
    ORDER BY priority DESC, job_id
    FOR UPDATE SKIP LOCKED
    LIMIT 100
)
UPDATE job AS j
SET state = 'running',
    claimed_by = $worker_id,
    claimed_at = clock_timestamp()
FROM picked
WHERE j.job_id = picked.job_id
RETURNING j.*;

COMMIT;

关键点:

  • pick 与 state transition 在同一短 transaction;
  • 有确定 ordering,但不承诺全局严格公平;
  • (state, priority DESC, job_id) 等候选索引需按第 9 章验证;
  • batch 有上限;
  • worker identity 与 lease/heartbeat 可查询;
  • crash 后有 reaper 将过期 running 恢复或重投;
  • job handler 自身仍需幂等;
  • 结果依赖 RETURNING,不另行猜测领取集合。

本章六个 job:

worker A 先锁 1,2,3 并停在 barrier
worker B 用 SKIP LOCKED 跳过它们,锁 4,5,6
both commit

稳定结果:

two workers × 3
distinct jobs=6
duplicate claims=0

它证明 claim 不重复,不证明任务外部副作用 exactly once。worker 在 commit 后、调用外部系统前后崩溃,仍需要 idempotency/outbox/reconciliation。

ORDER BY 与 locking 的 Read Committed 边界

Read Committed 中,查询可先按 snapshot 排序,再等待某行 lock;等待期间排序列被并发更新后,最终返回顺序可能相对新值失序。若严格按当前值排序并锁定是正确性要求,可以把 locking query 放入子查询,但这可能锁更多行;或提高隔离级别并处理 40001。不要把一个语法改写当无代价修复。

10.3.3 锁顺序、阻塞链与死锁

等待环才是 deadlock

普通 blocking 是一条有根的依赖链:

waiter B → holder A

deadlock 是环:

T1 locks row 1
T2 locks row 2
T1 waits row 2
T2 waits row 1

没有任何事务能自行前进。PostgreSQL 等到 deadlock_timeout 后运行检测,选择一个 victim:

SQLSTATE 40P01 deadlock_detected

victim 的整个 transaction abort;另一事务取得 lock 继续。应用不能假设“自己的第一条 UPDATE 已保留”。

本章两个 worker 先分别锁 row 1/2,再由两个 barrier 同时放行去锁对方。一次稳定结果:

one exit 40P01
one commit
row 1 value=1
row 2 value=1
workers=0

哪个 worker 被选中、检测耗时和 PID 都会变化。

统一 lock order 是首要预防

转账/批量库存等多对象 transaction,应把 key 排序后按相同顺序取得 lock:

SELECT account_id
FROM account
WHERE account_id = ANY($1)
ORDER BY account_id
FOR UPDATE;

所有代码路径、trigger、foreign key cascade 与后台 job 都要遵循同一 order。只修一个 service、另一个 service 反向锁仍会成环。

还要缩短锁持有:

  • 进入 transaction 前完成可安全的输入校验;
  • transaction 内不调用远程 API;
  • 使用合适索引减少被访问/锁定的 rows;
  • 限制 batch;
  • 设置 request、statement、lock、idle-in-transaction timeout;
  • commit/rollback 后再做可重放的外部工作。

lock_timeout 是 statement 等 lock 的预算,不是 transaction deadline;把它全局设得极短会让正常 DDL/写入随机失败。按 transaction family/session 设置,并让应用识别 SQLSTATE。

deadlock 能重试,根因仍要修

如果 transaction 可完整重放,40P01 可以和 40001 一样进入有界 whole-transaction retry。backoff/jitter 能降低再次同时碰撞,但不能替代:

  • 统一 lock order;
  • 减少 transaction scope;
  • 移除外部等待;
  • 热点拆分;
  • 正确索引;
  • 可见的 deadlock log/metric。

若 deadlock 突增,先保存 error detail 中的 process/transaction/SQL 关系和 log_lock_waits 上下文,再改代码。只把 retry 次数从 3 调到 20,会放大数据库负载和用户延迟。

table lock 也可能参与环

所有 DML/DDL 都会自动取得 table-level locks。例如:

UPDATE              → ROW EXCLUSIVE
CREATE INDEX         → SHARE
CREATE INDEX CONCURRENTLY → SHARE UPDATE EXCLUSIVE
ALTER/DROP/TRUNCATE  → 常见 ACCESS EXCLUSIVE

row、transaction ID、relation、advisory 等不同 lockable object 可以共同成环。诊断不能只筛 locktype='tuple'

延伸阅读


上一节:Lost update 不是一句口号 · 返回本章目录 · 下一节:乐观控制、重试与幂等 · 查看全书目录 · 查看索引中心

10.4 乐观控制、重试与幂等

乐观控制不是“不加锁”。UPDATE 和 unique check 最终仍使用 PostgreSQL 并发控制;“乐观”指 application 不预先持有长期 row lock,而在写入时验证前置版本,失败后放弃或重算。

要把三个概念分开:

optimistic concurrency → 旧版本还能不能写?
retry                  → 一个失败事务能不能从头安全重放?
idempotency            → 同一业务请求重放会不会产生第二次效果?

CAS 成功不代表请求不会重复,幂等键存在也不代表任意 transaction error 都该 retry。

10.4.1 版本列、唯一键与条件写入

version 是业务前置条件

DDL:

CREATE TABLE document (
    document_id bigint PRIMARY KEY,
    body jsonb NOT NULL,
    version bigint NOT NULL DEFAULT 0,
    CHECK (version >= 0)
);

读取:

SELECT document_id, body, version
FROM document
WHERE document_id = $1;

条件写:

UPDATE document
SET body = $2,
    version = version + 1
WHERE document_id = $1
  AND version = $3
RETURNING version;

影响一行表示“以我观察到的版本为前提,写入成功”;零行可能是不存在或版本冲突。API 可把 version 暴露为 ETag,并要求 If-Match,但要防止:

  • decoder 丢失/默认 version;
  • ORM UPDATE 不含 version predicate;
  • bulk update 绕过 version;
  • trigger 修改却不递增 version;
  • conflict 被当 200 success;
  • 失败后重复使用旧 application object;
  • version 与 tenant/authorization scope 未一起放入 WHERE

更完整:

UPDATE document
SET body = $body,
    version = version + 1
WHERE tenant_id = $tenant
  AND document_id = $id
  AND version = $expected
RETURNING document_id, version;

authorization predicate 和 concurrency predicate 同时成立,才能写。

unique key 是并发仲裁器

“先查不存在,再插入”有 race:

T1 SELECT none        T2 SELECT none
T1 INSERT             T2 INSERT

真正保证只能有一个 owner 的是 unique constraint/index:

ALTER TABLE payment_request
ADD CONSTRAINT payment_request_idempotency_key
PRIMARY KEY (tenant_id, idempotency_key);

然后用:

INSERT ...
ON CONFLICT (tenant_id, idempotency_key) DO NOTHING
RETURNING ...;

或:

INSERT ...
ON CONFLICT (...) DO UPDATE
SET ...
RETURNING ...;

PostgreSQL 的 ON CONFLICT DO UPDATE 在 Read Committed 下保证每个输入 row 得到 insert 或 update 之一;它不等于业务幂等。若 conflict 分支重复触发审计 trigger、覆盖已完成 response,反而制造第二次效果。

DO NOTHING 也有 snapshot 细节:它可能因另一未在当前 command snapshot 可见的事务结果而不插入。若要读取 winner row,常用两条 statement:

INSERT ... ON CONFLICT DO NOTHING
  → if inserted, create owner result
  → else, next Read Committed statement reads committed owner row

或设计经过验证的 DO UPDATE ... RETURNING,并承担额外 update/trigger/HOT/WAL 语义。不要从网上复制一个“一条 CTE 万能 get-or-create”就默认并发可见性正确。

idempotency key 必须绑定请求语义

表至少保存:

scope/tenant
idempotency key
request fingerprint
operation type
owner aggregate/payment id
terminal/in-progress state
canonical response or response reference
created/expires timestamps

同 key:

  • fingerprint 相同 → 等待/读取同一权威结果;
  • fingerprint 不同 → 明确 conflict,不能把旧 response 交给不同请求。

fingerprint 应基于 canonical request fields,而不是未规范化 JSON 文本、会变化的 header 或 secret。key scope、长度、熵、认证主体、保留期与重用政策必须写进 API 合同。

10.4.2 重试只包围可重放的事务

40001 后从 transaction function 外重来

伪代码:

deadline = request_deadline
for attempt in 1..max_attempts:
    begin a new transaction
    try:
        read all decision inputs
        recompute from the new snapshot
        write database state and outbox
        commit
        return committed result
    catch SQLSTATE in retryable_set:
        rollback
        if attempt/deadline exhausted: return retry_exhausted
        sleep(exponential_backoff_with_jitter)
    catch anything else:
        rollback
        rethrow

retry loop 必须位于 transaction 外。40001 后当前 transaction 已失败;在同一 transaction 里再发 SQL 只会得到 25P02。savepoint 也不能把一个 serializability failure 局部修复成“事务其余部分仍然基于正确 snapshot”。

whole-transaction replay 的原因:

  • 决策 read 已过期;
  • query 结果集合可能改变;
  • generated ID/sequence 可能已消耗;
  • lock order/owner 可能改变;
  • application memory 中保存了旧值;
  • 第一个 attempt 的响应不能对外承诺。

把事务体封装为无共享可变状态的 function,输入只来自 request/idempotency context,通常更容易证明可重放。

retryable set 要窄且有语义

默认候选:

40001 serialization_failure
40P01 deadlock_detected

40P01 重试同时要修 lock order。其他信号要逐案:

  • 55P03:可能按产品语义快速返回 busy,也可能短暂 backoff;
  • 23505:通常是业务 owner 已存在,应读取/返回 conflict;
  • 57014 query_canceled:可能 request 已取消,不能擅自继续;
  • connection failure:commit 结果未知,必须按 idempotency key 查询;
  • syntax/permission/check violation:重试不会变好。

禁止:

catch DatabaseError → sleep → retry forever

它会重放永久错误、越过 request deadline、制造 retry storm。

budget 同时约束尝试数与总时间

例如:

max attempts = 4
max total elapsed = 800 ms
base backoff = 10 ms
cap = 150 ms
jitter = full/randomized

数字由 SLO、冲突率和事务成本决定。至少记录:

  • attempts histogram;
  • success-after-retry;
  • exhausted;
  • SQLSTATE;
  • total retry time;
  • transaction family/query identity;
  • lock/serialization/deadlock rate;
  • request cancellation。

当冲突持续,高 attempt 只把相同热点放大。应转向短 row lock、sharding authority、queueing、批处理或模型重构。

pool/driver 必须保留同一 connection 到结束

一个 transaction 的所有 statement 必须在同一 backend/connection 上。transaction-pooling proxy、异步 driver 和 ORM 需要正确 pin;失败时:

ROLLBACK or discard broken connection
clear local transaction state
do not return idle-in-transaction/failed connection to pool
start retry on a clean transaction

连接断开后不能依据 client 是否收到 COMMIT 响应判断数据库结果。commit 可能已经成功而 ACK 丢失,或根本没提交;业务 idempotency record 才是查询 authority。

10.4.3 支付、消息与外部副作用的边界

数据库不能回滚已经发出的远程请求

危险顺序 A:

BEGIN
  write payment row
  call payment provider  ← provider success
  database ROLLBACK      ← remote charge remains

危险顺序 B:

call provider success
process crashes
database has no durable record
retry calls provider again

把 HTTP 放进 transaction 还会长时间持锁和 snapshot。PostgreSQL two-phase commit 也不会让任意 HTTP/邮件/SaaS 自动加入一个可靠 distributed transaction。

transactional outbox 固定“提交了发送意图”

在同一短数据库 transaction:

BEGIN;

INSERT INTO payment_request (...);

INSERT INTO outbox (
    event_key,
    aggregate_key,
    event_type,
    payload
) VALUES (...);

COMMIT;

然后独立 relay:

claim committed outbox rows
publish with stable event_key
mark delivered / record attempt
retry on failure

数据库原子保证 payment state 与 event intent 同时有/同时无。它不保证 broker 只收到一次:relay 可能 publish 成功后、mark delivered 前崩溃。因此 consumer 也需要 inbox/dedup key 或幂等业务写。

准确表述是:

at-least-once delivery
+ stable event identity
+ idempotent consumer/reconciliation
→ effectively-once business effect within declared scope

不要承诺跨任意系统的神奇 exactly-once。

本章 payment 并发合同

两个 backend 使用:

idempotency_key = idem-order-1001
fingerprint =
  sha256:amount=3000;currency=CNY;merchant=demo

它们各自提出不同 payment ID,在 barrier 放行后并发:

  1. INSERT ... ON CONFLICT (idempotency_key) DO NOTHING
  2. winner 在同一 transaction 写一条 outbox;
  3. loser的下一条 Read Committed statement 读取 winner record;
  4. 两者返回同一个 canonical response。

实测:

requests=2
inserted=1
reused=1
distinct responses=1
payment rows=1
outbox rows=1

让两个 worker 提出不同 payment ID 很重要:idempotency key 才是 arbiter。若同时让另一个 unique payment ID 也相同,冲突可能在错误的 unique constraint 上报 23505,掩盖协议。

随后用同一 key、不同 fingerprint 请求 9999:

SQLSTATE P0001
payment rows remains 1
outbox rows remains 1

P0001 是本实验自定义错误;生产可定义稳定 domain error/SQLSTATE/API 409 contract。核心是不同 payload 不复用旧操作。

in-progress、失败与保留期

真实 payment 还要处理:

owner request still in progress
owner crashed before terminal response
provider timeout with unknown outcome
declined vs retriable provider failure
idempotency record expiry
client retries after expiry
manual reconciliation
refund/compensation

一套常见状态机:

accepted request
  → intent committed
  → provider pending
  → succeeded | declined | unknown
  → reconciled/compensated

同 key caller 读取同一状态,不另起一次 payment。删除 idempotency record 前要保证 provider 和所有下游重投窗口都已过;保留期是财务/合规/容量决定,不是随手 TTL。

延伸阅读


上一节:悲观锁与锁队列 · 返回本章目录 · 下一节:咨询锁与跨行协调 · 查看全书目录 · 查看索引中心

10.5 咨询锁与跨行协调

Advisory lock 让应用给一个整数 key 赋予“资源正在被协调”的含义。PostgreSQL lock manager 只知道 key、shared/exclusive、session/transaction lifetime;它不知道这个 key 是 tenant、invoice、cron job 还是部署。

因此 advisory lock 的正确性来自两部分:

PostgreSQL guarantees mutual exclusion for the same key
+
all participants voluntarily use the same key/lifetime/protocol

任何绕过协议的 SQL 仍可修改底层 rows。

10.5.1 会话级与事务级咨询锁

两种 lifetime

transaction-level:

BEGIN;
SELECT pg_advisory_xact_lock(42);
-- protected database work
COMMIT;  -- 自动释放,不能手工提前释放

session-level:

SELECT pg_advisory_lock(42);
-- protected session work
SELECT pg_advisory_unlock(42);

关键差异:

行为 session-level transaction-level
transaction commit/rollback 继续持有 自动释放
手工 unlock 支持 不支持
同 session 重复 acquire 计数叠加,需同次数 unlock 同事务内不产生额外释放责任
connection 结束 全部释放 当前事务结束时释放
pool 泄漏风险 较低

本章真实验证:

pg_advisory_lock(3610,1015)
  → ROLLBACK
  → lock still granted
  → explicit unlock

pg_advisory_xact_lock(3610,1016)
  → COMMIT
  → lock count=0

session lock 在 transaction rollback 后仍存在不是 bug。若 pool 把同一 backend 交给另一请求,它会继承 lock;重复 acquire 还会 stack。除非保护范围确实跨多个 transaction,并有严格 connection pin/unlock/finally,优先 xact lock。

blocking 与 try 版本

等待:

SELECT pg_advisory_xact_lock($key);
SELECT pg_advisory_xact_lock_shared($key);

不等待:

SELECT pg_try_advisory_xact_lock($key);         -- boolean
SELECT pg_try_advisory_xact_lock_shared($key);  -- boolean

session 级也有 pg_advisory_lock*/pg_try_advisory_lock* 与显式 unlock。shared locks 彼此兼容,但与 exclusive 冲突。

选择与 row lock 类似:

  • 必须串行且允许等待 → blocking + timeout;
  • leader/cron 若已有 owner 就跳过 → try;
  • 多 reader、单 writer → shared/exclusive,但协议更难;
  • transaction 内数据库不变量 → xact lock;
  • 跨事务外部资源 → session lock 只在有明确租约/断连语义时使用。

Advisory lock 也会参与 deadlock detection。两个 transaction 反向取得 advisory keys,同样可能 40P01。

不要让 SQL expression order 偷锁

危险:

SELECT pg_advisory_lock(id)
FROM resource
WHERE id > 12345
LIMIT 100;

SQL 不保证 volatile function 一定在 LIMIT 后只对最终 100 行求值;可能取得超出预期的 locks。先固定子查询:

SELECT pg_advisory_lock(resource.id)
FROM (
    SELECT id
    FROM resource
    WHERE id > 12345
    ORDER BY id
    LIMIT 100
) AS resource;

即使如此,一次取得 100 个 session locks 也需要完整 unlock/failure 设计。更常见的安全选择是逐批 transaction-level lock 或重新设计 queue。

10.5.2 键空间、碰撞与所有权

两种 key 形式互不重叠

PostgreSQL 提供:

one signed bigint
two signed integer values

两个 key space 不重叠。团队应只选一种规范并写出 namespace:

key1 = domain namespace
key2 = stable resource identifier

(100, tenant_id)   tenant maintenance
(200, report_id)   report generation
(300, shard_id)    shard rebalance

本章保留 (3610,1001..1016),所以 fixture cleanup 能精确查:

SELECT *
FROM pg_locks
WHERE locktype = 'advisory'
  AND classid = 3610::oid
  AND objid BETWEEN 1001::oid AND 1016::oid;

pg_locksclassid/objid/objsubid 是 lock manager 编码,不是自带业务字典。runbook 必须能把数字反解为 domain/resource,并保留 application name。

hash 不是无碰撞 identity

把任意字符串压成 64-bit:

hash(tenant || ':' || external_id)

理论上会碰撞。低概率碰撞对“偶尔多串行一次”可能可接受,对“错误资源被授权/跳过”则不可接受。评审:

  • 输入 canonicalization;
  • tenant/domain 是否进入 key;
  • hash algorithm/seed 是否跨语言稳定;
  • collision 的错误方向;
  • 是否能直接使用无碰撞的 numeric ID;
  • mapping 版本升级如何兼容;
  • 同一资源的所有 caller 是否实现一致。

不要使用语言运行时每进程随机化的 hash();不同 process 可能为同一字符串产生不同 key,互斥完全失效。

lock 没有内建 owner metadata

PostgreSQL 记录 backend PID/session、lock mode、key 与 granted,不记录:

业务 owner
request id
lease expiry
why acquired
runbook

这些要来自:

  • unique application_name
  • request/job identity;
  • transaction/session start;
  • companion owner table(若需要 durable lease);
  • structured log/trace;
  • documented key dictionary。

session 断开会释放 lock,因此 advisory lock 不是 durable ownership record。需要“进程死后仍知道谁做到哪一步”的任务系统,应把 state/lease/checkpoint 存表,lock 只协调瞬时竞争。

Advisory locks 使用 shared lock memory,受 max_locks_per_transaction 与连接数规模影响;大量不同 keys 不是免费 distributed cache。不要为每个长期对象永久持锁。

timeout 与 cancellation

blocking advisory function obeys session/statement timeout 与 cancellation。生产 transaction family 应设置:

SET LOCAL lock_timeout = '200ms';
SET LOCAL statement_timeout = '2s';

具体预算由 SLO 决定。timeout 后 transaction 进入 failed state,需要 rollback;不能在同一 transaction 当作“没拿到锁,继续无锁执行”。若产品语义是立即跳过,用 pg_try_advisory_xact_lock() 的 boolean 更清晰。

10.5.3 不用咨询锁掩盖缺失的数据约束

能用 constraint 表达的仍交给 constraint

错误:

acquire advisory hash(email)
SELECT whether email exists
INSERT
release

如果任一 importer、migration、另一个 service 忘记拿 lock,就可产生重复。正确 authority:

UNIQUE (normalized_email)

advisory lock 可以减少预期冲突噪声,却不能替代 unique/exclusion/FK/check。

类似:

不变量 首选
单列/组合唯一 UNIQUE
引用存在 FOREIGN KEY
同房间时间不重叠 EXCLUDE
单行值域 CHECK
version 未变化 conditional UPDATE
queue item 只被一 worker claim row lock + state transition
跨行 predicate 可串行化 Serializable/guard row/模型

Advisory lock 适合数据库没有天然 row 可锁、且不变量难以直接约束的短协调,例如:

  • 每 tenant 同时只运行一个 schema backfill;
  • 同一报表参数只生成一次昂贵结果;
  • cron leader 竞争;
  • 按 external resource ID 协调,但 durable state 仍存表。

所有路径必须加入协议

若决定使用:

API writer
batch/import
admin script
retry worker
migration
repair/reconciliation

都必须调用同一个 key mapping 和 lifetime wrapper。通过受控数据库 function 封装可降低漂移,但仍要:

  • schema-qualify;
  • 固定 search_path;
  • 处理权限;
  • 返回 lock outcome;
  • 约束 timeout;
  • 写测试证明竞争与释放;
  • 保留 constraint 作为可表达不变量的最后 authority。

不在锁内等待外部系统

“先拿 advisory lock,再调用 payment provider”仍会把外部延迟塞进 PostgreSQL lock queue;session 中断还会释放 lock,而 provider 可能已处理。支付应使用 durable idempotency state/outbox/reconciliation,不把 advisory lock 当跨系统 distributed transaction。

若确实协调外部资源,至少有:

durable owner/lease row
fencing token
expiry/renewal
stale-owner recovery
idempotent remote operation
reconciliation

仅有一个 session lock 无法提供 fencing:旧 owner 网络暂停后恢复,可能与新 owner 同时对外部系统操作。

审查问题

每个 advisory lock 提案必须回答:

  1. 为什么 row/constraint/Serializable 不能更直接表达?
  2. key space 与 collision policy 是什么?
  3. shared 还是 exclusive?
  4. session 还是 transaction lifetime,为什么?
  5. 谁是 owner,如何观察?
  6. 等待/try/timeout 语义是什么?
  7. error/rollback/pool release 时怎样保证释放?
  8. 所有写路径如何遵守?
  9. 进程崩溃后 durable state 在哪里?
  10. 是否跨外部系统,fencing/idempotency 如何做?

答不全就不是一个可上线协议。

延伸阅读


上一节:乐观控制、重试与幂等 · 返回本章目录 · 下一节:观察与诊断并发 · 查看全书目录 · 查看索引中心

10.6 观察与诊断并发

并发故障有两个时间尺度:

historical:
  lock/deadlock/rollback/latency metrics + logs + traces

live:
  exact session → wait → blocker graph + transaction age + SQL

指标告诉你“何时、影响多大”,catalog 告诉你“现在谁等谁”。杀会话只会改变 live graph,不会自动解释根因。

10.6.1 pg_stat_activitypg_locks 与等待事件

state 与 wait_event 是两个维度

SELECT
    pid,
    backend_start,
    xact_start,
    query_start,
    state_change,
    datname,
    usename,
    application_name,
    client_addr,
    state,
    wait_event_type,
    wait_event,
    backend_xid,
    backend_xmin,
    query_id,
    left(query, 500) AS query_sample
FROM pg_stat_activity
WHERE backend_type = 'client backend';

state='active' 只表示 backend 正在执行 query;它仍可能:

active + Lock/transactionid  → 等另一事务结束
active + Lock/relation       → 等 table lock
active + Lock/advisory       → 等 advisory key
active + Client/ClientWrite  → server 等客户端读取
active + IO/...              → I/O wait

idle in transaction 则没有正在执行 query,却仍持有 transaction、snapshot 和 locks;它常比一条 active query 更危险。

完整 query/session 信息需要适当监控权限,例如受控 pg_read_all_stats;不要给普通应用 superuser。query text、参数、client_addr 可能含敏感数据,证据包应脱敏并设置保留期。

pg_locks 是 lockable object 明细

SELECT
    lock.pid,
    activity.application_name,
    lock.locktype,
    lock.mode,
    lock.granted,
    lock.fastpath,
    lock.waitstart,
    lock.relation::regclass AS relation_name,
    lock.page,
    lock.tuple,
    lock.transactionid,
    lock.virtualxid,
    lock.classid,
    lock.objid,
    lock.objsubid
FROM pg_locks AS lock
LEFT JOIN pg_stat_activity AS activity
  ON activity.pid = lock.pid
WHERE lock.database = (
          SELECT oid
          FROM pg_database
          WHERE datname = current_database()
      )
   OR lock.database IS NULL
ORDER BY lock.granted, lock.waitstart, lock.pid;

字段按 locktype 才有意义。relation、transactionid、virtualxid、tuple、advisory、object 等可能共同出现。

row-level lock 的常见观察陷阱:holder 的 row locks 通常不逐行显示在 pg_locks;当另一个 transaction 等该 row 时,它经常表现为等待 holder 的 transaction ID:

wait_event_type=Lock
wait_event=transactionid

所以只搜 locktype='tuple' 会漏掉真实 row blocker。

直接使用 pg_blocking_pids()

手工用 pg_locks 所有 nullable identity columns 做 self join 容易错,也难处理 soft blockers。PostgreSQL 提供:

SELECT
    waiter.pid,
    waiter.backend_start,
    waiter.application_name,
    waiter.wait_event_type,
    waiter.wait_event,
    pg_blocking_pids(waiter.pid) AS blocker_pids
FROM pg_stat_activity AS waiter
WHERE cardinality(pg_blocking_pids(waiter.pid)) > 0;

展开成边:

SELECT
    waiter.pid AS waiter_pid,
    waiter.backend_start AS waiter_epoch,
    waiter.application_name AS waiter_app,
    waiter.wait_event_type,
    waiter.wait_event,
    blocker.pid AS blocker_pid,
    blocker.backend_start AS blocker_epoch,
    blocker.application_name AS blocker_app,
    blocker.state AS blocker_state,
    blocker.xact_start AS blocker_xact_start,
    blocker.query_start AS blocker_query_start
FROM pg_stat_activity AS waiter
CROSS JOIN LATERAL unnest(
    pg_blocking_pids(waiter.pid)
) AS edge(blocker_pid)
LEFT JOIN pg_stat_activity AS blocker
  ON blocker.pid = edge.blocker_pid;

LEFT JOIN 很重要:prepared transaction 可能成为 blocker 却没有普通 backend activity row。遇到 blocker PID/活动缺失,应同时查 prepared transactions 和 lock catalog,而不是假设采样坏了。

一次采样只是瞬间

短等待可能在两次查询之间消失。实时事件要:

  • 设置低成本周期采样或 exporter;
  • 保存 UTC timestamp;
  • 保留 session identity epoch;
  • 关联 log/trace/query id;
  • 不因某次 snapshot 为空就否定历史 lock spike;
  • 不用高频全字段 query 把监控本身变成压力。

本章屏障让 row-lock edge 停住,便于可靠捕获;生产没有这种配合。

10.6.2 从 Pigsty 定位锁等待与长事务

从影响面缩到 exact graph

在 Pigsty v4.5 的当前仪表盘体系中,可按以下顺序:

PGSQL Activity
  sessions/load/active-idle/locks overview

PGSQL Xacts
  transaction rate, rollback, locks, transaction time

PGCAT Locks
  current activity and lock waits from catalog

PGSQL Query / PGCAT Query
  affected query family and statistics

PGLOG Overview / Session
  deadlock, lock wait, timeout and SQLSTATE context

PGSQL Persist / Replication
  long snapshot, WAL, replica side effects

仪表盘名称/布局会随版本变化,以当前 Dashboard 文档 为准,不把截图坐标写进 runbook。

常用时间序列包括:

pg_lock_count{mode=...}
pg_db_deadlocks
pg_db_ixact_time
transaction commit/rollback rate
session state/time
query calls/runtime
WAL and replica lag

具体 metric/label 以当前 Pigsty Metrics reference 为准。counter 要用 rate/increase 并注意 reset epoch;deadlocks=0 的瞬时值不能代表历史从未发生。

先固定四个维度

调查窗口至少固定:

  1. cls/ins:哪个 cluster/instance,primary 还是 replica;
  2. datname:哪个 database;
  3. UTC time range:与用户错误/发布窗口对齐;
  4. query/application identity:谁受影响、谁可能持锁。

然后回答:

等待数量/持续时间是否超过 SLO?
是 Lock 还是 Client/IO/其他 wait?
一条 root blocker 还是多条独立冲突?
blocker 是 active、idle in transaction、DDL、autovacuum、
prepared transaction 还是业务 writer?
transaction age 从何时开始?
是否伴随 deployment、batch、schema change、retry storm?

看到 lock count 高不一定是问题:已 granted 的非冲突 locks 很正常。重点是 ungranted wait、阻塞时长、队列扩散和用户 SLI。

40001 不一定在 lock 面板出现

SSI SIReadLock 不造成常规 blocking;serialization failure 可能没有一条长 lock wait 曲线。需要 application/driver 暴露 SQLSTATE 40001 和 retry attempts,并关联:

  • transaction family;
  • abort/success rate;
  • active connections;
  • transaction duration;
  • query plan/predicate lock 粒度;
  • hot key/tenant;
  • deploy/version。

同理,deadlock victim 很快被 abort,live graph 已消失;pg_db_deadlocks 与 PostgreSQL log 才保留历史。

transaction age 是放大器

长 transaction:

  • 持锁更久;
  • 保留 old snapshot;
  • 增加 SSI overlap;
  • 阻碍 vacuum cleanup;
  • 放大 WAL/replication/DDL 等待;
  • 让 retry 代价更大。

因此并发性能优化常常不是改 lock mode,而是把 remote call、用户思考、巨大 batch 移出 transaction,并治理 pool 中 idle in transaction

10.6.3 保存阻塞图,而不是先杀会话

动作前证据包

最小 live artifact:

captured_at UTC
cluster/instance/database
waiter PID + backend_start + user/app/client
waiter state/wait/query/xact/query start
every blocker edge
blocker PID + backend_start + state/query/xact age
relevant pg_locks rows
query_id / normalized query / parameters where safe
deployment/job/request identity
impact/SLO

本章由并发协调器生成的 row-lock-graph.csv,一次关系是:

waiter:
  pg36-ch10-row-lock-waiter
  active / Lock / transactionid

blocker:
  pg36-ch10-row-lock-holder
  active / Lock / advisory

edge count=1

holder 故意等教学 barrier;生产则要问 holder 为何尚未 commit。

找 root blocker,而非随便处理叶子

取消 waiter 只减少一个症状,root blocker 仍可能阻塞几十个请求。应把 graph 沿边向上追到:

no blocker
or cycle/deadlock
or prepared transaction

再按影响与业务 owner 决策。root 也可能是正在执行必须完成的财务事务、migration 或恢复操作;“阻塞最多”不自动等于“应该杀”。

cancel 与 terminate 不同

SELECT pg_cancel_backend($pid);

请求取消当前 query。若 session 在显式 transaction 中,query error 会使 transaction failed,但 client 若不 rollback,仍可能继续占用连接/某些事务资源。

SELECT pg_terminate_backend($pid);

终止整个 backend,未提交 transaction rollback,client 断开。它的影响更大,可能触发应用 retry storm 或留下外部副作用未知状态。

执行前必须重新验证 PID epoch,避免 PID reuse:

SELECT pid, backend_start, datname, usename, application_name
FROM pg_stat_activity
WHERE pid = $pid
  AND backend_start = $captured_epoch
  AND datname = $expected_db
  AND application_name = $expected_app;

还要确认:

  • 是否为 autovacuum/background/replication/system backend;
  • transaction rollback 的业务影响;
  • application 是否会自动 retry;
  • external effect 是否 commit-unknown;
  • 是否有 owner/incident approval;
  • 动作后怎样验收 graph 与数据不变量。

自动化绝不能按 xact_start 最老或 application name 模糊匹配批量 kill。

处理后仍要解释根因

完成止血后保存 after:

edge disappeared
waiter outcomes
rollback/commit
application error/retry
business invariant/reconciliation
remaining workers/locks

再修:

  • transaction scope;
  • lock order;
  • missing index 导致访问过多 rows;
  • queue claim;
  • external call in transaction;
  • timeout/retry storm;
  • DDL 发布方式;
  • leaked pool connection;
  • missing idempotency。

“杀掉 blocker,图空了”只是动作成功,不是问题解决。

延伸阅读


上一节:咨询锁与跨行协调 · 返回本章目录 · 下一节:实战:库存扣减与支付幂等 · 查看全书目录 · 查看索引中心

10.7 实战:库存扣减与支付幂等

本节把并发正确性做成一个机器可验证的矩阵:

same fixture
  × two independent PostgreSQL sessions
  × controlled interleaving
  × explicit isolation/lock strategy
  × SQLSTATE + row count
  × final serial oracle/invariant
  × exact session/advisory cleanup

它不依赖两个终端由人“尽量同时按回车”,也不把 PID、胜者或毫秒写成 golden。

10.7.1 在指定隔离级别重现丢更新和死锁

确认 disposable L1

使用权限受控的 service file:

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

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

只在已确认可写、可重建的 L1/本地目标继续。脚本会再验证 ch04-v1/ch05 rollback-only 合同。没有 service、database 错误、recovery target、effective role/search_path 不符或 marker collision 都 fail closed。

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

为什么结果可重复

每个 worker 的关键顺序:

BEGIN at declared isolation
  → execute the decision read or acquire first row lock
  → wait on a dedicated advisory barrier

controller 查询 pg_stat_activity,确认所有目标 worker:

state=active
wait_event_type=Lock
wait_event=advisory

才释放 barrier。这样固定“都先读 100”“双方各自先锁一行”等关键 happens-before,不固定谁最终赢。

barrier key 只用两整数空间 (3610,1001..1016)。它是 test harness,不是被测业务机制;case 结束后 namespace 必须为 0。

Lost update

两个 lost-update worker 在 Read Committed:

A reads 100, computes 90
B reads 100, computes 80
barrier release
both absolute UPDATE and commit

一次 raw output:

worker=a/observed=100/qty=10/replacement=90
worker=b/observed=100/qty=20/replacement=80

最终:

requested total=30
serial expected=70
actual=80       # 也可为 90
version=2       # 证明“有 version 列”但不用 predicate 仍无保护

Repeatable Read same-row conflict

同一 interleaving 改为 Repeatable Read worker

one commit
one psql exit=3
one stderr SQLSTATE=40001
final=80 or 90 / version=1

审查器只要求一条 40001,不绑定 A/B。

Write skew 与 SSI

两个 doctor 都初始 on call。两个 doctor worker 分别关闭自己:

Repeatable Read:
  both read on_call=2
  commits=2
  final on_call=0
  invariant violated

Serializable:
  both read on_call=2
  SIReadLock rows observed >=2
  commits=1
  SQLSTATE 40001=1
  final on_call=1

raw serializable-siread.csv 保存每个 application 的 lock mode、relation/page/tuple 粒度,不把 relation-level 当所有计划的固定粒度。

Deadlock

两个 deadlock worker

A updates row 1 and waits gate 1011
B updates row 2 and waits gate 1012
controller captures both lock sets
release both
A asks row 2; B asks row 1

稳定断言:

SQLSTATE 40P01=1
commit=1
row values=[1,1]
worker=0

victim 的第一条 UPDATE 被整事务 rollback,幸存者对两行各加 1。

10.7.2 比较原子更新、行锁与可重试事务

四种库存策略的实测对照

策略 两请求首轮 最终 application 责任
RC 绝对值写回 2 commit 80/90,错误 禁止该 pattern
RC 原子条件 UPDATE 2 commit 70/version2 解释 zero rows
RC version CAS 1 success + 1 zero-row conflict 首轮80/90;重算后70/version2 whole operation re-read/recompute
row FOR UPDATE waiter blocking holder 后 waiter 见90,最终70 短事务、timeout、lock order
RR stale row write 1 commit + 1×40001 80/90/version1 whole transaction retry

atomic-update-worker.sql 的核心:

UPDATE shop_private.ch10_inventory
SET available = available - :qty,
    version = version + 1
WHERE sku_id = 1001
  AND available >= :qty
RETURNING available, version;

optimistic-worker.sql 则比较 observed version。首轮 loser 影响 0 行;协调器识别 loser quantity,启动一个全新 transaction 执行重试,最终 70。

行锁还要保存 blocker edge

holder:

FOR UPDATE sees 100
waits advisory barrier while holding row

waiter:

FOR UPDATE blocks

observer 捕获:

waiter application=pg36-ch10-row-lock-waiter
wait=Lock/transactionid
blocker application=pg36-ch10-row-lock-holder
blocker edge=1

放行后 holder 扣 10 并提交,waiter 锁住新版本 90、再扣 20,最终 70。这个 raw graph 证明 blocking 原因,而不是只看最终正确值。

NOWAITSKIP LOCKED 与 advisory lifetime

完整 locking case 还断言:

NOWAIT:
  contested row → SQLSTATE 55P03

SKIP LOCKED:
  worker A holds jobs 1..3
  worker B skips them and claims 4..6
  duplicate=0

advisory:
  session lock survives ROLLBACK until explicit unlock
  xact lock disappears at COMMIT

它们不是互换的优化项:

  • NOWAIT 把排队变成明确失败;
  • SKIP LOCKED 只适合可替代 queue rows;
  • advisory lock 协调 application-defined resource;
  • row lock 保护真实 tuple/version。

Payment idempotency 与 outbox

两个 payment worker 使用同一:

idempotency key=idem-order-1001
request fingerprint=sha256:amount=3000;currency=CNY;merchant=demo

但各自提出不同 payment ID。winner:

insert payment
insert matching outbox
commit

loser的 conflict statement 完成后,用下一条 Read Committed statement 读取 winner response。结果:

concurrent requests=2
inserted=1 / reused=1
distinct responses=1
payment=1 / outbox=1

不同 payload 反例 用同 key 请求 amount 9999,必须 P0001,且两张表计数仍为 1。实验只写 outbox,不调用任何外部系统。

10.7.3 用并发测试验证业务不变量并追加规约

全量 evidence

一次 all

manifest.txt
preflight.txt
setup.txt

lost-*.stdout/stderr
lost-waiting.csv
atomic-*.stdout/stderr
optimistic-*.stdout/stderr
rr-update-*.stdout/stderr
write-skew-*.stdout/stderr
serializable-siread.csv

nowait-*.stdout/stderr
job-*.stdout/stderr
deadlock-*.stdout/stderr
deadlock-before-release.csv
row-lock-*.stdout/stderr
row-lock-graph.csv
advisory-*-gate.stdout/stderr

payment-*.stdout/stderr
concurrency-result.json
verify.txt
review.json
review.txt

manifest.txt 保存 UTC、action、service、client/server/Python version 和所有 source SHA-256。raw artifact 保留动态身份,review 只比较稳定关系。

一次审查摘要:

lost=observed:100+100/expected:70/actual:80
safe=atomic:70/optimistic:1-conflict+1-retry->70
isolation=rr-update:40001/rr-skew:0/serializable:40001->1
locks=55P03/40P01/blocker-edge:1/skip-locked:6-distinct
advisory=session-survives-rollback/xact-released
idempotency=requests:2/payment:1/outbox:1/mismatch:P0001
proposal=0.1.0->0.5.0/DEFAULT-TXNN-007/depends-on-v0.4
final=workers:0/advisory:0/
      checksum:f8a7bfae59c6d16cd323abecfefe1014

DEFAULT-TXNN-007 v0.5 candidate

baseline-v0.5-proposal.json 在原规则“事务只覆盖保持不变量所需的最短边界”上提议追加:

并发写入合同必须声明:
  business invariant
  isolation level or lock strategy
  retryable SQLSTATE
  retry budget
  idempotency key
  external side-effect boundary

提案绑定:

immutable baseline v0.1 canonical checksum
ch09 v0.4 proposal canonical checksum
ch10 source/evidence paths

review.py 每次重算 checksum;依赖漂移、artifact 缺失或 rule id 改变都会 fail。它仍是 candidate,晋升条件包括:

  • PostgreSQL 14–18 compatibility;
  • 至少一个真实 driver/pool 的断连、timeout、重复投递测试;
  • Pigsty L1 下的 abort/lock/tail 指标;
  • 先晋升 v0.2–v0.4 依赖链。

实验通过不冒充治理基线已发布。

Reset 与负向安全

all 保留最终 fixture 供复核。删除属于 R2:

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

export PG36_RESET_TOKEN=RESET_CH10_CONCURRENCY_LAB
export PG36_RESET_TARGET=pg36_shop/shop_private/ch10
./task.sh reset

reset 必须同时:

  • action token 正确;
  • database/schema/chapter target 正确;
  • 六张同名对象的 marker 正确;
  • 没有活跃 pg36-ch10-* worker。

成功:

status=ok
reset_target=pg36_shop/shop_private/ch10
remaining_ch10_relations=0

然后 ch05 verify 再次证明业务 checksum 不变。错误 token、错误 target、无 service、marker collision 或 active worker 都必须非零退出且不删除对象。

静态与最终复现

bash -n static/labs/ch10/task.sh

PYTHONPYCACHEPREFIX=/tmp/pg36-pycache \
  python3 -m py_compile \
    static/labs/ch10/run_concurrency.py \
    static/labs/ch10/review.py

python3 -m json.tool \
  static/labs/ch10/baseline-v0.5-proposal.json >/dev/null

export PGSERVICEFILE=/absolute/private/path/pg_service.conf
export PGSERVICE=pg36-admin
export PG36_EVIDENCE_DIR="$PWD/evidence/ch10/final-$(date -u +%Y%m%dT%H%M%SZ)"
static/labs/ch10/task.sh all
static/labs/ch10/task.sh verify

通过后,团队获得的是可迁移的并发验收模板:每个正确性结论都由至少两个连接、明确 interleaving、机器错误合同和最终不变量共同证明。


上一节:观察与诊断并发 · 返回本章目录 · 下一章:守正出奇:模式变更与安全发布 · 查看全书目录 · 查看索引中心

11 守正出奇:模式变更与安全发布

PostgreSQL 能在事务中执行许多 DDL,但“能回滚”不等于“能在线发布”。一次模式变更能否安全进入生产,至少同时取决于:

logical compatibility
  × lock mode and lock duration
  × scan/rewrite/WAL/resource cost
  × old/new application coexistence
  × resumable data migration
  × observable switch and rollback window
  × explicit contract gate

所以本章不把 ALTER TABLE 写成命令速查。我们把变更拆成一个有状态、有停止线、有证据的发布协议:

legacy
  → expand       add backward-compatible shape
  → migrate      bounded, restartable backfill
  → validate     prove old and new rows satisfy invariants
  → switch       move traffic while retaining rollback shape
  → observe      prove old readers/writers have disappeared
  → contract     remove old semantics only after a separate gate

任何箭头失败,都先问“当前已提交状态是什么、服务是否仍兼容、下一步是重试、暂停、回退流量还是前滚修复”,而不是下意识运行一份机械 down.sql

本章目标

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

  • 从 lock、scan、rewrite、WAL 与 application compatibility 五个维度评审 DDL;
  • 说明元数据 fast path 为什么仍会等待 ACCESS EXCLUSIVE
  • lock_timeout 把无限排队变成可识别的 55P03
  • 区分 non-volatile constant default 与 volatile default 的物理行为;
  • 识别类型变更、约束验证、列删除和表重写的不同恢复语义;
  • 设计 expand–migrate–validate–switch–contract 状态机;
  • 为旧写入、新双写和影子读取定义兼容矩阵;
  • 用 keyset 批次、原子 checkpoint 与停止线实现可中断回填;
  • 解释为什么双写需要单一一致性权威,不能依赖两个独立写调用;
  • 使用 NOT VALID 先保护新写入,再用 VALIDATE CONSTRAINT 检查历史行;
  • 在 PostgreSQL 14–17 与 18 之间正确处理 NOT NULL 目录差异;
  • CREATE INDEX CONCURRENTLY 放在事务块外,并复用第 9 章的失败回收纪律;
  • 预建可证明的 CHECK,降低 ATTACH PARTITION 的验证扫描风险;
  • 说明 ATTACH 对父表和子表的实际锁,以及 default partition 的额外边界;
  • 正确使用 PostgreSQL 14+ 的 DETACH PARTITION CONCURRENTLY
  • 在 Pigsty 中隔离发布连接,观察锁、WAL、复制、磁盘、连接和延迟;
  • 区分 PostgreSQL 原生证据、Pigsty 平台证据与应用发布证据;
  • 拒绝在旧依赖或观察窗口证据缺失时执行 contract;
  • SAFE-MIGR-006DEFAULT-VERS-010 产出 v0.6 candidate evidence。

实验边界

实验基线为 PostgreSQL 18.6、Pigsty v4.5.0、Ubuntu 24.04 L1;主体发布路径面向 PostgreSQL 14–18。所有可重建对象都位于 shop_private,以 ch11_ 开头并带固定 marker,不修改 shop.* 业务表:

ch11_order                 50,000-row legacy order fixture
ch11_migration_state       monotonic phase and backfill checkpoint
ch11_default_probe         constant/volatile default A/B
ch11_default_probe_result  physical and WAL evidence
ch11_event                 range-partitioned parent
ch11_event_2025q1          preloaded standalone attach candidate

订单发布的最终自动验收故意停在:

phase=switched
shipping_code NOT NULL and validated
shipping_method retained
compatibility bridge retained
contract not executed

这是设计结果,不是“少做一步”。本地脚本能证明目录、数据和 SQLSTATE,不能证明生产中的旧容器、离线作业、BI 查询和临时脚本已经退出,也不能把几秒钟等待冒充约定的回退观察期。

下载资产:

本章目录

11.1 识别 DDL 的四类风险

11.2 Expand–Migrate–Contract

11.3 索引与约束的在线化路径

11.4 在线分区化

11.5 数据回填与流量切换

11.6 发布窗口中的平台观察

11.7 实战:无中断演进订单模式

实测摘要

一次 PostgreSQL 18.6 全量验收得到:

risk:
  ACCESS SHARE holder + ADD COLUMN
    → waiter requests AccessExclusiveLock
    → SQLSTATE 55P03
    → shipping_code remains absent

default:
  50,000 rows + constant 7
    → same relfilenode / atthasmissing=true / WAL ≈ 12 KB
  50,000 rows + clock_timestamp()
    → new relfilenode / atthasmissing=false / WAL ≈ 11.5 MB

compatibility:
  old insert → standard/STD
  old update → express/EXP
  new dual write → pickup/PUP
  mismatch → 23514 / named constraint identity

backfill:
  initial legacy nulls=49,999
  two × 5,000 batches → exit 75 / remaining 39,999
  resume eight batches → migrated 49,999 / remaining 0
  checkpoint total=10 batches / last_order_id=50,000

constraints:
  pair CHECK NOT VALID → VALIDATE
  non-null CHECK NOT VALID → VALIDATE → SET NOT NULL
  SET NOT NULL relfilenode unchanged
  PG18 relation NOT NULL appears in pg_constraint and pg_attribute

partition:
  ATTACH parent=ShareUpdateExclusiveLock
  ATTACH child=AccessExclusiveLock
  validated bound CHECK present before attach
  DETACH CONCURRENTLY outside transaction / rows retained=20,000
  child relfilenode unchanged / final reattached

release:
  phase=switched / contract=P3612 refused
  old column and bridge retained / worker=0
  business checksum=f8a7bfae59c6d16cd323abecfefe1014

WAL 字节、filenode OID、PID、毫秒和具体 lock backend 都不是跨环境 golden。稳定结论是 fast/rewrite 的相对关系、锁边、SQLSTATE、单调状态、批次原子性、零遗漏、数据保留和最终兼容边界。

章节验收

  1. 变更说明同时回答 lock、scan/rewrite、WAL/space/lag 与兼容性;
  2. lock_timeout 小于发布允许排队时间,statement_timeout 留出执行预算;
  3. 55P03 被当作“未取得发布条件”,不是盲目无限重试;
  4. constant/volatile default 的物理差异有目录与 WAL 证据;
  5. expand 对所有仍受支持的旧版本保持可读写;
  6. 双写只有一个一致性 authority,并有命名约束反例;
  7. backfill 使用有界 keyset batch,不用巨型 OFFSET;
  8. 数据更新与 checkpoint 同事务提交;
  9. 受控中止能从精确水位继续,不能跳过低 key 遗留行;
  10. NOT VALID 不被误写成“暂不执行约束”;
  11. VALIDATE CONSTRAINT 的扫描和 lock budget 单独规划;
  12. PostgreSQL 18 的关系级 NOT NULL catalog 差异已隔离;
  13. concurrent index 命令位于事务块外,失败残留有 exact cleanup;
  14. ATTACH 候选列定义完全匹配父表且 bound CHECK 已验证;
  15. default partition 和 subpartition 的额外扫描边界已评审;
  16. DETACH CONCURRENTLY 只在 PostgreSQL 14+ 且事务块外使用;
  17. Pigsty 发布连接、应用流量和只读分析入口职责分离;
  18. 锁、WAL、replication lag、disk、connection 与 SLI 同窗观察;
  19. application deployment 与 database migration 各有独立 identity;
  20. contract 需要旧读写依赖清零与真实观察期证据;
  21. reset 的错误 token、错误 target 和 active-worker 反例全部拒绝;
  22. 最终业务 checksum 不变,v0.6 仍是 candidate 而非已发布 baseline。

下一章 ch12《一气呵成:从数据库契约到后端服务》 将从数据库状态机继续走向 driver、连接池、应用发布和端到端验收。

参考资料


上一章:顾此失彼:并发控制与隔离异常 · 返回上卷导读 · 下一章:一气呵成:从数据库契约到后端服务 · 查看全书目录 · 查看索引中心

11.1 识别 DDL 的四类风险

评审 DDL 时,先把“这条语句通常很快”改写成四个可证伪的问题:

lock:          要什么锁?等多久?拿到后持有多久?队列会挡住谁?
physical work: 扫描、重写、索引构建、WAL、临时空间各是多少?
compatibility: 旧/新读写组合是否都理解中间 schema?
backfill:      如何分批、停止、重入、限速并证明没有跳行?

这四类风险互相放大。一个只执行 5 ms 的 catalog change,如果在锁队列中等待 20 分钟,就不是 5 ms 变更;一个逻辑兼容的 nullable column,如果回填制造持续 WAL 和副本延迟,也不是无风险变更。

11.1.1 锁等级与持锁时间

先分开 lock mode、wait time 与 hold time

PostgreSQL 的多数 ALTER TABLE 子命令在没有特别说明时请求 ACCESS EXCLUSIVE。它与普通查询取得的 ACCESS SHARE 冲突。风险不是简单的:

ACCESS EXCLUSIVE = 一定很慢

而是:

impact
  = time waiting in lock queue
  + time executing after grant
  + time until transaction commit
  + queue amplification on later sessions

即使 catalog change 在取得锁后只需几毫秒,前方一个长查询也可能让它排队。更隐蔽的是 lock queue fairness:

long SELECT holds ACCESS SHARE
  → DDL queues for ACCESS EXCLUSIVE
  → later SELECT may queue behind incompatible waiting DDL
  → one planned change becomes a service-wide convoy

因此 DDL 不能在已经做了远程调用、人工确认或大量前置 SQL 的长事务尾部执行。锁一旦取得,会一直持有到事务结束;“语句执行完成”不是“锁已释放”。

用 timeout 表达发布预算

BEGIN;
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '30s';

ALTER TABLE shop_private.ch11_order
    ADD COLUMN shipping_code text;

COMMIT;

两个 timeout 回答不同问题:

  • lock_timeout:单次等待锁最多多久;
  • statement_timeout:从命令开始到完成的总预算,包括锁等待。

通常令 lock_timeout < statement_timeout,否则总超时可能先触发,无法区分“没拿到锁”和“拿到后执行过久”。本章用 verbose error 保存 55P03,并在失败后检查列仍不存在。

timeout 不是自动重试许可。若 DDL 已经在队列中造成业务抖动,立即高频重试会持续重建队列。重试前至少重新观察:

root blocker identity and transaction age
queue depth and affected SLI
remaining change window
whether application traffic can be drained or shifted
whether the command is idempotent or has partial external state

实测一个“物理上快、锁上失败”的 ADD COLUMN

锁图协调器 打开两个真实 backend:

holder:
  BEGIN
  LOCK ch11_order IN ACCESS SHARE MODE

waiter:
  ALTER TABLE ch11_order ADD COLUMN shipping_code text
  lock_timeout=4s

observer 保存:

waiter=pg36-ch11-lock-waiter
blocker=pg36-ch11-lock-holder
wait_event_type=Lock
wait_event=relation
requested_mode=AccessExclusiveLock
granted=false

结果:

SQLSTATE=55P03
blocker_edges=1
shipping_code_after_failure=absent
holder_release=COMMIT

释放 holder 后才进入真正 expand。这个实验同时证明三件事:

  1. nullable ADD COLUMN 的 physical work 很小;
  2. 它仍需强锁;
  3. timeout failure 没有把 schema 留在“也许改了一半”的状态。

lock 计划最少写到对象级

一次变更说明至少列出:

对象 命令 主要 lock 预计持有 blocker 来源 超时后动作
order ADD COLUMN AccessExclusive catalog-only long SELECT/xact abort, observe, retry
order VALIDATE CHECK ShareUpdateExclusive scan duration DDL/vacuum family pause backfill or reschedule
order CREATE INDEX CONCURRENTLY multi-phase lighter table locks build duration concurrent DDL/snapshots inspect INVALID
partition parent ATTACH ShareUpdateExclusive catalog + validation maintenance DDL retain standalone child
attached child ATTACH AccessExclusive validation window readers/loaders postpone attach

表格中的 lock mode 必须以目标 PostgreSQL 大版本的官方文档和演练为准,不能从另一条相似命令推断。

11.1.2 表重写、全表扫描与 WAL 放大

catalog-only、scan 与 rewrite 是三件事

常见物理行为可粗分为:

catalog-only:
  change metadata, no per-row visit

validation scan:
  read every relevant row, keep tuple representation

table rewrite:
  write a new physical relation and rebuild affected indexes

它们的风险不同:

行为 主要资源 常见后果
catalog-only strong but short lock lock queue
scan read IO, buffer churn, CPU latency/replica read contention
rewrite read + write IO, WAL, disk, index work lag, disk pressure, long lock
backfill UPDATE WAL, dead tuples, index maintenance autovacuum debt, bloat, lag

pg_relation_size() 不变不能证明没有扫描;relfilenode 不变也只排除某些 rewrite,不能证明命令便宜。反过来,relfilenode 改变是强烈的 rewrite 证据。

constant default 的 metadata fast path

PostgreSQL 11 起,新增带 non-volatile default 的列可以把一次计算结果保存在 column metadata 中,不必立刻重写每个旧 tuple。目录证据:

SELECT
    attname,
    atthasmissing,
    attmissingval
FROM pg_attribute
WHERE attrelid =
      'shop_private.ch11_default_probe'::regclass
  AND attname = 'fast_flag';

本章在 50,000 行表上执行:

ALTER TABLE shop_private.ch11_default_probe
    ADD COLUMN fast_flag integer NOT NULL DEFAULT 7;

一次 PostgreSQL 18.6 观测:

relfilenode: 20116 → 20116
atthasmissing=true
attmissingval={7}
WAL≈12 KB

OID 与 WAL 字节每次都可能变化;稳定关系是 same filenode、missing metadata 存在、WAL 远小于逐行改写。

volatile default 必须逐行求值

ALTER TABLE shop_private.ch11_default_probe
    ADD COLUMN volatile_stamp timestamptz
    NOT NULL DEFAULT clock_timestamp();

clock_timestamp() 是 volatile,同一命令中每行都需要实际值。相同 fixture 的观测:

relfilenode: 20116 → 20129
atthasmissing=false
WAL≈11.5 MB

这不是要建立“11.5 MB”阈值,而是证明:

constant/non-volatile default → metadata path
volatile default             → per-row rewrite path

如果最终值本来就需要按行计算,更安全的路径通常是:

ADD nullable column without default
  → protect new writes
  → bounded backfill
  → validate
  → add desired default for future writes
  → set not null

default 只定义未来“省略该列”时写什么,不是历史事实生成器。

类型变更不能只看 cast 是否存在

ALTER COLUMN TYPE 通常会重写表和索引;某些 binary-coercible 或内容不变的转换可避免表重写,但 index、collation、statistics 仍可能变化。发布前至少检查:

all values representable under new type
default expression convertible
CHECK/FK/generated/expression index dependencies
collation and opclass semantics
table + indexes size and free disk
WAL/replica lag budget
old application parameter/result decoding
ANALYZE requirement after change

不要把 USING expression 当作无代价转换。它允许更复杂的计算,恰恰意味着要逐行应用,并且不会自动替你正确转换旧 default。

DROP COLUMN 也有物理延迟

DROP COLUMN 通常只在 catalog 中把列标为 dropped,不会立刻缩小 heap。旧 tuple 中空间随后续更新逐渐回收;若强求立即回收,往往需要 rewrite,风险更大。

这还揭示恢复边界:

down: ADD COLUMN old_name ...

只能重建一个空壳列,不能恢复已删除值。列删除后的恢复是 forward repair、从权威源重建或 restore/PITR,而不是把 DDL 方向反过来。

11.1.3 新旧应用版本的兼容窗口

schema 不是瞬时切换

滚动发布至少存在这些组合:

application read path write path database phase
old old column old column only expanded
new shadow old response + compare new dual write migrating
new primary new column dual write validated/switched
rolled-back old old column old column only switched rollback window

只测试“新 application + 新 schema”漏掉了真正危险的组合:

old app + expanded schema
new app + partially backfilled data
old app rollback + switched schema
offline job + almost-contracted schema

兼容窗口要按最旧仍可能运行的 artifact定义,不按“主服务已经 100%”定义。consumer 包括:

  • web/API replicas;
  • queue workers 与 cron;
  • ETL/CDC/sink;
  • BI 与 ad-hoc SQL;
  • migration/repair scripts;
  • 失败后可能回滚的上一版;
  • 长连接中仍缓存旧 prepared statement 的进程。

兼容矩阵先于 DDL

shipping_methodshipping_code 为例:

legacy representation:
  standard / express / pickup

new representation:
  STD / EXP / PUP

发布前先回答:

情形 预期
old insert omits code database derives code
old update changes method code follows method
new dual-write consistent pair accept
new dual-write mismatched pair reject 23514
new read sees legacy null fallback or do not switch
old rollback after switch still reads/writes successfully

本章用一个 temporary BEFORE trigger 作为单一映射 authority,再用命名 CHECK 闭合表示一致性。触发器不是默认答案;它只是本例在多 writer 共存期内比“应用连续发两条独立 UPDATE”更可审计。

rename 往往不是兼容变更

直接 RENAME COLUMN old TO new

  • 对数据库本身是 catalog change;
  • 对仍引用 old 的 SQL 是立即破坏;
  • SELECT *、row decoder、ORM metadata、prepared statement 可能产生额外影响。

跨应用版本重命名通常需要:

add new
  → keep old
  → bridge/dual-write
  → backfill
  → switch reads
  → retire old users
  → drop old

“只是改名”描述的是数据库物理工作,不描述 API compatibility。

11.1.4 数据回填的节奏与失败恢复

一条大 UPDATE 的问题不只是锁

UPDATE huge_table
SET new_col = transform(old_col)
WHERE new_col IS NULL;

即使 row locks 不阻塞普通读取,它仍可能:

  • 生成巨大 WAL;
  • 让 replicas 持续落后;
  • 延长 transaction 与 crash recovery;
  • 产生大量 dead tuples;
  • 让 autovacuum、checkpoint 和前台 IO 竞争;
  • 在最后一行失败时回滚全部工作;
  • 让取消操作也需要长时间 undo/cleanup。

所以“数据库支持事务”不是把所有行放进一个事务的理由。

批次合同

一个可运行的 backfill 至少声明:

stable ordering key
batch size and transaction boundary
predicate selecting only unresolved rows
checkpoint committed with the same batch
sleep/rate or load feedback
lock and statement timeout
maximum batches/time/WAL/lag
restart semantics
new-write protection
completion oracle and mismatch query

本章使用 order_id keyset,而不是逐页增长的 OFFSET:

WHERE order_id > :last_order_id
  AND shipping_code IS NULL
ORDER BY order_id
LIMIT :batch_size
FOR UPDATE

数据更新与:

last_order_id
rows_migrated
batches
phase

在同一事务提交。进程在两个 batch 之间退出,已提交批次保留;在一个 batch 内失败,数据与 checkpoint 一起回滚。

checkpoint 不能制造跳行

若配合 SKIP LOCKED,直接把 checkpoint 推到最大 key 可能永久跳过被锁的低 key。选择包括:

  • 不跳锁,给每批设置短 lock timeout;
  • 维护可回访的 pending ranges;
  • 让 checkpoint 只表示连续完成前缀;
  • 结束前做独立 unresolved sweep。

本章没有使用 SKIP LOCKED。每批后还断言:

NOT EXISTS (
  SELECT 1
  FROM ch11_order
  WHERE shipping_code IS NULL
    AND order_id <= checkpoint.last_order_id
)

这是“水位没有越过遗漏行”的机器证据。

受控中止也是成功路径

回填器 的:

--max-batches 2

在两批各 5,000 行后返回 75

phase=backfilling
rows_migrated=10,000
remaining=39,999
resume_from=<exact committed key>

再次运行不重建 fixture:

start=backfilling
batches_run=8
total_batches=10
rows_migrated=49,999
remaining=0
mismatches=0
phase=migrated

75 不是事故;它表示到达已声明停止线,可以由发布编排在重新检查水位后继续。真正危险的是脚本把“进程退出”与“数据库是否部分提交”混在一起。

本节验收问题

  1. 每条 DDL 的 lock mode、等待预算、持锁到何时是否明确;
  2. 是否考虑等待 DDL 对后来查询造成的队列放大;
  3. physical work 是 catalog、scan、rewrite、index build 还是 backfill;
  4. WAL、额外磁盘、replica lag 和 autovacuum 债务是否有预算;
  5. old/new/rollback/offline consumer 的兼容矩阵是否完整;
  6. backfill 的 ordering、batch、checkpoint 与停止线是否可执行;
  7. checkpoint 能否跳过 locked/gapped rows;
  8. timeout 后如何判定未变、部分变或需要 forward repair;
  9. 动态 OID/毫秒是否被误写为跨环境 golden;
  10. contract 是否与 expand 被错误地塞进同一发布窗口。

任一高影响问题没有答案时,这条 DDL 仍是设计草案,不是可执行变更。

参考资料


返回本章目录 · 下一节:Expand–Migrate–Contract · 查看全书目录 · 查看索引中心

11.2 Expand–Migrate–Contract

Expand–Migrate–Contract 的价值不在三个英文词,而在于它强迫团队承认:数据库 schema 与 application fleet 不能原子切换。

本书把它细化为六个可查询阶段:

legacy
  → expanded
  → backfilling
  → migrated
  → validated
  → switched
  → contract

每个阶段都要定义入口条件、允许的读写版本、完成证据、失败语义与下一步。阶段名不是 deployment 日志的一行字符串,而是 release protocol。

11.2.1 先扩展兼容结构

Expand 的判定标准

一个 expand 变更应满足:

old application can still read
old application can still write
new application can discover/use the new shape
existing rows need not already satisfy the final invariant
new writes cannot create unbounded new migration debt
operation fits a measured lock budget

常见 expand:

  • 添加 nullable column;
  • 添加新表或新 relation;
  • 添加不改变旧调用结果的新函数参数/overload;
  • 添加兼容 view;
  • 增加 NOT VALID 的 CHECK/FK;
  • 建立 concurrent index;
  • 安装临时 bridge trigger;
  • 扩展 enum-like catalog,而非立即删除旧值。

常见非 expand:

  • rename/drop old column;
  • 直接 SET NOT NULL
  • 收窄 type/length/range;
  • 删除旧 enum/catalog value;
  • 改变函数返回 shape;
  • 改变旧字段语义但保留同名;
  • 让旧 writer 因新约束立即失败。

“DDL 能在旧代码旁边执行”不等于逻辑兼容;要实际运行最旧受支持版本的 read/write contract。

本章的兼容扩展

初始表只有:

shipping_method text NOT NULL

expand 事务做:

ALTER TABLE shop_private.ch11_order
    ADD COLUMN shipping_code text;

CREATE TRIGGER ch11_order_shipping_bridge
BEFORE INSERT OR UPDATE
ON shop_private.ch11_order
FOR EACH ROW
EXECUTE FUNCTION shop_private.ch11_sync_shipping_code();

ALTER TABLE shop_private.ch11_order
    ADD CONSTRAINT ch11_order_shipping_pair_consistent
    CHECK (...)
    NOT VALID;

这三部分承担不同职责:

组件 职责
nullable new column 让历史行暂时可表示
bridge trigger 让旧 writer 不再制造 NULL,并集中映射规则
pair CHECK NOT VALID 拒绝新/更新行的表示不一致,不扫描旧行

NOT VALID 不是“约束关闭”。约束加入后,新插入或更新的行仍被检查;只有已有行暂时没做全表验证。

bridge 必须是临时且单一的 authority

本例映射:

standard ↔ STD
express  ↔ EXP
pickup   ↔ PUP

旧 application 只提供 shipping_method,trigger 派生 code。新 application 在共存期 dual-write;若 pair 不一致,trigger/constraint 以命名 23514 拒绝。

为什么不让 application 连续执行:

UPDATE ... SET shipping_method = ...;
UPDATE ... SET shipping_code = ...;

因为两个 statement 之间可能:

  • transaction 被取消;
  • 进程断连;
  • 第二条被 retry/skip;
  • 另一个 writer 介入;
  • 第一条提交而第二条未提交。

若两条在同一 transaction,可以保证原子性,但仍存在多个 application 实现映射漂移的问题。短期 database bridge 把映射收敛为一个 authority;长期则应收缩回一个 canonical representation,避免永久双写。

Expand 也需要版本 identity

本章 state row:

migration_id=shipping-code-v1
phase=legacy

expand 在同一事务中:

add objects
  + install compatibility
  + update phase=expanded

若 DDL rollback,phase 也 rollback。下一次执行会先检查:

  • migration identity 是否正确;
  • 当前 phase 是否恰为 legacy
  • new column 是否确实不存在;
  • table marker 是否匹配。

它选择“前置拒绝”而不是无条件 IF NOT EXISTSIF NOT EXISTS 只能证明同名对象存在,不能证明 type、default、owner、constraint 与 function body 是期望版本。

11.2.2 分批迁移、双读校验与切换

Migrate 不等于一条 UPDATE

迁移阶段包含三个并行事实:

new writes remain compatible and complete
historical debt monotonically decreases
read comparison proves semantic equivalence

若只做 backfill,而旧 writer 继续写 NULL,remaining count 永远追不上;若只保护新写入,却不做 shadow comparison,可能把错误映射完整填满全表。

本章的次序:

expanded:
  old/new application probes
  build temporary partial index for unresolved rows

backfilling:
  keyset batches + atomic checkpoint
  controlled stop and resume

migrated:
  remaining NULL=0
  mapping mismatch=0

validated:
  pair CHECK validated
  non-null CHECK validated
  column SET NOT NULL

switched:
  new reads authoritative
  old column and bridge retained for rollback window

双读不是向用户返回两个结果

shadow read 的基本结构:

primary result = currently trusted representation
shadow result  = candidate representation
compare normalized semantics
emit mismatch metric/log with stable identity
return only primary result

它要回答:

  • 全量还是采样;
  • 采样是否覆盖 tenant/value/time buckets;
  • 如何归一化 NULL、时区、排序、rounding;
  • mismatch 是否含敏感数据;
  • 谁处理 mismatch;
  • mismatch=0 要持续多久;
  • shadow query 的额外负载预算。

本例可以在数据库内做精确比较:

SELECT count(*) AS mismatches
FROM shop_private.ch11_order
WHERE shipping_code IS DISTINCT FROM
      CASE shipping_method
          WHEN 'standard' THEN 'STD'
          WHEN 'express'  THEN 'EXP'
          WHEN 'pickup'   THEN 'PUP'
      END;

IS DISTINCT FROM 让 NULL 也进入确定的相等语义。生产业务的等价关系可能跨服务或包含版本化规则,不能只比较文本。

先切写还是先切读

常见安全次序是:

1 protect new writes at database boundary
2 deploy code capable of reading both
3 turn on new/dual write
4 backfill and validate
5 shadow new read
6 switch primary read
7 observe
8 disable old write compatibility
9 contract old representation

“先切写再切读”让 new representation 逐步变新鲜,便于读比较;但具体顺序仍取决于:

  • 新值能否从旧值无损派生;
  • old writer 是否仍可能运行;
  • new writer 是否能继续提供 old representation;
  • read fallback 是否会掩盖 migration debt;
  • rollback 时旧应用能否理解新写入。

不要把模式当教条,应把每个箭头写进兼容矩阵。

Switch 是流量动作,不是 DDL

本章 switch.sql 在数据库内只能模拟:

  • mismatch=0;
  • 新表示可作为 read authority;
  • 切换后旧 writer 仍能写;
  • 新 writer 继续 dual-write;
  • state 进入 switched

真实 switch 通常是 application flag、deployment、routing 或 query version 的改变。它需要自己的:

release identity
owner
start/end time
traffic percentage
SLI guard
rollback command
database migration identity

不要用 schema_version=42 代替 application rollout 证据,也不要用“应用已发布”代替数据库 catalog postcheck。

11.2.3 观察稳定后再收缩旧结构

Contract 是新的独立发布

Contract 删除的是兼容空间:

  • drop old column/table/function;
  • drop bridge trigger;
  • remove fallback read;
  • tighten type/range;
  • remove old index/API;
  • revoke old privilege;
  • delete old catalog values。

它不应和 expand 放在同一个 maintenance window。否则旧 application 一旦仍在运行,expand 提供的兼容立刻被 contract 撤销,整个模式失去意义。

contract 的入口条件至少包括:

database phase=switched
new representation complete and validated
old read traffic=0
old write traffic=0
offline/BI/ETL dependency inventory cleared
old prepared statements/connections aged out
rollback observation window elapsed
backup/PITR posture current
exact target and owner approved
forward repair documented

“观察一周”必须可验证

时间长度本身不够。需要观测对象:

old column read counter or query family
old write path/application version
bridge trigger invocation count
fallback-read count
mismatch count
old deployment replica count
offline job last success and next schedule
database errors for unknown old/new columns

如果没有区分旧/新路径的 telemetry,“观察一周无报警”不能证明旧依赖为零。

同时要考虑低频 consumer。一个月只跑一次的财务作业不会在七天窗口出现;依赖 inventory 和 owner 确认仍不可省略。

本地 suite 为什么拒绝 contract

contract-gate.sql 要求三个独立输入:

action token:
  CONTRACT_CH11_AFTER_OBSERVATION

exact target:
  pg36_shop/shop_private/ch11_order/shipping_method

external observation evidence:
  legacy-readers=0;legacy-writers=0;rollback-window=elapsed

task.sh all 故意不提供。稳定结果:

psql exit=3
SQLSTATE=P3612
phase=switched
shipping_method exists
bridge exists

这样全自动 CI 不会因为“测试跑完”而获得删除旧语义的权力。若有人在 disposable fixture 上显式满足 gate,可以演练真正 DROP;生产审批、证据和权限仍是另一条边界。

Contract 后没有免费回滚

删除旧列之后:

application rollback to old binary

往往已不再可行。可选恢复:

  • 前滚部署兼容修复;
  • 从 new representation 重建 old 值(仅当转换可逆且规则仍在);
  • 从外部权威源 reconciliation;
  • 从 backup/PITR 恢复到另一个环境并提取数据;
  • 全库恢复,接受明确 RPO/RTO 与其他数据影响。

所以 contract 是 destructive semantic change,即使 DROP COLUMN 物理上很快。

状态机的单调性

本章不提供 phase=validated → phase=expanded 的数据库 down path。回退流量时:

database stays expanded/validated/switched-compatible
application read path returns to old representation
new writer may continue dual-write
issue is repaired forward

这种“应用回退、数据库不倒退”通常比反向 DDL 更可靠。数据库状态可以暂时更宽松,只要:

  • 两种表示继续一致;
  • 新写入不积累债务;
  • owner 和 expiry 明确;
  • 后续 forward path 仍可执行。

发布状态表

可把每阶段写成以下审查表:

Phase 允许版本 写入 authority 完成证据 失败后
legacy old old baseline checksum redesign
expanded old + new bridge/dual catalog + compatibility cases retry/forward repair
backfilling old + new bridge/dual checkpoint + watermarks pause/resume
migrated old + new bridge/dual remaining=0, mismatch=0 repair anomalies
validated old + new constraints convalidated, attnotnull fix and revalidate
switched old rollback + new dual SLI + shadow match route reads back
contract new only new dependency zero + observation forward repair/restore

阶段必须由事实推动,不由“脚本跑到了第几行”推动。

本节验收问题

  1. expand 是否对最旧受支持 writer/readers 真正兼容;
  2. new write protection 是否在 backfill 前建立;
  3. mapping/dual-write 是否只有一个一致性 authority;
  4. migration identity 与 phase 是否同 DDL 原子提交;
  5. shadow comparison 的语义、采样和 owner 是否明确;
  6. switch 的 application release identity 是否独立留证;
  7. rollback 是流量回退还是数据库反向 DDL;
  8. contract 是否是独立发布、独立授权和独立窗口;
  9. 低频/offline consumer 是否进入依赖清单;
  10. contract 后丢失语义时是否诚实声明 forward repair/restore。

Expand–Migrate–Contract 不是让发布变慢;它是把原本隐含、同时发生且不可诊断的风险,拆成可以停止和验证的阶段。

参考资料


上一节:识别 DDL 的四类风险 · 返回本章目录 · 下一节:索引与约束的在线化路径 · 查看全书目录 · 查看索引中心

11.3 索引与约束的在线化路径

索引和约束的“在线化”不是无锁,而是把:

build / enforce new rows / scan old rows / publish identity

拆到不同阶段,缩短最强锁的持续时间,并让失败状态可识别。每种对象支持的拆分方式不同;不能把 NOT VALIDCONCURRENTLYUSING INDEX 当成通用后缀。

11.3.1 CREATE INDEX CONCURRENTLY 的阶段与失败残留

Concurrent build 解决什么

普通 CREATE INDEX 会阻止表上的写入。CREATE INDEX CONCURRENTLY 允许普通 insert/update/delete 继续,但代价是:

  • 多阶段目录状态;
  • 至少两次 table scan;
  • 等待影响旧 snapshot 的 transaction;
  • 更多总工作量和更长 elapsed time;
  • 每表同一时刻只能有一个 concurrent build;
  • 不能在 transaction block 中运行;
  • expression/predicate evaluation 仍可能失败;
  • 失败可能留下 INVALID index。

所以 CONCURRENTLY 的意思是“降低对普通写的阻塞”,不是“免费后台任务”。

先复用第 9 章的候选纪律

模式发布中的 index 也必须先回答:

query shape and parameterization
operator / collation / opclass
before/after plan and result identity
index size and build WAL
write/HOT cost
replica and disk watermarks
failure cleanup identity
retention or removal phase

本章不重复第 9 章的全套收益评估,只把一个 temporary partial index 应用于回填:

CREATE INDEX CONCURRENTLY
    ch11_order_shipping_missing_idx
ON shop_private.ch11_order (order_id)
WHERE shipping_code IS NULL;

它绑定:

WHERE order_id > checkpoint
  AND shipping_code IS NULL
ORDER BY order_id
LIMIT batch_size

随着 backfill 完成,predicate 集合缩到零;它是 migration acceleration object,不是永久 schema。

为什么必须是独立入口

online-index.sh 让 psql 在同一 session 中依次执行:

SET ROLE
SET lock_timeout
SET statement_timeout
CREATE INDEX CONCURRENTLY

每个 --command 是独立 top-level command,避免把 concurrent build 塞进 implicit multi-statement transaction。下面写法会失败:

BEGIN;
CREATE INDEX CONCURRENTLY ...;
COMMIT;

migration framework 如果默认“每个 migration 自动包事务”,需要为这类命令声明 non-transactional phase;不能偷偷关闭整套 framework 的事务保护。

失败后查 catalog,不按文件名猜

SELECT
    index_class.relname,
    index_catalog.indisready,
    index_catalog.indisvalid,
    index_catalog.indisunique,
    pg_get_indexdef(index_catalog.indexrelid),
    pg_get_expr(
        index_catalog.indpred,
        index_catalog.indrelid
    ) AS predicate
FROM pg_index AS index_catalog
JOIN pg_class AS index_class
  ON index_class.oid = index_catalog.indexrelid
WHERE index_catalog.indrelid =
      'shop_private.ch11_order'::regclass;

失败的 concurrent index:

  • 可能仍占磁盘;
  • 可能给写入带来维护开销;
  • 若是 unique build,某些阶段甚至可能开始施加 uniqueness;
  • 不会被 planner 当作正常 valid index。

处理顺序:

capture SQLSTATE/stderr and catalog
  → identify exact schema/index/table/definition
  → decide repair/rebuild/drop
  → DROP INDEX CONCURRENTLY exact_name
  → verify catalog absence

不要运行模糊 DROP INDEX IF EXISTS some_name 后声称“已清理”;同名跨 schema、错误定义和并发新建都需要防护。

从 unique index 快速接成约束

对非分区普通表,可以先:

CREATE UNIQUE INDEX CONCURRENTLY candidate_uidx
ON account (tenant_id, external_ref);

验证 valid 后:

ALTER TABLE account
    ADD CONSTRAINT account_external_ref_key
    UNIQUE USING INDEX candidate_uidx;

第二步通常是短 catalog operation。边界:

  • 必须是 unique B-tree;
  • 使用默认排序;
  • 不能是 expression index;
  • 不能是 partial index;
  • PRIMARY KEY 还要求列 NOT NULL,否则可能触发扫描;
  • 当前不能用该语法直接给 partitioned table 添加约束;
  • 转换后 index 由 constraint 拥有,drop constraint 会连带 drop index。

先 concurrent build 再 attach,不消除第二步的锁预算,只缩短需要强锁时做的工作。

11.3.2 NOT VALIDVALIDATE CONSTRAINT 与验证扫描

NOT VALID 的精确定义

对支持的 CHECK/FK(PostgreSQL 18 还扩展到关系级 NOT NULL),ADD ... NOT VALID

does not scan all pre-existing rows at ADD time
does enforce the constraint for future INSERT/UPDATE rows
records convalidated=false

它不是:

constraint disabled
validation optional forever
available to UNIQUE/PRIMARY KEY
no locks

本章 expand 后:

ch11_order_shipping_pair_consistent
  contype=c
  convalidated=false

pg_attribute.shipping_code
  attnotnull=false

旧行可以 shipping_code IS NULL,但新/更新行不能产生错误 pair。

为什么 validation 能与 DML 共存

ALTER TABLE shop_private.ch11_order
    VALIDATE CONSTRAINT
    ch11_order_shipping_pair_consistent;

PostgreSQL 扫描旧行时,新/更新行已经由 constraint enforcement 保护,因此 validation 使用 SHARE UPDATE EXCLUSIVE,不需要像直接 ADD valid constraint 那样长期阻止普通更新。

这仍然是全表读取:

  • 会消耗 IO/buffer/CPU;
  • 与某些 DDL、VACUUM family 操作冲突;
  • 可能造成 replica/存储侧压力;
  • 遇到历史坏值会失败;
  • 需要单独 statement_timeout 与发布水位。

convalidated=true 是完成证据;“命令返回成功”之外还应保存:

SELECT
    conname,
    contype,
    convalidated,
    pg_get_constraintdef(oid, true)
FROM pg_constraint
WHERE conrelid = '...'::regclass;

非空的跨版本路径

PostgreSQL 14–17 的通用做法:

ALTER TABLE orders
    ADD CONSTRAINT orders_new_col_nn
    CHECK (new_col IS NOT NULL)
    NOT VALID;

ALTER TABLE orders
    VALIDATE CONSTRAINT orders_new_col_nn;

ALTER TABLE orders
    ALTER COLUMN new_col SET NOT NULL;

当一个 valid CHECK 已经证明列无 NULL,SET NOT NULL 可以避免再做一次全表扫描;执行时让该 CHECK 保持存在。

本章实测:

pair CHECK      false → true
non-null CHECK  false → true
attnotnull      false → true
SET NOT NULL relfilenode 19911 → 19911

same filenode 说明没有 table rewrite;官方保证与 valid CHECK 共同支持“无需重复验证扫描”的判断。它仍需要短时强锁,不能省略 lock budget。

PostgreSQL 18 的目录差异

PostgreSQL 17 及以前,relation column 的 NOT NULL 主要表示在:

pg_attribute.attnotnull

pg_constraintcontype='n' 主要用于 domain。PostgreSQL 18 把 relation NOT NULL 也提升为完整 constraint:

pg_constraint.contype='n'
pg_constraint.conrelid=<table>
named NOT NULL
convalidated state
inheritance/enforcement metadata

本章 PG18.6 验收同时看到:

ch11_order_shipping_code_not_null | n | true
pg_attribute.shipping_code        | a | true

因此跨 14–18 的 catalog checker 必须 version-gate:

PG14–17:
  require attnotnull=true
  do not require relation contype=n

PG18:
  require attnotnull=true
  require exactly one validated relation NOT NULL constraint

不要因为 PG18 新语法支持 NOT NULL NOT VALID 就把它直接写进声明支持 PG14–18 的无条件 migration。通用主体仍可使用 CHECK → VALIDATE → SET NOT NULL。

NOT VALID 失败恢复

若 validation 发现坏值:

constraint remains present and not valid
new/updated rows remain protected
old violations remain queryable

这通常比直接 ADD valid constraint 后整个发布卡住更可控。修复流程:

  1. 保存 violation query 与 stable row identity;
  2. 暂停或限速 backfill;
  3. 修复历史数据;
  4. 再次验证 zero violations;
  5. 重跑 VALIDATE CONSTRAINT
  6. convalidated
  7. 才进入 switch。

不应为了让 validation 通过而随手 drop constraint;那会重新打开新债务入口。

11.3.3 默认值、非空与类型变更的版本边界

默认值按 volatility 与目标版本判断

对于:

ALTER TABLE t ADD COLUMN c type DEFAULT expression;

先回答:

expression volatility?
evaluated once or per row?
does target PG support metadata missing value?
is value a real historical fact?
will old application explicitly send NULL?
does default need removal after migration?

non-volatile constant fast path 在当前支持范围内可用,但强锁仍在。volatile expression 会逐行更新;不同 extension/function 还要确认 volatility declaration 是否真实,不能为追求 fast path 把非 immutable 函数伪装成 immutable。

default 与 NOT NULL 的组合

新增:

ADD COLUMN flag integer NOT NULL DEFAULT 7

对 constant default 可以很快,但业务语义仍可能错误:

  • 所有旧行真的都是 7 吗;
  • 未来调用方省略时真的应为 7 吗;
  • 旧 application 显式传 NULL 会不会失败;
  • 7 是临时 backfill 值还是长期 default;
  • 是否需要区分“未知”与“默认”。

物理 fast path 不能替代领域建模。若历史值需要从旧数据计算,应 nullable expand + backfill,而不是给所有历史 tuple 伪造同一个事实。

类型变更的四条路径

路径 适用 主要风险
in-place ALTER TYPE 小表/可证明无重写转换 lock、依赖、plan/statistics
new column + backfill 可双表示、需转换 coexistence、WAL、contract
new table + dual write 大结构变化/新 key consistency、cutover
logical copy/CDC 极大表/跨系统 ordering、lag、reconciliation

选型不只看表大小。还看 write rate、转换是否可逆、FK/unique、业务 key、partition、可用窗口和 rollback。

Precheck 必须在写前失败

text → integer 例子:

SELECT id, old_value
FROM source
WHERE old_value !~ '^[0-9]+$'
   OR old_value::numeric >
      2147483647;

真正 migration 仍要处理 precheck 与执行之间的竞态:

  • 先加兼容 CHECK 约束;
  • 暂停旧 writer;
  • 在同一受控 transaction 再检查;
  • 或把所有写引到能验证新范围的路径。

还要检查:

default
generated column
views/functions
expression/partial indexes
foreign keys
statistics and extended statistics
logical replication publications/subscribers
driver parameter/result type decoding

成功后重新 ANALYZE,并验证 query plans 与 driver contract;不能只比较 information_schema.columns

版本矩阵写进 artifact

每个 migration repository 应维护至少:

minimum supported major
maximum validated major
version-specific syntax
catalog assertion branch
feature introduced version
known semantic differences

本章:

能力 PG14 PG15 PG16 PG17 PG18
constant-default metadata path
CHECK/FK NOT VALID
valid CHECK helps SET NOT NULL
relation NOT NULL in pg_constraint
named/relation NOT NULL NOT VALID
DETACH PARTITION CONCURRENTLY

“最低 PG14”意味着代码必须先在 PG14 parser/catalog 上成立;不能只在 PG18 运行后根据结果猜兼容。

本节验收问题

  1. concurrent index 是否真正位于 transaction block 外;
  2. build 前后的 query、write、size/WAL 证据是否完整;
  3. failure 是否检查 indisvalid/indisready 并精确清理;
  4. partial migration index 是否有明确 drop phase;
  5. NOT VALID 是否被正确解释为“新写入已执行”;
  6. validation scan 的 IO、lock 与 timeout 是否独立预算;
  7. CHECK → VALIDATE → SET NOT NULL 次序是否跨 PG14–18;
  8. PG18 relation NOT NULL catalog 是否 version-gated;
  9. default 的 volatility 与历史语义是否都评审;
  10. ALTER TYPE 是否检查数据、依赖、driver 和 statistics;
  11. catalog fast path 是否被误写成“零锁零风险”;
  12. dynamic cost/timing 是否只作观测,不作跨环境常数。

参考资料


上一节:Expand–Migrate–Contract · 返回本章目录 · 下一节:在线分区化 · 查看全书目录 · 查看索引中心

11.4 在线分区化

本书对分区采用渐进式教学:

ch04: decide whether partitioning is justified
ch11: rehearse attach/detach as a safe schema release
ch28: operate the full partition lifecycle

本节不推翻 ch04 对订单模型“暂不分区”的 ADR。我们使用独立事件 fixture,学习当证据门已经通过后,如何把预装数据表挂入/摘出分区层次,并把 lock、scan、version 和 recovery 语义说清楚。

11.4.1 从 ch04 的分区 ADR 选择迁移路径

先确认旧决定为什么失效

ch04 分区决策门 要求至少出现一类真实证据:

  • retention/归档需要按整批数据处理;
  • relation/index 规模已形成可测瓶颈;
  • 代表性查询能稳定携带 partition key,并证明 pruning 收益;
  • 热冷分层、备份或维护窗口需要独立 physical units。

进入 ch11 时,不应把“学会 ATTACH”误解为“现在应该分区”。先更新 ADR:

old evidence
new evidence and timestamp
candidate key and partition count
unique/PK/FK implications
NULL and row-movement semantics
query/pruning measurements
retention and operational owner
migration and rollback strategy

如果新证据仍不足,正确结论可以继续是 not-now

Regular table 不能原地变成 partitioned table

迁移通常需要新结构。常见路径:

A. new parent + new partitions
   → copy/backfill
   → catch up writes
   → switch logical name/view

B. prepare an existing regular table
   → prove it fits one bound
   → ATTACH PARTITION

C. new partitioned table + logical replication/CDC
   → reconcile
   → cut over

路径 B 适合:

  • 已经按边界离线装载的一批数据;
  • 归档表重新加入 logical parent;
  • 新周期分区先单独 load/validate;
  • 迁移时把一段现有数据转换成 child。

它不是把任意一张巨大表“瞬间变成分区表”。父/子 column shape、constraints、indexes、owner、storage 与数据 bound 都要准备。

Key、unique 与 FK 必须先重做语义

PostgreSQL partitioned table 的 unique/primary key 必须包含全部 partition key columns,且 key 不能含 expression。因为 uniqueness 最终由各 child 的本地 index 执行,数据库要从 partition routing 推导跨 child 不重复。

如果旧合同是:

UNIQUE(order_no)

placed_at 分区后机械改为:

UNIQUE(order_no, placed_at)

不再保证 order_no 全局唯一。两个不同月份可以重复 order number。可选设计:

  • 改 API identity 为复合键,明确语义变化;
  • 保留未分区 global registry;
  • 选择不同 partition key;
  • 接受只在 partition 内唯一;
  • 不分区。

外键、idempotency key、upsert conflict target 和 driver 参数都会受影响。没有解决这些逻辑问题时,在线 DDL 技巧没有意义。

本章 fixture 的范围

事件表没有 global unique/FK,只演练 range bound:

CREATE TABLE shop_private.ch11_event (
    event_id bigint NOT NULL,
    occurred_at timestamptz NOT NULL,
    payload text NOT NULL
) PARTITION BY RANGE (occurred_at);

独立候选 ch11_event_2025q1 有 20,000 行。数据都落在:

[2025-01-01 00:00:00+00, 2025-04-01 00:00:00+00)

这个简化让实验精确关注 attach/detach;它不是完整生产事件模型。

11.4.2 预建约束、ATTACH 与扫描规避

ATTACH 的两种验证路径

命令:

ALTER TABLE shop_private.ch11_event
ATTACH PARTITION shop_private.ch11_event_2025q1
FOR VALUES FROM ('2025-01-01 00:00:00+00')
             TO ('2025-04-01 00:00:00+00');

PostgreSQL 必须证明所有 child row 满足隐含 partition bound。若没有可用证明,它会在持有 child ACCESS EXCLUSIVE 时扫描该表。

预先建立并验证匹配 CHECK:

ALTER TABLE shop_private.ch11_event_2025q1
ADD CONSTRAINT ch11_event_2025q1_bound
CHECK (
    occurred_at >=
        timestamptz '2025-01-01 00:00:00+00'
    AND occurred_at <
        timestamptz '2025-04-01 00:00:00+00'
);

官方文档建议这样让系统跳过 attach validation scan。注意:

  • constraint 必须 valid;
  • expression 必须足以让系统证明相同 bound;
  • time zone/type/cast/NULL 语义要一致;
  • child columns 必须与 parent 完全匹配;
  • child 若自身 partitioned,递归 subpartition 也要考虑;
  • attach 仍会取得锁,预建 CHECK 不等于 lock-free。

实测 ATTACH 的锁

partition_lab.py 在一个 transaction 中执行 ATTACH 后故意暂不 commit,observer 查询 holder 的 pg_locks

PostgreSQL 18.6 结果:

Relation Granted mode
ch11_event parent ShareUpdateExclusiveLock
ch11_event_2025q1 child AccessExclusiveLock

这与 PostgreSQL 18 ALTER TABLE 文档一致:

parent: SHARE UPDATE EXCLUSIVE
attached table: ACCESS EXCLUSIVE
default partition (if any): ACCESS EXCLUSIVE

预验证 CHECK 降低的是持有 child 强锁期间的扫描工作,不改变 child 需要强锁这一事实。若 child 仍被报表长读,ATTACH 仍可能等锁或阻塞。

本地证据能证明到哪里

实验保存:

bound CHECK convalidated=true before attach
child row count=20,000
exact ATTACH lock modes
child relfilenode unchanged
parent count=20,000 after attach
pg_inherits edge=1
relispartition=true

relfilenode 未变说明数据没有 copy/rewrite,但不能单独证明“没有 validation scan”。“匹配 valid CHECK 可避免扫描”来自官方语义;运行证据证明我们确实提供了该 CHECK 和目标 bound。

不要用一个极快 elapsed time 声称 scan 被证明跳过:数据可能在 cache 中、表可能太小、计时噪声也可能掩盖差异。

Default partition 的隐藏扫描

如果 parent 已有 DEFAULT partition,添加一个新显式 range 时,PostgreSQL 还必须证明 DEFAULT 中没有属于新 range 的行。没有排除性 CHECK 时会:

scan default partition
while holding ACCESS EXCLUSIVE on it

若 DEFAULT 自身 partitioned,会递归检查。发布计划要么:

  • 预先为 DEFAULT 添加排除新 range 的 valid CHECK;
  • 先迁出该 range 的行;
  • 在可接受窗口完成扫描;
  • 重新设计是否需要 DEFAULT。

只优化待 attach child 而忘记 DEFAULT,是常见的“测试环境快、生产突然锁住”原因。

Partitioned index 的在线路径不同

不能直接对 partitioned parent 使用:

CREATE INDEX CONCURRENTLY ON partitioned_parent (...);

官方推荐的渐进路径:

CREATE INDEX ON ONLY parent          # invalid parent index
  → CREATE INDEX CONCURRENTLY child1
  → ALTER INDEX parent_idx ATTACH PARTITION child1_idx
  → repeat for every child
  → parent index becomes valid when complete

每个 child index 要验证定义、opclass、collation、validity 与 ownership。对 unique/PK 还要满足 partition key 规则。生产实施还要把这套检查扩展到全部现存分区,并验证新增分区不会漏建索引。

11.4.3 DETACH、并发能力与锁等级必须按版本说明

普通 DETACH 与 CONCURRENTLY

ALTER TABLE parent
DETACH PARTITION child;

普通形式对 parent 请求 ACCESS EXCLUSIVE。PostgreSQL 14 引入:

ALTER TABLE parent
DETACH PARTITION child CONCURRENTLY;

并发形式对 parent 使用较低的 SHARE UPDATE EXCLUSIVE,内部有两个 transaction:

  1. parent 与 child 取得 SHARE UPDATE EXCLUSIVE,标记正在 detach 并 commit;
  2. 等待使用该 partitioned table 的旧 transaction 结束;
  3. 再取得 parent SHARE UPDATE EXCLUSIVE 和 child ACCESS EXCLUSIVE
  4. 完成 detach,并建立等价 CHECK。

这解释了两个边界:

  • 它仍可能等待长 transaction;
  • 最终仍要短时取得 child ACCESS EXCLUSIVE

“CONCURRENTLY”不是不等待、不锁或固定秒数完成。

事务与结构限制

DETACH PARTITION ... CONCURRENTLY

  • 不能在 transaction block 中运行;
  • parent 有 DEFAULT partition 时不允许;
  • interrupted/pending detach 可能需要 FINALIZE
  • 目标版本必须 PostgreSQL 14+;
  • FK、subpartition 和 concurrent activity 仍需按目标版本复核。

因此 migration runner 需要像 concurrent index 一样提供独立 non-transactional command。

本章的 Python runner 用两个 psql top-level command:

SET ROLE pg36_owner
ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY

它们在同一 session 执行,但 DETACH 不在显式或 implicit multi-statement transaction 内。

版本事实不能倒推

本书支持 PG14–18,因此该语法在主体矩阵中可用。若维护 PG13:

CONCURRENTLY / FINALIZE unavailable

不能运行时 fallback 成普通 DETACH 后仍报告相同 lock semantics。应:

  • 明确跳过并报告 unsupported;
  • 选择有维护窗的 ordinary detach;
  • 或先升级目标 major。

本章 manifest 保存 server_version_num=180006,review 要求 >=140000;它不把 18.6 的 timing 当成 PG14–17 保证。

11.4.4 产出一次可回退的分区化发布

完整演练

实验状态:

before:
  child standalone
  valid bound CHECK
  rows=20,000
  parent rows=0
  relispartition=false

attach:
  capture parent/child locks before commit
  commit
  parent rows=20,000
  relispartition=true

detach concurrently:
  top-level PG14+ command
  child rows=20,000
  parent rows=0
  relispartition=false
  filenode unchanged

rollback rehearsal:
  verify data/bound still valid
  reattach

final:
  parent rows=20,000
  child attached
  filenode unchanged throughout

这里“回退”是结构性 reattach,因为 detach 后没有允许 child 与 parent 的写路径发生分叉。若 detach 后:

  • child 单独接收写入;
  • parent 在同一 bound 又建立新 partition;
  • constraint 被修改;
  • indexes/privileges/schema 发生漂移;

reattach 就不再是机械 undo,需要 reconciliation 和新的 lock/scan 评审。

发布 runbook

生产 attach 前:

1 verify target database/primary/role/search_path
2 verify parent/child identity and exact column shape
3 freeze or control writers to standalone child
4 validate bound CHECK and count violations=0
5 build/validate child indexes and constraints
6 handle DEFAULT exclusion
7 measure relation/index size and active transactions
8 set lock/statement timeout
9 observe service SLI, WAL, lag, disk
10 ATTACH
11 verify pg_inherits, relispartition, rows, pruning and privileges
12 keep standalone recovery plan until observation completes

detach 前:

1 identify PG major and DEFAULT restriction
2 decide ordinary vs concurrently
3 check long transactions/snapshots
4 define routing after detach
5 execute top-level command
6 inspect pending/final state
7 verify row count and new CHECK
8 archive/copy/drop only as separate destructive action

Detach 不等于删除

DETACH 保留 table 和数据,适合:

  • 归档前 COPY/backup;
  • 低频报表;
  • 数据压缩/汇总;
  • 独立验证;
  • 在明确边界下重新 attach。

DROP TABLE partition 则删除数据对象。不要在同一个“一键 retention”里把 detach、archive verification 和 drop 混成无法暂停的动作。

API 不应暴露 child identity

应用继续访问 logical parent:

SELECT ...
FROM event
WHERE occurred_at >= $1
  AND occurred_at < $2;

不要让业务 URL、job payload 或 ORM model 绑定 event_2025q1。否则每次 attach/detach 都变成 application contract 变更,物理生命周期无法独立演进。

本节验收问题

  1. ch04 ADR 的进入门是否真的被新证据触发;
  2. partition key 是否同时满足 lifecycle、query、NULL 与稳定性;
  3. global unique/PK/FK 语义是否重新设计;
  4. regular→partitioned 是否有新结构和切换路径;
  5. candidate child column shape 是否与 parent 完全一致;
  6. matching bound CHECK 是否 valid 且可被系统证明;
  7. ATTACH 的 parent/child/default locks 是否按目标版本写明;
  8. DEFAULT partition 的排除扫描是否处理;
  9. partitioned index 是否使用 per-child concurrent build/attach;
  10. DETACH CONCURRENTLY 是否 PG14+ 且位于 transaction block 外;
  11. pending detach/FINALIZE 与长 transaction 是否进入故障预案;
  12. detach 后写入是否可能让 reattach 失去可逆性;
  13. archive verification 与 destructive drop 是否分开授权;
  14. 应用是否只依赖 logical parent。

参考资料


上一节:索引与约束的在线化路径 · 返回本章目录 · 下一节:数据回填与流量切换 · 查看全书目录 · 查看索引中心

11.5 数据回填与流量切换

在线模式发布中,DDL 往往只占几秒,回填和流量切换却持续数小时或数天。真正的控制回路是:

measure
  → commit one bounded batch
  → observe database + replica + application watermarks
  → continue / slow / pause
  → verify semantic equality
  → switch a bounded traffic slice
  → observe / rollback traffic / advance

批量工作不能只追求“尽快跑完”;它要让前台服务始终优先,并让任意停止点都可解释。

11.5.1 批次、限速、水位与断点续跑

先选择稳定遍历方式

不要对不断变化的大表使用:

ORDER BY id
OFFSET 5000000
LIMIT 5000;

OFFSET 会反复扫描/跳过前缀,成本随进度增长;并发 insert/delete 还可能让页边界移动。优先 keyset:

WHERE id > :last_id
  AND new_value IS NULL
ORDER BY id
LIMIT :batch_size

ordering key 应:

  • 稳定不变;
  • 有确定顺序;
  • 可索引;
  • 能表达连续完成前缀;
  • 不因分区切换或业务更新而重排。

若没有单调 key,可以使用 range table、work queue 或显式 claim records;不要用 ctid 作为跨 transaction checkpoint,因为 UPDATE/VACUUM/rewrites 会改变它。

一批的事务边界

本章每批:

BEGIN
  lock migration state row
  select next unresolved key range
  lock target rows
  update derived representation
  assert no unresolved row <= new watermark
  update checkpoint/phase
COMMIT

关键不变量:

data batch committed ⇔ checkpoint committed

如果先提交数据、后写外部 checkpoint,进程可能重复处理;如果先推进 checkpoint、后提交数据,可能永久跳过。将 checkpoint 放在同一 PostgreSQL transaction 最简单。

跨系统 backfill 无法原子提交时,需要 idempotent sink、outbox/inbox、source offset 与 reconciliation,而不是假装有单库事务。

Batch size 不是常数

5000 只是本地 fixture 的演示值。生产 batch 由目标约束:

batch transaction duration
rows and bytes changed
WAL bytes/sec
replica replay lag
disk and IO latency
autovacuum/dead tuples
lock wait and user latency
connection pool saturation
remaining window

可采用反馈式控制:

if user latency or lag above stop threshold:
    stop after current commit
elif above slow threshold:
    reduce batch / increase sleep
elif safely below target for several windows:
    cautiously increase

不要在 transaction 内 sleep;commit 后再限速,让 locks、snapshot 和 transaction resources 及时释放。

三层水位

至少区分:

水位 例子 用途
progress last key, rows done, remaining 续跑与 ETA
safety WAL rate, lag, disk, lock wait, SLI pause/slow
correctness nulls, mismatches, constraint status switch gate

progress 变快不能覆盖 safety 超线;remaining=0 也不能覆盖 mismatch>0。

ETA 只能是动态估计:

remaining rows / recent safe throughput

如果 workload、row width、cache 或 replication 变化,早期吞吐不能外推全程。

SKIP LOCKED 的 checkpoint 陷阱

假设:

rows 1..100
row 20 locked
batch SKIP LOCKED selects 1..19,21..51
checkpoint=max(id)=51

下次 id > 51,row 20 永久遗漏。解决方式:

  • 不跳锁,短 lock_timeout 后暂停;
  • checkpoint 只推进连续完成前缀;
  • 记录 skipped ranges 并回访;
  • 使用 claim/work table;
  • 最终做 full unresolved sweep。

本章选择“不跳锁 + 连续前缀 assertion”。第 10 章 SKIP LOCKED 适合可替代任务队列,不应机械搬到所有 backfill。

受控中止与重入

第一次运行:

./backfill.py \
  --service pg36-admin \
  --batch-size 5000 \
  --max-batches 2 \
  --output backfill-interrupted.json

结果:

exit=75
start.phase=expanded
end.phase=backfilling
batches_run=2
rows_migrated=10,000
remaining_nulls=39,999
committed_batches_preserved=true

第二次没有 --max-batches

start.phase=backfilling
start.last_order_id=<previous end>
batches_run=8
end.phase=migrated
end.rows_migrated=49,999
end.batches=10
remaining_nulls=0
mismatches=0

review 比较两份 JSON 的 exact checkpoint relationship,而不要求动态时间或 PID 相同。

11.5.2 影子读、双写的风险与一致性验证

双写为什么危险

双写可能指:

  1. 同一数据库 transaction 内写两列/两表;
  2. 两个独立 database transaction;
  3. 数据库 + cache;
  4. 数据库 A + 数据库 B;
  5. 数据库 + message/API。

只有第一种天然共享 PostgreSQL 原子性。其他都需要:

idempotency
ordering
retry semantics
outbox/inbox or log authority
reconciliation
partial failure handling

“应用会同时写两边”没有说明断连、timeout、retry、reordering 和版本漂移。

同库双表示也会漂移

即使在一行内:

shipping_method='express'
shipping_code='STD'

如果每个 application 都独立实现 mapping,版本差异或 bug 仍会制造矛盾。本章用:

  • temporary trigger 派生 old-only write;
  • new application dual-write;
  • named CHECK 拒绝 mismatch;
  • shadow query 比较全量;
  • final NOT NULL;

组成 closed loop。

负向 case:

shipping_method=express
shipping_code=STD

必须:

SQLSTATE=23514
constraint=ch11_order_shipping_pair_consistent
row absent after subtransaction rollback

只断言“插入失败”不够;错误可能来自 unique、权限、语法或其他 constraint。

Trigger bridge 的代价

bridge trigger 是迁移工具,不是永久隐藏层。它会:

  • 增加每次写入成本;
  • 改变显式 NULL 的语义;
  • 影响 COPY/replication/repair paths;
  • 让 application 看不见数据库自动补值;
  • 增加 function/owner/search_path/privilege 审查面;
  • 在 contract 时形成依赖。

本章 function 是 invoker,不需要 SECURITY DEFINER。若必须 definer,继续遵守 SAFE-DEFR-004:固定可信 search_path、受控 owner、撤销 PUBLIC EXECUTE、schema-qualify object。

bridge 必须有:

owner
installation phase
invocation telemetry
removal gate
expiration
test for old/new/mismatch

否则临时双写会变成多年永久复杂度。

Shadow read 的三种精度

模式 优点 风险
database exact count 全量、简单 可能昂贵,只覆盖库内等价
application synchronous compare 贴近真实 decoder 增加请求延迟/负载
async sampled compare 对前台影响小 采样偏差、时序差

对于大表,可以分 bucket:

WHERE id >= :lo
  AND id < :hi
  AND old_normalized IS DISTINCT FROM new_normalized

保存:

range
snapshot/watermark
mapping version
row count
mismatch count
sample identities
query duration/buffers

如果比较在不同 snapshot 或跨异步系统执行,要区分真实不一致与复制/传输 lag。

Read fallback 会隐藏债务

新代码常写:

if new_value is null:
    derive from old_value

这有利于早期兼容,但如果没有 fallback counter 和 expiry:

  • backfill 漏行不会暴露;
  • new write bug 会被掩盖;
  • contract 前无法证明依赖清零;
  • 读取可能永久使用旧语义。

进入 switch 前要做到:

remaining null=0
mismatch=0
constraint valid
fallback count=0 for observation window

然后再删除 fallback,最后才删除 old representation。

11.5.3 何时暂停、回退或前滚

三种动作不是同义词

pause:
  stop creating more change after current safe commit

rollback traffic:
  route reads/writes back to a compatible application path
  while database remains expanded

forward repair:
  keep monotonic schema state and fix data/code/config ahead

数据库 schema 在 expand 后通常不需要立刻 down。只要 old path 仍兼容,可以先回退 application traffic,再修复 new path。

Stop conditions 要量化

发布前定义,例如:

any unexpected SQLSTATE or constraint identity
lock wait > 2s or queue depth > N
p95/p99 exceeds agreed budget for M windows
replica replay lag > threshold
WAL retention/disk free crosses threshold
checkpoint fails to advance
mismatch > 0
unexpected null debt increases
worker retry/error rate > threshold
primary role changes/failover begins
monitoring blind spot

数值必须来自实际 SLO/capacity,不应从本章本地数字照抄。停止条件还要指定:

  • 谁有权 pause;
  • 当前 batch 是否允许 commit;
  • 如何防止 orchestrator 自动重启;
  • 下一次 resume 需要哪些复核;
  • incident/change owner 如何交接。

选择 rollback traffic

适合:

database expanded and backward compatible
new read/write shows application regression
old path still receives complete old representation
no contract executed

动作:

stop rollout/flag
route to old version
keep bridge/dual write
capture new-path evidence
repair forward

若 new writer 已经产生 old application 无法理解的数据,即使 old column 存在也不能安全 rollback;这必须在兼容矩阵提前证明。

选择 forward repair

适合:

  • backfill 中少量 deterministic bad rows;
  • constraint validation 发现历史 violation;
  • migration 已提交且 down 会丢失更多语义;
  • schema 已扩展,旧路径仍正常;
  • contract 尚未发生。

例如:

constraint convalidated=false
new rows still enforced
bad historical rows identified

保留 constraint,修数据,再 validate,通常优于 drop constraint 退回无保护状态。

何时需要 restore/PITR

只有当:

  • destructive contract 已丢失不可重建语义;
  • 大范围错误写入无法可靠 reconciliation;
  • catalog/physical corruption;
  • forward repair 风险高于恢复;

才进入 restore/PITR 决策。它不是轻量 undo。必须明确:

restore target and isolation
RPO/RTO
other databases/tenants impact
replay/cutover plan
post-restore reconciliation

多数模式发布问题应在 contract 之前被兼容窗口和 gate 截住。

Decision table

证据 动作
safety waterline crossed, current batch healthy commit batch, pause
lock timeout before DDL state change observe blocker, reschedule/retry
backfill process exits between batches resume from checkpoint
validation finds old bad rows keep NOT VALID, repair forward
new application SLI regress, old path compatible rollback traffic
mismatch or null debt grows stop switch, fix writer/bridge
old dependency still observed block contract
destructive loss already occurred forward reconstruct or restore

变更窗口结束不等于强行完成

如果窗口将结束而只完成 60% backfill,安全结果可以是:

phase=backfilling
old/new compatibility retained
checkpoint durable
temporary index retained and documented
owner + next window + expiry recorded

强行扩大 batch、取消 timeout 或在监控盲区继续,只是把进度指标置于数据与服务安全之上。

本节验收问题

  1. keyset ordering 是否稳定,checkpoint 是否表示连续完成前缀;
  2. batch data 与 checkpoint 是否同事务;
  3. size/rate/sleep 是否由 safety watermarks 调节;
  4. SKIP LOCKED 是否可能制造永久遗漏;
  5. controlled exit 与 unexpected failure 是否可区分;
  6. 双写的原子性范围和 authority 是否明确;
  7. mismatch 反例是否核对 SQLSTATE 与 constraint identity;
  8. trigger/fallback 是否有 owner、telemetry、expiry 和 removal gate;
  9. shadow comparison 是否处理 snapshot/lag/normalization;
  10. pause、traffic rollback、forward repair 是否分别定义;
  11. stop conditions 是否量化并绑定 owner;
  12. 窗口结束时是否允许安全停在中间 phase。

上一节:在线分区化 · 返回本章目录 · 下一节:发布窗口中的平台观察 · 查看全书目录 · 查看索引中心

11.6 发布窗口中的平台观察

数据库 catalog 只能回答“当前对象是什么、谁在等谁”;发布平台还要回答“哪条流量进入哪里、影响面多大、过去几分钟发生了什么、是否仍在安全水位内”。

一次发布观察闭环:

change identity + UTC window
  → exact Pigsty service / database / role / application_name
  → PostgreSQL live catalog
  → Prometheus/Grafana historical trend
  → application release + SLI/error evidence
  → pause/switch/contract decision

仪表盘不是 DDL 正确性的替代品;SQL postcheck 也不是容量与用户影响的替代品。

11.6.1 从服务入口隔离实验流量

Pigsty 的四类默认服务

Pigsty v4.5 的默认 PostgreSQL 服务:

Service Port 默认路径 典型用途
primary 5433 HAProxy → primary pool 生产读写
replica 5434 HAProxy → replica pool 生产只读
default 5436 HAProxy → primary PostgreSQL DBA、ETL、直接写
offline 5438 HAProxy → offline/replica PostgreSQL OLAP、离线、交互

端口与 selector 来自 pg_default_services / pg_services 配置,以目标集群的当前 Pigsty Service 文档 和 inventory 为准。

模式变更不应随便借用 production application pool:

  • session-level DDL 设置可能被 pool 复用污染;
  • transaction pooling 可能不支持所需 session 语义;
  • maintenance traffic 与用户请求难以区分;
  • pool timeout 与 migration timeout 可能互相覆盖;
  • 连接数/排队会混入应用 SLI。

通常使用受控 DBA/default direct service 或专用 migration service;仍必须确认它路由到 writable primary。

“连到 5436”仍不是目标证明

连接后第一条证据:

SELECT
    current_database(),
    session_user,
    current_user,
    current_setting('server_version'),
    pg_is_in_recovery(),
    current_schemas(false),
    current_setting('application_name');

还要验证:

  • cluster/instance identity;
  • expected schema version;
  • object owner/marker;
  • target relation OID/definition;
  • transaction pooling 是否绕过;
  • connection string 没有展开 secret;
  • role 能力只覆盖本次 change。

错误 primary 上的正确 DDL 仍是事故;正确 primary 上的错误 database 也是。

本章 service file 只保存受控连接 identity,脚本使用:

service=pg36-admin
application_name=pg36-ch11-<phase>

manifest 记录 service name,不展开密码/URI。

用 application_name 切出发布流量

每个 phase 使用稳定前缀:

pg36-ch11-lock-holder
pg36-ch11-lock-waiter
pg36-ch11-backfill
pg36-ch11-index-build
pg36-ch11-partition-attach
pg36-ch11-partition-detach

这样可以在:

SELECT
    pid,
    backend_start,
    application_name,
    state,
    xact_start,
    query_start,
    wait_event_type,
    wait_event,
    left(query, 300)
FROM pg_stat_activity
WHERE application_name LIKE 'pg36-ch11-%';

区分 phase,保存 live evidence,并让 reset 在 worker 存活时 fail closed。

application_name 是可伪造 label,不是 authentication/authorization。取消 session 前仍要组合:

database + pid + backend_start + role + client + application + query identity

防止 PID reuse 或同名前缀误伤。

隔离流量不等于隔离资源

专用 service/role/application_name 改善 attribution 和权限边界,却仍共享:

  • primary CPU/IO/buffer;
  • WAL 与 replication;
  • disk;
  • autovacuum;
  • lock manager;
  • checkpoint;
  • network;
  • replicas 的 replay。

因此 L1 演练服务不能证明生产容量。若要在生产 shadow/backfill,必须设置实际资源和 SLI 水位。

不要为一次 DDL 临时改平台配置

例如为了让 backfill 更快,随手全局修改:

max_wal_size
checkpoint_timeout
autovacuum
statement_timeout
work_mem
max_parallel_maintenance_workers

会把一个 schema change 变成 schema + config 联合 change,扩大变量和恢复面。优先使用 session/local setting;确需 config change 时,独立变更单、独立 evidence 和独立复位。

11.6.2 观察锁、复制延迟、WAL 与资源水位

从发布 SLI 开始

发布期间先看用户影响:

request success/error rate
p50/p95/p99 latency
timeout/retry rate
queue depth
connection acquisition latency
business invariant errors

再解释数据库侧原因。一个 WAL spike 如果不影响 SLO 且在预算内,可能可接受;一个没有明显 CPU spike 的 lock convoy 也可能让 tail latency 立即恶化。

Pigsty dashboard 路线

Pigsty v4.5 当前文档列出的相关面板:

PGSQL Activity:
  sessions, load, active/idle, locks

PGSQL Xacts:
  transaction rate/time, rollback, lock

PGCAT Locks:
  live activity and lock waits from catalog

PGSQL Query / PGCAT Query:
  affected query family, calls, latency, plan/stat context

PGSQL Persist:
  WAL, checkpoint, archive, IO, XID

PGSQL Replication:
  physical/logical replication, slots, pub/sub

PGSQL Service / Proxy / PgBouncer:
  routing, backend health, queue and pool

PGSQL Instance / NODE dashboards:
  CPU, memory, disk, filesystem, IO, network

名称与布局会升级,以当前 Pigsty Dashboard 文档 为准,不把截图坐标或 panel ID 写进长期 runbook。

锁观察必须落回 exact graph

历史曲线回答:

when did lock waits rise?
how many sessions and how long?
which database/cluster?
did user latency rise together?

live catalog 回答:

SELECT
    waiter.pid AS waiter_pid,
    waiter.backend_start AS waiter_epoch,
    waiter.application_name AS waiter_app,
    waiter.wait_event_type,
    waiter.wait_event,
    blocker.pid AS blocker_pid,
    blocker.backend_start AS blocker_epoch,
    blocker.application_name AS blocker_app,
    blocker.xact_start,
    blocker.state
FROM pg_stat_activity AS waiter
CROSS JOIN LATERAL unnest(
    pg_blocking_pids(waiter.pid)
) AS edge(blocker_pid)
LEFT JOIN pg_stat_activity AS blocker
  ON blocker.pid = edge.blocker_pid;

对 DDL 还要查 relation lock:

SELECT
    activity.application_name,
    lock.relation::regclass,
    lock.mode,
    lock.granted,
    lock.waitstart
FROM pg_locks AS lock
JOIN pg_stat_activity AS activity
  ON activity.pid = lock.pid
WHERE lock.relation IS NOT NULL;

看到 AccessExclusiveLock 不能直接杀 session;先确定 holder、waiter、队列和业务影响。

WAL 观察不是只看一个 counter

模式变更会影响:

WAL generation rate
WAL directory size
archive throughput/failure
replication slot retained WAL
replica receive/replay lag
checkpoint frequency and write pressure
network throughput
backup/PITR window

单节点本章用 pg_current_wal_insert_lsn() 前后差作为相对 A/B;生产需时间序列。counter 应使用 rate/increase,并注意 restart/reset epoch。还要区分:

  • generated WAL;
  • sent/received WAL;
  • replay progress;
  • slot restart_lsn retention;
  • archive queue。

主库 lag 为零不代表 replica 已应用;bytes lag 与 time lag也不能互换。

Replication lag 的停止线

backfill/validation/CREATE INDEX 可让 replica:

  • replay 落后;
  • read query 与 replay 冲突;
  • WAL 堆积占盘;
  • failover 后 RPO/RTO 风险增加。

停止条件应同时绑定:

max replay bytes/time lag
max retained WAL / minimum disk free
replica read SLI
archive success
failover readiness policy

如果发布时 primary failover,默认暂停 change,重新验证:

new primary identity
committed schema phase
checkpoint state
concurrent index/partition intermediate state
worker ownership
application routing

不能假设脚本连接自动漂移后可从上一行继续。

资源水位与 workload attribution

至少保存:

维度 观察 解释
CPU instance/node usage transform/index build CPU
IO read/write latency/throughput scan/rewrite/checkpoint
disk data/WAL/temp free rewrite double space/retention
memory buffer/cache/OS pressure scan cache displacement
connection app/admin/pool queues migration contention
vacuum dead tuples/xmin/age backfill cleanup debt
query user + migration families regression attribution

发布 connection 的 application_name、UTC window 和 query identity 让这些图能与具体 phase 对齐。

指标盲点也是停止条件

如果 exporter、Grafana、logs、application telemetry 任一关键链路不可用:

cannot observe ≠ no impact

对于高风险 rewrite/backfill/contract,应暂停,而不是在盲区继续。监控恢复后先补采 live state,再决定 resume。

11.6.3 配置变更与模式变更分别留证

三种 identity

一次完整发布至少有:

application release:
  image/git SHA, rollout/flag, version population

database migration:
  migration ID, source checksum, phase, schema post-state

platform/config change:
  inventory commit, rendered diff, apply task, runtime value

它们相互引用,但不能共享一个模糊“release-2026-07-29”后失去可归因性。

Database evidence

本章 evidence:

manifest.txt
preflight.txt
setup/expand/validate/switch outputs
lock-attempt.stderr
lock-graph.csv
default-catalog.csv
constraint-before.csv
constraint-after.csv
backfill-interrupted.json
backfill-resumed.json
partition-summary.json
contract-gate.stderr
verify.txt
model-verify-after.txt
review.txt

manifest 保存:

  • UTC capture time;
  • action/service;
  • psql/Python/server version;
  • database/user/recovery;
  • source SHA-256。

它不保存 secret,也不把 dynamic PID/filenode/timing 当 golden。

Pigsty config evidence

如果本次确实改变 pg_services、PostgreSQL 参数或监控配置,另存:

inventory/config source commit
target cluster/instance selector
rendered before/after diff
validation/lint output
exact Ansible tag/command
changed vs restarted instances
pg_settings source/sourcefile/pending_restart
service routing/health postcheck
rollback/reapply command

不要只保存 SHOW parameter。runtime value 不能证明它来自哪个 inventory version,也不能说明 restart 后会不会保持。

反过来,config repo diff 也不能证明 runtime 已生效;两边都要。

Schema migration 不应修改不可变历史

baseline-v0.6-proposal.json 不改写 v0.1 baseline,而是:

base checksum
  + dependency v0.5 checksum
  + SAFE-MIGR-006 statement change
  + DEFAULT-VERS-010 runtime check change
  + ch11 evidence paths
  + promotion conditions

同理,生产 migration 文件一旦发布,不应原地改内容后保留同 migration ID。若修复:

  • 发布新 migration identity;
  • 说明依赖和前置 phase;
  • 保留旧 artifact/checksum;
  • fresh install 继续由同一权威链生成或验证。

Change log 与 evidence package

建议目录:

change-id/
  intent.md
  approvals/
  application/
  database/
  platform/
  observations/
  decision-log.md
  final-review.json

decision log 记录:

timestamp UTC
observer/decision owner
current phase
watermarks
continue/slow/pause/switch/contract
reason and evidence links
next review time

这样事故复盘能回答“当时基于什么证据继续”,而不是只有最后成功/失败。

敏感信息与保留期

SQL、query parameter、client address、logs、application payload 可能包含:

  • customer identifiers;
  • tokens/credentials;
  • financial/personal data;
  • internal topology。

证据包要:

redact secrets and payload
retain stable hashes/IDs when enough
apply access control
declare retention and deletion
preserve chain of custody for incidents

不要为了“完整证据”把 password-bearing URI、PGPASSWORD 或完整用户 payload 写入 artifact。

Final review 的职责边界

自动 review 可以确认:

files exist
checksums/dependencies match
SQLSTATE and catalog relationships
checkpoint monotonicity
data checksums and cleanup

人/发布系统仍要确认:

production SLI acceptable
replication/disk within budget
old consumers truly zero
observation window elapsed
contract authority granted

让工具拒绝它不能证明的 contract,是质量而不是自动化不完整。

本节验收问题

  1. 发布使用哪个 Pigsty service、port、pool/direct path;
  2. 连接后是否再次验证 database/role/primary/schema;
  3. 每个 phase 是否有独立 application_name;
  4. service 隔离与资源隔离是否被正确区分;
  5. user SLI、lock、WAL、lag、disk、pool 是否同窗观察;
  6. lock metric 是否能落回 exact blocker graph;
  7. failover 后是否默认暂停并重新发现 state;
  8. 监控盲点是否是明确停止条件;
  9. application、database、platform identity 是否分离;
  10. config source diff 与 runtime postcheck 是否都有;
  11. migration/source artifacts 是否 checksum 固定且不可变;
  12. evidence 是否脱敏、有权限和保留期;
  13. 自动 review 是否诚实拒绝无法证明的生产事实。

参考资料


上一节:数据回填与流量切换 · 返回本章目录 · 下一节:实战:无中断演进订单模式 · 查看全书目录 · 查看索引中心

11.7 实战:无中断演进订单模式

本节把模式发布做成一条可重复、可中止、可复核的证据链:

target guard
  → legacy fixture
  → physical-risk A/B
  → lock failure with unchanged catalog
  → compatible expand
  → old/new writer matrix
  → concurrent temporary index
  → controlled backfill interruption
  → exact resume
  → validate and SET NOT NULL
  → switch while retaining rollback shape
  → contract refusal
  → partition attach/detach rehearsal
  → model checksum and proposal review

实验不把生产“无中断”承诺缩成一次本地脚本成功。它证明机制和状态关系;真实服务 SLI、复制水位与旧依赖清零仍需在目标 Pigsty 发布窗口完成。

11.7.1 加字段、回填、建约束与新旧版本共存

确认 disposable L1

准备权限受控的 service file:

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

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

只在已确认可写、可重建的 L1/本地目标继续。本章脚本再次验证:

database=pg36_shop
writable primary
effective role=pg36_owner
search_path=pg_catalog,shop_private
ch04-v1 marker
ch11 object marker

无 service file、错误 database、recovery target、role/path/model 不符都会 fail closed。

全量入口:

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

可独立运行:

setup | risk | expand | backfill | validate
partition | verify | review | reset | all

riskvalidate 会重建专用 fixture;verify 只检查当前完整状态。

Legacy fixture

setup.sql 建立 50,000 行:

CREATE TABLE shop_private.ch11_order (
    order_id bigint PRIMARY KEY,
    order_ref text NOT NULL UNIQUE,
    shipping_method text NOT NULL,
    created_at timestamptz NOT NULL,
    payload text NOT NULL,
    CONSTRAINT ch11_order_shipping_method_check
      CHECK (
        shipping_method IN
          ('standard', 'express', 'pickup')
      )
);

以及单行 state:

migration_id=shipping-code-v1
phase=legacy
source_rows=50,000
target_rows=NULL
rows_migrated=0
batches=0
last_order_id=0

shipping_code 必须不存在。setup 只在所有已存在同名对象 marker 匹配时才重建,防止误删用户对象。

先测 default/rewrite 风险

default-probe.sql 对另一张 50,000 行 disposable table 做 A/B:

ADD fast_flag integer NOT NULL DEFAULT 7
ADD volatile_stamp timestamptz
    NOT NULL DEFAULT clock_timestamp()

一次 raw result:

fast:
  before_filenode=20116
  after_filenode=20116
  atthasmissing=true
  attmissingval={7}
  WAL=12088

volatile:
  after_filenode=20129
  atthasmissing=false
  WAL=11488328

review 只要求:

before_filenode == fast_filenode
fast_filenode != volatile_filenode
fast atthasmissing
not volatile atthasmissing
volatile WAL > fast WAL
row_count=50,000

具体 OID/WAL 随环境变化。

Expand 前先让锁预算失败

run_lock_case.py

holder:
  ACCESS SHARE on ch11_order

waiter:
  ADD COLUMN shipping_code text
  requests ACCESS EXCLUSIVE
  lock_timeout=4s

observer:
  one pg_blocking_pids edge

结果:

lock=55P03
requested=AccessExclusiveLock
blocker_edges=1
shipping_code remains absent
holder released by COMMIT

只有这条 negative path 通过,才执行 expand.sql

phase legacy required
ADD nullable shipping_code
install bridge function/trigger
ADD pair CHECK NOT VALID
phase=expanded in same transaction

这验证 timeout 后可安全重新评估并重试,而不是声称所有 55P03 都可以自动循环。

旧/新版本共存

compatibility.sql 运行四条合同:

old insert:
  method=standard, code omitted
  → standard/STD

old update:
  existing legacy row method express
  → express/EXP

new insert:
  method=pickup, code=PUP
  → pickup/PUP

bad dual write:
  method=express, code=STD
  → 23514
  → constraint=ch11_order_shipping_pair_consistent
  → row absent

此时:

total rows=50,002
legacy NULLs=49,999
pair constraint convalidated=false
attnotnull=false

其中一条旧行已经被 old update 顺带填充,两个新 insert 都由 bridge/dual write 提供 code,所以 backfill target 不是 50,000,而是 49,999。审查器固定这个关系,防止统计口径漂移。

Temporary partial index

online-index.sh 在 transaction block 外:

CREATE INDEX CONCURRENTLY
    ch11_order_shipping_missing_idx
ON shop_private.ch11_order (order_id)
WHERE shipping_code IS NULL;

build 后验证:

indisready=true
indisvalid=true
indpred=(shipping_code IS NULL)
indrelid=exact ch11_order

backfill 完成并验证后,再 exact:

DROP INDEX CONCURRENTLY
    shop_private.ch11_order_shipping_missing_idx;

最终 verify 要求该 migration-only index 不存在。

Backfill 与约束收紧

第一次:

batch size=5,000
max batches=2
exit=75
rows migrated=10,000
remaining=39,999
phase=backfilling

续跑:

eight more batches
total migrated=49,999
total batches=10
last_order_id=50,000
remaining=0
mismatches=0
phase=migrated

validate.sql

ALTER TABLE ch11_order
VALIDATE CONSTRAINT
    ch11_order_shipping_pair_consistent;

ALTER TABLE ch11_order
ADD CONSTRAINT ch11_order_shipping_code_nn
CHECK (shipping_code IS NOT NULL)
NOT VALID;

ALTER TABLE ch11_order
VALIDATE CONSTRAINT
    ch11_order_shipping_code_nn;

ALTER TABLE ch11_order
ALTER COLUMN shipping_code SET NOT NULL;

PostgreSQL 18.6 目录:

ch11_order_shipping_pair_consistent | c | valid
ch11_order_shipping_code_nn         | c | valid
ch11_order_shipping_code_not_null   | n | valid
pg_attribute.shipping_code          | attnotnull=true

SET NOT NULL 前后 relfilenode 相同。PG14–17 review 分支不要求 relation contype=n

Switch 保留回退形状

switch.sql 先做 full shadow comparison,再分别运行一个 old/new writer:

shadow mismatches=0
old writer after switch succeeds
new writer remains dual-write
phase=switched
shipping_method retained
bridge retained

最终总行数:

50,000 baseline
+2 coexistence
+2 switch probes
=50,004

读取可以切到 shipping_code,但数据库继续支持旧 application rollback。

11.7.2 注入锁等待和回填中断,验证中止与恢复

Evidence 不是最终一句 PASS

一次全量目录:

manifest.txt
preflight.txt
setup.txt

default-probe.txt
default-catalog.csv

lock-holder.stdout/stderr
lock-attempt.stdout/stderr
lock-graph.csv
lock-summary.json

expand.txt
index-build.txt
compatibility.txt
constraint-before.csv

backfill-interrupted.json
backfill-resumed.json

validate.txt
constraint-after.csv
index-drop.txt
switch.txt

contract-gate.stdout/stderr/exit

partition-prepare.txt
partition-attach-holder.txt
partition-detach.txt
partition-summary.json

verify.txt
model-verify-after.txt
review.txt

raw evidence 保存动态值,review 比较稳定关系。

锁注入的 happens-before

协调器不是用 sleep 1 猜时序:

  1. interactive holder 完成 LOCK ... ACCESS SHARE
  2. holder 输出 readiness marker;
  3. 才启动 DDL waiter;
  4. observer 循环直到捕获 exact blocker edge;
  5. waiter 以 55P03 结束;
  6. 检查 column count=0;
  7. 向 holder 发送 COMMIT,不是强杀 backend。

这固定关键顺序而不固定 PID/时间。异常退出时 finally 只处理自己启动的 child process,并尝试 rollback。

回填中止的原子边界

--max-batches=2 在第二次 commit 后由 client 主动退出,模拟:

  • maintenance window 到达停止线;
  • safety waterline 要求暂停;
  • orchestrator 有计划让出资源。

它证明 between-batch restart。每个 batch 内:

target updates
checkpoint updates
phase updates

同事务,因此 disconnect/error 时一起 rollback。

审查器比较:

resume.start.last_order_id
  == interrupted.end.last_order_id

resume.end.rows_migrated
  == target_rows

resume.end.remaining_nulls
  == 0

resume.end.last_order_id
  == 50,000

还要求 mismatches=0 与 total batches=10,不能仅看 phase 字符串。

Contract 是故意失败的验收项

全量流程运行:

psql --file=contract-gate.sql

但不提供 token/target/observation。期望:

exit=3
SQLSTATE=P3612
message=contract refused

随后 final verify 要求:

release=switched/contract:not-executed
compatibility=legacy-column+bridge-retained

如果 contract 意外成功,verify.sql 会因 old column/bridge 缺失而失败。负向 gate 与正向 post-state 双重闭合。

分区实验的可逆边界

partition_lab.py

prepare standalone child + valid CHECK
  → ATTACH inside held transaction
  → observer captures SUE + AX
  → COMMIT
  → DETACH CONCURRENTLY in top-level command
  → verify rows and filenode retained
  → reattach

一次结果:

server_version_num=180006
before child rows=20,000 / parent=0
after attach child is partition / parent=20,000
after detach child standalone / rows=20,000 / parent=0
final reattached / parent=20,000
same child filenode across all states

elapsed ms 被记录,但不参与 PASS。

最终数据库验收

verify.sql 要求:

active pg36-ch11 workers=0
release phase=switched
checkpoint=49,999 rows / 10 batches / last id 50,000
orders=50,004
NULL/mismatch=0
pair + nn CHECK valid
attnotnull=true
old column + bridge present
temporary index absent
default A/B relationship intact
partition attached / 20,000 rows

再运行 ch05 model verify:

relation_checksum=
  f8a7bfae59c6d16cd323abecfefe1014

证明专用实验没有改变 shop.* 业务基线。

Reset 与负向安全

all 保留 fixture 供人工复核。删除属于 R2:

export PG36_RESET_TOKEN=RESET_CH11_RELEASE_LAB
export PG36_RESET_TARGET=pg36_shop/shop_private/ch11
./task.sh reset

reset 前要求:

  • action token exact;
  • database/schema/chapter target exact;
  • 所有同名 relation/function marker exact;
  • 没有活跃 pg36-ch11-* worker。

成功:

status=ok
reset_target=pg36_shop/shop_private/ch11
remaining_ch11_relations=0
remaining_ch11_functions=0
business checksum unchanged

错误 token、错误 target 或 active worker 必须 psql exit=3 且 state 保持。

11.7.3 把发布证据与新规则追加到规约

Review summary

一次 PostgreSQL 18.6 审查:

status=ok
risk=lock:55P03/schema-unchanged/
     default:metadata-vs-rewrite
wal=fast:12088/volatile:11488328
compatibility=old+new/mismatch:23514/contract:P3612
backfill=interrupt:2x5000/
         resume:49999-in-10/remaining:0
constraints=not-valid->valid->not-null/
            catalog:pg18-pg_constraint+pg_attribute
partition=attach:SUE+AX/
          detach-concurrently:180006/
          retained:20000
proposal=0.1.0->0.6.0/
         SAFE-MIGR-006+DEFAULT-VERS-010/
         depends-on-v0.5
final=switched/contract:not-executed/
      checksum:f8a7bfae59c6d16cd323abecfefe1014

WAL 数字仅为该次观测;关系和规则才是 golden。

SAFE-MIGR-006 的 v0.6 change

baseline-v0.6-proposal.json 提议把原 statement:

类型收窄、列删除、表重写或约束收紧前完成
precheck、timeout、兼容、恢复和 post-state;
contract 与 application rollback 共同设计。

收紧为:

跨应用版本 DDL 必须划分:
  expand / migrate / validate / switch / contract

执行前验证 target,并声明:
  lock + statement timeout
  compatibility window
  scan/rewrite/WAL/replica-lag budget
  batch and stop conditions
  restart semantics
  post-state

contract only after:
  old readers/writers zero
  real observation evidence

lost semantics:
  forward repair or restore
  never an empty-shell down migration

这不是把所有小 DDL bureaucratize。scope 是跨 application version 或 destructive/high-impact change;低风险变更仍按实际风险分级。

DEFAULT-VERS-010 的 runtime check

statement 不改,新增 check:

every migration phase has:
  stable migration identity
  monotonic queryable state

interruption:
  resumes from committed checkpoint
  cannot skip unresolved data

本章证据:

  • phase 与 DDL 同事务;
  • checkpoint 与 batch 同事务;
  • two-batch interruption;
  • exact resume;
  • zero unresolved before watermark;
  • final catalog/data checksum。

Proposal 依赖链

v0.6 保存:

immutable v0.1 canonical checksum
v0.5 proposal canonical checksum
rule_changes
evidence paths
promotion conditions

review.py 每次重算 canonical JSON checksum。依赖 artifact 改动后 checksum 不匹配,v0.6 fail;不能静默引用一个同名但内容已变的 proposal。

它仍是 candidate。晋升条件:

  1. ch11 suite 在 PG14–18 compatibility matrix 通过;
  2. PG18 NOT NULL catalog 分支与 PG14–17 分支都实测;
  3. 至少一个真实 Pigsty HA 发布窗口保存 lock/WAL/lag/disk/tail evidence;
  4. application owner 证明旧 read/write 清零;
  5. 约定 rollback observation window 真实经过;
  6. v0.2–v0.5 依赖先晋升;
  7. 再发布不可变 baseline v0.6 artifact。

本地 PASS 不冒充这些条件已经成立。

从实验模板迁移到真实 change

替换 fixture 时,不要只改 table name。必须重新设计:

domain mapping and reversibility
old/new application versions
row ordering and partitioning
batch size and watermarks
temporary/permanent indexes
constraint identity
Pigsty service/role
replica and disk thresholds
switch/rollback mechanism
consumer inventory
contract evidence and authority

保留 harness 的结构:

context guard
negative lock case
compatibility matrix
controlled interruption
catalog before/after
final checksum/invariants
safe reset for disposable rehearsals
candidate rule evidence

本章最终交付

本章完成后,读者不应只会写:

ALTER TABLE ...;

而应能提交一个 change package:

intent + risk classification
version compatibility matrix
state machine
SQL/migration artifacts
backfill/checkpoint code
Pigsty observation plan
stop/rollback/forward-repair decisions
raw evidence + automated review
contract gate
post-state and governance proposal

这才是“安全发布”可复用的工程能力。

本节验收问题

  1. 全量任务能否从 clean fixture 重复运行;
  2. default A/B 是否保存 physical 与 WAL 关系;
  3. lock case 是否有 exact edge、55P03 与 unchanged catalog;
  4. old/new/mismatch compatibility cases 是否全部通过;
  5. temporary index 是否事务块外 build/drop;
  6. interrupted/resumed JSON 的 checkpoint 是否连续;
  7. constraints before/after 与 PG version branch 是否正确;
  8. switch 后 old rollback shape 是否仍在;
  9. contract 是否因缺证以 P3612 拒绝;
  10. partition ATTACH locks 和 DETACH boundary 是否实测;
  11. worker、temporary object 与 business checksum 是否闭合;
  12. reset 是否双 token、marker、active-worker 三重保护;
  13. v0.6 checksum/dependency/rule IDs 是否由 review 验证;
  14. candidate 是否诚实保留生产 promotion conditions。

参考资料


上一节:发布窗口中的平台观察 · 返回本章目录 · 下一章:一气呵成:从数据库契约到后端服务 · 查看全书目录 · 查看索引中心

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

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

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

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

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

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

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

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

本章目标

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

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

参考服务与实验边界

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

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

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

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

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

另外提供:

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

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

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

实验模式保存:

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

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

下载资产

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

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

本章验证的 frozen 组合是:

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

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

本章目录

12.1 数据库契约与应用边界

12.2 为服务设计查询接口

12.3 Go 服务中的连接与事务

12.4 会话状态与连接池陷阱

12.5 服务级可观测性

12.6 部署与接入 pg36_shop

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

实测摘要

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

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

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

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

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

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

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

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

v1.0 artifact 的 canonical checksum 为:

c85a930af366a9e96be7a0e166d3d0c04faace778208743718af51f633d8044d

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

章节验收

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

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

参考资料


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

12.1 数据库契约与应用边界

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

what may be sent
what PostgreSQL guarantees after commit
what may be returned
how failure is identified
which application/database versions may coexist
how an operator proves the target is the intended target

这些约定合起来才是数据库契约。它不是一份 ORM model,也不是只有列名的 schema 文档,而是应用与数据库可以分别验证的行为边界。

12.1.1 模式、查询、错误与兼容性契约

五张合同,而不是一张 ER 图

把数据库契约拆成五个互相引用、但可以独立评审的部分:

合同 要回答的问题 机器证据
schema 哪些对象、类型、约束与权限必须存在 migration ID、catalog、constraint name
query 参数与结果的类型、顺序、基数、排序是什么 SQL text、fixture、result assertions
error 哪些失败可区分,如何映射到领域语义 SQLSTATE、constraint/routine identity
compatibility 哪些 app/schema 版本组合可共同运行 expand/switch/contract matrix
operations 连接到谁、以谁运行、何时算 ready database/user/recovery/service/metrics

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

ALTER TABLE shop_ch12.sales_order
    ADD COLUMN note text;

对显式列查询可能兼容;对 SELECT * 加位置扫描、按列数解码或缓存 result description 的客户端可能不兼容。数据库的物理变更很小,不代表 query contract 不变。

反过来,一条 SQL 文本不变,也可能因为:

  • search_path 改变;
  • column type 或 collation 改变;
  • RLS context 缺失;
  • transaction pooling 换了 backend;
  • generic/custom plan 或 statistics 改变;
  • 连接到了 replica;
  • 运行角色权限漂移;

而产生完全不同的行为。

用单行 marker 定义 schema contract

本章在隔离模式中保留:

CREATE TABLE shop_ch12.schema_version (
    singleton boolean PRIMARY KEY DEFAULT true,
    version integer NOT NULL,
    contract text NOT NULL,
    installed_at timestamptz NOT NULL,
    CHECK (singleton),
    CHECK (
        version = 1
        AND contract = 'pg36-ch12-service-contract-v1'
    )
);

marker 不是 migration history 的替代。它表示“应用启动所需的完整后置条件已经成立”,因此 readiness 可以检查:

SELECT EXISTS (
    SELECT 1
    FROM shop_ch12.schema_version
    WHERE singleton
      AND version = 1
      AND contract =
          'pg36-ch12-service-contract-v1'
);

成熟项目还会有不可变 migration ledger、artifact checksum、owner 和执行时间。关键是不能把:

latest migration command returned zero

直接等同于:

all required objects, privileges and data transitions are valid

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

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

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

input:
  order_id positive int64

success:
  exactly one order object
  items is an array ordered by line_no
  payment is object or JSON null
  money is integer minor units + currency

absence:
  no row → domain order_not_found / HTTP 404

failure:
  database error is not rewritten as not-found

QueryRow().Scan() 返回 pgx.ErrNoRows 与网络错误、取消、权限错误完全不同。若把所有 error 都映射成 404,数据库事故会被伪装成“用户输入不存在”。

列表接口还要声明:

ordering key=(order_id ASC)
cursor means order_id > after
limit range=1..100
next_cursor exists only when another row is known to exist

如果没有稳定顺序,分页结果不是一个可重放合同。

Error contract 使用身份,不使用文案

PostgreSQL error 至少有:

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

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

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

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

404 sku_not_found
409 insufficient_inventory

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

Compatibility contract 是一个矩阵

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

application database phase 允许? 证明
old legacy yes current production
old expanded yes backward-compatible DDL
new expanded/backfilling conditional fallback/nullable semantics
new validated/switched yes new query contract
old rollback switched yes until contract rollback window
old contracted no old artifact inventory must be zero

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

expand:
  DB accepts v1 and v2

deploy:
  v2 app rolls out, v1 remains rollback-capable

observe:
  old query/writer identity reaches zero

contract:
  DB stops accepting v1 only in separate release

服务发布与 database migration 有不同 identity、不同 rollback 方式和不同 owner,不能揉成一个“deploy succeeded”。

12.1.2 业务不变量在应用与数据库之间分工

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

应用擅长:

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

数据库擅长:

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

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

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

库存不能先查后写

错误模式:

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

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

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

结果基数就是决策:

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

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

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

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

same key + same fingerprint
  → return the persisted first response

same key + different fingerprint
  → reject 409 idempotency_conflict

本章订单事务先执行:

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

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

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

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

ledger、订单和 outbox 在同一 transaction:

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

or:
  none of them commits

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

Outbox 不等于消息已经送达

事务中插入:

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

只保证:

business fact committed ↔ intent to publish committed

它不保证 broker 已收到,也不保证 consumer 只执行一次。后续 publisher 还需要:

  • claim/lease 或 FOR UPDATE SKIP LOCKED 协议;
  • event key 去重;
  • retry/backoff/dead-letter;
  • consumer idempotency;
  • lag 与 stuck event 告警。

本章故意不启动 publisher,避免把“事务 outbox”误写成“端到端 exactly once”。

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

不要这样:

BEGIN
  lock order
  call payment provider over network
  update payment
COMMIT

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

更可靠的边界通常是:

persist intent/idempotency state
COMMIT
perform remote protocol with provider idempotency key
persist observed outcome in a new transaction
publish through outbox

不同支付协议的补偿语义不同,本章只建模“已经得到可信 capture 结果后,如何在数据库中幂等落账”。不能从样例推导出真实支付系统的完整协议。

防止两种极端

“全部放应用”会让第二个 writer、修复脚本或并发请求绕过规则;“全部放数据库”则容易隐藏远程副作用、把 API 版本耦合到 trigger,并让错误语义不可控。

一个实用评审表:

规则 主要执行者 数据库最后防线
JSON 字段格式 application bounded column/check if durable
stock non-negative atomic SQL transaction CHECK
request replay application protocol + ledger PK/UNIQUE + transaction
one payment/order transaction UNIQUE(order_id)
exact amount application/domain transaction CHECK + locked order comparison
order state vocabulary application + migration named CHECK
remote payment retry integration protocol persisted idempotency/outbox
tenant identity auth layer + transaction context RLS in ch23

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

启动顺序由兼容性决定

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

expand migration compatible with current app
  → verify database post-state
  → deploy new app with old-path fallback if needed
  → shadow/observe
  → switch new read/write path
  → retain rollback shape
  → separate contract release

若新应用在 schema marker 缺失时启动,它应该 fail readiness,而不是等第一位用户撞到 undefined_column。但 liveness 可以继续为真,让编排系统区分:

process broken
database dependency not ready
business route overloaded

Readiness 检查身份,而不只 SELECT 1

本章查询:

SELECT
    current_database(),
    current_user,
    NOT pg_catalog.pg_is_in_recovery(),
    EXISTS (
        SELECT 1
        FROM shop_ch12.schema_version
        WHERE singleton
          AND version = 1
          AND contract =
              'pg36-ch12-service-contract-v1'
    );

它同时防止:

  • DNS/service 指向错误 database;
  • 使用 admin 而非 runtime role;
  • 写服务落到 recovery replica;
  • migration 尚未达到可运行 post-state。

生产还可验证 tenant/extension/config baseline,但 readiness 必须轻量、有预算、失败不泄露敏感内部信息。完整 catalog 审计留给 deployment gate,而不是每个 probe 周期扫描。

App artifact 要声明最低和最高兼容版本

示例 manifest:

application=pg36-api
application_version=1.0.0-rc.1
database_contract_min=1
database_contract_max=1
query_bundle_checksum=...
driver=pgx/v5.10.0
query_mode=exec
pooling_assumption=transaction-compatible

只写 minimum 可能让应用在未知 future schema 上静默运行。是否允许 contract >= 1 取决于团队是否承诺所有 future expand 都 backward compatible;若没有这项治理,精确范围更安全。

Migration 成功不自动放行服务

发布 gate 至少分为:

database gate:
  target identity
  migration post-state
  constraints and grants
  old/new query compatibility
  lock/WAL/replica evidence

application gate:
  runtime role
  direct/pooler path identity
  readiness
  business smoke
  cancellation/retry/idempotency
  logs and metrics

traffic gate:
  SLI/error/tail latency
  pool and database saturation
  rollback observation window

三者任何一个缺证,都不能用另外两个“看起来正常”代替。

本章为何只发布 release candidate

本地证据已证明:

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

尚未证明:

unchanged suite through Pigsty primary → PgBouncer transaction pool
PostgreSQL 14, 15, 16, 17 compatibility
HA failover and in-flight semantics
L1 load, tail latency, WAL and replica impact
application + database owner sign-off

因此 baseline-v1.0-rc.json 的状态是 release-candidate。这正是版本合同的价值:它把“已知可运行”与“允许晋级生产基线”分开。

本节检查表

  • schema contract 有稳定 identity 与 catalog post-state;
  • query contract 定义输入、基数、排序、null 与 no-row;
  • error contract 使用 SQLSTATE/constraint identity;
  • compatibility matrix 覆盖旧 app rollback;
  • operations contract 验证 database、role、writable target;
  • durable invariant 由所有 writer 都无法绕过的层执行;
  • 原子竞争不用“先查后写”;
  • idempotency key 绑定 fingerprint 和 persisted response;
  • outbox 与业务状态同事务,但不冒充消息已送达;
  • remote side effect 不在持锁事务或自动 retry callback 中;
  • migration、application、traffic gate 各自留证;
  • 未运行的环境矩阵保持 blocker,不写成成功。

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

12.2 为服务设计查询接口

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

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

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

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

绑定参数只解决 value

pgx 使用 $1$2

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

参数化的主要价值:

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

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

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

动态 identifier 需要:

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

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

把原子决策放进语句结果

库存预留:

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

这条 SQL 的 contract 包含:

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

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

zero rows:
  SKU absent or quantity unavailable

constraint:
  available remains >= 0 for every writer

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

显式列优于 SELECT *

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

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

服务查询逐列列出:

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

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

稳定 JSON 必须定义内部顺序

聚合 items:

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

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

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

Keyset pagination

第一页:

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

下一页:

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

核心 predicate:

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

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

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

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

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

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

string(payload)

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

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

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

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

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

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

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

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

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

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

CTE 表达分页阶段

本章列表查询:

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

阶段关系清楚:

page:
  use keyset + limit to bound parent rows

ranked:
  number only the bounded page

final:
  build nested items only for selected parents

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

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

Window 不改变行基数

row_number()

row_number() OVER (ORDER BY page.order_id)

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

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

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

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

CTE 不是拆事务

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

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

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

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

先保留原始身份

服务内部 error 至少保存:

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

外部响应:

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

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

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

一个建议映射表

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

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

同一 SQLSTATE 需要上下文

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

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

本章故障矩阵分别注入:

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

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

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

Constraint name 是可版本化 API

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

ch12_sales_order_state_check

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

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

不要吞掉未知错误

最危险的映射:

if err != nil {
    return notFound
}

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

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

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

本节检查表

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

参考资料


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

12.3 Go 服务中的连接与事务

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

request context
  bounds pool acquisition
  bounds SQL execution
  triggers cancellation

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

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

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

NewWithConfig 不等于已连接

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

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

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

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

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

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

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

直连时,一个保守起点:

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

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

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

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

有 PgBouncer 后还要分两层:

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

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

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

池大小不是 CPU 数量公式

需要从 workload 推导:

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

然后用负载实验观察:

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

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

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

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

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

Deadline 要形成预算瀑布

不同 timeout 回答不同问题:

client deadline
  total willingness to wait

HTTP/server request deadline
  service execution budget

pool acquire deadline
  queue budget before any SQL starts

statement_timeout
  PostgreSQL statement execution budget

lock_timeout
  one lock acquisition wait budget

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

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

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

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

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

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

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

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

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

实验:

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

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

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

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

503 pool_unavailable

取得连接后 statement_timeout

504 database_timeout
SQLSTATE=57014

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

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

Wrapper 必须拥有完整 transaction lifecycle

本章 wrapper 每次 attempt:

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

不能只重试最后一条 SQL:

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

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

Retry allowlist

本章只允许:

40001 serialization_failure
40P01 deadlock_detected

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

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

连接断开尤其危险:

client did not receive COMMIT response

不等于:

database did not commit

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

Callback 必须可重放

事务 retry callback 中不能做:

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

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

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

attempt 1:
  nextval=1
  raise 40001
  transaction rolls back

attempt 2:
  nextval=2
  continue
  order commits once

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

下单 transaction

逻辑顺序:

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

任何 domain error 都 rollback,包括:

insufficient inventory
missing SKU
same key + different fingerprint

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

支付 transaction

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

数据库还用:

UNIQUE (payment.order_id)

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

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

常见 Go pattern:

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

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

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

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

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

三种不同问题

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

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

Readiness 验证依赖身份

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

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

本章 readiness 返回:

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

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

Readiness 也需要限流与预算

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

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

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

Ready 不等于核心业务成功

readiness 证明:

can acquire
right identity
writable
schema marker

它不证明:

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

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

实测连接指标

第二轮全量证据结束时:

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

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

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

本节检查表

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

参考资料


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

12.4 会话状态与连接池陷阱

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

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

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

12.4.1 session、transaction 与 statement pooling

三种“归还 backend”的时刻

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

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

transaction pooling:

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

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

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

Transaction pooling 中哪些东西会坏

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

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

例:

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

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

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

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

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

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

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

state lifetime <= transaction lifetime

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

Autocommit 是 transaction

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

所以这种代码不成立:

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

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

不要用 session advisory lock 做跨请求 ownership

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

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

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

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

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

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

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

需要至少区分:

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

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

max_prepared_statements > 0

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

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

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

pgx 的默认行为

pgx v5.10.0 默认 QueryExecModeCacheStatement

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

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

本章保守固定:

config.ConnConfig.DefaultQueryExecMode =
    pgx.QueryExecModeExec

该模式:

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

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

不要误用 SimpleProtocol

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

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

DDL 后的 cached plan

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

cached plan must not change result type

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

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

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

建议的组合矩阵

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

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

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

SETSET LOCAL

PostgreSQL:

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

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

transaction pooling 的主线应是:

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

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

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

RLS context 要 fail closed

若 ch23 使用:

current_setting('app.tenant_id', true)

policy 要明确 missing context 是:

zero rows / reject

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

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

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

同一 transaction 才能相信 context

错误:

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

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

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

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

search_path 也不要依赖 session residue

本章所有对象 schema-qualified:

shop_ch12.sales_order

SECURITY DEFINER function 固定:

SET search_path = pg_catalog

然后引用 qualified object。这样:

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

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

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

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

第 22 章将深入连接治理:

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

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

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

本章保留的 cross-chapter contract:

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

本节检查表

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

参考资料


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

12.5 服务级可观测性

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

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

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

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

Identity 分层

本章的关联关系:

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

database session class:
  application_name=pg36-ch12-api

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

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

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

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

application_name 保持低基数

本章所有业务 backend:

application_name=pg36-ch12-api

不要每请求改成:

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

否则:

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

合理粒度:

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

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

Query identity 不等于 raw SQL + values

观测 query 应优先关联:

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

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

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

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

Transaction attempt 与 logical request

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

attempt 1 → 40001 rollback
attempt 2 → commit order 1200002

metrics 同时需要:

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

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

结构化日志

正常请求:

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

数据库 timeout:

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

日志不包含:

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

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

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

一个慢请求至少有四段

[ T_{request} = T_{app}

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

T_db 还可拆:

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

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

应用 pool wait

pgxpool 暴露:

AcquireCount
AcquireDuration
EmptyAcquireCount
EmptyAcquireWaitTime
CanceledAcquireCount
AcquiredConns
IdleConns
TotalConns
MaxConns

本章转换成:

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

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

PgBouncer wait

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

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

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

PostgreSQL wait

取得 backend 后,观察:

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

典型解释:

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

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

Error 指标保留 SQLSTATE 与领域 code

本章同时记录:

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

一次运行:

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

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

Rate、error、duration 与 saturation 同看

最小服务面板:

traffic:
  requests/s by route

errors:
  status class + domain code + SQLSTATE

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

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

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

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

一条可执行调查路径

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

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

然后沿层次走:

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

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

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

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

5. durable business evidence
   idempotency ledger/order/outbox

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

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

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

实测 trace 关系

本章 observer 独立于 app 查询:

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

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

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

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

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

诊断一次 pool exhaustion

实验时间线:

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

t1:
  /health/live → 200

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

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

t4:
  holders complete 200
  acquired returns 0
  readiness → 200

证据组合:

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

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

诊断一次 statement timeout

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

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

Unknown commit 的调查

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

ledger has completed response
  → return it; command committed

ledger absent
  → safe to attempt command

ledger exists but incomplete
  → protocol defect/manual repair path

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

本节检查表

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

参考资料


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

12.6 部署与接入 `pg36_shop`

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

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

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

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

Owner 与 runtime 分离

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

pg36_owner
  NOLOGIN
  object owner
  migration effective role

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

pg36_ro
  LOGIN
  reviewed read path

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

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

本章 setup 最终断言:

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

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

Pigsty declaration

声明示例 中的关键部分:

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

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

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

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

重新计算。

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

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

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

Credential 是输入,不是源码

样例只要求:

PG36_DATABASE_URL

但不记录它。生产建议:

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

systemd unit 示例 从:

/etc/pg36-api/runtime.env

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

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

写进 process list。

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

应记录:

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

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

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

Pigsty 默认服务语义

Pigsty v4.5 默认:

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

因此:

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

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

为什么 migration 用 direct path

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

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

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

application_name
lock_timeout
statement_timeout
target guard
change identity
evidence directory

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

应用侧仍然使用 pgxpool

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

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

但两层池不要无限叠加:

100 pods × MaxConns 100
→ 10,000 PgBouncer clients

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

晋级时使用不变的 service suite

本地验证连接:

service=pg36-admin user=pg36_app
direct PostgreSQL socket

manifest 明确:

validation_path=direct-postgresql
pooler_validation=not-run

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

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

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

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

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

admin direct URL
application primary/PgBouncer URL

并在证据中保存:

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

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

Failover 不由连接串自动变安全

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

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

应用仍需要:

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

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

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

Deployment gate

部署后按层验证:

process:
  exact binary/config checksum
  liveness

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

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

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

business:
  idempotent order/payment smoke
  outbox and trace

operations:
  logs/metrics/dashboard
  rollback and reconnect

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

Pigsty 观察面

发布窗口至少同时看:

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

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

用关系做验收

不设跨环境绝对 golden:

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

应设合同关系和 SLO:

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

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

发布后观察而非立即 contract

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

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

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

失败时停止什么

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

本节检查表

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

参考资料


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

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

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

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

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

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

确认目标是可重建 L1

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

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

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

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

运行:

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

可选 action:

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

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

Fixture

初始库存:

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

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

创建订单

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

返回:

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

提交关系:

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

重放与冲突

相同 body、相同 request_key

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

相同 key、quantity 改成 3:

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

HTTP 409,状态不变。

此外:

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

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

第二笔订单通过 40001 retry

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

结果:

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

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

支付

先用错误金额:

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

正确请求:

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

响应:

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

提交:

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

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

查询

详情:

GET /v1/orders/1200001

返回:

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

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

Keyset page:

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

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

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

语句超时必须零提交

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

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

观察:

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

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

40001 只重试整 transaction

metric:

pg36_db_errors_total{sqlstate="40001"} 1
pg36_transaction_retries_total 1

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

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

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

Client cancellation

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

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

服务日志:

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

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

Pool exhaustion

服务固定:

PG36_MAX_CONNS=2

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

pg_stat_activity sleepers=2
pool acquired=max

随后:

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

最终 metrics:

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

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

Error matrix

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

Raw evidence directory

最终 rebuild 至少有:

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

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

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

不是把 proposal 文件拼成大 JSON

ch07–ch11 分别增加:

ch07:
  plan/statistics/parameter evidence

ch08:
  hypothesis-led diagnosis and negative controls

ch09:
  workload-bound index decision and write cost

ch10:
  concurrency invariant, retry and idempotency

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

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

新规则

DEFAULT-APP-011

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

POOL-STATE-012

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

Release candidate,不是 release

artifact

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

当前已证:

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

晋级 blockers:

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

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

评审器检查什么

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

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

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

冻结什么

本章结束后冻结:

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

后续章节可以引用:

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

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

后续如何复用

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

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

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

ch22 pooling:
  reuse the frozen connection/error matrix

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

若发现 ch12 真正 defect:

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

Reset

显式 reset:

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

它拒绝:

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

成功后:

schema_remaining=0
ch04 checksum=f8a7bfae59c6d16cd323abecfefe1014

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

最终输出

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

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

本节验收

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

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

13 言出法随:函数、触发器与存储过程

数据库端逻辑最危险的误解,是把“PostgreSQL 能做”当成“应该放进 PostgreSQL”。函数、触发器和过程都能执行复杂逻辑,但三者不是更高级的 应用框架。它们首先是不同的数据库对象,各有调用方式、事务语义、规划 承诺、权限边界和可观测性。

本章只追问一个工程问题:

一条规则由谁负责,才能在所有写入口下保持正确,同时仍然能够测试、 发布、观测和回退?

答案不是“全部放应用”或“全部放数据库”。更可靠的分层是:

单列与单行合法域
  └─ NOT NULL / CHECK / 类型

表间引用与可声明关系
  └─ UNIQUE / FOREIGN KEY / EXCLUDE

旧行到新行的数据库状态跃迁
  └─ BEFORE ROW trigger(确实无法声明时)

事务最终点的跨表断言
  └─ deferred constraint trigger(知道并发边界时)

应用可调用的窄数据库命令
  └─ SECURITY INVOKER / SECURITY DEFINER function

需要分批提交的数据库维护动作
  └─ top-level CALL + procedure

跨系统工作流、重试策略与调度
  └─ 应用、outbox、worker 与平台

越靠上越声明式、越容易由 PostgreSQL 自动维护;越靠下越需要显式协议。 触发器不是把跨系统工作流藏起来的捷径,过程也不是调度器。

本章完成后

你应当能够:

  • 先用约束、普通 SQL 和事务表达规则,再判断是否真的需要例程;
  • 区分 SQL function、PL/pgSQL function、trigger function 与 procedure;
  • 设计标量、复合、集合返回和多态函数,并控制重载歧义;
  • VOLATILESTABLEIMMUTABLE 当成给优化器的承诺;
  • 正确声明 STRICTPARALLEL SAFE/RESTRICTED/UNSAFECOSTROWS
  • 使用稳定 SQLSTATE、DETAILHINT 定义机器可消费的错误合同;
  • 理解 EXCEPTION 块为什么形成子事务,以及它不能替代正常控制流;
  • 区分行级、语句级、BEFOREAFTERINSTEAD OF 触发器;
  • 用 transition table 对批量变更做一次集合处理;
  • 解释 deferred constraint trigger 检查的是事务最终状态,而不是 任意并发历史;
  • 识别递归、触发顺序、每行放大与隐藏 I/O;
  • 准确说明 function 与 procedure 的调用和事务控制边界;
  • 让批处理可重入、可续跑、可限批,而不把过程误当作 scheduler;
  • 安全编写 SECURITY DEFINER:NOLOGIN owner、固定 search_path、 全限定对象名、撤销 PUBLIC EXECUTE、输入收窄与最小授权;
  • pg_procpg_trigger、ACL、SQLSTATE 和函数统计中取得证据;
  • 在 Pigsty L1 中交付声明、SQL 变更、测试证据、观察窗口和回退入口。

贯穿实验:订单状态护栏

本章不使用只展示语法的零散对象,而是维护一个完整的 shop_ch13 实验:

规则 实现 为什么
金额为正、状态属于有限集合 CHECK 单行、可声明、目录可见
created → paid/canceled/expired 等跃迁 BEFORE ROW trigger 必须比较 OLDNEW
paid 时捕获金额等于订单金额 deferred constraint trigger 两张表在提交点同时成立
应用取消订单、捕获支付 SECURITY DEFINER function 应用没有底表 DML,只调用窄命令
多行更新写审计 AFTER STATEMENT + transition tables 三行更新只产生一条 statement audit
过期五张陈旧订单 SECURITY INVOKER procedure 顶层 CALL2/2/1 三批提交
邮件、HTTP、消息消费、定时启动 不放触发器或过程 属于外部系统和平台

夹具刻意让不同机制叠在同一事务里:

pg36_app
  │ EXECUTE only
capture_payment(...)
  ├─ lock order
  ├─ insert captured payment
  └─ update order: created -> paid
       ├─ BEFORE ROW validates edge + version
       ├─ AFTER STATEMENT writes row history + statement audit
       └─ deferred constraint triggers validate final payment total
          COMMIT or SQLSTATE P3614

这条链路同时说明两个事实:

  1. SECURITY DEFINER 不是绕开约束;提升后的命令仍然经过触发器和提交点 验证;
  2. 触发器只能参与当前 PostgreSQL 事务,不能证明外部副作用已经完成。

实验入口由 ch13 实验合同 统一说明:

正式实验在 PostgreSQL 18.6 直连路径运行,同时把适用范围限制为 PostgreSQL 14–18。它没有经过 PgBouncer,因此不能声称 pooler 路径已验证。

快速运行

沿用前章的受控管理 service:

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

PG36_EVIDENCE_DIR="$PWD/evidence/ch13" \
  ./static/labs/ch13/task.sh all

all 会:

  1. 验证 ch04-v1 模型与 ch05 业务 checksum;
  2. 精确重建 shop_ch13
  3. 采集 pg_procpg_trigger 和 ACL;
  4. 穷举七个状态的 49 个有序对,证明恰好六条合法边;
  5. pg36_app 调用成功命令;
  6. 验证七个失败 case、六类 SQLSTATE;
  7. 证明异常子事务、transition table 与函数计数;
  8. 证明显式事务里的过程以 2D000 失败;
  9. 顶层调用过程取得 2/2/1,重跑取得 0;
  10. 拒绝错误 token、错误 target 和活跃 worker 下的 reset;
  11. 精确复位,再完整重建和复验第二遍。

成功摘要为:

status=ok
business=orders:13/payments:1/history:10/audit:6
boundary=check+before-row+deferred-constraint+security-definer
failure=42501/P3613/P3614/P3616/P3618/2D000
transaction=exception-subtransaction+commit-time-check+procedure-batches
observability=transition-table+function-stats+sqlstate
release=1.1-proposal
release_candidate_checksum=32377d82a7ce958aa50b0077ebe99c47d27672223c3c77fd9f91072d3745de9d

计时和生成的 identity 值不是 golden。验收比较状态分布、权限矩阵、 SQLSTATE、批次关系和 canonical proposal checksum。

失败合同

SQLSTATE 含义 谁产生 预期结果
42501 应用直接写底表 PostgreSQL ACL 没有任何业务变化
P3613 非法状态边 BEFORE trigger 行、历史和审计一起回滚
P3614 支付最终状态不一致 deferred trigger 到提交点拒绝整个事务
P3616 乐观版本不匹配 command function 调用方重新读取后决定是否重试
P3618 支付命令前置条件不成立 command function 不插支付、不改订单
2D000 显式事务块内试图结束事务 procedure runtime 该显式事务失败

自定义 P36xx 只属于本书实验合同;真实项目必须建立自己的错误注册表, 避免不同模块复用同一码位。调用方匹配 SQLSTATE,而不是匹配可能被翻译、 改写或补充上下文的 message。

学习路径

13.1 先决定逻辑放在哪里

先建立决策算法。如果跳过这一节,后面的语法很容易变成“看到锤子,到处 找钉子”。

13.2 SQL 与 PL/pgSQL 函数

函数是查询表达式的一部分,因此必须同时理解类型系统、优化器承诺和 调用者事务。

13.3 触发器与约束触发器

触发器要从“自动执行”还原为“写语句执行计划中隐藏的一段同步代码”。

13.4 过程、任务与事务控制

过程最独特的能力是受限的事务控制,不是“函数的加强版”。

13.5 安全、测试与观测

例程一旦成为权限边界,就必须按 API 和安全敏感代码来发布,而不是当成 一段随手粘贴的 SQL。

13.6 实战:为订单状态建立数据库端护栏

最后把决策、对象、失败、证据、声明和回退压成一份可评审交付物。

版本与证据边界

本章使用 PostgreSQL 14–18 共有的核心能力;anycompatible 多态类型族从 14 开始,因此实验下限设为 14。PostgreSQL 18.6 是本次实际验证版本, 不是暗示 14–17 会自动通过所有环境差异。

权威语义以以下文档为准:

本章会明确区分“官方定义”“本章设计选择”和“本地实验观察”。只有第三类 结论能够由当前 evidence 目录证明。


上一章:一气呵成:从数据库契约到后端服务 · 返回上卷导读 · 下一章:博采众长:内核分支与扩展生态 · 查看全书目录 · 查看索引中心

13.1 先决定逻辑放在哪里

写函数之前,先写一句可以被反驳的责任声明:

这条规则必须位于数据库,因为……

如果理由只是“这样少写几行应用代码”或“数据库更快”,先停下来。逻辑位置 决定的不只是延迟,还决定谁能绕过规则、谁负责版本兼容、错误怎样传播、 副作用何时提交、故障在哪里观测。

本节给出一个从声明式机制向外扩展的决策顺序。

13.1.1 数据不变量、批处理与接口封装

从最窄、最声明式的机制开始

同一条规则可能有多种写法:

-- 声明式
total_minor bigint NOT NULL CHECK (total_minor > 0)

-- 触发式
CREATE TRIGGER validate_total
BEFORE INSERT OR UPDATE ON sales_order
FOR EACH ROW EXECUTE FUNCTION validate_total();

-- 命令式
IF p_total_minor <= 0 THEN
    RAISE EXCEPTION ...;
END IF;

三者都能拒绝负数,但并不等价。CHECK

  • 对所有普通写入口生效;
  • 由系统目录公开表达;
  • 能被 schema diff、dump、迁移工具和错误字段识别;
  • 不需要人为维护触发器执行顺序;
  • 让 PostgreSQL 自己生成稳定的约束拒绝。

因此第一条规则是:

能由类型、NOT NULLCHECKUNIQUEFOREIGN KEYEXCLUDE 正确表达的规则,不先写触发器。

“正确表达”也有限制。PostgreSQL 假定 CHECK 对同一行是不可变判断; 它不会持续重新检查约束表达式引用的其他行。跨行、跨表查询不应伪装成 普通 CHECK。这类语义要重新建模、用原生唯一/引用约束,或在确实必要时 进入事务逻辑。参见 Constraints

用规则形状选工具

规则形状 首选位置 典型例子
单值合法域 类型、NOT NULLCHECK 金额为正、状态枚举
行内列关系 CHECK、生成列 end_at >= start_at
候选键 PRIMARY KEYUNIQUE 外部请求号唯一
引用关系 FOREIGN KEY 明细必须属于订单
范围互斥 EXCLUDE 同资源预订时间不重叠
集合变换 一条集合 SQL 批量改价、聚合回填
旧行到新行的边 条件更新或 BEFORE ROW trigger 状态只能沿有限图变化
事务最终状态 延迟约束或 constraint trigger 支付总额与 paid 状态一致
窄数据库命令 function 以 expected version 取消订单
多批次维护 procedure / 外部 worker 每 5000 行提交一次
HTTP、邮件、消息消费 应用 + outbox 提交后通知其他系统
何时运行 scheduler / 平台 每日归档、周期巡检

表里的“首选”不是绝对答案,而是评审起点。每次偏离都要留下理由和测试。

批处理先问能否是一条 SQL

PL/pgSQL 循环很直观:

FOR target IN
    SELECT order_id FROM sales_order WHERE ...
LOOP
    UPDATE sales_order
    SET status = 'expired'
    WHERE order_id = target.order_id;
END LOOP;

但一条集合更新通常更清楚:

UPDATE sales_order
SET status = 'expired'
WHERE status = 'created'
  AND created_at < $1;

集合 SQL 给优化器更多空间,也避免一次业务动作产生 N 次解析、执行和触发 边界。只有当批次需要独立提交、外部节流、checkpoint、队列竞争或每项错误 隔离时,才进入过程或外部 worker。即使如此,每一批内部仍应尽量使用集合 SQL。

function 是接口,不是代码收纳箱

把 SQL 包进 function 只有在形成明确合同后才有意义:

name + input types
  -> privilege
  -> transaction and lock behavior
  -> result shape
  -> SQLSTATE set
  -> observable identity
  -> compatible replacement / rollback

本章的应用角色没有 shop_ch13.sales_orderSELECTUPDATE, 只得到:

GRANT EXECUTE ON FUNCTION
    shop_ch13.order_snapshot(bigint)
TO pg36_app;

GRANT EXECUTE ON FUNCTION
    shop_ch13.transition_order(bigint, bigint, text, text)
TO pg36_app;

这是真正的接口封装:底表权限被拿走,函数签名、结果和错误成为协议。若应用 仍有任意底表 DML,函数往往只是可选的便利封装,不能被宣称为唯一护栏。

一次决策走查

对“订单进入 paid 前必须完成支付”逐层判断:

  1. status 属于有限集合:CHECK
  2. created → paid 是旧行到新行的边:条件更新或 transition guard;
  3. 捕获金额等于订单金额:跨 sales_order/payment 的事务最终断言;
  4. 应用要原子完成插支付和改状态:command function 或应用事务;
  5. 支付成功后通知履约:同事务写 outbox;
  6. 调用远端履约 API:提交后由 worker 执行。

不同部分由不同机制负责,不必强迫一条“业务规则”只有一个物理位置。

13.1.2 数据库内聚与应用可演进性的权衡

“把规则放近数据”能减少绕过路径;“把流程放在应用”能获得更好的协议演进 和跨系统编排。真正的权衡不是数据库与应用谁更强,而是变化与失败在哪一层 最容易被控制。

六个评审维度

1. 覆盖所有写入口

如果写入来自 API、ETL、管理脚本、批处理和多个语言栈,数据库约束覆盖面 最大。只在一个应用 handler 中校验,其他入口可能绕过。

但覆盖面也有前提:

  • 超级用户、表 owner 和复制/恢复路径拥有更高能力;
  • session_replication_role 等管理开关会改变触发行为;
  • 逻辑复制默认重放的是行变化,不是在订阅端重新执行发布端所有业务逻辑;
  • 管理员仍可能删除或禁用对象。

所以“数据库保证”是权限与部署合同下的保证,不是对所有特权行为的魔法。

2. 并发仲裁

唯一性、引用完整性、行锁和 MVCC 由 PostgreSQL 掌握最终事实。应用先 SELECT 再判断通常有竞态;原子条件写、唯一约束或数据库事务更可靠。

但触发器也不会自动解决并发:

T1 reads aggregate A
T2 reads aggregate A
T1 writes based on A
T2 writes based on A

如果规则依赖聚合或多行集合,仍要设计锁顺序、隔离级别、唯一仲裁点或整 事务重试。deferred trigger 只是晚检查,不等于串行化。

3. 发布耦合

数据库函数签名和触发器行为是应用依赖。变更时要回答:

  • 旧应用与新函数能否共存?
  • 默认参数是否改变调用解析?
  • 返回列新增、删除、改名会不会破坏驱动映射?
  • trigger 在 expand 阶段会不会让旧写入失败?
  • function replacement 会不会拿到等待中的对象锁?
  • 回退应用时,旧数据库行为是否还兼容?

第 11 章的 expand/migrate/validate/switch 思路同样适用于例程:先增加兼容 能力,再迁移调用,观察后才收缩旧接口。

4. 调试与可见性

应用调用链通常天然有 trace、请求参数、部署版本和统一日志。数据库函数 可能只在 SQL 文本中显示为一次调用;触发器甚至不出现在原始业务 SQL 里。

如果选择数据库端逻辑,必须补回:

  • 稳定 function/trigger identity;
  • 低基数 application_name
  • SQLSTATE、约束名和 routine context;
  • pg_stat_user_functions 或事务级计数;
  • pg_stat_statements、慢日志与 lock/wait 证据;
  • 业务 actor、request/trace ID 的安全关联。

看不见的正确逻辑,在事故中仍然是风险。

5. 团队所有权

例程不是“DBA 的代码”或“开发的 SQL”。需要明确:

  • 谁评审业务语义;
  • 谁评审权限与 search_path
  • 谁维护迁移顺序;
  • 谁运行负面和并发测试;
  • 谁响应慢调用或递归事故;
  • 谁批准回退。

所有权不清时,隐藏自动行为尤其危险。

6. 可移植性

PL/pgSQL、transition table、constraint trigger、SECURITY DEFINER 和 过程事务控制都有 PostgreSQL 特定语义。若产品确实要求多数据库运行, 应用实现可能更易移植。

反过来,为不存在的迁移目标牺牲当前数据库的原生正确性也没有价值。把 “未来也许换库”转化为明确概率、成本和退出计划,而不是口号。

一个可执行评分卡

对候选规则逐项打分:

问题
是否必须覆盖多个写入口? 倾向数据库 倾向应用
是否依赖 PostgreSQL 并发仲裁? 倾向数据库 中性
能否由原生约束声明? 用约束 继续判断
是否包含远端 I/O? 留应用/outbox 继续判断
是否需要跨事务分批提交? procedure/worker function/SQL
是否需要请求级 trace 与复杂协议? 倾向应用 中性
数据库对象能否独立版本化与测试? 可以进入 先补工程能力
失败能否用 SQLSTATE 和不变量验收? 可以进入 不应隐藏

评分卡不替团队做决定;它迫使理由显式化。

推荐的职责声明

本章实验采用:

database owns:
  state domain
  legal transition edges
  version step
  payment/order commit-time invariant
  history and statement audit in the same transaction

application owns:
  authentication and authorization context
  command choice
  optimistic-conflict retry policy
  API response
  outbox consumption and external side effects

Pigsty/platform owns:
  role/database declaration
  primary routing and pooling
  secret delivery
  metrics/logs/alerts
  scheduled invocation and overlap prevention

这份声明比“业务逻辑在数据库”精确得多。

13.1.3 不用触发器隐藏跨系统工作流

触发器与原语句同步成败

普通 DML trigger 在触发它的语句和事务中执行。触发函数报错,原语句也 失败;事务回滚,触发器写入也回滚。这正适合:

  • 派生同数据库内的审计行;
  • 验证 OLD → NEW
  • 同事务维护局部冗余;
  • 写入 outbox 事实。

它不适合直接完成:

  • HTTP 请求;
  • 发邮件;
  • 发 Kafka/RabbitMQ 消息后等待确认;
  • 调用支付或履约系统;
  • 写入另一个无法参与同一 PostgreSQL 事务的数据源。

这些动作不具备与 PostgreSQL 提交相同的原子边界。

“触发器里调用 HTTP”为什么会失败

假设触发器同步调用远端服务:

UPDATE order
  -> trigger calls remote API
  -> remote succeeds
  -> PostgreSQL COMMIT fails

外部动作已经发生,数据库却回滚。反过来:

UPDATE order
  -> remote times out
  -> unknown whether remote succeeded
  -> database transaction holds locks while waiting

此时重试可能重复副作用,数据库连接、行锁和事务快照还被远程尾延迟拖住。 把网络调用包装成 extension function 并不会改变分布式事务事实。

正确边界:同事务写 outbox

第 12 章使用:

BEGIN;

UPDATE order ...;
INSERT INTO outbox (...);

COMMIT;

数据库只保证“状态与待发布事实一起提交”。提交后 worker:

  1. 读取/领取 outbox;
  2. 调用外部系统;
  3. 使用幂等键处理至少一次投递;
  4. 记录成功、失败、重试与死信;
  5. 暴露 backlog、age 和错误指标。

这不是把分布式问题消掉,而是把不可控的同步双写改造成可恢复状态机。

NOTIFY 也不是 durable queue

LISTEN/NOTIFY 适合低延迟提示,但通知不是持久任务队列。消费者断开、事务 提交边界、payload 限制与处理确认都需要额外设计。可靠工作仍应以表中 durable fact 为准,通知只用于“醒来看看”。

不把 scheduler 藏进 procedure

procedure 只定义“被调用时做什么”。它不会决定:

  • 每天几点执行;
  • failover 后由哪台 primary 执行;
  • 上一轮未结束是否跳过;
  • 失败重试几次;
  • 超期多久告警;
  • 如何暂停、补跑和审计。

这些属于 pg_cron、OS cron、systemd timer、作业平台或应用 worker。 数据库过程可以是 job body,但不是 job control plane。

进入触发器前的停止线

若候选触发器满足任一项,先重新设计:

  • 发起远端 I/O;
  • 吞掉异常后继续提交;
  • 根据 wall-clock 或不稳定配置伪装为 IMMUTABLE
  • 每行再次扫描整张大表;
  • 修改触发表并依赖 pg_trigger_depth() 阻止递归;
  • 依赖另一个同类 trigger 的名字顺序才能正确;
  • 失败没有稳定 SQLSTATE;
  • 无法在绕过应用的 SQL 下测试;
  • 无法说明 bulk load 的放大倍数;
  • 无法提供停用、兼容和回退方案。

触发器的价值是让数据库不变量覆盖所有写入口;一旦它变成隐藏工作流引擎, 这个优势很快会被运维风险抵消。

本节结论

选择逻辑位置时按以下顺序停靠:

declarative constraint
  -> set-based SQL
  -> explicit application transaction
  -> narrow function boundary
  -> trigger for unavoidable implicit invariant
  -> procedure for controlled multi-transaction maintenance
  -> external worker/scheduler for cross-system lifecycle

不是每条规则都必须走到最后。成熟设计往往在最早能够正确表达的位置停止。


返回本章目录 · 下一节:SQL 与 PL/pgSQL 函数 · 查看全书目录 · 查看索引中心

13.2 SQL 与 PL/pgSQL 函数

PostgreSQL function 可以出现在 SELECT 列表、WHERE、索引表达式、 生成列、约束、触发器和另一个例程中。正因为它嵌入查询,函数声明不只是 文档;优化器会相信波动性、严格性、并行安全、成本和预估行数。

本节先把函数看成一个带类型和规划属性的数据库 API,再进入 PL/pgSQL 控制流。

13.2.1 参数、返回值、集合与多态

先选最小语言

如果函数只需要一条或几条集合查询,优先 LANGUAGE sql

CREATE FUNCTION shop_ch13.allowed_transition(
    p_from text,
    p_to text
)
RETURNS boolean
LANGUAGE sql
IMMUTABLE
STRICT
PARALLEL SAFE
AS $function$
    SELECT (p_from, p_to) IN (
        ('created', 'paid'),
        ('created', 'canceled'),
        ('created', 'expired'),
        ('paid', 'packing'),
        ('packing', 'shipped'),
        ('shipped', 'completed')
    )
$function$;

需要局部变量、分支、循环、动态 SQL、异常处理或多条命令编排时,才使用 LANGUAGE plpgsql。语言选择和 function/procedure 选择是两个维度: PL/pgSQL 既可以实现 function,也可以实现 procedure。

PostgreSQL 还支持其他过程语言和 C 扩展;它们引入安装、信任、二进制兼容 与崩溃边界,不属于“为了少写 SQL”就启用的选项。参见 User-Defined Functions

参数模式与调用方式

常见参数模式:

模式 含义 是否参与调用输入
IN 输入,默认模式
OUT 命名输出列
INOUT 输入后作为输出
VARIADIC 把尾部实参收成数组

命名参数允许:

SELECT *
FROM shop_ch13.transition_order(
    p_order_id        => 101,
    p_expected_version => 0,
    p_target_status   => 'canceled',
    p_actor           => 'api:user-42'
);

命名调用提高可读性,却也把参数名变成外部兼容面。CREATE OR REPLACE FUNCTION 不能随意改已有输入参数名;驱动和 SQL 可能已经按名调用。

默认参数必须位于无默认输入参数之后。增加默认参数看似兼容,却可能与已有 重载产生歧义。发布前要用实际调用类型测试解析,而不是只看 DDL 成功。

标量、复合与集合返回

标量

RETURNS boolean

适合纯判断或单一计算。调用者可把它嵌入表达式。

多列单行

本章使用 RETURNS TABLE

CREATE FUNCTION shop_ch13.order_snapshot(p_order_id bigint)
RETURNS TABLE (
    result_order_id bigint,
    result_order_ref text,
    result_total_minor bigint,
    result_status text,
    result_version bigint,
    result_updated_at timestamptz
)
...

调用时把函数放在 FROM

SELECT *
FROM shop_ch13.order_snapshot(102);

不要依赖 SELECT function(...) 返回的匿名复合显示格式;明确列形状更适合 驱动映射和版本评审。

集合

RETURNS SETOF some_typeRETURNS TABLE (...) 可以返回多行。集合函数 应回答:

  • 顺序是否有合同;若有,函数内部或调用方必须显式 ORDER BY
  • 最大行数是多少;
  • 能否被谓词下推或内联;
  • ROWS 预估是否合理;
  • 空集与一行 NULL 是否被清楚区分。

ORDER BY 的集合没有稳定顺序。把测试机当前顺序冻结为 API 行为,会在 计划、并行度或版本变化时失败。

表的复合类型

RETURNS shop_ch13.sales_order 很方便,但把函数 API 与整张表的物理列强 绑定。新增、删除、重排列会改变结果类型。对外接口通常更适合命名输出列或 专用复合类型。

多态类型

多态函数让实参类型决定返回类型。PostgreSQL 14+ 的 anycompatible 类型族会为多个实参选择共同类型:

CREATE FUNCTION clamp_value(
    value anycompatible,
    low   anycompatible,
    high  anycompatible
)
RETURNS anycompatible
LANGUAGE sql
IMMUTABLE
STRICT
PARALLEL SAFE
AS $function$
    SELECT greatest($2, least($1, $3))
$function$;

调用:

SELECT clamp_value(12, 0, 10);              -- integer 10
SELECT clamp_value(12.5::numeric, 0, 10);   -- numeric 10

anyelement/anyarray 要求相关参数是同一具体类型族;anycompatible* 允许 寻找可隐式转换的共同类型。多态并不表示动态类型逃逸:解析阶段必须能从 输入推导出实际类型。

使用多态前问三个问题:

  1. 不同类型是否真的共享相同语义,而不只是共享运算符名字?
  2. 隐式转换会不会丢精度或选到意外类型?
  3. 错误是否比几个显式重载更难理解?

重载是类型解析协议

同一 schema 可以有同名、不同输入类型的函数:

quote_id(bigint)
quote_id(uuid)

PostgreSQL 根据参数数量、类型、隐式转换、首选类型和 search_path 解析。 未定型字符串字面量、默认参数和 VARIADIC 会增加歧义:

SELECT quote_id('42');          -- '42' 初始类型 unknown
SELECT quote_id(42::bigint);    -- 明确

对安全敏感调用:

  • schema-qualify function;
  • 给不明确的实参加显式 cast;
  • 不在不受信 schema 中暴露可劫持的同名重载;
  • 避免依赖微妙的隐式转换优先级。

官方 Function Overloading 明确提醒:重载在存在不可信用户的数据库中带来额外安全注意事项。

SQL body 的两种写法

字符串 body:

AS $function$
    SELECT ...
$function$;

在函数执行时解析。SQL-standard body:

RETURN expression;

BEGIN ATOMIC ... END 在创建时解析,能更早发现对象与类型错误,也能建立 更明确的依赖,但不适用于所有动态场景。无论使用哪一种,都要把 source 纳入版本库;从 pg_get_functiondef() dump 出来的结果是运行态证据,不是 源代码评审的替代品。

13.2.2 波动性、严格性、并行安全与规划影响

波动性是承诺,不是优化提示

三类波动性:

声明 对同一语句的承诺 是否可写数据库 典型例子
VOLATILE 每次调用都可能不同 random()、命令函数
STABLE 同一语句内相同输入结果稳定 查询当前配置或表快照
IMMUTABLE 相同输入永久得到相同结果 纯数学、固定规则

VOLATILE 是默认值。不要为了“让它更快”错误标成 IMMUTABLE。优化器可对 不可变常量调用做预计算,prepared statement 还可能复用已折叠结果。

本章:

allowed_transition(text,text) -> IMMUTABLE
order_snapshot(bigint)        -> STABLE
transition_order(...)         -> VOLATILE
capture_payment(...)          -> VOLATILE
trigger functions             -> VOLATILE

波动性也决定可见快照

对 SQL 和标准过程语言函数:

  • STABLE / IMMUTABLE 内部查询使用调用语句建立的快照;
  • VOLATILE 函数执行的每条查询可取得更新的快照;
  • STABLE / IMMUTABLE 不能直接包含非 SELECT SQL 命令。

从表读取的函数通常最多是 STABLE,不是 IMMUTABLE。PostgreSQL 不会 彻底证明你对 IMMUTABLE 的承诺;错误标签可能返回过期或不一致结果。

依赖 TimeZonelc_*、配置参数或 collation 的转换也往往不是 IMMUTABLE。例如时间文本解析在不同设置下可能不同。

完整语义见 Function Volatility Categories

STRICT 的精确含义

STRICT 等价于 RETURNS NULL ON NULL INPUT

任一输入为 NULL
  -> 不执行函数 body
  -> 直接返回 NULL

它不是“做严格校验”。如果 NULL 应返回业务错误、空集合或默认值,就不能 声明 STRICT

本章的纯判断和 snapshot 是 strict;command function 需要自己给出输入 错误合同,因此没有用 STRICT 静默短路。

并行标签

标签 规划含义
PARALLEL SAFE 可在 parallel worker 中运行
PARALLEL RESTRICTED 并行计划中只能由 leader 运行
PARALLEL UNSAFE 出现在查询中会阻止并行计划

默认是 UNSAFE。修改数据库、改事务状态、访问 sequence、持久改配置的 函数必须 unsafe;访问临时表、cursor、prepared statement 或 backend-local 状态通常 restricted。

把不安全函数误标 safe 不只是性能问题,可能报错或产生错误结果。拿不准就 保留默认 UNSAFE。规则由 CREATE FUNCTION 定义。

COSTROWS

规划器不知道自定义函数真实成本,只能使用声明:

ALTER FUNCTION expensive_match(text)
COST 1000;

ALTER FUNCTION expand_tokens(text)
ROWS 20;
  • COST 使用 cpu_operator_cost 单位;
  • 对 set-returning function,cost 是每行成本;
  • ROWS 只用于集合返回,默认估算可能与实际相差很大。

错误估算会改变 join 顺序、调用次数和计划形状。先用真实计划和数据证明偏差, 再调整;不要把 COST 当成强制 hint。

SQL function 内联与可观测性

满足条件的简单 SQL function 可能被优化器内联,调用形态会融入外层查询。 这通常有利于谓词优化,但意味着:

  • 不要依赖函数一定作为独立执行节点;
  • 函数级计数不等于完整调用 trace;
  • 观察时同时看外层 query、plan 和 pg_stat_statements
  • 安全敏感函数不能靠“看起来像独立调用”建立边界。

SECURITY DEFINER、配置属性和更复杂 body 会限制可用的优化。不要为了内联 牺牲权限正确性。

从目录审计声明

routine-catalog.sql 读取:

SELECT
    p.oid::regprocedure,
    p.prokind,       -- f=function, p=procedure
    p.provolatile,   -- i/s/v
    p.proisstrict,
    p.proparallel,   -- s/r/u
    p.prosecdef,
    p.proconfig
FROM pg_proc AS p
...

DDL source 说明意图;pg_proc 证明目标数据库实际装了什么。发布门禁要比较 两者,而不是二选一。

13.2.3 异常、子事务与错误契约

错误是接口结果的一部分

不稳定的做法:

RAISE EXCEPTION 'bad order';

它默认使用通用 P0001,调用方只能解析 message。更好的合同:

RAISE EXCEPTION USING
    ERRCODE = 'P3613',
    MESSAGE = 'order status transition rejected',
    DETAIL = format(
        'order_id=%s transition=%s->%s',
        OLD.order_id,
        OLD.status,
        NEW.status
    ),
    HINT = 'Use an allowed transition through the command API.';

客户端判断:

SQLSTATE P3613 -> domain transition rejected
SQLSTATE 40001 -> retry whole transaction within budget
SQLSTATE 42501 -> deployment/privilege defect, do not retry

message 给人读,SQLSTATE 给程序判断。命名约束、schema/table/column 和 routine context 也应保留给诊断。

自定义 SQLSTATE 可以使用除 00000 之外的五字符编码,但应维护集中注册表。 不要使用以 000 结尾的 category code,因为异常处理只能匹配整个类别, 难以精确捕获。

默认传播通常是正确答案

没有 EXCEPTION 块时,函数错误向外传播,调用语句失败;调用者事务进入 相应失败状态。这保留了原子性。

不要在底层函数中这样写:

EXCEPTION WHEN OTHERS THEN
    RETURN NULL;

它会:

  • 把权限错误、数据损坏和编程错误伪装成“无结果”;
  • 丢掉 SQLSTATE 和上下文;
  • 可能让外层事务提交部分工作;
  • 让告警与重试策略失去依据。

尤其注意:OTHERS 不捕获 QUERY_CANCELEDASSERT_FAILURE;显式捕获 它们通常也不明智。

EXCEPTION 块形成子事务

PL/pgSQL:

BEGIN
    -- inner block
    UPDATE ...;
    PERFORM risky_call();
EXCEPTION
    WHEN SQLSTATE 'P3613' THEN
        ...
END;

进入带 handler 的 block 后,内部持久化修改在错误时回滚;局部变量保持错误 发生时的值,handler 继续执行。底层由子事务实现,进入/退出比普通 block 昂贵。

本章 exception-probe.sql 证明:

event=caught-inner-subtransaction
sqlstate=P3613
status_after=created
version_after=0

非法更新没有逃出 inner block,外层仍取得错误字段。整个 probe 最后 ROLLBACK,不污染 fixture。

读取原始错误字段

在 handler 中:

GET STACKED DIAGNOSTICS
    caught_state   = RETURNED_SQLSTATE,
    caught_message = MESSAGE_TEXT,
    constraint_id  = CONSTRAINT_NAME,
    detail_text    = PG_EXCEPTION_DETAIL,
    hint_text      = PG_EXCEPTION_HINT,
    context_text   = PG_EXCEPTION_CONTEXT;

优先保留结构化字段;不要用正则从 message 提取约束名。控制结构与可用字段 见 PL/pgSQL Control Structures

只捕获能解决的错误

合理用途:

  • 把已知底层约束错误转换成稳定领域 SQLSTATE,同时保留 cause;
  • 对一项可跳过的批任务记录失败后继续;
  • 实现确有必要的补偿分支;
  • 测试某个失败后内部修改确实回滚。

不合理用途:

  • 用 unique violation 实现常规 upsert,而不用 ON CONFLICT
  • 在函数里无限重试 serialization failure;
  • 捕获所有错误并写一条 NOTICE
  • 把 statement timeout 当成空结果;
  • 在 trigger 中吞错,让非法主写入提交。

重试属于更外层的整事务协议

一个 function 调用可能读写多张表、触发多个 trigger。若收到 4000140P01,重试其中某条内部 SQL 不能还原事务入口快照。应由知道完整业务 意图的一层,在有界预算内重放整个事务。

自定义领域拒绝 P3613/P3614/P3616/P3618 不是瞬态数据库错误:

  • P3613:调用命令错误;
  • P3614:事务最终事实不一致;
  • P3616:先重新读取,再由业务决定;
  • P3618:支付前置条件错误。

把所有错误都自动重试只会放大负载和隐藏缺陷。

本节检查表

发布一个 function 前确认:

  1. 输入类型、参数名与默认值是否是有意的兼容面;
  2. 返回标量、单行、多行和顺序是否明确;
  3. 多态与重载能否对实际实参唯一解析;
  4. volatility 是否真能兑现;
  5. NULL 是否应该 strict 短路;
  6. parallel 标签是否符合内部行为;
  7. COST/ROWS 是否有证据;
  8. 成功、空结果、领域拒绝和系统错误是否可区分;
  9. handler 是否只捕获能处理的 SQLSTATE;
  10. 失败是否保持调用者事务原子性;
  11. 目录属性、ACL 与 source 是否一致;
  12. 能否在应用角色下执行正负路径测试。

上一节:先决定逻辑放在哪里 · 返回本章目录 · 下一节:触发器与约束触发器 · 查看全书目录 · 查看索引中心

13.3 触发器与约束触发器

trigger 是“当某类事件发生时,在同一 PostgreSQL 事务中自动调用函数”的 对象。自动不等于异步,也不等于免费:

original DML
  + trigger function SQL
  + trigger locks
  + trigger WAL
  + trigger errors
= caller latency and transaction outcome

设计 trigger 时,必须同时说明事件、粒度、时机、返回语义、权限、顺序、 批量成本和失败合同。

13.3.1 行级、语句级与 transition table

行级:一次处理一对 OLD/NEW

FOR EACH ROW 对每个受影响行调用一次:

CREATE TRIGGER a_guard_order_transition
BEFORE UPDATE OF status, version
ON shop_ch13.sales_order
FOR EACH ROW
EXECUTE FUNCTION shop_ch13.guard_order_transition();

一条更新三行的 SQL,会进入 trigger function 三次。PL/pgSQL trigger function 通过特殊变量取得上下文:

变量 作用
TG_OP INSERT / UPDATE / DELETE / TRUNCATE
TG_WHEN BEFORE / AFTER / INSTEAD OF
TG_LEVEL ROW / STATEMENT
TG_TABLE_SCHEMATG_TABLE_NAME 触发关系
TG_ARGV[] CREATE TRIGGER 传入的文本参数
OLD UPDATE/DELETE 的旧行
NEW INSERT/UPDATE 的新行

本章 guard 比较:

IF NEW.status IS DISTINCT FROM OLD.status THEN
    IF NOT shop_ch13.allowed_transition(
               OLD.status,
               NEW.status
           ) THEN
        RAISE ... ERRCODE = 'P3613';
    END IF;

    IF NEW.version IS DISTINCT FROM OLD.version + 1 THEN
        RAISE ... ERRCODE = 'P3615';
    END IF;
END IF;

这是行级 trigger 的合适形状:判断只依赖一对旧、新行和纯 transition matrix,没有为每行扫描整张表。

语句级:一次处理整个命令

FOR EACH STATEMENT 对一条符合事件的语句调用一次,即使最终影响零行也可能 调用。它没有单行 OLD/NEW。如果需要看到受影响集合,使用 transition relations:

CREATE TRIGGER z_audit_order_transition
AFTER UPDATE
ON shop_ch13.sales_order
REFERENCING
    OLD TABLE AS old_rows
    NEW TABLE AS new_rows
FOR EACH STATEMENT
EXECUTE FUNCTION shop_ch13.audit_order_transition();

trigger function 将它们当只读关系使用:

INSERT INTO shop_ch13.order_history (...)
SELECT ...
FROM old_rows
JOIN new_rows USING (order_id)
WHERE old_rows.status IS DISTINCT FROM new_rows.status;

随后写一条 statement audit:

INSERT INTO shop_ch13.statement_audit (...)
SELECT
    pg_current_xact_id(),
    actor,
    session_user,
    count(*)::integer,
    array_agg(new_rows.order_id ORDER BY new_rows.order_id),
    statement_timestamp()
FROM old_rows
JOIN new_rows USING (order_id)
WHERE old_rows.status IS DISTINCT FROM new_rows.status;

实验中:

UPDATE shop_ch13.sales_order
SET status = 'canceled', version = version + 1
WHERE order_id IN (105, 106, 107);

得到:

affected_count=3
order_ids={105,106,107}
statement_audit rows added=1
order_history rows added=3

这比 row trigger 内每行再做聚合更符合集合模型。

transition table 的边界

transition relations:

  • 只用于 AFTER trigger;
  • 捕获一条原始 SQL 对该关系形成的旧/新行集合;
  • 可以给 AFTER ROWAFTER STATEMENT trigger 使用;
  • 不能与 constraint trigger 结合;
  • PostgreSQL 当前不允许带 transition relations 的 UPDATE trigger 同时使用 UPDATE OF column_list
  • 会物化变更集合,因此大批量语句要评估内存、临时文件与延迟。

它们不是跨事务 change stream,也不是 logical decoding 的替代品。

constraint trigger

用户定义的 constraint trigger:

  • 使用 CREATE CONSTRAINT TRIGGER
  • 必须是 plain table 上的 AFTER ROW trigger;
  • 可声明 DEFERRABLEINITIALLY DEFERRED
  • 可被 SET CONSTRAINTS 调整到事务末尾或立即检查;
  • 同样在当前事务中执行。

本章分别挂在订单与支付表:

CREATE CONSTRAINT TRIGGER z_validate_paid_order
AFTER INSERT OR UPDATE OF status, total_minor
ON shop_ch13.sales_order
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION shop_ch13.validate_paid_order();

CREATE CONSTRAINT TRIGGER z_validate_payment
AFTER INSERT OR UPDATE OR DELETE
ON shop_ch13.payment
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION shop_ch13.validate_paid_order();

command function 先插入 captured payment,再把订单改为 paid。两个中间瞬间 分别不满足最终关系,但提交点满足:

inside transaction:
  payment captured + order created   -- 暂时不一致
  payment captured + order paid      -- 最终一致
COMMIT:
  deferred checks run                -- 通过

只把订单改成 paid,则提交点返回 P3614,订单更新、history 和 statement audit 全部回滚。

延迟不等于并发安全

constraint trigger 能检查当前事务看到的最终状态,却不会自动选择正确锁。 例如两个事务并发改变同一聚合的不同明细,如果没有共同仲裁行、适当锁或 serializable 协议,双方可能基于不完整视图判断。

本章 capture_payment() 先:

SELECT ...
FROM shop_ch13.sales_order
WHERE order_id = p_order_id
FOR UPDATE;

同一订单的支付命令在订单行上串行化。这是显式并发设计,不是 deferred 关键字赠送的能力。复杂跨行断言必须单独做并发测试。

13.3.2 BEFORE、AFTER 与 INSTEAD OF

BEFORE:拒绝、规范化或改写当前行

row-level BEFORE 在行写入前运行,可以:

  • 检查 OLD/NEW
  • 修改 INSERT/UPDATE 的 NEW
  • 返回 NEW 继续;
  • 返回 NULL 跳过该行。

本章在合法状态变化时统一:

NEW.updated_at := statement_timestamp();
RETURN NEW;

返回 NULL 会让当前行操作被静默跳过,还会影响后续 row trigger 和命令 影响行数。除非“跳过”本身就是明确合同,通常应抛出带 SQLSTATE 的错误, 而不是让调用方误以为写入成功。

row-level BEFORE DELETE 返回 OLD 才能继续删除。trigger function 若要 复用于多个事件,必须逐个写清返回规则。

UPDATE OF 看 SET 列表,不看最终差异

BEFORE UPDATE OF status

status 出现在 SET 目标列表时触发,即使:

SET status = status

它也会触发。反过来,另一个 BEFORE trigger 修改 NEW.status 并不会让原本 未列出 status 的 column-specific trigger 补触发。

真正判断值是否变化要使用:

WHEN (OLD.status IS DISTINCT FROM NEW.status)

或在 body 内判断。IS DISTINCT FROM 对 NULL 有确定语义。

AFTER:观察已完成变化

AFTER 运行时:

  • 当前行操作和即时约束已经完成;
  • 其他 trigger 造成的变化可见;
  • 返回值被忽略;
  • 抛错仍会回滚原语句和事务。

适合:

  • 同事务 audit/history;
  • 基于最终行值派生另一张表;
  • transition table 集合处理;
  • deferred constraint check。

不适合远端 I/O,原因仍是它属于原事务同步延迟。

INSTEAD OF:为 view 定义写语义

INSTEAD OF 只用于 view 的 row trigger。它收到 view 的 OLD/NEW,由 trigger function 决定对底表做什么。

先确认 view 是否已经自动可更新。对简单单表 view,PostgreSQL 可以自动把 DML 映射到底表;不需要 trigger。只有复杂 join、聚合或有意设计的 view command surface 才考虑 INSTEAD OF

示意:

CREATE VIEW order_command AS
SELECT order_id, status, version
FROM private_order;

CREATE TRIGGER route_order_command
INSTEAD OF UPDATE ON order_command
FOR EACH ROW
EXECUTE FUNCTION route_order_command();

trigger function 必须:

  • 定义哪些 view 列可写;
  • 拒绝其余列;
  • 处理并发 version;
  • 返回符合 view 形状的 NEW
  • 给出稳定 SQLSTATE;
  • 保持权限边界。

如果实际意图是一个显式命令,SELECT transition_order(...) 往往比伪装成 view UPDATE 更清楚。

同类 trigger 的顺序

同一表、同一事件、同一时机的多个 trigger 按名字字母顺序执行。这个事实可 用于确定性,但不应构建脆弱流水线:

a_normalize
b_validate
c_audit

一旦正确性依赖命名,重命名、extension trigger 或迁移合并都可能改变行为。 更稳妥的选择:

  • 合并强耦合逻辑到一个 trigger function;
  • 让各 trigger 彼此独立、幂等;
  • 用约束表达真正的最终条件;
  • 在目录测试中冻结 trigger inventory。

官方顺序与语义见 CREATE TRIGGER

运行角色

trigger 与触发语句属于同一事务。PostgreSQL 18 对 queued trigger 明确保留 排队时的 active role;若 trigger function 是 SECURITY DEFINER,则以 function owner 执行。14–17 的延迟触发角色细节必须按目标版本验证。

本章把会写保护表的 trigger function 显式设为 SECURITY DEFINER,固定 search_path,撤销应用对内部函数的 EXECUTE。这样权限意图不依赖嵌套 command function 返回后的角色状态。

创建 trigger 时,创建者需要表的 TRIGGER privilege 和 trigger function 的 EXECUTE privilege。运行态权限设计还必须结合 function 的 SECURITY INVOKER/DEFINER

13.3.3 递归、顺序、批量写入与隐藏成本

trigger 是写路径的一部分

评估成本不要只看原 SQL:

rows affected
× row triggers per row
× SQL issued per trigger
+ statement triggers
+ deferred trigger queue
+ indexes/WAL on derived tables
+ contention introduced by trigger queries

一条 COPY 或无过滤 UPDATE 可能把平时每次一行的隐藏成本放大百万倍。

递归不会自动终止

trigger function 再写同一表,可能再次触发自己:

UPDATE t
  -> trigger
       -> UPDATE t
            -> trigger
                 -> ...

PostgreSQL 允许 cascading trigger;终止责任在设计者。

pg_trigger_depth() 能告诉当前嵌套深度,适合诊断。把:

IF pg_trigger_depth() > 1 THEN
    RETURN NEW;
END IF;

当作主要正确性机制往往掩盖模型问题:另一个合法 trigger 链也可能让深度 大于一,而真正递归仍可能从其他路径进入。优先:

  • 不在 trigger 中更新触发表;
  • BEFORE 中直接修改 NEW
  • 将派生写放到不同关系;
  • 让操作幂等并用明确状态终止;
  • 对递归反例做受控测试。

ON CONFLICT 与 MERGE 会组合多个事件

INSERT ... ON CONFLICT DO UPDATE 可能先运行 row-level BEFORE INSERT, 冲突后再运行 BEFORE UPDATE。statement-level INSERT/UPDATE trigger 也有 定义好的组合顺序,即使 UPDATE 分支最终没有影响行。

因此:

  • INSERT normalization 必须考虑其结果会进入 EXCLUDED
  • 两组 trigger 不应重复不可幂等副作用;
  • 测试要覆盖 insert 成功、conflict update、conflict no-op;
  • 不能从“最终是 UPDATE”推断只执行 UPDATE trigger。

PostgreSQL 15+ 的 MERGE 同样需要按实际 action 路径测试,不凭类比; 14 环境没有该语句。

每行查询导致 N+1

反模式:

-- 每个更新行都扫描一次 history
SELECT count(*)
INTO n
FROM order_history
WHERE order_id = NEW.order_id;

批量更新 N 行就产生 N 次查询。替代方案:

  • 用原生约束;
  • 在原 UPDATE 中 join/CTE;
  • 用 transition table 一次集合处理;
  • 为不可避免的 lookup 建正确索引;
  • 把可延后的分析移到异步 worker。

审计不是“复制整行就完成”

可靠 audit 要定义:

  • 记录业务变化还是所有 UPDATE;
  • old/new 哪些列,是否包含敏感数据;
  • actor 是认证主体、数据库 session 还是服务;
  • request/trace ID 如何传递和防伪;
  • transaction ID 与 statement 时间是什么语义;
  • 审计表谁能改、保留多久、如何分区;
  • 失败时是否必须与主写入一起回滚。

本章保存 actorsession_actor,但 actor 来自受控 command function 设置 的 transaction-local custom setting。由于应用没有底表 DML,不能仅靠 设置该值伪造一次写入;真正系统还要把 actor 与认证层可信上下文绑定。

分区表的额外行为

在 partitioned table 上创建 row trigger,会在已有和后续 partition 上建立 clone trigger。attach/detach、同名冲突和 major version 行为都需要目录测试。行因更新 partition key 被移动时,源 partition 的 DELETE 与目标 partition 的 INSERT trigger 也会参与。

不要只在 root table 的 \d 输出上推断所有 partition 的实际 trigger。

禁用 trigger 是高风险动作

ALTER TABLE ... DISABLE TRIGGER、replication role 或恢复路径可能绕开 业务 trigger。批量导入前“先关 trigger 提速”意味着暂时取消不变量,必须 有:

  • 明确授权与维护窗口;
  • 隔离写入口;
  • 导入后全量验证;
  • 恢复 trigger 的 finally 路径;
  • 失败时数据修复方案;
  • 目录与配置证据。

若规则应是不可绕过的约束,优先用原生 constraint,而不是依赖所有人永不 禁用 trigger。

从目录取得事实

trigger-catalog.sql 读取:

SELECT
    c.relname,
    t.tgname,
    t.tgfoid::regprocedure,
    t.tgdeferrable,
    t.tginitdeferred,
    t.tgoldtable,
    t.tgnewtable,
    pg_get_triggerdef(t.oid, true)
FROM pg_trigger AS t
JOIN pg_class AS c ON c.oid = t.tgrelid
WHERE NOT t.tgisinternal;

实验冻结四个 user trigger:

payment:
  z_validate_payment          AFTER ROW, deferred

sales_order:
  a_guard_order_transition    BEFORE ROW
  z_audit_order_transition    AFTER STATEMENT, old_rows/new_rows
  z_validate_paid_order       AFTER ROW, deferred

tgisinternal 过滤了外键等系统内部 trigger;不要把它们误认成“没有 trigger”。 用户定义 constraint trigger 还会在 pg_constraint 中留下 contype='t' 记录。

发布检查表

  1. 为什么不是原生 constraint 或原 SQL?
  2. event、row/statement、timing 与返回语义是什么?
  3. 零行、单行、批量和 ON CONFLICT 路径是否测试?
  4. transition table 会物化多少数据?
  5. deferred check 的锁与并发协议是什么?
  6. 有没有写触发表或递归链?
  7. 同类 trigger 是否依赖名字顺序?
  8. 运行角色和 definer owner 是否最小权限?
  9. 错误是否有稳定 SQLSTATE?
  10. bulk load、partition、复制和恢复行为是否明确?
  11. pg_trigger inventory 是否进入 release gate?
  12. 回退时是撤销新调用、禁用、替换还是删除,顺序是什么?

trigger 只有在这些问题都能回答时,才称得上数据库护栏。


上一节:SQL 与 PL/pgSQL 函数 · 返回本章目录 · 下一节:过程、任务与事务控制 · 查看全书目录 · 查看索引中心

13.4 过程、任务与事务控制

procedure 与 function 都是 routine,但 procedure 不是“返回 void 的 function”。最重要的差别是调用位置与受限的事务控制。

本节把三个经常混在一起的概念拆开:

procedure = database routine body
job       = one intended execution with identity and state
scheduler = decides when/where/how often a job executes

PostgreSQL procedure 只解决第一项。

13.4.1 procedure 与 function 的边界

调用方式决定语义

维度 function procedure
定义 CREATE FUNCTION CREATE PROCEDURE
调用 表达式、SELECT、DML 独立 CALL
普通返回 RETURNS ... 无 function value
输出 标量/复合/集合 OUT/INOUT 参数形成结果行
可嵌入查询
STRICT 等规划属性 可用 不适用
事务结束 不允许 满足限制时可 COMMIT/ROLLBACK

function:

SELECT *
FROM shop_ch13.transition_order(101, 0, 'canceled', 'api');

procedure:

CALL shop_ch13.expire_stale_orders(
    timestamptz '2024-02-01 00:00:00+00',
    500,
    0
);

最后一个 0 对应 INOUT p_total,调用完成后 PostgreSQL 返回包含 p_total 的一行。它不是可放进 join 的集合函数。

function 属于调用者事务

function 不能 COMMITROLLBACK。它的所有写入、trigger 与异常和外层 语句/事务一起成败:

BEGIN;
SELECT command_function(...);
UPDATE another_table ...;
COMMIT;

这非常适合原子业务命令。若 function 尝试结束事务,会报错;不要用动态 SQL 绕过。

procedure 的事务控制有严格前提

PL/pgSQL procedure 和顶层 DO 可以结束事务,结束后 PostgreSQL 自动开始 新事务。但必须满足:

  1. CALL/DO 从 top level 调用,或调用栈只有连续的 CALL/DO
  2. 外面没有显式 transaction block;
  3. 中间没有 SELECT function() 等其他命令打断 procedure 调用链;
  4. 当前不在带 EXCEPTION handler 的子事务 block 内;
  5. procedure 不是 SECURITY DEFINER
  6. procedure 定义没有附加 SET configuration_parameter clause。

允许:

CALL p1()
  -> CALL p2()
       -> COMMIT

不允许:

CALL p1()
  -> SELECT f2()
       -> CALL p3()
            -> COMMIT

也不允许:

BEGIN;
CALL procedure_that_commits();
COMMIT;

本章故意运行后一种形式,得到:

SQLSTATE 2D000
invalid transaction termination

随后用独立 top-level CALL 成功。规则由 PL/pgSQL Transaction ManagementCALL 定义。

SECURITY DEFINER 与事务控制不能兼得

SECURITY DEFINER procedure 不能执行 transaction control。附加 SET search_path = ... 等 configuration clause 的 procedure 也不能。

这造成一个有意的设计压力:

  • 需要提权的窄业务命令:通常用原子 function;
  • 需要多次提交的维护过程:使用 SECURITY INVOKER,由受控运维角色调用;
  • 不要给应用一个既提权又跨事务的万能入口。

本章的 procedure:

prosecdef=false
proconfig=[]
EXECUTE for pg36_app=false

它只能由 owner/受控管理路径调用。

何时选 function

选择 function,当:

  • 必须嵌入 query;
  • 整个业务动作要原子提交;
  • 需要返回集合;
  • 要作为 trigger function;
  • 需要 STRICT、volatility、parallel 等查询规划属性;
  • 要用窄 SECURITY DEFINER 接口授予能力。

何时选 procedure

选择 procedure,当:

  • 操作天然分成多个可独立提交批次;
  • 单事务会造成不可接受的 WAL、锁、快照或恢复成本;
  • 调用就是一个独立维护命令;
  • 能接受部分批次已经提交;
  • body 能设计为可重入、可续跑;
  • 调用路径满足 transaction-control 限制。

如果 procedure 不需要结束事务,选择它的理由应是调用语义或组织方式,而不 是“名字更企业级”。

13.4.2 批处理、维护任务与显式事务

从失败恢复目标反推批次

假设要过期一亿张陈旧订单。单事务可能:

  • 长时间持有 row/table locks;
  • 维持旧 snapshot,阻碍 vacuum;
  • 产生巨量 WAL 和 replica lag;
  • 失败时回滚很久;
  • 超过 statement timeout 或维护窗口。

批处理把恢复单位缩小:

select bounded candidates
  -> update one batch
  -> validate/record checkpoint
  -> commit
  -> repeat

但它放弃“全有或全无”。第 1–10 批已经提交,第 11 批失败时不能假装任务 未发生。

本章过程

setup.sql 中:

CREATE PROCEDURE shop_ch13.expire_stale_orders(
    p_before timestamptz,
    p_batch_size integer,
    INOUT p_total integer
)
LANGUAGE plpgsql
SECURITY INVOKER
AS $procedure$
DECLARE
    batch_count integer;
BEGIN
    ...
    LOOP
        PERFORM set_config(
            'pg36.actor',
            'ch13-maintenance',
            true
        );

        WITH candidate AS MATERIALIZED (
            SELECT order_id
            FROM shop_ch13.sales_order
            WHERE status = 'created'
              AND created_at < p_before
            ORDER BY order_id
            FOR UPDATE SKIP LOCKED
            LIMIT p_batch_size
        )
        UPDATE shop_ch13.sales_order AS target
        SET
            status = 'expired',
            version = target.version + 1
        FROM candidate
        WHERE target.order_id = candidate.order_id;

        GET DIAGNOSTICS batch_count = ROW_COUNT;
        p_total := p_total + batch_count;

        EXIT WHEN batch_count = 0;
        COMMIT AND CHAIN;
    END LOOP;
END
$procedure$;

实验有五张陈旧订单、batch size 2,statement audit 证明:

batch affected counts = [2, 2, 1]
p_total = 5

为什么 ORDER BY

bounded candidate 没有顺序,重跑时每批成员不可预测。ORDER BY order_id 提供稳定领取方向,也便于 evidence 和 checkpoint。

这不承诺全局处理完成顺序:并发 worker、SKIP LOCKED 和事务提交会改变 观察次序。若业务要求严格全局顺序,不能同时假设自由并发领取。

SKIP LOCKED 的精确含义

FOR UPDATE SKIP LOCKED 跳过当前无法立即取得行锁的候选,适合 queue-like 多 worker 领取。它提供的是不一致视图,因此不适合普通报表或必须看到所有 匹配行的判断。

设计 worker 时必须有终止与重扫策略:

  • 本轮跳过不代表永远处理;
  • 长期被锁行需要 age/backlog 告警;
  • worker 崩溃后事务锁会释放;
  • 已提交状态必须让重跑跳过;
  • 最终扫尾不能只看某一轮 ROW_COUNT=0 就断言全局完成,除非保证没有其他 worker 和锁。

本章只运行一个受控 worker,因此 0 可作为夹具终止条件;生产并发作业要 定义更强协议。

可重入比内存计数更重要

p_total 只报告本次调用处理量,不是 durable checkpoint。真正的恢复依据是:

WHERE status = 'created'

已提交行成为 expired,重跑不会重复变化。状态跃迁和 audit 同事务提交。

复杂 backfill 应维护 durable progress:

  • job/run identity;
  • range 或 high-water mark;
  • source/target row counts;
  • last committed key;
  • attempts 与 last error;
  • started/heartbeat/completed time;
  • release/schema version。

checkpoint 必须与对应批次数据在同一事务提交,否则会“数据已写而进度未记” 或“进度已记而数据未写”。

COMMIT AND CHAIN

普通 COMMIT 后也会自动开始新事务;COMMIT AND CHAIN 让下一事务继承 上一事务的 transaction characteristics,例如 isolation level。

它不会保留 transaction-local 状态:

  • SET LOCAL 在 commit 后结束;
  • transaction-level advisory lock 释放;
  • row/table locks 释放;
  • snapshot 更换。

所以本章每轮重新设置 transaction-local actor。生产代码也不能假设一个 procedure body 就是一个事务。

cursor loop 的陷阱

在 cursor-driven loop 中第一次 COMMIT 后,cursor 可能转为 holdable, 查询在该点被完整求值;cursor 原先取得的锁也不再持续持有。这可能:

  • 把“流式处理”变成一次物化;
  • 增加内存/临时文件;
  • 让后续数据变化不再出现在 cursor;
  • 失去预期锁保护。

本章每批重新执行 bounded query,不跨提交持有 cursor。

异常与部分完成

procedure 第三批失败时:

batch 1 committed
batch 2 committed
batch 3 rolled back
CALL returns error

调用方必须把“CALL 报错”与“没有变化”分开。运维输出要报告:

  • 已提交批次/行数;
  • 当前 checkpoint;
  • 失败 SQLSTATE;
  • 是否可重入;
  • 下一动作;
  • 数据一致性验证。

不要在最外层 WHEN OTHERS 把错误吞掉后返回 p_total,否则 scheduler 会 误判成功。

事务预算

批大小不是拍脑袋常数。基于:

  • 每行写放大、索引数与 WAL;
  • 单批 lock hold time;
  • replica apply lag;
  • autovacuum 和 bloat;
  • statement timeout;
  • worker 数量;
  • 业务并发延迟;
  • maintenance window。

使用关系而非固定耗时 golden:

batch rows <= configured maximum
checkpoint and rows commit together
next run starts after last committed boundary
lag/lock budget stays below stop line

跨机器的“每批必须 200ms”通常不是可靠测试。

13.4.3 调度属于平台职责,不由过程本身解决

一个可运维 job 至少有五层

schedule
  -> leader / target routing
  -> overlap and lease control
  -> procedure/worker body
  -> evidence, retry, alert, pause

procedure 只实现 body。把其余四层留空,任务虽然能手工 CALL,却还不能 上线。

Pigsty 中的入口选择

Pigsty 可管理 PostgreSQL 所在主机的 postgres 用户 cron,参数 pg_crontab 用于声明 OS crontab 项。Pigsty 扩展生态也提供 pg_cron;需要按 扩展配置 确认安装、preload、目标 database 和参数。

两者不是同一个机制:

机制 执行位置 适合
OS cron / systemd timer 主机进程启动 psql/程序 脚本、备份、跨工具工作
pg_cron PostgreSQL extension worker 数据库内 SQL schedule
应用 job platform 外部 worker/control plane 跨系统、重试、依赖编排

选择后绑定实际版本和行为,不从“装了 extension”推断任务已安全运行。

primary routing 与 failover

写任务必须回答:

  • 连接的是 current primary service,还是固定节点?
  • failover 时旧连接如何退出?
  • 新 primary 何时允许接管?
  • 同一 schedule 是否会在两台主机同时触发?
  • procedure 内部 commit 后,连接是否仍在正确实例?
  • recovery instance 上是否 hard refuse?

本章 context.sql 在写前检查:

NOT pg_is_in_recovery()

但单次 preflight 不能证明整个多事务 procedure 期间永不发生 role change。 生产 job 还要处理中断、重连和幂等续跑。

overlap control

定时任务可能上一轮未结束,下一轮又启动。可选控制:

  • scheduler 的 Forbid / single-flight policy;
  • durable job lease row;
  • session-level advisory lock;
  • 唯一 active-run constraint;
  • 任务状态机。

procedure 内含 COMMIT 时,transaction-level advisory lock 每批都会释放, 不能保护整个调用。session-level advisory lock 能跨 commit,但依赖同一 session,必须确保错误/断线释放并验证 pooler 路径。通常把 overlap policy 放 scheduler,并用数据库 durable lease 作为第二道防线。

pooler 边界

多事务 maintenance procedure 不是普通短 OLTP 请求。通过 PgBouncer transaction pool 前必须在目标组合上验证:

  • 一个 CALL 内部多次 commit 的协议行为;
  • statement timeout 与 cancel;
  • session-local setting、advisory lock 和 temp object;
  • 长任务是否占住 server connection;
  • 管理流量是否挤压应用 pool。

默认更清楚的做法是经 Pigsty direct 管理 service 运行受控维护,应用事务 经 primary + PgBouncer。第 12 章已说明服务端口只是参考映射,必须读取目标 inventory。

调度证据

一次可审计运行至少保存:

job name and release
schedule / manual initiator
target cluster, database, server identity
primary/recovery state
application_name
start/end/heartbeat
input cutoff and batch size
committed rows and checkpoint
SQLSTATE and retry decision
replication/lock/resource stop lines
artifact checksum

敏感 DSN 和密码不进入 evidence。

告警不是“exit != 0”就结束

同时监控:

  • last successful completion age;
  • current run age;
  • overlap/lease conflict;
  • backlog rows 与 oldest age;
  • processed rate;
  • per-SQLSTATE failure;
  • replica lag、WAL、locks、connections;
  • skipped/poison item 数;
  • repeated no-progress run。

procedure 正常返回但处理零行,可能是“没有 backlog”,也可能是过滤条件、 权限或连接目标错误。用前置 target identity 和业务关系区分。

发布与停用

上线顺序:

  1. 部署向后兼容的 table/function/procedure;
  2. 以受控角色手工运行小范围;
  3. 验证 SQLSTATE、batch、locks、WAL、replica;
  4. 建 schedule,但先 disabled 或一次性;
  5. 启用并观察至少一个完整周期;
  6. 冻结 source、manifest 和 run evidence。

停用顺序:

  1. 先禁止新的 schedule;
  2. 等待或有界取消当前 run;
  3. 验证没有 active worker;
  4. 保留 procedure 供兼容/恢复窗口;
  5. 观察期后再撤权和删除对象。

直接 DROP PROCEDURE 不会取消外部 scheduler;下一轮只会开始报错。

本节结论

procedure 是一个允许显式 CALL、在严格条件下结束事务的 routine。它适合 可重入多批维护,不适合:

  • 原子业务命令;
  • 查询表达式;
  • 自动调度;
  • 提权后跨事务万能操作;
  • 隐藏部分提交;
  • 远端工作流。

把 body、job state 和 scheduler 分开设计,才有可恢复性。


上一节:触发器与约束触发器 · 返回本章目录 · 下一节:安全、测试与观测 · 查看全书目录 · 查看索引中心

13.5 安全、测试与观测

数据库例程一旦拥有底表或管理能力,就同时是:

  • 可执行代码;
  • SQL API;
  • 权限边界;
  • 查询计划输入;
  • 写事务的一部分;
  • 生产观测对象。

因此评审标准不能停在“函数能调用、trigger 会触发”。本节把安全、测试和 观测合并,因为三者都在回答同一个问题:运行态是否真的是我们声明的对象。

13.5.1 SECURITY DEFINER、固定 search_path 与最小权限

invoker 与 definer

默认 SECURITY INVOKER

function uses caller privileges

SECURITY DEFINER

function uses owner privileges

后者可以给应用一个窄能力,而不授予底表权限:

pg36_app:
  no SELECT/UPDATE on shop_ch13.sales_order
  no INSERT on shop_ch13.payment
  EXECUTE capture_payment(...)

capture_payment owner:
  pg36_owner NOLOGIN
  owns only intended database objects

这比把 pg36_owner grant 给应用安全得多,但前提是 function 本身无法被 劫持或滥用。

threat model:名字解析

危险函数:

CREATE FUNCTION admin.check_secret(...)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
    SELECT ... FROM password_table ...;
END
$$;

如果运行时 search_path 先命中调用者可写 schema 或临时关系,攻击者可以 创建同名对象,让 definer 权限访问错误目标。函数、operator、type 和隐式 cast 的解析也可能成为入口。

官方 Writing SECURITY DEFINER Functions Safely 要求排除不可信可写 schema,并把 pg_temp 放在可信路径最后。

本章使用:

SECURITY DEFINER
SET search_path = pg_catalog, pg_temp

并在 body 中全限定业务对象:

UPDATE shop_ch13.sales_order ...
INSERT INTO shop_ch13.payment ...

pg_catalog 明确位于前面,pg_temp 明确位于最后;没有 public 或应用可写 schema。

固定 path 还不够

逐项检查:

  1. 所有 table/view/sequence/function/operator/type 是否解析到可信 owner;
  2. 动态 SQL 的 identifier 是否来自 allowlist,并用 %I
  3. value 是否通过 USING 绑定,不拼接;
  4. 是否调用可被不可信角色替换的同名重载;
  5. 临时对象能否遮蔽未限定 relation;
  6. 默认参数表达式是否依赖不可信对象;
  7. function owner 能否被低权限用户 SET ROLE
  8. owner 是否拥有超出需求的 cluster 能力。

本章 owner 是:

pg36_owner:
  NOLOGIN
  NOSUPERUSER
  NOCREATEDB
  NOCREATEROLE
  NOREPLICATION
  NOBYPASSRLS

NOLOGIN 阻止它成为应用连接身份;但能 SET ROLE pg36_owner 的成员仍等于 拥有其能力,membership 必须受控。

创建时立即撤销 PUBLIC

新 function 默认可能给 PUBLIC EXECUTE。若先创建、稍后再 revoke,中间 存在可调用窗口。把 DDL 与 ACL 放在一个事务:

BEGIN;

CREATE FUNCTION shop_ch13.transition_order(...)
...
SECURITY DEFINER;

REVOKE ALL ON FUNCTION
    shop_ch13.transition_order(bigint, bigint, text, text)
FROM PUBLIC;

GRANT EXECUTE ON FUNCTION
    shop_ch13.transition_order(bigint, bigint, text, text)
TO pg36_app;

COMMIT;

本章 setup 最终执行:

REVOKE ALL ON ALL FUNCTIONS IN SCHEMA shop_ch13 FROM PUBLIC;

GRANT EXECUTE ON FUNCTION order_snapshot(bigint) TO pg36_app;
GRANT EXECUTE ON FUNCTION transition_order(...) TO pg36_app;
GRANT EXECUTE ON FUNCTION capture_payment(...) TO pg36_app;

内部 trigger functions 和 maintenance procedure 不授给应用。

参数不是授权

危险接口:

admin.run_sql(command text)
admin.read_table(schema_name text, table_name text)
admin.set_role(role_name text)

即使用 %I 防注入,调用者仍可能选择不应访问的合法对象。安全接口必须 收窄业务能力:

transition_order(order_id, expected_version, target_status, actor)

body 自己决定:

  • 只写哪张表;
  • 允许哪些边;
  • 取得什么锁;
  • version 如何推进;
  • 返回哪些列;
  • 哪些 SQLSTATE 暴露。

“防 SQL injection”只是必要条件,不等于授权正确。

输入与资源预算

definer function 应限制:

  • identifier 长度与字符集;
  • array/JSON 最大大小;
  • batch size;
  • 正则或全文检索复杂度;
  • 可查询时间范围;
  • 动态 identifier 集合;
  • statement/lock timeout;
  • 单次返回行数。

本章 actor:

IF p_actor IS NULL
   OR p_actor !~ '^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,63}$' THEN
    RAISE ... ERRCODE = 'P3617';
END IF;

actor 仍不是认证机制;它只保证安全形状。可信服务必须从已认证上下文生成, 而不是把任意用户输入原样传入。

RLS 不是自动叠加

table owner 通常绕过 row-level security,除非 FORCE ROW LEVEL SECURITY; superuser 和 BYPASSRLS 也有特殊能力。definer function 以 owner 运行时, 不能假设 caller 的 RLS policy 继续隔离行。

若 command API 需要 tenant isolation:

  • 显式把 tenant identity 绑定到可信 session/参数;
  • 在 body 的每条 SQL 中加入 tenant predicate;
  • 评审 owner 与 FORCE ROW LEVEL SECURITY
  • 测试跨 tenant 读取、更新和错误差异;
  • 防止通过存在性、timing 或错误字段泄露其他 tenant。

“底表有 RLS”不是 definer function 的完整安全证明。

trigger function 也是代码入口

应用通常不会直接调用 trigger function,但:

  • trigger 创建者需要相应权限;
  • function source 仍可能被替换;
  • function owner 和 path 决定运行能力;
  • 其他表可能误挂同一 trigger function;
  • 默认 PUBLIC EXECUTE 仍扩大无意义攻击面。

所以本章也 revoke 内部函数 direct execute,并冻结:

trigger name
parent table
function regprocedure
SECURITY DEFINER
search_path
marker

安全目录测试

security-catalog.sql 验证:

app_schema_usage=true
app_order_select=false
app_order_update=false
app_payment_insert=false
app_snapshot_execute=true
app_transition_execute=true
app_capture_execute=true
app_guard_execute=false
app_procedure_execute=false
public_transition_execute=false

ACL 是发布 artifact,不是手工配置备注。

13.5.2 单元测试、属性测试与并发测试

测试从目录到事务逐层增加

1. DDL/目录合同

验证:

  • exact signature 与 prokind
  • language、volatility、strict、parallel;
  • prosecdefproconfig
  • trigger event/timing/level;
  • deferred、transition table;
  • owner、ACL、marker;
  • pg_get_functiondef() / pg_get_triggerdef() 与 release source。

这能发现“装错对象”,不能证明业务行为。

2. 纯函数单元测试

对 transition matrix 枚举所有状态对;实验由 transition-matrix.sql 固化:

WITH state(value) AS (
    VALUES
      ('created'), ('paid'), ('packing'), ('shipped'),
      ('completed'), ('canceled'), ('expired')
)
SELECT
    old.value,
    new.value,
    shop_ch13.allowed_transition(old.value, new.value)
FROM state AS old
CROSS JOIN state AS new
ORDER BY 1, 2;

断言允许边恰好是六条,反向边和 terminal outward 全部 false。对纯函数, 这种穷举 property test 比几个 happy example 更强。

3. command 正负路径

成功:

created v0 -> canceled v1
created v0 + exact payment -> paid v1

失败:

created -> shipped        -> P3613
paid without payment      -> P3614 at commit
delete captured payment   -> P3614 at commit
expected v0 after v1      -> P3616
wrong payment amount      -> P3618
direct app UPDATE         -> 42501

每个失败都同时断言:

  • order status/version unchanged;
  • payment count unchanged;
  • history/audit unchanged;
  • transaction can only continue when error is intentionally caught in a subtransaction。

只检查“报错了”不够;错误前的隐藏写也必须回滚。

4. trigger 粒度测试

单条三行 UPDATE:

BEFORE ROW calls = 3
history rows     = 3
AFTER STATEMENT  = 1
affected_count   = 3
order_ids        = {105,106,107}

再测试零行 UPDATE,确认 statement trigger 是否执行以及 body 是否避免写空 audit。

5. deferral 测试

在同一事务中分别执行:

INSERT captured payment;
UPDATE order TO paid;
SET CONSTRAINTS ALL IMMEDIATE;

应通过。只做其中一步应在 SET CONSTRAINTS 或 commit 时报 P3614。这能 区分“语句成功”与“事务可提交”。

6. exception 子事务

exception-probe.sql 精确捕获 P3613, 使用 GET STACKED DIAGNOSTICS,并证明 inner persistent change 回滚。

7. procedure 事务边界

同一 fixture 先运行:

BEGIN;
CALL expire_stale_orders(...);
COMMIT;

必须是 2D000 且候选仍为 created。再以 top-level CALL 运行,取得 2/2/1 与 total 5;第二次 CALL 必须取得 total 0 且 audit 不增长。

以真实角色测试

owner 测试不能证明应用 ACL。实验分别建立连接:

admin connection:
  session_user=postgres
  SET ROLE pg36_owner

application connection:
  session_user=pg36_app
  no SET ROLE

正向 API 和直接写拒绝必须在 application connection 运行。测试 DSN 不应 因为本机 trust 就被误认为生产认证已验证。

绕过应用是必测路径

如果 trigger 声称覆盖所有普通写入口,测试必须直接:

SET ROLE pg36_owner;
UPDATE shop_ch13.sales_order
SET status = 'shipped', version = version + 1
WHERE order_id = 103;

它绕过 command function,仍应收到 P3613。只从应用 API 测 trigger, 无法区分是应用校验还是数据库护栏生效。

并发属性

至少覆盖:

  1. 两个 command 使用同一 expected version;
  2. 两个支付引用争同一订单;
  3. 相反顺序锁多张表是否 deadlock;
  4. deferred aggregate 在并发明细下是否遗漏;
  5. procedure 与在线命令争同一行时 SKIP LOCKED 是否可恢复;
  6. function 在 READ COMMITTED / REPEATABLE READ / SERIALIZABLE 的错误集合;
  7. cancel/timeout 后锁、连接和事务是否释放。

本章 deterministic suite 证明单订单 FOR UPDATE 与 optimistic version 合同,但没有声称覆盖生产并发规模。对真实模型应沿用第 10 章 gate worker 方法,保存 PID、backend_start、application_name、wait graph 和 SQLSTATE。

property 不只测输入

可冻结的关系:

sum(order versions) = history rows
sum(statement_audit.affected_count) = history rows
paid orders = orders with exact captured total
terminal statuses have no outgoing history edge
failed cases add zero durable rows
rerun procedure processes zero already-expired rows
business checksum stable across exact rebuild

这种关系比 identity sequence 恰好连续或耗时固定更耐环境变化。

migration 与 rollback 测试

例程发布还要验证:

  • CREATE OR REPLACE 是否保持 OID/ACL/依赖和返回类型限制;
  • 新旧签名是否同时存在并产生重载歧义;
  • trigger 新旧版本是否会重复执行;
  • 回退应用调用旧签名是否仍成功;
  • drop 前是否还有依赖和活跃调用;
  • reset 是否只作用于 marker 对象。

本章 reset 对错误 token、错误 target、活跃 worker 和对象 inventory 漂移 全部 fail closed。

13.5.3 函数级统计、日志与慢调用定位

track_functions

track_functions 控制用户函数累计统计:

含义
none 不跟踪,默认
pl 跟踪过程语言函数
all 也跟踪 SQL/C 函数

开启有开销,应按观察目标和窗口决定。需要相应权限修改;生产上通过受控 配置流程,而不是应用连接临时打开。

累计视图:

SELECT
    schemaname,
    funcname,
    calls,
    total_time,
    self_time
FROM pg_stat_user_functions
WHERE schemaname = 'shop_ch13'
ORDER BY total_time DESC;
  • total_time 包含被调函数时间;
  • self_time 排除被调函数时间;
  • 数值是累计量,不是分位数;
  • stats 有 flush 延迟,并受 transaction 内 snapshot/cache 影响;
  • restart、crash 或显式 stats reset 会影响统计连续性;PostgreSQL 18 的 pg_stat_user_functions 本身不提供每行 stats_reset 列,观察系统要另行 记录采集窗口。

当前事务可看:

SELECT *
FROM pg_stat_xact_user_functions;

本章在一笔 rollback-only probe 中打开 all,调用 snapshot 和 transition, 取得五个 routine 的 calls >= 1,然后回滚业务变化。官方定义见 Cumulative Statistics System

function counters 不能回答什么

它们不能直接给出:

  • p95/p99;
  • 哪个 request 调用;
  • 参数值;
  • 哪条内部 SQL 最慢;
  • 哪个 call 失败;
  • lock/wait 分解;
  • SQL function 内联后的完整逻辑边界。

所以它是定位入口,不是 trace。

把外层与内部 SQL 关联

组合:

application_name + trace/request id
  -> outer SELECT function(...)
  -> pg_stat_activity / wait_event
  -> pg_stat_statements
  -> nested statement stats/log
  -> function counters
  -> SQLSTATE + trigger context
  -> business audit/outbox

pg_stat_statements.track = all 可纳入嵌套语句,但会改变数据量;需按目标 配置验证。auto_explain.log_nested_statements 可在有界诊断窗口记录嵌套 计划,同样要控制 duration、sample rate、buffers 与日志敏感性。

不要长期把所有参数和完整 PL/pgSQL context 无筛选写日志。订单引用、用户 标识、token、payload 可能是敏感信息。

慢 routine 的诊断顺序

  1. 确认目标 cluster/database/schema/signature;
  2. 区分 outer call 慢还是在 pool/lock 等待;
  3. pg_stat_activity.state/wait_event
  4. 看 block graph 与长事务;
  5. 对内部 SQL 取得规范化 query identity;
  6. 用实际参数分布 EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS)
  7. 检查 row-trigger 放大与 transition table 大小;
  8. 检查 deferred queue 是否在 commit 集中爆发;
  9. 比较 function total_time/self_time
  10. 最后才改 SQL、索引、batch 或逻辑位置。

不要看见高 total_time 就重写 PL/pgSQL。总时间可能只是调用次数高,或内部 SQL 在锁上等待。

trigger 的可见性

原始 query:

UPDATE sales_order SET ...

不会把所有 trigger body 展开在 pg_stat_activity.query。需要:

  • pg_trigger inventory;
  • function stats;
  • nested statement statistics/logging;
  • SQLSTATE context;
  • derived audit relationship;
  • 应用端命令与数据库 transaction ID 关联。

本章 statement audit 保存 pg_current_xact_id(),history 保存同一 xid8。 这是数据库内关联,不是全链路 trace。

Pigsty 观察面

Pigsty monitoring 以 metrics、logs、alerting 为三根支柱,并覆盖 PostgreSQL 实例、SQL、连接、复制、WAL 和基础设施。见 Monitoring System

例程上线时至少增加或确认:

  • command function rate/error by low-cardinality identity;
  • SQLSTATE rate;
  • function cumulative calls/time delta;
  • outer SQL latency;
  • lock/wait;
  • job backlog/age/last success;
  • audit/outbox growth;
  • database/replica/WAL/connection resource;
  • deployment/release annotation。

不要把 actor、order_id 或 function 参数做成 metrics label;高基数和敏感性 都不合适。它们应进入受控日志或数据库 evidence。

观察窗口

发布后按阶段:

catalog/ACL verified
  -> canary command
  -> negative path
  -> representative bulk
  -> lock/WAL/replica observation
  -> enable production callers
  -> watch one workload cycle
  -> only then remove old path

本地 suite 无法伪造生产 observation window。自动 review 应输出 “not observed”,而不是因为 unit test 通过就填绿。

本节安全门禁

进入发布前必须同时满足:

  • owner NOLOGIN、非 superuser、能力最小;
  • definer path 可信且 pg_temp 最后;
  • source 中对象名和 dynamic SQL 已审计;
  • PUBLIC 权限在同事务撤销;
  • application ACL matrix 精确;
  • 正向、负向、绕过应用、deferral、bulk、procedure 边界通过;
  • 并发协议有实际 evidence 或明确未验证;
  • function/trigger inventory 已冻结;
  • metrics/log/alert 查询可执行;
  • rollback 会先停调用者和 job,再处理对象;
  • evidence 不包含 secret;
  • 本地事实与 Pigsty/PgBouncer 事实没有混写。

安全、测试和观测缺一项,数据库端逻辑都还只是“能运行”,不是“可运营”。


上一节:过程、任务与事务控制 · 返回本章目录 · 下一节:实战:为订单状态建立数据库端护栏 · 查看全书目录 · 查看索引中心

13.6 实战:为订单状态建立数据库端护栏

本节把前五节压成一个可运行、可失败、可复位的 release proposal。目标不是 展示最多的 PL/pgSQL 特性,而是让每个机制只承担一种可解释责任。

环境边界

task.sh all 会精确删除并重建专用 shop_ch13 schema。它适合本书的 本地/开发夹具;不要把它当生产迁移直接执行。生产发布使用向前迁移、 canary、观察窗口和独立回退,不先删 schema。

13.6.1 比较约束、函数、触发器与应用实现

先冻结状态图

实验只允许六条边:

stateDiagram-v2
    [*] --> created
    created --> paid: capture_payment
    created --> canceled: cancel command
    created --> expired: maintenance procedure
    paid --> packing
    packing --> shipped
    shipped --> completed
    canceled --> [*]
    expired --> [*]
    completed --> [*]

图中没有:

created -> shipped
canceled -> paid
completed -> created

禁止边必须由数据库拒绝,而不是只在 UI 隐藏按钮。

规则拆分

局部合法域:约束

setup.sql

CONSTRAINT sales_order_total_positive
    CHECK (total_minor > 0),

CONSTRAINT sales_order_status_domain
    CHECK (
        status IN (
            'created', 'paid', 'packing', 'shipped',
            'completed', 'canceled', 'expired'
        )
    ),

CONSTRAINT sales_order_version_nonnegative
    CHECK (version >= 0)

这些规则不需要 OLD,不查询其他行,原生 CHECK 最合适。

transition matrix:纯 SQL function

allowed_transition(text,text)
  IMMUTABLE
  STRICT
  PARALLEL SAFE
  SECURITY INVOKER

它没有表访问和副作用,既可由 transition-matrix.sql 穷举 49 个状态对, 也能被 guard trigger 复用。

所有普通写入口:BEFORE ROW

invalid edge       -> P3613
version not +1     -> P3615
valid edge         -> normalize updated_at, return NEW

应用 command function、owner 直接 SQL 和 maintenance procedure 都经过同一 guard。应用层仍可做更早校验以改善 UX,但数据库是最终护栏。

事务最终点:deferred constraint triggers

最终不变量:

status = paid
  <=> captured_minor = total_minor

实验为简单起见不建 partial payment/refund 状态机,因此非 paid 订单捕获金额 必须为 0。真实支付模型通常需要 authorization、capture、refund、chargeback 账本,不能照抄这个简化等式。

两个 constraint trigger 同时覆盖:

  • 改订单状态/金额;
  • 插入、修改或删除 payment。

只挂一边会留下绕过入口。

应用命令:definer functions

应用只能调用:

order_snapshot(order_id)
transition_order(order_id, expected_version, target, actor)
capture_payment(order_id, expected_version, payment_ref, amount, actor)

它没有底表 DML。capture_payment

lock order row
  -> validate expected version/status/amount
  -> set transaction-local actor
  -> insert payment
  -> update order to paid and version +1
  -> row + statement triggers
  -> deferred checks at commit

支付引用有 UNIQUE;本章没有实现第 12 章那种完整幂等 response ledger, 因此 duplicate payment_ref 仍是约束错误。生产 API 应明确 duplicate request 是 replay 还是 conflict。

批量维护:invoker procedure

expire_stale_orders

  • 仅 owner/管理路径可调用;
  • batch size 限制 1–1000;
  • ORDER BY order_id FOR UPDATE SKIP LOCKED LIMIT ...
  • 每批集合 UPDATE;
  • COMMIT AND CHAIN
  • 已 expired 行自然成为重跑断点。

它不提权、不调外部系统、不安排自己何时运行。

跨系统动作:应用与 outbox

订单 paid 后通知履约不在 trigger 内发送。本章只证明数据库护栏;完整 outbox 服务见第 12 章。

物理对象

专用 schema:

shop_ch13
├── schema_version
├── sales_order
├── payment
├── order_history
├── statement_audit
├── 7 functions
├── 1 procedure
└── 4 user triggers

身份 sequence 和系统内部 FK triggers 不算 user trigger inventory。

所有实验对象带同一 marker:

pg36 ch13 routine guard lab; safe to rebuild

setup/reset 遇到未知 relation、routine、user trigger 或 marker 漂移会拒绝, 不会用 CASCADE 把未知依赖带走。

权限模型

postgres/admin session
  └─ SET ROLE pg36_owner for reviewed DDL

pg36_owner
  ├─ NOLOGIN, non-superuser
  ├─ owns shop_ch13 objects
  └─ runs maintenance procedure

pg36_app
  ├─ LOGIN, constrained
  ├─ USAGE shop_ch13
  ├─ EXECUTE 3 public API functions
  └─ no table DML / internal function / procedure EXECUTE

所有 definer functions:

SET search_path = pg_catalog, pg_temp

业务对象全限定。

审计模型

每个状态变化写一行 order_history

order_id
old_status/new_status
old_version/new_version
actor/session_actor
statement_timestamp
xid8

每个 UPDATE statement 写一行 statement_audit

xid8
actor/session_actor
affected_count
ordered order_ids[]
statement_timestamp

关系:

sum(statement_audit.affected_count)
  = count(order_history)
  = sum(final order versions)
  = 10

这是一条可机器验收的不变量。

fixture 分工

order 用途 最终状态
101 应用取消成功、旧 version 重放失败 canceled v1
102 原子支付成功 paid v1
103 非法 created→shipped、金额错误、异常 probe created v0
104 paid 无 payment,提交点失败 created v0
105–107 单语句三行 bulk canceled v1
108 function stats rollback-only probe created v0
201–205 procedure 2/2/1 expired v1

最终:

orders=13
created=3
paid=1
canceled=4
expired=5
payments=1
history=10
statement_audit=6
affected_sum=10

设计选择对照

候选实现 本章结论
应用 if 检查全部规则 可做早校验,不能作为唯一护栏
CHECK allowed_transition(old,new) CHECK 没有 OLD,不适用
transition function 由应用自愿调用 底表 DML 被拿走;同时 trigger 防 owner/脚本绕过
row trigger 每行写一条 statement audit 粒度错误;用 transition table
immediate cross-table trigger 原子支付的中间步骤会被过早拒绝
deferred constraint trigger 适合提交点,但必须另有锁协议
trigger 内调用履约 HTTP 拒绝;写 outbox 后异步处理
definer procedure 分批 commit PostgreSQL 禁止该组合;用 invoker 管理过程
procedure 自己每天运行 不可能;scheduler 属平台

13.6.2 注入绕过应用的错误写入

前置条件

实验依赖前章建立的:

database=pg36_shop
owner=pg36_owner
application role=pg36_app
model=ch04-v1
business checksum=stable

准备受控 libpq service:

[pg36-admin]
host=/path/to/socket-or-host
port=5432
dbname=pg36_shop
user=postgres

然后:

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

不要把密码写进命令行或 evidence。生产使用受控 secret path。

先跑静态和单阶段入口

./static/labs/ch13/task.sh setup
./static/labs/ch13/task.sh catalog
./static/labs/ch13/task.sh behavior

catalogbehavior 会先重建 exact fixture,以保证结果不依赖上一轮。 正式验收直接运行 all

正向路径

api-happy.sqlpg36_app

SELECT *
FROM shop_ch13.transition_order(
    101, 0, 'canceled', 'app-cancel'
);

SELECT *
FROM shop_ch13.capture_payment(
    102, 0, 'pay-ch13-102', 2000, 'app-payment'
);

预期:

101,canceled,1
102,paid,1,pay-ch13-102

这同时证明 definer 权限、trigger、deferred check 和返回形状。

故障 1:绕过 command API 的直接写

pg36_app

UPDATE shop_ch13.sales_order
SET status = 'canceled', version = version + 1
WHERE order_id = 105;

预期:

SQLSTATE 42501

失败发生在 ACL,trigger 无需承担应用授权。

故障 2:非法状态边

SELECT *
FROM shop_ch13.transition_order(
    103, 0, 'shipped', 'app-invalid'
);

预期:

SQLSTATE P3613
order 103 remains created v0
history delta=0
audit delta=0

再用 owner 直接 UPDATE 同一非法边,仍应由 guard 拒绝。这才证明护栏不依赖 应用 handler。

故障 3:提交点不一致

SELECT *
FROM shop_ch13.transition_order(
    104, 0, 'paid', 'app-no-payment'
);

BEFORE 认为 created→paid 是允许边,UPDATE 与 AFTER audit 会在事务内部 执行;到 deferred check 时发现 captured=0:

SQLSTATE P3614
order/history/audit all rolled back

这证明不能只看 function 的 RETURNING;事务必须成功提交才是完成。

反方向也必须覆盖:delete-payment.sql 删除 order 102 的 captured payment,会由 payment 表上的 constraint trigger 在提交点返回同一个 P3614,paid 订单与 payment 都保持原状。

故障 4:乐观版本冲突

order 101 已是 v1,再传 expected v0:

SQLSTATE P3616

这不是 blind retry 信号。调用方重新读取,判断业务意图是否仍成立。

故障 5:支付前置条件

order 103 金额 3000,传 1:

SQLSTATE P3618
payment delta=0
order remains created v0

前置条件在插 payment 前检查,且整笔 function 仍在一个事务。

故障 6:procedure 放进显式事务

procedure-in-transaction.sql

BEGIN;
CALL shop_ch13.expire_stale_orders(..., 2, 0);
COMMIT;

过程第一次 COMMIT AND CHAIN

SQLSTATE 2D000

显式事务回滚,201–205 仍 created。随后 procedure-run.sql 用 top-level CALL:

p_total=5
batches=[2,2,1]

立即第二次 top-level CALL:

p_total=0
audit delta=0

这证明恢复依据是已提交状态,而不是只存在过程局部变量中的计数。

异常子事务

exception-probe.sql 在 inner block 直接做非法 owner UPDATE,精确捕获 P3613

caught_state=P3613
status_after=created
version_after=0

probe 外层最后 ROLLBACK。它证明 handler 的持久化回滚语义,不把捕获当作 生产容错建议。

函数统计

function-stats.sql

RESET ROLE;
SET track_functions = 'all';
SET ROLE pg36_owner;

BEGIN;
-- rollback-only calls
...
SELECT ... FROM pg_stat_xact_user_functions;
ROLLBACK;

证据至少包含:

allowed_transition calls>=1
guard_order_transition calls>=1
audit_order_transition calls>=1
order_snapshot calls>=1
transition_order calls>=1

时间只要求非负,不做跨机器阈值。

完整 suite

evidence="$PWD/evidence/ch13/$(date -u +%Y%m%dT%H%M%SZ)"

PG36_EVIDENCE_DIR="$evidence" \
  ./static/labs/ch13/task.sh all

它额外验证 reset:

case 预期
错误 token P3620
错误 target P3621
pg36-ch13-* worker active P3623
marker/inventory drift P3622
正确 token + target + no worker exact reset

活跃 worker probe 只取消精确 PID、database、application_name 对应的 pg_sleep,不会广泛终止连接。

evidence 结构

evidence/
├── manifest.txt
├── preflight.txt
├── setup.txt
├── routine-catalog.csv
├── trigger-catalog.csv
├── security-catalog.csv
├── transition-matrix.csv
├── api-happy.csv
├── invalid-transition.{exit,stdout,stderr}
├── paid-without-payment.{exit,stdout,stderr}
├── version-conflict.{exit,stdout,stderr}
├── payment-mismatch.{exit,stdout,stderr}
├── delete-payment.{exit,stdout,stderr}
├── direct-write.{exit,stdout,stderr}
├── exception-probe.csv
├── function-stats.csv
├── bulk-update.csv
├── procedure-in-transaction.{exit,stdout,stderr}
├── procedure-run.csv
├── procedure-rerun.csv
├── final-state.csv
├── verify.txt
├── review.txt
├── reset-*.{exit,stdout,stderr}
├── reset.txt
└── rebuild/
    └── 同一套第二遍证据

review.py 读取原始 CSV/stderr/manifest,不从成功摘要自证成功。

最终 checksum

final-state.sql 对:

  • order id/status/version;
  • payment reference/amount/status;
  • history edge/version/actor;
  • statement affected set/actor;

做确定性排序和 MD5:

business_checksum=f045467816a9be6774f30312adc16402

时间、xid、identity sequence 不进入 checksum,因为它们每次合法运行都可能 变化。

13.6.3 在 Pigsty L1 输出实现选择、测试证据与回退脚本

L1 不是“本机换个 host”

本地 PostgreSQL 18.6 direct 成功只证明:

source + fixture + direct server behavior

Pigsty L1 还要绑定:

cluster identity
service route
primary/recovery role
PostgreSQL minor version
PgBouncer path if used
role/database declaration
secret delivery
HA behavior
metrics/logs/alerts
change window and rollback authority

没有这些证据,就输出 not-run,不能把参考架构当成已验证事实。

声明角色与 database

pigsty-declaration.example.yml 提供无凭据 fragment:

pg_users:
  - name: pg36_owner
    login: false
    superuser: false
    ...

  - name: pg36_app
    login: true
    pgbouncer: true
    pool_mode: transaction
    ...

pg_databases:
  - name: pg36_shop
    owner: pg36_owner
    schemas:
      - { name: shop_ch13, owner: pg36_owner }

它不包含 password。实际 secret 由受控 inventory/overlay 注入。

声明只负责 role/database/schema 基础对象;function source、ACL、marker 和 tests 仍由 reviewed SQL migration 管理。不要让两套系统同时争夺同一函数 定义。

接入路径

参考决策:

application routine calls
  -> Pigsty primary service
  -> PgBouncer transaction pool
  -> pg36_app

reviewed DDL, catalog, maintenance CALL
  -> Pigsty direct/default management service
  -> PostgreSQL
  -> controlled admin SET ROLE pg36_owner

端口和 DNS 必须从目标 inventory 读取,不能照抄示例数字。应用路径要实际 验证:

  • function calls;
  • transaction-local setting;
  • deferred commit error;
  • cancel/timeout;
  • failover/reconnect;
  • transaction pooling 下的协议与 latency。

本章正式 suite 记录:

validation_path=direct-postgresql

所以 PgBouncer 项仍为未验证。

把 setup 改造成生产 migration

生产 migration 不能运行“drop exact fixture + seed”:

  1. 创建新 schema/table/constraints;
  2. 创建纯 function 与内部 trigger functions;
  3. 同事务创建 definer function、revoke PUBLIC、grant 精确 app;
  4. 创建 trigger;
  5. 运行 catalog/ACL contract;
  6. 以 canary 业务行运行正负路径;
  7. 启用新应用调用;
  8. 观察;
  9. 最后撤旧接口。

若改已有大表,先按第 11 章评估 lock、rewrite、backfill 和 validation。 CREATE FUNCTION 本身快,不代表挂 trigger 后的每次写入成本可忽略。

生产 canary 不使用教学 seed

选择:

  • 隔离 tenant/test order;
  • 有清晰清理合同;
  • 不触发真实外部副作用;
  • 可在 outbox consumer 侧隔离;
  • 能用业务不变量验证;
  • 不暴露敏感数据到 evidence。

同时执行 bypass test 需要额外 owner 权限,应在变更窗口和隔离对象上完成, 不是任意改生产订单。

观察查询

目录:

SELECT *
FROM pg_proc
WHERE oid IN (
  'shop_ch13.transition_order(bigint,bigint,text,text)'::regprocedure,
  'shop_ch13.capture_payment(bigint,bigint,text,bigint,text)'::regprocedure
);

调用:

SELECT *
FROM pg_stat_user_functions
WHERE schemaname = 'shop_ch13'
ORDER BY total_time DESC;

活跃与等待:

SELECT
    pid, backend_start, application_name,
    state, wait_event_type, wait_event,
    xact_start, query_start
FROM pg_stat_activity
WHERE datname = 'pg36_shop'
  AND application_name LIKE 'pg36-%';

业务关系:

SELECT
    count(*) FILTER (WHERE status = 'paid') AS paid_orders,
    count(*) FILTER (WHERE status = 'paid'
                     AND captured_minor <> total_minor) AS invalid
FROM reviewed_payment_projection;

最后一个 projection 需要按真实 schema 编写,示例名不是本章已创建对象。

release proposal

baseline-v1.1-proposal.json 冻结:

  • target/version;
  • 逻辑放置决策;
  • SQLSTATE;
  • 最终状态关系;
  • 权限矩阵;
  • rollback token/target;
  • 未验证边界。

canonical SHA-256:

32377d82a7ce958aa50b0077ebe99c47d27672223c3c77fd9f91072d3745de9d

manifest 和 review 独立重算;不是手抄字符串就算通过。

实验复位

仅对专用开发夹具:

PG36_RESET_TOKEN=RESET_CH13_ROUTINE_GUARD \
PG36_RESET_TARGET=pg36_shop/shop_ch13 \
  ./static/labs/ch13/task.sh reset

reset.sql 检查:

  • database pg36_shop
  • writable instance;
  • effective owner;
  • ch04-v1;
  • schema/object marker;
  • relation/routine/trigger 白名单;
  • 没有 pg36-ch13-* active worker;
  • exact token 与 target。

随后按 FK/dependency 顺序 drop 精确对象,最后 DROP SCHEMA;不使用 CASCADE

生产回退不是 reset

生产回退顺序:

stop new callers / disable job schedule
  -> observe and drain active calls
  -> route application to compatible old API
  -> verify old writes still accepted
  -> revoke new EXECUTE
  -> disable/drop new trigger only if data remains valid
  -> preserve audit and migration evidence
  -> observation window
  -> later contract objects

若新逻辑已经产生旧应用无法理解的新状态,DDL 回退不能自动恢复语义;需要 数据补偿或 forward fix。发布前必须演练。

L1 交付包

一份完整交付至少包含:

  1. 逻辑放置 ADR;
  2. migration source 与 artifact checksum;
  3. exact signatures、owners、ACL、paths;
  4. transition/state diagram;
  5. 正向、负向、bypass、bulk、deferral、并发测试;
  6. target manifest;
  7. direct 与 pooler 路径结果;
  8. SQLSTATE → 应用行为映射;
  9. dashboard/log/alert 查询;
  10. canary 与观察窗口;
  11. scheduler/overlap 设计;
  12. rollback 与停用顺序;
  13. 未验证事实。

本章验收

你应能在不看答案时解释:

  • 为什么状态域是 CHECK,状态边是 trigger;
  • 为什么 payment invariant 要延迟,但仍要 row lock;
  • 为什么应用没底表 DML;
  • 为什么 definer path 必须固定、PUBLIC 必须撤销;
  • 为什么 bulk audit 用 transition table;
  • 为什么 procedure 显式事务中返回 2D000
  • 为什么 procedure 不是 scheduler;
  • 为什么 trigger 不调用远端系统;
  • 为什么 function counters 不是 trace;
  • 为什么本地 direct 成功不能冒充 Pigsty/PgBouncer 成功;
  • 为什么生产回退不能运行教学 reset。

能回答并用 evidence 证明,才算真正掌握数据库端逻辑。


上一节:安全、测试与观测 · 返回本章目录 · 下一章:博采众长:内核分支与扩展生态 · 查看全书目录 · 查看索引中心

14 博采众长:内核分支与扩展生态

PostgreSQL 的扩展生态很强,但“有这个扩展”不是架构理由,“能够安装”也 不是生产结论。一个扩展进入数据库后,可能同时改变:

  • 节点上的软件包、控制文件、SQL 脚本与动态库;
  • 数据库里的类型、函数、操作符、访问方法和系统目录依赖;
  • 主库、备库、备份恢复、逻辑订阅与大版本升级的前置条件;
  • 安装、升级和删除所需的特权;
  • 应用数据的可移植性、故障半径与退出成本。

因此本章不做“常用扩展清单”,而是建立一套可复用的治理方法:

先证明问题,再检查原生替代;先冻结版本与运行条件,再创建数据库对象; 先演练升级、恢复和退出,再允许业务依赖。

第 15–17 章会分别深入检索、时空与分析/分布式能力。本章负责给它们提供 同一把尺子,避免每遇到一个新扩展就重新发明评审标准。

本章完成后

你应当能够:

  • 区分 PostgreSQL server、发行版/内核、OS 软件包、扩展支持文件与数据库 扩展对象;
  • .control、版本 SQL、动态库与 pg_extension 解释一个扩展如何 被发现、安装和拥有;
  • 解释 superusertrustedrelocatablerequiresshared_preload_libraries 各控制什么;
  • pg_available_extensionspg_available_extension_versionspg_extension_update_paths()pg_depend 取得原生证据;
  • 不把“兼容 PostgreSQL”误解为扩展、目录、运维和故障语义都兼容;
  • 用六个问题筛选扩展,而不是按热度、功能数量或安装便利度决策;
  • 区分软件包版本与数据库对象版本,并设计受控的 ALTER EXTENSION UPDATE
  • 把备份恢复、物理备库、逻辑复制与 pg_upgrade 纳入扩展生命周期;
  • 在 Pigsty 中区分仓库下载、节点安装、预加载配置和数据库启用;
  • 解释包别名 pgvector 与 SQL 扩展名 vector 为什么不能混用;
  • 识别节点漂移、control file 缺失、动态库缺失、未预加载与对象版本漂移;
  • 写出包含问题、成功标准、供应链、权限、升级、恢复和退出的扩展 ADR;
  • 对同一批候选给出“接受、限域试点、当前拒绝”三种有证据的结论。

三层状态,四个动作

扩展治理首先要拆开三层状态:

供应层
  repository/package/image
    └─ control + version SQL + shared library

进程层
  shared_preload_libraries / server restart / loadability
    └─ backend can load the module

数据库层
  pg_extension + member objects + extversion
    └─ this database can use the SQL interface

Pigsty 把典型流程组织成四个动作:

Download -> Install -> Configure -> Enable
仓库下载     节点装包    预加载/参数    CREATE EXTENSION

四步并非每个扩展都全部需要:

  • pg_trgm 有数据库对象,但不需要 shared preload;
  • vector 有数据库对象和动态库,本章版本也不需要 shared preload;
  • wal2json 是逻辑解码插件,装包后按插件名使用,并不要求 CREATE EXTENSION
  • Citus、TimescaleDB 等扩展有额外预加载、拓扑或生命周期要求,必须查目标 版本文档,不能类推。

反过来,完成最后一步也不能证明前面三层在所有节点一致。主库已经 CREATE EXTENSION,某台备库仍可能缺动态库;数据库目录显示 0.8.4, OS 仓库却已经换成另一构建。每层都需要独立证据。

贯穿实验:三个候选,三种结论

本章围绕 shop_ch14 评审三个候选:

问题 候选 结论 核心理由
单字段拼写容错 pg_trgm accept 问题有界、contrib、受信任、GIN 可证、退出不锁定列类型
语义近邻检索 vector pilot 能力成立,但模型、质量、资源、恢复与退出仍需真实语料证明
分布式分片 citus reject now 尚无单节点上限证据、分片键合同和跨分片事务设计

“拒绝”不表示 Citus 有缺陷。Pigsty 当前扩展目录提供 Citus,说明平台有 交付能力;本章仍拒绝它,是因为供应能力不能替代问题适配。

实验故意让权限差异可见:

pg36_owner (non-superuser, database owner)
  ├─ CREATE EXTENSION pg_trgm VERSION '1.3'  -> success
  │    trusted=true, extension owner=pg36_owner
  └─ CREATE EXTENSION vector VERSION '0.8.4' -> SQLSTATE 42501
       trusted=false, admin approval required

postgres/admin
  └─ CREATE EXTENSION vector -> success
       extension owner remains a superuser

pg36_app
  ├─ SELECT reviewed tables and use operators -> success
  └─ ALTER EXTENSION pg_trgm UPDATE            -> SQLSTATE 42501

随后由 pg36_ownerpg_trgm 从 1.3 更新到 1.6。更新前后:

trigram top ids = 1,5,2
vector top ids  = 1,2,5
trigram plan    = GIN bitmap index scan
vector plan     = HNSW index scan

这只证明固定五行夹具的机制与回归合同,不证明生产相关性、召回率或尾延迟。

实验资产

完整合同与入口:

正式本地证据在 Homebrew PostgreSQL 18.6 上采集。正文把平台映射核对到 Pigsty 4.5,但没有在 Pigsty L1 主机上运行,因此输出明确记录:

validation_path=direct-postgresql
pigsty_l1=not-run

这不是缺点掩饰,而是证据边界。读者在自己的 L1 上必须补采仓库、各节点 包版本、预加载和数据库对象状态。

快速运行

沿用前章受控的 libpq service:

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

PG36_EVIDENCE_DIR="$PWD/evidence/ch14" \
  ./static/labs/ch14/task.sh all

all 会:

  1. 验证数据库、角色、ch04-v1 模型和基础业务不变量;
  2. 对目标 schema 与同名扩展执行 marker/owner/version 碰撞保护;
  3. 以 owner 安装受信任的 pg_trgm 1.3;
  4. 证明 owner 安装未受信任的 vector 返回 42501
  5. 由管理员安装 vector 0.8.4,建立 GIN 与 HNSW 夹具;
  6. 采集可用版本、control 属性、成员、ACL、索引和更新路径;
  7. 证明应用可查询但不能升级扩展;
  8. 记录升级前查询与强制索引计划;
  9. pg_trgm 更新到 1.6,再次记录相同证据;
  10. 对 control、安装/更新 SQL 与动态库生成 SHA-256;
  11. 对比全库 schema dump 与 --schema=shop_ch14 选择性 dump;
  12. 把向量转为文本导出,形成试点退出材料;
  13. 验证错误 token、错误 target 和活跃 worker 下的 reset 拒绝;
  14. 不使用 CASCADE 精确复位,再从零完整重建和复验。

成功摘要:

status=ok
decision=pg_trgm:accept/vector:pilot/citus:reject
boundary=package+control+database-object
failure=42501-owner+42501-superuser
upgrade=pg_trgm:1.3->1.6-behavior-stable
index=gin+hnsw
dump=create-extension+selective-dependency-warning
exit=portable-text-export
pigsty_l1=not-run
release=1.2-proposal
release_candidate_checksum=6a4b74baec5f522eb098c868f1d4f1b441bf5b5f6708411588af0a8793f7f573

这个 proposal checksum 标识 ADR、版本、夹具和验收合同;运行时间、绝对 安装路径以及文件系统 inode 不进入业务 golden。

安全边界

task.sh all 会删除并重建专用 shop_ch14,并删除带本章精确 marker 的 pg_trgmvector。它只适合本书本地/开发夹具。生产安装和升级必须 使用分阶段迁移、备库检查、恢复演练、观察窗口和独立回退,不运行 “先删后建”的教学入口。

学习路径

14.1 PostgreSQL 扩展机制

先把扩展还原为 PostgreSQL 原生对象和支持文件。不了解这层,就无法解释 为什么“包已安装”和“数据库可用”不是同一件事。

14.2 内核、发行版与托管服务

再把扩展放回具体供应环境,建立 SQL、协议、目录、扩展与运维五层兼容矩阵。

14.3 扩展选型的六个问题

这一节把“喜欢哪个扩展”转化为六个可反驳、可采证的问题。

14.4 生命周期与升级耦合

安装只是生命周期起点。真正的承诺发生在升级、恢复、复制和退出时。

14.5 用 Pigsty 管理扩展可用性

把原生机制映射到 Pigsty 4.5,但始终回到节点文件、live 参数和系统目录 复核。

14.6 建立可复用扩展 ADR

把讨论沉淀为能够被后续章节复用、被版本变化触发复审的决策记录。

14.7 实战:评审三个候选扩展

最后把包、权限、对象、查询、升级、dump、出口和复位压成一份可审计交付物。

版本与证据边界

本章原理以 PostgreSQL 14–18 为范围;可执行 baseline 固定 pg_trgm 1.3/1.6、vector 0.8.4,并在 PostgreSQL 18.6 上验证。目标环境 没有这些精确版本时,不应伪造通过,而应复制 ADR、更新版本范围和 golden, 重新评审。

Pigsty 内容按 4.4 文档在 2026-07-29 核验。扩展目录、包版本和支持矩阵会 持续变化,实际变更前必须查目标 Pigsty 版本与仓库。

权威入口:

本章明确区分三种陈述:

  1. PostgreSQL/Pigsty 文档定义的机制;
  2. 本章针对三个问题作出的架构选择;
  3. 当前本地 evidence 实际证明的观察。

只有第三类能由 /tmp/pg36-ch14-final 或读者自己的 evidence 目录直接 复现。


上一章:言出法随:函数、触发器与存储过程 · 返回上卷导读 · 下一章:见微知著:全文、模糊与向量检索 · 查看全书目录 · 查看索引中心

14.1 PostgreSQL 扩展机制

CREATE EXTENSION vector; 只有一行,却横跨文件系统、权限系统、版本图和 数据库依赖。要治理扩展,必须先把这一行展开。

本节讨论 PostgreSQL 自己知道什么、不会替你知道什么。软件包仓库、容器与 Pigsty 映射留到后面。

14.1.1 control、SQL 脚本、动态库与对象所有权

一套扩展至少有两个身份

假设执行:

CREATE EXTENSION vector
WITH SCHEMA app_ext
VERSION '0.8.4';

这里的 vectorSQL 扩展名。它不是项目名 pgvector,也不必等于 RPM/DEB 包名。PostgreSQL 根据 server 自己的安装目录寻找:

$(pg_config --sharedir)/extension/vector.control
$(pg_config --sharedir)/extension/vector--0.8.4.sql
$(pg_config --pkglibdir)/vector.so       # Linux 常见
$(pg_config --pkglibdir)/vector.dylib    # macOS 可能出现

典型扩展由三类文件组成:

文件 作用 PostgreSQL 何时使用
name.control 元数据、默认版本、权限、依赖、可迁移性 发现与创建/更新扩展
name--version.sql 创建这一数据库版本的成员对象 CREATE EXTENSION
name--old--new.sql 从一个对象版本迁到另一个版本 ALTER EXTENSION UPDATE
动态库 C 函数、hook、访问方法等运行代码 创建时或 backend 加载/调用时

纯 SQL 扩展可以没有动态库;只提供可加载模块的组件也可能没有 CREATE EXTENSION 接口。不能从扩展名猜文件组合,要读 control 与目标版本 说明。

查看当前 server 的查找位置:

pg_config --sharedir
pg_config --pkglibdir
pg_config --version

这里的 pg_config 必须属于目标 server major。用 PATH 中另一个 PostgreSQL 版本的 pg_config 检查文件,可能得到一个完全正确、却与正在运行实例无关 的目录。

control 文件是创建合同

一个简化 control 文件可能表达:

comment = 'example data type'
default_version = '1.4'
module_pathname = '$libdir/example'
relocatable = true
superuser = true
trusted = false
requires = 'btree_gist'

关键字段:

  • default_version:未写 VERSION 时创建哪个对象版本;
  • module_pathname:版本 SQL 中 MODULE_PATHNAME 的替换值,常指向 $libdir 下动态库;
  • requires:必须先存在的其他扩展;
  • superuser:安装脚本是否原则上要求超级用户;
  • trusted:在 superuser=true 的前提下,是否允许具备当前数据库 CREATE 权限的非超级用户安装;
  • relocatable:扩展整体是否允许换 schema;
  • schema:若指定,强制成员安装到该 schema,并使扩展不可随意迁移。

这些是 具体已安装 control 文件 的事实,不是扩展项目永久不变的属性。 同名扩展升级后可以改变元数据,发行版也可能带不同补丁。用目录视图读取 当前 server 实际看到的值:

SELECT
    name,
    version,
    installed,
    superuser,
    trusted,
    relocatable,
    schema,
    requires
FROM pg_available_extension_versions
WHERE name IN ('pg_trgm', 'vector')
ORDER BY name, version;

本章正式夹具观测到:

pg_trgm 1.3..1.6  superuser=t trusted=t relocatable=t
vector  0.8.4     superuser=t trusted=f relocatable=t

这是 PostgreSQL 18.6 + 当前本地支持文件的证据;读者环境必须重查。

CREATE EXTENSION 创建的是一个依赖边界

版本 SQL 可以创建类型、函数、操作符、访问方法、opclass、表或其他对象。 PostgreSQL 把这些对象登记为扩展成员。扩展本身记录在:

SELECT
    e.extname,
    e.extversion,
    pg_get_userbyid(e.extowner) AS owner,
    n.nspname AS nominal_schema,
    e.extrelocatable
FROM pg_extension AS e
JOIN pg_namespace AS n
  ON n.oid = e.extnamespace;

成员关系记录在 pg_depend,依赖类型为 e

SELECT
    d.classid::regclass AS member_catalog,
    count(*) AS members
FROM pg_extension AS e
JOIN pg_depend AS d
  ON d.refclassid = 'pg_extension'::regclass
 AND d.refobjid = e.oid
 AND d.deptype = 'e'
WHERE e.extname = 'pg_trgm'
GROUP BY d.classid
ORDER BY member_catalog::text;

这个关系有三个后果:

  1. 成员通常不能绕过扩展被单独删除;
  2. DROP EXTENSION 会删除成员,即使没有写 CASCADE
  3. pg_dump 通常用 CREATE EXTENSION 重建整组对象,而不是逐个 dump 成员定义。

第三点非常重要:备份文件能记录“需要 vector 0.8.4 的对象合同”,却不会 把 control、SQL 脚本和动态库塞进备份。恢复目标必须先拥有兼容支持文件。

nominal schema 不是容器

pg_extension.extnamespace 常被叫作扩展 schema,但它不是一个不可穿透的 容器。官方文档明确指出,扩展成员可能位于多个 schema;这个字段表示扩展 的 nominal schema。扩展名本身也不受 schema 限定:

-- 一个数据库中只能有一个同名 extension
CREATE EXTENSION pg_trgm SCHEMA app_ext;

-- 这不是合法的第二份“另一个 schema 的 pg_trgm”
CREATE EXTENSION other.pg_trgm;

relocatable=true 也不表示所有业务依赖都能无痛迁移。换 schema 会影响:

  • 未全限定的函数与操作符解析;
  • 固化在 view、expression index 或 function body 中的对象引用;
  • 应用 search_path
  • dump/restore 与旧迁移脚本;
  • 安全审计假设。

把“目录允许 ALTER EXTENSION ... SET SCHEMA”与“应用兼容迁移”分开验证。

扩展 owner 与成员 owner

扩展有自己的 owner。通常创建者成为 extension owner;但安装脚本内部成员 的所有权还受脚本、可信安装规则和 PostgreSQL 版本语义影响,不能简单假设 “schema owner 拥有其中一切”。

本章把 pg_trgm 装在 shop_ch14

SET ROLE pg36_owner;
CREATE EXTENSION pg_trgm
  WITH SCHEMA shop_ch14
  VERSION '1.3';
RESET ROLE;

由于本地 control 标记 trusted=true,有数据库 CREATE 权限的 pg36_owner 可以安装,extension owner 是 pg36_owner。安装脚本在受控 的高权限上下文中执行,使扩展能够创建所需对象。

这不是把任意第三方 SQL 交给普通用户安全执行。trusted 是扩展供应者和 发行者作出的安全承诺,管理员仍要审查来源、版本与安装 schema。

参见 Packaging Related Objects into an Extensionpg_extension

14.1.2 普通扩展、预加载库与超级用户需求

“已创建”不等于“已加载”

按运行方式可以粗分四类:

类型 支持文件 CREATE EXTENSION preload/restart
纯 SQL 扩展 control + SQL 通常需要 通常不需要
按需加载的 C 扩展 control + SQL + library 通常需要 首次调用可加载
需要早期 hook 的扩展 control + SQL + library 通常需要 常需 shared preload
非 SQL 插件 library 或可执行组件 可能不需要 按子系统规则使用

扩展若要在 backend 初始化早期注册 shared memory、planner/executor hook、 background worker 或全局审计能力,往往需要:

shared_preload_libraries = '...'

这个参数在 server 启动时处理。修改后 reload 不够,通常需要滚动重启或 集群重启。库名拼错、文件缺失或二进制不兼容,可能直接阻止实例启动,所以 它是比普通 CREATE EXTENSION 更高风险的变更。

检查声明与 live 值:

SELECT
    name,
    setting,
    source,
    sourcefile,
    pending_restart
FROM pg_settings
WHERE name = 'shared_preload_libraries';

只看配置仓库中的 YAML 不够;只看 SHOW 也不够。前者是意图,后者是当前 实例事实,还要在所有主备节点检查动态库。

pg_trgm 与本章的 vector 0.8.4 不需要 shared preload。不能由此推断 其他版本或扩展也不需要。Pigsty 当前文档列举 Citus、TimescaleDB、 pg_cronpgaudit 等常见预加载场景,最终以目标扩展/版本文档和启动 实验为准。

superusertrusted 是两道判断

pg_available_extension_versions 中:

superuser=true, trusted=false

表示只有超级用户可以执行创建/更新脚本。vector 在本章环境属于这一类:

SET ROLE pg36_owner;
CREATE EXTENSION vector
  WITH SCHEMA shop_ch14
  VERSION '0.8.4';

得到:

SQLSTATE 42501
permission denied to create extension "vector"
HINT: Must be superuser to create this extension.

管理员安装后:

CREATE EXTENSION vector
  WITH SCHEMA shop_ch14
  VERSION '0.8.4';

extension owner 保持管理员角色。应用只获得使用所需类型、函数和表权限, 不获得扩展所有权。

对:

superuser=true, trusted=true

有数据库 CREATE 权限的非超级用户可以安装。安全关键点是:

  • 安装脚本以 bootstrap superuser 的能力执行;
  • extension owner 是调用者;
  • 供应者必须保证非特权调用者不能借脚本选择、schema 或预置对象提权;
  • 管理员应只信任随受控 PostgreSQL 发行版交付、且明确标记 trusted 的版本。

这就是为什么官方 CREATE EXTENSION 文档警告:从不可信来源安装扩展,相当 于以高权限运行其安装脚本。版本 SQL 能执行 DDL,也可能引用安装 schema 中预先存在的对象;安全安装应使用受控 schema 和受控 search_path

权限分层

推荐把角色拆开:

platform/admin
  ├─ installs OS packages on every node
  ├─ changes preload and restarts
  ├─ creates untrusted extensions
  └─ owns privileged extension lifecycle

NOLOGIN database owner
  ├─ owns application schemas/tables
  ├─ may own reviewed trusted extensions
  └─ runs migrations through controlled SET ROLE

application login
  ├─ uses selected functions/operators/types
  └─ cannot CREATE/ALTER/DROP EXTENSION

本章证明:

-- pg36_app
ALTER EXTENSION pg_trgm UPDATE TO '1.6';

返回:

SQLSTATE 42501
must be owner of extension pg_trgm

应用能使用扩展能力,并不需要拥有生命周期控制权。

创建失败要按层定位

常见错误及第一检查点:

错误 更可能是哪一层 首查
extension is not available control 文件不可见 pg_available_extensions、server sharedir
could not access file $libdir/... 动态库缺失/路径错 pg_config --pkglibdir、各节点文件
must be superuser control 权限合同 pg_available_extension_versions
must be loaded via shared_preload_libraries 进程初始化条件 pg_settings、重启状态、日志
extension already exists 数据库作用域冲突 pg_extension
no installation script for version 支持文件/版本图不完整 available versions、包版本
incompatible library server major/CPU/ABI 错配 包构建、PG major、架构、启动日志

不要用反复 CREATE EXTENSION ... CASCADE 猜答案。先把错误归到供应、进程或 数据库层。

14.1.3 扩展依赖、版本与 ALTER EXTENSION

requires 是扩展依赖,不是 OS 包依赖

control 中:

requires = 'foo, bar'

表示数据库内扩展依赖。创建前可以显式安装:

CREATE EXTENSION foo;
CREATE EXTENSION bar;
CREATE EXTENSION target;

也可以:

CREATE EXTENSION target CASCADE;

但生产治理不应默认 CASCADE,因为它会:

  • 选择依赖的默认版本;
  • 使用当前 schema/search path 决定安装位置;
  • 扩大实际变更集合;
  • 让审批只写一个扩展,实际多装若干对象。

更可审计的做法是显式列出依赖顺序、版本、schema、owner 和每步验证。

数据库依赖也不替代 OS 包依赖。目标 control 文件声明需要 foo,但节点上 仍必须先安装提供 foo.control、SQL 和 library 的软件包。

软件支持版本与对象版本分离

假设节点刚装入支持 1.6 的新包,而数据库仍显示:

SELECT extversion
FROM pg_extension
WHERE extname = 'pg_trgm';

-- 1.3

此时:

filesystem supports: 1.3, 1.4, 1.5, 1.6
database objects are: 1.3

这是正常的中间状态,不是 PostgreSQL 自动遗漏升级。装包不会主动在每个 数据库执行对象迁移;管理员必须逐库评审:

ALTER EXTENSION pg_trgm UPDATE TO '1.6';

先列出版本:

SELECT
    name,
    version,
    installed,
    superuser,
    trusted,
    relocatable
FROM pg_available_extension_versions
WHERE name = 'pg_trgm'
ORDER BY string_to_array(version, '.')::integer[];

再检查更新图:

SELECT source, target, path
FROM pg_extension_update_paths('pg_trgm')
WHERE source = '1.3'
   OR target = '1.6'
ORDER BY source, target;

本章得到:

1.3 -> 1.6 : 1.3--1.4--1.5--1.6

PostgreSQL 会按可用更新脚本寻找路径;路径不是任意版本号比较。若没有从 当前对象版本到目标版本的脚本链,更新就不能发生。所谓“降级”同样需要明确 反向脚本,不能假设 ALTER EXTENSION ... TO old 会还原。

更新是 DDL 事务,不是无风险元数据改名

更新脚本可执行 DDL/DML,可能:

  • 替换函数、操作符与类型支持函数;
  • 增删成员;
  • 改写扩展配置表;
  • 获取对象锁;
  • 使依赖表达式、索引或 cached plan 失效;
  • 对大表触发长时间工作。

扩展脚本在一个隐式事务中运行,不能在其中自行提交,也不能把需事务外执行 的操作当普通更新步骤。即使脚本通常很快,也要把它当 schema migration:

freeze target package build
  -> read release notes and update scripts
  -> clone/restore rehearsal
  -> dependency and lock inspection
  -> behavior baseline
  -> ALTER EXTENSION UPDATE
  -> catalog + query + plan + log verification
  -> observation window

更新 owner 才能 ALTER EXTENSION;某些脚本还因 control 权限需要更高 特权。本章由 pg36_owner 更新自己拥有的 trusted pg_trgm,而不是给 应用角色临时提权。

成员变化与 dump 语义

扩展维护者可以用:

ALTER EXTENSION name ADD object;
ALTER EXTENSION name DROP object;

调整成员关系。这不是业务迁移的日常捷径。成员一旦归入扩展:

  • dump 通常不再单独保存其定义;
  • DROP EXTENSION 会删除它;
  • 随手修改成员定义可能不会按预期进入 dump;
  • 正确升级应通过新的扩展版本和 update script 交付。

检查成员,而不是只看 \dx

SELECT
    e.extname,
    d.classid::regclass AS catalog,
    count(*) AS members
FROM pg_extension AS e
JOIN pg_depend AS d
  ON d.refclassid = 'pg_extension'::regclass
 AND d.refobjid = e.oid
 AND d.deptype = 'e'
GROUP BY e.extname, d.classid
ORDER BY e.extname, catalog::text;

本章 pg_trgm 1.3 有 37 个登记成员,更新到 1.6 后为 47;vector 0.8.4 在 PostgreSQL 18.6 夹具中有 237 个。成员数量用于发现本次环境漂移, 不是跨 PG major 的普适 golden。

本节结论

CREATE EXTENSION 读成一条完整声明:

using support files from this exact server installation,
run this reviewed version script with this privilege model,
create one database-scoped extension owned by this role,
attach these member objects and dependencies,
and promise that future dump/restore/update can obtain matching files.

少掉任何一段,扩展都只是“今天在这台主机上能用”,还不是可运营能力。


返回本章目录 · 下一节:内核、发行版与托管服务 · 查看全书目录 · 查看索引中心

14.2 内核、发行版与托管服务

“基于 PostgreSQL”可能表示使用上游源码加少量补丁,也可能只表示接受一部分 PostgreSQL wire protocol。两者对扩展的意义完全不同。

选扩展前先冻结运行载体:

server implementation
  + server major/minor/build
  + OS/distribution/architecture
  + package source and build
  + topology and managed restrictions
  + database extension object version

扩展不是脱离这些条件存在的功能标签。

14.2.1 上游 PostgreSQL、补丁内核与兼容性承诺

“内核”至少要说明源码与构建

在本书中,上游 PostgreSQL 指 PostgreSQL Global Development Group 发布的 代码与版本语义。供应者可以在其上:

  • 回移安全或缺陷补丁;
  • 增加认证、存储、优化器或复制能力;
  • 替换某些系统组件;
  • 发布自己的包名、构建号与支持周期;
  • 形成需要独立升级路径的 fork。

“补丁少”不自动等于二进制兼容;“版本号相同”也不证明动态库来自相同 ABI 与编译选项。C 扩展会与 server headers、符号、内存上下文、catalog 和内部 API 交互。PostgreSQL 不承诺跨 major 的内部 C ABI,扩展通常必须按目标 major 构建。

先采原始身份:

SELECT version();
SHOW server_version;
SHOW server_version_num;

SELECT
    name,
    setting,
    source
FROM pg_settings
WHERE name IN (
    'server_version',
    'server_version_num',
    'data_directory',
    'config_file',
    'shared_preload_libraries'
);

再采主机/包事实:

postgres --version
pg_config --version
pg_config --configure
uname -m

在 Pigsty 环境还要记录 pg_versionpg_mode、镜像/仓库快照与节点实际包 清单。不要只复制应用连接返回的 version();代理、兼容层或读写路由可能让 它不足以唯一标识整个集群。

扩展兼容承诺要逐项问

对一个补丁内核或 fork,至少问:

问题 为什么影响扩展
是否使用上游系统目录布局 扩展 SQL 可能查询/修改 catalog
是否支持 PGXS 与上游 server headers 决定能否按目标内核构建
是否保持所需 C symbols/hook 决定动态库能否加载和正确运行
WAL、存储和复制是否改动 自定义类型/访问方法能否在备库恢复
pg_upgrade 是否使用上游路径 外部模块与数据格式怎样迁移
由谁发布扩展包 上游扩展 release 不等于目标内核构建
谁承担联合支持 内核供应者和扩展供应者是否互相认可组合

“这个扩展支持 PostgreSQL 18”只说明扩展项目的一个范围;目标若是 PostgreSQL 18 衍生内核,仍需该组合的构建与验证证据。

SQL 扩展也不必然可移植

没有 C 动态库只能降低 ABI 风险,不能消除语义耦合。纯 SQL 扩展可能依赖:

  • 特定系统目录列;
  • 特定函数、数据类型或语法版本;
  • planner 行为;
  • event trigger;
  • trusted extension 机制;
  • superuser/owner 权限;
  • 复制、dump 或安全策略。

因此兼容性不是“C 扩展危险、SQL 扩展安全”的二分,而是依赖面的大小。

支持矩阵要精确到组合

不要写:

supports PostgreSQL

而写:

server: upstream PostgreSQL 18.6, vendor build X
OS: Ubuntu 24.04 amd64
extension package: pgvector build Y
database object: vector 0.8.4
topology: 1 primary + 2 physical standbys
preload: not required
backup/restore: rehearsed on clean target Z

同一扩展在 EL9/aarch64、Ubuntu/amd64 和某托管服务上是三个验证组合。

14.2.2 包仓库、容器镜像与托管白名单

包仓库解决供应,不替你做数据库升级

发行版包通常编码:

extension project version
PostgreSQL major
OS family/version
CPU architecture
vendor release/build

例如 Pigsty 的包别名 pgvector 可以映射到不同系统上的:

EL:     pgvector_18*
Debian: postgresql-18-pgvector

这层映射很有价值,但别名不是 SQL 名:

pg_extensions: [pgvector]   # package intent

数据库中仍是:

CREATE EXTENSION vector;    -- SQL extension name

仓库有包只证明“某个源声明可以供应”;还要验证:

  • 目标 OS/PG major/架构是否有具体 artifact;
  • repo metadata、签名与校验是否可信;
  • 包是否已经同步到本地/离线仓库;
  • 所有节点安装的 build 是否相同;
  • control、更新 SQL 和动态库是否都随包出现;
  • 升级包后每个数据库的 extversion 是否仍需迁移。

锁定生产变更时,保存具体包 NEVRA/DEB version 或文件哈希,不只保存一个 会随仓库漂移的“latest”别名。

容器把供应快照化,但不把数据生命周期一起快照

容器镜像可以把 server 与扩展文件打包在同一 digest 中:

image digest
  ├─ postgres binary
  ├─ control and SQL scripts
  └─ shared libraries

这有助于节点一致性,却有几个陷阱:

  1. 数据目录通常在持久卷,里面的 pg_extension.extversion 不随镜像自动 更新;
  2. 滚动换镜像期间,新旧 pod 可能同时服务,动态库 build 必须满足复制和 failover 条件;
  3. 恢复 job、备份验证 job 和临时维护容器也需要相同扩展文件;
  4. 镜像能启动不代表 ALTER EXTENSION UPDATE 已完成;
  5. 使用浮动 tag 会把可复现优势重新丢掉。

因此镜像 digest 是供应锁,不是数据库迁移状态。

托管白名单是产品合同

托管 PostgreSQL 常限制超级用户、文件系统和 server 参数。用户通常只能从 服务商允许列表中执行:

CREATE EXTENSION approved_name;

这带来不同问题:

  • 是否允许该扩展;
  • 允许哪个对象版本;
  • 哪些区域、实例规格或 PG major 可用;
  • 是否需要服务商参数组/重启;
  • 谁控制更新窗口;
  • 是否暴露扩展 owner;
  • 是否允许自定义 schema;
  • 备份、只读副本、跨区恢复与 major upgrade 是否支持;
  • 从服务迁出时怎样导出自定义类型数据。

托管服务显示“支持 pgvector”,仍不能直接套用自建包的版本、参数和升级 步骤。白名单名称相同,控制面合同可能不同。

仓库、镜像与白名单的共同锁文件

为每个环境维护:

server:
  implementation: upstream-postgresql
  version: 18.6
  build: vendor-build-id
  os: ubuntu-24.04-amd64

extension:
  sql_name: vector
  project: pgvector
  package_alias: pgvector
  package_version: exact-build
  object_version: 0.8.4
  preload: false

supply:
  repo_snapshot_or_image_digest: immutable-id
  control_sha256: ...
  install_sql_sha256: ...
  library_sha256: ...

validation:
  primary: passed
  standbys: passed
  clean_restore: passed
  major_upgrade_clone: passed

不是所有项目都要手写 YAML,但这些字段必须能从 CMDB、inventory、镜像 SBOM、evidence 或变更单还原。

14.2.3 “兼容 PostgreSQL”需要逐层验证

五层兼容模型

把“兼容”拆为五层:

要验证什么 典型误判
SQL 语义 类型、函数、事务、隔离、DDL 行为 能跑简单 CRUD 就等于 PostgreSQL
Wire protocol 驱动连接、认证、参数、错误字段 驱动能连就等于 server 等价
Catalog/API pg_catalog、扩展机制、统计视图 ORM 能用就等于管理工具能用
Extension control/SQL/C ABI、preload、成员与版本 “支持 pgvector”就等于任意版本
Operations 备份、PITR、复制、failover、upgrade、监控 单实例功能测试代替生产生命周期

兼容声明必须说明通过了哪层。一个 wire-compatible 服务可能不提供 CREATE EXTENSION;一个支持扩展 SQL 接口的服务可能不允许用户控制版本; 一个上游二进制兼容内核仍可能在备份或升级控制面上有不同合同。

用需求驱动 probe

不要为了“全面”跑一堆无关 SQL。根据应用依赖形成最小 probe:

application contract
  ├─ exact type/function/operator signatures
  ├─ SQLSTATE and transaction behavior
  ├─ planner/index behavior
  ├─ privilege boundary
  ├─ backup/restore representation
  └─ failover/upgrade behavior

本章对 pg_trgm/vector 的 probe 包括:

-- 供应可见性
SELECT * FROM pg_available_extension_versions
WHERE name IN ('pg_trgm', 'vector');

-- 数据库对象
SELECT * FROM pg_extension
WHERE extname IN ('pg_trgm', 'vector');

-- 成员关系
SELECT ... FROM pg_depend WHERE deptype = 'e';

-- 索引实现
SELECT ... FROM pg_index JOIN pg_am JOIN pg_opclass ...;

-- 权限失败
ALTER EXTENSION pg_trgm UPDATE TO '1.6'; -- application: 42501

-- 行为与计划
EXPLAIN ... title % 'PostgreSQL extenson';
EXPLAIN ... ORDER BY embedding <-> '[1,0,0]';

这些 probe 仍没有覆盖备库与恢复,所以 evidence 不能写“生产兼容已验证”。

区分等价、适配与迁移

三个词不要混用:

  • 等价:在声明范围内行为相同;
  • 适配:应用通过条件分支、兼容层或限制使用范围后可运行;
  • 迁移:接受行为变化并修改 schema、查询、运维或 SLO。

例如目标不支持 HNSW,但支持精确向量距离:

不是:完全兼容 pgvector
可能是:类型/距离查询兼容,ANN 索引不兼容
决策是:小数据集适配,或迁移到另一检索架构

精确描述可以阻止“兼容”在采购、开发和事故处理中不断膨胀。

兼容矩阵

对候选平台填表:

验证项 上游自建 Pigsty L1 托管候选 证据
SQL 扩展名/版本 catalog
package/build 服务商托管 package/API
preload/restart live setting
owner/权限 negative test
GIN/HNSW catalog + plan
物理副本 failover test
schema-only dump dump artifact
clean restore restore report
major upgrade cloned rehearsal
portable export row/checksum

空格不是“默认通过”,而是未验证。若某项与业务无关,可以标 N/A 并说明 理由;不能把它悄悄留空后宣称全兼容。

停止线

遇到以下任一情况,不进入生产:

  • 无法唯一标识 server/扩展 build;
  • 主备节点供应状态不一致;
  • 只有创建成功,没有 clean restore;
  • 目标服务商不能说明 major upgrade 时怎样处理扩展;
  • 自定义类型无法导出为稳定交换格式;
  • 兼容层不返回应用依赖的 SQLSTATE/事务语义;
  • 供应者与扩展项目相互否认联合支持;
  • 只能使用浮动包/tag,无法复现已测组合。

本节结论

“PostgreSQL 兼容”不是布尔值,而是一个带版本、层次和证据的向量:

compatibility =
  SQL × protocol × catalog × extension × operations
  under exact version/build/topology constraints

扩展越深入类型、索引、hook 与存储,越不能只验证前两层。


上一节:PostgreSQL 扩展机制 · 返回本章目录 · 下一节:扩展选型的六个问题 · 查看全书目录 · 查看索引中心

14.3 扩展选型的六个问题

扩展评审最容易从产品介绍开始:

它支持什么?

更好的起点是:

我们已经观察到什么问题?

本节用六个问题形成漏斗:

  1. 具体问题和原生替代是什么?
  2. 成功、停止与反例怎样测量?
  3. 是否引入数据格式锁定,怎样导出退出?
  4. 备份、复制与升级生命周期是否成立?
  5. 维护、许可证与商业连续性如何?
  6. 权限、崩溃面和供应链风险能否接受?

前两问证明价值,中间两问证明可运营,后两问证明风险归属。任何一问没有 答案,都只能进入调查或限域试点,不能直接成为平台默认。

14.3.1 它解决的具体问题和原生替代是什么

问题必须可证伪

以下不是问题陈述:

我们需要向量数据库。
我们需要分布式 PostgreSQL。
大家都在用时序扩展。
这个扩展会让查询更快。

它们已经把候选解写进需求。可评审的陈述应包含:

workload + current evidence + target + boundary

例如:

商品标题查询中,8% 的零结果请求只有一个拉丁字母拼写错误;
在 2000 万活跃标题、P95 50 ms 的边界内,希望返回至多 20 个候选;
中文分词、语义搜索和全站文档检索不在本次范围。

这时 pg_trgm 才是候选之一,而不是需求本身。

先列 PostgreSQL 原生替代

“原生”不等于永远更好,但它通常具有更小供应面。按问题检查:

问题 先检查
精确/前缀查找 B-tree、表达式/partial index、规范化列
词项全文检索 tsvector、GIN、词典与查询函数
范围/包含/重叠 range/multirange、GiST、EXCLUDE
半结构化属性 jsonb + GIN/表达式索引,或重新建模
地理点/简单距离 内置 point 是否真的足够;复杂 GIS 再评 PostGIS
时间分区/归档 declarative partitioning、维护流程
容量问题 查询/索引修正、归档、分区、纵向扩容
批量分析 物化、并行查询、专用副本或外部分析系统

还要列应用/外部服务替代。一个扩展减少网络跳数,但把失败和升级绑定到 PostgreSQL;外部服务增加分布式复杂性,却可能提供独立扩缩容与专用算法。 这是工程权衡,不是“数据库内一定更快”。

比较单位是完整方案

不要比较:

one SQL function vs one HTTP call

而比较:

PostgreSQL extension solution
  package + preload + schema + index + backup + standby + upgrade + skills

external service solution
  service + network + sync pipeline + consistency + backup + operations

native solution
  schema/query/index + application behavior + operational limits

遗漏生命周期成本,会让扩展看起来永远最简单;遗漏外部同步成本,又会让 独立服务看起来永远更可扩展。

第二问:成功与停止怎样测

一个 PoC 至少同时有:

  • 正确性/质量:结果集合、不变量、召回/精度或误差;
  • 性能:P50/P95/P99、吞吐、build time、写放大;
  • 资源:内存、磁盘、CPU、WAL、临时文件;
  • 运行:备库延迟、恢复时间、升级锁、失败表现;
  • 边界:数据规模、过滤选择性、并发、语言/模型/维度;
  • 停止线:何时立即拒绝或回到替代方案。

本章五行 fixture 的成功标准故意很窄:

pg_trgm: top ids 1,5,2 and GIN plan is usable
vector:  top ids 1,2,5 and HNSW plan is usable

它证明 API 与索引机制,不证明:

真实搜索质量
大规模 ANN recall
生产尾延迟
写入与索引构建成本
备库和恢复 SLO

因此 pg_trgm 的接受范围只是“有界单字段模糊匹配”;vector 仍是 pilot。

反例必须进入数据集

只测成功样本会让任何扩展通过。检索候选至少加入:

  • 短字符串、空值、重复值;
  • 不同语言、大小写、重音和规范化;
  • 高频词、低选择性谓词;
  • 过滤后很少/很多候选;
  • 大批更新与删除;
  • 冷缓存、热缓存;
  • 与业务谓词组合的真实查询。

分布式候选则要加入:

  • 跨分片事务;
  • 热分片;
  • rebalance;
  • 节点失联;
  • DDL 传播;
  • 全局唯一性与引用完整性;
  • 备份、恢复和扩缩容窗口。

问题没有对应反例,PoC 更像演示。

14.3.2 数据格式是否锁定、能否导出和退出

第三问:锁定发生在哪里

扩展可能只增加可重建索引,也可能让业务列使用自定义类型:

依赖 锁定程度 退出方式
纯函数、无持久数据 改查询后删除
可重建 expression/index/opclass 较低 先换查询/索引,再删除
extension-owned 配置表 导出、转换、重建
自定义列类型 列级转换或交换格式迁移
自定义 table/access method 全表重写/逻辑迁移
存储/WAL/分布式元数据 很高 专用迁移与拓扑退场

pg_trgm 在本章只提供函数、操作符与 GIN opclass,业务 title 仍是 text。退出可以先改查询、删除 GIN,再删除扩展。

vector 让:

embedding shop_ch14.vector(3)

成为列类型。只要该列存在:

DROP EXTENSION vector;

就会因依赖失败;使用 CASCADE 会把业务对象一起删除,不是可接受的退出。

在采用前写出出口

本章先建立可移植导出:

COPY (
    SELECT
        doc_id,
        title,
        embedding::text AS embedding_text
    FROM shop_ch14.candidate_doc
    ORDER BY doc_id
) TO STDOUT WITH (FORMAT csv, HEADER true);

输出形如:

1,PostgreSQL extension guide,"[1,0,0]"

这只建立一个交换入口。真正退出还要回答:

  • 文本格式由谁解析,精度是否损失;
  • 行数、主键和 checksum 怎样核对;
  • 目标类型是什么;
  • 应用何时双读/双写;
  • ANN 索引何时停止使用;
  • 大表转换是否重写、锁多久、产生多少 WAL;
  • rollback 点在哪里;
  • 备份中最后一个扩展依赖何时消失。

没有跑过迁移的“理论可导出”只能算风险缓解,不能算完成退出演练。

不把逻辑 dump 当数据出口

全库 pg_dump 通常写:

CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA shop_ch14;

这仍要求恢复端安装 vector。它是 同构恢复 合同,不是脱离扩展的出口。

真正的 portability artifact 应使用目标系统可理解的格式:

  • 内置 text/numeric/jsonb/array
  • CSV/JSON/Parquet 等交换格式;
  • 明确坐标系、单位、模型与版本的领域格式;
  • 行数、范围和 checksum。

例如 PostGIS 几何不能只导出一个没有 SRID 的坐标字符串;embedding 不能 只导出数字而丢掉 model、dimension、normalization 与 distance metric。

第四问:生命周期是否成立

锁定不只发生在数据格式,也发生在运维路径。逐项问:

install:
  every primary/standby/restore host?

backup:
  pg_dump and physical backup prerequisites?

restore:
  clean environment package bootstrap order?

replication:
  physical library parity?
  logical subscriber type/schema parity?

upgrade:
  extension object update path?
  server-major-compatible binary?
  lock and downtime?

rollback:
  package rollback?
  object downgrade script?
  data format backward compatibility?

只有 happy-path CREATE EXTENSION 的项目,生命周期证据为零。

退出预算

把退出成本量化:

项目 估算
需要转换的数据量 bytes / rows
双写窗口 hours/days
额外存储 old + new + indexes
最大锁窗口 seconds/minutes
WAL 与备库延迟 projected/tested
应用版本跨度 N / N+1 compatibility
回滚最晚点 before/after backfill/switch
人员与演练时间 owner + date

如果退出成本已经超过系统可承受窗口,决策不是“以后再说”,而是当前已经 形成实质锁定,必须由业务负责人接受。

14.3.3 维护活跃度、许可证与商业连续性

第五问不是“最近有没有 commit”

维护健康至少包括:

  • 是否有明确维护者和 release 流程;
  • 对当前/未来 PostgreSQL major 的响应速度;
  • 缺陷、崩溃和安全问题是否被分类与修复;
  • release notes 与 update scripts 是否完整;
  • CI 是否覆盖目标 OS/架构/PG major;
  • 文档是否说明备份、升级、preload 和限制;
  • issue/PR 是否有持续 triage;
  • 是否存在多名可发布维护者;
  • 旧版本支持与 EOL 策略是否清晰。

“每天很多 commit”可能只是功能开发;“半年没 commit”也可能是成熟稳定。 用与你的风险相关的证据判断。

许可证检查三个层次

至少分别检查:

  1. 源码许可证;
  2. 二进制包与捆绑依赖;
  3. 企业使用、再分发、托管服务或商业功能条款。

不要从项目名称、GitHub 页面徽章或旧博客推断。保存目标版本的 LICENSE、 NOTICE、依赖清单与法务结论。许可证可能随 major、模块或商业发行版变化。

技术上能装,不表示组织有权按计划分发;开源,也不表示所有附加服务和 品牌条款相同。

商业连续性不是“有公司背书”

公司支持可以降低某些风险,也引入:

  • 定价或授权变化;
  • 产品方向与开源版分叉;
  • 单一 vendor build;
  • 支持合同终止;
  • 收购、停服或仓库下线。

社区项目则可能有 bus factor、发布带宽和联合支持问题。两者都要问:

如果主要供应者明天停止交付,
我们能否合法获得源码、复现构建、修补安全问题、
恢复历史备份并迁出数据?

答案不一定要求团队自己维护 fork,但必须有时间与责任人。

建立维护快照

ADR 中保存带日期的证据:

project_release: exact-tag
reviewed_at: 2026-07-29
supported_pg: [14, 15, 16, 17, 18]
target_build: exact-package
license_review: ticket-or-document
security_contact: ...
last_restore_test: ...
next_review: ...

维护状态会变化,所以结论必须有复审日,不能把一次评估写成永久事实。

14.3.4 权限、崩溃面与供应链风险

第六问:谁获得什么能力

扩展评审画出特权链:

repository maintainer
  -> package builder
  -> node installer (root)
  -> PostgreSQL admin
  -> extension owner
  -> schema/object owner
  -> application users

每一环都可能改变下一环执行的代码。要记录:

  • 谁能把包加入仓库;
  • 谁能改 shared_preload_libraries 和重启;
  • 谁能 CREATE/ALTER/DROP EXTENSION
  • extension owner 是谁;
  • 成员函数默认给 PUBLIC 什么权限;
  • 应用通过哪些 schema、函数、操作符和类型使用;
  • 谁能在安装 schema 预置同名对象影响脚本解析。

C 扩展与 server 共享故障域

C 扩展不是旁路微服务,它在 PostgreSQL 进程地址空间内运行。缺陷可能导致:

  • backend crash;
  • postmaster 重启其他 backend;
  • 内存破坏;
  • 错误结果或数据损坏;
  • 无限循环/资源耗尽;
  • 特权边界漏洞。

这不表示拒绝所有 C 扩展。PostgreSQL 大量核心能力也使用 C;结论是:

一个 in-process 扩展的审核与发布等级,应接近数据库 server 组件,而不是 普通 SQL 库。

需要:

  • 来源与构建可追溯;
  • 目标 major/架构测试;
  • crash/recovery 与备库测试;
  • 资源上限;
  • 安全通告与快速替换能力;
  • core dump/日志/回滚预案。

纯 SQL/PL 扩展没有任意 C 内存访问,但仍可能包含提权、search_path、 动态 SQL、错误 ACL、超大查询和对象劫持风险。

供应链不止校验下载文件

最小证据链:

upstream source/tag
  -> trusted build pipeline
  -> signed repository metadata
  -> exact OS package
  -> control/SQL/library hashes on nodes
  -> database extversion/member inventory

本章 package manifest 对这些具体文件做 SHA-256:

pg_trgm.control
pg_trgm--1.3.sql
pg_trgm--1.3--1.4.sql
pg_trgm--1.4--1.5.sql
pg_trgm--1.5--1.6.sql
pg_trgm shared library
vector.control
vector--0.8.4.sql
vector shared library

哈希能发现漂移,不能证明代码安全。它必须与仓库签名、构建来源、审计和漏洞 响应组合。

风险分级

一个实用起点:

等级 例子 最低门槛
L0 可重建 SQL/索引 不改变持久类型、无 preload 功能/计划/恢复/退出
L1 自定义类型/C 函数 持久列依赖动态库 供应锁、主备、clean restore、出口
L2 preload/hook/worker server 启动与全局执行路径 滚动重启、crash/failover、资源与禁用
L3 存储/分布式拓扑 WAL、shard、专用 catalog 完整故障模型、升级/回退、联合支持

等级不是产品好坏,而是证据成本。高等级候选可以采用,但不能用 L0 的 “创建成功”验收。

决策状态

只允许清晰状态:

investigate  资料不足
pilot        限域、有停止线、不得成为默认依赖
accept       在明确版本/场景内批准
reject       当前问题或风险不匹配
superseded   已由新 ADR 替代

避免“原则同意”“先装上再看”“有需要都可以用”这类无法执行的结论。

本节结论

六问的顺序很重要:

problem -> evidence -> data/exit -> lifecycle -> continuity -> security

越早失败,越应尽早停止。没有实际问题时,不需要花几周证明供应链;问题 成立后,也不能用性能收益跳过恢复和退出。


上一节:内核、发行版与托管服务 · 返回本章目录 · 下一节:生命周期与升级耦合 · 查看全书目录 · 查看索引中心

14.4 生命周期与升级耦合

扩展有两条相互耦合、却不会自动同步的生命周期:

node lifecycle:
  repository -> package -> control/SQL/library -> preload/restart

database lifecycle:
  CREATE EXTENSION -> member objects -> ALTER UPDATE -> DROP

运维事故往往发生在两条线暂时分离时:包已升级而对象未升级、对象已写入 catalog 而新备库缺库、备份完整却恢复环境没有旧脚本。

14.4.1 安装版本不等于数据库对象版本

三个“版本”不要合成一个字段

至少记录:

名称 来源 示例
项目/release 版本 upstream release/package metadata pgvector 0.8.4
节点软件包 build rpm/deb/image vendor release + PG18 + OS
数据库对象版本 pg_extension.extversion vector 0.8.4

有些包一次提供多个 SQL 对象版本和更新脚本。于是:

package release = newest support files
extversion      = current objects in one database

两者不同并不必然错误,但必须是被管理的过渡状态。

先装包,再逐库迁移

一个安全的普通更新顺序:

1. freeze exact package build
2. read control/release/update scripts
3. install package on standbys and primary nodes
4. verify files and loadability on every node
5. rehearse on restored/cloned database
6. inventory every database extversion/owner/dependency
7. establish query + plan + correctness baseline
8. ALTER EXTENSION ... UPDATE in controlled window
9. verify catalog/member/behavior/log/replication
10. observe before removing old support files

为什么先把文件铺到所有节点?因为 failover 不应把一个刚完成对象更新的 数据库交给缺动态库的备库。物理复制会复制数据目录变化,不会替你把 $libdir 文件复制到另一台主机。

为什么逐库?扩展是 database-scoped。一个 cluster 中:

\l

列出的每个数据库都有自己的 pg_extensionpostgres 已经 1.6,不代表 appanalyticstemplate 也已经 1.6。

建立跨节点、跨数据库矩阵

节点侧:

pg_config --version
pg_config --sharedir
pg_config --pkglibdir

# 由包管理器/CMDB采集 exact build;不要解析一个浮动别名当版本

数据库侧:

SELECT
    current_database() AS database_name,
    e.extname,
    e.extversion,
    pg_get_userbyid(e.extowner) AS owner,
    n.nspname AS nominal_schema
FROM pg_extension AS e
JOIN pg_namespace AS n
  ON n.oid = e.extnamespace
ORDER BY e.extname;

聚合后应能回答:

node A/B/C package build
  × database D1/D2/D3 extversion
  × primary/standby role

只保存 \dx 截图无法发现某台备库缺包,也无法显示其他数据库。

control 默认版本不会升级已有对象

升级包后:

SELECT
    name,
    default_version,
    installed_version
FROM pg_available_extensions
WHERE name = 'pg_trgm';

可能显示:

default_version=1.6
installed_version=1.3

含义是:

  • 新执行 CREATE EXTENSION pg_trgm 默认创建 1.6;
  • 当前数据库对象仍是 1.3;
  • 只有显式 ALTER EXTENSION ... UPDATE 才迁移它。

不要靠重跑:

CREATE EXTENSION IF NOT EXISTS pg_trgm;

期待升级。IF NOT EXISTS 只会 notice 并保留现有对象,也不保证同名现有 对象就是期望内容。

更新前读脚本,不只读 release notes

查看路径:

SELECT *
FROM pg_extension_update_paths('pg_trgm')
WHERE source = '1.3'
  AND target = '1.6';

再读取实际 package 中:

pg_trgm--1.3--1.4.sql
pg_trgm--1.4--1.5.sql
pg_trgm--1.5--1.6.sql

评审:

  • 是否改类型或存储格式;
  • 是否重建/重写索引;
  • 是否触碰扩展配置表;
  • 是否删除/重命名函数与操作符;
  • 是否会扫描业务数据;
  • 需要何种锁;
  • 失败是否能事务回滚;
  • 更新后的旧应用是否仍兼容。

本章对这些文件和动态库做 SHA-256,保证复验时读的是同一构建;哈希不替代 代码评审。

package rollback 不等于 object rollback

假设已经把对象从 1.3 更新到 1.6,再把 OS 包降回只支持 1.3:

database extversion=1.6
filesystem supports only 1.3

这是更危险的不一致。反向对象迁移只有在扩展明确提供 downgrade path 且 数据格式兼容时才可能。更常见的回退是:

  • 在更新前保留可恢复备份/快照;
  • 在 clone 上验证;
  • 更新后向前修复;
  • 若必须回退,恢复到更新前一致时间点并协调业务数据。

因此扩展更新的“可回滚”不能只写 apt downgrade

14.4.2 大版本升级、备份恢复与逻辑复制兼容

pg_upgrade 不会替你验证外部模块

PostgreSQL 官方 pg_upgrade 文档明确提醒:所有外部模块必须与新 server 二进制兼容;pg_upgrade 无法检查这一点。新集群主库与备库都要安装匹配 shared libraries。

大版本升级前,对每个扩展冻结:

old server major/build
old extension object version
old package build

new server major/build
new-compatible package build
target extension object version
supported transition order

可能的顺序取决于扩展:

old PG: update extension to prerequisite version
  -> install new-PG-compatible files
  -> pg_upgrade / logical migration
  -> new PG: ALTER EXTENSION to target version

也可能要求另一个顺序。以扩展的目标版本升级文档为准。

pg_upgrade --check 通过不等于扩展可用。clone rehearsal 至少要:

  • 启动新集群;
  • 查询每个扩展类型/函数;
  • 检查 expression index/opclass;
  • 重建或验证要求重建的索引;
  • 跑应用回归;
  • 启动新备库;
  • 验证 dump/restore 和监控。

物理备份包含数据,不包含 OS 供应链

物理备份复制数据库文件和 WAL。它不会自动保存:

  • PostgreSQL server binary;
  • control 与版本 SQL;
  • 动态库;
  • preload 配置的外部部署来源;
  • OS package repository。

恢复手册必须能重建:

compatible server binary
  + exact/compatible extension packages
  + configuration/preload
  + data directory and WAL

只保留最新仓库,未必能恢复三年前依赖旧扩展对象版本的备份。长期保留策略要 考虑 package snapshot、image digest 或可复现构建。

逻辑 dump 用声明恢复扩展

PostgreSQL 把扩展视为整体。全库 schema-only dump 中,本章看到:

CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA shop_ch14;
COMMENT ON EXTENSION pg_trgm IS '...';

CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA shop_ch14;
COMMENT ON EXTENSION vector IS '...';

却没有:

CREATE TYPE shop_ch14.vector ...
CREATE FUNCTION shop_ch14.similarity ...

这正是扩展机制的 dump 合同。恢复顺序隐含要求:

target support files available
  -> schema/extension creation
  -> dependent application tables/indexes
  -> data

本章还证明一个容易忽略的选择性 dump 语义:

pg_dump --schema-only --schema=shop_ch14 ...

输出包含:

candidate_doc table
vector column
GIN/HNSW indexes

但不包含 CREATE EXTENSION。PostgreSQL pg_dump 文档对 --schema 选择明确警告:它不保证自动带上所选对象依赖的所有对象。这个 artifact 不能单独在洁净环境恢复,必须由恢复清单显式先创建扩展。

参见 pg_dump

clean restore 是唯一有力的恢复证据

不要在原集群上执行 dump 后立刻宣称可恢复。洁净环境要求:

  • 没有预装数据库扩展对象;
  • 使用冻结 server/package build;
  • 从空 database 开始;
  • 按 runbook 恢复;
  • 验证 extension owner/schema/version/member;
  • 验证业务行数/checksum;
  • 验证查询、计划与权限;
  • 记录时间、日志和失败。

若恢复必须“手工试几个版本直到成功”,供应合同尚未完成。

逻辑复制不复制 schema 与扩展生命周期

内置逻辑复制主要复制表数据变更,不替你复制 DDL、extension control files 或 CREATE EXTENSION。发布端列使用自定义类型时,订阅端必须预先拥有可 接受该列值、函数和索引的兼容 schema。

评审:

  • publisher/subscriber 类型名与语义;
  • text/binary 传输与转换能力;
  • extension object version;
  • DDL 发布顺序;
  • replica identity;
  • extension-owned 配置/metadata 是否作为普通表复制;
  • 订阅端触发器/默认值/约束的执行差异;
  • major/architecture 组合。

“两端都显示 extension installed”仍不足以证明版本/数据语义兼容。

如果逻辑复制被用作迁移出口,先把自定义数据转换为内置交换类型通常更容易 控制。例如本章把 vector(3) 显式转为 text,而不是要求目标立即加载 同一 extension。

参见 Logical Replication Restrictions

备库与 failover

物理 standby 会重放创建表、类型依赖和 extension catalog 变化,但不会 运行节点包管理器。变更前:

all standbys have compatible support files
  -> preload/config staged
  -> restart completed if required
  -> primary database DDL/update
  -> replication caught up
  -> controlled switchover/failover probe

检查不能只 SSH 到主库。新加入节点、灾备节点、延迟副本和备份 restore worker 都属于供应范围。

14.4.3 依赖扩展不可用时的降级策略

先区分必需能力与增强能力

扩展依赖可分:

类型 例子 不可用时
数据可读必需 业务列是自定义类型 数据库/查询可能无法正常使用
写入必需 trigger/function 是写入合同 应停止写而不是绕过不变量
查询增强 可重建索引/opclass 可回退较慢原生查询
观测增强 统计/采样扩展 核心业务可运行,诊断能力下降
维护增强 repack/调度工具 延后维护并告警

只有后两三类适合真正“降级”。把自定义列类型说成可选能力是自欺。

设计能力探测,但不要每次请求查 catalog

发布/启动时探测:

SELECT
    e.extname,
    e.extversion
FROM pg_extension AS e
WHERE e.extname IN ('pg_trgm', 'vector');

再验证所需签名与索引:

SELECT to_regprocedure('shop_ch14.similarity(text,text)');
SELECT to_regclass('shop_ch14.candidate_doc_title_trgm_idx');

结果进入部署 gate 或低基数健康状态,而不是每个请求动态猜。应用 feature flag 必须与数据库迁移阶段同步:

extension absent:
  extension-dependent feature disabled

extension installed and validated:
  canary reads

index built and valid:
  limited traffic

observation passed:
  normal traffic

可重建索引的降级

pg_trgm 例子:

normal:
  title % $query
  GIN gin_trgm_ops

degraded:
  exact normalized equality
  or prefix lookup
  or PostgreSQL FTS

降级查询语义不同,API 要明确:

  • 是否返回较少结果;
  • 是否暂停 fuzzy mode;
  • 延迟是否提高;
  • 哪些 SLO 暂时失效。

不要悄悄返回不同业务含义。

自定义类型的退场顺序

vector 为例:

1. freeze model/dimension and export format
2. add destination native/external representation
3. backfill with row/checksum verification
4. deploy dual-read or switched-read application
5. stop new extension-type writes
6. verify no view/function/index/table depends on type
7. drop extension-dependent indexes/columns
8. DROP EXTENSION without CASCADE
9. remove preload if any and then node package

依赖检查:

-- 先看 pg_depend 和业务对象定义
\d+ shop_ch14.candidate_doc
\dx+ vector

-- 最后的 DROP 必须用 RESTRICT 语义暴露遗漏
DROP EXTENSION vector;

绝不以:

DROP EXTENSION vector CASCADE;

作为“清理方便”的生产脚本。CASCADE 会把尚未迁走的业务对象一起删除。

当动态库临时缺失

若 catalog 已有扩展而节点缺 library:

  • 不要继续 failover 到该节点;
  • 阻断相关流量或节点晋升;
  • 从受控仓库恢复匹配包;
  • 验证哈希、loadability 与查询;
  • 检查所有其他节点是否同样漂移;
  • 解释配置管理为何未发现;
  • 完成恢复/备库回归。

删除 catalog 中 extension 不是修动态库缺失的第一反应,尤其当业务类型依赖 它时。

降级 SLO

ADR 预先定义:

feature: fuzzy-title-search
dependency: pg_trgm
failure_detection: deployment probe + query error alert
fallback: normalized-prefix-search
semantic_change: typo tolerance disabled
latency_budget: 200ms
maximum_duration: 2h
owner: search-team
restore_action: package parity + catalog/query verification

没有时间、语义和 owner 的 fallback 只是愿望。

本节结论

扩展生命周期的真正完成条件:

can install
  + can update
  + can fail over
  + can back up and clean-restore
  + can cross major
  + can degrade or stop safely
  + can exit without CASCADE

CREATE EXTENSION 只完成第一项的一部分。


上一节:扩展选型的六个问题 · 返回本章目录 · 下一节:用 Pigsty 管理扩展可用性 · 查看全书目录 · 查看索引中心

14.5 用 Pigsty 管理扩展可用性

Pigsty 能把仓库、包、配置和数据库声明统一管理,但平台声明不是 PostgreSQL 事实的替代品。正确用法是:

declare intent in Pigsty
  -> converge nodes and databases
  -> verify live package/config/catalog/query evidence

本节以 Pigsty 4.5 文档为基线。参数与扩展目录会变化,目标集群变更前应使用 同版本文档和 inventory,而不是照抄本章时间点。

14.5.1 包、仓库、模板与节点差异

Pigsty 中的四步模型

当前 Pigsty 扩展文档把过程分成:

Download
  从上游仓库取得包,或同步到本地仓库

Install
  在 PGSQL cluster 的所有相关节点安装 OS 包

Configure
  处理 shared_preload_libraries 和扩展参数

Create
  在指定数据库执行 CREATE EXTENSION

这与上一节三层状态一致:

Pigsty 动作 主要对象 原生验证
Download repo/cache repo metadata、artifact、checksum
Install node package/files package inventory、control/library
Configure Patroni/PostgreSQL 参数 pg_settings、restart、日志
Create database object pg_extension、成员、查询

不是每个扩展都需要 preload,也不是每个已安装插件都需要创建数据库对象。

pg_packagespg_extensions

Pigsty 4.5 文档区分:

pg_packages:
  - pgsql-main pgsql-common

pg_extensions:
  - postgis timescaledb pgvector
  • pg_packages:通用基础包组,通常用于所有 cluster 的核心组件;
  • pg_extensions:特定 PGSQL cluster 需要的扩展软件包,初始化时安装, 也可对已存在 cluster 执行 pg_extension tag 收敛。

示例:

pg-meta:
  hosts:
    10.10.10.10: { pg_seq: 1, pg_role: primary }
    10.10.10.11: { pg_seq: 2, pg_role: replica }
  vars:
    pg_cluster: pg-meta
    pg_extensions: [pgvector]

对于已运行 cluster,先修改受版本控制的声明,再执行目标明确的 playbook:

./pgsql.yml -l pg-meta -t pg_extension

临时命令行覆盖:

./pgsql.yml -l pg-meta -t pg_extension \
  -e '{"pg_extensions":["pgvector"]}'

适合受控应急或实验,但若不回写 inventory,下一位维护者看不到持久意图。

参见 Pigsty: Install Extensions

包别名是跨发行版映射

Pigsty 使用稳定别名:

pgvector
postgis
timescaledb

映射到 PG major 与 OS 对应包,例如:

pgvector
  -> pgvector_18*                 # EL
  -> postgresql-18-pgvector       # Debian/Ubuntu

还提供 pgsql-ragpgsql-gispgsql-fts 等类别别名。类别安装范围大, 实验便利不等于生产应一次装整类。生产 ADR 优先列精确候选,避免供应面无意 扩大。

包别名与 SQL 名必须在清单中同时保存:

项目 package alias SQL extension
pgvector pgvector vector
PostGIS postgis postgispostgis_topology
pg_trgm pgsql-main/默认包集合供应 pg_trgm

参见 Pigsty: Extension Package Aliases

默认供应与默认启用不是同一层

Pigsty 4.5 当前默认文档说明:

  • pgvector 随默认 pgsql-main 包集合安装;
  • pg_trgm 位于 pg_default_extensions,默认在数据库的 public schema 启用;
  • pg_stat_statementsauto_explain 等进入默认 preload/观测集合。

这些默认值会随 Pigsty release 演进。目标环境要检查自身 inventory,而不是 从“4.4 默认”反推一个已升级多次的 cluster。

还要注意:若 pg_trgm 已在数据库 public 创建,就不能再在 app_ext 创建第二份同名扩展。自定义 schema 前必须协调 pg_default_extensions,不能让两个声明互相竞争。

参见 Pigsty: Default Extensions

仓库可达性与本地仓库

在线环境可能直接从配置的上游/第三方仓库下载。受限环境通常由 Pigsty infra 节点维护本地软件仓库。无论哪种模式,验证:

inventory requests alias
  -> alias resolves for OS + PG major + architecture
  -> artifact exists in chosen repository snapshot
  -> every PG node installs same build
  -> future restore/new-node path can obtain it

“主库现在有文件”不能证明:

  • 新副本能加入;
  • 灾备站点能恢复;
  • 离线仓库仍保留旧版本;
  • major upgrade 目标包已经可用。

扩展变更单应附 repo snapshot/version,而不只附公共下载 URL。

参见 Pigsty: Download Extensions

节点漂移要按拓扑检查

至少覆盖:

primary
all synchronous/asynchronous standbys
delayed standby
disaster-recovery nodes
backup/restore worker image
future replacement node template

可以用 Pigsty/Ansible 采包事实:

ansible pg-meta -b -a 'pig ext status -c -v 18'

具体 pig 子命令以目标版本为准。更稳妥的检查还包括:

pg_config --version
pg_config --sharedir
pg_config --pkglibdir

对 control/library 取 hash,结果按 host 保存。不要只看 play recap:

ok=...
changed=...

它说明自动化执行状态,不说明数据库能加载文件。

漂移矩阵

host role PG build package build control hash library hash preload live
pg-1 primary
pg-2 replica
pg-3 replica

任一 host 不同,先修供应层,不急着执行数据库 DDL。

14.5.2 声明安装与数据库内 CREATE EXTENSION

三个参数分别回答三个问题

pg_extensions: [pgvector]

pg_libs: 'pg_stat_statements, auto_explain'

pg_databases:
  - name: pg36_shop
    extensions:
      - { name: vector, schema: app_ext }

含义:

参数 问题
pg_extensions cluster 节点要安装哪些扩展软件包
pg_libs server 启动时要 preload 哪些库
pg_databases[].extensions 某数据库要创建哪些 SQL extension

三者不能互换:

  • vector 写进 pg_extensions 会把 SQL 名误作包别名;
  • 只写 pgvector package 不会自动保证每个已有数据库都创建对象;
  • 把不需 preload 的库塞进 pg_libs 会增加启动耦合;
  • 只执行 CREATE EXTENSION,备库节点可能仍缺包。

Pigsty 当前数据库声明示例:

pg_databases:
  - name: meta
    extensions:
      - { name: vector }
      - { name: postgis, schema: public }
      - { name: pg_stat_statements, schema: monitor }

这里用的是 SQL extension name。参见 Pigsty: Create Extensions

本章声明片段

pigsty-declaration.example.yml 刻意写成:

pg_extensions:
  - pgvector

pg_databases:
  - name: pg36_shop
    schemas:
      - { name: app_ext, owner: pg36_owner }
    extensions:
      - { name: vector, schema: app_ext }

它没有重复声明 pg_trgm,因为 stock Pigsty 默认已经在 public 启用。

本地直连实验为了让 namespace 与成员集中可见,把:

pg_trgm + vector -> shop_ch14

放在同一 schema。这是教学 fixture,不要求读者破坏 Pigsty 的合理默认。 平台实践可以是:

pg_trgm -> public (default)
vector  -> app_ext (per-database declaration)

只要 ADR、查询、dump 与权限证据反映真实布局。

声明不应夹带对象版本假设

仓库安装“最新可用包”与数据库对象 VERSION 是两个控制面。Pigsty 初始化 声明能创建扩展,但生产需要单独的 SQL migration:

CREATE EXTENSION vector
WITH SCHEMA app_ext
VERSION '0.8.4';

或:

ALTER EXTENSION vector UPDATE TO 'reviewed-version';

是否在 Pigsty YAML 固定 version 要以该版本参数 schema 和初始化实现为 准;即使声明能写版本,已有数据库升级仍应通过有证据的迁移流程,而不是 假设重新跑初始化会更新。

推荐职责:

Pigsty inventory:
  repository/package/preload/database intent

SQL migration repository:
  exact CREATE/ALTER statements
  owner/schema/ACL
  pre/post catalog and behavior assertions

evidence:
  node + live config + database facts

预加载变更的发布顺序

需要 preload 的扩展:

1. install package on every node
2. update pg_libs/parameters in inventory
3. apply Patroni/PostgreSQL config
4. rolling restart under HA policy
5. verify live setting and logs on every node
6. CREATE EXTENSION in intended databases
7. verify query/metrics/failover

CREATE EXTENSION 再补 preload 可能直接失败;先改 preload 而节点缺库 可能导致 restart 失败。

本章两项扩展不要求 preload,因此声明不应为了“统一”加入它们。最小配置面 也是可靠性。

owner 与 schema

Pigsty 能创建角色、数据库与 schema;扩展 migration 仍要检查最终 owner:

SELECT
    e.extname,
    e.extversion,
    pg_get_userbyid(e.extowner) AS owner,
    n.nspname AS nominal_schema
FROM pg_extension AS e
JOIN pg_namespace AS n
  ON n.oid = e.extnamespace;

对 untrusted extension,管理员创建后可能由高权限角色拥有。不要为了让 应用迁移工具“方便”而把 extension owner 交给 LOGIN 应用角色。

已有 cluster 的收敛

不要只编辑 inventory 后等下一次重建:

review declaration diff
  -> download/sync repository if needed
  -> run package convergence on exact cluster
  -> configure/restart if needed
  -> run database migration
  -> verify all layers
  -> record evidence and commit identity

如果 playbook 只能在部分节点成功,停止数据库对象更新,先修节点一致性。

14.5.3 从监控和日志识别加载失败

一张三层检查表

供应层

pg_config --version
pg_config --sharedir
pg_config --pkglibdir

# 目标文件存在、owner/mode 正确、hash 与基线一致

数据库看到的 control:

SELECT
    name,
    default_version,
    installed_version,
    comment
FROM pg_available_extensions
WHERE name IN ('pg_trgm', 'vector');

若查不到,先看 server 实际 sharedir,不要先查 search_path

进程层

SELECT
    name,
    setting,
    source,
    sourcefile,
    pending_restart
FROM pg_settings
WHERE name = 'shared_preload_libraries';

再看每个实例启动日志:

could not access file ...
could not load library ...
undefined symbol ...
must be loaded via shared_preload_libraries ...

配置声明、Patroni dynamic config、磁盘配置与 live setting 可能暂时不同。 以 live + restart history + log 为准。

数据库层

SELECT
    current_database(),
    e.extname,
    e.extversion,
    pg_get_userbyid(e.extowner),
    n.nspname
FROM pg_extension AS e
JOIN pg_namespace AS n
  ON n.oid = e.extnamespace;

再查:

SELECT * FROM pg_extension_update_paths('pg_trgm');

最后跑真正业务 probe。\dx 只证明 catalog 里有一行,不证明索引有效或 查询正确。

失败模式到动作

观察 解释 安全动作
pg_available_extensions 无记录 当前 server 看不到 control 核对节点/PG major/安装目录
available 有、installed 为空 包在,当前数据库未创建 走审批后的 CREATE EXTENSION
default > installed 支持文件较新、对象仍旧 评审 update path,不自动升级
installed 有、library 缺 节点漂移,failover 风险 阻断晋升,恢复 exact package
pending_restart=true 配置尚未生效 按 HA 策略滚动重启
primary 成功、replica 加载失败 主备供应不一致 停止变更/晋升,修所有副本
must be owner 生命周期权限边界生效 用受控 owner/admin migration
must be superuser untrusted/control 要求 管理员评审,禁止给 app 提权
object already exists schema/历史手工对象冲突 盘点依赖,禁止 CASCADE 硬装
no update path package 脚本图不支持 选择受支持中间版本或迁移方案

监控哪些事实

低基数状态:

extension_expected{cluster,db,name,version}
extension_installed{cluster,db,name,version}
extension_package_parity{cluster,name,build}
extension_preload_live{cluster,instance,name}
extension_probe_success{cluster,db,name}

不要把每个 SQL 对象或 hash 都做成高基数时序标签。详细成员、文件 hash 和 包清单保存在 inventory/evidence;监控只暴露是否与期望一致,并链接 runbook。

事件/日志告警:

  • postmaster 因库加载失败重启;
  • undefined symbol/ABI 错误;
  • extension update DDL 失败;
  • recovery/replica 上 extension function 报错;
  • extension 相关查询错误率突增;
  • ANN/特殊索引 invalid;
  • 更新后 P95/P99、WAL、内存、replica lag 越界。

L1 验证包

本章本地 evidence 明确写 pigsty_l1=not-run。在真实 L1 补齐:

00-inventory-commit.txt
01-repository-snapshot.txt
02-node-package-matrix.csv
03-control-library-hashes.csv
04-pg-settings-all-instances.csv
05-pg-available-versions.csv
06-pg-extension-all-databases.csv
07-member-and-index-catalog.csv
08-query-plan-and-correctness.txt
09-replica/failover-probe.txt
10-clean-restore-report.txt
11-reset-or-rollback-report.txt

每份 evidence 带:

captured_at
cluster/database/host
server and package build
command/tool version
change/commit identity
operator

凭证不得进入 evidence。

Pigsty 管理扩展的停止线

  • inventory 别名无法解析到目标 OS/PG major;
  • 本地仓库没有灾备/新节点需要的包;
  • 任一 replica 包/hash 不一致;
  • preload 变更未完成滚动重启;
  • 只在 postgres 数据库验证,业务数据库未盘点;
  • 默认 pg_trgm 与自定义 schema 声明冲突;
  • playbook 成功但原生 catalog/query probe 失败;
  • 没有 clean restore 与 major-upgrade 路线。

平台自动化可以缩短执行时间,不能降低验收标准。

本节结论

Pigsty 提供的是可声明、可重复的控制面:

package alias + cluster intent + preload + database declaration

PostgreSQL 提供的是 live 数据面事实:

files + settings + pg_extension + members + query behavior

两者一致,扩展才“可用”;再加升级、恢复和退出证据,才“可运营”。


上一节:生命周期与升级耦合 · 返回本章目录 · 下一节:建立可复用扩展 ADR · 查看全书目录 · 查看索引中心

14.6 建立可复用扩展 ADR

ADR(Architecture Decision Record)不是会议纪要,也不是给既定选择补理由。 它要让未来的维护者回答:

当时解决什么问题?
在什么版本和假设下?
比较了哪些替代?
什么证据使结论成立?
哪些风险仍然存在?
何时必须复审或退出?

本章提供 扩展 ADR 模板。模板不是为了 填满十个标题,而是强迫“价值—运行—退出”形成闭环。

14.6.1 问题、候选、假设与成功标准

标题写问题,不先写扩展

较差:

ADR-023: Adopt pgvector

更好:

ADR-023: Semantic nearest-neighbor retrieval for product support corpus

第二种标题允许结论是:

  • 采用 pgvector;
  • 采用另一个 PostgreSQL 扩展;
  • 使用外部服务;
  • 使用精确检索;
  • 现在不做。

候选没有绑架问题。

决策元数据

最小字段:

id: ADR-023
status: proposed
owners:
  product: ...
  application: ...
  database: ...
  platform: ...
created_at: ...
review_at: ...
decision_scope:
  environment: ...
  postgresql: ...
  pigsty: ...
  os_arch: ...

状态只用明确集合:

proposed -> pilot -> accepted
                    -> rejected
accepted/rejected -> superseded by ADR-N

不要把 pilot 当没有期限的半批准。它必须有 traffic/data/environment 边界、 停止标准和截止复审日。

问题陈述

写:

current behavior
observed evidence
business/technical impact
target SLO/quality
in scope
out of scope
do-nothing consequence

示例:

当前标题检索的零结果率为 X;
经标注样本确认 Y% 来自一个字符拼写误差;
目标只覆盖英文产品标题,返回上限 20,P95 < 50 ms;
中文分词、语义相关性和全站文档不在范围;
不改变时影响为 Z。

每个数字链接到 query snapshot、dashboard 或数据集版本。没有证据的假设 单独列:

assumptions:
  - typo distribution remains stable
  - title updates are below ...
  - one cluster can hold index within ...

后续证据推翻假设时自动触发复审。

候选集合

至少包含:

  1. 不做;
  2. PostgreSQL 原生机制;
  3. 候选扩展;
  4. 外部服务/应用实现(若实际可行)。

对每个候选用同一维度:

维度 不做 原生 扩展 A 外部服务
正确性/质量
P95/P99
写入与资源成本
一致性
HA/恢复
升级/供应
权限/安全
退出成本
团队技能/owner

不要把“扩展一行 SQL”与“外部服务完整运维”比较;每格都是完整方案。

成功标准与停止标准成对出现

示例:

success:
  relevance_at_20: ">= 0.82"
  p95_ms: "<= 50"
  p99_ms: "<= 100"
  replica_lag_p95_s: "<= 2"
  clean_restore: pass
  upgrade_rehearsal: pass

stop:
  crash_or_corruption: immediate
  wrong_result: immediate
  p99_ms: "> 200"
  wal_multiplier: "> 3"
  restore_rto: "> agreed budget"
  no_portable_export: reject

“比现在快”不是标准;“无明显问题”不是停止线。

分离硬门槛与权重

某些条件不可用加权分抵消:

hard gates:
  license approved
  target packages available on all nodes
  no correctness regression
  clean restore passes
  exit artifact exists
  security boundary accepted

weighted trade-offs:
  latency
  cost
  operator effort
  feature richness

否则一个非常快但不能恢复的扩展,可能用性能分“赢”过恢复硬门槛。

决策声明

结论写成:

We accept X
for problem Y
in environment/version boundary Z
because evidence A/B/C passed.

We do not approve M/N.
Residual risks are R.
Before production, gates G must pass.
Review is triggered by T.

这比“综合考虑后决定采用”更容易审计。

14.6.2 最小 PoC、风险清单与退出路径

PoC 的最小不是样本最少

最小 PoC 是覆盖决策最关键不确定性的最小实验。它不需要模拟所有生产流量, 但不能只跑 happy path。

扩展通用 PoC:

identity
  server/package/control/library/object versions

install
  intended role success
  unauthorized role failure
  preload/restart if required

behavior
  correctness and representative query
  indexes/plans
  boundary and adverse data

lifecycle
  update path
  update before/after regression
  physical standby/failover
  logical/dump behavior
  clean restore
  major upgrade clone

exit
  portable export
  dependency inventory
  removal without CASCADE

本章本地 PoC 覆盖其中 install、behavior、object update、dump 与文本出口; 没有覆盖 Pigsty L1、备库、clean restore 和 major upgrade,所以 vector 只能是 pilot。

fixture 必须确定性

记录:

  • schema/data version;
  • 生成方式;
  • 随机 seed;
  • 数据规模与分布;
  • query 参数;
  • expected rows/order/error;
  • baseline checksum。

本章不是比较浮点的无限精度,而固定六位小数与 top ID:

trigram scores:
  0.620690,0.305556,0.205128

vector L2:
  0.000000,0.141421,0.282843

对于近似索引,大数据 PoC 应定义 recall tolerance,而不是错误要求每次物理 计划与结果顺序完全相同。

正向、负向、破坏性测试分层

read-only:
  catalog, availability, plan, dependency

reversible DDL in isolated lab:
  CREATE/ALTER/DROP extension

fault injection:
  missing library, wrong preload, failover, crash

destructive lifecycle:
  restore, major upgrade, exit conversion

后两类必须在隔离 clone/L1 进行,有明确 target 与恢复路径。不要为了完成 ADR 在生产主库拔动态库。

本书 lab 的 destructive action 只接管:

pg36_shop/shop_ch14/pg_trgm+vector

并要求 marker、token、target 与无活跃 worker。生产迁移另写,不复用 “删掉重建”脚本。

evidence 不是终端滚屏

每轮输出一个不可变目录:

manifest.txt
package-manifest.txt
available-versions.csv
extension-inventory-before/after.csv
member-catalog-before/after.csv
security-catalog.csv
behavior-before/after.csv
plans
failure stdout/stderr/exit
database-schema.sql
selected-schema.sql
portable-export.csv
verify.txt
review.txt

manifest 包含:

captured_at
target/service
server/tool versions
validation path
source file hashes
proposal checksum

不要写密码、连接 URI secret 或生产个人数据。

风险清单有 owner 与触发器

风险 概率/影响 缓解 观测 owner trigger
package 在新 PG major 缺失 提前构建/替代 release matrix major roadmap
C library crash canary/rollback crash/restart error budget
ANN recall 漂移 golden corpus quality job model/data change
restore 缺旧脚本 repo snapshot restore drill retention review
vendor/license 改变 legal/exit periodic review new terms
node package drift Pigsty convergence parity probe failover/new node

没有 owner 的风险不是被管理,只是被记录。

退出路径从依赖图开始

SELECT
    d.classid::regclass,
    d.objid,
    d.deptype
FROM pg_depend AS d
JOIN pg_extension AS e
  ON e.oid = d.refobjid
WHERE d.refclassid = 'pg_extension'::regclass
  AND e.extname = 'vector';

还要查引用扩展成员的业务对象。退出步骤必须显式:

export/copy
  -> verify
  -> dual representation
  -> switch reads
  -> stop old writes
  -> remove business dependencies
  -> DROP EXTENSION RESTRICT
  -> remove preload/restart
  -> remove packages from nodes/repository only when safe

最后一步不是第一步。包删除前要考虑历史备份与降级节点。

验证退出,而不是只验证导出

退出 PoC 成功条件:

  • 导出行数/主键/checksum 匹配;
  • 目标表示能承载单位、坐标系、模型与精度;
  • 新查询结果和 SLO 在容差内;
  • 旧应用与新 schema 的兼容窗口成立;
  • 无残余 view/function/index/table 依赖;
  • DROP EXTENSION 在不使用 CASCADE 时成功;
  • 包与 preload 清理后实例重启、备库和恢复通过。

14.6.3 结论的版本范围和复审触发器

ADR 是带范围的结论

错误:

pgvector is approved.

可执行:

vector 0.8.4 is approved for a bounded pilot
on upstream PostgreSQL 18.6 / Ubuntu 24.04 amd64 / Pigsty 4.5,
using dimension D and model M,
for corpus C and query shape Q,
under package build B and SLO envelope E.

范围外不是自动拒绝,但必须重新验证。

版本块

scope:
  postgresql:
    implementation: upstream
    versions: ["18.6"]
  pigsty: ["4.4"]
  os_arch: ["ubuntu-24.04-amd64"]
  extension:
    sql_name: vector
    object_version: "0.8.4"
    package_build: "..."
  topology:
    primary: 1
    physical_standby: 2
  workload:
    corpus_version: "..."
    model: "..."
    dimension: 1536
    distance: cosine

“支持 PG14–18”可以是项目宣称;ADR 的验证范围可能只完成 17/18。两者分列。

复审触发器

日历触发:

每 6/12 个月
扩展或 PostgreSQL EOL 前
license/support 合同续签前

变更触发:

  • PostgreSQL major/minor 或内核供应者变化;
  • Pigsty release、OS、CPU architecture 变化;
  • extension project/package/object version 变化;
  • control 的 trusted/preload/requires/relocatable 变化;
  • 数据规模、分布、语言、模型、维度、距离度量变化;
  • 新建/替换 standby、灾备或恢复镜像;
  • SLO、错误预算或容量越界;
  • crash、错误结果、安全通告;
  • 维护者、许可证、供应商或仓库变化;
  • clean restore/upgrade drill 失败;
  • 退出成本估算越过窗口。

不覆盖历史,使用 supersede

决策改变时:

ADR-014 accepted pg_trgm 1.6 in scope X
ADR-028 supersedes ADR-014 for scope Y

保留旧 ADR:

  • 能解释旧备份/旧服务为何依赖它;
  • 能追踪当时证据;
  • 能区分错误决策与条件变化;
  • 能为事故和退出提供历史。

只在原文底部改“现在改用 Z”,会抹掉因果链。

把复审接入变更门禁

自动检查:

inventory package version changed
pg_extension extversion changed
control/library hash changed
server major changed
model/corpus identity changed

若任一发生:

baseline no longer matches
  -> block silent promotion
  -> open review
  -> run scoped test matrix
  -> issue new proposal checksum

不要让监控自动决定架构,但让它阻止“版本已经漂了,ADR 仍显示已批准”。

供第 15–17 章复用

后续三章沿用同一模板,但各自增加领域项:

第 15 章检索

language/tokenizer/dictionary
ranking and relevance corpus
query grammar and denial-of-service boundary
index pending-list/bloat/update cost

第 16 章时空

SRID/coordinate order/unit
geometry validity
spatial selectivity
time zone and temporal range
GIS export format

第 17 章分析与分布式

shard key/co-location
cross-shard transaction
rebalance/failure
columnar/OLAP consistency
capacity crossover point

它们可以增加字段,不能删掉供应、恢复、权限和退出。

ADR 验收问题

评审者逐句问:

  • 问题是否在没有候选扩展名时仍成立?
  • 是否有“不做”和原生替代?
  • 成功/停止标准是否能机器或人工复验?
  • 是否写了 exact server/package/object 版本?
  • 是否测过未授权失败?
  • 是否覆盖备库、clean restore 和 major upgrade?
  • 自定义数据能否导出,退出是否不用 CASCADE
  • 残余风险是否有 owner?
  • 哪个变化会让结论失效?
  • evidence 能否由另一位工程师重跑?

任一回答“以后再补”,ADR 状态最多是 proposed/pilot。

本节结论

好的扩展 ADR 不是“为什么喜欢它”,而是一个可撤销承诺:

under these facts,
for this problem,
this option passes these gates,
with these residual risks,
until one of these triggers changes.

它让采用扩展成为受控工程选择,而不是永久信仰。


上一节:用 Pigsty 管理扩展可用性 · 返回本章目录 · 下一节:实战:评审三个候选扩展 · 查看全书目录 · 查看索引中心

14.7 实战:评审三个候选扩展

本节把前六节压成一个 release proposal:

three problems
  -> three decisions
  -> package/control evidence
  -> privilege boundaries
  -> member/index/query evidence
  -> one object upgrade
  -> dump and portable exit
  -> exact reset and rebuild

它不是扩展性能评测,也不是生产安装脚本。实验的价值是证明评审结构能够 运行、失败、复位和重复。

环境与破坏边界

正式 evidence 来自 Homebrew PostgreSQL 18.6 直连服务,未在 Pigsty L1 运行。task.sh all 会精确删除并重建带本章 marker 的 shop_ch14pg_trgmvector,只适合本地/开发数据库。生产变更不得运行这一 “删后重建”入口。

14.7.1 一个接受、一个试点、一个拒绝

问题 A:有界单字段拼写容错

候选 pg_trgm

问题边界:

field: one title text column
query: typo-tolerant lookup
fixture typo: "PostgreSQL extenson"
result limit: 3
not in scope: language segmentation, semantic ranking, document search

原生替代:

  • 精确 B-tree;
  • 规范化前缀搜索;
  • PostgreSQL FTS;
  • 应用侧拼写纠正。

采用理由:

  • PostgreSQL contrib 扩展;
  • 当前 control 为 trusted/relocatable;
  • 不改变 title text 类型;
  • GIN gin_trgm_ops 可由目录与计划验证;
  • 可以先切回精确/FTS,再删 GIN 和扩展;
  • 1.3 → 1.6 更新路径与行为回归可重复。

结论:

accept pg_trgm
only for bounded fuzzy matching

不是批准它替代第 15 章的全部检索设计。

问题 B:语义近邻检索

候选 vector(项目/包别名常为 pgvector)。

本地 PoC:

type: vector(3)
distance: L2
index: HNSW vector_l2_ops
query vector: [1,0,0]
top ids: 1,2,5

已证明:

  • control/安装 SQL/动态库存在并有 hash;
  • trusted=false,非超级用户创建以 42501 失败;
  • 管理员能在目标 schema 创建 0.8.4;
  • 表、类型、HNSW opclass/index 有目录证据;
  • 应用角色可查询但没有表写权限;
  • embedding::text 可导出五行;
  • 全库 dump 用 CREATE EXTENSION vector 表示成员。

未证明:

  • 真实 embedding model、dimension 与 normalization;
  • 真实语料 relevance/recall;
  • 过滤组合下 ANN 行为;
  • 索引 build、内存、磁盘、WAL 与更新成本;
  • 并发 P95/P99;
  • 物理备库/failover;
  • clean restore;
  • PostgreSQL major upgrade;
  • 在 Pigsty L1 所有节点的包一致性。

结论:

pilot vector 0.8.4
bounded to an isolated workload and evidence plan

任何真实业务接入前必须补上第 15 章的质量语料与 L1 生命周期证据。

问题 C:分布式分片

候选 Citus。

当前事实:

no measured single-cluster capacity breach
no shard-key contract
no co-location model
no cross-shard transaction budget
no rebalance/failure test

本地 Homebrew server 的 pg_available_extensions 也没有 Citus,但这不是 拒绝的主要理由。Pigsty 当前扩展目录提供 Citus 14.0.0;平台有包仍不能替 架构证明问题。

当前替代:

  • 修正查询与索引;
  • 生命周期/归档治理;
  • PostgreSQL declarative partitioning;
  • 垂直扩容;
  • 读副本或分析副本;
  • 到第 17 章测量单集群容量边界。

重新打开 ADR 的条件:

measured capacity/SLO crossover
  + stable distribution key
  + transaction and uniqueness model
  + rebalance/failure/backup plan

结论:

reject Citus now

拒绝的是当前采用时机,不是产品评价。

把结论写进数据库

setup.sql 建立:

CREATE TABLE shop_ch14.extension_review (
    candidate text PRIMARY KEY,
    extension_name text NOT NULL,
    package_alias text NOT NULL,
    decision text NOT NULL
        CHECK (decision IN ('accept', 'pilot', 'reject')),
    problem text NOT NULL,
    success_criterion text NOT NULL,
    exit_path text NOT NULL,
    review_trigger text NOT NULL,
    reviewed_on date NOT NULL
);

最终必须精确得到:

citus:reject,pg_trgm:accept,vector:pilot

把 ADR 行放进实验数据库不是建议生产数据库存文档;它使 fixture checksum 同时覆盖数据与决策,防止测试脚本与文字结论分叉。

最小架构

shop_ch14
├── extension_review
├── candidate_doc
│   ├── title text
│   └── embedding vector(3)
├── pg_trgm 1.3 -> 1.6
│   └── GIN gin_trgm_ops
└── vector 0.8.4
    └── HNSW vector_l2_ops

所有 schema、扩展与非成员 relation/index 带 marker:

pg36 ch14 extension lifecycle lab; safe to rebuild

扩展成员通过 pg_depend.deptype='e' 识别,不要求逐个添加 comment。

14.7.2 在 L1 安装并验证原生对象与平台状态

标题中的 L1 是目标运行形态,不是本地证据伪装。流程分两步:

  1. 在受控直连 PostgreSQL 完成机制 fixture;
  2. 把同一合同移植到 Pigsty L1,补齐节点、HA 与恢复证据。

1. 准备 libpq service

[pg36-admin]
host=/path/to/socket-or-host
port=5432
dbname=pg36_shop
user=postgres
export PGSERVICEFILE=/path/to/pg_service.conf
export PGSERVICE=pg36-admin

service 文件权限收窄,不在命令行或 evidence 打印密码。

context guard 要求:

database=pg36_shop
writable primary/direct PostgreSQL
server major=14..18
session superuser=true
can SET ROLE pg36_owner
ch04-v1 model exists
pg36_app is constrained LOGIN
pg_trgm 1.3 and 1.6 support files available
vector 0.8.4 support files available

版本不符时脚本拒绝;读者应复制 proposal、更新版本与 golden 后重新评审, 不应删掉 guard。

2. 分阶段入口

./static/labs/ch14/task.sh setup
./static/labs/ch14/task.sh inventory
./static/labs/ch14/task.sh upgrade
./static/labs/ch14/task.sh dump

每个会精确重建 fixture,适合单独教学。最终只认:

PG36_EVIDENCE_DIR="$PWD/evidence/ch14" \
  ./static/labs/ch14/task.sh all

3. 先验证支持文件

package-manifest.txt 记录:

pg_config path/version
server major
sharedir/pkglibdir
validation_path=direct-postgresql
pigsty_l1=not-run

并对:

pg_trgm.control
pg_trgm--1.3.sql
pg_trgm--1.3--1.4.sql
pg_trgm--1.4--1.5.sql
pg_trgm--1.5--1.6.sql
pg_trgm.dylib/.so
vector.control
vector--0.8.4.sql
vector.dylib/.so

生成 SHA-256。

脚本先比较 pg_config major 与 live server major。PATH 指向错误 PG 安装时 立即失败,不会拿另一套支持文件做出“可用”结论。

在 Pigsty L1,这份清单要按所有主备 host 展开,而不是只在 primary 生成。

4. 碰撞保护与 trusted 安装

setup.sql 若发现:

  • shop_ch14 marker/owner 不符;
  • pg_trgmvector 已位于别的 schema;
  • extension marker、owner 或版本不在允许集合;
  • schema 中有未知非 extension relation/routine/type/operator/opclass;

就拒绝重建。

随后:

SET ROLE pg36_owner;

CREATE SCHEMA shop_ch14 AUTHORIZATION pg36_owner;

CREATE EXTENSION pg_trgm
  WITH SCHEMA shop_ch14
  VERSION '1.3';

RESET ROLE;

结果:

pg_trgm_owner=pg36_owner
pg_trgm_version=1.3
vector_installed=false

这证明 trusted 规则与数据库 owner 权限,不表示 pg36_owner 是超级用户。

5. 注入预期特权失败

owner-create-vector.sql

SET ROLE pg36_owner;
CREATE EXTENSION vector
  WITH SCHEMA shop_ch14
  VERSION '0.8.4';

必须:

psql exit=3
SQLSTATE=42501
Must be superuser to create this extension

若它意外成功,说明 control/权限环境与 proposal 不同,review 失败,而不是 把差异忽略。

管理员再执行 install-vector.sql

CREATE EXTENSION vector
  WITH SCHEMA shop_ch14
  VERSION '0.8.4';

并由 owner 建表:

CREATE TABLE shop_ch14.candidate_doc (
    doc_id bigint PRIMARY KEY,
    title text NOT NULL,
    embedding shop_ch14.vector(3) NOT NULL
);

6. 建立两个可验证索引

CREATE INDEX candidate_doc_title_trgm_idx
ON shop_ch14.candidate_doc
USING gin (title shop_ch14.gin_trgm_ops);

CREATE INDEX candidate_doc_embedding_hnsw_idx
ON shop_ch14.candidate_doc
USING hnsw (embedding shop_ch14.vector_l2_ops)
WITH (m = 8, ef_construction = 32);

目录验收:

index AM opclass valid/ready/live
candidate_doc_title_trgm_idx gin shop_ch14.gin_trgm_ops true/true/true
candidate_doc_embedding_hnsw_idx hnsw shop_ch14.vector_l2_ops true/true/true

CREATE INDEX 成功还不够;检查 pg_indexpg_ampg_opclass,防止 名字相同但实现漂移。

7. 采集扩展与成员目录

extension-inventory.sql

name
object version
owner
nominal schema
relocatable
superuser/trusted/requires
member count
marker

更新前 PostgreSQL 18.6:

pg_trgm  1.3    owner=pg36_owner  trusted=t  members=37
vector   0.8.4  owner=postgres    trusted=f  members=237

member-catalog.sql 再按 catalog 分解:

pg_trgm:
  pg_opclass, pg_operator, pg_opfamily, pg_proc, pg_type

vector:
  pg_am, pg_cast, pg_opclass, pg_operator,
  pg_opfamily, pg_proc, pg_type

更新后 pg_trgm 成员为 47。数量只冻结本次 PG18.6 build;其他 major 可有 条件差异。

8. 权限矩阵

pg36_app

USAGE shop_ch14       = true
SELECT review/docs    = true
INSERT/UPDATE/DELETE  = false
extension owner       = false

app-query.sql 成功使用函数、操作符与类型;随后:

ALTER EXTENSION pg_trgm UPDATE TO '1.6';

必须:

SQLSTATE 42501
must be owner of extension pg_trgm

应用使用能力与扩展管理权被分离。

9. 行为 baseline

模糊检索:

SELECT
    doc_id,
    round(
      shop_ch14.similarity(
        title,
        'PostgreSQL extenson'
      )::numeric,
      6
    ) AS score
FROM shop_ch14.candidate_doc
ORDER BY score DESC, doc_id
LIMIT 3;

结果:

1  0.620690
5  0.305556
2  0.205128

向量检索:

SELECT
    doc_id,
    round(
      (
        embedding
        OPERATOR(shop_ch14.<->)
        '[1,0,0]'::shop_ch14.vector(3)
      )::numeric,
      6
    ) AS distance
FROM shop_ch14.candidate_doc
ORDER BY
    embedding
      OPERATOR(shop_ch14.<->)
      '[1,0,0]'::shop_ch14.vector(3),
    doc_id
LIMIT 3;

结果:

1  0.000000
2  0.141421
5  0.282843

10. 索引计划

五行表优化器自然可能选择 seq scan。实验:

SET enable_seqscan = off;

只用于证明索引路径存在,不用于性能结论。

trigram:

Bitmap Index Scan on candidate_doc_title_trgm_idx

vector:

Index Scan using candidate_doc_embedding_hnsw_idx

生产验收应恢复默认 planner 配置,用真实数据比较:

EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS)

并检查结果质量;不能用 enable_seqscan=off 证明索引值得使用。

11. 更新 1.3 → 1.6

update-paths.sql 先证明:

1.3--1.4--1.5--1.6

upgrade.sql 要求 source 精确为 1.3:

SET ROLE pg36_owner;
ALTER EXTENSION pg_trgm UPDATE TO '1.6';
RESET ROLE;

若 source 已经变化,返回本章自定义 P3640,不猜迁移路径。

更新后重新采集:

  • available version installed flag;
  • extension/member catalog;
  • index validity/opclass;
  • ACL;
  • 两个查询;
  • 两个计划。

pg_trgm_version 与成员清单外,行为 golden 不变。

12. dump 的正反例

全库:

pg_dump \
  --schema-only \
  --no-owner \
  --no-privileges \
  --dbname='service=pg36-admin' \
  > database-schema.sql

必须包含:

CREATE EXTENSION ... pg_trgm
CREATE EXTENSION ... vector

且不展开 shop_ch14 扩展成员函数/类型。

选择性 schema:

pg_dump \
  --schema-only \
  --schema=shop_ch14 \
  --no-owner \
  --no-privileges \
  --dbname='service=pg36-admin' \
  > selected-schema.sql

它包含应用表和索引,却没有 CREATE EXTENSION。review 把这个“不完整依赖” 作为预期证据,提醒恢复 runbook 先供应并创建扩展。

13. portable exit

portable-export.sql

doc_id,title,embedding_text
1,PostgreSQL extension guide,"[1,0,0]"
...

review 要求:

  • header 精确;
  • 五个主键按 1..5;
  • 每个 embedding 是 bracketed text。

生产退出还需导入目标、语义比对和删依赖;本章只证明可携带 representation。

14. 最终不变量

final-state.sql

review_rows=3
document_rows=5
pg_trgm_version=1.6
vector_version=0.8.4
pg_trgm_members=47
vector_members=237
trigram_top_ids=1,5,2
vector_top_ids=1,2,5
business_checksum=5398634500fe53ba1fb683e9a2c6e745

checksum 包含:

  • 三行 ADR 内容;
  • 五行文档/向量文本;
  • extension name/version/schema/relocatable。

它不包含管理员用户名,避免换一个受控超级用户就改变业务 golden。

15. 精确复位

手工入口:

PG36_RESET_TOKEN=RESET_CH14_EXTENSION_LAB \
PG36_RESET_TARGET='pg36_shop/shop_ch14/pg_trgm+vector' \
  ./static/labs/ch14/task.sh reset

reset.sql 在删除前验证:

  • database、writable instance、server 与角色;
  • token/target;
  • schema marker/owner;
  • extension name/version/schema/owner/marker;
  • 非成员 relation/type/routine/operator/opclass 白名单;
  • 没有 pg36-ch14-* 活跃 worker。

删除顺序:

DROP TABLE shop_ch14.candidate_doc;
DROP TABLE shop_ch14.extension_review;
DROP EXTENSION vector;
DROP EXTENSION pg_trgm;
DROP SCHEMA shop_ch14;

没有 CASCADE。若仍有未知业务依赖,DROP EXTENSION 失败并暴露它。

all 还注入:

wrong token  -> P3650
wrong target -> P3651
active worker -> P3653

拒绝后才精确复位,再完整重建第二遍。最终环境保留通过验收的 fixture。

16. 移植到 Pigsty L1

先审查 Pigsty 声明片段

pg_extensions:
  - pgvector

pg_databases:
  - name: pg36_shop
    schemas:
      - { name: app_ext, owner: pg36_owner }
    extensions:
      - { name: vector, schema: app_ext }

stock Pigsty 默认把 pg_trgm 启用在 public,无需与本地 shop_ch14 布局 完全相同。

L1 执行顺序:

review inventory diff
  -> verify repo/alias availability for exact PG/OS/arch
  -> install package on all nodes
  -> hash control/SQL/library on all nodes
  -> verify no preload requirement for these exact versions
  -> create extension through reviewed database migration
  -> query catalog/member/index/ACL
  -> run behavior and negative tests
  -> test replica query and controlled switchover
  -> clean restore to fresh L1/clone
  -> attach evidence to a new proposal

L1 不应强行复用本地 proposal checksum,因为:

  • schema 布局可能不同;
  • package build/OS 不同;
  • owner 名或 default extension state 不同;
  • 应补主备/restore evidence。

复制 ADR 结构,生成属于目标 L1 的新 baseline。

14.7.3 产出供 ch15–ch17 复用的 ADR 模板

交付包

本章交付不是一张“推荐扩展”表,而是:

candidate-review.md
extension-adr-template.md
baseline-v1.2-proposal.json
pigsty-declaration.example.yml
lab-contract.md
SQL/Bash/Python executable evidence chain

candidate-review.md 记录三项结论; extension-adr-template.md 提供十段结构:

  1. 决策元数据;
  2. 问题与边界;
  3. 候选与原生替代;
  4. 成功与停止标准;
  5. 供应链与运行条件;
  6. 数据与兼容性;
  7. 安全与治理;
  8. 最小 PoC;
  9. 退出路径;
  10. 结论。

第 15 章:检索候选如何复用

继承通用字段,再增加:

language/tokenizer/dictionary/config identity
query grammar
ranking formula
golden relevance corpus
GIN/GiST/RUM/other index behavior
write/pending-list/bloat cost
adversarial query boundary

pg_trgm 的 accept 不能自动批准所有字段。每个字段/查询形态仍需索引与 相关性 ADR。

vector 的 pilot 进入第 15 章后,要补:

embedding model/version
dimension
normalization
distance metric
exact-vs-ANN control
recall@k
filter selectivity
HNSW/IVFFlat build/update/maintenance

第 16 章:时空候选如何复用

增加:

SRID
coordinate order and units
geometry/geography choice
validity and precision
spatial predicate semantics
temporal interval/time zone
GiST/SP-GiST/BRIN behavior
WKT/WKB/GeoJSON export

PostGIS 若被采用,自定义类型的 restore/exit 门槛不能因为生态成熟而省略。

第 17 章:分析与分布式候选如何复用

增加:

single-node measured ceiling
shard/distribution key
co-location
global uniqueness/FK
cross-shard transaction
rebalance
node failure
DDL propagation
backup/restore and topology exit

Citus 只有在这些字段有证据后才从 reject 重新进入 proposed;“Pigsty 有包” 不是触发批准。

自动审校器检查什么

review.py 不比较终端输出的外观,而比较关系:

manifest proposal identity
package support-file hashes
two exact SQLSTATE 42501 failures
three candidate decisions and availability
before/after extversion
trusted/owner/schema/member relationships
update path
index AM/opclass/validity
least-privilege matrix
query results stable across update
forced index paths present
full dump vs selective dump semantics
portable export shape
final checksum
no-CASCADE reset source

关系式 review 比“命令 exit 0”更接近发布验收。

审校结果

正式两轮输出:

status=ok
decision=pg_trgm:accept/vector:pilot/citus:reject
boundary=package+control+database-object
failure=42501-owner+42501-superuser
upgrade=pg_trgm:1.3->1.6-behavior-stable
index=gin+hnsw
dump=create-extension+selective-dependency-warning
exit=portable-text-export
pigsty_l1=not-run
release=1.2-proposal
release_candidate_checksum=6a4b74baec5f522eb098c868f1d4f1b441bf5b5f6708411588af0a8793f7f573

第一轮通过后,脚本证明复位 guard,再删除并重建,第二轮得到同一关系和 proposal identity。

哪些结论可以带走

可以:

  • 扩展要同时管理供应、进程和数据库三层;
  • trusted/untrusted 与 owner 边界必须负面测试;
  • package version 与 extversion 分开;
  • update 前后比较 catalog、行为和计划;
  • dump 不携带支持文件,选择性 dump 不保证依赖闭包;
  • 自定义类型采用前先定义交换格式;
  • Pigsty 声明后回到原生证据;
  • ADR 允许 accept/pilot/reject,而不是所有候选二选一。

不能:

  • pg_trgm 对所有搜索都足够;
  • pgvector 0.8.4 已通过生产验证;
  • Citus 不值得使用;
  • PostgreSQL 14–17 会得到相同成员数;
  • Homebrew 文件 hash 能代表 Pigsty 包;
  • 五行查询速度能代表生产性能。

能清楚说出“实验没有证明什么”,是扩展治理成熟度的一部分。

本章最终检查

完成本章后,面对新扩展先写:

problem
native alternative
success/stop
data/exit
lifecycle
maintenance/license
privilege/supply
version scope
review triggers

然后才写:

CREATE EXTENSION ...

顺序反过来,数据库很快会积累一组谁也不敢升级、恢复或删除的隐性平台。


上一节:建立可复用扩展 ADR · 返回本章目录 · 下一章:见微知著:全文、模糊与向量检索 · 查看全书目录 · 查看索引中心

15 见微知著:全文、模糊与向量检索

搜索不是“给某一列加个索引”。它至少包含四个问题:

哪些对象有资格出现?        filtering
从多少对象中找候选?        candidate generation
候选之间怎样比较先后?      ranking
怎样证明结果真的更好?      evaluation

全文检索、三元组模糊匹配和向量近邻解决的是不同子问题:

方法 最擅长捕捉 主要盲区
PostgreSQL FTS 词形归一、多词布尔/短语、字段权重 拼写错误、词表之外的语义表达
pg_trgm 字符串局部相似、错拼、包含与相似候选 业务语义、长文档相关性
向量检索 由模型编码的相似性 精确词优势、模型偏差、近似召回
混合检索 多路召回互补 参数、成本、解释与验证复杂度

它们可以组合,却不能因为“混合”两个字就自动更好。本章把检索从演示查询 变成一个可反驳的工程实验:

冻结语料、查询、相关性标注和模型身份;每一路独立产生候选;质量黄金值 使用精确计算;近似索引另测召回;最后才讨论延迟、部署与上线。

本章完成后

你应当能够:

  • 区分精确过滤、词法相关、字符串相似与模型相似;
  • 把查询解析、过滤、候选生成、排序、融合和重排拆成独立阶段;
  • 为检索实验建立带版本的语料、查询集与分级相关性标注;
  • 解释 parser、dictionary、configuration、tsvectortsquery
  • 显式固定文本搜索配置,构造带 A/B 权重的存储生成列;
  • 正确选择 websearch_to_tsqueryplainto_tsqueryphraseto_tsqueryto_tsquery,而不把原始用户输入直接交给严格语法;
  • ts_rank_cd 排序,并知道 0–1 归一化不等于跨检索器可比概率;
  • 解释 similarityword_similarity% 门槛与 GIN/GiST 的边界;
  • 选择 L2、余弦或内积时,把模型训练与归一化合同写清楚;
  • 区分精确近邻与 HNSW/IVFFlat 近似近邻,不用 ANN 结果定义质量黄金值;
  • 解释过滤为何可能降低 ANN 返回数,以及 iterative scan 能补什么、不能 保证什么;
  • 用 RRF 合并不同分数尺度的排名,并知道何时需要校准或重排;
  • 计算 Precision@K、Recall@K、MRR@K 与 NDCG@K;
  • 在 Pigsty 中把扩展装包、数据库启用、版本核对和节点一致性分开验收;
  • 把模型费用、数据出境、向量回填、索引维护、WAL 与副本延迟纳入 ADR;
  • 交付一个可运行、可审校、可精确复位的检索 PoC。

一条有意不完美的实验

贯穿实验位于 shop_ch15,固定:

17 products (16 active + 1 inactive guard)
8 queries
24 graded relevance judgments
4-dimensional handcrafted vectors
3 candidate generators
RRF k=60, source depth=4, result depth=3

四维向量的模型标识为:

pg36-handcrafted-topic-4d-v1

它不是 embedding 模型,也没有调用任何外部 API。它只是可人工核算、每次 重建完全相同的主题坐标,用来隔离数据库机制与模型随机性。真实模型质量必须 用真实文本重新测,不能继承本章数字。

固定评估集得到:

策略 Precision@3 Recall@3 MRR@3 mean NDCG@3 min NDCG@3
全文 0.291667 0.291667 0.750000 0.613043 0.000000
模糊 0.916667 0.916667 1.000000 0.942881 0.842828
精确向量 1.000000 1.000000 1.000000 0.817314 0.631039
RRF 混合 1.000000 1.000000 1.000000 0.962929 0.759192

结果故意保留三个反例:

  1. wireles hedphonespostgre databse tuning 的全文结果为空,说明 词形归一不是拼写纠正;
  2. 精确向量覆盖全部相关对象,但次序不总正确,Recall 高不等于 NDCG 高;
  3. q02 上混合 NDCG 为 0.759192,低于纯模糊的 0.842828,说明 融合可以把弱信号带进来。

因此本章的结论不是“向量最好”或“混合必胜”,而是:

the measured winner on this frozen set
is a release candidate for this frozen set

精确质量与近似服务路径分开

质量视图枚举过滤后的全部候选并计算精确 L2 距离,不经过 HNSW。HNSW 只在 独立 probe 中运行:

query = q06 / trail hydration
exact top-3 = 7,8,9
HNSW top-3  = 7,8,9
recall@3    = 1.000000

这一个点证明“能够查询并比较”,不证明生产召回为 1。pgvector 官方说明, 默认无 ANN 索引时是精确搜索;HNSW/IVFFlat 以部分召回换取速度,生产应持续 拿近似结果与精确结果对照。参见 pgvector 0.8.4 README

强制执行计划分别验证:

FTS      -> GIN bitmap index scan
trigram  -> GIN bitmap index scan
exact    -> sequential scan + sort
HNSW     -> HNSW index scan
filtered -> HNSW index scan + active/category filter

这里关闭部分 planner 路径,只为证明索引可用;不是 17 行数据上的性能比较。

实验资产

规范与决策:

输入与实现:

三份 CSV 不只是仓库附件。自动化会从数据库重新导出并执行逐字节比较, fixture-manifest.json 还固定各文件 SHA-256、行数、模型身份、生成方法与 许可证边界。

快速运行

本章复用第 14 章已经安装和认证的扩展,不自行接管扩展生命周期:

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

PG36_EVIDENCE_DIR="$PWD/evidence/ch15" \
  ./static/labs/ch15/task.sh all

all 会:

  1. 验证目标数据库、角色、ch04-v1 模型和第 14 章扩展身份;
  2. 以 owner/marker/对象白名单保护 shop_ch15
  3. 创建带权重的存储 tsvector、四张表、七个排名/质量视图;
  4. 创建全文 GIN、标题 trigram GIN、向量 HNSW 与过滤 B-tree;
  5. 从数据库导出三份 CSV 并与冻结输入逐字节比较;
  6. 采集文档、索引、体积、权限、查询解析、排名和质量证据;
  7. 采集精确计划与三类索引计划;
  8. 以精确集合为真值测一次 HNSW Recall@3;
  9. 证明 pg36_app 可读、更新返回 SQLSTATE 42501
  10. 运行关系、生成列、过滤、质量、checksum 和权限的全量断言;
  11. 证明错误 token、错误 target、活跃 worker 时 reset 分别被拒绝;
  12. 不用 CASCADE 精确复位,确认第 14 章扩展保留,再完整重建复验。

正式 Homebrew PostgreSQL 18.6 证据两轮均得到:

status=ok
fixture=frozen-byte-identical
quality=precision+recall+mrr+ndcg
ranking=fts+trigram+exact-vector+rrf
ann=q06-exact-vs-hnsw-recall-1.000000
guards=P3660+P3661+P3663
extensions=ch14-preserved
pigsty_l1=not-run
release_candidate_checksum=bf92a6ad0f60dc3e125b39dbf67bf4d6c5e50275192bd01a7ca4c50d142f822e

安全边界

task.sh all 会删除并重建带本章精确 marker 的 shop_ch15,只适合 本书本地/开发夹具。它不会删除扩展。生产上应以在线建索引、双写/回填、 灰度读流量和可回退发布替代“删后重建”。

学习路径

15.1 先定义检索任务与评估集

先固定“什么叫好”。没有评估集,后面的任何好看查询都只是故事。

15.2 PostgreSQL 全文检索

从 PostgreSQL 原生词法管线开始,理解它为何强,也理解错拼为何仍会漏。

15.3 模糊匹配与拼写容错

用字符三元组补足错拼,但不给零分 Top-K 贴上“相关”标签。

15.4 可复现的向量检索

把向量看作有来源、有版本、有度量合同的数据,而不是神秘的“AI 列”。

15.5 混合检索与排序验证

学会合并名次、计算指标、解释反例,而不是拿一个查询挑选截图。

15.6 扩展部署与运行代价

把 PostgreSQL 对象映射到 Pigsty 交付和长期运行,不把安装成功当作上线。

15.7 实战:pg36_shop 商品混合检索 PoC

最后运行双周期实验,拿出可以评审、可以拒绝、也可以退出的 proposal。

版本与证据边界

本章原理覆盖 PostgreSQL 14–18;可执行 baseline 固定 pg_trgm 1.6、 vector 0.8.4,并在 PostgreSQL 18.6 上验证。实验使用英语配置,因为冻结 语料是英语。中文、多语言与混合字段不能照抄 english,应重新选择 tokenizer/ dictionary、语料和指标。

Pigsty 映射按 4.4 文档在 2026-07-29 核验。本地证据路径是直接 PostgreSQL, 没有冒充 Pigsty L1。当前 Pigsty 文档把 pgvector 列为默认安装包,把 pg_trgm 列为默认启用扩展;实际环境仍要检查目标版本、镜像/仓库和每个 主备节点。

权威入口:


上一章:博采众长:内核分支与扩展生态 · 返回上卷导读 · 下一章:经天纬地:时序、空间与时空查询 · 查看全书目录 · 查看索引中心

15.1 先定义检索任务与评估集

如果没有“哪些结果算相关”的外部判断,搜索系统只能证明自己能够执行。 返回三行、命中一个熟悉商品、甚至计划使用了索引,都不等于检索质量好。

本节先冻结问题。第 15.2–15.5 节才允许比较实现。

15.1.1 精确筛选、词法相关与语义相似

先问是在判断资格,还是比较相关

下面两个 SQL 看起来都在“找商品”,合同完全不同:

-- 布尔资格:结果要么符合,要么不符合
SELECT product_id, title
FROM product
WHERE active
  AND category = 'outdoor'
  AND price < 500;

-- 排名:每个候选都有先后
SELECT product_id, title
FROM product
WHERE active
  AND category = 'outdoor'
ORDER BY relevance_score DESC, product_id
LIMIT 10;

前者是 filtering。正确性来自布尔谓词、约束、权限和事务快照;B-tree、 hash、BRIN 或分区裁剪可能让它更快,但不会改变逻辑答案。

后者是 retrieval/ranking。正确性至少包含:

  • 相关对象是否进入候选集;
  • 不相关对象是否被抑制;
  • 最相关对象是否排得足够靠前;
  • 同分时结果是否稳定;
  • 过滤是否在正确阶段生效;
  • 结果是否符合当前用户的权限与业务状态。

不要把两个问题压成一个“不透明相关性 SQL”。先用可验证谓词确定资格,再在 合格集合中比较相关性,最容易推理。

四种相似不是一回事

以商品检索为例:

输入与目标 更接近的能力 例子
SKU、状态、类别完全相等 精确过滤 sku = 'AUD-001'
词形、布尔词、短语与字段权重 全文词法检索 headphones 匹配词位
字符局部重合与错拼 trigram 模糊匹配 wireles hedphones
模型编码的意图相近 向量相似 music on the go

词法相关不等于字符串包含。PostgreSQL FTS 会把文本解析为 token,再经 词典归一为 lexeme;英语配置能把 rats 归一成 rat,也可能去掉 stop word。它支持 AND、OR、NOT、短语和位置,适合“文档含有哪些有意义的词”。

字符串相似不等于语言含义。pg_trgm 按三个连续字符的集合衡量重合, 所以对局部拼写错误很有效;databasedatabse 很近,但字符很近并不 保证商品意图相同。

向量相似也不是数据库自动理解语义。数据库只看一串数和一个距离函数; “这串数表达什么”来自模型、输入模板、截断、版本和归一化。手工坐标也能 做向量近邻,但不能称为训练模型的语义质量证据。

因此“语义检索”至少应展开为:

model identity
+ input construction
+ preprocessing/tokenization
+ vector dimension
+ normalization
+ distance/operator
+ relevance evaluation

缺任意一项,未来都很难解释同一文本为何得到不同结果。

把任务写成合同

一份可执行任务定义至少回答:

document_unit: one product row
searchable_fields: [title, description]
eligible_if:
  active: true
  category: query.category_filter
query_language: English
top_k: 3
tie_breaker: product_id ascending
relevance_scale: 0..3
latency_scope: not established by this fixture

本章没有把价格、库存、租户或行级权限塞进夹具,但生产合同不能省略。尤其是 租户与 ACL:一个相关性极高却无权读取的对象不是“排名较低”,而是根本不能 进入候选集合。

先写反例

本章八个查询不是八种同义表达,而是有意覆盖失败模式:

查询 想观察什么
wireless headphones 精确商品词
wireles hedphones 两处错拼
music on the go 描述性意图
coffee bean grinder 标题与描述共同提供词
make espresso at home 任务表达
trail hydration 类别过滤与多候选
postgre databse tuning 技术词错拼
semantic nearest neighbor 长尾精确术语

如果评估集只有实现者已经看过的成功查询,它只会确认实现者的直觉。先把必然 失败的查询写进去,后续比较才有信息量。

15.1.2 查询、候选、排序和过滤的分层

一条检索链有六个阶段

raw request
  -> query parsing / normalization
  -> hard filters and authorization
  -> candidate generation
  -> per-source ranking
  -> fusion / re-ranking
  -> top-K response and evidence

每一层有不同的失败:

阶段 典型失败
查询解析 非法语法、空 query、极长输入、语言选错
硬过滤 跨租户泄漏、停用对象复活、过滤放到 ANN 之后导致不足 K
候选生成 FTS 零召回、模糊门槛太低、ANN 漏召回
单路排序 字段权重错、距离函数错、同分漂移
融合/重排 分数尺度乱加、弱源挤掉强源、重排器超时
响应 无稳定游标、证据不可解释、缓存越权

分层不是为了制造更多组件,而是为了让每个结论可以单独测。

查询解析必须显式

生产接口通常接收 raw text。不要让客户端偷偷决定它是:

  • 所有词必须出现;
  • 任一词出现;
  • 完整短语;
  • 支持引号与排除词;
  • 前缀查询;
  • 还是严格 tsquery 表达式。

本章显式使用:

websearch_to_tsquery(
  'pg_catalog.english'::regconfig,
  raw_query
)

它接受普通 web 风格文本、引号、OR 和减号排除,并且官方文档说明不会因 输入语法抛错。这降低了语法层事故,但不代表可以无限接受输入:长度、token 数、超时、并发和滥用仍要在接口层设限。

先过滤还是先 ANN,要写出物理含义

逻辑目标是:

WHERE active
  AND category = 'outdoor'
ORDER BY embedding <-> :query_vector
LIMIT 3;

精确执行可以枚举所有合格行再排序。近似索引通常先沿图或倒排结构取候选, 然后应用 PostgreSQL 过滤;当过滤选择性高时,扫描出的近邻大多被过滤掉, 最终可能不足三行。

所以“SQL 把 WHERE 写在 ORDER BY 前面”不证明物理上先过滤。要看:

  • 执行计划;
  • 过滤选择性;
  • 返回行数;
  • ANN 与 exact 的集合差异;
  • ef_search、iterative scan、partial index 或 partition 策略。

本章的 HNSW filtered plan 明确显示:

Index Scan using product_search_embedding_hnsw_idx
  Filter: (active AND category = 'outdoor')

它证明过滤发生在 HNSW 扫描结果上。hnsw.iterative_scan = 'strict_order' 能在过滤后不足时继续扫描,仍受最大扫描限制和数据分布影响, 不是“保证召回”的开关。

候选深度与返回深度不是一个 K

若最终返回 3 个结果,每路只取 3 个候选,融合器没有纠错空间。常见设计是:

lexical top  N_l
fuzzy   top  N_f
vector  top  N_v
        -> union/deduplicate
        -> fusion or re-rank
        -> final top K

本章每路取前 4,最终取 3,只为保持 SQL 可读。生产候选深度应通过质量/延迟 曲线决定,不能照抄 4

候选源还必须带上:

query_id
document_id
source
source_rank
source_score_or_distance
filter/version/model identity

否则融合以后只剩一个总分,无法追问“它为何出现”。

排名必须确定

浮点分数可以相同,近似索引也可能在近似等距对象间选择不同结果。所有实验 查询都追加稳定 tie-breaker:

ORDER BY score DESC, product_id;

这不会让近似算法变成精确算法,却能消除同分的随机输出,让回归测试和分页 至少有明确顺序。线上游标还要把排序键全部编码进去,不能只按一个不唯一分数 翻页。

过滤正确性优先于相关性

本章商品 17:

Legacy Wireless Earbuds
active = false
embedding = [0.95,0,0,0]

它对两个 audio 查询非常接近,是专门布置的 canary。verify.sql 断言它 不能出现在全文、模糊、精确向量或混合的任何排名中。若出现,实验立即失败, 即使平均 NDCG 更高也不能发布。

这条原则可推广为:

authorization / tenant / lifecycle correctness
beats relevance score

15.1.3 建立带人工相关性标签的查询集合

三份输入,三个不同身份

本章把输入拆成:

数据库中对应:

product_search(product_id, ..., embedding, search_document)
eval_query(query_id, raw_query, category_filter, embedding, intent)
relevance_judgment(query_id, product_id, grade, rationale)

把查询与标注分开很重要:查询是实际输入分布,标注是人对业务意图的判断。 模型和检索器都不能改写标注来让自己得分更高。

先定义标注等级

本章使用 1–3 级正相关:

grade 含义
3 直接满足意图,是理想结果
2 明确相关,但不是最佳
1 可接受的邻近结果
0/无行 未标注为相关

每条标注都有 rationale,例如:

q06,7,3,water bottle exactly serves trail hydration
q06,8,2,backpack includes a hydration sleeve
q06,9,1,water filter is related to outdoor water needs

理由不是装饰。两位标注者发生分歧时,没有理由就无法判断是规范模糊、文档 信息不足,还是标注错误。

真实项目应进一步规定:

  • 标注者是否知道检索器输出;
  • 一个查询由几人独立标注;
  • 分歧怎样仲裁;
  • 未展示对象是“不相关”还是“未判断”;
  • 如何抽样候选池,避免只标注现有系统召回到的对象;
  • 是否按用户群、语言、地区、设备与时间分层;
  • PII、敏感类别与数据保留规则。

如果只标注旧检索器的候选,新方法召回的新对象会被误当成零相关,这叫 pooling bias。

四个指标回答四个问题

令前 KK 个结果为 RKR_K,相关集合为 GG

Precision@K:

P@K=RKGK P@K = \frac{|R_K \cap G|}{K}

它问“展示位有多少是相关的”。本章即使某策略只返回一行,也仍除以 3; 空位会损失质量,避免系统靠少返回来虚增 precision。

Recall@K:

R@K=RKGG R@K = \frac{|R_K \cap G|}{|G|}

它问“已知相关对象覆盖了多少”。本章每个查询恰好有 3 个相关对象,因此 Precision@3 与 Recall@3 数值相同;这是夹具结构的巧合,不是两个指标等价。

MRR@K:

MRR@K=1QqQ{1/rankq,rankqK0,otherwise MRR@K = \frac{1}{|Q|} \sum_{q \in Q} \begin{cases} 1 / rank_q, & rank_q \le K \\ 0, & \text{otherwise} \end{cases}

它只关心第一个相关结果多靠前,适合“用户通常点第一个可用答案”的任务。 多个高质量结果的次序差异不会充分反映在 MRR 中。

NDCG@K 使用分级相关性:

DCG@K=i=1K2gradei1log2(i+1) DCG@K = \sum_{i=1}^{K} \frac{2^{grade_i}-1}{\log_2(i+1)} NDCG@K=DCG@KIDCG@K NDCG@K = \frac{DCG@K}{IDCG@K}

它奖励高等级对象靠前,并以理想次序归一。本章用 NDCG 暴露了“向量找全, 次序却不理想”以及“混合在某个错拼查询上变差”。

指标不是越多越科学。先从用户行为选择指标:

任务 更重要的信号
唯一答案/导航 MRR、Success@K
商品列表前三屏 NDCG、Precision@K、业务转化
法务/安全材料发现 Recall@K、漏召回审计
ANN 服务路径 exact-relative recall + latency

冻结不等于永久

fixture-manifest.json 固定:

corpus SHA-256
query SHA-256
judgment SHA-256
loader SHA-256
row counts
model id + dimension + method
text/vector license boundary

自动化执行:

cmp frozen-corpus.csv evidence/corpus.csv
cmp frozen-queries.csv evidence/queries.csv
cmp frozen-judgments.csv evidence/judgments.csv

数据库多一个空格、少一条标注、换一个向量都会被发现。

但冻结集只是一个版本。遇到以下变化应生成新版本,而不是覆盖旧 golden:

  • 真实查询分布改变;
  • 商品/文档类型扩展;
  • 模型、模板或预处理改变;
  • 语言配置与词典改变;
  • 人工标注规则改变;
  • 融合目标或业务约束改变。

发布报告要同时写:

quality delta on frozen regression set
+ quality on fresh holdout set
+ online behavior under guarded experiment

只在一个反复调参的集合上提高,会逐渐把参数拟合到测试集。

本章验收

运行:

PG36_EVIDENCE_DIR="$PWD/evidence/ch15" \
  ./static/labs/ch15/task.sh evaluate

审校器要求:

17 corpus rows
8 query rows
24 judgment rows
all three exports byte-identical
every query has exactly three positive judgments
product 17 inactive and absent from every ranking

到这里还没有决定用哪种检索器。我们只是让后续每个决定都有同一把尺。


返回本章目录 · 下一节:PostgreSQL 全文检索 · 查看全书目录 · 查看索引中心

15.2 PostgreSQL 全文检索

PostgreSQL 全文检索不是 LIKE 的加速版。它把原文处理成带词位、权重的 lexeme 向量,把查询处理成逻辑表达式,再用 @@ 匹配、用 ranking function 排序。

本节先只讨论词法证据。拼写容错与向量语义分别留给后两节。

15.2.1 文档、词典、配置与 tsvector

文档是检索单位,不一定是一列

全文检索中的 document 是“一次返回和排序的单位”。它可以是:

  • 一篇文章;
  • 一个商品;
  • 一封邮件;
  • 多列拼接后的一个业务对象;
  • 甚至跨表构造的投影。

本章把一个商品行视为 document,并只索引标题与描述。不要先把所有字符串 列都拼进去:SKU、类目、品牌、权限标签往往需要精确过滤或独立权重,盲目 拼接会同时损害相关性和索引体积。

从原文到 tsvector 的管线是:

text
  -> parser: split and classify tokens
  -> dictionary chain: recognize / normalize / discard
  -> lexemes + positions + optional weights
  -> tsvector

四类 PostgreSQL 对象各有职责:

对象 职责
parser 切 token 并判定 token type
dictionary 把 token 归一为 lexeme,或认定 stop word
template 提供 dictionary 的底层行为
configuration 把 parser token type 映射到 dictionary 链

官方文档把 token 解释为原始片段,把 lexeme 解释为用于索引的归一化词。 例如英语配置会去掉常见 stop word,并把一些复数/词形归到词干。参见 Full Text Search Introduction

配置必须是数据合同的一部分

下面写法依赖会话/数据库/集群默认值:

to_tsvector(title || ' ' || description)

同一数据迁到另一个集群,default_text_search_config 不同,就可能生成不同 lexeme。更危险的是建索引和查询端使用了不同配置:SQL 都能执行,却永远 错过部分匹配。

本章始终显式写:

'pg_catalog.english'::pg_catalog.regconfig

并用全限定名称避免受 search_path 影响。生产 ADR 至少记录:

configuration OID/name
dictionary files and versions
custom synonym/thesaurus source
language routing rule
reindex/rebuild procedure

english 适合本章英语夹具,不适合中文照抄。语言不是一个装饰参数:中文 如何分词、多语言文档如何路由、专有名词是否要保留原形,都会改变 document 和 query 的共同词表。

检查当前配置:

SHOW default_text_search_config;

\dF

SELECT
  cfgname,
  pg_get_userbyid(cfgowner) AS owner,
  cfgnamespace::regnamespace
FROM pg_ts_config
ORDER BY cfgnamespace::regnamespace::text, cfgname;

调试某段文本:

SELECT *
FROM ts_debug(
  'pg_catalog.english'::regconfig,
  'Making espresso at home'
);

ts_debug 能显示 token type、dictionary、lexeme 与是否被丢弃,是处理 “为什么没命中”的第一证据,而不是先调相关性权重。

多字段用权重表达结构

本章定义:

search_document tsvector
GENERATED ALWAYS AS (
  pg_catalog.setweight(
    pg_catalog.to_tsvector(
      'pg_catalog.english'::pg_catalog.regconfig,
      coalesce(title, '')
    ),
    'A'
  )
  ||
  pg_catalog.setweight(
    pg_catalog.to_tsvector(
      'pg_catalog.english'::pg_catalog.regconfig,
      coalesce(description, '')
    ),
    'B'
  )
) STORED

三个细节都不能省:

  1. coalesce 防止一列 NULL 让整个 document 变成 NULL
  2. title 用 A、description 用 B,保留字段结构给 ranking;
  3. configuration 显式固定,生成列和查询端一致。

默认 ts_rank/ts_rank_cd 权重按 D、C、B、A 分别为 0.1, 0.2, 0.4, 1.0。这只是默认,不是商品搜索的普遍最优值。

为什么用存储生成列

存储生成列在行写入/更新时计算,占用真实存储;读取时不必重新解析全文。 PostgreSQL 还保证它随基列变化自动更新,应用不能绕过表达式写入另一份 不一致的 tsvector

官方限制生成表达式只能使用 immutable function,不能含子查询,也不能 引用当前行之外的数据。参见 Generated Columns

因此它适合:

  • 稳定、行内、确定性的文本预处理;
  • 明确的 parser/dictionary 配置;
  • 由数据库维护的一致索引列。

它不适合直接调用:

  • 外部 embedding API;
  • 会变化的跨表同义词;
  • 当前时间;
  • 随机或非确定函数。

外部模型输出应是普通列加模型身份与作业状态,由受控异步流程写入。

用目录证明生成关系

看值:

SELECT
  product_id,
  title,
  search_document
FROM shop_ch15.product_search
ORDER BY product_id;

看生成属性与表达式:

SELECT
  a.attname,
  a.attgenerated,
  pg_get_expr(d.adbin, d.adrelid) AS expression
FROM pg_attribute AS a
JOIN pg_attrdef AS d
  ON d.adrelid = a.attrelid
 AND d.adnum = a.attnum
WHERE a.attrelid = 'shop_ch15.product_search'::regclass
  AND a.attname = 'search_document';

attgenerated = 's' 表示 stored。verify.sql 还会为 17 行重新计算表达式, 逐行与存储值比较,避免只有目录声明正确、数据却来自旧表达式。

15.2.2 查询语法、权重与相关性排序

tsquery 不是原始字符串

@@ 比较的是 document 与 query:

search_document @@ parsed_query

常用构造函数:

函数 输入合同 典型用途
plainto_tsquery 普通文本,存活词之间加 AND 简单所有词查询
phraseto_tsquery 普通文本,保留词位顺序/stop word 距离 短语
websearch_to_tsquery web 风格文本、引号、OR、减号 面向原始用户输入
to_tsquery 严格运算符表达式 受控高级查询语言

to_tsquery 能表达 &|!<->、权重和前缀,但它要求输入已经 符合语法。把 raw user text 直接传入,标点或漏写运算符就可能报错。

本章选择:

websearch_to_tsquery('pg_catalog.english', query.raw_query)

官方文档说明它不会抛出 syntax error,并支持:

unquoted words -> AND
"quoted phrase" -> FOLLOWED BY
OR              -> OR
-term           -> NOT

参见 Controlling Text Search

“不会语法报错”并不等于“没有风险”。要处理:

SELECT
  websearch_to_tsquery('pg_catalog.english', :raw) AS parsed,
  numnode(websearch_to_tsquery('pg_catalog.english', :raw)) AS nodes;

空 query、全部是 stop word、节点过多、负向条件过多或输入过长,都应有接口 级策略与 statement_timeout

先用 @@ 缩小,再计算 rank

本章词法排名:

WITH scored AS (
  SELECT
    q.query_id,
    p.product_id,
    ts_rank_cd(
      p.search_document,
      websearch_to_tsquery(
        'pg_catalog.english',
        q.raw_query
      ),
      32
    )::double precision AS score
  FROM shop_ch15.eval_query AS q
  JOIN shop_ch15.product_search AS p
    ON p.active
   AND p.category = q.category_filter
  WHERE p.search_document @@
        websearch_to_tsquery(
          'pg_catalog.english',
          q.raw_query
        )
)
SELECT ...;

@@ 是候选条件;ts_rank_cd 只对命中 document 排序。若对所有行先算 rank 再筛选,会放大 CPU 与 I/O。

ts_rank 主要基于匹配 lexeme 频率;ts_rank_cd 还考虑 cover density, 也就是匹配词在文档中的接近程度,并依赖词位信息。对被 strip 掉词位的 tsvector,cover density 信息会丢失。

normalization 不是概率校准

本章给 ts_rank_cd 的第三个参数为 32:

rank / (rank + 1)

它把正分数压进 0–1 范围,但官方文档特别说明,这只是 cosmetic scaling, 不会改变排序,也不能产生全局百分比。

所以这些写法没有理论依据:

-- 错:看到都在 0..1 就当同一量纲
0.5 * ts_rank_normalized
+ 0.5 * cosine_similarity

全文 rank 的 0.7、余弦相似的 0.7、trigram similarity 的 0.7 来自不同 生成机制,含义不相同。要么用训练/标注数据做分数校准,要么像本章一样用 只依赖名次的 RRF。

权重是可验证假设

setweight(..., 'A') 只给 lexeme 标标签;最终权重由 ranking function 的 weight array 决定:

ts_rank_cd(
  ARRAY[0.1, 0.2, 0.4, 1.0]::real[],
  search_document,
  parsed_query,
  32
)

数组顺序是 D、C、B、A,不是 A、B、C、D。调权重时应记录:

which fields map to which labels
weight vector
query set version
metric deltas per query segment

不要因为一个标题命中的样例“看起来更好”就把 A 权重调到很大;它可能把描述 中的强意图全部压下去。

高亮不是匹配证据本身

ts_headline 可以生成带命中词的摘要,但它会重新处理原始 document,且 输出是展示材料,不应替代 @@ 与 rank 证据。生产还要:

  • 对输出做 HTML escaping;
  • 控制片段长度和调用成本;
  • 不把未经授权的原文片段泄露给用户;
  • 缓存时把语言、query 与权限身份纳入 key。

先返回稳定 document id 和 rank,再在安全边界内生成展示片段。

15.2.3 GIN/GiST 索引、更新与语言边界

GIN 是通常的全文首选

PostgreSQL 支持用 GIN 或 GiST 加速全文检索。官方文档把 GIN 称为 preferred text search index type:

CREATE INDEX product_search_fts_idx
ON shop_ch15.product_search
USING gin (search_document);

GIN 为每个 lexeme 保存匹配位置列表,适合多词交集。它不在索引里保存 tsvector 的权重标签,所以带权查询需要回表 recheck。

GiST 用固定长度 signature 表示 document,是 lossy 的,可能产生 false match,由 PostgreSQL 自动回表排除。GiST 可以 INCLUDE 其他列,而 GIN/ GiST 在写入、体积、构建和查询上的权衡应由目标数据测量。

参见 Preferred Index Types for Text Search

本章不是 GIN/GiST benchmark。它选择 GIN 是因为普通 document FTS 的候选 生成合同明确,并用计划证明 opclass:

Bitmap Heap Scan on product_search
  Recheck Cond: (search_document @@ ...)
  -> Bitmap Index Scan on product_search_fts_idx

由于只有 17 行,正常 planner 很可能觉得顺序扫描更便宜。实验临时:

SET enable_seqscan = off;

只证明路径存在。真正上线要恢复默认 planner 设置,在代表性规模下看 EXPLAIN (ANALYZE, BUFFERS, WAL) 和延迟分布。

存储列、表达式索引与触发器

三种常见维护方式:

方式 优点 代价
tsvector 存储生成列 + GIN 自动一致、值可见、查询清晰 行与 WAL 增大,表达式受 immutable 限制
GIN(to_tsvector(config, text)) 表达式索引 不存单独列 查询表达式必须匹配,rank 仍需计算 document
普通 tsvector + trigger/job 可跨更复杂来源 有漂移/失败状态,必须治理回填

如果 document 只来自当前行且表达式稳定,本章偏好存储生成列。若 document 跨表、依赖外部词典版本或异步 enrichment,就把刷新状态建成显式数据模型, 不要伪装成同步生成列。

每次内容更新都可能更新多个索引

一次 title/description UPDATE 可能同时:

  1. 生成新 tsvector
  2. 更新 FTS GIN;
  3. 更新标题 trigram GIN;
  4. 若重新生成 embedding,更新 HNSW;
  5. 产生 heap/index WAL;
  6. 在副本重放;
  7. 留下旧 tuple/index entry 等待 vacuum。

“搜索读得快”可能把成本转给写入、WAL、autovacuum 和副本延迟。生产实验要 分别测:

bulk initial build
steady inserts
document updates
embedding-only updates
concurrent index build
vacuum/reindex
standby replay

不能用 pg_relation_size 的一个时间点代替整个生命周期。

GIN pending list 与维护预算

GIN 默认可用 fast update,把新条目先放入 pending list,之后批量合并。 这通常降低前台写入成本,却可能让后续查询或 cleanup 承担尖峰。检查:

SELECT *
FROM gin_metapage_info(
  get_raw_page(
    'shop_ch15.product_search_fts_idx',
    0
  )
);

这需要 pageinspect 和相应权限,只应由运维角色执行。更通用的观测包括:

SELECT
  pg_relation_size('shop_ch15.product_search_fts_idx') AS index_bytes,
  last_autovacuum,
  n_dead_tup
FROM pg_stat_user_tables
WHERE relid = 'shop_ch15.product_search'::regclass;

参数选择应结合更新率、gin_pending_list_limit、autovacuum 与延迟尖峰,而 不是全局照抄一组值。

语言边界是功能边界

本章的英语结果:

wireless headphones       -> 1 lexical match
wireles hedphones         -> 0
music on the go           -> 1
trail hydration           -> 2
postgre databse tuning    -> 0

这证明:

  • stemming/stop word 能处理词法变体与普通短语;
  • 它不会自动修正 wireleshedphonesdatabse
  • AND 语义可能把只命中部分词的 document 排除。

对中文或混合语言,先回答:

how language is detected
how text is segmented
which dictionaries normalize which token types
how names/SKUs/code are preserved
how query and document choose the same config
how config changes trigger rebuild and re-evaluation

如果需要外部分词扩展,回到第 14 章的扩展 ADR:软件包、权限、升级、备份、 主备节点和退出都要重新评审。

何时停止扩展 FTS

PostgreSQL FTS 很适合与事务数据同库、查询边界明确、规模和 ranking 需求 可控的场景。以下信号值得重新评估架构,而不是继续堆 SQL:

  • 多语言/自定义分析链远超当前 parser/dictionary 能力;
  • 需要复杂学习排序、拼写纠正、聚合/facet 与实时个性化;
  • 搜索写入和主交易表索引维护互相伤害;
  • 索引规模、构建窗口或副本恢复不满足 SLO;
  • 需要独立扩缩容、故障域或跨多源索引。

重新评估不等于立刻外置。先用本章评估集和成本证据比较 PostgreSQL、扩展与 外部搜索系统,保持双写、回放、校验和退出路径。


上一节:先定义检索任务与评估集 · 返回本章目录 · 下一节:模糊匹配与拼写容错 · 查看全书目录 · 查看索引中心

15.3 模糊匹配与拼写容错

全文检索把不同词形归一到 lexeme,却不会自动把错拼词改成正确词。 pg_trgm 从另一个角度补召回:不先理解语言,而是比较字符三元组。

这使它很适合人名、标题、标识符和拼写容错,也意味着它可能找到“长得像” 但业务上完全不相关的字符串。

15.3.1 pg_trgm 相似度与距离

三元组如何形成

trigram 是三个连续字符。pg_trgm 忽略非单词字符;对每个词,左侧补两个 空格,右侧补一个空格。官方例子中 cat 产生:

"  c", " ca", "cat", "at "

两个字符串共享的 trigram 越多,similarity(a, b) 越高。它的取值在 0–1,距离运算符则是:

a <-> b = 1 - similarity(a, b)

这不是 Levenshtein edit distance。字符插入、删除或换位会同时影响周围 多个 trigram;短字符串可用 trigram 更少,一个字符的影响会更大。

查看:

SELECT shop_ch14.show_trgm('wireless');

SELECT
  shop_ch14.similarity(
    'wireless headphones',
    'wireles hedphones'
  );

本章扩展装在 shop_ch14,所以函数和 operator class 都全限定。普通安装 可能在 public;不要假设 search_path

similarityword_similarity 与 strict 版本

三个函数回答不同问题:

函数 比较范围
similarity(a,b) 两个完整字符串的 trigram 集合
word_similarity(a,b) ab 中任一连续 trigram extent
strict_word_similarity(a,b) extent 还必须落在词边界

例如 query 是一个短词,title 是多个词时,完整字符串 similarity 会被 title 额外字符稀释;word_similarity(query, title) 更像是在 title 中寻找最相似 局部。strict 版本适合不希望跨词边界拼接的场景。

参数顺序很重要。官方把 word_similarity(first, second) 描述为 first 的 trigram 集与 second 某一连续 extent 的最大相似。对称函数 similarity 可以交换,word_similarity 的语义不要想当然地交换。

本章用:

greatest(
  shop_ch14.similarity(lower(p.title), lower(q.raw_query)),
  shop_ch14.word_similarity(
    lower(q.raw_query),
    lower(p.title)
  )
) AS score

这样同时保留整串与局部词候选。lower 使表达式与索引表达式一致;官方文档 还说明默认构建中的 trigram similarity 本身不区分大小写,但显式规范化能 让应用合同与表达式索引更清楚。

参见 pg_trgm Functions and Operators

函数分数、布尔门槛与距离排序

pg_trgm 提供三组布尔操作符:

%      uses pg_trgm.similarity_threshold
<%     uses pg_trgm.word_similarity_threshold
<<%    uses pg_trgm.strict_word_similarity_threshold

默认门槛在本章 PostgreSQL 18 文档中分别为 0.3、0.6、0.5。它们是 session GUC,可以 SET LOCAL,不应靠某个连接之前遗留的值:

BEGIN;
SET LOCAL pg_trgm.similarity_threshold = 0.35;

SELECT product_id, title
FROM shop_ch15.product_search
WHERE lower(title)
      OPERATOR(shop_ch14.%)
      lower('wireles hedphones')
ORDER BY
  shop_ch14.similarity(
    lower(title),
    lower('wireles hedphones')
  ) DESC,
  product_id;
COMMIT;

门槛控制 candidate set,函数分数控制 set 内次序。若只写:

ORDER BY similarity(...) DESC
LIMIT 10

就会永远返回十行,即使后几行分数是 0;也不一定能利用 GIN 做候选过滤。 这是“Top-K 必有结果”最常见的误判。

本章 fuzzy_ranking 故意对过滤后的全部小集合排名,不设阈值。它让评估 框架看到低分尾部,也展示为何生产必须选择门槛、最小结果可信度或 no-result 行为。

索引操作类决定可用路径

本章:

CREATE INDEX product_search_title_trgm_idx
ON shop_ch15.product_search
USING gin (
  lower(title) shop_ch14.gin_trgm_ops
);

强制 probe:

SET enable_seqscan = off;

EXPLAIN (COSTS OFF)
SELECT product_id
FROM shop_ch15.product_search
WHERE lower(title)
      OPERATOR(shop_ch14.%)
      lower('wireles hedphones');

得到:

Bitmap Index Scan on product_search_title_trgm_idx

目录同时验证:

access_method = gin
operator_class = shop_ch14.gin_trgm_ops
valid/ready/live = true

PostgreSQL 官方 pg_trgm 提供 GiST 与 GIN opclass。两者都支持相似操作符, 也支持 trigram 可提取的 LIKEILIKE、正则与等号查询;普通等值查询 通常还是 B-tree 更合适。

若需求是:

ORDER BY title <-> :query
LIMIT K

GiST 能提供 KNN distance 路径;官方文档明确指出某些 distance Top-N 形式 能高效使用 GiST,而不能用 GIN 直接完成同样的有序扫描。选择前先写清楚是 “按门槛过滤大量候选”还是“按距离取最近 K 个”。

15.3.2 前缀、包含、拼写错误与候选召回

先按意图选算子

用户意图 首选起点
SKU 完全相等 B-tree =
规范化前缀补全 B-tree/pattern opclass 或专门前缀设计
任意位置包含 trigram LIKE '%term%'
错拼容忍 % / word similarity + threshold
词形、布尔词、短语 FTS
模型表达的语义 vector

能被 trigram 索引支持,不等于它是所有文本谓词的首选。用 trigram 做每一次 等值查询,通常比普通唯一/B-tree 索引更大、更贵、语义也更弱。

短输入是天然边界

trigram 需要可提取的三字符片段。官方文档提醒:LIKE 或正则模式若没有 可提取 trigram,会退化为 full-index scan。对一两个字符的自动补全:

  • trigram 区分力低;
  • 候选可能巨大;
  • 门槛对短词异常敏感;
  • 用户每敲一个字符就查询会放大负载。

常见策略:

length < 3       -> no fuzzy query / curated prefix path
length 3..N      -> prefix or stricter threshold
longer typo text -> trigram candidate generation

长度规则要按语言与规范化后的 token 定义,不要只按 UTF-8 byte 数。

规范化必须与索引表达式相同

本章索引的是:

lower(title)

所以 predicate 也写 lower(title)。生产可能还要处理:

  • Unicode normalization;
  • accent folding;
  • 标点与空白;
  • 全角/半角;
  • SKU 中应保留的连字符;
  • locale/collation;
  • 同义缩写。

若规范化函数不 immutable,或查询表达式和索引表达式不同,表达式索引可能 无法使用。更重要的是:过度规范化会把本来不同的业务标识合并。先把规则做成 测试向量,再决定存储列或表达式索引。

门槛不是一次拍脑袋

对每个 query,按 score 取候选并与标注比较:

SELECT
  q.query_id,
  p.product_id,
  greatest(
    shop_ch14.similarity(lower(p.title), lower(q.raw_query)),
    shop_ch14.word_similarity(lower(q.raw_query), lower(p.title))
  ) AS score,
  j.grade
FROM shop_ch15.eval_query AS q
JOIN shop_ch15.product_search AS p
  ON p.active
 AND p.category = q.category_filter
LEFT JOIN shop_ch15.relevance_judgment AS j
  USING (query_id, product_id)
ORDER BY q.query_id, score DESC, p.product_id;

再画出或列出不同 threshold 下:

candidate count
precision/recall
latency
index/heap buffers
no-result rate

门槛可能按字段、查询长度或语言分层,但每多一个规则就多一个需要版本化和 回归的参数。不要在应用各处散落 0.20.30.45

召回池与展示结果要分开

模糊匹配很适合 召回候选,不一定适合最终排序:

typo query
  -> trigram top-N candidate ids
  -> exact filters
  -> FTS/vector/business features
  -> fusion or re-rank
  -> final K

如果 title 很短、候选同质,trigram 分数可以直接做主要排序;如果 document 很长、字段复杂或业务相关性与字面差别大,它更适合作为一项 feature。

本章 q04 coffee bean grinder 的模糊前三名包含商品 14(coffee scale), 却漏掉标注相关的 espresso machine,说明“字符串看起来像”会压过工作流上 相关但字面不同的对象。

15.3.3 与全文检索的互补和重复

用失败矩阵判断互补

本章观察:

query FTS matches fuzzy top-3 relevant 解释
q01 exact words 1 3 FTS 精确,fuzzy 扩展同类
q02 two typos 0 3 trigram 补错拼
q03 descriptive phrase 1 3 描述命中与标题相似都可工作
q04 grinder 1 2 fuzzy 被字面相似干扰
q05 espresso task 1 3 两路互补
q06 trail hydration 2 2 两路都漏一个字面较远对象
q07 technical typos 0 3 trigram 补技术词错拼
q08 exact long-tail 1 3 FTS 精确,fuzzy 给邻近书籍

这比“FTS + trigram 效果更好”更有用。它指出:

  • 哪些查询需要 fallback;
  • 哪些查询适合 union;
  • 哪些弱候选可能污染最终结果;
  • 下一批标注应该补什么失败类型。

不要做脆弱的二选一 fallback

一种常见实现:

if FTS has results:
    use FTS only
else:
    use trigram

它简单,但“FTS 有一条结果”不代表召回充分。q06 的 FTS 有两条,却漏掉 第三条相关对象;fallback 不会启动。

更稳健的做法是并行产生有限候选:

lexical top N
UNION ALL
fuzzy top N

然后保留 source/rank,去重并融合。这样能看到一条对象由几路支持,也能在 延迟预算不足时按证据关闭某一路。

也不要无条件扩大候选

多一路候选会增加:

  • index/heap 读取;
  • SQL 排序与去重;
  • 重排器输入;
  • 解释复杂度;
  • 弱信号把强结果挤下去的机会。

本章 RRF 的 q02 就是反例:纯 fuzzy 把最理想商品排第一;vector 与 lexical 信息加入后,混合把商品 3 排到第一,NDCG 下降。

候选源的采用条件应写成:

incremental recall gain
vs latency/resource cost
vs ranking degradation
on a frozen and a fresh evaluation set

可解释证据

给每个最终结果至少保留:

{
  "product_id": 3,
  "sources": {
    "fuzzy": {"rank": 1, "score": 0.7},
    "vector": {"rank": 2, "distance": 0.06}
  },
  "fusion": {"method": "rrf", "k": 60, "rank": 1}
}

不必把内部数值全部暴露给最终用户,但调试与审核必须能重建。

一条可操作的路由原则

先不要设计“智能路由器”。以简单证据开始:

exact identifier detected -> exact path first
normal natural-language query -> FTS + bounded vector
likely typo / sparse FTS -> add trigram candidates
very short query -> avoid broad fuzzy scan
authorization filters -> always mandatory

每条规则都需要 query segment 指标,最终也可能被统一并行候选替代。路由器 本身是一个模型;如果没有独立评估,它只是在隐藏失败。

本节验收

本章同时固定两类证据:

behavior:
  fuzzy quality and per-query ranks

mechanics:
  GIN operator class and forced bitmap index plan

行为证据回答“结果如何”;机制证据回答“数据库能否走这条路径”。二者不能 相互替代。


上一节:PostgreSQL 全文检索 · 返回本章目录 · 下一节:可复现的向量检索 · 查看全书目录 · 查看索引中心

15.4 可复现的向量检索

向量列并不自带“语义”。它只是一种有维度的数值,配合一个距离/相似度函数 产生次序。语义来自向量生成过程;查询性能来自精确或近似执行路径;质量来自 外部标注。

把三者分开,才能复现,也才能退出。

15.4.1 维度、距离度量与归一化

一列必须有模型身份

最小表不是:

embedding vector(1536)

而是至少:

embedding       vector(1536) NOT NULL,
embedding_model text         NOT NULL,
embedded_at     timestamptz  NOT NULL

真实系统还可能需要:

input_template_version
source_text_hash
normalization
generation_status / error
provider/model revision
dimension

同一维度不代表同一空间。两个 1536 维模型生成的向量不能因为类型相容就放进 同一个近邻索引比较。一次模型升级应视为数据迁移,而不是悄悄覆盖列。

本章用:

embedding shop_ch14.vector(4) NOT NULL,
embedding_model text NOT NULL
  CHECK (
    embedding_model =
    'pg36-handcrafted-topic-4d-v1'
  )

四维分别是 audio、kitchen、outdoor、books 的人工主题坐标。它的价值是每个 距离可手算、没有 API 漂移;它不证明真实 embedding 的语义能力。

四个常见距离/相似合同

pgvector 0.8.4 对 vector 提供:

运算符 含义 HNSW opclass
<-> L2 / Euclidean distance vector_l2_ops
<#> negative inner product vector_ip_ops
<=> cosine distance vector_cosine_ops
<+> L1 / taxicab distance vector_l1_ops

还有 binary vector 的 Hamming/Jaccard,以及 halfvecsparsevec 的相应 能力;本章不展开。

pgvector 让“越小越近”符合 PostgreSQL ascending index scan:

-- L2:小者优先
ORDER BY embedding <-> :query

-- inner product:返回负内积,所以仍按 ASC
ORDER BY embedding <#> :query

-- cosine similarity 若要展示
1 - (embedding <=> :query)

不要把 <#> 的负数直接叫“相似度”。用于展示内积时要乘 -1,用于索引 排序则保留升序负内积。

参见 pgvector 0.8.4 Querying

选距离要回到模型合同

L2:

dL2(x,y)=i(xiyi)2 d_{L2}(x,y)=\sqrt{\sum_i(x_i-y_i)^2}

它同时受方向与向量长度影响。

cosine similarity:

cos(x,y)=xyxy \cos(x,y)=\frac{x\cdot y}{\|x\|\|y\|}

更强调方向,cosine distance 通常为 1cos(x,y)1-\cos(x,y)

inner product:

xy=ixiyi x\cdot y=\sum_i x_i y_i

同时受方向与模长影响。某些模型明确训练为用 dot product 排序。

若所有向量都被归一化为单位长度:

xy22=22(xy) \|x-y\|_2^2 = 2 - 2(x\cdot y)

此时 L2、cosine 与 inner product 的次序存在紧密关系;但仍应按模型文档 和性能目标选 opclass,不能因为数学关系就混用未归一化数据。

模型 ADR 要回答:

does the producer normalize?
does the database verify normalization tolerance?
which operator is used by every query?
which matching opclass indexes that operator?
how are zero vectors handled?

一个常见错误:

CREATE INDEX ... (embedding vector_cosine_ops);
SELECT ... ORDER BY embedding <-> :q LIMIT 10;

索引是 cosine,查询却用 L2;二者 operator family 不匹配,不能期待该索引 支持查询。目录与执行计划必须同时复核。

维度是存储和索引合同

本章使用 vector(4),数据库拒绝不同维度的值。pgvector 0.8.4 的 HNSW vector 索引支持最多 2000 维;halfvecbitsparsevec 有各自上限。 这些是当前版本事实,升级前重查官方文档。

“维度越高语义越好”不是规律。维度会影响:

  • 每行存储;
  • index tuple 与图内存;
  • 构建和查询计算;
  • WAL、备份和网络;
  • 模型能力与压缩损失。

先以模型要求为输入,再用真实规模测成本。不要为了迎合数据库索引上限随意 截断向量;降维、量化或子向量都需要新的质量基线。

15.4.2 精确近邻与近似索引

默认是精确搜索

没有近似索引时:

SELECT product_id, title
FROM shop_ch15.product_search
WHERE active
  AND category = 'outdoor'
ORDER BY
  embedding
    OPERATOR(shop_ch14.<->)
    '[0,0,0.9,0]'::shop_ch14.vector(4),
  product_id
LIMIT 3;

PostgreSQL 对所有合格行算距离、排序并取前三。这是 exact nearest neighbor: 在当前快照和距离合同下,召回是完备的。它的成本随参与比较的行数、维度与 并行度增长。

本章质量视图不是直接执行一个可能被 HNSW 接管的 ORDER BY ... LIMIT。 它先产生所有 pair 的 distance,再用 window function 排名:

row_number() OVER (
  PARTITION BY query_id
  ORDER BY distance, product_id
)

这使质量 golden 明确来自 exact 全集,与线上 planner 是否选择 ANN 无关。

强制 exact plan:

SET enable_indexscan = off;
SET enable_bitmapscan = off;

得到:

Seq Scan on product_search
Sort Key: ((embedding <-> ...)), product_id

这是本章的 reference path。

ANN 用召回换速度

pgvector 支持 HNSW 与 IVFFlat:

exact search:
  computes all eligible distances
  perfect recall under the metric

approximate search:
  visits a selected part of an index
  lower work, potentially different results

官方 README 明确指出,增加 approximate index 后,同一查询可能得到不同 结果;必须把这种差异视为算法合同,而不是数据库 bug。

本章建:

CREATE INDEX product_search_embedding_hnsw_idx
ON shop_ch15.product_search
USING hnsw (
  embedding shop_ch14.vector_l2_ops
)
WITH (
  m = 8,
  ef_construction = 32
);

默认值在 pgvector 0.8.4 是 m=16ef_construction=64;本章用较小值 只是让夹具声明显式,并非生产建议。

HNSW 建多层图:

  • 通常有较好的 speed/recall trade-off;
  • 构建更慢、内存更多;
  • 不需要像 IVFFlat 那样先训练 lists,所以空表也能先建;
  • 持续写入仍要维护图和 WAL。

IVFFlat 把向量分到 lists,查询探测部分 lists:

  • 构建更快、内存较少;
  • 通常查询 speed/recall trade-off 低于 HNSW;
  • 建索引前应有代表性数据;
  • lists/probes 选择直接影响 recall。

这不是永久排名。数据规模、更新率、过滤、内存和 SLO 会改变选择。

索引查询形状必须匹配

让 ANN index 生效,典型查询要:

ORDER BY embedding <-> :query
LIMIT K

只写距离范围:

WHERE embedding <-> :query < :radius

不一定形成同样的 ordered index path。pgvector 官方建议把范围条件与 ORDER BYLIMIT 结合。

还要避免把 indexed expression 包进不等价表达式:

-- 可能破坏路径匹配
ORDER BY 1 - (embedding <=> :query) DESC

-- 直接按 index operator 升序
ORDER BY embedding <=> :query

展示 similarity 可以在外层计算;候选扫描保持与 opclass/operator 一致。

ANN recall 必须相对 exact 定义

对于同一 query 与 filters:

Recall@KANN=ANNKExactKK Recall@K_{ANN} = \frac{|ANN_K \cap Exact_K|}{K}

本章 ann-compare.sql 在同一事务中:

  1. 从 exact 质量视图取 q06 前三;
  2. 强制 HNSW path 并取同样过滤后的前三;
  3. 求集合交集。

结果:

exact_ids = 7,8,9
ann_ids   = 7,8,9
recall@3  = 1.000000

一次查询、17 行、四维向量上的 1.0 不是生产结论。正式测量至少按:

query segment
filter selectivity
K
ef_search/probes
concurrency
data freshness
model version

报告分布,并把 exact 抽样任务长期保留。pgvector 官方 monitoring 建议同样 是关闭 index scan 取得 exact 结果,再与近似结果比较。

15.4.3 索引参数、过滤条件与召回代价

HNSW 有构建参数和查询参数

构建参数:

参数 作用 增大通常带来的影响
m 每层最大连接数 图更密、潜在召回更好、空间/构建更贵
ef_construction 构图候选列表大小 潜在召回更好、构建/写入更慢

查询参数:

参数 作用 增大通常带来的影响
hnsw.ef_search 查询动态候选列表 recall 上升机会、延迟与工作量上升

pgvector 0.8.4 默认 ef_search=40。单次实验应使用事务局部设置:

BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT ... ORDER BY embedding <-> :query LIMIT 10;
COMMIT;

不要把 session pool 中遗留的 GUC 当作服务配置;也不要只测一个值。需要 绘制:

ef_search -> recall distribution
          -> P50/P95/P99 latency
          -> buffers/CPU

参数发布要与 index build identity、模型、数据快照和查询集一起版本化。

过滤为什么会“吃掉”结果

pgvector 0.8.4 官方说明,approximate index scan 的普通过滤是在扫描后应用。 假设只有 10% 行满足过滤,初次探索 40 个候选,平均可能只留下约 4 个;需要 K=10 时就会短缺。

这不是 SQL 三值逻辑错,而是 ANN 没继续探索足够候选。

可选策略:

  1. 过滤列 B-tree + exact search 当过滤后集合很小,先用普通索引缩小到少量行,再 exact 排序,常常更快且 recall 完整。
  2. iterative index scan 过滤后不足时继续探索 ANN。
  3. partial HNSW index 少数稳定类别各有索引,例如 WHERE category_id=123
  4. partitioning 过滤维度离散且能形成真实数据生命周期/裁剪边界时分区。
  5. 扩大 candidate/ef_search 简单但增加每次查询成本,仍要测。

本章同时创建:

CREATE INDEX product_search_filter_idx
ON shop_ch15.product_search(category, product_id)
WHERE active;

它提醒读者:向量检索仍然是 PostgreSQL 查询,普通关系索引和选择性统计并未 失效。

iterative scan 的 strict 与 relaxed

pgvector 从 0.8.0 起支持 iterative index scan:

SET LOCAL hnsw.iterative_scan = 'strict_order';
-- or
SET LOCAL hnsw.iterative_scan = 'relaxed_order';

strict 保持距离严格次序;relaxed 允许轻微乱序,可能获得更好的 recall。 relaxed 结果若要重新严格排序,官方示例使用 materialized CTE 后在外层排序。

迭代不会无限进行,还受:

hnsw.max_scan_tuples
ivfflat.max_probes
memory limits

等边界影响。返回 K 行、返回顺序与 recall 都要分别断言。

本章用 strict:

SET hnsw.iterative_scan = 'strict_order';

只为让证据顺序稳定。它不能让 approximate graph 等价于 exact scan。

partial index 不是“每个租户建一个”

官方建议在过滤值很少时考虑 partial HNSW:

CREATE INDEX ...
USING hnsw (embedding vector_l2_ops)
WHERE category_id = 123;

若有成千上万租户或高基数 ACL,给每个值建索引会造成:

  • index 数量爆炸;
  • DDL/catalog/autovacuum 管理困难;
  • 写放大;
  • planner 规划开销;
  • 备份恢复和升级时间增长。

高基数过滤更可能需要:

  • exact on a selective conventional index;
  • 合理分区;
  • 分片/路由层;
  • 两阶段候选与业务过滤;
  • 或重新选择搜索架构。

任何方案都必须验证权限过滤不会因“为了 recall”被放宽。

构建与维护的安全线

HNSW 构建图若能放入 maintenance_work_mem 会更快;官方也警告不要把该参数 设到耗尽 server 内存。生产建索引:

CREATE INDEX CONCURRENTLY ...

可以减少阻塞写入,但会更慢、产生更长资源占用,失败还可能留下 invalid index。要监控:

SELECT
  phase,
  blocks_done,
  blocks_total
FROM pg_stat_progress_create_index;

并检查:

SELECT indisvalid, indisready, indislive
FROM pg_index
WHERE indexrelid = 'schema.index_name'::regclass;

本章 setup 是离线教学重建,所以用普通 CREATE INDEX;不得把这个选择直接 搬到在线主表。

何时退回 exact

以下场景 exact 可能更好:

  • 过滤后只剩少量行;
  • K 较大,ANN 需要探索大部分集合;
  • 质量要求不允许近似漏召回;
  • 数据规模尚小;
  • 向量更新频繁,ANN 维护成本超过收益;
  • exact 可以在可接受延迟内并行完成。

先测 crossover,再引入 ANN。拥有 HNSW 扩展能力不构成使用理由。

15.4.4 冻结文本、模型标识、许可证、向量文件与校验和

“同一个模型”仍可能生成不同向量

可复现输入至少包括:

exact source text bytes
field concatenation/template
Unicode and whitespace normalization
truncation/chunking
model/provider/revision
tokenizer revision
output dimension
post-normalization
generation parameters

只记录营销名,例如 embedding-v3,不够。云服务可能滚动更新后端;本地模型 可能因权重、tokenizer、运行库或量化不同而变化。

本章如何消除外部变量

fixture-manifest.json 明确:

{
  "model_id": "pg36-handcrafted-topic-4d-v1",
  "dimensions": 4,
  "method": "manually assigned deterministic topic coordinates",
  "external_model": false,
  "external_api": false,
  "distance": "L2"
}

商品与 query 的向量都保存在冻结 CSV 中。setup 载入后,数据库导出:

COPY (
  SELECT
    product_id,
    sku,
    category,
    active::text,
    title,
    description,
    embedding::text
  FROM shop_ch15.product_search
  ORDER BY product_id
) TO STDOUT WITH (FORMAT csv, HEADER true);

再与源文件逐字节 cmp。这比“查询结果差不多”严格得多:向量文本一个数字 变化就失败。

文件 hash 与数据库 checksum 各负责什么

fixture manifest 固定:

frozen-corpus.csv      SHA-256
frozen-queries.csv     SHA-256
frozen-judgments.csv   SHA-256
fixture.sql            SHA-256

最终数据库 business_checksum 则覆盖:

all products and vectors
all queries and vectors
all judgments
all top-3 ranks and rounded scores
all quality summaries

二者互补:

  • file hash 证明输入资产没变;
  • database checksum 证明装载、计算和最终行为没变。

运行时间、OID、绝对路径、索引物理页大小不进入 business checksum,因为它们 不是跨重建稳定的业务事实。

许可证和数据边界不能等上线后再补

向量可能泄露源数据特征,也可能受模型服务条款约束。ADR 至少记录:

source text ownership and lawful basis
whether text leaves the trust boundary
provider retention/training policy
region and transfer mechanism
model/output usage license
secrets and service account scope
deletion propagation
backup retention
incident and audit trail

“只传向量,不传原文”也不是自动匿名。是否能从向量推断敏感信息要按威胁 模型评估。

本章文本和数字都是项目自有合成 fixture,不调用外部服务:

text_license   = project-owned synthetic fixture
vector_license = handcrafted numeric fixture

这使仓库可重复,不替真实项目完成法务评审。

模型升级要双版本迁移

不要:

UPDATE product
SET embedding = new_model(text);

在同一列原地混写。更稳健的流程:

1. create new vector column/table with model_id v2
2. backfill from frozen source-text hash
3. build matching v2 opclass/index
4. evaluate v1 vs v2 on frozen + fresh sets
5. shadow query / guarded traffic
6. switch read version explicitly
7. retain rollback window
8. export or retire v1 under data-retention rules

若维度改变,类型和索引自然需要新对象;即使维度相同,也应保持逻辑隔离。

退出路径

pgvector 列可转文本导出:

COPY (
  SELECT
    product_id,
    embedding_model,
    embedding::text
  FROM shop_ch15.product_search
  ORDER BY product_id
) TO STDOUT WITH (FORMAT csv, HEADER true);

但“能导出字符串”只是技术出口第一步。还要证明目标系统:

  • 能按同一精度解析;
  • 保留 document/model identity;
  • 使用同一距离与归一化;
  • 重建索引后质量不变;
  • 在切换窗口双读校验;
  • 能回放迁移期间更新。

本章精确 reset 删除 shop_ch15,却保留第 14 章持有的扩展;它演示了对象 边界,不是生产数据迁移演练。


上一节:模糊匹配与拼写容错 · 返回本章目录 · 下一节:混合检索与排序验证 · 查看全书目录 · 查看索引中心

15.5 混合检索与排序验证

混合检索的价值来自失败互补,不来自方法数量。每加一路候选,都同时增加 召回机会、查询成本和排序污染。

本节先保留每路独立排名,再用只依赖名次的 RRF 合并;最后用同一标注集找出 收益与退化。

15.5.1 词法、模糊和语义候选合并

每一路先独立成立

本章先建三个排名视图:

lexical_ranking
fuzzy_ranking
vector_exact_ranking

共同输出:

query_id
product_id
result_rank
score

向量视图还保留 distance。每路都使用相同资格条件:

p.active
AND (
  q.category_filter IS NULL
  OR p.category = q.category_filter
)

如果三路过滤不同,融合后的差异既可能来自检索能力,也可能来自候选资格, 指标无法解释。授权、租户和生命周期过滤应在每路都保持同一语义。

source rank 是融合接口

本章把每路前四名规范成:

SELECT
  query_id,
  product_id,
  'lexical' AS source_name,
  result_rank,
  1.0 AS source_weight
FROM shop_ch15.lexical_ranking
WHERE result_rank <= 4

UNION ALL
...

为什么用 UNION ALL

  • 同一商品被多路召回是有价值的支持证据;
  • 普通 UNION 会按整行去重,但 source/rank 不同,本来也不会合并;
  • 后续应显式按 (query_id, product_id) 聚合,而不是让 set operator 隐式决定。

每路只提供 candidate,不应在视图内部偷偷调用另一路。这样才能:

  • 单独关闭某一路;
  • 比较增量质量;
  • 观察每路延迟;
  • 定位失败;
  • 给最终结果解释 source。

候选深度是资源预算

本章:

source_depth = 4
final_depth  = 3

它只适合 17 行夹具。生产候选深度影响:

recall ceiling
union cardinality
dedup/sort work_mem
re-ranker cost
network payload
tail latency

应按 query segment 做曲线:

depth = 10,20,50,100
  -> Recall@final_K
  -> NDCG@final_K
  -> P95/P99
  -> candidate count after dedupe

当一条 source 加深只增加重复/低质量对象而不提高最终 recall,就没有继续 扩大的理由。

空结果与低分结果不同

全文视图只输出 @@ 命中对象,所以 q02/q07 没有行。

模糊视图对全部合格对象排名,即使低分也有行。这是评估 fixture 的有意设计, 生产则应加候选门槛。否则:

no lexical evidence
+ zero fuzzy evidence
+ weak vector evidence

仍可能被融合成一个看似精确的总排名。

候选接口应区分:

source unavailable
source returned no qualified candidate
source timed out
source returned candidates

超时不能被当作“该方法认为没有相关结果”,否则线上质量诊断会混淆能力与 故障。

去重后的 evidence 仍要保留

一个商品多路出现时,最终记录应能表达:

product 1:
  lexical rank 1
  fuzzy rank 1
  vector rank 3

本章 hybrid_rrf_ranking.sources 用 text array 保存 source 名称,完整排名 仍可回查原视图。生产可把细节写进响应 trace、审计表或离线日志,不必塞进 业务表永久保存。

SQL 内混合的边界

在 PostgreSQL 内完成候选和融合有明显优势:

  • 与事务快照、状态和权限过滤同处一处;
  • 少一次跨系统网络与数据同步;
  • SQL 可以直接被 EXPLAIN、统计和审计;
  • 小中规模问题结构简单。

但如果模型推理、复杂学习排序、跨多源索引或独立扩缩容占主导,融合可能更 适合应用/检索服务。决策标准不是“SQL 能不能写”,而是:

failure domain
data freshness
latency budget
operational ownership
quality evidence
exit cost

15.5.2 分数归一、倒数排名融合与重排

为什么不能直接加原始分数

本章三路原始值:

ts_rank_cd(..., 32)  -> bounded cosmetic score
trigram similarity  -> character overlap in 0..1
vector L2 distance  -> non-negative distance, lower is better

即使都变换到 0–1,也没有共同概率含义。下面只是拍脑袋:

0.4 * lexical_score
+ 0.3 * fuzzy_score
+ 0.3 * (1 / (1 + vector_distance))

它会隐含:

  • 三种尺度的形状可比;
  • 一单位变化意义相同;
  • 权重对所有 query segment 相同;
  • 极值与分布稳定;
  • 模型升级后仍适用。

若要线性加分,应使用标注数据做 calibration/learning-to-rank,并在 fresh holdout 上验证。

RRF 只比较名次

Reciprocal Rank Fusion 对 document dd

RRF(d)=ssourceswsk+ranks(d) RRF(d)=\sum_{s \in sources} \frac{w_s}{k+rank_s(d)}

其中:

  • ranks(d)rank_s(d) 是 document 在 source ss 中的名次;
  • 未出现在 source candidate depth 内就没有该项;
  • wsw_s 是可选 source 权重;
  • kk 平滑头部名次差。

本章:

k = 60
w_lexical = w_fuzzy = w_vector = 1
source_depth = 4

SQL:

SELECT
  query_id,
  product_id,
  sum(
    source_weight / (60.0 + result_rank)
  )::double precision AS score,
  array_agg(source_name ORDER BY source_name) AS sources
FROM source_rank
GROUP BY query_id, product_id;

再:

row_number() OVER (
  PARTITION BY query_id
  ORDER BY score DESC, product_id
)

pgvector 官方 Hybrid Search 小节也把 RRF 或 cross-encoder 列为融合路径; 参见 pgvector 0.8.4 Hybrid Search

k 不等于返回 K

RRF 中的小写 kk 是平滑常数,不是最终结果数。增大它会让头部第 1 与 第 4 的单路差距变小,多路共同支持相对更重要;减小它会放大头部名次差。

不要因为经典示例常用 60 就把它当数学常数。本章用 60 是显式 proposal 参数。调参时要同时版本化:

rrf_k
source weights
source candidate depths
tie breaker
final K

并报告每个 query segment 的变化。

RRF 的优点与盲区

优点:

  • 不要求原始 score 同尺度;
  • 一个 document 被多路高排会自然加分;
  • 实现与解释简单;
  • source 新旧版本可独立。

盲区:

  • 丢失 source 内分数差距:第 1 比第 2 好很多或只好一点都看不出来;
  • candidate depth 外信息完全消失;
  • 弱 source 也会投票;
  • 不直接使用业务新鲜度、库存、质量、安全等 feature;
  • 不会自动学会 query-dependent weight。

所以 RRF 是可靠 baseline,不是排序终点。

source weight 要谨慎

加权 RRF:

source_weight / (k + rank)

很容易实现,但“vector 权重 1.5”必须由指标支持。若某一路只在错拼 query 有帮助,更合理的方向可能是:

  • query segment 路由;
  • 以置信门槛启用;
  • 学习排序;
  • 或扩大/收窄该路 candidate depth。

全局权重会把局部收益和局部伤害平均掉。

两阶段重排

当候选生成需要快、最终质量需要更多 feature:

stage 1:
  FTS/trigram/ANN -> union top 100

stage 2:
  exact vector
  + structured business features
  + cross-encoder or learned ranker
  -> top 10

关键合同:

  • 第一阶段 recall 必须足够;被漏掉的对象无法被重排救回;
  • 第二阶段有独立超时与 fallback;
  • 重排模型有版本、输入与许可证;
  • fallback 次序仍经过评估;
  • 在线日志保留 candidate pool 与最终选择。

外部 cross-encoder 可能最贵、最慢、最敏感,不要让数据库事务长时间等待 网络推理。常见做法是在数据库取候选后结束事务,再用版本化快照数据重排; 若新鲜度要求更高,需要明确一致性策略。

精确重排近似候选

ANN 常用模式:

WITH candidate AS MATERIALIZED (
  SELECT product_id, embedding
  FROM product
  ORDER BY embedding <-> :q
  LIMIT 100
)
SELECT product_id
FROM candidate
ORDER BY embedding <-> :q, product_id
LIMIT 10;

第二层用原向量精确重排 candidate,可以修正量化/子向量/relaxed ordering 内部次序,却不能召回第一层没选中的对象。最终仍要用 exact-relative recall 评估 candidate generator。

15.5.3 用标注集比较质量,不用单个“好看案例”

结果表只是起点

运行:

PG36_EVIDENCE_DIR="$PWD/evidence/ch15" \
  ./static/labs/ch15/task.sh evaluate

得到 quality-summary.csv

strategy,queries,precision@3,recall@3,mrr@3,mean_ndcg@3,min_ndcg@3
fuzzy,8,0.916667,0.916667,1.000000,0.942881,0.842828
hybrid_rrf,8,1.000000,1.000000,1.000000,0.962929,0.759192
lexical,8,0.291667,0.291667,0.750000,0.613043,0.000000
vector_exact,8,1.000000,1.000000,1.000000,0.817314,0.631039

平均值显示 hybrid 在本集合上 mean NDCG 最高,但不能到此结束。

看每个 query 的退化

quality-detail.csv 暴露:

q02:
  fuzzy NDCG@3      = 0.842828
  hybrid NDCG@3     = 0.759192

q02/q07:
  lexical result_count = 0
  lexical NDCG@3       = 0

q02 的前三名:

fuzzy: 1,3,2
hybrid: 3,2,1

商品 1 是 grade 3,纯 fuzzy 放第一;混合受到另外两路名次影响,把 grade 2 商品 3 放第一。因此:

mean improved
does not imply every intent improved

生产 release gate 应同时有:

  • aggregate threshold;
  • 关键 query segment threshold;
  • worst-case/低分位保护;
  • 不允许退化的 canary query;
  • filter/security hard assertions。

四种策略各告诉了什么

全文

high precision when exact lexical evidence exists
zero results for two typo queries

它不是“差”,而是 candidate role 较窄。单独 NDCG 低不等于应删除;它可能 提供最可解释、最精确的头部证据。

模糊

excellent typo recovery on this title corpus
misses one relevant result in q04 and q06

它证明字符相似的价值,也暴露业务邻近但字面不近的盲区。

精确向量

all three relevant objects recalled for every query
mean NDCG lower because grade ordering is imperfect

这是“召回好、排序不够好”的典型 candidate generator。

RRF

full recall and best mean NDCG
one typo query worse than fuzzy

它是当前 proposal baseline,而不是普遍胜者。

8 个查询没有统计外推力

本章数字可以证明:

  • SQL 指标计算可复现;
  • fixture 改动会被发现;
  • 策略之间确有可解释差异;
  • 反例和 filter guard 可持续回归。

不能证明:

  • 真实流量的总体质量;
  • 各语言/用户/类别公平;
  • 指标提升具有统计显著性;
  • 点击、转化或任务完成提高;
  • 线上 P95/P99;
  • embedding 模型优劣。

真实评估集应从代表性查询日志和业务任务构建,保留 fresh holdout。对线上 A/B:

pre-register primary metric and guardrails
randomize at a safe unit
control novelty and carry-over
track no-result and latency
audit exposure/authorization
set stop conditions

离线相关性是上线前门槛,不是用户价值的最终替代。

指标 SQL 也要审校

本章 quality_per_query 明确建立所有策略 × 所有查询的 grid,再 LEFT JOIN 返回结果。这样没有返回行的 query 仍有 0 分;若从 ranking 表直接 GROUP BY, 空 query 会消失,平均值被悄悄抬高。

它还:

  • 固定前三;
  • 分母使用 3,不使用实际 result count;
  • 对未标注 result 记 grade 0;
  • 用 grade 计算 DCG 与 ideal DCG;
  • 稳定按 grade/product id 生成理想排序。

审查 retrieval 指标实现时,重点找:

missing queries silently dropped
precision denominator changed
unjudged treated inconsistently
ties unstable
ideal ranking calculated from retrieved set
approximate results used as truth

这些 bug 往往比检索算法差异更大。

发布结论的正确句式

可以写:

ch15-search-v1 的 8 个冻结查询、24 条分级标注和精确向量 golden 下,等权 RRF 的 mean NDCG@3 为 0.962929,并覆盖全部标注相关对象; q02 相对纯 fuzzy 退化,必须保留分段监控。结果不外推到真实语料。

不要写:

混合检索准确率 96%,优于其他方案。

后一句把 NDCG 当 accuracy,把小合成集当总体,也隐去了反例。


上一节:可复现的向量检索 · 返回本章目录 · 下一节:扩展部署与运行代价 · 查看全书目录 · 查看索引中心

15.6 扩展部署与运行代价

到目前为止,我们只证明了检索行为。生产采用还要回答:

每个节点有没有同一扩展构建?
数据库对象是谁创建和升级的?
索引建造、更新、WAL、备库与恢复要付什么代价?
文本怎样变成向量,谁有权把它发给谁?

本节把 PostgreSQL 机制映射到 Pigsty 4.5,但仍以 live 节点、系统目录和实际 证据为准。

15.6.1 安装检索扩展并核对版本

复用第 14 章的三层状态

检索扩展仍然有三层:

package/support files on every host
  -> backend can load compatible library
  -> pg_extension object exists in this database

pg_trgm 随 PostgreSQL contrib 交付;vector 的项目/包常叫 pgvector,而 SQL 扩展名是 vector。不要混写:

package alias: pgvector
SQL:           CREATE EXTENSION vector
catalog:       pg_extension.extname = 'vector'

本章不会安装或升级它们,而是要求第 14 章最终状态:

pg_trgm 1.6
vector  0.8.4
schema  shop_ch14
exact extension markers

如果版本、owner、schema 或 marker 不符,context.sql 拒绝。下游实验不能 悄悄接管上游扩展。

Pigsty 中分开“装包”和“启用”

Pigsty 4.5 当前文档指出:

  • pgvectorpgsql-main 默认安装;
  • pg_trgm 位于默认启用扩展列表;
  • 可通过 pg_packages/pg_extensions 影响包;
  • 可通过 pg_default_extensions 影响数据库默认启用对象。

参见 Pigsty Default Extensions

本章给出的 pigsty-declaration.example.yml 只是合并片段:

all:
  vars:
    pg_version: 18
    pg_packages:
      - pgsql-main
      - pgvector
    pg_default_extensions:
      - { name: pg_trgm, schema: public }
    pg_databases:
      - name: pg36_shop
        owner: pg36_owner
        extensions:
          - { name: vector, schema: public }

实际版本中 pgvector 可能已被 pgsql-main 包别名覆盖,重复声明是否允许、 包名如何展开、目标 OS/PG major 是否有构建,都要用目标 inventory 与 Pigsty 文档确认。

教学夹具把扩展放在 shop_ch14,是为了凸显 namespace/owner。生产片段使用 public 只是示例,不是推荐所有扩展都堆进 public。schema 选择必须同时 满足:

  • extension 是否 relocatable/是否强制 schema;
  • 应用是否用全限定名称;
  • search_path 安全;
  • dump/restore;
  • 运维与升级脚本;
  • 权限最小化。

L1 每个节点都要核对

物理复制会把数据库对象复制到备库,但不会把 OS 软件包和动态库通过 WAL 复制过去。主库 CREATE EXTENSION vector 成功,某备库仍可能因缺 vector.so 在查询、恢复或升主后失败。

对每个 L1 host 收集:

pg_config --version
pg_config --sharedir
pg_config --pkglibdir

test -r "$(pg_config --sharedir)/extension/vector.control"
test -r "$(pg_config --sharedir)/extension/pg_trgm.control"

再核对目标 major 的 package manager 版本与文件 hash。不能从当前 shell PATH 里的 pg_config 猜正在运行的 server;先从实例确认 server major, 再找对应安装树。

数据库内:

SELECT
  e.extname,
  e.extversion,
  n.nspname AS schema_name,
  pg_get_userbyid(e.extowner) AS owner,
  e.extrelocatable
FROM pg_extension AS e
JOIN pg_namespace AS n
  ON n.oid = e.extnamespace
WHERE e.extname IN ('pg_trgm', 'vector')
ORDER BY e.extname;

控制文件可见性:

SELECT
  name,
  version,
  installed,
  superuser,
  trusted,
  relocatable
FROM pg_available_extension_versions
WHERE name IN ('pg_trgm', 'vector')
ORDER BY name, version;

两份目录回答不同问题:

  • pg_available_extension_versions:server 支持文件允许哪些版本;
  • pg_extension:当前 database 创建了哪个对象版本。

安装权限不要交给应用

本章角色边界:

platform/admin:
  package / untrusted extension / lifecycle

pg36_owner (NOLOGIN or controlled SET ROLE):
  schema, tables, reviewed migrations

pg36_app:
  SELECT search interface
  no table DML
  no extension lifecycle

pg36_appproduct_search UPDATE 必须返回:

SQLSTATE 42501
permission denied for table product_search

真实商品服务当然需要写入,但写角色不等于在线读角色必须拥有表级任意 UPDATE。 可使用:

  • 受控迁移角色;
  • 列级权限;
  • 参数化函数;
  • 独立异步 embedding worker;
  • 队列/作业表状态机。

尤其不要让业务请求角色创建、升级或删除扩展。

证据边界

本章正式运行:

Homebrew PostgreSQL 18.6
direct PostgreSQL service
Pigsty 4.5 docs reviewed
Pigsty L1 execution not run

所以 manifest 明确:

validation_path=direct-postgresql
pigsty_l1=not-run

移植到 Pigsty 后要补:

inventory commit
repository/package resolution
every-node file/version/hash
database extension catalog
primary/replica query
failover/failback
backup clean restore

15.6.2 观察索引体积、构建、查询和维护

先列出四类物理对象

本章商品表有:

heap + toast (if needed)
FTS GIN
title trigram GIN
embedding HNSW
active/category partial B-tree

基础盘点:

SELECT
  pg_relation_size('shop_ch15.product_search') AS heap_bytes,
  pg_total_relation_size('shop_ch15.product_search') AS total_bytes,
  pg_relation_size('shop_ch15.product_search_fts_idx') AS fts_bytes,
  pg_relation_size('shop_ch15.product_search_title_trgm_idx') AS trgm_bytes,
  pg_relation_size('shop_ch15.product_search_embedding_hnsw_idx') AS hnsw_bytes;

17 行夹具看到的 8/16/32 KiB 级数字主要由最小页分配决定,不能计算 “每百万商品多少 GiB”。size-catalog.csv 只是证明对象存在且非零。

生产 size baseline 应记录:

row count and active count
average text/vector width
model dimension/type
index definition/options
data and query distribution
build/update age
server/filesystem/compression

否则两个 size 数字不可比较。

构建要观察过程与阻塞

初始离线载入通常:

load data
-> analyze
-> create indexes

比逐行维护索引高效。在线已有表则通常考虑:

CREATE INDEX CONCURRENTLY ...

它减少对写入的阻塞,却有更长构建时间、额外扫描、资源占用和失败状态。运行时 观察:

SELECT
  pid,
  datname,
  relid::regclass,
  index_relid::regclass,
  command,
  phase,
  lockers_total,
  lockers_done,
  blocks_total,
  blocks_done,
  tuples_total,
  tuples_done
FROM pg_stat_progress_create_index;

并结合:

SELECT *
FROM pg_locks
WHERE pid = :builder_pid;

发布后必须确认 pg_index.indisvalid/indisready/indislive,不能只看 DDL client exit。

查询观测分机械与用户两层

机械层:

EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS)
SELECT ...;

看:

  • index/seq/bitmap path;
  • actual rows 与估算;
  • filter removed rows;
  • buffers 与 temp I/O;
  • planning/execution time;
  • 生效的非默认 settings。

用户层:

query segment
candidate/result count
quality metric
P50/P95/P99 latency
timeout/error/no-result
model/index/release version

执行快但 recall 差,不是成功;quality 高但 P99 超时,也不是成功。

Pigsty 默认启用/预加载的 pg_stat_statements 可帮助聚合 SQL 调用与耗时, 但要为检索 query 保持可归一的 SQL 形状,并将业务 query id/segment 放在 受控日志或 trace,而不是拼进 SQL 注释造成 statement 指纹爆炸。

写入成本要单独压测

文本变化会更新两个 GIN 和 generated tsvector;向量变化会更新 HNSW。 测:

rows/s
WAL bytes/row
CPU
index growth
autovacuum cadence
dead tuples
replica replay lag
checkpoint/write latency

可以在受控事务前后比较:

SELECT pg_current_wal_lsn();
-- representative batch
SELECT pg_current_wal_lsn();

再用 pg_wal_lsn_diff 估算该受控批次产生的 WAL。并发生产环境还有其他 事务,不能把全局 LSN 差未经隔离就归给一个作业。

HNSW 维护不是普通 B-tree 的复制粘贴

pgvector 官方说明 HNSW vacuum 可能耗时,并给出先 REINDEX INDEX CONCURRENTLYVACUUM 的一种加速建议。这是需要谨慎评估的维护动作, 不是每晚固定模板:

  • reindex 需要额外磁盘与构建资源;
  • concurrently 有更长窗口和失败恢复;
  • vacuum 仍要处理 heap;
  • 副本会重放相关 WAL;
  • 索引重建期间 recall/latency 要观测;
  • 新 index 的参数与 opclass 必须一致。

先在代表性副本/演练环境测周期,再决定维护阈值。

监控矩阵

维度 PostgreSQL 证据 Pigsty/平台视图
SQL 调用/耗时 pg_stat_statements、logs dashboard/alerts
执行计划 EXPLAIN、auto_explain 集中日志
index 使用 pg_stat_user_indexes 表/索引面板
表生命周期 pg_stat_user_tables vacuum/bloat 面板
构建进度 pg_stat_progress_create_index 变更任务证据
WAL/副本 LSN、replication views HA/replication 面板
主机资源 PostgreSQL/OS stats node/PG exporter
质量/ANN recall 自建 eval job release/SLO dashboard

最后一行不能由通用数据库 exporter 自动推导。检索质量是业务测量,必须把 evaluation job 当成一等生产组件。

15.6.3 外部嵌入生成的权限、费用与数据边界

不要从数据库触发器同步调用外部 API

一个危险设计:

UPDATE product title
  -> trigger
  -> HTTP embedding API
  -> wait inside transaction

它把:

  • 外部网络延迟;
  • rate limit;
  • provider outage;
  • 费用;
  • 密钥;
  • 不确定重试;

放进数据库锁与事务寿命。远端已收费成功、本地事务却回滚时,还会出现不可 原子化的副作用。

更稳健的异步结构:

transaction:
  update source text
  record source_text_hash / desired_model / pending job
  commit

worker:
  claim job with bounded lease
  build exact versioned input
  call model service
  validate dimension/norm
  write vector + model_id + source_hash
  mark success or retry state

可用 outbox、作业表或消息系统实现;第 13 章已经讨论过触发器与异步边界。

幂等身份

一次 embedding 任务的自然 key 可以是:

(document_id, source_text_hash, model_id, template_version)

写回时做 compare-and-set:

UPDATE product_embedding
SET embedding = :vector,
    status = 'ready',
    embedded_at = clock_timestamp()
WHERE product_id = :id
  AND source_text_hash = :hash
  AND model_id = :model
  AND status = 'processing';

如果源文本在推理期间变化,旧结果不能覆盖新版本。重试同一 key 应复用已完成 结果或安全 upsert,避免重复收费。

worker 最小权限

embedding worker 通常需要:

  • 读取允许外发的字段;
  • 读取/更新自己的 job;
  • 写特定 vector/model/status 列;
  • 不需要 DDL、扩展 owner、超级用户;
  • 不需要任意读其他敏感 schema。

API secret 放在 secret manager/受控运行环境,不存进 SQL、YAML 仓库、表 comment 或 evidence manifest。Pigsty 负责 PostgreSQL 平台,不意味着模型 密钥应注入数据库 server 进程。

若文本不能离开边界,可以:

  • 在受控网络自托管模型;
  • 只对批准字段生成;
  • 做脱敏/分区处理;
  • 或拒绝向量方案。

“先接 API,之后再补合规”不是试点策略。

费用模型

至少估算:

[ Cost = InitialBackfillTokens \times Price

  • DailyChangedTokens \times Price
  • QueryTokens \times Price
  • RetryWaste
  • Storage/Index/Compute ]

还包括:

  • backfill 期间 API 并发与限流;
  • 模型升级全量重算;
  • 双版本存储与索引;
  • 失败重试和重复调用;
  • query embedding cache;
  • 数据出口与网络;
  • 本地推理 GPU/CPU 和运维。

费用控制要有:

per-job token/byte limit
daily/project budget
rate/concurrency limit
retry ceiling and dead-letter state
backfill pause/resume
cost attribution by model/version

缓存 query vector 时,key 必须包含 exact normalized text、model、template 与 版本;只按原始字符串缓存会在模型切换时串用旧空间。

数据生命周期要双向传播

删除或更正 source document 时,检查:

primary row
vector row/index
job queue and retry payload
query/result caches
logs/traces
offline evaluation exports
backups and retention
provider-side retained data

物理删除在 PostgreSQL 中还受 MVCC、vacuum、WAL 与备份保留影响。法律上的 删除承诺必须和备份/恢复政策一致,不能只执行一条 DELETE

故障与降级

模型服务不可用时,搜索不一定要整体不可用:

vector generation outage:
  keep last valid vector with staleness marker
  queue updates

query embedding outage:
  fall back to FTS + trigram
  expose degraded-mode metric

ANN/index incident:
  exact path for small filtered sets
  or lexical-only bounded fallback

每种 fallback 都要在离线标注集上测质量、在线压测容量,且不能放宽权限过滤。

上线前问题

  • 哪些字段能外发,依据是什么?
  • provider 是否保留输入/输出、是否用于训练?
  • 模型版本是否可固定,变更如何通知?
  • 单次、每日、回填、升级的费用上限是什么?
  • 谁能读原文、调用服务、写 vector?
  • 如何证明 vector 对应当前 source hash?
  • 失败如何重试,何时进入 dead letter?
  • 模型切换如何双写、评估、回退?
  • 删除、备份与 provider 侧数据如何协调?
  • 无模型服务时,业务还能提供什么质量的结果?

回答不了这些问题时,向量 PoC 可以继续离线,不能进入生产写链路。


上一节:混合检索与排序验证 · 返回本章目录 · 下一节:实战:pg36_shop 商品混合检索 PoC · 查看全书目录 · 查看索引中心

15.7 实战:`pg36_shop` 商品混合检索 PoC

本节把前六节压成一个可审计的 1.3-proposal

frozen inputs
  -> deterministic schema/index/ranking
  -> exact quality golden
  -> separate ANN probe
  -> application privilege failure
  -> reset guards
  -> exact reset
  -> rebuild and re-review

正式证据来自 Homebrew PostgreSQL 18.6 的受控开发数据库。Pigsty 4.5 的 声明与运维职责已经映射,但本地没有运行 L1,因此不把直接 PostgreSQL 结果 伪装成 Pigsty 集群验收。

破坏边界

task.sh all 会删除并重建带精确 marker 的 shop_ch15。它不删除 pg_trgmvector 或第 14 章 schema,只适合本书本地/开发夹具。 生产不得执行这条“删后重建”路径。

15.7.1 用冻结向量离线复现实验

前置状态

本章沿用前章 libpq service:

[pg36-admin]
host=/path/to/socket-or-host
port=5432
dbname=pg36_shop
user=postgres
chmod 600 /path/to/pg_service.conf
export PGSERVICEFILE=/path/to/pg_service.conf
export PGSERVICE=pg36-admin

不要把密码放进命令行、脚本或 evidence。

context.sql 要求:

database = pg36_shop
writable instance
PostgreSQL major = 14..18
session user = superuser
can SET ROLE pg36_owner
ch04-v1 physical model exists
pg36_app = constrained non-superuser LOGIN
shop_ch14 marker is exact
pg_trgm = 1.6 in shop_ch14 with exact marker
vector = 0.8.4 in shop_ch14 with exact marker

任何一项不符就停止。本章不会“顺便安装一个更接近的版本”,因为那会同时 改变扩展与检索两个变量。

输入清单

fixture-manifest.json 固定:

corpus:
  17 rows
  sha256=7136d6f1705e560c5d564407926b44c455cd59b3883cd640f4f51599806a90c8

queries:
  8 rows
  sha256=4b54b0ee322bf52649b5c682d468ea024ae301d8cac40d63aaaed908c9d8a45d

judgments:
  24 rows
  sha256=657ddc4d82f9b42af589fd64a6326407d5cfb6536b2f2c3c28862279eca41438

loader:
  sha256=2548978f3652f816452f22aed0d560a0d0263234a2f62a8fb77d4009c5545787

这些 hash 识别的是仓库输入。proposal checksum 识别的是版本、方法、质量和 验收合同,二者不要混淆。

单步建立

./static/labs/ch15/task.sh setup

setup 先检查已有 shop_ch15

  • schema owner 必须是 pg36_owner

  • schema comment 必须是:

    pg36 ch15 search quality lab; safe to rebuild
  • relation/index/view 必须在 21 个对象白名单中;

  • 每个对象必须有同一 marker;

  • schema 不能有未知 routine/operator/opclass。

碰撞保护通过后,它按依赖顺序删除旧视图/表/schema,不用 CASCADE,再以 pg36_owner 创建。

核心表:

CREATE TABLE shop_ch15.product_search (
  product_id bigint PRIMARY KEY,
  sku text NOT NULL UNIQUE,
  category text NOT NULL,
  active boolean NOT NULL,
  title text NOT NULL,
  description text NOT NULL,
  embedding shop_ch14.vector(4) NOT NULL,
  embedding_model text NOT NULL,
  search_document tsvector
    GENERATED ALWAYS AS (...) STORED
);

CREATE TABLE shop_ch15.eval_query (...);
CREATE TABLE shop_ch15.relevance_judgment (...);
CREATE TABLE shop_ch15.fixture_meta (...);

索引:

product_search_fts_idx              GIN pg_catalog.tsvector_ops
product_search_title_trgm_idx       GIN shop_ch14.gin_trgm_ops
product_search_embedding_hnsw_idx   HNSW shop_ch14.vector_l2_ops
product_search_filter_idx           partial B-tree WHERE active

排名/质量视图:

lexical_ranking
fuzzy_ranking
vector_exact_ranking
hybrid_rrf_ranking
all_ranking
quality_per_query
quality_summary

完成摘要:

status=fixture-ready
products=17
queries=8
judgments=24

逐字节回读

PG36_EVIDENCE_DIR="$PWD/evidence/ch15-cycle" \
  ./static/labs/ch15/task.sh evaluate

它用 COPY ... TO STDOUT CSV HEADER 从数据库导出 corpus/query/judgment, 再执行:

cmp frozen-corpus.csv evidence/corpus.csv
cmp frozen-queries.csv evidence/queries.csv
cmp frozen-judgments.csv evidence/judgments.csv

这验证:

  • SQL loader 没有手工录错;
  • vector::text 可稳定导出;
  • 布尔、字符串、顺序和 rationale 都一致;
  • 重建后数据身份没有漂移。

如果 CSV 行顺序没有显式 ORDER BY,逐字节比较没有意义。本章三条 export 分别按 product id、query id、query/product id 排序。

为什么不调用真实模型

若测试运行时调用外部 embedding API:

  • provider 可能改变输出;
  • 网络/限流会让数据库实验不稳定;
  • 凭据和费用进入教学流程;
  • 无法区分模型漂移与 SQL 漂移;
  • 离线读者无法复现。

所以本章把真实模型评估留作迁移任务。读者要替换为真实向量,应复制 ch15-search-v1 为新 fixture/version,保留旧 golden,不要覆盖四维输入后 继续沿用本章 checksum。

15.7.2 比较全文、模糊、向量与混合结果

查看词法解析

psql "service=pg36-admin" \
  -f static/labs/ch15/fts-analysis.sql

固定结果:

query parsed tsquery matches
q01 'wireless' & 'headphon' 1
q02 'wirel' & 'hedphon' 0
q03 'music' & 'go' 1
q04 'coffe' & 'bean' & 'grinder' 1
q05 'make' & 'espresso' & 'home' 1
q06 'trail' & 'hydrat' 2
q07 'postgr' & 'databs' & 'tune' 0
q08 'semant' & 'nearest' & 'neighbor' 1

q02/q07 的 lexeme 并不会因为“看起来像错拼”自动改正。这个证据把 FTS 零召回定位在语言处理层,不是 GIN index 故障。

前三名全景

固定 product id:

query lexical fuzzy exact vector hybrid RRF
q01 1 1,3,2 2,3,1 1,2,3
q02 3,1,2 2,3,1 3,2,1
q03 2 1,2,3 2,3,1 2,1,3
q04 4 4,6,14 5,6,4 4,6,5
q05 5 5,6,4 5,6,4 5,6,4
q06 7,8 7,8,15 8,9,7 7,8,9
q07 10,11,12 11,10,12 10,11,12
q08 12 12,10,11 12,11,10 12,10,11

是无行,不是三条零分结果。

注意 q04:

fuzzy third = product 14 / Digital Coffee Scale / unjudged
vector first = product 5 / Home Espresso Machine / grade 1
hybrid       = 4,6,5 / all judged relevant

这是互补成功例。

注意 q02:

fuzzy puts grade-3 product 1 first
hybrid puts grade-2 product 3 first

这是融合退化例。两种都必须写进报告。

质量结果

SELECT *
FROM shop_ch15.quality_summary
ORDER BY strategy;

得到:

strategy queries P@3 R@3 MRR@3 mean NDCG@3 min NDCG@3
fuzzy 8 .916667 .916667 1 .942881 .842828
hybrid_rrf 8 1 1 1 .962929 .759192
lexical 8 .291667 .291667 .75 .613043 0
vector_exact 8 1 1 1 .817314 .631039

质量视图把未返回 query 保留为 0,避免 survivorship bias。

计划证据

psql "service=pg36-admin" \
  -f static/labs/ch15/fts-plan.sql

psql "service=pg36-admin" \
  -f static/labs/ch15/trigram-plan.sql

psql "service=pg36-admin" \
  -f static/labs/ch15/vector-exact-plan.sql

psql "service=pg36-admin" \
  -f static/labs/ch15/vector-hnsw-plan.sql

psql "service=pg36-admin" \
  -f static/labs/ch15/vector-filtered-plan.sql

断言:

FTS      Bitmap Index Scan product_search_fts_idx
trigram  Bitmap Index Scan product_search_title_trgm_idx
exact    Seq Scan + Sort
HNSW     Index Scan product_search_embedding_hnsw_idx
filtered HNSW Index Scan + Filter

这些文件通过禁用 planner 备选路径建立“能力证明”。正常 17 行查询走顺序 扫描很合理;不要把强制 index plan 贴成性能结果。

目录证据:

SELECT
  index_name,
  access_method,
  operator_class,
  is_valid,
  is_ready,
  is_live
FROM (... index-catalog.sql ...);

最终四个 index 都必须 valid/ready/live,opclass 与查询 operator 一致。

ANN 对照

psql "service=pg36-admin" \
  --csv \
  -f static/labs/ch15/ann-compare.sql

结果:

exact_ids,ann_ids,recall_at_3
"7,8,9","7,8,9",1.000000

这个 probe:

  • 固定 q06 与 outdoor/active filter;
  • exact 结果来自全量 window ranking;
  • ANN 强制 HNSW;
  • 用集合交集测 recall。

它没有报告毫秒,因为 tiny fixture 的时间不稳定且无业务意义。

应用权限

psql "service=pg36-admin user=pg36_app" \
  -f static/labs/ch15/app-query.sql

应用可读 q02/q08 混合结果和质量摘要。

未授权更新:

UPDATE shop_ch15.product_search
SET title = 'unauthorized mutation'
WHERE product_id = 1;

必须:

psql exit=3
SQLSTATE=42501
permission denied for table product_search

自动化不是只检查非零 exit;它同时检查精确 SQLSTATE,避免连接失败或语法 错误被误当成权限测试通过。

15.7.3 输出 ADR、质量证据、生产代价与退出路径

ADR 结论

search-adr.md 决定:

FTS:
  pg_catalog.english
  stored weighted tsvector
  title A / description B
  GIN

fuzzy:
  lower(title)
  similarity + word_similarity
  GIN candidate predicate
  production must add a qualified threshold

vector:
  versioned model identity
  L2
  exact for quality golden
  HNSW only as measured serving candidate

fusion:
  equal-weight RRF
  k=60
  source depth=4
  final depth=3

它还明确否决:

  • ILIKE 替代完整检索;
  • 只用 FTS;
  • 只用向量;
  • 未校准原始分数直接相加;
  • 用 HNSW 结果生成质量 golden。

proposal 是版本化合同

baseline-v1.3-proposal.json 固定:

  • PostgreSQL/extension/Pigsty 参考版本;
  • fixture/model/距离;
  • index 与权限;
  • 四种质量指标;
  • ANN probe;
  • business checksum;
  • evidence 文件清单;
  • reset 边界;
  • 明确 limitation。

canonical JSON checksum:

bf92a6ad0f60dc3e125b39dbf67bf4d6c5e50275192bd01a7ca4c50d142f822e

更改 key 顺序或空白不会改变 canonical checksum;更改合同值会改变。

最终数据库状态:

release=1.3-proposal
fixture=ch15-search-v1
embedding_model=pg36-handcrafted-topic-4d-v1
products=17
active_products=16
queries=8
judgments=24
business_checksum=c637abf09edba88b7793f91201a57c34

business checksum 不包含运行时间、OID、index bytes 或绝对路径。

evidence 目录

一轮完整采集包含:

manifest.txt
setup.txt
corpus.csv
queries.csv
judgments.csv
document-catalog.csv
fts-analysis.csv
index-catalog.csv
size-catalog.csv
security-catalog.csv
quality-summary.csv
quality-detail.csv
ranking-results.csv
ann-compare.csv
fts-plan.txt
trigram-plan.txt
vector-exact-plan.txt
vector-hnsw-plan.txt
vector-filtered-plan.txt
app-query.csv
app-write.{exit,stdout,stderr}
final-state.csv
verify.txt
review.txt

manifest.txt 记录 server、database、in-recovery、扩展版本、模型、Pigsty 证据边界、proposal checksum、fixture manifest checksum 与全部实验源文件 SHA-256。

review.py 不信任脚本“跑完了”,它重新解析证据并断言:

  • 三份 export 与 source byte-identical;
  • manifest hash 与真实文件一致;
  • 17/8/24 行数;
  • 唯一 inactive 商品为 17;
  • 四种索引 AM/opclass/状态/marker;
  • 应用 ACL;
  • FTS match counts;
  • 32 个策略×查询质量格;
  • 79 条实际 top-3 ranking 记录;
  • hybrid 每个 query 的 id 次序;
  • ANN exact/approx intersection;
  • 五份计划的关键 node;
  • 42501 权限失败;
  • final checksum 与 verify summary。

生产代价清单

当前 proposal 给出性能线,因为本地 17 行不能回答:

latency/throughput under representative concurrency
heap/GIN/HNSW size at target scale
bulk and concurrent build duration
steady write/WAL cost
autovacuum/reindex cost
replica replay/failover
clean backup restore
real model quality and generation cost

迁移到业务数据后,ADR 需要附:

领域 最小证据
质量 frozen + fresh queries,分段 P/R/MRR/NDCG
ANN exact-relative recall curve
查询 P50/P95/P99、timeout、buffers、CPU
写入 rows/s、WAL/row、索引增长、vacuum
HA replica query、lag、switchover/failback
恢复 clean environment restore
模型 version/input/license/cost/failure
安全 tenant/ACL canaries、secret/data boundary

精确退出

手工 reset 需要两个一致的显式确认:

PG36_RESET_TOKEN=RESET_CH15_SEARCH_LAB \
PG36_RESET_TARGET=pg36_shop/shop_ch15 \
PG36_EVIDENCE_DIR="$PWD/evidence/ch15-reset" \
  ./static/labs/ch15/task.sh reset

还会检查:

  • context/database/writable instance;
  • schema owner 与 marker;
  • 21 个 relation 的精确白名单与 marker;
  • 未知 routine/operator/opclass;
  • application_name LIKE 'pg36-ch15-%' 的其他活跃 worker。

错误分别用自定义 SQLSTATE:

P3660 invalid action token
P3661 invalid target
P3662 identity/inventory collision
P3663 active workers

删除顺序:

quality views
-> ranking views
-> relevance/query/product/meta tables
-> shop_ch15 schema

没有 CASCADE。最终必须:

remaining_schema=0
preserved_extensions=pg_trgm:1.6,vector:0.8.4

生产退出不是 DROP schema。生产要先:

stop/read-switch vector path
export and verify vectors/model identity
remove async generation traffic
drop/rebuild indexes online
observe fallback quality/capacity
retain rollback window
then remove no-longer-needed objects/packages

15.7.4 验收采用 checklist:evidence,不设脱离场景的性能线

为什么不给“必须 10 ms”

延迟取决于:

rows and dimensions
document/vector width
cache state
hardware/storage
concurrency
filters and selectivity
candidate K
index params
quality/recall target
write workload
network/application path

在 17 行、热缓存、本地 socket 上测到的微秒/毫秒,既不能预测一亿行,也 不能作为读者机器失败线。硬写一个数字只会诱导为过测试而牺牲质量或关闭 安全过滤。

因此本章把 15.7.4 的规则定义为:

checklist:evidence

即每一类能力都必须有可复核证据;业务上线再给每项填入自己的 SLO。

本地机制验收

检查 证据 固定结论
输入身份 manifest + byte cmp 17/8/24,三份一致
FTS 行为 parsed query/match table q02/q07 零命中
fuzzy 行为 ranks/quality mean NDCG .942881
vector quality exact ranking Recall@3 1
fusion RRF ranks/quality mean NDCG .962929,有 q02 退化
ANN 机制 exact vs HNSW q06 Recall@3 1,仅单点
index 机制 catalog + forced plans GIN/GIN/HNSW 路径存在
filtering canary 17 所有排名均不出现
ACL catalog + expected failure read-only,UPDATE 42501
reset token/target/active tests P3660/P3661/P3663
rebuild second full cycle checksum 相同
Pigsty manifest L1 not run

这一表中没有任何一项可被“SQL 返回了三行”替代。

业务场景性能卡

移植时创建一份场景卡:

dataset:
  products: ...
  active_ratio: ...
  dimensions: ...
  update_rate: ...
query:
  segments: ...
  filters/selectivity: ...
  top_k: ...
quality:
  min_recall_at_k: ...
  min_ndcg_at_k: ...
  max_segment_regression: ...
ann:
  min_exact_relative_recall: ...
latency:
  p50: ...
  p95: ...
  p99: ...
capacity:
  peak_qps: ...
  write_rate: ...
operations:
  max_build_window: ...
  max_replica_lag: ...
  restore_rto/rpo: ...
cost:
  monthly_model_budget: ...

数字必须来自业务 owner/SLO 和代表性测试,而不是本书替读者决定。

L1 验收增量

在 Pigsty L1 上补:

inventory commit and rendered config
package resolution for target OS/PG major
control/library hashes on every node
pg_extension catalog in target database
primary and replica search query
monitoring dashboards and alerts
planned switchover and failback
backup and clean restore
extension/model/index upgrade rehearsal

若某项未运行,报告应写 not-run,而不是 pass

双周期正式运行

evidence_dir="$(mktemp -d /tmp/pg36-ch15-evidence.XXXXXX)"

PG36_EVIDENCE_DIR="$evidence_dir" \
  ./static/labs/ch15/task.sh all

只把新建的专用 evidence 路径传给实验;不要把仓库根或广泛目录当目标。

all 的内部顺序:

cycle-1 collect + review
-> wrong token reset must fail P3660
-> wrong target reset must fail P3661
-> active worker reset must fail P3663
-> exact reset
-> verify extensions preserved
-> cycle-2 collect + review

正式结果:

status=ok
fixture=frozen-byte-identical
quality=precision+recall+mrr+ndcg
ranking=fts+trigram+exact-vector+rrf
ann=q06-exact-vs-hnsw-recall-1.000000
guards=P3660+P3661+P3663
extensions=ch14-preserved
pigsty_l1=not-run
release_candidate_checksum=bf92a6ad0f60dc3e125b39dbf67bf4d6c5e50275192bd01a7ca4c50d142f822e

第二轮完成后数据库保留可查询的 shop_ch15 最终状态,便于继续第 16 章; evidence 保留两个独立 cycle,证明 reset 后不是依赖第一次残留才通过。

最终评审句

本章可以得出的最强结论是:

1.3-proposal 在固定 PostgreSQL 18.6、本章扩展版本与合成 fixture 上, 两轮可重复;全文、模糊、精确向量、RRF、HNSW 测量路径、权限与精确复位 均有证据。它可以进入真实语料与 Pigsty L1 的下一阶段试点,尚未获得生产 性能与真实模型质量批准。

这比一句“PostgreSQL 可以做混合搜索”更窄,也更有用。


上一节:扩展部署与运行代价 · 返回本章目录 · 下一章:经天纬地:时序、空间与时空查询 · 查看全书目录 · 查看索引中心

16 经天纬地:时序、空间与时空查询

“时间”与“空间”都很容易被压缩成错误的表结构:

created_at timestamp,
longitude  numeric,
latitude   numeric

这三个字段看起来够用,却没有回答最关键的问题:

created_at 是事情发生、服务器接收,还是规则生效的时间?
timestamp 表示绝对时刻,还是某地墙上时间?
经纬度遵守哪个坐标参考系,顺序与单位是什么?
边界上的点算区域内还是区域外?
距离是角度、米,还是某个投影坐标系的单位?
历史查询应使用今天的围栏,还是当时生效的围栏版本?

一旦业务需要处理夏令时、迟到、乱序、重复写入、围栏换版或距离筛选,这些 未回答的问题就会从“数据建模细节”变成错误结果。

本章建立一条统一原则:

先固定时间与空间语义,再选择分区、扩展和索引;先证明逻辑答案,再证明 物理路径;最后才讨论容量和性能。

本章完成后

你应当能够:

  • 区分事件时间、接收时间、处理时间和业务有效时间;
  • 选择 timestamptztimestamp,解释 PostgreSQL 的存储、输入和显示 时区职责;
  • 用一次夏令时跳变说明“墙上时间差”为什么不等于实际经过时间;
  • 识别迟到、乱序和重复是三个不同问题,并分别设计水位线、重算与幂等合同;
  • tstzrange 和半开区间 [) 表达无歧义的有效期;
  • 选择事件时间作为分区键,写出可裁剪的半开范围谓词;
  • 从计划中区分“只访问一个分区”与“扫描所有分区后再过滤”;
  • 解释原生分区、聚合与 TimescaleDB 解决的问题边界;
  • 区分 PostGIS geometrygeography 的计算模型和单位;
  • 说明 SRID 是坐标参考身份,ST_SetSRID 不会转换坐标;
  • ST_CoversST_ContainsST_IntersectsST_DWithinST_Distance<-> 之间按业务语义选择;
  • 解释包围盒候选与精确几何判断的二阶段关系;
  • 用 GiST/SP-GiST 计划证明路径存在,同时不把小表强制计划冒充性能基准;
  • 把事件时间裁剪、围栏有效期与空间谓词组合成可审计的时空查询;
  • 在 Pigsty 中区分扩展装包、preload、CREATE EXTENSION、版本核对与 L1 节点一致性;
  • 把 PostGIS 纳入备份恢复、大版本升级、WAL、索引和副本成本;
  • 交付一个有冻结输入、反例、计划、权限、校验和、ADR 与精确复位路径的 配送事件 PoC。

贯穿本章的配送事件

实验固定三种时间:

语义 用途
occurred_at 配送事件实际发生时刻 业务排序、分区、历史归属
received_at 该写入尝试被接收的时刻 迟到、乱序、重放审计
valid_during 围栏版本生效区间 历史时点连接

固定两种空间表示:

表示 本章职责
geometry(..., 4326) 拓扑谓词、边界判断、空间索引
geography(..., 4326) 以米为单位的距离判断

冻结 fixture 包含:

13 ingest attempts
12 distinct delivery events
 4 geofence versions across 3 zones
 3 delivery hubs
 3 daily UTC partitions

13 次尝试中,e003 被发送两次;数据库保留尝试事实,再选出唯一规范事件。 三张日分区分别得到 1 / 7 / 4 行。e008 发生在 2026-03-08 23:59:59Ze009 正好发生在次日 00:00:00Z,用来证明 半开分区边界。

一个十分钟却跨过两小时刻度的例子

纽约在 2026-03-08 进入夏令时。fixture 中:

事件 UTC America/New_York 显示
e002 06:55Z 01:55
e003 07:05Z 03:05

墙上时间从 01:55 跳到 03:05,看起来相隔 70 分钟;两个绝对时刻实际只相隔 600 秒。实验把两项都保存为证据:

dst_e002_local=2026-03-08 01:55:00
dst_e003_local=2026-03-08 03:05:00
dst_elapsed_seconds=600

PostgreSQL 的日期时间类型与时区转换规则以官方 Date/Time TypesDate/Time Functions 为准。应用程序不应自己维护一份简化时区规则。

围栏边界不是实现细节

e003 位于 central v1 的东边界,也位于 east v1 的西边界。固定结果是:

区域 ST_Covers ST_Contains ST_Touches
central v1 true false true
east v1 true false true

本章选择 ST_Covers,所以边界算命中,e003 会同时属于两个区域。这是业务 合同,不是 PostGIS 替业务做出的唯一正确选择。若配送系统要求唯一归属,还要 增加优先级、分区化面集或确定性消歧。

另一个版本例子:

central v1  [2026-03-07 00:00Z, 2026-03-08 12:00Z)
central v2  [2026-03-08 12:00Z, 2026-03-10 00:00Z)

e004 在扩张前位于旧围栏外;同一点的 e005 正好在 12:00 发生,按 [) 落入 v2 并位于新围栏内。同一 zone_id 的有效期由 btree_gist 排他约束 禁止重叠,重叠写入固定失败为 SQLSTATE 23P01

逻辑正确与物理路径分开

时间查询的正确写法是直接约束分区键:

WHERE occurred_at >= TIMESTAMPTZ '2026-03-08 00:00:00+00'
  AND occurred_at <  TIMESTAMPTZ '2026-03-09 00:00:00+00'

固定计划只出现:

delivery_event_20260308

把列包进表达式:

WHERE (occurred_at AT TIME ZONE 'UTC')::date = DATE '2026-03-08'

逻辑答案仍是七行,但计划通过 Append 访问三张分区。PostgreSQL 官方分区 文档强调,裁剪依据是分区边界,而不是分区上的普通索引;写法必须让规划器或 执行器能够把谓词与分区键对应。参见 Table Partitioning

空间计划分别证明:

ST_DWithin geography -> event_20260308_geog_gist_idx
ST_DWithin geometry  -> delivery_hub_location_spgist_idx
ST_Covers join       -> event_20260308_location_gist_idx
zone_id lookup       -> geofence_version_no_overlap

这些计划在 12 行 fixture 上关闭顺序扫描,仅证明路径可用。正常规划器选择 顺序扫描并不表示索引失效,更不能用强制计划声称生产更快。

实验资产

规范与决策:

冻结输入:

实现与证据:

三份 CSV 不只是示例附件。自动化从数据库重新导出并逐字节比较; fixture-manifest.json 还固定行数、SHA-256、时间合同、坐标合同和许可证 边界。

快速运行

本地开发数据库应先完成第 4 章的角色与物理模型:

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

PG36_EVIDENCE_DIR="$PWD/evidence/ch16" \
  ./static/labs/ch16/task.sh all

all 会:

  1. 验证数据库、可写状态、PostgreSQL 14–18、管理员、owner/app 角色和 ch04-v1 模型;
  2. 核对本机恰好可供应 PostGIS 3.6.4 与 btree_gist 1.8;
  3. 只接管带精确 owner、marker、版本与扩展依赖的两个 schema;
  4. 在单事务中安装扩展、创建三张日分区、四类数据表和 13 个管理索引;
  5. 导入 13 次尝试,验证重复 payload 一致,再生成 12 个规范事件;
  6. 从数据库回读三份冻结 CSV 并逐字节比较;
  7. 采集 DST、迟到、乱序、分区路由、时间桶、围栏版本、边界和距离证据;
  8. 采集扩展、索引、权限、对象体积和五份执行计划;
  9. 证明混合 SRID、重叠有效期、应用写入分别以 XX00023P0142501 失败;
  10. 运行 34 个关系对象、扩展依赖、数据事实、索引、权限与业务校验和的 完整断言;
  11. 证明错误 token、错误 target、活跃 worker 时 reset 分别以 P3660/P3661/P3663 被拒绝;
  12. 在单事务中不用 CASCADE 精确复位,确认第 14 章扩展保留,再完整 重建第二次。

正式 Homebrew PostgreSQL 18.6 双周期证据得到:

status=ok
fixture=frozen-byte-identical
time=event+ingest+validity+dst
space=geometry+geography+srid+boundary
plans=pruning+gist+spgist+joint
guards=P3660+P3661+P3663
extensions=btree_gist:1.8+postgis:3.6.4
pigsty_l1=not-run
release_candidate_checksum=13902984b3da92a66638d0d6e2f886d6d8ac5cb20ba89ec08b1527ae79d2b923

安全边界

task.sh all 会删除并重建带本章精确 marker 的 shop_ch16shop_ch16_ext 和其中两项扩展,只适合本书本地/开发 fixture。生产环境 必须使用经过评审的扩展供应、在线分区与索引发布、备份恢复验证和回退流程。

学习路径

16.1 时间语义先于时序扩展

先学会给“时间”命名和验收。未固定语义时,引入任何时序扩展只会更快地得到 不确定答案。

16.2 时序表与时间分区

把事件时间落实到原生分区,理解裁剪、路由、生命周期和引入时序扩展的决策 门槛。

16.3 空间类型与坐标参考

先固定坐标身份、表示与单位,再允许业务写空间谓词。

16.4 空间谓词与索引

从“问题是什么”推导谓词,再从谓词和数据分布推导索引,不反过来。

16.5 时空联合查询是本章收束目标

把事件时间、围栏有效时间和空间命中合成同一条可解释查询。

16.6 时空扩展的交付与观察

把本地 SQL 映射到 Pigsty 的装包、配置、建库、节点一致性和运行证据。

16.7 实战:配送事件的时空 PoC

最后完整执行两周期 PoC,并明确哪些结论已证明、哪些仍需生产规模测试。

权威参考


上一章:见微知著:全文、模糊与向量检索 · 返回上卷导读 · 下一章:合纵连横:分析加速与分布式选型 · 查看全书目录 · 查看索引中心

16.1 时间语义先于时序扩展

时序系统首先是时间语义系统,其次才是高吞吐写入、压缩或连续聚合系统。

如果一张表只有 created_at,读者无法判断:

  • 它由设备、应用还是数据库生成;
  • 它表示业务发生、消息到达、事务提交还是规则生效;
  • 它能否用于重建业务顺序;
  • 它是否适合成为分区键;
  • 迟到事件应修改旧聚合,还是被丢弃;
  • 修改历史维表后,旧事件是否要重新归属。

本节先建立一套可以写入 schema、查询和验收证据的时间词汇。

16.1.1 事件时间、处理时间与有效时间

一条事实至少可能有四只钟

时间 回答的问题 常见来源
事件时间 occurred_at 业务世界何时发生? 设备、业务服务、领域事件
接收时间 received_at 本系统何时看见这次尝试? API/消息消费者入口
处理时间 processed_at 某处理阶段何时完成? worker、ETL、聚合任务
有效时间 valid_during 某规则/版本何时适用? 业务配置、主数据版本

接收时间是处理时间的一种边界,但不要因此把所有处理阶段都压成一个字段。 例如:

device occurred_at
  -> gateway received_at
  -> queue enqueued_at
  -> consumer processed_at
  -> database committed_at

每一项都可能有诊断价值,却不都应成为业务查询的默认时间。配送事件的“当天 发生量”通常按 occurred_at;消息积压通常按 received_at - occurred_atprocessed_at - received_at;围栏归属还要用 valid_during

本章的最小模型

setup.sql 创建:

CREATE TABLE shop_ch16.ingest_attempt (
  attempt_id      text PRIMARY KEY,
  event_id        text NOT NULL,
  occurred_at     timestamptz NOT NULL,
  received_at     timestamptz NOT NULL,
  courier_id      text NOT NULL,
  event_type      text NOT NULL,
  longitude       numeric(9,5) NOT NULL,
  latitude        numeric(8,5) NOT NULL,
  source_sequence bigint NOT NULL,
  CHECK (received_at >= occurred_at)
);

CREATE TABLE shop_ch16.event_registry (
  event_id             text PRIMARY KEY,
  canonical_attempt_id text NOT NULL REFERENCES shop_ch16.ingest_attempt,
  first_received_at    timestamptz NOT NULL,
  last_received_at     timestamptz NOT NULL,
  attempt_count        integer NOT NULL,
  payload_fingerprint  text NOT NULL
);

原始尝试与规范事件分开,保留两种真相:

transport truth: 这条消息到过几次、每次何时到
business truth: 这个 event_id 只产生一个领域事实

若直接把 event_id 设为事件表唯一键并使用 ON CONFLICT DO NOTHING, 数据库能做到幂等,却会丢失重试次数和 payload 冲突证据。本章先保存 ingest_attempt,再要求同一 event_id 的业务 payload 完全一致,选择最早 到达的尝试作为 canonical。

timestamptz 表示绝对时刻

PostgreSQL 有两种常被混淆的 timestamp:

类型 语义 是否保留输入时区名称
timestamp with time zone / timestamptz 时间线上的绝对时刻
timestamp without time zone 没有时区解释的日期与墙上时间 不适用

timestamptz 输入会依据显式偏移、时区名称或会话 TimeZone 解释成绝对 时刻;内部以统一形式保存,输出时再按当前 TimeZone 显示。原始的 America/New_YorkAsia/Shanghai 名称不会随值保存。

因此,下面两个输入表示同一个时刻:

SELECT
  TIMESTAMPTZ '2026-03-08 07:05:00+00'
    =
  TIMESTAMPTZ '2026-03-08 03:05:00-04';

结果是 true。若业务还要知道用户选择的法定时区,应另存经过校验的 IANA 时区名:

event_timezone text

不要试图从 UTC offset 反推时区。-04:00 同时可能对应多个地区,也不能 表达未来或过去的夏令时规则。

PostgreSQL 官方 日期时间类型 详细描述输入、存储与输出行为。特别要注意:一个没有偏移的字符串写入 timestamptz 时依赖会话 TimeZone,所以 API 合同应要求显式偏移。

timestamp 也有正当用途

不能把规则简化为“永远用 timestamptz”。以下值本来就不是一个已确定的绝对 时刻:

  • 商店每天 09:00 开门;
  • 用户生日 1990-05-06
  • “2027 年 5 月第一周一上午十点”这项待排程规则;
  • 一张历史文档只记录了当地时间但未知地区。

它们应使用 datetimetimestamp 或“本地日期时间 + IANA 时区 + 解析状态”的复合模型。只有在规则被具体化到某个地区和日期后,才能解析出 timestamptz

有效时间是区间,不是两个互不相关字段

本章围栏版本:

CREATE TABLE shop_ch16.geofence_version (
  zone_id      text NOT NULL,
  version      integer NOT NULL,
  valid_during tstzrange NOT NULL,
  zone_geom    geometry(Polygon, 4326) NOT NULL,
  PRIMARY KEY (zone_id, version)
);

valid_fromvalid_to 两列相比,range 把“是否包含端点、是否为空、 是否重叠”变成类型和操作符可见的合同:

valid_during @> event.occurred_at
valid_during && another_range
lower(valid_during)
upper(valid_during)

业务连接因而直接表达为:

JOIN shop_ch16.geofence_version AS zone
  ON zone.valid_during @> event.occurred_at

这回答的是“事件发生时哪个版本有效”,而不是“现在最新版本是什么”。

事务时间是另一条轴

本章没有实现完整双时态表。现实中还可能需要:

valid time: 业务上何时有效
system time: 数据库何时知道/记录这个版本

例如 3 月 10 日才补录“围栏从 3 月 8 日开始生效”,业务有效期与系统记录期 不同。若需要审计“当时系统认为什么”,应增加系统版本、审计表或不可变事件 日志,而不是覆盖旧行后只保留最终答案。

选择默认时间的判断表

问题 应优先使用
某日发生多少配送事件 occurred_at
消息积压/链路延迟 received_at - occurred_at
worker 吞吐与处理延迟 processed_at - received_at
历史事件属于哪个围栏 valid_during @> occurred_at
何时把修订写入数据库 审计/事务时间
数据保留按到达还是发生 由法规和回补合同明确,不能猜

一张表可以有多只钟,但每个查询只能在合同中明确自己使用哪一只。

16.1.2 时区、迟到、乱序与重复事件

时区是显示规则,也是输入解析规则

本章连接上下文固定:

SET TimeZone = 'UTC';
SET DateStyle = 'ISO, YMD';

这让导出和校验和稳定,不意味着用户只能看 UTC。展示时显式转换:

SELECT
  event_id,
  occurred_at,
  occurred_at AT TIME ZONE 'America/New_York' AS local_time
FROM shop_ch16.delivery_event
WHERE event_id IN ('e002', 'e003')
ORDER BY occurred_at;

AT TIME ZONE 的返回类型取决于输入:

timestamptz AT TIME ZONE zone -> timestamp
timestamp   AT TIME ZONE zone -> timestamptz

前者把绝对时刻投影为某地墙上时间;后者把无时区的墙上时间按指定地区解释为 绝对时刻。方向相反,代码审查时必须看输入类型,不能只看函数名字。

夏令时反例

fixture 中:

e002 = 2026-03-08 06:55:00+00
e003 = 2026-03-08 07:05:00+00

纽约本地显示:

e002 = 2026-03-08 01:55:00
e003 = 2026-03-08 03:05:00

正确的实际时长:

SELECT e3.occurred_at - e2.occurred_at;
-- 00:10:00

错误模式是先把两边转成无时区本地时间再相减:

SELECT
  (e3.occurred_at AT TIME ZONE 'America/New_York')
  -
  (e2.occurred_at AT TIME ZONE 'America/New_York');
-- 01:10:00

第二个结果计算的是墙上刻度差,不是经过时长。在秋季回拨时还可能出现同一 本地时刻两次。绝对持续时间应在 timestamptz 上运算;按本地日历排程则要 先明确地区,再接受 DST 带来的 23/25 小时日。

temporal-analysis.sql 将两个本地显示与 600 秒实际差同时固化,防止只验证其中一面。

迟到不等于乱序

本章定义超过五分钟为“迟到”:

received_at - occurred_at > interval '5 minutes'

固定迟到事件:

e001 delay = 29100 seconds
e004 delay =   900 seconds

迟到比较事件时间与接收/处理时间。乱序比较多个事件在两条时间轴上的 顺序。本章:

e004 occurred 11:55, received 12:10
e005 occurred 12:00, received 12:00:05

e004 先发生却后到达,因此 e004 -> e005 是乱序对。一个事件可以迟到但 不造成当前批次乱序,也可以只晚几秒却翻转相邻事件顺序。

迟到策略不能藏在 SQL 里

流式或增量聚合常见策略:

策略 好处 代价
永远接受并重算 最接近最终事实 旧分区、缓存和下游长期可变
水位线内重算 成本可控 水位线外需要补偿路径
迟到旁路/人工处理 主链稳定 两套状态与操作流程
直接丢弃 简单 数据损失,必须有明确业务授权

水位线不是 now() - interval '5 minutes' 这么简单。还要定义:

  • 以哪个来源、分区或租户推进;
  • 空闲来源如何处理;
  • 来源时钟漂移多大;
  • 重放是否让水位线倒退;
  • 聚合、缓存、物化视图和外部消费者如何更正;
  • 超过水位线的数据被保留、补偿还是拒绝。

本章只标记迟到,不模拟完整流处理平台。它建立的是数据库中可重算的事实 基础。

重复也有两种

传输重复:同一 event_id、同一 payload 被发送多次。本章 e003a003/a004 两次尝试,注册表记录:

event_id=e003
canonical_attempt_id=a003
attempt_count=2

业务冲突:同一 event_id 带来不同 payload。不能将它静默视为普通 重复。本章 loader 先计算 payload variant 数,只有恰好一个版本的 event_id 才进入注册表;生产应把冲突写入隔离表并报警。

一个可靠幂等键应来自领域身份,而不是接收时间或随机重试 ID:

good: order_id + event_type + domain sequence
risky: received_at
risky: database-generated serial for every retry

若来源只能提供“近似重复”,需要另设去重窗口、payload hash 与误合并风险, 不能假装获得 exactly-once。

来源序列补足时间排序

两条事件可能具有相同 timestamp 精度,设备时钟也可能回拨。本章保留:

source_sequence bigint NOT NULL

同一 courier 内的业务顺序可用:

ORDER BY courier_id, source_sequence, event_id

它不替代时间:序列通常只能在单一来源内比较,也无法回答真实时长。稳健模型 同时保存领域序列、事件时间、接收时间和唯一身份。

输入时钟也要被观测

本章约束 received_at >= occurred_at 是教学简化。生产设备的时钟可能快于 服务器,直接拒绝会丢数据。更现实的处理是:

source_occurred_at
server_received_at
clock_skew_estimate
normalized_occurred_at (optional and versioned)
source clock quality/status

原始时间不可覆盖;任何校正都要带算法版本,才能在规则变化后重算。

16.1.3 范围类型、窗口与时间对齐

半开区间让相邻边界只有一个归属

本章统一采用:

[lower, upper)

左端包含,右端不包含。因此:

central v1 [00:00, 12:00)
central v2 [12:00, next_day)

正好 12:00 只属于 v2。日分区:

day8 [2026-03-08 00:00Z, 2026-03-09 00:00Z)
day9 [2026-03-09 00:00Z, 2026-03-10 00:00Z)

23:59:59 与次日 00:00:00 也不会重叠或漏掉。不要用 23:59:59.999999 人工制造闭区间上界:精度变化、类型转换和代码生成很容易 产生缝隙。

PostgreSQL range 支持包含、重叠、相邻、交集等操作,并可由 GiST/SP-GiST 索引。参见官方 Range Types

用约束保护有效期

同一围栏版本不能重叠:

EXCLUDE USING gist (
  zone_id      shop_ch16_ext.gist_text_ops WITH =,
  valid_during WITH &&
);

普通 B-tree 能找 zone_id,却不能单独表达“同 zone 的两个 range 不得 重叠”。btree_gist 为标量等值提供 GiST operator class,使它能与 range 重叠操作符组合在一个排他约束中。

故意写入:

central 99 [2026-03-08 11:00Z, 13:00Z)

会与 v1/v2 冲突并返回 23P01。这比在应用中先 SELECTINSERT 可靠,因为并发事务仍由数据库约束仲裁。

时间桶必须固定原点

本章用原生 date_bin 对齐 15 分钟:

SELECT
  date_bin(
    interval '15 minutes',
    occurred_at,
    timestamptz '2001-01-01 00:00:00+00'
  ) AS bucket_start,
  count(*)
FROM shop_ch16.delivery_event
GROUP BY bucket_start;

三个参数分别是:

stride   桶宽
source   待对齐时间
origin   网格原点

不固定 origin,就没有完整的桶合同。不同服务若使用不同原点,即使桶宽相同 也无法合并。

date_trunc('hour', ...) 适合自然日历单位;date_bin 可表达 15 分钟这类 任意固定长度,但不能把“一个月”当成固定秒数。月份、季度、当地营业日应使用 明确日历与时区规则。

官方 Date/Time Functions 给出 date_truncdate_binAT TIME ZONE 的类型和行为。

UTC 桶与本地日历桶不是同一产品

UTC 15 分钟监控桶:

date_bin('15 minutes', occurred_at, '2001-01-01Z')

“纽约当地营业日”则需要先定义本地日期边界,再转换成两个绝对时刻作为 查询范围。不要简单写:

(occurred_at AT TIME ZONE 'America/New_York')::date = :day

这虽然逻辑可读,却可能包裹分区键而失去裁剪。更好的应用流程:

input local date + IANA zone
  -> resolve local midnight and next local midnight
  -> obtain two timestamptz bounds
  -> query occurred_at >= lower AND occurred_at < upper

DST 切换日的两个 UTC 边界可能相差 23 或 25 小时,这恰好是正确的当地日。

窗口函数不是时间窗口状态机

SQL 窗口函数:

lag(occurred_at) OVER (
  PARTITION BY courier_id
  ORDER BY occurred_at, event_id
)

能在当前查询快照中比较相邻事件,适合轨迹间隔、停留候选和乱序审计。但它 不会自动:

  • 等待迟到事件;
  • 维护跨批水位线;
  • 修正已发送给外部系统的结果;
  • 把无限事件流变成有界状态。

数据库增量表、物化视图、TimescaleDB continuous aggregate 或外部流系统 可以承接不同职责。选择前仍要先固定 event time、lateness 与 correction 合同。

可执行验收

运行:

psql "service=pg36-admin" \
  -f static/labs/ch16/temporal-analysis.sql

psql "service=pg36-admin" \
  -f static/labs/ch16/time-buckets.sql

关键事实:

duplicate_event=e003:2
late_event_ids=e001,e004
out_of_order_pair=e004->e005
utc_day8_events=7
partition_boundary=e008=...20260308;e009=...20260309

时间桶共 11 个,事件总数仍为 12,迟到总数仍为 2;12:00 桶含 e005/e006 两行。聚合后的总量与原始事实不守恒时,应先定位过滤、边界或 重复处理,而不是调整索引。

本节判断线

进入下一节前,至少能完整回答:

哪一列是 event time?
哪一列是 ingest/processing time?
业务规则如何表示 valid time?
输入没有 offset 时由谁解释?
绝对时长在哪种类型上计算?
迟到阈值与水位线是什么?
重复 payload 冲突如何处理?
所有相邻区间采用什么端点合同?
本地日如何转换成可裁剪的绝对范围?

回答不完整时,不应先争论日分区还是小时分区,也不应先安装时序扩展。


返回本章目录 · 下一节:时序表与时间分区 · 查看全书目录 · 查看索引中心

16.2 时序表与时间分区

分区不是“数据带时间戳以后自然要做的事”。它是一项物理设计决策:

查询能否按边界排除大部分数据?
写入能否稳定路由?
唯一性与外键合同是否仍成立?
分区数量、索引数量和维护动作是否可控?
迟到数据会落到仍可写的历史分区吗?
删除一个时间段是否真的比普通 DELETE 更有价值?

第 4 章先建立类型、约束与分区 ADR。本节只把那套判断落实到事件时间场景, 不把“按天分区”包装成默认答案。

16.2.1 从 ch04 的分区 ADR 选择时间键

分区键首先决定数据归属

配送事件有两个候选:

occurred_at  事件实际发生时间
received_at  系统接收时间

received_at 分区的好处是写入几乎总落到最新分区,创建和冻结历史分区 容易;坏处是“某业务日发生的事件”会散布到以后到达的分区。

occurred_at 分区使业务日查询与保留自然对应,也能只扫描目标事件时间 范围;代价是迟到和重放会写旧分区,旧分区不能简单变成永久只读。

本章 ADR 选择:

partition_key: occurred_at
partition_timezone: UTC
partition_strategy: native RANGE
partition_bounds: "[)"

理由不是“事件表都该这么做”,而是本 PoC 的主要查询与历史围栏连接都以事件 发生时间为准。若法规要求按接收时间保留原始消息,可让 raw ingest 与规范 事件采用不同分区键。

父表合同

核心定义摘自 setup.sql

CREATE TABLE shop_ch16.delivery_event (
  event_id       text NOT NULL,
  occurred_at    timestamptz NOT NULL,
  received_at    timestamptz NOT NULL,
  courier_id     text NOT NULL,
  event_type     text NOT NULL,
  source_sequence bigint NOT NULL,
  location       geometry(Point, 4326) NOT NULL,
  location_geog  geography(Point, 4326)
    GENERATED ALWAYS AS (
      location::geography
    ) STORED,
  PRIMARY KEY (occurred_at, event_id)
) PARTITION BY RANGE (occurred_at);

主键包含 occurred_at,不是为了业务身份。PostgreSQL 在分区父表上建立 UNIQUE/PRIMARY KEY 时,约束列必须包含所有分区键列;这样每个叶分区的 局部唯一索引才能共同证明父表范围内不重复。

业务要求的全局 event_id 唯一性由未分区的:

event_registry(event_id PRIMARY KEY, ...)

承担。这个模式把两个合同分开:

event_registry: 全局领域身份
delivery_event: 分区内物理身份与数据载荷

若只在每个叶分区上建 UNIQUE(event_id),同一个 ID 仍可出现在不同分区。 应用重试改变 occurred_at 时尤其危险。

半开日分区

CREATE TABLE shop_ch16.delivery_event_20260308
PARTITION OF shop_ch16.delivery_event
FOR VALUES FROM ('2026-03-08 00:00:00+00')
             TO ('2026-03-09 00:00:00+00');

边界含义是:

lower <= occurred_at < upper

fixture 验证:

e008 2026-03-08 23:59:59Z -> delivery_event_20260308
e009 2026-03-09 00:00:00Z -> delivery_event_20260309

若没有可接收某值的分区,向父表写入会失败。生产应提前创建未来分区并监控 覆盖范围,不应依赖事故发生后手工补表。

UTC 边界与当地业务日

本章日分区是 UTC 日,不等于纽约当地日。纽约 2026-03-08 当地日可能跨越 两个 UTC 分区:

local 2026-03-08 00:00 America/New_York
  -> 2026-03-08 05:00Z

local 2026-03-09 00:00 America/New_York
  -> 2026-03-09 04:00Z

查询仍然写两个绝对边界:

WHERE occurred_at >= :lower_timestamptz
  AND occurred_at <  :upper_timestamptz

规划器可能保留两个相关 UTC 分区,其余分区被裁剪。这比为每个用户时区建立 分区可控得多。

裁剪取决于谓词与分区边界

正例:

SELECT event_id
FROM shop_ch16.delivery_event
WHERE occurred_at >=
        TIMESTAMPTZ '2026-03-08 00:00:00+00'
  AND occurred_at <
        TIMESTAMPTZ '2026-03-09 00:00:00+00';

time-pruned-plan.sql 的固定计划只有:

Seq Scan on delivery_event_20260308

这已经是成功的分区裁剪。叶表只有七行,顺序扫描是合理选择;“没有使用 B-tree”不影响裁剪已经生效。

反例:

WHERE (occurred_at AT TIME ZONE 'UTC')::date
      = DATE '2026-03-08'

time-wrapped-plan.sql 显示:

Append
  -> delivery_event_20260307
  -> delivery_event_20260308
  -> delivery_event_20260309

逻辑答案一样,物理工作不同。不要用“给表达式建索引”代替分区裁剪;索引可 减少每张叶表内的扫描,却不一定让规划器排除叶表。

官方 声明式分区文档 区分规划期与执行期裁剪,也明确指出裁剪由分区边界驱动而非普通索引。

从目录验证,而不是从表名猜

SELECT
  child.relname,
  pg_get_expr(child.relpartbound, child.oid)
FROM pg_inherits AS inheritance
JOIN pg_class AS child
  ON child.oid = inheritance.inhrelid
WHERE inheritance.inhparent =
      'shop_ch16.delivery_event'::regclass;

partition-catalog.sql 同时采集边界、行数、 最早/最晚事件、owner 与 marker。正式验收不应只检查三张名字像日期的表。

16.2.2 写入模式、冷热生命周期与保留

写入链先处理身份,再路由事实

本章确定性流程:

ingest_attempt
  -> validate event_id payload consistency
  -> choose canonical attempt
  -> insert event_registry
  -> insert delivery_event parent
  -> PostgreSQL routes by occurred_at

顺序很重要。如果先向分区事件表写入,再尝试全局注册,两个并发事务可能把 同一业务 ID 写进不同叶表。生产可使用单事务、注册表 INSERT ... ON CONFLICT、显式状态机或消息 inbox/outbox 协调,但必须让全局身份争用发生在 可证明唯一的位置。

为迟到写入保留窗口

按事件时间分区时,“旧”不等于“不再写”。生产 ADR 至少定义:

normal_lateness: 15m
accepted_lateness: 7d
manual_backfill: ticketed
partition_read_only_after: 14d
retention_after: 400d

数字取决于业务,重要的是分开:

  • 正常迟到:自动接受并更新聚合;
  • 允许迟到:接受但触发更正或告警;
  • 超窗回补:需要显式作业、审计和容量窗口;
  • 冻结:应用角色不再写,但受控管理员可能回补;
  • 保留到期:可删除或归档。

若把“昨天的分区”每天 00:00 立刻设只读,e001 这类迟到事件会在正常链路 失败。

不要让 DEFAULT 分区变成垃圾桶

DEFAULT 分区可以避免缺分区导致写入失败,但会引入新的责任:

  • 为什么正常日期落入 default?
  • 补建正式分区时怎样迁移而不长时间阻塞?
  • default 上的约束是否允许 attach 新分区?
  • 查询是否意外长期扫描 default?
  • 异常未来时间和损坏年份是否被悄悄接受?

一种稳健策略:

提前创建 N 个未来分区
监控 max upper bound 与当前时间的距离
没有 DEFAULT,缺口立即失败并报警
异常事件进入独立 quarantine

另一种策略可以保留受控 DEFAULT,但必须把行数、年龄和迁移作业作为一等 监控。不存在普适答案。

分区粒度由约束共同决定

按小时、日、周或月选择时,至少估算:

每分区行数与字节
高频查询时间跨度
保留/归档的最小动作单位
迟到回补范围
每分区索引数
总分区数与规划开销
autovacuum/analyze 节奏
备份、恢复与副本应用成本

例如一年按日 365 张、每张 4 个索引,已经有约 1,460 个叶索引;若按小时, 一年约 8,760 张表和数万个索引。小分区不自动更快,空或微小分区也有目录、 锁、统计与规划成本。

本章用三张日分区只为让边界和计划可见,不构成生产粒度建议。

索引是叶分区成本

本章每张事件分区维护:

primary key (occurred_at, event_id)
B-tree     (courier_id, occurred_at, event_id)
GiST       (location geometry)
GiST       (location_geog geography)

三个叶表一共 12 个索引对象。分区越多,DDL、REINDEX、统计、磁盘 inode、 缓存和故障面越大。

父分区索引能管理对应的叶索引集合,但 PostGIS、不同历史策略或在线构建流程 仍可能需要逐叶控制。无论自动还是手工创建,都要从 pg_index 验证 indisvalid/indisready/indislive,不能只看 DDL 命令返回成功。

热、温、冷不是表空间颜色

可以按生命周期决定:

状态 可能动作
正常写、完整索引、频繁 analyze
低频回补、限制写角色、保留关键索引
detach/归档、压缩、外部存储或只读集群
到期 经审批删除并保留删除证据

但每个动作都要回答恢复路径。DROP TABLE old_partition 很快,却会同时删除 数据和局部索引;只有备份、归档与法规合同允许时才是正确保留策略。

PostgreSQL 支持 DETACH PARTITION,可让数据先脱离父表再归档或处理。在线 动作的锁、并发、约束验证和版本差异应在接近生产的环境验证,本章 PoC 不演示 线上表迁移。

删除与回补会影响 vacuum

时间序列通常“追加为主”,不等于没有 MVCC 成本:

  • 重复处理可能执行冲突更新;
  • 迟到修正会更新旧聚合;
  • 围栏重算可能写结果表;
  • 保留若使用大批 DELETE 会产生死元组和 WAL;
  • 索引页仍会分裂、膨胀或缓存失衡。

分区级删除可以避免海量行删除,但活跃分区仍需 vacuum/analyze。后续 第 28 章 专门处理 vacuum、冻结与膨胀;本章只要求 把这些成本列入 ADR。

16.2.3 原生分区、聚合与可选时序扩展

先列需求,再选能力

“这是时序数据,所以安装 TimescaleDB”不是决策。先问:

需求 PostgreSQL 原生基线 何时考虑专用扩展
时间范围裁剪 RANGE 分区 分片/自动 chunk 管理明显减负
普通时间聚合 date_bin、GROUP BY continuous aggregate 有量化收益
预计算 物化视图、增量任务 自动刷新与失效模型更合适
保留 detach/drop 分区 policy 自动化降低运维风险
压缩/列式收益 外部归档、其他扩展/方案 压缩率与查询代价已实测
高写入 批量、COPY、schema/索引优化 chunk 并行与架构收益已验证

原生方案的优势:

  • 能力随 PostgreSQL 一起交付;
  • 依赖与升级边界较小;
  • SQL、备份和故障模型更接近核心数据库;
  • 可以先建立可信基线。

扩展的优势可能包括自动 chunk、保留策略、压缩、时间函数和连续聚合,但也 增加:

package supply
shared_preload_libraries (when required)
restart coordination
extension version matrix
backup/restore compatibility
major upgrade path
licensing and feature-tier review
replica node consistency

“少写运维脚本”有价值,但必须与新依赖成本一起衡量。

本章为何推迟 TimescaleDB

fixture 只有 12 行、三天数据。它能证明:

  • 事件时间分区合同;
  • 半开边界;
  • 裁剪正反例;
  • 聚合语义;
  • 迟到和历史围栏连接。

它不能证明:

  • 压缩比;
  • continuous aggregate 刷新成本;
  • 大规模 chunk 规划;
  • 写吞吐或副本延迟;
  • 自动保留比受控分区作业更可靠。

所以 spatiotemporal-adr.md 把 TimescaleDB 标为 deferred,而不是反对。重开条件是压缩、保留、连续聚合或 运维收益出现量化证据。

可选扩展仍要完整走交付链

在 Pigsty 中,时序扩展不是一句 CREATE EXTENSION

Download / package availability
  -> Install on every L1 node
  -> Config / preload if required
  -> restart or rolling change
  -> CREATE EXTENSION in target database
  -> catalog and functional validation

Pigsty 当前 TimescaleDB 扩展页 应作为目标 release 的供应入口;实际版本和 PG major/OS 可用性要在 inventory 中核对,不能从本章快照推断未来版本。

聚合真值仍来自时间合同

无论使用:

GROUP BY date_bin
materialized view
continuous aggregate
external stream processor

都必须固定:

  • event time 还是 processing time;
  • bucket origin 和 timezone;
  • [) 边界;
  • 迟到水位线;
  • 更正是否回写旧桶;
  • 去重在哪一层发生;
  • 结果版本与重建方式。

扩展能自动化计算,不会替业务决定这些语义。

用 A/B 迁移而不是信仰迁移

若要引入时序扩展,建议保留原生基线:

same frozen/replayed input
same semantic query set
same expected aggregate checksum
native path vs extension path

然后分别比较:

ingest throughput
query latency distribution
storage and WAL
compression/decompression
background job impact
replica lag
backup and restore
operational actions and failure recovery

只有语义结果一致后,性能数字才可比较。

16.2.4 不在本章重复在线分区化和维护细节

本章边界

本章从空 schema 创建三张固定分区,目的是教学验证,不处理已有大表的在线 分区化。以下主题需要单独的迁移设计:

  • 在写入不中断时建立新分区父表;
  • 双写、触发器或逻辑复制;
  • 历史数据分批回填;
  • ATTACH PARTITION 前的约束证明;
  • 索引并发构建与父索引 attach;
  • 外键、序列、权限、RLS 和依赖对象迁移;
  • 切流、回退、校验和与旧表退役。

它们属于迁移、锁和运维章节,而不是时空语义入门。这里不提供一条貌似通用的 “在线改分区”命令,以免读者在生产大表上照抄。

分区维护也不应塞进应用请求

不要让第一条新日期写入在业务事务里执行 CREATE TABLE。DDL 会涉及锁、 catalog、权限、审计和副本传播。更合适的是受控作业:

discover current coverage
  -> propose future partitions
  -> create with exact owner/tablespace/options
  -> create/attach indexes
  -> analyze
  -> verify bounds and privileges
  -> emit evidence

删除旧分区也需要独立审批、备份/归档确认和 active worker 防护。

本章必须保留的判断力

即使篇幅有限,也不能删掉:

  1. event/ingest/valid time 分离;
  2. 选择分区键的 ADR;
  3. [) 边界;
  4. 全局 event_id 与分区主键分离;
  5. 直接谓词与包裹谓词的计划对照;
  6. 迟到写旧分区的成本;
  7. 原生能力与扩展收益的证据门槛;
  8. 分区数量乘以索引数量的运维成本。

可以删的是某一扩展的参数百科或某一版本的命令清单。基础判断一旦省掉,读者 会把工具选择误当成时间模型。

本节验收

psql "service=pg36-admin" \
  -f static/labs/ch16/partition-catalog.sql

psql "service=pg36-admin" \
  -f static/labs/ch16/time-pruned-plan.sql

psql "service=pg36-admin" \
  -f static/labs/ch16/time-wrapped-plan.sql

应看到:

delivery_event_20260307 rows=1
delivery_event_20260308 rows=7
delivery_event_20260309 rows=4

direct predicate -> only 20260308
wrapped predicate -> Append over all three

如果逻辑行数正确但计划扫描全部分区,先修谓词和边界;如果行路由错误,先修 分区定义或事件时间。不要用更多索引掩盖语义错误。


上一节:时间语义先于时序扩展 · 返回本章目录 · 下一节:空间类型与坐标参考 · 查看全书目录 · 查看索引中心

16.3 空间类型与坐标参考

PostGIS 不只是“经纬度函数包”。它把空间对象、坐标参考、拓扑关系、距离 模型、索引操作符和目录元数据带进 PostgreSQL。

学习顺序应当是:

业务对象
  -> 坐标参考与单位
  -> geometry / geography
  -> 合法性与边界规则
  -> 谓词
  -> 索引

若从“建一个 GiST”开始,很容易得到能够执行却单位错误的查询。

16.3.1 geometry、geography 与测量语义

两种类型回答不同计算问题

PostGIS 的核心空间类型:

类型 计算表面 距离/面积单位 典型用途
geometry 指定 CRS 的平面坐标 CRS 单位 拓扑、局部投影、丰富函数、空间索引
geography 地球曲面模型 米/平方米 全球经纬度上的距离、半径、面积

geometry(Point, 4326) 的坐标是经纬度角度。下面这条距离:

ST_Distance(point_a_4326, point_b_4326)

返回的是坐标系单位,也就是角度,不是米。把结果乘一个固定“每度米数”只在 非常有限的局部近似下成立,且经度尺度随纬度变化。

将同一点作为 geography

ST_Distance(
  point_a_4326::geography,
  point_b_4326::geography
)

才得到以米为单位的地表距离语义。PostGIS 官方 空间查询章节 说明 geometry 的平面计算与 geography 的大地计算差异。

不是所有列都要存两份

本章将规范值保存在 geometry:

location geometry(Point, 4326) NOT NULL

再生成 geography:

location_geog geography(Point, 4326)
  GENERATED ALWAYS AS (
    location::geography
  ) STORED

好处:

  • 只有一个可写坐标事实;
  • geography 与 geometry 不会因应用漏更新而漂移;
  • geography 可建独立 GiST,米制查询不必每次临时转换;
  • 生成表达式和类型可以从目录审计。

代价:

  • 多一列存储;
  • 写入要计算生成值;
  • 多一个索引意味着更多 WAL、磁盘和缓存;
  • schema 将业务允许的 CRS 固定为 4326。

若米制查询很少,可以只存 geometry 并在查询中转换;若大多数查询都在一个 适当局部投影内,也可以统一使用投影 geometry。要由查询和单位合同决定, 不是机械地“双列最保险”。

typmod 把对象类型与 SRID 放进 schema

比较:

location geometry

和:

location geometry(Point, 4326)

后者让数据库拒绝非 Point 或非 4326 的值,使表结构本身表达坐标合同。本章 围栏同样固定:

zone_geom geometry(Polygon, 4326)

如果业务允许 MultiPolygon,应明确写:

geometry(MultiPolygon, 4326)

或在接入时将 Polygon 规范化为 MultiPolygon。不要直到某个区域含离岛才临时 修改客户端。

X/Y 是坐标轴,不是自动的“纬/经”

EPSG:4326 常见 WKT/GeoJSON 使用:

X = longitude
Y = latitude

本章点:

ST_SetSRID(
  ST_MakePoint(-74.00000, 40.71000),
  4326
)

即经度 -74、纬度 40.71。把两者颠倒仍可能落在各自合法数值范围内, 数据库不一定能发现。

接入合同应写清:

format: longitude,latitude
x: longitude
y: latitude
srid: 4326
longitude_range: [-180, 180]
latitude_range: [-90, 90]

并用已知控制点做端到端验证,而不是只做数值范围检查。

geography 不是“更准确”的万能开关

geography 很适合:

  • “距离配送中心 1 km 内”;
  • 跨较大区域的地表距离;
  • 用经纬度数据直接得到米制结果。

但 geometry 仍常用于:

  • ST_CoversST_Intersects 等拓扑;
  • 本地工程坐标和高精度投影;
  • 更广的 PostGIS 函数集合;
  • 需要明确平面模型的地图与分析。

问题不是哪种类型高级,而是哪种计算模型与业务问题一致。

本章距离证据

distance-semantics.sql 对三个中心寻找最近 事件,并用 geography 计算米:

hub 最近事件 距离(四舍五入米)
airport e007 0
central e001 0
east e004 423

三者都在 1 km 内。这里的 423 只验证单位与查询链,不是测绘级距离承诺。

16.3.2 SRID、投影、单位与坐标转换

SRID 是坐标参考身份

同样一对数:

(500000, 4500000)

在不同 CRS 中代表完全不同位置和单位。SRID 让 PostGIS 知道坐标属于哪个 参考系统,并能查找转换定义。

检查:

SELECT
  ST_SRID(location),
  GeometryType(location)
FROM shop_ch16.delivery_event;

本章所有事件、中心和围栏都是 4326。

ST_SetSRID 只贴标签

ST_SetSRID(geom, 4326)

不会改变任何坐标数值。它适用于“这些数本来就是 4326,只是对象没有声明” 的场景。

真正转换:

ST_Transform(geom, target_srid)

会依据源 CRS 与目标 CRS 重新计算坐标。PostGIS ST_Transform 文档 明确区分转换坐标与仅修改 SRID 标签。

危险反例:

-- 原始数值其实是 Web Mercator,却被错误贴成 WGS84
ST_SetSRID(mercator_numbers, 4326)

这不是近似误差,而是数据身份损坏。以后再 ST_Transform 只会把错误输入 转换得更复杂。

混合 SRID 应当显式失败

本章故意执行:

SELECT ST_Intersects(
  ST_SetSRID(ST_MakePoint(0, 0), 4326),
  ST_SetSRID(ST_MakePoint(0, 0), 3857)
);

固定结果:

SQLSTATE XX000
Operation on mixed SRID geometries

srid-mismatch.sql 把错误作为验收证据。不要 捕获这个错误后自动 ST_SetSRID 到另一边;系统无法仅凭数值知道哪边身份 正确。

CRS 决定单位与失真

常见选择:

选择 优点 风险
EPSG:4326 geometry 交换广泛,保存经纬度自然 平面距离是角度
EPSG:4326 geography 米制地表距离直接 函数/性能模型与 geometry 不同
本地投影 geometry 局部距离、面积和形状可控 适用区域有限,需转换治理
EPSG:3857 geometry Web 地图显示生态常见 距离/面积失真,非通用测量 CRS

Web Mercator 适合瓦片显示,不应仅因为前端地图使用它,就把业务距离也定义在 3857 平面上。

选投影需要:

  • 业务覆盖区域;
  • 容许失真;
  • 距离、面积、方向还是拓扑;
  • 数据供应者的 CRS;
  • 跨区/跨国查询;
  • 权威测绘要求。

这通常要由 GIS 专业人员与业务共同评审,而不是数据库管理员猜一个 EPSG 编号。

转换表达式与索引要一致

若查询反复写:

WHERE ST_DWithin(
  ST_Transform(location, :local_srid),
  :query_point,
  1000
)

原始 location GiST 通常不能直接服务这个转换表达式。选项包括:

  • 存储/生成规范投影列并建索引;
  • 建表达式索引;
  • 先用原 CRS 的安全包围盒缩小候选,再精确转换;
  • 使用 geography 的米制谓词。

表达式索引必须与查询表达式结构、SRID 和函数可索引条件一致。每行临时转换 还会增加 CPU。不能只验证结果,不看计划。

坐标转换也要版本化

CRS 定义、网格文件和转换路径可能随 PROJ/PostGIS/操作系统包变化。高精度 业务应在证据中保存:

PostGIS_Full_Version()
PROJ version
source and target SRID
transformation method/grid availability
control points and expected tolerance

本章没有外部权威坐标或网格文件,因此只固定 PostGIS 版本、SRID 和合成 控制点,不声称测绘精度。

16.3.3 点、线、面、边界与有效几何

对象维度决定问题

对象 本章/配送中的例子 典型问题
Point 配送事件、中心 在哪里、离多远
LineString 轨迹、道路 长度、相交、沿线位置
Polygon 围栏 覆盖、包含、面积
Multi* 多片区域、分段轨迹 多个几何组成一个业务对象

用两列经纬度只能表示 Point,且无法让数据库知道它们共同构成一个空间对象。 PostGIS 类型让约束、函数和索引围绕整个对象工作。

Polygon 有内部、边界和外部

点对面的关系至少有三种:

interior
boundary
exterior

所以:

ST_Contains(polygon, point)
ST_Covers(polygon, point)

不是同义词。本章 e003 在边界:

ST_Covers   = true
ST_Contains = false
ST_Touches  = true

PostGIS ST_Covers 说明它允许 B 的点位于 A 的内部或边界,并会自动利用包围盒比较。

业务要先决定:

  • 边界订单属于两个区、任一区还是某个优先区;
  • 边界容差如何处理 GPS 噪声;
  • 围栏之间是否允许重叠;
  • 点恰好在洞边界如何解释;
  • 版本换挡时边界与时间边界谁先判定。

SQL 只能实现已选规则。

有效几何是谓词的前提

自相交 Polygon、未闭合 ring、错误洞关系等无效几何可能让拓扑谓词产生意外 结果。写入时至少验证:

CHECK (ST_IsValid(zone_geom))

本章还固定:

CHECK (NOT ST_IsEmpty(zone_geom))
CHECK (ST_SRID(zone_geom) = 4326)

必要时用:

ST_IsValidReason(geom)

定位原因。ST_MakeValid 可以尝试修复,但修复可能改变对象类型、拆成多个面 或改变业务边界,不能在生产接入中无审计地自动覆盖原值。

空、NULL 与未知不同

NULL geometry  -> 未提供/未知
EMPTY geometry -> 已知为空的空间对象

它们在函数和聚合中的行为不同。本章业务事件和围栏都要求 NOT NULL 且 非空,避免把“没有位置”误当成“位于任何区域之外”。如果设备可能不上传定位, 应另设质量状态:

location_status = missing / invalid / approximate / verified

不要用 (0,0) 作为缺失哨兵;它是几内亚湾中的真实坐标。

轨迹不是无序点集

若把多个 Point 组成 LineString,顺序必须来自稳定合同:

ST_MakeLine(location ORDER BY occurred_at, event_id)

还应分段:

  • courier/session;
  • 最大时间间隔;
  • 设备重启;
  • 不合理速度跳变;
  • SRID/质量状态。

跨越长间隔直接连线会制造从 A 到 B 的虚假直线。轨迹的 LineString 是一种 派生产品,应可从原始事件和版本化规则重建。

经纬度数据也有许可证与版本

真实边界、道路、地址或兴趣点通常来自外部数据源。上线前要保存:

provider and dataset
version / snapshot date
license and attribution
allowed redistribution/use
source CRS
transformation chain
import checksum
update and rollback policy

本章坐标全部为项目自造的合成数据, fixture-manifest.json 明确 external_geodata=false。这让实验离线可复现,也意味着它不能证明真实地图 数据质量。

建表前的空间合同

business_object: delivery event
geometry_type: Point
canonical_srid: 4326
coordinate_order: longitude, latitude
geometry_role: topology and index
geography_role: meter distance
null_policy: forbidden for canonical event
validity_policy: ST_IsValid and non-empty
boundary_policy: ST_Covers
external_dataset: none

这份合同完整后,下一节的谓词与索引才有确定含义。

本节验收

psql "service=pg36-admin" \
  -f static/labs/ch16/boundary-semantics.sql

psql "service=pg36-admin" \
  -f static/labs/ch16/distance-semantics.sql

再检查生成列:

SELECT
  attname,
  attgenerated,
  format_type(atttypid, atttypmod)
FROM pg_attribute
WHERE attrelid =
      'shop_ch16.delivery_event'::regclass
  AND attname IN ('location', 'location_geog');

verify.sql 要求 location_geog.attgenerated = 's',所有 geometry 与 geography 的 SRID 都是 4326,围栏全部非空且有效。任一项漂移,时空查询 即使返回“看起来正确”的五行也不能通过。


上一节:时序表与时间分区 · 返回本章目录 · 下一节:空间谓词与索引 · 查看全书目录 · 查看索引中心

16.4 空间谓词与索引

空间查询不应从函数名猜语义。先把业务问题归类:

布尔拓扑:是否相交/覆盖/包含?
范围邻近:是否在给定距离内?
度量:具体距离或面积是多少?
排名:最近的 K 个是谁?

这四类问题的返回类型、边界、单位、索引方式和停止条件不同。

16.4.1 包含、相交、邻近与最近邻

拓扑谓词回答关系,不回答距离

常用关系:

谓词 问题
ST_Intersects(a,b) 两者是否共享任何点?
ST_Disjoint(a,b) 两者是否完全不相交?
ST_Contains(a,b) B 是否位于 A 内部,且内部有共同点?
ST_Within(a,b) A 是否位于 B 内,是 contains 的反向关系
ST_Covers(a,b) B 是否没有任何点位于 A 外部?
ST_Touches(a,b) 是否只在边界接触、内部不相交?

对“事件属于围栏”:

ST_Covers(zone.zone_geom, event.location)

参数顺序是:

area first, point second

若使用反向表达,可写 ST_CoveredBy(point, area)。不要因为 ST_Intersects 参数对称,就假设所有空间谓词都对称。

边界政策决定 contains 还是 covers

固定探针:

SELECT
  ST_Covers(zone_geom, location),
  ST_Contains(zone_geom, location),
  ST_Touches(zone_geom, location)
FROM ...
WHERE event_id = 'e003';

结果:

covers=true, contains=false, touches=true

这不是函数争论,而是业务选择:

业务规则 更接近的表达
边界也算在服务区 ST_Covers
必须严格位于内部 ST_Contains
只找边界点 ST_Touches
任何接触都算 ST_Intersects

boundary-semantics.sql 还证明同一点在 围栏扩张前后得到不同结果,说明时间版本不能被省略。

邻近筛选使用 ST_DWithin

“在中心 1 km 内”是布尔资格:

WHERE ST_DWithin(
  event.location_geog,
  hub.location::geography,
  1000
)

对 geography,距离参数以米为单位。对 geometry,距离参数使用 CRS 单位。 PostGIS ST_DWithin 说明该函数包含可利用索引的包围盒比较,然后执行距离判断。

不要写成:

WHERE ST_Distance(a, b) <= 1000

再期待同样的索引路径。ST_Distance 必须为候选计算具体值;ST_DWithin 针对阈值问题设计,规划器可使用对应空间操作符缩小候选。

距离值与距离资格分开

常见响应同时需要资格与显示距离:

SELECT
  event_id,
  ST_Distance(location_geog, :hub_geog) AS meters
FROM shop_ch16.delivery_event
WHERE ST_DWithin(location_geog, :hub_geog, 1000)
ORDER BY meters, event_id;

先用 ST_DWithin 过滤,再为较小集合计算/排序距离。SQL 表达式仍可能被 优化器重排,但逻辑合同清楚,且索引条件可见。

距离函数还涉及:

  • geography 使用 spheroid 还是 sphere;
  • 2D 还是 3D;
  • 误差容忍;
  • 坐标精度与 GPS 噪声;
  • 是否按路网距离而非直线距离。

本章只验证 2D 地表直线距离,不做路线规划。

最近邻是排名问题

找最近的 K 个:

SELECT hub_id, hub_name
FROM shop_ch16.delivery_hub
ORDER BY location <-> :query_geometry, hub_id
LIMIT 2;

<-> 是距离排序操作符,适配的索引访问方法/operator class 可以执行 KNN 路径。它与 ST_DWithin 不同:

ST_DWithin -> 资格:所有半径内对象
<-> LIMIT K -> 排名:最近 K 个,不保证在某半径内

生产常组合:

WHERE ST_DWithin(location, :point, :radius)
ORDER BY location <-> :point, hub_id
LIMIT :k;

radius 控制业务资格,k 控制返回深度。

稳定 tie-breaker 不能省

多个对象可能与查询点等距。实验和分页都要追加:

ORDER BY distance, event_id

否则同距离对象的顺序未定义。空间索引也不会替你创造业务唯一顺序。

最近点不等于最近路径

两点直线很近,可能隔着河流、围墙或单行路网。若问题是配送 ETA/路径,应 引入:

  • 权威路网;
  • 拓扑连接;
  • 交通规则和时间版本;
  • 路径算法;
  • 地图匹配;
  • 实际行驶数据校准。

PostGIS 点距离只能作为几何近似,不能自动变成路由引擎。

16.4.2 包围盒过滤与精确计算

空间索引保存可搜索近似

复杂 Polygon 可能有成千上万个顶点。每行都执行精确拓扑会很贵。常见路径:

bounding box candidate filter
  -> exact geometry predicate

包围盒是包住对象的轴对齐矩形。两个对象的包围盒不相交,则对象必不相交; 包围盒相交,只说明它们可能相交。

因此:

bbox reject = 可以安全排除
bbox match  = 仍需精确判断

命名谓词会加入索引友好的初筛

PostGIS 对一组常见命名谓词自动加入包围盒条件,例如本章使用的:

ST_Covers
ST_DWithin

固定 GiST 计划中可以看见:

Index Cond:
  location_geog && _st_expand(query_geography, 1500)

Filter:
  st_dwithin(location_geog, query_geography, 1500, true)

Index Cond 缩小候选,Filter 执行精确距离。本章联合计划中:

Index Cond:
  location @ zone.zone_geom

Filter:
  st_covers(zone.zone_geom, location)

内部操作符展示可能随版本和计划格式变化,工程上应关注“索引候选 + 精确谓词”结构,而不是把某一行文本当 API。

PostGIS 官方 空间索引与查询 列出会自动利用空间索引的函数,并解释两阶段比较。

手工 && 只回答包围盒

WHERE geometry_a && geometry_b

只测试二维包围盒重叠。它适合:

  • 明确只需要视窗候选;
  • 分阶段调试;
  • 为后续自定义精确计算生成候选。

它不等于 ST_Intersects。对凹多边形、带洞区域或长斜线,包围盒会包含大量 实际不相交对象。

不要为了“更快”把精确谓词删掉,除非业务合同本来就只需要 bbox。

lossy/recheck 是正常行为

GiST 等索引可能返回需要 heap recheck 的候选。看到计划中的:

Recheck Cond
Rows Removed by Filter

不表示索引错误,而是近似索引和精确关系的正常分工。应观察:

  • 候选数量与最终命中数量;
  • recheck 比例;
  • 几何复杂度;
  • 选择率估计;
  • heap page 命中;
  • 查询半径和区域大小。

若一个巨大 Polygon 的 bbox 覆盖整座城市,空间索引无法凭 bbox 排除很多 点。可考虑细分几何、预计算层级网格或业务分区,但任何近似都要保留精确 复核或明确误差合同。

无效几何会破坏前提

官方 ST_Covers 文档提醒不要对无效 geometry 期待可靠结果。索引只会让 错误候选更快地产生。接入时:

CHECK (ST_IsValid(zone_geom))

并保留 ST_IsValidReason 证据,比查询时临时修复更可控。

扩张半径与单位必须一致

geometry 上:

ST_Expand(point_4326, 1000)

会按度扩张 1000,不是 1000 米,几乎覆盖全球。不要把 geography 的米参数 直觉套到 geometry 函数。

本章 geometry SP-GiST probe 使用:

ST_DWithin(location, point_4326, 0.02)

这里 0.02 是度,仅用于证明 operator class 路径;业务 1 km 查询使用 geography 和 1000 米。

二阶段也适用于跨类型方案

若业务必须在局部投影做高精度计算,可以:

cheap canonical-CRS bbox
  -> smaller candidate set
  -> ST_Transform
  -> exact projected calculation

但粗筛边界必须是保守的,不能漏掉真值。跨 CRS 的安全包围盒设计需要处理 投影非线性和区域边缘,不能简单转换两个角点就默认安全。

16.4.3 GiST/SP-GiST 计划与选择率验证

访问方法不是单独的“空间索引类型”

PostgreSQL 索引能力由:

access method + operator class + data type + operator/query

共同决定。本章目录:

对象 access method operator class
围栏 geometry GiST gist_geometry_ops_2d
事件 geometry GiST gist_geometry_ops_2d
事件 geography GiST gist_geography_ops
中心 Point SP-GiST spgist_geometry_ops_2d
围栏有效期约束 GiST gist_text_ops, range_ops

因此“建了 GiST”信息不完整。还要知道列、opclass、维度和目标查询。

GiST 与 SP-GiST 的直觉边界

GiST 是通用搜索树框架,PostGIS 常用它管理几何包围盒,也支持 geography 和 KNN 等相应 operator class 能力。

SP-GiST 将空间递归划分,适合某些可分区的数据结构与分布。本章用 spgist_geometry_ops_2d 为三个 Point 建索引,并只证明 ST_DWithin 产生该索引路径。

不要从 access method 名字推导所有能力。本章未声称这个 SP-GiST opclass 服务 <-> KNN;最近邻是否走索引必须对目标版本、类型、opclass 和实际 查询看计划。

建索引

CREATE INDEX event_20260308_location_gist_idx
ON shop_ch16.delivery_event_20260308
USING gist (
  location shop_ch16_ext.gist_geometry_ops_2d
);

CREATE INDEX event_20260308_geog_gist_idx
ON shop_ch16.delivery_event_20260308
USING gist (
  location_geog shop_ch16_ext.gist_geography_ops
);

CREATE INDEX delivery_hub_location_spgist_idx
ON shop_ch16.delivery_hub
USING spgist (
  location shop_ch16_ext.spgist_geometry_ops_2d
);

本地实验把 PostGIS 安装到 shop_ch16_ext,所以 opclass 与操作符都显式 schema 限定。生产可选择 public 或受控扩展 schema,但搜索路径、迁移工具 和 ORM 必须与之兼容。

目录验收

psql "service=pg36-admin" \
  -f static/labs/ch16/index-catalog.sql

index-catalog.sql 固定 13 个管理索引,并 验证:

access method
all operator classes
indisvalid
indisready
indislive
index bytes
object marker

其中:

3 geography GiST
4 geometry GiST (3 event + 1 geofence)
1 geometry SP-GiST
1 mixed text/range GiST exclusion
4 B-tree

主键自动索引另由 34 个关系对象白名单验收,不混进“本章主动选择的 13 个 索引”计数。

为什么计划探针关闭顺序扫描

fixture 只有 3 个中心、4 个围栏和 12 个事件。正常成本模型选择 Seq Scan 很合理。为了证明路径存在,探针执行:

SET enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, ...);

固定结果:

spatial-gist-plan
  -> event_20260308_geog_gist_idx

spatial-spgist-plan
  -> delivery_hub_location_spgist_idx

joint-plan
  -> geofence_version_no_overlap
  -> event_20260308_location_gist_idx

这只证明:

query/operator/opclass/index are compatible

不证明:

planner should choose it at realistic scale
index is faster
estimated selectivity is accurate
cache/WAL/write cost is acceptable

生产选择率验证

生产候选应在代表性数据上运行:

EXPLAIN (
  ANALYZE,
  BUFFERS,
  WAL,
  SETTINGS,
  VERBOSE
) ...

对比:

estimated rows vs actual rows
index candidates vs exact matches
heap/index blocks
cache warm/cold
radius/区域大小分布
不同租户与城市的数据倾斜
并发下延迟

空间选择率高度依赖数据分布。城市中心密集、郊区稀疏;巨大围栏和小围栏的 bbox 过滤能力不同。一个平均值无法代表所有查询。

统计与维护

装载或大批更新后:

ANALYZE shop_ch16.delivery_event;
ANALYZE shop_ch16.geofence_version;

还要观察:

  • autovacuum/analyze 是否覆盖每个叶分区;
  • 历史分区统计是否陈旧;
  • PostGIS 列统计目标是否足够;
  • 索引膨胀与重建窗口;
  • 写放大与 WAL;
  • 副本 replay 延迟;
  • 新分区是否漏建空间索引。

父表有索引声明不等于每个未来分区都满足预期,自动化应从目录持续核对。

本节反例清单

以下说法都不足以作为上线结论:

“EXPLAIN 里出现 GiST,所以很快”
“用了 geography,所以最准确”
“SP-GiST 比 GiST 新,所以更好”
“ST_Distance 能算距离,所以能用索引筛半径”
“包围盒命中就是空间相交”
“12 行强制 Index Scan 比 Seq Scan 快”

正确结论必须包含语义、类型/SRID、operator class、计划、代表性规模和实际 测量。


上一节:空间类型与坐标参考 · 返回本章目录 · 下一节:时空联合查询是本章收束目标 · 查看全书目录 · 查看索引中心

16.5 时空联合查询是本章收束目标

时空查询不是“时间 WHERE + 空间 WHERE”这么简单。历史围栏场景至少有三项 同时成立:

event.occurred_at 在请求时间段内
zone.valid_during 包含 event.occurred_at
zone.geometry 覆盖 event.location

第一项选择事件分区,第二项选择当时规则版本,第三项执行空间关系。少任何 一项,答案都可能看起来合理却在历史边界上出错。

16.5.1 某时段、某区域内的配送事件

先把业务问题写完整

目标:

找出 2026-03-08 UTC 日内,事件发生时属于 central 围栏的配送事件。

完整 SQL:

SELECT
  event.event_id,
  event.occurred_at,
  zone.zone_id,
  zone.version
FROM shop_ch16.delivery_event AS event
JOIN shop_ch16.geofence_version AS zone
  ON zone.valid_during @> event.occurred_at
 AND ST_Covers(zone.zone_geom, event.location)
WHERE event.occurred_at >=
        TIMESTAMPTZ '2026-03-08 00:00:00+00'
  AND event.occurred_at <
        TIMESTAMPTZ '2026-03-09 00:00:00+00'
  AND zone.zone_id = 'central'
ORDER BY event.occurred_at, event.event_id;

固定结果:

e002 central v1
e003 central v1
e005 central v2
e006 central v2
e008 central v2

e004e005 位于同一点附近:

e004 occurred 11:55 -> central v1 -> outside
e005 occurred 12:00 -> central v2 -> inside

如果查询只连接 max(version),两条都会按 v2 判断,历史答案被今天的规则 重写。

时间范围约束放在事件时间

应用可能请求“纽约当地 3 月 8 日”。接口层先将当地日解析为两个 timestamptz 参数:

lower = 2026-03-08 05:00:00Z
upper = 2026-03-09 04:00:00Z

SQL 仍是:

event.occurred_at >= :lower
AND event.occurred_at < :upper

不要在列上转换时区或取 date。参数计算与存储查询分层后,既保留当地日 语义,也保留分区裁剪机会。

围栏版本也使用半开区间

zone.valid_during @> event.occurred_at

@> 依据 range 自身端点规则。v1 的上界不包含 12:00,v2 的下界包含 12:00,因此不需要:

event.occurred_at BETWEEN valid_from AND valid_to

BETWEEN 两端都包含,会让相邻版本在换挡时刻同时命中。用 range 可以把 端点合同保存在数据中。

空间边界可能产生多归属

本章允许相邻围栏共享边界,ST_Covers 又包含边界,所以 e003 同时命中:

central v1
east v1

这意味着:

count(*) FROM event_zone_membership

可以大于事件数。固定 12 个事件得到 14 条 membership。若聚合“各区事件数” 后求和,不能假设等于全局事件数。

需要唯一归属时,可以定义:

zone priority
smallest area first
explicit ownership of shared boundary
pre-topologized non-overlapping polygons
deterministic row_number() tie-break

但任何规则都会改变业务含义,应版本化并进入 ADR,而不是在报表 SQL 中随机 DISTINCT ON

视图是可复用语义,不是性能保证

本章创建:

CREATE VIEW shop_ch16.event_zone_membership AS
SELECT ...
FROM delivery_event AS event
JOIN geofence_version AS zone
  ON zone.valid_during @> event.occurred_at
 AND ST_Covers(zone.zone_geom, event.location);

应用读取:

SELECT event_id, zone_id, zone_version
FROM shop_ch16.event_zone_membership
WHERE occurred_at >= :lower
  AND occurred_at <  :upper
  AND zone_id = :zone;

普通 view 保存查询定义,规划器通常会展开优化;它不缓存结果,也不保证 每次选择相同计划。权限上,本章只授予 pg36_app 对父表、中心和三个视图的 SELECT,不授予任何写权限。

app-query.sql 以应用角色返回固定五行; app-write.sql 更新事件固定失败为 SQLSTATE 42501

参数、权限与租户必须先过滤

真实查询还可能需要:

AND event.tenant_id = :tenant
AND zone.tenant_id = :tenant
AND event.courier_id = ANY(:allowed_couriers)

空间命中不能越过租户和授权边界。若使用 RLS,要验证:

  • view 的 security invoker/definer 行为;
  • 空间函数是否泄露错误或执行时间信息;
  • 查询计划是否在权限过滤后仍可接受;
  • plan cache 对不同租户选择率的影响。

本章单租户 fixture 不声称覆盖这些生产边界。

空间输入也要设限

若 API 允许用户上传任意 Polygon:

  • 顶点数可能巨大;
  • geometry 可能无效;
  • SRID 可能错误;
  • bbox 可能覆盖全球;
  • 拓扑计算可消耗大量 CPU;
  • WKT/GeoJSON 大小可能成为滥用入口。

接口应限制字节、顶点、对象类型、SRID、区域范围和 statement timeout,并在 受控流程中验证/规范化。不能因为 PostGIS 函数是 SQL,就把它当廉价谓词。

16.5.2 轨迹、停留、地理围栏与迟到修正

轨迹首先是有序事件序列

最小查询:

SELECT
  courier_id,
  event_id,
  occurred_at,
  location,
  lag(occurred_at) OVER courier_order AS previous_at,
  lag(location)    OVER courier_order AS previous_location
FROM shop_ch16.delivery_event
WINDOW courier_order AS (
  PARTITION BY courier_id
  ORDER BY occurred_at, source_sequence, event_id
);

稳定顺序由三项共同提供:

occurred_at
source_sequence
event_id

单用 timestamp 可能同值;单用来源序列无法跨来源解释实际时间;event ID 用于最后确定 tie。

先分段,再连线

生成轨迹:

ST_MakeLine(location ORDER BY occurred_at, event_id)

只对已确定的 segment 安全。分段条件可能包括:

  • courier/session 改变;
  • 相邻事件间隔超过阈值;
  • 设备重启或 sequence 回退;
  • 推算速度超过物理上限;
  • 位置质量从 verified 变成 missing;
  • 数据跨过不可连接的业务状态。

若从 10:00 的北京点直接连到 18:00 的上海点,LineString 只画出一条直线, 并没有证明实际路径。

停留是派生规则

“在一个区域停留十分钟”需要同时定义:

distance threshold
minimum duration
sampling gap tolerance
entry/exit boundary policy
GPS accuracy
late event correction
segment identity

一种简单候选:

consecutive points within R meters
and max(time)-min(time) >= D
and every gap <= G

但稀疏采样只能证明观测点,不能证明两点之间始终停留。生产应把结果标为推断, 保存算法版本和输入范围。

地理围栏事件有三种生成方式

方式 特点
查询时计算 membership 总能使用最新修正,查询成本高
写入时计算并存结果 读快,但迟到/围栏修订要更正
批/流增量派生 可控重算,增加状态与作业

本章 view 使用查询时计算,最容易证明语义。生产可以物化:

event_zone_result (
  event_id,
  zone_id,
  zone_version,
  predicate_version,
  computed_at,
  source_checksum,
  ...
)

不能只存 event_id, zone_id。至少要知道使用哪个围栏版本、哪套边界算法和 哪批输入。

入围/出围不是两个独立点

若连续位置从 outside 变成 inside,可派生 enter;inside 变 outside 可派生 exit。但 GPS 抖动会在边界反复切换。常见稳健化:

  • 进入与退出使用不同阈值(hysteresis);
  • 要求连续 N 个样本;
  • 使用定位精度圆而不是无误差 Point;
  • 对边界附近状态标记 uncertain;
  • 限制最大采样间隔;
  • 保存原始点以便重算。

ST_Covers 只定义单点关系,不自动解决状态机抖动。

迟到事件会插入历史中间

本章 e004e005 早发生却后到。若系统先看到 e005 并已生成轨迹/围栏 状态,e004 到达后应:

insert raw/canonical fact
identify affected courier + time neighborhood
recompute local segment or bucket
version or retract previous derived result
emit correction evidence

不能只在列表末尾追加。否则 processing order 被误当成 event order。

围栏修订也会重写历史

若业务在 3 月 10 日修订“central v2 从 3 月 8 日 12:00 生效”,至少有两种 政策:

retroactive truth:
  重算历史 event-zone membership

as-known-at-the-time:
  保留当时系统认知,并另存修订版本

前者适合最终业务事实,后者适合审计。需要两者时,应同时建 valid time 与 system time,而不是在原行上静默覆盖。

重算范围要可证明

对于变更围栏 Polygon:

affected time = old/new valid range union
affected space = old/new bbox union
candidate events = time range AND bbox
exact changes = compare old/new predicates

这正是时空联合过滤的另一个用途。先用时间与 bbox 缩小候选,再对旧/新几何 执行精确关系,可避免全表重算;但必须保留旧 geometry 或可恢复版本。

派生结果不应覆盖原始事实

建议层次:

raw attempts
  -> canonical events
  -> normalized/quality-assessed locations
  -> zone memberships / trajectories / stays
  -> aggregates and alerts

每层保存:

  • 输入版本或 checksum;
  • 算法/规则版本;
  • 计算时刻;
  • 可重建路径;
  • 更正/撤回身份。

把“是否在围栏内”直接写回唯一事件行且不留版本,会让历史无法审计。

16.5.3 时间裁剪、空间索引与二阶段过滤

两条独立缩小路径

联合查询的候选空间可以理解为:

all events
  -> partition pruning by requested event-time range
  -> spatial bbox candidates within surviving partitions
  -> exact zone valid-time + ST_Covers filters
  -> final rows

固定联合计划:

Nested Loop
  -> Index Scan geofence_version_no_overlap
       Index Cond: zone_id = 'central'
  -> Index Scan event_20260308_location_gist_idx
       Index Cond: location @ zone_geom
       Filter:
         occurred_at in day8
         zone.valid_during @> occurred_at
         st_covers(zone_geom, location)

最关键的不是 Nested Loop,而是:

only delivery_event_20260308 appears
geometry GiST supplies candidates
valid-time and exact covers remain visible filters

joint-plan.sql 保存完整证据。

SQL 书写顺序不等于执行顺序

把时间谓词写在 WHERE 第一行不会强制数据库先执行它。PostgreSQL 规划器会 根据等价变换与成本选择路径。我们能做的是:

  • 写出可推导的直接分区键范围;
  • 使用有索引语义的空间谓词;
  • 保持统计新鲜;
  • 在真实参数分布下检查计划;
  • 必要时调整模型、索引或查询边界;
  • 不把关闭 planner 开关当生产提示。

“先时间后空间”是逻辑与候选设计,不是靠 SQL 行顺序控制算子。

prepared statement 也要看参数计划

应用通常使用参数:

WHERE occurred_at >= $1
  AND occurred_at <  $2
  AND zone_id = $3

PostgreSQL 可能使用 custom 或 generic plan。执行期裁剪可以根据参数移除 分区,但不同参数选择率仍可能使通用计划不理想。生产验证应包括:

EXPLAIN EXECUTE with narrow range
EXPLAIN EXECUTE with wide range
generic/custom plan behavior
plan cache and connection pool settings

不要只在 psql 常量查询上验收,然后假设 ORM prepared statement 完全相同。

先过滤围栏还是先过滤事件取决于基数

本章只有两个 central 版本和七个 day8 事件,Nested Loop 很自然。现实中:

  • 一个 zone + 短时间:先找 zone 再扫事件空间索引可能好;
  • 许多 zone + 一个事件:对事件点查围栏索引可能好;
  • 巨大 polygon:bbox 候选可能很多;
  • 大半径 geography:空间选择率可能很低;
  • 多租户:tenant/zone 复合过滤会改变基数。

应从业务参数分布测量,不应固定 join order。

范围排他索引兼任查找路径

geofence_version_no_overlap 原本为约束创建:

(zone_id gist_text_ops, valid_during range_ops)

联合计划也用它查 zone_id。一个索引可以同时承担约束与查询,但这不保证它 覆盖所有查询。若主要模式是:

WHERE zone_id = ?
  AND valid_during @> ?

应在真实规模验证该复合 GiST 的选择率和代价,再决定是否需要其他索引。

大查询要显式预算

若请求:

all zones
all events
five years
global polygon

时间与空间索引都无法制造高选择率。接口必须限制:

  • 最大时间跨度;
  • 最大区域/半径;
  • zone 数;
  • 返回行数与分页;
  • statement timeout;
  • 并发与资源组;
  • 是否异步导出。

索引不是资源治理替代品。

验收逻辑结果与计划结果

先验收结果:

psql "service=pg36-admin user=pg36_app" \
  -f static/labs/ch16/app-query.sql

应为:

e002 central 1
e003 central 1
e005 central 2
e006 central 2
e008 central 2

再验收计划:

psql "service=pg36-admin" \
  -f static/labs/ch16/joint-plan.sql

最后验收全量 membership:

psql "service=pg36-admin" \
  -f static/labs/ch16/zone-membership.sql

必须是 14 行,并保留 e003e005e006 的双区域命中。只对五行 central 结果做截图不足以证明边界、多归属和版本语义。

本节收束

一条可交付的时空查询结论应包含:

event-time bounds and timezone
valid-time range policy
geometry/geography and SRID
boundary predicate
multi-membership policy
logical expected rows/checksum
partition pruning evidence
spatial index candidate evidence
exact predicate evidence
representative-scale performance limits
late/revision recomputation policy

这十项比“用了 PostGIS + 分区”更接近生产合同。


上一节:空间谓词与索引 · 返回本章目录 · 下一节:时空扩展的交付与观察 · 查看全书目录 · 查看索引中心

16.6 时空扩展的交付与观察

本地执行一条 CREATE EXTENSION postgis,只能证明当前实例已有可用控制文件与 动态库。生产交付要回答:

所有数据库节点是否有同一包?
扩展是否需要 preload/restart?
在哪些数据库、哪个 schema 创建?
谁持有 extension,谁能调用?
备份恢复目标是否预装兼容版本?
主备切换后新主是否具备同一二进制能力?
升级、回退和监控由谁负责?

Pigsty 提供扩展供应与数据库声明的实现路径;PostgreSQL/PostGIS 目录仍是最终 验收事实。

16.6.1 安装 PostGIS 与可选时序扩展

四个阶段不能合并

Pigsty 把扩展生命周期概括为:

Download -> Install -> Config -> Create

对应工程问题:

阶段 验收
下载/解析 目标 Pigsty、OS、PG major 有哪个包版本
安装 每个 L1 节点都有控制文件、SQL 和动态库
配置 preload、GUC、重启和资源参数一致
创建 目标数据库 pg_extension 中有正确对象

只做 Create,在当前主库可能成功,但切换到缺二进制的副本后函数会失败;只装 包,则数据库里还没有类型、函数和 operator class。

参考 Pigsty 当前 扩展概览包别名创建扩展

package alias 与 SQL extension name 不一定相同

例子:

package alias: postgis
SQL extension: postgis

package alias: timescaledb
SQL extension: timescaledb

package alias: pgvector
SQL extension: vector

不要从 SQL 名猜操作系统包名。包还随:

Pigsty release
Linux distribution
architecture
PostgreSQL major
repository snapshot

变化。生产 inventory 应同时记录 package alias、解析后的实际包、版本和 SQL extension。

本章 Pigsty 声明

pigsty-declaration.example.yml 是合并片段,不是完整生产配置:

all:
  vars:
    pg_version: 18

    pg_extensions:
      - postgis

    pg_databases:
      - name: pg36_shop
        owner: pg36_owner
        schemas:
          - { name: app_ext, owner: pg36_owner }
        extensions:
          - { name: btree_gist, schema: app_ext }
          - { name: postgis, schema: app_ext }

三层含义:

pg_extensions
  -> cluster 节点供应哪些额外软件包

pg_databases[].schemas
  -> 数据库内准备哪些受控 schema

pg_databases[].extensions
  -> 在该数据库创建哪些 SQL extension

btree_gist 属于 PostgreSQL contrib,通常随主包集合供应;仍要从目标节点的 pg_available_extension_versions 验证,不能只根据经验省掉。

本地实验为何不用 public

本地 PoC 安装到:

shop_ch16_ext

并将数据放在:

shop_ch16

好处是扩展对象与业务对象边界清楚,reset 可分别验证依赖;代价是操作符、 类型和 opclass 常要显式 schema 限定:

location shop_ch16_ext.gist_geometry_ops_2d
location OPERATOR(shop_ch16_ext.<->) other
point::shop_ch16_ext.geography

生产可选择 publicapp_ext 或其他标准,但要评审:

  • extension 是否支持指定/迁移 schema;
  • ORM、迁移器和 SQL 是否会限定类型/操作符;
  • search_path 是否包含可被低权限用户写入的 schema;
  • 备份恢复是否重建同一 namespace;
  • 多数据库是否遵循同一约定。

PostGIS 在本章版本中不可 relocatable,创建时 schema 选择更应提前确定。

trusted 与 superuser 边界

目录快照:

extension version trusted relocatable 本地 owner
btree_gist 1.8 true true pg36_owner
postgis 3.6.4 false false 管理员

btree_gist 是 trusted extension,满足数据库权限的非超级用户可以安装; PostGIS 非 trusted,本章由管理员创建。应用角色 pg36_app 永远不获得 CREATE 或扩展 owner 权限,只得到两个 schema 的 USAGE 与受控对象 SELECT。

目录证据来自:

psql "service=pg36-admin" \
  -f static/labs/ch16/extension-catalog.sql

不要把“应用需要调用 PostGIS 函数”误解为“应用要拥有 PostGIS”。

PostGIS 不要求 preload,TimescaleDB 要单独评审

本章 PostGIS 路径不修改 shared_preload_libraries。可选 TimescaleDB 分支 示意:

pg_extensions:
  - postgis
  - timescaledb

pg_libs: 'timescaledb, pg_stat_statements, auto_explain'

pg_databases:
  - name: pg36_shop
    extensions:
      - { name: timescaledb, schema: public }

这段故意没有在基线启用。TimescaleDB 涉及包、preload、重启和数据库对象, 必须走集群变更窗口。以目标 Pigsty release 的 TimescaleDB 扩展页 为准。

声明后回到 SQL 验收

SELECT
  e.extname,
  e.extversion,
  n.nspname,
  pg_get_userbyid(e.extowner),
  e.extrelocatable
FROM pg_extension AS e
JOIN pg_namespace AS n
  ON n.oid = e.extnamespace
WHERE e.extname IN ('postgis', 'btree_gist');

功能探针至少包括:

SELECT PostGIS_Full_Version();
SELECT ST_SRID(ST_SetSRID(ST_MakePoint(0, 0), 4326));
SELECT tstzrange(now(), now() + interval '1 hour', '[)');

再执行本章边界、距离、索引计划和排他约束。版本存在不等于业务路径可用。

16.6.2 核对版本、依赖、备份和升级边界

版本是矩阵,不是一个数字

发布证据应保存:

Pigsty release
OS distribution and architecture
PostgreSQL major/minor
PostGIS extension version
PostGIS library/full version
GEOS / PROJ / GDAL versions when relevant
btree_gist version
package NEVRA/deb identity
all L1 node checksums or package versions

本章正式证据固定:

PostgreSQL 18.6
PostGIS 3.6.4
btree_gist 1.8
Pigsty reference 4.4
Pigsty L1 run not executed

最后一行很重要:直接 PostgreSQL 验收不能冒充 Pigsty 集群验收。

Pigsty 当前 PostGIS 扩展目录页 用于查看目标 release 的包可用性;版本会演进,不能把本章数字当长期默认。

主备所有 L1 节点必须一致

物理复制会把数据库页和 WAL 变更带到副本,却不会分发操作系统扩展包。 备库执行扩展查询、恢复后开放查询或升主继续服务时,仍依赖本地兼容的控制 文件、动态库及其依赖。

上线前为每个节点保存矩阵:

host role PG package control file shared library preload
pg-1 primary
pg-2 replica
pg-3 replica

任一行不同,应先修供应层。不要等故障切换后才发现新主缺 postgis 动态库。

扩展依赖是数据库对象图

CREATE EXTENSION postgis 注册大量:

types
functions
operators
operator classes/families
casts
metadata tables/views

它们通过 pg_dependpg_extension 关联。本章 reset 在删除扩展前验证 shop_ch16_ext 的关系、类型、函数、操作符和 opclass 都是合法扩展成员或 扩展表的自动对象。若出现外来对象,停止而不是 DROP ... CASCADE

这避免两个风险:

  • 把用户误建在扩展 schema 的对象一起删除;
  • 扩展对象身份漂移后仍声称复位安全。

备份不是只备 geometry 列

恢复要同时具备:

compatible PostgreSQL
compatible extension packages
CREATE EXTENSION path/control files
same or supported extension version
database data and extension membership
required CRS/grid resources
roles, schemas, privileges, search_path

pg_dump 会按扩展成员关系处理对象;恢复环境必须先能供应相容扩展。物理 备份同样要求目标运行环境可加载相应库。

发布前至少做一次隔离恢复:

  1. 新建与生产隔离的 Pigsty/PG 环境;
  2. 安装声明版本;
  3. 恢复角色、schema、扩展和数据;
  4. 核对 PostGIS_Full_Version()
  5. 运行 SRID、有效性、边界、距离与空间索引计划;
  6. 对关键表做逻辑行数和 checksum;
  7. 演练主备切换后的相同查询。

“备份任务成功”不证明 PostGIS 查询已可恢复。

扩展升级与 PostgreSQL 大版本升级分开设计

可能的变化轴:

PostGIS package version
ALTER EXTENSION ... UPDATE
GEOS/PROJ dependency
PostgreSQL major
Pigsty release
OS major

一次同时改变所有轴,失败后很难归因。稳健流程:

read target compatibility notes
freeze source evidence
test package/extension upgrade in clone
run functional and checksum suite
test backup/restore
test replica and failover
measure plan and performance regression
prepare supported rollback
roll through L1 nodes under change control

某些 extension update 不可简单降级。回退可能依赖恢复旧集群/备份或蓝绿 切流,不能默认执行 ALTER EXTENSION 反向版本。

扩展 schema 与 search_path 是安全边界

本章上下文固定:

SET search_path = pg_catalog;

所有数据对象、类型、函数与操作符显式限定。这样可以避免低权限用户在 search_path 前端 schema 创建同名函数,影响管理员脚本解析。

生产未必需要如此冗长,但管理员自动化应:

  • 使用可信固定 search_path
  • 显式限定关键对象;
  • 禁止 PUBLIC 在扩展/应用 schema CREATE;
  • 审计 extension owner;
  • 不让应用角色成为 schema owner。

空间函数调用量大,名称解析安全不能被“写起来太长”省掉。

版本断言要分兼容与精确

本章教学实验要求精确 PostGIS 3.6.4,因为计划文本、依赖目录和 checksum 需要可复现。生产策略可以是:

desired exact version per release
allowed source versions for upgrade
blocked known-bad versions

不要在 setup 中悄悄接受“任何 3.x”。也不要把本章精确版本断言误当成 PostGIS 永远只能使用 3.6.4。

16.6.3 观察分区、索引、写入与聚合成本

先建对象清单

本章的固定对象规模:

2 managed schemas
2 extensions
34 relations in shop_ch16
  tables/partitions
  indexes
  views
13 explicitly managed non-primary indexes

verify.sql 使用精确白名单和 marker。生产不一定 需要把所有对象硬编码进单个 DO block,但必须有期望状态与漂移检测。

分区覆盖与行分布

每日检查:

SELECT
  child.relname,
  pg_get_expr(child.relpartbound, child.oid)
FROM pg_inherits AS inheritance
JOIN pg_class AS child
  ON child.oid = inheritance.inhrelid
WHERE inheritance.inhparent =
      'schema.events'::regclass;

监控:

future coverage horizon
missing/overlapping bounds
rows and bytes per partition
min/max event time
late writes by partition age
default/quarantine rows
new partition owner/privileges/indexes

本章固定 1/7/4 只用于回归;生产应关注趋势和异常分布。

父分区大小可能是零

size-catalog.sql 得到:

delivery_event_parent_total      = 0
delivery_event_partitions_total  > 0

分区父表不存 heap 行,只查:

pg_total_relation_size('parent')

可能严重低估整棵分区树。容量查询要遍历 pg_partition_tree/ pg_inherits 汇总叶表与叶索引。

写入成本不是一行 heap

每个事件写入:

ingest_attempt heap + PK + lookup index
event_registry heap + PK
one event partition heap
partition PK
courier/time B-tree
geometry GiST
geography GiST
generated geography computation
WAL for all changed pages
replica replay

本章为了可见性保留完整链;生产应测每一项是否需要。删除一个索引可能降低 写放大,却也改变关键查询。决策来自读写 workload,不来自“空间列都建 GiST”。

观察索引状态与使用

目录状态:

SELECT
  indexrelid::regclass,
  indisvalid,
  indisready,
  indislive
FROM pg_index
WHERE indrelid IN (...);

运行统计:

SELECT *
FROM pg_stat_user_indexes
WHERE schemaname = 'shop_ch16';

idx_scan = 0 不能立刻证明索引无用:

  • 统计可能重置;
  • 它可能为约束服务;
  • 查询可能只在事故/月底运行;
  • 小表规划器合理选择 Seq Scan;
  • standby 查询不一定反映在 primary 指标。

删除前应结合查询样本、约束职责、时间窗口和回退计划。

空间候选比率

对代表性 query 记录:

index candidate rows
exact result rows
rows removed by filter/recheck
heap blocks
execution time distribution
geometry complexity
query radius/area

候选/命中比很高,说明 bbox 粗筛弱。可能原因:

  • 巨大或细长 geometry;
  • 查询区域过大;
  • 数据高度密集;
  • 无效/异常 geometry;
  • 不合适的 CRS/opclass;
  • 统计估计失真。

不要只盯索引大小。

聚合与迟到更正

监控时间桶:

events per bucket
late events per bucket
recomputed buckets
correction lag
failed/queued refresh
watermark by source

本章 quarter_hour_volume 是普通 view,每次现算。若改成物化或 continuous aggregate,还要观察刷新窗口、失效范围、后台 worker、锁、WAL 与旧结果 更正。

PostgreSQL/Pigsty 观测面

常用原生证据:

pg_stat_activity
pg_stat_statements
pg_stat_user_tables
pg_stat_user_indexes
pg_stat_wal
pg_stat_replication
pg_stat_progress_create_index
pg_locks
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS)

Pigsty 将其中许多指标接入监控与仪表盘。平台视图适合发现趋势,SQL 与系统 目录适合确认对象和查询事实。告警链接应能回到具体 cluster/database/schema/ partition/index,而不是只有一个“PostGIS 慢”标签。

生产基准矩阵

至少覆盖:

维度 样本
时间范围 15 分钟、1 日、30 日、全保留
空间范围 小半径、城市区、多边形、超大区域
数据密度 中心区、郊区、极端热点
状态 热缓存、冷缓存、并发写
事件 正常、迟到、批量回补
计划 常量、prepared custom/generic
节点 primary、read replica、failover 后

记录 P50/P95/P99、吞吐、CPU、I/O、WAL、锁、副本延迟和结果 checksum。

观测不能改变语义

若性能不达标,优化顺序应是:

  1. 结果与时间/空间合同是否正确;
  2. 参数范围是否合理;
  3. 分区裁剪是否生效;
  4. 候选/精确阶段是否存在;
  5. 类型、SRID、谓词和 opclass 是否匹配;
  6. 统计是否可信;
  7. 索引、分区粒度或预计算是否需要调整;
  8. 是否有引入扩展/分片/异步路径的量化理由。

不要为了让曲线好看,把 ST_Covers 换成 bbox-only 或丢弃迟到事件而不修改 业务合同。


上一节:时空联合查询是本章收束目标 · 返回本章目录 · 下一节:实战:配送事件的时空 PoC · 查看全书目录 · 查看索引中心

16.7 实战:配送事件的时空 PoC

本节把前六节压成一个可审计的 1.4-proposal

frozen attempts/geofences/hubs
  -> exact extension and schema ownership
  -> canonical event deduplication
  -> UTC native partition routing
  -> geometry/geography modeling
  -> temporal, boundary, distance, and plan evidence
  -> expected failures and application privileges
  -> full catalog/data checksum
  -> reset guards
  -> transactional exact reset
  -> rebuild and second review

正式证据来自 Homebrew PostgreSQL 18.6 的受控开发数据库。Pigsty 4.5 的声明 与交付职责已经映射,但没有执行 Pigsty L1,因此结果明确标为:

pigsty_l1=not-run

破坏边界

task.sh all 会删除并重建带精确 marker 的 shop_ch16shop_ch16_ext,以及后者中的 PostGIS/btree_gist。它只适用于本书 本地/开发 fixture。生产环境不得执行这条删后重建路径。

16.7.1 生成确定性事件与地理数据

前置连接

沿用第 4 章的 libpq service:

[pg36-admin]
host=/path/to/socket-or-host
port=5432
dbname=pg36_shop
user=postgres
chmod 600 /path/to/pg_service.conf
export PGSERVICEFILE=/path/to/pg_service.conf
export PGSERVICE=pg36-admin

不要把密码写进命令行、脚本、Git 或 evidence。

context.sql 要求:

database = pg36_shop
writable instance
PostgreSQL major = 14..18
session user = superuser
can SET ROLE pg36_owner
ch04-v1 physical model exists
pg36_app is constrained non-superuser LOGIN
PostGIS 3.6.4 is available or exact managed install exists
btree_gist 1.8 is available or exact managed install exists
existing ch16 schemas/extensions, if any, have exact identities

任何一项不符都停止。脚本不会“接受最接近的 PostGIS”后继续生成一套无法与 golden 比较的证据。

资产清单

核心文件:

static/labs/ch16/
├── frozen-attempts.csv
├── frozen-geofences.csv
├── frozen-hubs.csv
├── fixture.sql
├── fixture-manifest.json
├── context.sql
├── setup.sql
├── verify.sql
├── final-state.sql
├── reset.sql
├── review.py
├── task.sh
├── spatiotemporal-adr.md
├── baseline-v1.4-proposal.json
└── pigsty-declaration.example.yml

另有时间、分区、边界、距离、目录、权限与计划探针。

冻结文件身份

fixture-manifest.json 固定:

attempts
  rows=13
  distinct_events=12
  sha256=7fa1aadbba029061fbc7eb34c9f6285eabb38b438e7d0c0c8c6b820cdc738ccf

geofences
  rows=4
  zones=3
  sha256=b2da791c7adba720cf9f4fc1123546eb08036bb60ed8ed778ae5b8dc60435c66

hubs
  rows=3
  sha256=bd56e0f8f978495bc1e677270f285628163a3edc1656fe99a2a7173ffe8e18af

fixture.sql
  sha256=b254bf5d695cf1ab738fc71b573544ef526146355b18d60b99f342ca1536a860

这些 SHA-256 识别输入;release candidate checksum 识别发布合同,两者不是 同一个东西。

为什么使用合成坐标

冻结数据像一个简化城市网格,但不是权威地图:

central hub (-74.00000, 40.71000)
east hub    (-73.98000, 40.71000)
airport hub (-73.87500, 40.65000)

这样做可以:

  • 离线运行;
  • 不引入地图许可证;
  • 人工看懂边界点与围栏扩张;
  • 每次得到相同 WKT 和 checksum;
  • 隔离数据库机制与外部地理数据质量。

因此本章不能证明地址、道路、行政区或真实 GPS 精度。

单步建立

./static/labs/ch16/task.sh setup

setup 的碰撞保护先验证已有对象:

shop_ch16
  owner=pg36_owner
  exact schema marker
  all relations have marker
  no routines/operators/opclasses/opfamilies

shop_ch16_ext
  owner=pg36_owner
  exact schema marker
  exactly btree_gist 1.8 + postgis 3.6.4
  exact extension owner/trusted boundary
  no unmanaged relations/types/functions/operators/opclasses

通过后,整个重建位于一个事务:

BEGIN;
  drop exact old objects without CASCADE
  create extension schema
  create extensions
  create data schema/tables/partitions/indexes
  load fixture
  create views/grants/comments
  analyze
COMMIT;

第一次开发执行曾在视图语法处失败,这促使 setup 加入事务边界。如今任何 中途错误都会回滚,不留下会挡住下一次运行的半成品。

扩展创建

SET ROLE pg36_owner;
CREATE EXTENSION btree_gist
  WITH SCHEMA shop_ch16_ext
  VERSION '1.8';
RESET ROLE;

CREATE EXTENSION postgis
  WITH SCHEMA shop_ch16_ext
  VERSION '3.6.4';

PostGIS 由管理员创建,btree_gist extension owner 是业务 owner。两个 extension 和 schema 都带相同精确 marker:

pg36 ch16 spatiotemporal lab; safe to rebuild

marker 不是安全令牌的替代品;reset 还会核对 target、完整对象清单、依赖、 数据 checksum 和活跃 worker。

时间与空间 schema

数据层:

fixture_meta
ingest_attempt
event_registry
geofence_version
delivery_hub
delivery_event (partitioned parent)
  ├── delivery_event_20260307
  ├── delivery_event_20260308
  └── delivery_event_20260309
event_lateness view
event_zone_membership view
quarter_hour_volume view

关键约束:

ingest coordinates within lon/lat ranges
received_at >= occurred_at
event_registry payload consistency
geofence tstzrange is [), non-empty
geofence SRID=4326, non-empty, valid
same zone validity ranges cannot overlap
event geometry is Point/4326
partition primary key includes occurred_at
generated geography derives from geometry

确定性去重

loader 先统计同一 event_id 的 payload variant:

count(
  DISTINCT concat_ws(
    '|',
    occurred_at,
    courier_id,
    event_type,
    longitude,
    latitude,
    source_sequence
  )
)

只有 payload_variants = 1 才进入 registry。canonical 按:

ORDER BY event_id, received_at, attempt_id

选择。固定:

e003 attempts=a003,a004
canonical=a003
attempt_count=2

再由 canonical attempt 生成 Point 并写入父分区表。

建成摘要

status=fixture-ready
attempts=13
events=12
geofence_versions=4
postgis=3.6.4

这只是 setup 摘要,不是完整验收。

逐字节回读

PG36_EVIDENCE_DIR="$PWD/evidence/ch16-cycle" \
  ./static/labs/ch16/task.sh evaluate

自动化从数据库执行三条 COPY ... TO STDOUT CSV HEADER,再:

cmp frozen-attempts.csv evidence/attempts.csv
cmp frozen-geofences.csv evidence/geofences.csv
cmp frozen-hubs.csv evidence/hubs.csv

导出显式固定:

  • 行顺序;
  • UTC RFC3339 风格时间;
  • 经纬度五位小数;
  • WKT;
  • CSV header。

没有 ORDER BY 的数据库导出不具备逐字节比较意义。

16.7.2 验证 SRID 错误、裁剪失效和空间索引

先看时间事实

psql "service=pg36-admin" \
  -f static/labs/ch16/temporal-analysis.sql

应为:

dst_e002_local=2026-03-08 01:55:00
dst_e003_local=2026-03-08 03:05:00
dst_elapsed_seconds=600
duplicate_event=e003:2
late_event_ids=e001,e004
out_of_order_pair=e004->e005
partition_boundary=e008=...20260308;e009=...20260309
utc_day8_events=7

任何一个值改变,都意味着 fixture、时区、去重或路由合同漂移。

验证分区目录

psql "service=pg36-admin" \
  -f static/labs/ch16/partition-catalog.sql

固定:

partition bound rows
delivery_event_20260307 [03-07,03-08) 1
delivery_event_20260308 [03-08,03-09) 7
delivery_event_20260309 [03-09,03-10) 4

同时验证每张叶表 owner 与 marker。

对照裁剪正反例

psql "service=pg36-admin" \
  -f static/labs/ch16/time-pruned-plan.sql

psql "service=pg36-admin" \
  -f static/labs/ch16/time-wrapped-plan.sql

正例:

Seq Scan on delivery_event_20260308

反例:

Append
  20260307 rows removed
  20260308 seven rows
  20260309 rows removed

两条 SQL 都返回七行。计划对照证明“结果正确”与“裁剪正确”是两项验收。

验证围栏边界与换版

psql "service=pg36-admin" \
  -f static/labs/ch16/boundary-semantics.sql

固定:

scenario event zone/version covers contains touches
at expansion e005 central/2 t t f
before expansion e004 central/1 f f f
shared boundary e003 central/1 t f t
shared boundary e003 east/1 t f t

这四行比一个“地图截图”更容易自动回归。

混合 SRID 必须失败

psql "service=pg36-admin" \
  -v VERBOSITY=verbose \
  -f static/labs/ch16/srid-mismatch.sql

预期进程退出码 3,stderr 含:

XX000
Operation on mixed SRID geometries

自动化将“正确拒绝”当成功证据。若 SQL 意外返回 false/true,说明坐标身份 保护失效。

重叠有效期必须失败

psql "service=pg36-admin" \
  -v VERBOSITY=verbose \
  -f static/labs/ch16/overlap-geofence.sql

预期:

23P01
violates exclusion constraint geofence_version_no_overlap

失败语句不留下 version 99,随后 verify.sql 仍要求围栏恰好四行。

应用写入必须失败

psql "service=pg36-admin user=pg36_app" \
  -v VERBOSITY=verbose \
  -f static/labs/ch16/app-write.sql

预期:

42501 permission denied for table delivery_event

应用可读取 central day8 五行,却不能更新事件或绕过生成/分区/去重链。

GiST 与 SP-GiST 路径

psql "service=pg36-admin" \
  -f static/labs/ch16/spatial-gist-plan.sql

psql "service=pg36-admin" \
  -f static/labs/ch16/spatial-spgist-plan.sql

固定计划包含:

event_20260308_geog_gist_idx
  Index Cond: geography bbox expansion
  Filter: ST_DWithin(...,1500)

delivery_hub_location_spgist_idx
  Index Cond: geometry bbox expansion
  Filter: ST_DWithin(...,0.02)

两条探针临时关闭 Seq Scan;它们只证明路径可用。

时空联合计划

psql "service=pg36-admin" \
  -f static/labs/ch16/joint-plan.sql

必须同时出现:

geofence_version_no_overlap
event_20260308_location_gist_idx
ST_Covers filter

且不出现 20260307/20260309 事件分区。

索引目录不能只看名字

psql "service=pg36-admin" \
  -f static/labs/ch16/index-catalog.sql

13 行全部要求:

indisvalid=true
indisready=true
indislive=true
index_bytes>0
marker exact
access method/opclass exact

特别是:

geography -> gist_geography_ops
geometry  -> gist_geometry_ops_2d
hub point -> spgist_geometry_ops_2d
exclusion -> gist_text_ops + range_ops

完整 SQL 断言

./static/labs/ch16/task.sh verify

verify.sql 检查:

  • 两个 schema 的 owner/marker;
  • 34 个关系对象精确白名单;
  • extension schema 中没有未管理成员;
  • 两项扩展版本、owner、trusted/relocatable 边界;
  • fixture 身份与 13/12/4/3 基数;
  • 去重 canonical;
  • 1/7/4 路由与两个边界事件;
  • DST 600 秒、迟到和乱序;
  • [)、非重叠、有效 geometry;
  • 14 条 membership 和四个边界事实;
  • generated geography;
  • 13 个索引状态/opclass;
  • 应用权限;
  • 业务校验和。

成功:

status=ok
fixture=frozen-byte-identical
time=event+ingest+validity
space=geometry+geography+4326
partition=utc-range-1+7+4
membership=14
business_checksum=53f51cef1f0bed1a5c2fc89bfad109f4

16.7.3 输出 ADR、PoC 证据与生产代价清单

一键双周期验收

PG36_EVIDENCE_DIR="$PWD/evidence/ch16-final" \
  ./static/labs/ch16/task.sh all

all 执行:

cycle-1 setup + collect + review
  -> wrong token reset guard
  -> wrong target reset guard
  -> active worker reset guard
  -> exact reset
  -> cycle-2 setup + collect + review

第二周期不是重复表演。它证明 reset 后:

  • 数据 schema 消失;
  • 扩展 schema 消失;
  • PostGIS/btree_gist 被精确移除;
  • 第 14 章 pg_trgm/vector 保留;
  • 同一输入能重建同一业务 checksum;
  • 计划、权限和失败边界仍成立。

evidence 结构

evidence/ch16-final/
├── cycle-1/
│   ├── manifest.txt
│   ├── attempts.csv
│   ├── geofences.csv
│   ├── hubs.csv
│   ├── temporal-analysis.csv
│   ├── partition-catalog.csv
│   ├── time-buckets.csv
│   ├── zone-membership.csv
│   ├── boundary-semantics.csv
│   ├── distance-semantics.csv
│   ├── extension-catalog.csv
│   ├── index-catalog.csv
│   ├── security-catalog.csv
│   ├── size-catalog.csv
│   ├── *-plan.txt
│   ├── *-failure stderr/exit
│   ├── final-state.csv
│   ├── verify.txt
│   └── review.txt
├── reset-wrong-token.*
├── reset-wrong-target.*
├── reset-active-worker.*
├── reset-exact.*
└── cycle-2/
    └── same evidence set

manifest 身份

每周期 manifest 保存:

captured_at
action/service
validation_path=direct-postgresql
pigsty_reference=4.4
pigsty_l1=not-run
model_version=ch04-v1
partition_timezone=UTC
coordinate_contract=EPSG:4326-synthetic
server/database/admin/recovery
extension versions
preserved ch14 extensions
baseline canonical checksum
fixture manifest canonical checksum
all source file SHA-256

动态采集时间不进入业务 checksum。它说明“证据何时采集”,不改变数据真值。

最终状态

final-state.sql 固定:

attempts=13
events=12
duplicate_registry=e003:2
late_events=e001,e004
partition_counts=1,7,4
memberships=14
central_day8=e002,e003,e005,e006,e008
extensions=btree_gist:1.8,postgis:3.6.4
business_checksum=53f51cef1f0bed1a5c2fc89bfad109f4

checksum 覆盖:

attempts
registry
geofences and WKT
hubs and WKT
canonical events and WKT
zone memberships

它不包含执行计划、物理 OID、索引页或采集时间,因此 reset/rebuild 后仍应 相同。

自动审校

review.py 不连接数据库,只审查 evidence 与冻结 source:

  • 三份导出字节相同且 hash/行数匹配 manifest;
  • DST、迟到、乱序与路由事实精确;
  • 分区、桶与 membership 守恒;
  • 边界、距离结果精确;
  • 扩展、索引、权限、体积目录满足合同;
  • 直接/包裹时间计划形成反例;
  • GiST/SP-GiST/联合计划包含目标路径;
  • 三个失败的退出码与 SQLSTATE 正确;
  • final state 与完整 verify 通过;
  • baseline JSON 与业务 checksum 一致。

这样可以把“数据库输出的确生成了”与“输出符合我们预先定义的结论”分开。

reset 三道动作护栏

精确复位要求:

export PG36_RESET_TOKEN=RESET_CH16_SPATIOTEMPORAL_LAB
export PG36_RESET_TARGET='pg36_shop/shop_ch16+shop_ch16_ext'

./static/labs/ch16/task.sh reset

错误 token:

P3660

错误 target:

P3661

活跃 pg36-ch16-* worker:

P3663

通过动作护栏后,reset 在事务内先 \ir verify.sql。也就是说,只有完整状态 仍与本章合同相同时才开始 DROP。

为什么不用 CASCADE

删除顺序:

views
event parent (and its owned partitions/indexes)
other data tables
data schema
postgis
btree_gist
extension schema

全部使用 RESTRICT 默认语义。若外部对象意外依赖本章扩展,DROP 会失败,事务 整体回滚,数据和扩展不会处于半删状态。

ADR 的核心决策

spatiotemporal-adr.md 记录:

occurred_at is event time and partition key
received_at remains ingest evidence
valid_during is non-overlapping tstzrange
UTC daily native RANGE is baseline
TimescaleDB is deferred
EPSG:4326 is canonical
geometry serves topology/index
geography serves meter distance
ST_Covers includes boundary

同时列出否决方案、代价与重开条件。ADR 的价值不是替 SQL 写说明,而是保留 “为什么这样选”和“什么新证据会让我们重选”。

生产代价清单

本 PoC 未证明:

production ingest throughput
P50/P95/P99 time-space query latency
WAL and replica lag
GiST/SP-GiST build/reindex duration
autovacuum and statistics behavior
real polygon complexity/selectivity
GPS and authoritative map quality
backup/restore on Pigsty L1
failover behavior
PostGIS/Pigsty upgrade path
TimescaleDB benefit

发布前应将这些项目变成有负责人、环境、阈值、证据路径和停止线的验收计划。

最终正式输出

两轮 Homebrew PostgreSQL 18.6 结果:

status=ok
fixture=frozen-byte-identical
time=event+ingest+validity+dst
space=geometry+geography+srid+boundary
plans=pruning+gist+spgist+joint
guards=P3660+P3661+P3663
extensions=btree_gist:1.8+postgis:3.6.4
pigsty_l1=not-run
release_candidate_checksum=13902984b3da92a66638d0d6e2f886d6d8ac5cb20ba89ec08b1527ae79d2b923

这个 checksum 对应 baseline-v1.4-proposal.json 的规范化 JSON。修改合同后必须生成新 proposal checksum,不能继续引用旧 结果。

16.7.4 超预算时先删扩展专属细节,不删基础判断力

本章的教学最小闭环

如果书稿、课程或项目时间不足,最小闭环仍必须保留:

event / ingest / valid time
timestamptz + explicit timezone
DST counterexample
late / out-of-order / duplicate distinction
[) range policy
partition-key ADR
pruning positive and negative plans
geometry / geography / SRID / units
ST_SetSRID vs ST_Transform
boundary predicate counterexample
ST_DWithin vs ST_Distance vs KNN
bbox candidate + exact predicate
one joint time-space query
one expected SRID failure
one exact reset/rebuild path

删掉其中任一组,读者很可能只记住命令,不会形成判断力。

第一优先可删:扩展参数百科

可压缩:

  • TimescaleDB 某一版本的全部 GUC;
  • 所有 PostGIS 子扩展列表;
  • 每个索引 opclass 的完整矩阵;
  • 某发行版每个包文件名;
  • 罕见 geometry 类型函数目录。

它们变化快,也可从目标版本官方文档查到。正文应保留如何核对,而不是复制 百科。

第二优先可删:未实测的高级方案

本章没有假装实现:

  • 路网最短路;
  • 地图匹配;
  • 轨迹压缩;
  • 3D/4D 几何;
  • raster;
  • 全球多投影治理;
  • 双时态修订系统;
  • continuous aggregate 基准。

这些可以成为后续项目,但不应挤掉本章已经可复现的基础闭环。

不能把 Pigsty 映射删成一句话

即使篇幅少,也至少保留:

package supply
preload/config when required
CREATE EXTENSION
all L1 nodes
catalog/functional validation
backup/restore and upgrade

否则读者会把本地 CREATE EXTENSION 当成生产交付。

不能把反例全删掉

本章四个关键反例:

  1. 夏令时墙上差 70 分钟,实际 600 秒;
  2. 包裹分区键后逻辑结果相同,但三分区全扫;
  3. 边界点 covers=truecontains=false
  4. 混合 SRID 必须失败。

成功路径告诉读者“怎么写”;反例让读者知道“为什么这样写”。若只留成功 截图,认知无法迁移到新业务。

生产预算不足时的正确停止线

如果没有预算完成:

代表性规模压测
Pigsty L1 节点一致性
备份恢复
故障切换
扩展升级演练
真实地图许可证/质量评审

结论应停在:

semantic and mechanical PoC passed
production release not approved

不能因为 PoC 代码整洁就降低生产验收标准。

迁移练习

读者可复制 fixture 为 ch16-spatiotemporal-v2,任选一项扩展:

  • 改用一个真实但许可明确的公开边界数据集;
  • 增加 GPS accuracy 与 uncertain membership;
  • 实现围栏修订的 system time;
  • 对 native partition 与 TimescaleDB 做同输入 A/B;
  • 加入真实数量级并比较 GiST/SP-GiST;
  • 为当地业务日生成可裁剪 UTC 边界;
  • 增加唯一归属消歧规则。

必须:

  1. 新建 manifest/version;
  2. 保留旧 fixture;
  3. 写明新许可证与生成方法;
  4. 更新 expected facts/checksum;
  5. 加入至少一个新反例;
  6. 重新做 reset/rebuild 两周期;
  7. 不沿用本章 release checksum。

做到这一步,读者不只是会调用 PostGIS,而是能把时空需求变成可验证的 PostgreSQL/Pigsty 工程合同。


上一节:时空扩展的交付与观察 · 返回本章目录 · 下一章:合纵连横:分析加速与分布式选型 · 查看全书目录 · 查看索引中心

17 合纵连横:分析加速与分布式选型

“数据越来越多,所以要上分布式”不是一个架构结论,只是一句尚未完成的 问题描述。

同样一条慢月报,可能分别来自:

统计信息失真
  -> 规划器选错路径
缺少合适索引
  -> 选择性查询扫描过多数据
work_mem 不足
  -> 排序或哈希落到临时文件
每次重算历史事实
  -> 缺少可接受新鲜度的汇总层
OLTP 与 OLAP 争用资源
  -> 缺少负载隔离
单节点资源确已越界
  -> 才可能需要横向拆分

若不先辨认瓶颈,把数据分到更多节点只会把一个可观测的本地问题变成网络、 路由、远端事务、再平衡和部分失败共同参与的问题。

本章坚持一条次序:

先定义服务目标,再证明单机边界;先减少无效工作,再隔离负载;只有明确 哪一种资源无法在单节点满足目标后,才比较分布式候选。

这并不是反对分布式。恰恰相反,只有把进入条件、分布键、数据局部性、 失败语义和撤退路线写清楚,分布式才是一项可评审的工程决策,而不是对增长 焦虑的技术性反射。

本章完成后

你应当能够:

  • 把“分析慢”改写为数据量、并发、P50/P95/P99、吞吐、新鲜度、正确性、 RPO/RTO 和成本目标;
  • 区分 CPU、存储 I/O、缓存、临时文件、锁等待、计划误差和远端传输瓶颈;
  • EXPLAIN (ANALYZE, BUFFERS)、系统统计和冻结工作负载建立单机证据;
  • 从计划中识别 Gather、parallel scan、partial/final aggregate 与实际 worker 数;
  • 解释“计划允许并行”与“执行时拿到 worker”为什么是两件事;
  • 用 covering B-tree、BRIN、物化汇总和批处理分别解决不同访问形状;
  • 解释 Index Only Scan 的 visibility map 前置条件,不把一次偶然计划当 稳定合同;
  • work_mem 的外排/内排反例说明为什么不能按单查询峰值做全局调参;
  • 区分 PostgreSQL 原生物化视图的完整刷新与应用维护的增量汇总;
  • 识别 OLTP/OLAP 共存时对 CPU、buffer、temp、WAL、vacuum 和副本延迟的 竞争;
  • 写出进入分布式评审的硬门槛,而不是只写“未来数据会增长”;
  • 选择候选分布键,计算数据倾斜,并审计跨分片查询、JOIN、事务与唯一性;
  • 解释 PostgreSQL HASH 分区 remainder 为什么不等于整数 % modulus
  • 比较 PostgreSQL 扩展、兼容数据库与专用 OLAP 时区分 SQL、类型、事务、 扩展、运维和故障兼容;
  • 用相同冻结输入、相同查询和相同失败条件比较候选;
  • 通过 postgres_fdw 计划分清过滤下推、聚合下推、协调端聚合与行传输;
  • 解释“数据同分片”为什么仍不能自动证明某条 JOIN 已被下推;
  • 定义单分片不可达时,单租户读、全局读、写入与重试分别应如何表现;
  • 在 Pigsty 中把分析读隔离到 offline replica,或声明一个待验收的 Citus 拓扑,同时不把配置片段当生产验收;
  • 输出一份包含证据、限制、生产代价、复审触发器和退出路线的 ADR。

贯穿本章的销售分析

实验生成一份完全确定的合成数据:

8 tenants
50 accounts per tenant
120 days
5 sales per account per day

400 accounts
240,000 sales
1,200,000 units
2,256,000.00 amount

同一份业务月报由四条路径计算:

local raw facts
local daily materialized summary
partitioned postgres_fdw parent
two-stage remote daily + coordinator monthly aggregation

四条路径都必须逐字节等于 frozen-monthly.csv 的 32 行。正确性不一致 时,不允许继续比较计划或性能。

冻结事实:

项目 数值
本地事实 240,000 行
shard A / shard B 120,000 / 120,000 行
本地日汇总 2,880 行
最终月报 32 行
租户 3 的 4 月 7,500 笔,69,375.00
朴素 FDW 返回协调端 240,000 行
两阶段聚合返回协调端 960 行
业务校验和 42fb8ab5444469eba1f104a8e1e529dd
月报校验和 644d45544ebbc2a80c42270c38ac6885

这里的“返回行数”描述数据流形状,不是网络字节,也不是耗时。三个数据库都在 一台机器的一个 PostgreSQL 18.6 实例中,没有独立 CPU、磁盘、网络或故障域。

先看单机还能做什么

冻结计划证明四件不同的事。

月聚合可并行:

Finalize HashAggregate
  -> Gather
       Workers Planned: 2
       Workers Launched: 2
       -> Partial HashAggregate
            -> Parallel Seq Scan on sales_fact

租户 3 的选择性查询可走 covering index:

Index Only Scan using sales_fact_tenant_day_idx
  actual rows=7500
  Heap Fetches: 0

Heap Fetches: 0 并不是 INCLUDE 自动保证的。实验重建后显式执行 VACUUM (ANALYZE),让 visibility map 建立 all-visible 信息,再验证零回表。 如果跳过这一步,新表可能合理地使用 Bitmap Heap Scan。

同一排序在两个会话级配置下呈现不同资源路径:

work_mem=64kB -> external merge, Disk ~= 5.9MB
work_mem=32MB -> quicksort, Memory ~= 13.6MB

这只说明 spill 可被计划证据观察。一个查询需要 32MB,不等于应该把全局 work_mem 设置成 32MB;一个并发查询可以包含多个 sort/hash 节点,还有 并行 worker 和并发会话共同放大内存。

物化日汇总把月报输入从 240,000 行降为 2,880 行,但随之引入:

freshness target
refresh schedule
refresh failure recovery
late-arriving correction
locking and WAL cost
definition version

PostgreSQL 的物化视图持久保存查询结果,读取时像表;数据不会自动保持最新, 需要 REFRESH MATERIALIZED VIEW。官方 Materialized Views 把“读得更快”与“可能不新鲜”明确放在同一项权衡里。

再看分布式改变了什么

实验的协调端由 LIST 分区父表接管两个外表:

sales_fact_distributed PARTITION BY LIST (tenant_id)
├── sales_fact_dist_0: tenants 2,4,6,8 -> pg36_shard_a
└── sales_fact_dist_1: tenants 1,3,5,7 -> pg36_shard_b

租户 3 的查询只访问 shard B,计划中的远端 SQL 带上租户和日期:

Foreign Scan on sales_fact_dist_1
Remote SQL:
  SELECT amount
  FROM shop_ch17_shard.sales_fact
  WHERE occurred_on >= '2026-04-01'
    AND tenant_id = 3

全局月报若直接从分区父表聚合,两个 Foreign Scan 各返回 120,000 行, 协调端接收 240,000 条事实后聚合。改成每个远端先按租户、日期聚合:

shard A: 120,000 facts -> 480 daily aggregates
shard B: 120,000 facts -> 480 daily aggregates
coordinator: 960 daily aggregates -> 32 monthly rows

结果相同,传输形状完全不同。这是分布式查询最重要的思维之一:

尽量让过滤、连接和聚合靠近数据发生;但必须用实际计划证明下推,不可从 SQL 外观或拓扑图推断。

反例也被固定下来:租户 3 的账户与销售位于同一分片,查询通过两个分区外表 父表连接时,实测仍在协调端执行 Hash Join,接收 7,500 条销售与 50 条 账户。postgres_fdw 的远端优化、代价、fetch_size、连接与事务管理以 PostgreSQL 官方 postgres_fdw 文档为准。

HASH 分区不是整数取模

本章第一版失败原型使用:

remote fixture routing = tenant_id % 2
coordinator routing = PARTITION BY HASH (tenant_id)

它们不是同一算法。PostgreSQL HASH 分区先使用数据类型的哈希支持函数,再按 MODULUS/REMAINDER 判断分区;REMAINDER 0 不表示“偶数值”。当查询带 tenant_id 时,协调端会按自己的哈希算法裁剪到一个分区,而目标租户可能被 生成器放在另一个数据库,于是出现“全表看似有数据,按租户裁剪却静默漏数” 的危险结果。

冻结实验改用显式 LIST 路由,使物理分片和协调端边界完全一致。生产系统不应 手写八个租户清单,而应让同一个经过版本化的路由算法或分片元数据成为写入、 读取、再平衡和恢复的共同事实来源。

PostgreSQL 官方 Table Partitioning 说明 HASH 分区以 modulus/remainder 描述分区边界;不能把这些名词误读为对 原始整数直接做 %

Pigsty 中的两条候选路径

本章不把 Pigsty 等同于某一种分布式数据库。它首先提供一种声明和交付运行 环境的方法。

路径 A 是保留一个 PostgreSQL 数据体系,把 OLAP/ETL/交互慢查询隔离到 offline replica 或带 pg_offline_query 标签的副本:

all:
  children:
    pg-analytics:
      hosts:
        10.10.10.11: { pg_seq: 1, pg_role: primary }
        10.10.10.12:
          pg_seq: 2
          pg_role: replica
          pg_offline_query: true
      vars:
        pg_cluster: pg-analytics
        pg_conf: olap.yml

路径 B 是在硬门槛满足后评估 Citus。Pigsty 4.5 文档要求 Citus 拓扑声明 pg_mode: cituspg_shard、各分片的 pg_grouppg_primary_db, 并配置数据节点间访问规则;完整生产设计还必须补齐 coordinator/worker HA、 服务路由、备份恢复、再平衡、监控和升级。

Pigsty 的 配置入口集群/实例类型 给出了 offline 与 Citus 的当前声明方式。本章资产 pigsty-declaration.example.yml 只是两个互斥候选的草图,未执行 L1,不能直接合并进生产 inventory。

实验资产

规范与决策:

生成与建立:

结果与计划:

审计与退出:

快速运行

本地开发数据库先完成第 4 章的角色与物理模型,然后提供管理员 service:

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

PG36_EVIDENCE_DIR="$PWD/evidence/ch17" \
  ./static/labs/ch17/task.sh all

all 会执行两个完整周期:

bootstrap retained database shells
  -> rebuild shard A and B
  -> rebuild coordinator
  -> export and compare four monthly paths
  -> collect local and distributed plans
  -> prove application write denial
  -> make shard B temporarily unreachable
  -> prove shard A scoped read still works
  -> prove global read fails with 08001
  -> review all evidence
  -> prove reset token/target/active-worker guards
  -> pre-verify all three databases
  -> exact per-database reset
  -> rebuild everything
  -> repeat evidence and review

正式实测输出:

status=ok
fixture=frozen-byte-identical-four-paths
single_node=parallel+index+summary+spill
distributed=tenant-pruning+fdw+two-stage
counterexamples=hash-is-not-modulo+join-not-pushed
failure=healthy-shard-read+global-08001
guards=P3660+P3661+P3663
postgres_fdw=1.2
pigsty_l1=not-run
release_candidate_checksum=3dcb7308cf6983122ee860ad3dc2a4b44651549e3d5631770839bb9a0be450c6

破坏边界

task.sh all 会在精确身份、marker、对象清单、权限和数据校验和匹配后, 删除并重建 shop_ch17shop_ch17_ext、两个 foreign server、六个 user mapping,以及两个分片数据库中的 shop_ch17_shard。它保留空的 pg36_shard_apg36_shard_b 数据库壳。只可用于本书受控开发 fixture, 不得在生产执行。

本章目录

17.1 先证明单机边界

17.2 单机分析能力

17.3 何时需要分布式

17.4 比较分布式候选

17.5 部署最小分布式 PoC

17.6 实战:从单机证据到选型 ADR


上一章:经天纬地:时序、空间与时空查询 · 返回上卷导读 · 下一章:万法归宗:PostgreSQL 数据平台与替代边界 · 查看全书目录 · 查看索引中心

17.1 先证明单机边界

“单机扛不住”必须是证据结论,而不是架构会议里的气氛。

最常见的误判有两类:

局部问题被说成容量问题
  一条坏 SQL / 一个缺失索引 / 一次统计失真
  -> “PostgreSQL 不适合分析”

容量问题被说成局部问题
  工作集、写入、维护窗口或故障域已越过单节点
  -> “再调一个参数就好”

本节不预设答案。先把目标、负载和瓶颈拆成可测量的对象,再决定应该优化、 隔离、扩容,还是分布。

17.1.1 定义数据量、并发、延迟与新鲜度目标

“数据量”至少有六种尺寸

只报“十亿行”几乎没有决策价值。十亿个窄整数与十亿个宽 JSONB 的存储、 缓存和扫描成本不同;十亿行均匀访问与 99% 查询只看最近一天也不同。

基线至少记录:

维度 示例问题 可验证证据
逻辑规模 行数、租户数、时间跨度? count(*)、业务目录
物理规模 heap、TOAST、索引各多大? pg_relation_sizepg_total_relation_size
工作集 查询真正反复访问哪部分? 计划 buffers、时间谓词、缓存命中
增长 每日新增、更新、删除多少? 时序采样与容量预测
倾斜 最大租户/日期/键占多少? percentile、top-N、直方图
生命周期 热、温、冷数据如何变化? 保留、归档与访问统计

本章 fixture 的身份不是一句“24 万行”:

8 tenants
400 accounts
2026-01-01 .. 2026-04-30
240,000 sales
2,000 heap pages in the verified run
local facts + daily summary + two remote shard copies

它还有明确限制:数据均匀、确定、合成,无法代表真实倾斜、缓存冷启动、 网络、WAL、vacuum 或生产并发。

并发不是 QPS 的同义词

分析系统常见四种并发:

arrival concurrency
  同时到达多少请求

active database concurrency
  同时在 PostgreSQL 内执行多少语句

in-query parallelism
  一条语句使用多少 parallel workers

background concurrency
  autovacuum、checkpoint、备份、复制、ETL、刷新同时做什么

一个 dashboard 打开时发出 30 条 SQL,不等于数据库应该同时运行 30 个重 聚合。连接池可以排队,应用可以合并请求,汇总层可以复用结果。反过来, “线上只有 20 个连接”也不表示压力小:每条查询可能启动多个 worker、多个 sort/hash 节点并产生大临时文件。

因此基线应同时记录:

request rate
queue time
active sessions
parallel workers planned/launched
statements per request
rows scanned / returned
temporary bytes
CPU and I/O saturation

延迟要有分位数和查询类别

“平均 800ms”会掩盖两类事实:

  • 99% 查询 10ms,1% 查询 80s;
  • 所有查询稳定在 800ms。

两者的容量和用户体验完全不同。至少按 workload class 报告:

类别 典型目标
单租户交互明细 P50/P95/P99 与超时率
dashboard 聚合 首屏、完整加载与刷新周期
批量报表 完成窗口与失败重跑时间
数据导出 吞吐、并发上限与资源封顶
ETL/刷新 截止时间、WAL/lag 与恢复点

不要把一次 EXPLAIN ANALYZE 的执行时间直接当 SLO。它只是一条 SQL 在某个 缓存、数据、参数和系统负载下的一次观察。SLO 需要在代表性并发、冷暖缓存和 运行周期下统计。

新鲜度独立于查询速度

分析请求常把两个目标混成一个:

query latency: 用户发出查询后多久返回
data freshness: 返回的数据距离真实业务现在有多旧

一个物化汇总可以在 20ms 返回昨天的数据;一条扫描原表的查询可以在 2s 返回刚提交的数据。谁更好取决于合同,而不是毫秒数。

新鲜度目标应写成可验证形式:

event-time freshness <= 5 minutes at P99
daily financial close complete by 02:00 UTC
late events within 24 hours must be included in next rebuild
dashboard may lag primary commit by 60 seconds

若使用副本,还要区分:

source event lag
ingestion lag
replication replay lag
summary refresh lag
cache lag

只看其中一个指标会把旧数据误报成“查询很快”。

正确性是第一项 SLO

所有候选必须在相同输入下得到相同业务结果。本章冻结 32 行月报,并同时固定:

business checksum = 42fb8ab5444469eba1f104a8e1e529dd
monthly checksum  = 644d45544ebbc2a80c42270c38ac6885
CSV SHA-256       = 64b045809e10364fd84a587121d919e8562a15335c4c6c015e91a0ead3a44323

四条计算路径逐字节比较:

cmp frozen-monthly.csv monthly-local.csv
cmp frozen-monthly.csv monthly-summary.csv
cmp frozen-monthly.csv monthly-distributed.csv
cmp frozen-monthly.csv monthly-two-stage.csv

如果某个候选“快很多”但少一个租户,它不是优化,而是错误。

用目标表替代形容词

一个可评审的初始目标可以长这样:

指标 当前 目标 测量条件
单租户明细 P95 1.8s < 500ms 50 并发、30 日窗口
全局月报完成时间 24min < 10min 冷缓存、完整月
dashboard 新鲜度 P99 12min < 5min 按事件时间
temp write/小时 800GB < 100GB 正常峰值
primary CPU P95 92% < 70% OLTP+分析同时
replica replay lag P99 9min < 60s 报表窗口

当前值未知时写 unknown,随后安排测量。不要用“应该没问题”填表。

工作负载清单

选型前收集每类查询:

SQL fingerprint
business owner
read/write
frequency and concurrency
parameters and selectivity
rows scanned / returned
latency distribution
temporary I/O
lock behavior
freshness requirement
retry/idempotency behavior
failure consequence

同时固定 schema、统计信息、参数、数据生成方式和版本。否则两次跑分比较的 可能不是同一个系统。

本章的 fixture-manifest.json 保存生成器、行数、分片、校验和与限制;生产基线还应保存脱敏 workload manifest 和运行环境 manifest。

17.1.2 区分 CPU、I/O、内存、锁与计划瓶颈

先问“时间花在哪里”

慢查询的第一层分类:

waiting
  lock / I/O / client / WAL / remote / worker

running
  CPU expression / decompression / hash / sort / aggregation

planned badly
  row estimate / join order / access path / partition pruning

doing too much work
  wrong grain / no predicate / repeated calculation / data transfer

分类不是互斥的。错误估算可能选择大量随机 I/O;内存不足可能产生 temp I/O; 锁等待可能让 CPU 很空但延迟很高。

用执行计划建立因果链

推荐从:

EXPLAIN (
  ANALYZE,
  BUFFERS,
  WAL,
  SETTINGS,
  VERBOSE
)
SELECT ...;

开始,但要理解风险:

  • ANALYZE 会真正执行语句;
  • 对写语句使用时会真的修改数据,除非放在可回滚且外部副作用可控的事务中;
  • BUFFERS 展示 PostgreSQL buffer/I/O 计数,不等于操作系统层面的完整因果;
  • 一次计划不是延迟分布;
  • planner estimate 与 actual 的差距比节点名字本身更重要。

PostgreSQL 官方 Using EXPLAIN 解释 plan tree、cost、actual rows、loops、buffers 与不同节点的读法。

先检查:

actual rows × loops
estimated rows versus actual rows
rows removed by filter
heap fetches
sort method / memory / disk
hash batches
shared/local/temp buffers
workers planned / launched
partition subplans actually visited
remote SQL and returned rows

CPU 瓶颈

常见信号:

  • runnable CPU 长期接近可用核心上限;
  • 查询主要读取 cached buffers,物理 I/O 不高;
  • 大量表达式、JSON、正则、排序、哈希、聚合或 JIT 消耗;
  • 增加并发只增加排队,吞吐不再提高;
  • parallel worker 增加后单查询变快、系统总吞吐却下降。

CPU 证据必须区分:

database process CPU
kernel CPU
steal/throttling
per-query CPU
background maintenance CPU

不能从 PostgreSQL Execution Time 单独推导 CPU 时间。

可尝试的方向:

  • 减少扫描和返回行;
  • 改善连接顺序与聚合粒度;
  • 避免对每行重复做昂贵表达式;
  • 使用预计算/物化;
  • 审计并行度与并发;
  • 扩大单机 CPU;
  • 只有工作可被安全分片时再横向扩 CPU。

I/O 瓶颈

常见信号:

  • cache miss 后读取延迟高;
  • shared read 与系统块设备队列共同上升;
  • 顺序大扫描把 OLTP 热页挤出缓存;
  • temp read/write 大量增长;
  • checkpoint、backup、vacuum 与分析抢同一存储;
  • 增加 CPU 不改善吞吐。

要区分三类 I/O:

base relation/index I/O
temporary spill I/O
WAL/checkpoint/backup/replication I/O

它们的修复不同。缺索引与低选择性扫描不是同一问题;给全表聚合增加 B-tree 也未必比顺序扫描好。

内存与 spill

本章用同一排序证明:

SET work_mem = '64kB';
-- external merge, temp read/write

SET work_mem = '32MB';
-- quicksort in memory

计划来自:

冻结 24 万行上观察到:

64kB: external merge, Disk about 5920kB
32MB: quicksort, Memory about 13645kB

不要据此设置:

work_mem = 32MB

然后乘上几百连接。work_mem 是许多执行节点各自可以使用的预算,不是整个 查询或实例的硬上限;并行查询还会放大消费者。正确步骤是:

  1. 找到真实 spill 的 SQL 和节点;
  2. 判断能否通过索引、过滤、聚合顺序减少数据;
  3. 估算峰值并发 × 每查询节点 × worker;
  4. 优先用角色、数据库、会话或任务级设置;
  5. 同时设置超时、并发和 temp_file_limit 一类护栏;
  6. 用压力回放核对实例 RSS、OOM 与总吞吐。

PostgreSQL 的 Resource Consumptionwork_memhash_mem_multiplier、maintenance memory 与 huge pages 等 参数的版本基准。

锁瓶颈

分析查询通常只读,不等于不会造成并发问题:

  • 长事务延长 snapshot 生命周期,阻碍 vacuum 清理;
  • DDL 等待或被 ACCESS SHARE 阻塞;
  • REFRESH MATERIALIZED VIEW 的锁行为影响读者;
  • 报表函数可能隐含写临时/业务表;
  • 导出事务可能持有 snapshot 很久;
  • standby 上长查询可能与 WAL replay 冲突。

诊断要同时看:

SELECT
  pid,
  wait_event_type,
  wait_event,
  xact_start,
  query_start,
  state,
  application_name
FROM pg_catalog.pg_stat_activity
WHERE datname = current_database();

以及 blocking graph,而不是只数连接。第 10、12 章的事务、锁与慢查询诊断 方法在这里继续适用。

计划瓶颈

错误计划常见来源:

stale or insufficient statistics
correlated columns not represented
parameter-sensitive selectivity
implicit casts/collations
function-wrapped predicates
partition key not exposed
wrong join cardinality
generic plan versus custom plan
foreign table statistics drift

本章对外表执行 ANALYZE。官方 postgres_fdw 文档指出:本地统计可以减少 远端估算开销,但远端频繁变化时会很快过期;use_remote_estimate 则会增加 远端 planning 往返。两者都不是无条件更好。

“做太多工作”比节点选择更根本

原始月报与日汇总都得到 32 行:

raw plan:
  Parallel Seq Scan on sales_fact
  240,000 facts contribute

summary plan:
  Seq Scan on daily_tenant_summary
  2,880 summaries contribute

即使原始扫描计划完全正确,它仍在重复计算已经稳定的历史粒度。若业务允许 分钟或日级新鲜度,汇总可能比继续微调原表扫描更有效。

同理,分布式计划若把 240,000 行传到协调端再聚合,远端每个 Seq Scan 都 可能是“正确计划”,整体数据流却仍不合理。

一张瓶颈—证据—动作表

怀疑 至少需要的证据 优先动作
CPU CPU 饱和、每查询 CPU、计划工作量 少做工作、审计并行与表达式
base I/O buffer/read、设备延迟、访问形状 索引、裁剪、缓存/存储
temp I/O sort/hash 方法、temp bytes 减少输入、局部内存与并发
blocker、wait event、事务年龄 缩短事务、调度/锁语义
计划 estimate/actual、统计、参数 统计、SQL、索引、版本基线
重复计算 相同历史范围反复聚合 汇总、缓存、批处理
远端传输 Remote SQL、返回行、网络 下推、局部聚合、分布键

17.1.3 单机未被正确使用前不急于分布式

“单机优先”是一条证据顺序

合理的升级阶梯:

1. 业务口径与 SQL 正确
2. 统计、索引、分区裁剪正确
3. 内存与并行在并发预算内
4. 重复分析有汇总/批处理
5. OLTP 与 OLAP 有资源隔离
6. 单节点纵向容量仍不足
7. 分布键与主要查询天然对齐
8. 团队能承担分布式运维
9. 才进入横向分布

这不是要求永远把单机压到 100%。生产需要安全余量、维护窗口和故障容忍。 “正确使用”是达到经过评审的安全上限,而不是让事故替你找到极限。

先拒绝伪瓶颈

一个值得写进 ADR 的反例:

症状:
  租户 3 的 4 月明细聚合慢

错误推断:
  表有 24 万行,因此需要分片

证据:
  合适 covering index 后只读 7,500 个索引项
  Index Only Scan
  Heap Fetches: 0

结论:
  当前问题是访问路径,不是节点容量

索引定义:

CREATE INDEX sales_fact_tenant_day_idx
ON shop_ch17.sales_fact (
  tenant_id,
  occurred_on,
  account_id
)
INCLUDE (amount, units, channel);

fixture 重建结束后显式:

VACUUM (ANALYZE) shop_ch17.sales_fact;

这是计划合同的一部分。刚装载的 heap 尚未有足够 all-visible 位时,PostgreSQL 可能选择 Bitmap Heap Scan;不能把之前一次 autovacuum 留下的状态当可重复 前置条件。

BRIN 是相关性工具,不是“更小的 B-tree”

本章还创建:

CREATE INDEX sales_fact_day_brin_idx
ON shop_ch17.sales_fact
USING brin (occurred_on)
WITH (pages_per_range = 16);

BRIN 对“列值与物理位置天然相关”的大表按 block range 保存摘要,索引很小, 但返回候选 page range 后仍需 recheck,是 lossy 路径。它适合追加顺序与时间 大体一致的巨大事实表,不适合替代每种选择性 B-tree。

冻结小表只验证目录中存在 date_minmax_ops,并观察 BRIN 比 covering B-tree 小;不宣称这个查询上 BRIN 更快。官方 BRIN Indexes 说明 block range、物理相关性、lossy recheck、pages_per_range 与 summarization 行为。

单机边界应是曲线,不是一个点

容量实验应逐级增加:

data scale
concurrency
query mix
ingest rate
background maintenance
cache state

记录:

throughput
P50/P95/P99
queueing
CPU
read/write IOPS and latency
temp bytes
WAL
checkpoint
vacuum debt
replica lag
error/timeout rate

理想结果是一组曲线:

低并发:延迟稳定,吞吐线性增长
接近饱和:排队上升,吞吐增幅变小
过载:延迟和错误率急升,吞吐可能下降

生产容量线应位于拐点之前,并包含节点故障、维护和增长余量。

什么时候单机证据足以支持“继续单机”

可以暂缓分布式,当:

  • 调优后 SLO 在峰值与故障演练下满足;
  • 未来容量预测仍位于安全余量内;
  • 物化/批处理的新鲜度合同可接受;
  • offline replica 能隔离读负载;
  • 主要风险是可通过纵向扩容或存储升级解决;
  • 业务需要大量跨实体事务与灵活 JOIN,分片会显著破坏局部性;
  • 团队尚未具备分片备份、恢复、再平衡与值班能力。

什么时候不能再用“继续调优”拖延

应正式进入分布式评审,当代表性证据显示:

  • 单节点 CPU、内存、存储容量或 I/O 已越过安全上限;
  • 维护、vacuum、备份或恢复无法在窗口内完成;
  • 即使隔离到副本,分析吞吐仍受单节点资源限制;
  • 业务故障域或地域要求不能由一个集群满足;
  • 主要访问天然按租户/实体局部化,跨分片比例可控;
  • 硬件纵向升级的边际成本和上限不再可接受;
  • 团队已经定义跨分片事务、部分失败、重平衡和退出流程。

本节的停止条件

在以下问题没有答案前,不进入“选哪个分布式产品”:

目标是什么?
当前瓶颈是哪一种资源?
哪条 SQL、哪个粒度、哪类并发造成?
单机优化后曲线在哪里拐弯?
未来多久越过安全容量?
哪些查询可以按一个分布键局部化?
哪些事务一定跨边界?
如果一个节点不可用,业务允许什么结果?

下一节先把 PostgreSQL 单节点内部可用的并行、索引、BRIN、分区、物化和 负载隔离工具讲透,再讨论真正的分布式门槛。


返回本章目录 · 下一节:单机分析能力 · 查看全书目录 · 查看索引中心

17.2 单机分析能力

PostgreSQL 的“单机”不是“单进程、单线程、每次从原表重算”。

在引入分布式之前,至少有五个正交杠杆:

减少访问的数据       -> 选择性索引、分区裁剪
并行处理必要的数据   -> parallel scan/join/aggregate
缩小每次处理的粒度   -> 物化汇总、批处理
利用物理相关性       -> BRIN、聚簇/装载顺序
隔离不同负载         -> 会话护栏、连接池、offline replica

每个杠杆解决不同问题。把它们都叫“性能优化”会丢失决策边界。

17.2.1 并行扫描、连接、聚合与限制

并行计划的基本结构

PostgreSQL 在计划树中使用 GatherGather Merge 汇集 worker 的结果:

leader
  Gather / Gather Merge
    worker 0 -> parallel-aware subtree
    worker 1 -> parallel-aware subtree
    ...

Gather 不保留 worker 输出顺序;Gather Merge 合并已经排序的并行流。 Gather 下面并非每个节点都自动并行。只有 parallel-aware 的 scan、join、 aggregate 等节点能让 workers 分担输入;普通节点可能在每个 worker 内分别 执行,也可能只在 leader 上执行。

PostgreSQL 官方 Parallel Query 把并行扫描、连接、聚合、append 与 parallel safety 分开说明。读计划时应 沿 plan tree 判断“谁分担数据、谁合并结果”,而不是只搜索一个 Gather

本章的并行聚合

local-parallel-plan.sql 为冻结查询设置 一个可重复的实验上下文:

SET max_parallel_workers_per_gather = 2;
SET min_parallel_table_scan_size = 0;
SET parallel_setup_cost = 0;
SET parallel_tuple_cost = 0;

EXPLAIN (
  ANALYZE,
  BUFFERS,
  COSTS OFF,
  SUMMARY OFF,
  TIMING OFF
)
SELECT
  tenant_id,
  date_trunc('month', occurred_on::timestamp)::date
    AS month_start,
  count(*) AS sale_count,
  sum(units)::bigint AS unit_count,
  sum(amount)::numeric(18,2) AS amount_total
FROM shop_ch17.sales_fact
GROUP BY tenant_id, month_start
ORDER BY tenant_id, month_start;

冻结计划:

Sort (actual rows=32 loops=1)
  -> Finalize HashAggregate (actual rows=32 loops=1)
       -> Gather (actual rows=96 loops=1)
            Workers Planned: 2
            Workers Launched: 2
            -> Partial HashAggregate (actual rows=32 loops=3)
                 -> Parallel Seq Scan on sales_fact
                      actual rows=80000 loops=3

读法:

  1. leader 与两个 worker 合计三个参与者;
  2. 每个参与者扫描约 80,000 行;
  3. 每个参与者产出 32 个 partial groups;
  4. Gather 收到约 96 行;
  5. finalize aggregate 合并成 32 行;
  6. 最后按租户和月份排序。

这比“三个人一起扫 24 万行”更精确:并行收益来自把大量输入压成少量 partial state,再让 leader 合并。若每个 worker 都输出海量行,leader 可能成为瓶颈。

partial/final aggregate 的条件

聚合要能并行拆分,必须有可合并的中间状态。概念上:

worker partial state
  count = 100
  sum   = 935.50

another worker partial state
  count = 120
  sum   = 1101.20

combine/final
  count = 220
  sum   = 2036.70

某些聚合、表达式、函数或语义无法安全拆分,就不会出现 partial/final aggregate。用户自定义函数默认不是 parallel safe;必须由作者基于真实行为 正确标记,不能为了得到并行计划而随意改 catalog。

计划能并行,不表示执行一定并行

计划显示:

Workers Planned: 2

执行证据还要看:

Workers Launched: 2

可用 worker 受多个上限和当前占用影响,例如:

max_worker_processes
max_parallel_workers
max_parallel_workers_per_gather
other sessions already using workers

如果执行时拿不到 worker,leader 可能独自执行 Gather 以下部分。因此容量 测试必须在代表性并发下观察 launched,而不是从单会话计划推断。

PostgreSQL 官方 When Can Parallel Query Be Used? 还列出写入、行锁、cursor、parallel-unsafe function、嵌套并行和 worker 资源不足等限制。

并行扫描

常见 parallel-aware 扫描包括:

Parallel Seq Scan
Parallel Index Scan
Parallel Index Only Scan
Parallel Bitmap Heap Scan

它们适合的访问形状不同:

  • 大范围低选择性读取常适合 parallel seq scan;
  • 有序 B-tree 与查询方向匹配时可并行 index scan;
  • visibility map 允许时 index-only 可减少 heap 访问;
  • bitmap 路径适合聚合多个索引命中后批量访问 heap page。

不能把 Parallel Seq Scan 当成“没用索引所以坏”。24 万行几乎全参与月聚合, 顺序读并行处理可能正是正确路径。判断标准是选择性、缓存、物理布局、并发与 总体资源,而不是节点名字的好恶。

并行连接

并行连接可能让:

outer side produced in parallel
inner side shared or rebuilt per worker
join work divided among workers

不同 join 算法的资源行为不同:

  • nested loop 的 inner scan 可能在每个 worker 重复;
  • merge join 的 inner side 可能被多次执行;
  • parallel hash 可以共享 hash table;
  • skew、错误基数和 worker 数会改变收益。

因此“两个大表 JOIN 能否并行”不能只看顶层 Gather。要看每个 input 的 actual rows/loops、hash memory/batches、排序与 buffer。

并行不是免费 CPU

一条查询从 8 秒降到 3 秒,可能消耗更多总 CPU。对单用户很有利,对 100 个 并发报表可能降低系统总吞吐。

容量要同时看:

single-query latency
system throughput
queue time
CPU saturation
workers requested/launched
OLTP tail latency

一个常见策略是:

interactive OLTP role:
  low statement timeout
  limited parallelism

batch analytics role:
  controlled concurrency
  selected higher parallelism
  explicit work_mem/temp limits

不要只提高全局 max_parallel_workers_per_gather

实验设置不是生产建议

本章把:

SET min_parallel_table_scan_size = 0;
SET parallel_setup_cost = 0;
SET parallel_tuple_cost = 0;

用于稳定地产生教学计划。它们刻意降低并行门槛,不是生产基线。生产应让 cost model 在真实数据、硬件和并发下选择,并通过回归计划验证。

17.2.2 分区、物化视图、增量汇总与批处理

四种手段解决四个问题

手段 主要减少什么 不自动解决什么
分区 无关分区扫描与维护范围 单节点总容量、所有查询
物化视图 重复计算 自动实时增量、定义演进
增量汇总表 每次重扫历史 迟到修正、幂等与对账
批处理 峰值并发与重复启动 单批本身的坏计划

它们可以组合,但不能互相替代。

分区首先是数据管理边界

原生分区适合:

按时间快速 detach/drop 历史
按边界独立装载或维护
让明确谓词裁剪无关分区
缩小部分索引和 vacuum 的工作单元

它不是把数据自动放到多台机器。PostgreSQL 原生 declarative partitioning 仍可完全位于一个实例、一个 tablespace 和一个故障域。

设计分区前回答:

主要删除/归档边界是什么?
查询是否稳定携带分区键?
分区数量与规划成本是否可控?
唯一约束能否包含分区键?
跨分区更新和 default partition 如何处理?
备份、vacuum、索引和 schema change 如何编排?

本章协调端把外表挂到 LIST 分区父表,是为了展示租户裁剪和路由,不是把 原生分区冒充成分布式引擎。

物化视图保存一个可重建结果

本章:

CREATE MATERIALIZED VIEW
  shop_ch17.daily_tenant_summary AS
SELECT
  tenant_id,
  occurred_on,
  channel,
  count(*) AS sale_count,
  sum(units)::bigint AS unit_count,
  sum(amount)::numeric(18,2) AS amount_total
FROM shop_ch17.sales_fact
GROUP BY tenant_id, occurred_on, channel
WITH DATA;

CREATE UNIQUE INDEX daily_tenant_summary_pkey
ON shop_ch17.daily_tenant_summary (
  tenant_id,
  occurred_on,
  channel
);

冻结数据得到:

240,000 raw facts
  -> 2,880 tenant/day/channel summaries
  -> 32 tenant/month rows

原表月报计划:

Parallel Seq Scan on sales_fact
actual rows=80000 loops=3

汇总月报计划:

Seq Scan on daily_tenant_summary
actual rows=2880 loops=1

两个输出逐字节相同。这个对比证明的是“缩小输入粒度”,不是物化视图对所有 查询都快。

PostgreSQL 原生 refresh 不是自动增量维护

普通物化视图需要:

REFRESH MATERIALIZED VIEW shop_ch17.daily_tenant_summary;

或在满足条件时:

REFRESH MATERIALIZED VIEW CONCURRENTLY
  shop_ch17.daily_tenant_summary;

核心 PostgreSQL 不会因为 base table 新增一行,就自动把对应增量加进这个 物化视图。CONCURRENTLY 解决读可用性的一部分,并不把刷新变成免费,也不 替你定义迟到事实、删除、修正和失败恢复。

发布合同应固定:

refresh owner
schedule and trigger
maximum freshness lag
unique index prerequisite
expected duration and WAL
lock behavior
failure alert
retry/idempotency
late-arrival window
full rebuild path
definition version
checksum/reconciliation

官方 Materialized Views 说明结果持久化、不可直接更新和 refresh 行为。

增量汇总表是一项应用协议

若完整 refresh 太贵,可以自己维护 summary table:

raw immutable events
  -> watermark / changed key set
  -> recompute affected tenant/day buckets
  -> upsert summary
  -> record batch identity and source watermark
  -> reconcile checksum

推荐按“重算受影响桶”而非“对旧值直接 +delta”开始,因为:

  • 迟到事件可能修改历史日期;
  • 事件可能撤销或更正;
  • 重试必须幂等;
  • 聚合逻辑会升级;
  • min/max/distinct 一类聚合不容易用简单减加回滚;
  • 需要从 raw truth 完整重建。

一张稳健的汇总控制表可以记录:

CREATE TABLE summary_batch (
  batch_id          uuid PRIMARY KEY,
  definition_version text NOT NULL,
  source_from       timestamptz NOT NULL,
  source_to         timestamptz NOT NULL,
  started_at        timestamptz NOT NULL,
  finished_at       timestamptz,
  status            text NOT NULL,
  source_checksum   text,
  result_checksum   text
);

这比“每五分钟跑一条 UPSERT”多了一层治理,但也使失败可恢复、结果可解释。

批处理是调度与资源控制

把 100 个 dashboard 请求合并为一个定时汇总,减少的是:

duplicate scans
query startup
concurrency spikes
cache churn
client retries

批处理仍需要:

  • 明确 batch 边界和 watermark;
  • 限制最大运行时间与并发;
  • 避免与 checkpoint、backup、vacuum 高峰重叠;
  • 在失败后从确定位置重跑;
  • 不用一个长事务覆盖整个历史;
  • 控制 WAL、temp 和副本 lag;
  • 给消费者暴露最后成功批次与数据新鲜度。

分区与汇总的组合

一个常见设计:

raw facts partitioned by event month
daily summaries keyed by tenant/day
monthly closed partitions become immutable
current/late window can be recomputed
old raw partitions retained or archived by policy

好处是:

  • 新鲜窗口小;
  • 历史汇总稳定;
  • 迟到修正有明确范围;
  • 全量重建可按分区推进;
  • 对账可以逐分区做。

风险是出现两套粒度与状态机。必须写清:

哪张表是最终事实?
汇总多久可旧?
历史是否允许更正?
定义升级如何双跑?
消费者如何选择版本?
raw 删除后是否仍能重建?

17.2.3 列式能力候选必须写入版本基线

“列式”不是一个单一功能

候选可能提供:

columnar storage
vectorized execution
compression
late materialization
parallel scan
external file scan
cache/format conversion
specialized aggregate

一项产品或扩展拥有其中一个,不表示拥有全部。也不能从“压缩率更高”推导 “点查、更新、复制和恢复都更好”。

先写 workload fit

列式路径通常更适合:

  • 只读或追加为主;
  • 扫描少数列、很多行;
  • 聚合和过滤占主导;
  • 批量装载;
  • 更新/删除少;
  • 可以接受特定事务和索引限制。

行存 PostgreSQL 通常在以下方面仍有优势:

  • 高选择性点查;
  • 频繁小事务更新;
  • 丰富 B-tree/GIN/GiST/SP-GiST 索引;
  • 完整约束、触发器与扩展组合;
  • 成熟复制、PITR 和工具链;
  • 单一数据副本与事务语义。

真实系统常混合两类负载,所以问题通常不是“行存还是列存”,而是:

哪些数据、哪些查询、在哪个新鲜度和事务边界下使用哪条路径?

版本是功能的一部分

一个可执行基线至少固定:

PostgreSQL major/minor
extension/product exact version
operating system and package source
storage format version
required shared_preload_libraries
GUC baseline
CPU architecture and instruction set
license
supported backup/restore path
supported upgrade path
replica behavior
known incompatibilities

不能写:

uses columnar extension

而应写:

candidate X exact version Y
on PostgreSQL 18.x
package repository Z
validated on every Pigsty L1 node
backup/restore drill identifier ...

本章正式实验没有安装列式扩展,因此 baseline-v1.5-proposal.json 明确只验证行存、BRIN、物化和 loopback FDW。没有运行的候选不会出现在 “已验证”清单里。

查询兼容之外的基线

列式候选还要验证:

类别 问题
DML insert/update/delete/upsert/truncate 支持到哪?
DDL alter type、default、constraint、partition 如何?
索引 哪些 access method、unique、FK 可用?
MVCC snapshot、vacuum、HOT、freeze 如何变化?
WAL/复制 physical/logical、PITR、standby 是否支持?
扩展 PostGIS、vector、FDW、UDF 能否组合?
备份 工具是否理解存储格式?
升级 大版本与扩展版本如何排序?
观测 size、I/O、bloat、query metrics 是否可见?
许可 部署、节点、商业使用与再分发条件?

“SQL 跑通”只覆盖第一行的一小部分。

基准必须包含负面工作负载

不要只跑候选擅长的宽表聚合。还要包含:

single-row lookup
selective range query
high-concurrency small reads
batch insert
small update/delete
schema evolution
vacuum/compaction
backup while serving
restore and checksum
replica catch-up
node or process restart

选型不是找一个最高分,而是确认它在必要场景上没有不可接受的零分。

17.2.4 OLTP 与分析负载在同机共存的代价

共存争用表

资源 OLTP 典型需求 OLAP 典型行为 冲突
CPU 短请求低尾延迟 长扫描/聚合吞吐 worker 抢核心
shared buffers 热索引与热点页 大范围扫描 缓存污染
OS page cache 热数据 顺序历史读 热页被挤出
memory 小且稳定 sort/hash 波动 OOM/回收
storage 小随机 I/O、WAL 大顺序/临时 I/O 队列延迟
locks/snapshot 短事务 长快照/refresh vacuum/DDL
connections 短会话/池 少量长查询 slot 与队列
replicas 低 lag replay 与只读查询 recovery conflict

同一 SQL 在夜间快、白天慢,不一定是计划变化;可能是共存资源不同。

缓存命中率不能单独判断

分析大扫描可能有很高 shared hit,因为数据已经在缓存;它仍会消耗 CPU 并 驱逐其他热页。也可能有较低命中但利用高吞吐顺序读,对自己的完成时间尚可, 却让 OLTP 随机读尾延迟变差。

需要把:

database buffers
OS I/O
query latency
system throughput
OLTP tail latency

放在同一时间轴。

会话级护栏

对分析角色可以评审:

ALTER ROLE analyst SET statement_timeout = '10min';
ALTER ROLE analyst SET lock_timeout = '2s';
ALTER ROLE analyst SET idle_in_transaction_session_timeout = '1min';
ALTER ROLE analyst SET temp_file_limit = '20GB';
ALTER ROLE analyst SET work_mem = '64MB';
ALTER ROLE analyst SET max_parallel_workers_per_gather = 2;

数值只是示意,必须按容量计算。角色设置也不是资源管理器:它不能严格保证 CPU 百分比或 IOPS,仍需要连接池并发、作业调度、操作系统资源或实例隔离。

连接池与任务队列

分析任务应有独立入口和并发上限:

application request
  -> analytics queue
  -> bounded worker pool
  -> analyst database role
  -> statement/temp/parallel limits

这样过载首先表现为可观测排队,而不是所有查询同时进入数据库后互相拖垮。 队列本身要有:

deadline
priority
cancellation
deduplication
retry policy
idempotency
queue age alert

副本隔离不是免费复制

把报表放到只读副本可以隔离部分 CPU 和读 I/O,但仍共享:

  • primary 产生 WAL 的成本;
  • 网络带宽;
  • replay lag;
  • 长查询与 recovery conflict;
  • schema/extension 版本;
  • failover 时的角色变化;
  • 备份和维护体系。

还必须接受“副本可能比 primary 旧”。如果查询要求 read-your-writes 或刚提交 即见,不能无条件路由到异步副本。

Pigsty 4.5 把 offline 实例用于慢查询、ETL、OLAP 和交互查询隔离,也允许 在现有 replica 上设置 pg_offline_query。其当前行为与服务归属见 Cluster / Instance。 这是比直接分片更低一层的候选。

单独分析集群

若副本上的物理复制语义仍不合适,可以建立:

OLTP source
  -> logical replication / CDC / batch load
  -> independent analytical PostgreSQL cluster

它进一步隔离参数、存储、索引和维护,却引入:

data pipeline
schema propagation
freshness lag
replay/idempotency
DDL compatibility
backfill
dual-system reconciliation

是否比 Citus 或专用 OLAP 更合适,要由工作负载和运行模型决定。

何时单机能力已经被合理用尽

至少满足:

  • 大查询的扫描、连接、聚合路径合理;
  • worker planned/launched 与并发预算相符;
  • 选择性查询有正确索引;
  • 分区裁剪能消除无关数据;
  • spill 被量化并有会话级边界;
  • 重复历史计算已评估物化/汇总;
  • OLTP 与分析已有入口和资源隔离;
  • backup、vacuum、checkpoint、replica lag 一同压测;
  • 硬件纵向扩容与未来增长已建模;
  • 正确性和新鲜度仍满足。

只有到这一步,“单节点哪一种资源仍越界”才有明确答案。下一节据此定义何时 需要分布式,以及分片键会把哪些数据库语义变成应用必须承担的合同。


上一节:先证明单机边界 · 返回本章目录 · 下一节:何时需要分布式 · 查看全书目录 · 查看索引中心

17.3 何时需要分布式

分布式系统的价值,是把某种不可再容纳于单一故障域的资源或责任拆开。

它的代价,是把原本由一个 PostgreSQL 实例隐式保证的事实,变成显式协议:

row lives where?
query runs where?
transaction spans where?
failure is partial or total?
backup represents which global point?
schema change reaches which nodes?
how is data rebalanced?
how do we leave?

因此,“需要分布式”的完整句子必须包含:

因为哪一种经过测量的边界,采用哪一种拆分单位,并接受哪些一致性、延迟、 可用性和运维代价。

17.3.1 容量、吞吐、地域与组织边界

容量边界

容量可以指:

heap + index + TOAST storage
working set memory
WAL generation and retention
backup repository and window
restore duration
vacuum/freeze maintenance window
index build / schema change window
replica catch-up

“磁盘还放得下”不是容量充足。如果一个 40TB 节点需要 30 小时才能从备份 恢复,而 RTO 是 2 小时,恢复窗口已经越界;如果冻结维护无法追上事务年龄, 也已经越界。

容量评审要有未来曲线:

[ \text{projected bytes}(t) = \text{current bytes}

  • \text{daily net growth} \times t
  • \text{index/WAL/maintenance headroom} ]

还要加入:

  • 增长误差区间;
  • 最大租户与平均租户差异;
  • 扩容交付时间;
  • 节点故障时的冗余;
  • 大版本升级期间的双份空间;
  • 重分片期间的额外副本。

只用平均增长会低估热点和迁移峰值。

吞吐边界

吞吐越界不是“单查询太慢”,而是调优后:

arrival rate > sustainable completion rate
queue age continuously grows
CPU/I/O at safe ceiling
tail latency and timeout rise
more concurrency no longer increases throughput

横向拆分只有在工作可并行且协调开销小于收益时有用。若所有请求都需要访问 全部分片,增加节点可能同时增加 fan-out、连接和合并成本。

应把工作负载分类:

查询形状 横向拆分潜力
单租户、单实体 高,若所有相关数据同分片
跨租户可分解聚合 中高,可做 partial/final
全局 top-N 需要各分片候选 + 全局合并
跨分片大 JOIN 低,可能需要 shuffle
全局唯一写入 需要协调或新语义
强事务跨多个实体 代价高

地域边界

地理分布可能为了:

latency
data residency
disaster recovery
network sovereignty
organizational autonomy

这些目标不能混为“多活”。例如:

  • 就近读缓存不等于可在多地写同一行;
  • 数据驻留可能禁止跨境复制;
  • DR standby 不是日常承载写入的 active-active;
  • 跨地域同步提交会把网络 RTT 加进事务延迟;
  • 异步复制会引入 RPO 和陈旧读。

先定义:

who may write where
which data may replicate where
conflict owner
failover authority
RPO/RTO per region
rejoin and divergence handling

第 26、27 章会深入复制、高可用与故障切换;本章只把地域作为进入选型的边界, 不提前把一个 loopback FDW PoC 说成多地域方案。

组织边界

有时拆分的主要原因不是硬件,而是责任:

  • 不同团队有独立发布节奏;
  • 法规要求独立访问控制和审计;
  • 某业务需要独立 SLO 与故障域;
  • 租户需要物理隔离;
  • 成本需要可归属;
  • 数据生命周期不同。

但数据库拆分不会自动解决组织问题。若跨域查询、事务和 schema 仍高度耦合, 拆库只会把内部调用变成网络调用。

评审组织拆分时画出:

data owner
schema owner
writer
reader
cross-domain transaction
cross-domain report
incident owner
backup/restore owner

没有唯一 owner 的共享表,是分布式后最容易成为争议中心的对象。

四种边界的硬证据

边界 不能只说 至少要提供
容量 数据很大 增长、工作集、维护/恢复窗口
吞吐 QPS 很高 饱和曲线、查询 mix、排队
地域 用户遍布全球 RTT、驻留、写入/冲突/RPO
组织 微服务化 ownership 与跨域依赖图

不是分布式门槛的信号

以下现象本身不足以证明:

  • 单条查询偶发慢;
  • 某次 CPU 100%;
  • 行数达到一个整齐数量级;
  • 云厂商有一项“分布式”产品;
  • 团队担心未来增长;
  • 某竞品使用很多节点;
  • 一个 demo 在笔记本上跑通;
  • “PostgreSQL 是单机数据库”。

它们可以触发测量,不能直接触发迁移。

17.3.2 分片键、数据局部性与跨分片事务

分片键决定数据库能否继续像数据库

好的分片键同时满足:

high enough cardinality
balanced data and load
stable over entity lifetime
present in major queries
present in joins and transactions
allows related rows to co-locate
supports operational moves

这些条件常冲突。tenant_id 局部性好,但一个超级租户可能形成热点; event_id 均匀,却把同一租户的查询撒到全部节点;时间范围方便归档,却可能 让“最近一天”的所有写入集中在一个分片。

从查询反推分片键

建立 query-to-key matrix:

查询/事务 频率 候选键 单分片? 跨分片代价
租户 dashboard tenant_id
租户账户与销售 JOIN tenant_id
全局月报 tenant_id partial/final
跨租户对账 tenant_id fan-out
账户迁移租户 极低 tenant_id 数据移动

不是从最大表里挑一个列名,而是看主要业务单元能否局部闭合。

本章的租户路由

冻结生成器把:

tenant_id % 2 = 0 -> pg36_shard_a
tenant_id % 2 = 1 -> pg36_shard_b

协调端使用显式 LIST 边界:

CREATE TABLE shop_ch17.sales_fact_distributed (...)
PARTITION BY LIST (tenant_id);

CREATE FOREIGN TABLE shop_ch17.sales_fact_dist_0
PARTITION OF shop_ch17.sales_fact_distributed
FOR VALUES IN (2, 4, 6, 8)
SERVER pg36_ch17_shard_a;

CREATE FOREIGN TABLE shop_ch17.sales_fact_dist_1
PARTITION OF shop_ch17.sales_fact_distributed
FOR VALUES IN (1, 3, 5, 7)
SERVER pg36_ch17_shard_b;

这让租户 3 谓词可被裁剪到 sales_fact_dist_1

关键反例:HASH remainder ≠ 整数取模

错误原型:

CREATE TABLE ... PARTITION BY HASH (tenant_id);

CREATE FOREIGN TABLE ... PARTITION OF ...
FOR VALUES WITH (MODULUS 2, REMAINDER 0);

直觉误读:

remainder 0 -> even tenant_id
remainder 1 -> odd tenant_id

实际不是。PostgreSQL HASH partitioning 对分区键调用内部哈希支持,再依据 组合哈希值选择 remainder。原值为 2,不保证进入 remainder 0。

危险路径:

remote loader places tenant 3 by 3 % 2 -> shard B
PostgreSQL hashes tenant 3 -> perhaps chooses another remainder
partition pruning trusts PostgreSQL partition bounds
only the chosen foreign partition is scanned
tenant 3 physically absent there
query returns zero or partial rows without transport error

这是“可用性正常、SQL 成功、结果错误”的最坏一类问题。

本章没有通过关闭 partition pruning 掩盖它,而是修正路由合同。生产分片系统 必须保证:

writer router
reader router
catalog metadata
rebalance tool
restore tool
application cache

使用同一个版本化映射。若更换 hash function、seed、token range 或 shard count,需要正式数据迁移,不能只改配置。

验证物理放置

不要只查父表总数。验证每个物理分片:

SELECT
  tableoid::regclass,
  count(*),
  min(tenant_id),
  max(tenant_id)
FROM shop_ch17.sales_fact_distributed
GROUP BY tableoid;

再检查:

SELECT *
FROM shop_ch17.sales_fact_dist_0
WHERE mod(tenant_id, 2) <> 0;

SELECT *
FROM shop_ch17.sales_fact_dist_1
WHERE mod(tenant_id, 2) <> 1;

冻结结果:

dist_0 = 120000 rows, tenants 2,4,6,8
dist_1 = 120000 rows, tenants 1,3,5,7

生产还应保存每个 shard 的 count、checksum、key range、路由 epoch 与采集时间。

数据局部性不止“在同一节点”

一个 JOIN 要局部执行,通常需要:

same distribution key
same key type and semantics
compatible shard mapping/colocation group
join predicate includes the key
query can be pushed by the target engine
functions/collations/types are compatible
statistics and cost favor pushdown
same user mapping where FDW requires it

本章账户与销售在同一远端库,查询也有 tenant_id = 3。但通过两个 partitioned foreign-table parents 查询时,实测:

Hash Join on coordinator
  Foreign Scan sales_fact_dist_1 -> 7500 rows
  Foreign Scan account_dim_dist_1 -> 50 rows

这证明“物理共置”不等于“当前抽象层与规划器已把 JOIN 下推”。官方 postgres_fdw 文档说明同一个 foreign server 上的外表 JOIN 可能 整体 发送到远端,但规划器仍可能判断分别取回更合适,其他限制也会阻止下推; 实际远端 SQL 要用 EXPLAIN VERBOSE 检查。参见 postgres_fdw Remote Query Optimization

跨分片查询的四种形状

  1. 单分片路由

    WHERE tenant_id = ?

    理想情况下只访问一组 colocated shards。

  2. scatter/gather

    每个分片执行相同查询,协调端合并。节点越多,fan-out 与尾延迟越显著。

  3. partial/final aggregation

    远端先聚合,协调端合并小结果。本章从 240,000 条事实缩到 960 条日汇总。

  4. repartition/shuffle

    按另一个 join/group key 跨网络重分布。功能强,但网络、磁盘和故障复杂度 高,通常是分片设计不局部的成本中心。

候选产品对四种形状的支持不同,不能只用单租户点查比较。

跨分片事务

单机事务隐含:

one WAL stream
one transaction manager
one commit decision
one snapshot domain

跨节点后要回答:

  • 是否支持原子 commit?
  • 协调端在什么时点记录决定?
  • 某个参与者 commit 后网络断开怎么办?
  • retry 会不会重复写?
  • prepared transaction 谁清理?
  • snapshot 是否跨节点一致?
  • deadlock 是否跨节点检测?
  • 一个节点长期不可用时业务阻塞还是降级?

postgres_fdw 会为本地事务打开相应远端事务,并映射 savepoint;PostgreSQL 18 官方文档明确说明,它目前不支持把远端事务 prepare 为 two-phase commit。 所以本章只做只读分析与权限拒绝,不用这个 PoC 宣称具备通用跨分片原子写。

全局约束

分片后重新评审:

PRIMARY KEY / UNIQUE
FOREIGN KEY
sequence / identity
exclusion constraint
serializable invariant
trigger
advisory lock

如果唯一键不包含分布键,可能需要:

  • 中央目录;
  • 分布式协调;
  • 业务生成全局唯一 ID;
  • 接受仅分片内唯一;
  • 改模型。

不能假设一个节点上的 local index 会检查其他节点。

热点与大租户

按 tenant 均匀 hash 只保证 key 空间近似均匀,不保证:

bytes per tenant
queries per tenant
writes per tenant
CPU per tenant
time-of-day peak

监测:

skew ratio=largest shard loadmean shard load \text{skew ratio} = \frac{\text{largest shard load}} {\text{mean shard load}}

并分别对 bytes、QPS、CPU、I/O 计算。一个超级租户可能需要独立 shard、 二级分片或专门迁移机制;这应在选型前验证,而不是上线后临时手工搬表。

17.3.3 一致性、运维复杂度与退出成本

分布式首先改变故障集合

单节点主要状态:

up / down / recovering

两分片加协调端至少有:

coordinator down
shard A down
shard B down
coordinator can reach A but not B
client can reach coordinator but coordinator DNS/TLS/auth fails to B
schema version differs
route catalog stale
one shard lagging
partial rebalance

每一种都要定义读写语义。

本章的部分失败探针

shard-failure.sql 在一个事务里暂时把 shard B server port 设为不可达,并断开旧连接:

BEGIN;

ALTER SERVER pg36_ch17_shard_b
  OPTIONS (SET port '1');

SELECT shop_ch17_ext.postgres_fdw_disconnect(
  'pg36_ch17_shard_b'
);

随后先查 tenant 2:

SELECT count(*)
FROM shop_ch17.sales_fact_distributed
WHERE tenant_id = 2;

冻结输出:

healthy_shard_tenant_2=30000

因为 LIST pruning 只访问 shard A。

再查全局:

SELECT count(*)
FROM shop_ch17.sales_fact_distributed;

需要两个 shard,固定以 SQLSTATE 08001 失败。psql 因错误断开后,事务回滚, 任务再次导出 server catalog,并要求与故障前逐字节一致。

这个实验定义了机制,不自动定义产品语义。应用必须决定:

请求 shard B 不可用时
tenant 2 明细 可继续,标注路由 epoch
tenant 3 明细 失败,不返回“空”
全局月报 失败或明确标为 partial
写 shard A 是否允许取决于全局不变量
写 shard B 失败/排队,重试必须幂等

最危险的是默默返回 partial result 却不标识。

一致性不只是隔离级别

分布式分析至少有:

snapshot consistency
replication freshness
route consistency
schema consistency
summary definition consistency
global completeness

例如两个分片分别在 10:00:01 与 10:00:05 读取,即使各自是 Repeatable Read, 合并结果也未必代表同一个业务时点。必须明确报告需要:

  • latest available;
  • bounded staleness;
  • consistent cut;
  • closed period;
  • 或允许 approximate/partial。

备份不是“每台都备一份”

全局恢复要回答:

which coordinator metadata version?
which route epoch?
which point in time on every shard?
are distributed transactions in doubt?
are reference data and sequences aligned?
how is restored placement verified?

各节点各有成功备份,不表示组合后是业务一致的恢复点。恢复演练必须从空环境 重建拓扑,恢复数据,验证路由、行数、checksum 和应用查询。

schema change 从一次 DDL 变成编排

要考虑:

  • coordinator 与 worker 执行顺序;
  • mixed-version window;
  • old/new application compatibility;
  • 某节点 DDL 失败后的补偿;
  • index build 资源;
  • backfill 与 WAL;
  • 回滚是否还可逆;
  • 路由和 schema epoch。

“支持 ALTER TABLE”不等于在十个节点、在线负载和失败条件下安全。

监控维度乘法

除了每个 PostgreSQL 节点原有指标,还要有:

per-shard bytes/QPS/CPU/IO
skew
cross-shard query ratio
fan-out
remote rows/bytes
coordinator queue
route failures
rebalance progress
schema version drift
partial result count
distributed transaction state

平均指标尤其危险:平均 shard CPU 40% 可能掩盖一个 100% 的热点 shard。

再平衡是一项在线数据迁移

增加节点不会让旧数据自动均匀且无代价地移动。再平衡需要预算:

source read
destination write
network
WAL and replication
cache coldness
lock/metadata change
double storage
failure and resume
verification

还要决定迁移期间路由如何读写,以及最后切换点。

退出成本在进入前计算

退出路线可能是:

distributed -> larger single PostgreSQL
distributed -> independent tenant clusters
distributed -> new sharding engine
OLAP system -> PostgreSQL summaries
FDW federation -> copied local tables

提前保存:

  • canonical schema;
  • raw data export;
  • distribution map;
  • globally stable IDs;
  • ordering/watermark;
  • row counts/checksums;
  • dual-read comparison;
  • last reversible step;
  • DNS/service rollback;
  • decommission proof。

若候选使用专有类型、SQL、存储或事务语义,退出成本要显式计价。

跨数据库退出不是一个本地事务

本章 reset 需要删除协调库与两个分片库中的受管对象。每个数据库内部:

verify exact state
BEGIN
drop exact objects without CASCADE
COMMIT

但三个数据库不能被一个本地 DDL 事务原子包住。任务采用:

pre-verify coordinator + A + B
reset coordinator
reset A
reset B
rebuild all
verify all

如果中途失败,依靠可重复 reset/setup 与证据进行补偿。这一小段实验已经显示 分布式运维复杂度:即使同一实例里的三个数据库,也需要跨库编排;真实多节点 只会增加网络、权限和故障状态。

进入分布式的最终清单

只有以下问题有可审计答案,才进入产品比较:

[ ] 哪一种单节点资源已在代表性负载下越界?
[ ] 纵向扩容、汇总和副本隔离为什么不足?
[ ] 分布单位是租户、实体、时间、schema 还是别的?
[ ] 最大分片和倾斜是多少?
[ ] 多少查询/事务可以单分片?
[ ] 跨分片查询如何聚合或 shuffle?
[ ] 全局唯一、FK 和事务不变量如何变化?
[ ] 单节点、网络、协调端失败时分别返回什么?
[ ] 备份能否恢复成全局一致且路由正确的系统?
[ ] schema change、再平衡和升级是否演练?
[ ] 团队值班与工具是否能承担?
[ ] 如何迁入、如何双读验证、如何退出?

下一节在这些问题的约束下比较候选,而不是用一个“SQL 兼容”标签把不同系统 压成同一类。


上一节:单机分析能力 · 返回本章目录 · 下一节:比较分布式候选 · 查看全书目录 · 查看索引中心

17.4 比较分布式候选

候选比较的第一步,是保留“不分布”作为对照组。

如果表格只有三种分布式产品,团队会被迫在它们之间选一个;如果加入:

optimized primary
offline replica
independent analytical PostgreSQL
materialized summary

很多问题会暴露为负载隔离或数据粒度问题,而不是横向分片问题。

本节不替读者宣布一个普遍赢家,而是建立同口径的比较方法。

17.4.1 PostgreSQL 扩展、兼容数据库与专用分析系统

候选地图

可以按“离 PostgreSQL 原生语义有多远”分层:

Layer 0: same PostgreSQL instance
  indexes / partitions / parallel query / summaries

Layer 1: same physical PostgreSQL data
  read replica / offline replica

Layer 2: another PostgreSQL data copy
  logical replication / CDC / ETL to analytical PostgreSQL

Layer 3: PostgreSQL extension or federation
  postgres_fdw / Citus / workload-specific extensions

Layer 4: PostgreSQL-wire or SQL-compatible distributed database
  separate storage, transaction and operations implementation

Layer 5: specialized analytical system
  columnar/vectorized engine, separate ingestion and lifecycle

层数越高不表示越先进,只表示需要重新验证的语义越多。

对照组:优化后的 PostgreSQL

它应包含:

  • 正确 schema 与统计信息;
  • 代表性索引和 partition pruning;
  • 并行计划与并发预算;
  • 合理物化/汇总;
  • workload guardrails;
  • 当前硬件与一个可行纵向规格;
  • offline replica 或独立分析副本候选。

若分布式候选只比未经调优的原表扫描快,比较没有意义。

postgres_fdw:联邦访问与机制实验

postgres_fdw 把远端 PostgreSQL 表映射为 foreign table:

CREATE EXTENSION
  -> CREATE SERVER
  -> CREATE USER MAPPING
  -> CREATE FOREIGN TABLE / IMPORT FOREIGN SCHEMA
  -> SELECT / DML

它能做:

  • 远端过滤和列裁剪;
  • 某些 JOIN/聚合下推;
  • 外表分区;
  • 联邦读取;
  • 迁移期间的过渡;
  • 把远端数据物化到本地;
  • 显示远端 SQL 和数据流。

它不应被默认理解为一个完整的透明分布式数据库。路由、rebalance、全局 约束、协调端 HA、全局备份点和很多运维职责仍需自行设计。PostgreSQL 18 的 postgres_fdw 远端事务也不支持 prepare 为两阶段提交。

本章用它是因为机制透明:

Foreign Scan
Remote SQL
actual rows
user mapping
server failure

都能直接观察。它是很好的教学镜子,不是本章对生产选型的默认推荐。

Citus:PostgreSQL 扩展式分片候选

Citus 把表区分为 distributed、reference、local 等类型,以分布列决定行的 放置;相关表按相同分布键 colocate 后,单租户查询和某些 JOIN 可以在一组 共置分片上执行。跨租户聚合则可由 worker 产生 partial result,再由协调端 合并。

这使它适合评估:

multi-tenant workload with tenant-local transactions
real-time aggregate workload with decomposable computation
PostgreSQL ecosystem continuity

但分布键选择成为 schema 与查询合同。Citus 官方 Choosing Distribution Column 强调 tenant/entity key、co-location 与跨节点数据移动之间的关系;小型共享 维表可评估 reference table。

需要验证:

  • row-based 还是 schema-based sharding;
  • distribution column 是否出现在 PK/FK/查询;
  • colocated tables 与 reference tables;
  • 单租户与全局查询比例;
  • rebalance 与大租户隔离;
  • coordinator/worker HA;
  • distributed DDL;
  • backup/restore;
  • 版本升级和扩展组合;
  • Pigsty L1 的实际拓扑。

本章 loopback FDW 的“同分片 JOIN 未下推”不能直接外推为 Citus 行为;它只 提醒读者对目标产品的目标 SQL 看实际计划。

PostgreSQL-compatible distributed database

这类系统可能支持 PostgreSQL wire protocol、部分 SQL、驱动和工具,让迁移 起步更容易。必须拆开“兼容”:

wire protocol
parser syntax
catalog shape
data types
functions/operators
transaction semantics
isolation/locking
extensions
backup/restore
monitoring
operational commands

一个应用只用简单 SELECT/INSERT,兼容度可能足够;一个应用依赖 PostGIS、 自定义 operator class、logical decoding、advisory lock、trigger、COPY、 RLS 和精细 catalog 查询,迁移面完全不同。

不要用厂商兼容百分比替代自己的 feature inventory。

专用分析系统

专用 OLAP 系统通常优化:

columnar compression
large scans
vectorized execution
distributed aggregation
high analytical concurrency
object storage / tiering

它可能显著优于 PostgreSQL 处理某类宽表聚合,但会引入第二套:

ingestion / CDC
schema mapping
data freshness
deduplication
late-event handling
access control
backup/recovery
monitoring/on-call
cost model
query semantics

如果 OLTP truth 仍在 PostgreSQL,必须定义两个系统不一致时谁是事实来源。

候选能力矩阵

以下不是产品评分,而是评审问题:

维度 单机/副本 FDW Citus 类扩展 兼容分布式库 专用 OLAP
原生 PG 语义 最高 本地/远端 PG 高但分片有边界 必须实测 通常较低
单租户局部性 原生 手工路由 分布键核心 依实现 依模型
全局聚合 单节点 可下推/合并 分布式 partial/final 依实现 核心场景
跨分片事务 不适用 有明显限制 需按版本/形状验证 需验证 通常非 OLTP 重点
扩展生态 原生 两端一致性 需验证组合 通常有限 不适用/自有
运维体系 已有 多 PG + 编排 coordinator/workers 新体系 第二套体系
新鲜度 即时/复制 lag 远端即时视连接 即时视事务 依实现 CDC/批次 lag
退出成本 中高

矩阵中的每个“需验证”都要变成 PoC 用例。

所有权边界

候选不仅有技术 owner:

责任 必须有人承担
schema 与分布键 数据模型 owner
query migration 应用 owner
ingestion/CDC 数据平台 owner
cluster lifecycle DBA/SRE
correctness reconciliation 业务 owner + 数据 owner
incident decision on-call
cost 预算 owner
exit 项目 sponsor

缺少 owner 的候选不能因为跑分快进入生产。

17.4.2 SQL 兼容不等于事务、扩展和运维兼容

建立兼容性分层

推荐至少分八层:

L1 protocol
L2 syntax
L3 type and expression semantics
L4 transaction and concurrency
L5 schema objects and extensions
L6 planner/performance behavior
L7 operations and observability
L8 failure/recovery and lifecycle

只有 L1/L2 通过,应用仍可能在 L3–L8 失败。

协议兼容

验证:

  • TLS、SCRAM、GSS/SSO;
  • connection parameters;
  • prepared statements;
  • binary/text format;
  • COPY;
  • cancellation;
  • notices/errors 与 SQLSTATE;
  • connection pool transaction/session mode;
  • driver features;
  • failover reconnect。

能用 psql 登录只是起点。

SQL 与类型语义

测试应用真实使用的:

numeric precision and rounding
timestamp/time zone
collation and locale
NULL ordering
JSON/JSONB
arrays/ranges/multiranges
generated columns
identity/sequence
UPSERT/MERGE/RETURNING
CTE/window/lateral
recursive query

同样语法若类型、collation 或时区不同,会返回不同结果。

postgres_fdw 官方文档也建议 foreign table 的类型和 collation 与远端精确 匹配,否则本地与远端对条件的解释可能不同。它还不会自动导入除 NOT NULL 之外的约束,因为错误约束可能导致规划器做出不安全推断。

事务与并发语义

需要独立验证:

READ COMMITTED snapshot
REPEATABLE READ
SERIALIZABLE
row/table/advisory locks
deadlock detection
savepoints
DDL transactionality
cross-shard transaction
retry error classes
sequence behavior

例如“支持 serializable”不够,要验证:

  • 冲突时 SQLSTATE;
  • 是否需要 client retry;
  • 多分片是否同样保证;
  • range/predicate conflict 如何实现;
  • failover 后 in-flight transaction 的结果;
  • unknown commit 如何对账。

schema 与扩展兼容

列清单:

SELECT
  extname,
  extversion
FROM pg_catalog.pg_extension
ORDER BY extname;

对每个扩展检查:

  • 是否可安装;
  • exact version;
  • trusted/non-trusted;
  • shared preload;
  • 类型、函数、operator、index AM;
  • logical/physical replication;
  • backup/restore;
  • rolling upgrade;
  • 每个节点一致性。

不能把“兼容 PostgreSQL”理解为兼容任意 PostgreSQL 扩展。

catalog 兼容

许多工具读取:

pg_catalog
information_schema
pg_stat_*
pg_locks
pg_settings
pg_extension
pg_class/pg_attribute/pg_index

候选可能接受这些查询但字段为空、语义不同或只反映 coordinator。验证:

  • migration tool;
  • ORM introspection;
  • monitoring exporter;
  • backup tool;
  • schema diff;
  • incident runbook;
  • 自定义运维脚本。

planner 兼容

相同 SQL 的计划可以完全不同。需要观察:

where execution happens
which shards are pruned
which filters/joins/aggregates push down
how many rows cross network
how coordinator merges
what spills
what happens under skew

本章同一个 FDW 月报有两种 SQL:

parent aggregate:
  fetch 240,000 facts
  aggregate locally

explicit per-shard daily aggregate:
  fetch 480 + 480 aggregate rows
  aggregate monthly locally

语义相同,执行位置不同。兼容性测试不能只检查最终 rows。

运维兼容

对比日常动作:

动作 问题
provision 声明、包、密钥、节点身份如何?
scale 加节点是否自动,旧数据如何移动?
backup 一致点、加密、保留、校验如何?
restore 空环境恢复、route metadata 如何?
failover coordinator/worker 谁仲裁?
upgrade rolling、停机、扩展顺序?
schema change fan-out、失败补偿?
observability 全局与每 shard 指标?
security HBA、证书、user mapping、secret?
decommission 数据擦除与证明?

工具名字相同也不表示语义相同。例如在 coordinator 执行 VACUUM 是否覆盖 所有 shard,要由目标产品和版本证明。

错误兼容

应用通常围绕 SQLSTATE 决定:

retry
abort
return conflict
mark dependency unavailable

候选必须保留或重新映射这些错误语义。测试:

  • unique violation;
  • serialization failure;
  • deadlock;
  • lock timeout;
  • statement timeout;
  • connection failure;
  • read-only transaction;
  • insufficient privilege;
  • disk/full or quota;
  • shard unavailable。

只测试成功路径会让第一场故障变成兼容性测试。

精度与顺序

分析结果的隐性差异:

floating aggregate order
approximate distinct
collation sort
NULL order
time zone database version
decimal scale
non-deterministic top-N ties

冻结输出应:

  • 使用 exact numeric 或定义误差;
  • 明确 ORDER BY 与 tie-breaker;
  • 固定时区/locale;
  • 记录 approximate 算法/version/seed;
  • 对 checksum 使用稳定序列化。

17.4.3 用同一工作负载和失败条件比较

先冻结语义

比较协议应先固定:

schema
data generator / snapshot
business queries
expected results
freshness point
concurrency schedule
failure schedule
versions/config
measurement method

本章:

fixture = ch17-analytics-v1
frozen_at = 2026-07-29T00:00:00Z
monthly rows = 32
monthly checksum = 644d45544ebbc2a80c42270c38ac6885

任何候选先生成同一月报,再谈性能。

workload suite

至少包含:

  1. 高选择性单租户读

    WHERE tenant_id = 3
      AND occurred_on >= DATE '2026-04-01'

    验证 pruning、index、route、read amplification。

  2. 全局可分解聚合

    count/sum 按租户和月份,验证 partial/final 与传输。

  3. 同分片 JOIN

    account + sales,验证真实 join pushdown。

  4. 非分布键 JOIN

    故意触发 shuffle 或拒绝,量化代价。

  5. 小写入与幂等重试

    验证事务、unique、retry。

  6. 批量装载

    验证 ingest、WAL/replication、rebalance。

  7. schema change

    增列、建索引、backfill,验证 mixed-version。

  8. backup/restore

    从空环境恢复并跑 checksum。

数据规模阶梯

不要只跑一个尺寸:

S: fits in memory
M: working set near memory
L: exceeds memory
XL: near storage/maintenance target

观察曲线和拐点,而不是挑一张最好看的柱状图。

冷暖缓存

至少区分:

warm repeated query
cold/evicted data
after restart
after rebalance
after restore
after schema/index build

专用分析系统和 PostgreSQL 对缓存、编译、数据格式转换的预热不同。只比较 第十次执行或只比较第一次执行都可能偏颇。

并发与到达模型

开放环和封闭环会得出不同结论:

closed-loop:
  client waits for response, then sends next
  overloaded system self-throttles

open-loop:
  requests arrive by schedule independent of completion
  queueing and overload become visible

生产若有固定到达率,基准不能只用少量客户端闭环。还要记录 client queue 与 server queue,避免 coordinated omission。

资源和成本同报

每个结果同时报告:

latency distribution
throughput
error rate
CPU seconds
memory peak
storage read/write
temp/spill
network bytes
WAL/replication
storage footprint
node count
operator time
license/cloud cost

“P95 快 2 倍但使用 8 倍节点”与“同成本快 2 倍”不是同一结论。

失败矩阵

对每个候选执行:

故障 验证
query cancel 远端工作是否停止、资源是否释放
worker/shard down 单分片与全局查询如何返回
coordinator down 新连接、已有事务、恢复
network partition timeout、unknown commit、重试
disk pressure backpressure 与告警
replica lag freshness 标识与路由
rebalance interrupted resume、重复/遗漏
schema node drift 拒绝、修复与可见性
backup during load 恢复一致性

失败结果必须是验收的一部分,不是“以后做 chaos”。

本章的最小失败条件

冻结 PoC 至少证明:

application write -> SQLSTATE 42501
shard B unreachable -> global query SQLSTATE 08001
shard B unreachable -> tenant 2 scoped read = 30000
server catalog after rollback = before failure

它没有证明:

  • 真实网络分区;
  • process kill;
  • WAL/replica behavior;
  • coordinator HA;
  • shard failover;
  • in-flight distributed write;
  • rebalance resume。

这些是生产 PoC 的追加用例。

benchmark result template

每条结论写成:

claim:
  two-stage aggregation reduces coordinator input

environment:
  PostgreSQL 18.6, postgres_fdw 1.2
  same-host/same-instance loopback

input:
  ch17-analytics-v1, 240,000 facts

evidence:
  naive Append actual rows=240000
  two-stage Append actual rows=960
  both byte-identical to frozen monthly CSV

scope:
  row-transfer shape only

not proven:
  network bytes, latency, throughput, HA, scaling

这个模板迫使作者把结论和外推边界放在一起。

评分前设置 veto

某些条件不应靠加权平均掩盖:

incorrect result
cannot meet RPO/RTO
unsupported mandatory extension
unacceptable data residency
no recoverable backup
license conflict
no exit path
unknown-commit without business reconciliation

任何 veto 失败,候选退出;不能用“查询快 30%”抵消。

决策表

通过 veto 后再评分:

维度 权重 证据 分数 不确定性
correctness veto golden/checksum pass low
workload SLO 25 representative replay
failure/RPO/RTO 20 drills
operability 15 day-2 tasks
compatibility 15 feature inventory
cost 10 same horizon/TCO
migration 10 rehearsal
exit 5 reverse rehearsal

“不确定性”单列,避免没有验证的候选因为乐观估分胜出。

本节结论

比较方法的输出不是产品排行榜,而是:

baseline
candidate contract
evidence bundle
known limitations
veto results
cost/ownership
migration and exit
decision trigger

下一节构造一个最小 FDW PoC,目的不是给候选打性能分,而是验证“租户路由、 远端聚合、权限和部分失败能否被证据化”这一个关键假设集合。


上一节:何时需要分布式 · 返回本章目录 · 下一节:部署最小分布式 PoC · 查看全书目录 · 查看索引中心

17.5 部署最小分布式 PoC

一个好的 PoC 不是“把所有组件装一遍”,而是用最小拓扑证伪一个关键假设。

本章假设是:

tenant_id 为数据局部性边界时,协调端能裁剪到单分片;全局可分解聚合 能把部分计算推到数据侧;同时,未下推 JOIN、权限和单分片故障可以被明确 观察,而不是被 demo 隐藏。

为了让 SQL、catalog 和计划完全透明,本地 PoC 使用 postgres_fdw。它不是 对 Citus 性能或生产可用性的替代测试。

17.5.1 明确 PoC 只验证一个关键假设

拓扑

one PostgreSQL 18.6 instance
one Unix-domain socket
one process/storage/failure domain

pg36_shop                 coordinator database
  shop_ch17               local facts + partitioned foreign parents
  shop_ch17_ext           postgres_fdw 1.2
  pg36_ch17_shard_a       foreign server
  pg36_ch17_shard_b       foreign server

pg36_shard_a              retained database shell
  shop_ch17_shard         tenants 2,4,6,8

pg36_shard_b              retained database shell
  shop_ch17_shard         tenants 1,3,5,7

三个数据库共享一个实例。因此它能证明:

database boundary
foreign server/user mapping
partition routing/pruning
remote SQL
row transfer shape
remote failure SQLSTATE
cross-database reset orchestration

不能证明:

network latency/bandwidth
independent CPU/storage
multi-node throughput
replication/HA
node placement
rolling upgrade
rebalance
distributed backup

PoC 的验收问题

只回答十个问题:

  1. 三份数据库数据能否由同一确定生成器重建?
  2. 本地、summary、naive FDW、two-stage FDW 是否输出同一 frozen result?
  3. 单租户谓词是否裁剪到正确物理 shard?
  4. 租户和日期过滤是否出现在 Remote SQL?
  5. 朴素全局聚合向 coordinator 返回多少行?
  6. 远端预聚合后返回多少行?
  7. 同物理分片 JOIN 是否真的被下推?
  8. application role 是否只能读且使用具名 mapping?
  9. 一个 shard 不可达时,健康 shard 的 scoped read 与全局 read 分别怎样?
  10. 受管对象能否精确退出并完整重建?

没有延迟、QPS 或扩展倍数问题,因为这个拓扑没有资格回答。

冻结数据生成

协调端和远端分别使用:

共同公式:

tenant_id       = 1..8
account_id      = 1..50
day_offset      = 0..119
sale_per_day    = 1..5
sale_id         = deterministic integer composition
channel         = deterministic cycle
units/amount    = deterministic expressions

没有随机数、当前时间或外部数据。fixture_meta 固定:

fixture_version = ch17-analytics-v1
generator_identity = fixture-generator-v1
first_day = 2026-01-01
frozen_at = 2026-07-29T00:00:00Z

两端 generator 的 SHA-256 写入 fixture-manifest.json。生成器变化意味着 fixture 身份变化,不能仍用旧 golden。

数据库壳与 schema 分开

bootstrap.sql 只创建并保留两个数据库壳:

CREATE DATABASE pg36_shard_a
  WITH OWNER pg36_owner TEMPLATE template0 ENCODING 'UTF8';

CREATE DATABASE pg36_shard_b
  WITH OWNER pg36_owner TEMPLATE template0 ENCODING 'UTF8';

并固定 database comment:

pg36 ch17 fdw shard database a; retained shell
pg36 ch17 fdw shard database b; retained shell

已有同名数据库只有在 owner、comment、template/connection 身份精确匹配时才 可复用;否则停止碰撞。reset 删除内部 shop_ch17_shard,不 drop database。

这样做的理由:

  • DROP DATABASE 破坏性更大;
  • database DDL 不能在普通事务里执行;
  • 固定壳能把重复实验聚焦于 schema/data;
  • reset 输出明确说明 database_shell=retained

远端分片

每个 shard:

CREATE SCHEMA shop_ch17_shard AUTHORIZATION pg36_owner;

CREATE TABLE shop_ch17_shard.account_dim (...);
CREATE TABLE shop_ch17_shard.sales_fact (...);

分片 A 约束:

CHECK (mod(tenant_id, 2) = 0)

分片 B:

CHECK (mod(tenant_id, 2) = 1)

每个分片固定:

4 tenants
200 accounts
120,000 sales
2026-01-01 .. 2026-04-30

远端校验和不同,因为 tenant set 不同:

shard A = 274002669404fbcd449bdecd929624e3
shard B = 0bb770361058ec76ebc81a2a7d1e2629

协调端本地基线

协调端同时生成完整本地表:

CREATE TABLE shop_ch17.sales_fact (...)
WITH (parallel_workers = 2);

CREATE INDEX sales_fact_tenant_day_idx
ON shop_ch17.sales_fact (
  tenant_id,
  occurred_on,
  account_id
)
INCLUDE (amount, units, channel);

CREATE INDEX sales_fact_day_brin_idx
ON shop_ch17.sales_fact
USING brin (occurred_on)
WITH (pages_per_range = 16);

并创建日汇总 materialized view。于是同一个 PoC 内存在单机对照组,不会拿 分布式结果和一个不存在的 baseline 比较。

外表父表

协调端:

CREATE TABLE shop_ch17.sales_fact_distributed (...)
PARTITION BY LIST (tenant_id);

CREATE FOREIGN TABLE shop_ch17.sales_fact_dist_0
PARTITION OF shop_ch17.sales_fact_distributed
FOR VALUES IN (2, 4, 6, 8)
SERVER pg36_ch17_shard_a
OPTIONS (
  schema_name 'shop_ch17_shard',
  table_name 'sales_fact'
);

CREATE FOREIGN TABLE shop_ch17.sales_fact_dist_1
PARTITION OF shop_ch17.sales_fact_distributed
FOR VALUES IN (1, 3, 5, 7)
SERVER pg36_ch17_shard_b
OPTIONS (
  schema_name 'shop_ch17_shard',
  table_name 'sales_fact'
);

账户维表使用完全相同的 LIST 边界。这让“物理共置但 JOIN 是否下推”成为可测 问题。

为什么不用 HASH 分区

早期 PoC 曾写:

PARTITION BY HASH (tenant_id)
FOR VALUES WITH (MODULUS 2, REMAINDER 0)

而远端 generator 使用 mod(tenant_id, 2)。这造成路由算法不一致。冻结版本 改用 LIST,不是因为 LIST 普遍优于 HASH,而是为了让这个八租户教学 fixture 的物理映射无歧义。

生产 PoC 应使用目标系统真实的 shard function 和 metadata,并加入:

route(key) expected shard
physical rows comply
pruned query returns golden
rebalance changes epoch atomically
old router cannot write after cutover

PoC 应主动寻找反例

本章没有把目的写成“证明 FDW 很快”,而是:

prove pushdown where it happens
prove non-pushdown where it does not

这比只展示成功计划更能检验选型假设。若一个 PoC 从不失败,通常说明验收条件 太宽或只选择了产品最擅长的路径。

17.5.2 记录组件、版本、拓扑和数据分布

版本 manifest

正式证据写入:

validation_path=direct-postgresql-loopback-fdw
server_version=18.6 ...
postgres_fdw=1.2
database=pg36_shop
shard_databases=pg36_shard_a,pg36_shard_b
distribution=explicit-list-by-tenant
pigsty_reference=4.4
pigsty_l1=not-run

此外为实验目录中每个 source file 计算 SHA-256。

为什么正式 fixture 限制 PostgreSQL 18.x:

  • postgres_fdw 行为和功能随版本变化;
  • 本章固定 extension 版本 1.2;
  • SCRAM passthrough、connection inspection 等版本功能不能模糊外推;
  • 计划文本是 18.6 的证据。

概念适用于更多版本,但复制计划前应在目标 major 重新采集。

foreign server

setup.sql 动态读取本地实例:

unix_socket_directories
port

再创建:

CREATE SERVER pg36_ch17_shard_a
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (
  host '<lab socket>',
  port '<lab port>',
  dbname 'pg36_shard_a',
  fetch_size '10000'
);

fetch_size=10000 是 fixture 基线,不是生产最优值。官方 postgres_fdw 文档说明它控制每次 fetch 取得的行数,server 级设置可被 table 级覆盖。真实 网络要在延迟、内存和结果宽度下测量。

身份映射

每个 server 有三个具名 mapping:

local postgres   -> remote postgres
local pg36_owner -> remote postgres
local pg36_app   -> remote pg36_app

两 server 共六个,没有 PUBLIC mapping。

本地隔离实验使用:

OPTIONS (
  user 'pg36_app',
  password_required 'false'
)

安全边界

password_required=false 只能由 superuser 设置,会允许映射用户利用 PostgreSQL 操作系统账户可获得的认证材料或 trust/peer 关系。官方文档明确 警告不要对 PUBLIC 设置,并要求防止映射用户借机连接成远端 superuser。 本章只在同一实例、Unix-domain socket、受控开发数据库中使用。生产不得 照抄。

PostgreSQL 18 还提供 use_scram_passthrough 选项,但它有严格条件:远端必须 请求 SCRAM,相关节点需有相同 SCRAM secret,传入会话也必须以 SCRAM 认证。 生产应在目标版本中比较:

SCRAM credentials in reviewed secret lifecycle
SCRAM pass-through
GSS delegated credentials
approved certificate/service mechanism

身份与要求见官方 postgres_fdw Connection Options

应用权限

pg36_app 只获得:

USAGE on shop_ch17
SELECT on local, summary, distributed relations/views
USAGE on two foreign servers
remote SELECT on account/sales

不获得 insert/update/delete。负例:

INSERT INTO shop_ch17.sales_fact (...)
VALUES (...);

固定失败:

SQLSTATE 42501

成功读:

SELECT
  count(*) AS sale_count,
  sum(amount)::numeric(18,2) AS amount_total
FROM shop_ch17.sales_fact_distributed
WHERE tenant_id = 3
  AND occurred_on >= DATE '2026-04-01';

输出:

7500,69375.00

catalog 证据

自动采集:

2 foreign servers
6 named user mappings
18 coordinator relations
6 local indexes
7 application privilege facts
6 size facts

关键对象清单:

4 foreign partitions
2 partitioned parents
3 local tables
1 materialized view
2 views
6 indexes

extension schema shop_ch17_ext 不含关系,只有 postgres_fdw 的五个 extension member routines;所有非 extension object 都禁止混入。

Pigsty 路径 A:offline analytics

Pigsty 是 configuration-driven 平台。当前 4.4 文档把集群定义放在:

all.children.<cluster>.hosts

并以 pg_clusterpg_rolepg_seq 等 identity 参数定义实例。一个分析 隔离草图:

all:
  children:
    pg-analytics:
      hosts:
        10.10.10.11:
          pg_seq: 1
          pg_role: primary
        10.10.10.12:
          pg_seq: 2
          pg_role: replica
          pg_offline_query: true
      vars:
        pg_cluster: pg-analytics
        pg_conf: olap.yml

这条路径的假设是:

analysis can tolerate replica lag and read-only semantics
single-node read capacity is sufficient
resource isolation solves primary interference

验收仍需:

  • replica lag 与 freshness;
  • long query/recovery conflict;
  • offline 服务路由;
  • HBA 与只读 role;
  • failover 后标签/服务行为;
  • CPU/I/O 隔离;
  • backup 与升级。

Pigsty Configuration 给出同类 pg-analytics 示例;其 Cluster / Instance 说明 offline instance 与 pg_offline_query 的职责。

Pigsty 路径 B:Citus 评估拓扑

当分片门槛满足后,Pigsty 4.5 可声明 Citus。当前文档要求:

pg_mode: citus
pg_shard: shared horizontal shard name
pg_group: shard cluster number
pg_primary_db: managed Citus database
extra HBA for local/data-node access

简化草图:

all:
  children:
    pg-citus0:
      hosts:
        10.10.20.10: { pg_seq: 1, pg_role: primary }
      vars:
        pg_cluster: pg-citus0
        pg_mode: citus
        pg_shard: pg-citus
        pg_group: 0
    pg-citus1:
      hosts:
        10.10.20.11: { pg_seq: 1, pg_role: primary }
      vars:
        pg_cluster: pg-citus1
        pg_mode: citus
        pg_shard: pg-citus
        pg_group: 1

完整 inventory 还要有 pg_primary_db、database/extension、HBA、凭据以及 全局变量。本书资产为了避免硬编码生产秘密,只保留拓扑骨架。

L1 验收至少覆盖:

package/version on every node
pg_mode/shard/group identity
coordinator/worker metadata
distributed/reference/local tables
single-tenant route and colocated joins
cross-tenant aggregate
coordinator and worker HA
backup/restore
rebalance
rolling/major upgrade
monitoring and alert
security and service routing

本地输出写 pigsty_l1=not-run,所以不能把上述 YAML 称为已部署。

声明不等于状态

Pigsty inventory 是 desired state 的重要来源,但发布证据还要从运行态回读:

inventory commit
rendered config
installed package
pg_extension
pg_settings
Patroni membership
service endpoints
HBA effective rules
shard metadata
backup status
monitoring targets

否则可能出现“YAML 正确,节点尚未收敛”。

17.5.3 不把演示集群的绝对性能外推到生产

loopback 消除了最关键的变量

本章三个数据库共享:

CPU scheduler
shared_buffers
OS page cache
storage
filesystem
socket transport
PostgreSQL installation
host failure

真实多节点新增:

network RTT/throughput/loss
TLS/authentication
independent caches
clock behavior
node skew
replication
DNS/service discovery
firewall
host maintenance

因此不能发布:

two-stage is N times faster
two shards scale linearly
FDW overhead is X ms
Citus will behave like this

本章只发布:

naive plan returns 240000 foreign rows
two-stage plan returns 960 foreign aggregate rows

EXPLAIN rows 也有边界

actual rows 表示某计划节点每 loop 的输出数量。跨网络字节还取决于:

  • row width;
  • text/binary representation;
  • protocol framing;
  • compression;
  • TLS;
  • fetch batches;
  • remote output expressions;
  • retries。

要测网络必须采集 network bytes/packets 与 server/client metrics,不能把 rows 直接乘一个猜测宽度。

人为 planner 设置

教学计划可能使用:

SET max_parallel_workers_per_gather = 2;
SET min_parallel_table_scan_size = 0;
SET parallel_setup_cost = 0;
SET parallel_tuple_cost = 0;
SET enable_seqscan = off;

它们分别用于稳定复现某种路径。强制路径证明“可执行”,不证明 planner 在 真实成本下应选择它,更不证明它在生产更快。

每份强制计划旁都应写:

why forced
what property is proven
what performance claim is not made

小数据掩盖协调成本

24 万行对现代机器很小。它可能:

  • 全在内存;
  • 规划/连接开销占比过高;
  • 看不出网络拥塞;
  • 看不出 shard skew;
  • 看不出 vacuum 和 checkpoint;
  • 看不出 rebalance;
  • 看不出 compaction/backup;
  • 无法设置生产 P99。

正式 PoC 要按 S/M/L/XL 数据阶梯,直到越过 memory 工作集和目标维护窗口。

同机故障探针的含义

把 foreign server port 改为 1,能证明:

partition pruning avoids unopened bad server
global fan-out surfaces connection failure
SQLSTATE is captured
transaction rollback restores catalog

它不能证明:

  • 半开 TCP;
  • DNS 慢失败;
  • packet loss;
  • TLS rotation;
  • remote process crash;
  • node failover;
  • long transaction during disconnect;
  • unknown distributed commit。

生产 failure matrix 要在独立节点执行这些场景。

对比 Pigsty L1 的证据层级

建议区分:

L0 design review
  schema, query, topology, safety, ADR

L1 target environment
  actual packages, nodes, config, connectivity, backup

L2 functional workload
  golden results, plans, permissions, failures

L3 representative performance
  scale, concurrency, cold/warm, resources, cost

L4 operational drills
  restore, failover, rebalance, upgrade, exit

本章 loopback 具有 L0 和部分 L2 证据;Pigsty L1 明确未运行,更没有 L3/L4。

生产 benchmark 的最小补充

[ ] independent hosts/failure domains
[ ] target network/TLS/auth
[ ] target PG/Pigsty/extension versions
[ ] representative data width and skew
[ ] cold/warm/restart runs
[ ] open-loop arrival and bounded concurrency
[ ] OLTP + OLAP mixed workload
[ ] WAL/checkpoint/vacuum/backup overlap
[ ] per-shard and coordinator metrics
[ ] network rows and bytes
[ ] worker/coordinator failure
[ ] backup/restore checksum
[ ] rebalance interruption and resume
[ ] upgrade and rollback
[ ] cost and on-call effort

PoC 的停止规则

如果发生以下任一情况,应停止扩展 demo 并回到设计:

  • golden 不一致;
  • route 与 physical placement 不一致;
  • 必需查询无法局部化;
  • 跨分片事务比例不可接受;
  • mandatory extension 不兼容;
  • backup/restore 无法证明;
  • 身份需要不安全捷径;
  • 失败返回 partial data 却无标识;
  • 生产成本/owner 不明确;
  • 没有退出路线。

本节结论

最小 PoC 的价值不是“跑起来”,而是把关键假设变成:

frozen input
exact topology
observable plan
expected success
expected failure
explicit limitation
repeatable teardown/rebuild

下一节把这些资产串成一次完整执行,并从证据直接生成 ADR,而不是先写结论再 挑选支持它的截图。


上一节:比较分布式候选 · 返回本章目录 · 下一节:实战:从单机证据到选型 ADR · 查看全书目录 · 查看索引中心

17.6 实战:从单机证据到选型 ADR

本节把前五节压成一个可重复的 1.5-proposal

freeze generator and monthly golden
  -> bootstrap two retained shard database shells
  -> build two exact remote schemas
  -> build a local single-node baseline
  -> build LIST-partitioned foreign parents
  -> compare four byte-identical result paths
  -> collect parallel/index/spill/summary plans
  -> collect pruning/pushdown/transfer plans
  -> preserve a non-pushed JOIN counterexample
  -> prove application privilege denial
  -> prove partial shard failure semantics
  -> checksum catalogs and business state
  -> reject unsafe reset attempts
  -> pre-verify three databases
  -> exact per-database reset
  -> rebuild and repeat the entire review

正式证据来自 PostgreSQL 18.6 / postgres_fdw 1.2 的受控本地开发实例。 Pigsty 4.5 的 topology 和职责已经映射,但没有执行 L1:

pigsty_l1=not-run

破坏边界

task.sh all 会删除并重建精确标记的 shop_ch17shop_ch17_ext、 两个 foreign server、六个 user mapping,以及 pg36_shard_a/pg36_shard_b 中的 shop_ch17_shard。两个数据库壳会 保留。只可在本书受控开发 fixture 中执行,禁止在生产运行。

17.6.1 证明一个边界,拒绝一个伪瓶颈

前置连接

沿用第 4 章管理员 service:

[pg36-admin]
host=/path/to/socket-or-host
port=5432
dbname=pg36_shop
user=postgres
chmod 600 /path/to/pg_service.conf
export PGSERVICEFILE=/path/to/pg_service.conf
export PGSERVICE=pg36-admin

密码应放在受控 secret/service 机制中,不出现在命令行、脚本、evidence 或 Git。

环境保护

协调端 context.sql 要求:

database = pg36_shop
writable primary
PostgreSQL major = 18
session_user = postgres superuser
can SET ROLE pg36_owner
pg36_app = constrained LOGIN non-superuser
ch04-v1 physical model exists
postgres_fdw 1.2 available or exact managed state
pg36_shard_a/b database shell identity exact
fdw host/port exactly equal current instance

远端 remote-context.sql 还核对:

expected database name
database owner/comment
shard remainder
shard marker
UTC/ISO session
timeouts

任何已有同名 schema、server、mapping、extension 或 database 身份不符都停止, 不会因名字相同就接管。

资产清单

static/labs/ch17/
├── fixture-manifest.json
├── frozen-monthly.csv
├── fixture.sql
├── fixture-remote.sql
├── bootstrap.sql
├── remote-context.sql
├── remote-setup.sql
├── context.sql
├── setup.sql
├── verify.sql
├── remote-verify.sql
├── final-state.sql
├── reset.sql
├── remote-reset.sql
├── review.py
├── task.sh
├── analytics-distributed-adr.md
├── baseline-v1.5-proposal.json
└── pigsty-declaration.example.yml

另有四份月报导出、十份计划、五份 catalog、安全边界与单分片失败探针。

冻结输入身份

fixture-manifest.json 固定:

frozen-monthly.csv
  rows=32
  sha256=64b045809e10364fd84a587121d919e8562a15335c4c6c015e91a0ead3a44323

fixture.sql
  sha256=3110d0369b0c62fffeee643200f5320d1f6bd26ad5f9950b2ab2b58991080e10

fixture-remote.sql
  sha256=da02dbca8d2cfeb0294c369b15cad3619a9433d984f98866b5043eb4508e3e91

生成器不读取当前时间,不使用随机数。冻结时间只是 fixture metadata,不参与 运行时决定。

单步建立

./static/labs/ch17/task.sh setup

setup 的顺序:

connect maintenance database postgres
  -> create/reuse exact pg36_shard_a/b shells

connect pg36_shard_a
  -> exact remote schema rebuild
  -> load even tenant IDs
  -> analyze

connect pg36_shard_b
  -> exact remote schema rebuild
  -> load odd tenant IDs
  -> analyze

connect pg36_shop
  -> exact FDW/data schema rebuild in one transaction
  -> load full local baseline
  -> create indexes/materialized summary
  -> create LIST foreign partitions/views/grants
  -> analyze
  -> commit
  -> VACUUM ANALYZE local sales fact

协调端 DDL 在单事务内,避免半成品;数据库壳创建和三个数据库的 schema 建立不能放在一个全局事务里。

为什么 setup 后显式 vacuum

覆盖索引:

CREATE INDEX sales_fact_tenant_day_idx
ON shop_ch17.sales_fact (
  tenant_id,
  occurred_on,
  account_id
)
INCLUDE (amount, units, channel);

理论上包含查询所需列,但 Index Only Scan 是否免 heap 访问还取决于 visibility map。新装载表的 page 未必 all-visible。第一轮自动验收曾真实得到:

Bitmap Heap Scan on sales_fact
  -> Bitmap Index Scan on sales_fact_tenant_day_idx

而审查器要求:

Index Only Scan
Heap Fetches: 0

正确修复不是放宽断言,而是在 COMMIT 后显式:

VACUUM (ANALYZE) shop_ch17.sales_fact;

随后两个完整周期都稳定得到:

Index Only Scan using sales_fact_tenant_day_idx
  actual rows=7500
  Heap Fetches: 0

这个经历说明:计划回归不仅依赖 DDL 和数据,也依赖 vacuum/visibility 状态。实验必须显式制造自己的前置条件。

本地事实

fixture-facts.sql

distributed_amount=2256000.00
distributed_sales=240000
distributed_units=1200000
first_day=2026-01-01
last_day=2026-04-30
local_amount=2256000.00
local_sales=240000
local_units=1200000
shard_rows=dist_0:120000,dist_1:120000
summary_rows=2880
summary_sales=240000

远端:

数据库 租户 账户 销售 units amount checksum
pg36_shard_a 2,4,6,8 200 120,000 599,988 1,188,000.00 274002…24e3
pg36_shard_b 1,3,5,7 200 120,000 600,012 1,068,000.00 0bb770…2629

总数相等不够,物理分片 checksum 也必须匹配。

并行边界

psql -X -w \
  --dbname="service=$PGSERVICE" \
  --set=fdw_host=/path/to/socket \
  --set=fdw_port=5432 \
  --file=static/labs/ch17/local-parallel-plan.sql

通常不需要手工运行;task 会动态读取 socket/port 并注入。

冻结关键路径:

Finalize HashAggregate
  -> Gather
       Workers Planned: 2
       Workers Launched: 2
       -> Partial HashAggregate
            -> Parallel Seq Scan on sales_fact
                 actual rows=80000 loops=3

证明:

parallel path exists
two workers actually launched
aggregation decomposes into partial/final
240k rows become 32 groups

不证明:

production speedup
safe system-wide worker count
behavior under concurrent reports

work_mem 边界

低内存:

Sort actual rows=240000
Sort Method: external merge
Disk: about 5920kB
temp read/write present

高内存:

Sort actual rows=240000
Sort Method: quicksort
Memory: about 13645kB
no temp read

两个文件:

这证明 spill 可被定位到一个节点;不授权全局调大 work_mem

汇总边界

raw-aggregate-plan.sql

Parallel Seq Scan on sales_fact
actual rows=80000 loops=3

summary-aggregate-plan.sql

Seq Scan on daily_tenant_summary
actual rows=2880 loops=1

两者输出同一 32 行月报。ADR 因而可以拒绝:

“全局月报每次扫描 24 万行,所以必须分片”

更准确的结论:

这条重复聚合可先用 2,880 行日粒度处理;
生产还要评审刷新、新鲜度、迟到和恢复成本。

BRIN 只记录候选

catalog 验证:

sales_fact_day_brin_idx
  access_method=brin
  operator_class=pg_catalog.date_minmax_ops
  size > 0
  size < covering B-tree in this fixture

没有强制一条 BRIN 查询并宣称更快。小表无法代表物理相关性和 block range 收益。

17.6.2 比较单机加速与一个分布式候选

四条相同结果路径

自动导出:

monthly-local.csv
monthly-summary.csv
monthly-distributed.csv
monthly-two-stage.csv

分别来自:

任务逐个:

cmp static/labs/ch17/frozen-monthly.csv \
    evidence/.../monthly-local.csv

四个文件都要 byte-identical,不做“行数相等即可”的弱比较。

单租户裁剪

tenant-pruned-plan.sql

SELECT count(*), sum(amount)
FROM shop_ch17.sales_fact_distributed
WHERE tenant_id = 3
  AND occurred_on >= DATE '2026-04-01';

关键计划:

Foreign Scan on shop_ch17.sales_fact_dist_1
  actual rows=7500
  Remote SQL:
    SELECT amount
    FROM shop_ch17_shard.sales_fact
    WHERE occurred_on >= '2026-04-01'
      AND tenant_id = 3

审查器明确要求:

sales_fact_dist_1 present
sales_fact_dist_0 absent
tenant/date in Remote SQL

这同时证明 partition pruning、column projection 和 filter pushdown。

朴素全局聚合

distributed-naive-plan.sql 直接查询 分区父表:

HashAggregate
  -> Append actual rows=240000
       -> Foreign Scan dist_1 actual rows=120000
            Remote SQL: SELECT tenant_id, occurred_on, units, amount ...
       -> Foreign Scan dist_0 actual rows=120000
            Remote SQL: SELECT tenant_id, occurred_on, units, amount ...

远端没有 GROUP BY,协调端获得 24 万条事实再聚合。

这不表示 postgres_fdw 永远不能下推 aggregate;它说明这条父表查询在本次 目标版本和 SQL 形状下没有得到期望的跨分片预聚合。

两阶段聚合

distributed-two-stage-plan.sql 显式分别查询两个 foreign partition:

SELECT tenant_id, occurred_on,
       count(*), sum(units), sum(amount)
FROM shop_ch17.sales_fact_dist_0
GROUP BY tenant_id, occurred_on

UNION ALL

SELECT tenant_id, occurred_on,
       count(*), sum(units), sum(amount)
FROM shop_ch17.sales_fact_dist_1
GROUP BY tenant_id, occurred_on;

外层再按月合并。计划:

GroupAggregate actual rows=32
  -> Sort actual rows=960
       -> Append actual rows=960
            -> Foreign Scan Aggregate dist_0 actual rows=480
                 Remote SQL ... GROUP BY 1, 2
            -> Foreign Scan Aggregate dist_1 actual rows=480
                 Remote SQL ... GROUP BY 1, 2

数据流:

960240000=0.004 \frac{960}{240000} = 0.004

即返回行数为朴素路径的 0.4%。这个比例只描述冻结 fixture 的行数,不能直接 转成“性能提升 250 倍”。

同分片 JOIN 反例

collocated-parent-plan.sql

SELECT
  account.segment,
  count(*) AS sale_count,
  sum(sale.amount) AS amount_total
FROM shop_ch17.sales_fact_distributed AS sale
JOIN shop_ch17.account_dim_distributed AS account
  ON account.tenant_id = sale.tenant_id
 AND account.account_id = sale.account_id
WHERE sale.tenant_id = 3
  AND sale.occurred_on >= DATE '2026-04-01'
GROUP BY account.segment;

物理上两表的 tenant 3 都在 shard B。实测:

Hash Join on coordinator actual rows=7500
  -> Foreign Scan sales_fact_dist_1 actual rows=7500
  -> Hash
       -> Foreign Scan account_dim_dist_1 actual rows=50

两条 Remote SQL 都没有 JOIN。这个反例写进 ADR:

对目标抽象层而言,共置只是设计前提,不是下推证据。必须检查目标产品、 版本、SQL 与 EXPLAIN VERBOSE

若生产候选是 Citus,应在真实 Citus L1 上创建目标 distributed/reference tables,重新验证 colocated join;不能用 FDW 反例替代,也不能假定一定成功。

应用身份

成功读:

sale_count=7500
amount_total=69375.00

写入负例:

exit=3
SQLSTATE 42501

catalog:

app_schema_usage=true
app_local_sales_select=true
app_local_sales_write=false
app_distributed_sales_select=true
app_distributed_sales_write=false
app_server_a_usage=true
app_server_b_usage=true

六个 mapping 都是具名;password_required=false 被 review 当成必须显式存在 的 lab-only 警告,而不是悄悄依赖环境。

单分片失败

执行器先查询 tenant 2:

healthy_shard_tenant_2=30000

再执行全局 count,固定:

exit=3
SQLSTATE 08001

任务同时捕获 stdout/stderr;如果只看 stderr,就无法证明健康分片路径曾成功。

错误断开使事务回滚后,重新导出:

server-catalog-after-failure.csv

并与故障前 server-catalog.csv 逐字节比较,确保 shard B port 没有残留为 1。

一次完整 evaluate

如果不需要 reset/rebuild 双周期:

evidence_dir="$PWD/evidence/ch17/evaluate-$(date -u +%Y%m%dT%H%M%SZ)"

PG36_EVIDENCE_DIR="$evidence_dir" \
  ./static/labs/ch17/task.sh evaluate

evaluate 会重建一次、采集所有证据并运行 review。

仅验证现有数据库:

PG36_EVIDENCE_DIR="$PWD/evidence/ch17/verify" \
  ./static/labs/ch17/task.sh verify

它分别运行 coordinator、shard A、shard B 的完整数据库内断言。

review 的职责

review.py 不连数据库,只审查 evidence:

manifest version/target/checksum
source generator and frozen CSV hashes
four byte-identical monthly exports
local/remote cardinality and checksums
server/mapping/relation/index/security/size catalogs
parallel/index/spill/summary plans
tenant pruning and Remote SQL
naive/two-stage row shape
non-pushed JOIN counterexample
application read/write
shard failure and restored server catalog
final state
coordinator/remote verify outputs
baseline ADR contract

这使 evidence 可以离线审阅,也避免“数据库后来变了,旧报告仍假装当前”。

17.6.3 输出 ADR、PoC 证据、生产代价和撤退路线

ADR 不以产品名开头

analytics-distributed-adr.md 先写背景和决策顺序:

1. fix correctness, SQL, statistics, paths
2. prove parallel/index/BRIN/spill/summary on one node
3. assess offline replica for tolerable-staleness reads
4. enter distribution only after measured resource boundary
5. evaluate Citus when PostgreSQL-compatible sharding fits
6. compare specialized OLAP only when PostgreSQL paths miss SLO

postgres_fdw 的定位是 mechanics/counterexample lab,不是生产性能结论。

ADR 的冻结证据

问题 证据 决策影响
可并行? 2 workers launched 单机还有并行路径
选择性查询? index-only,heap fetch 0 先修访问路径
内存? 64kB 外排 / 32MB 内排 按并发设局部预算
重复聚合? 240k vs 2,880 输入 先评估汇总
单租户路由? 只访问 shard B tenant_id 可局部
朴素全局? 240k foreign rows 协调端/网络风险
两阶段? 960 aggregate rows 计算靠近数据
同分片 JOIN? coordinator Hash Join 必须实测下推
shard 失败? scoped read 成功/global 08001 定义 partial semantics

决策与限制同版本

baseline-v1.5-proposal.json 固定:

target versions
default path
distribution key
routing warning
remote aggregation design
production Citus gate
fixture contracts
expected checksums
lab authentication warning
evidence inventory
rollback contract
limitations

canonical JSON SHA-256:

3dcb7308cf6983122ee860ad3dc2a4b44651549e3d5631770839bb9a0be450c6

改变 ADR contract 会改变 release candidate identity。

进入生产 PoC 前的代价

ADR 要预算:

schema/query changes for distribution key
backfill and dual-write/read
coordinator/worker nodes
HA and service routing
network/TLS/auth
backup repository and restore
monitoring/alerting
rebalance capacity
rolling/major upgrade
on-call training
license/cloud cost
exit rehearsal

不能只比较机器数量。

Pigsty 交付分支

pigsty-declaration.example.yml 保留两个候选:

Path A:
  pg-analytics primary + replica with pg_offline_query
  pg_conf: olap.yml

Path B:
  pg-citus0/1/2 groups
  pg_mode: citus
  pg_shard / pg_group

它们不是同一 inventory 的叠加方案,也不是完整生产配置。ADR 应先决定测哪条 假设,再补齐目标地址、database、users、extensions、HBA、secret、backup、 service 和 HA。

撤退路线

PoC 撤退:

stop new lab sessions
verify coordinator + A + B exact state
drop coordinator views/matview/tables
drop mappings/servers/extension
drop remote tables/schemas
retain empty database shells
verify zero remaining managed objects

生产迁移撤退则应提前设计:

canonical source of truth remains PostgreSQL
dual-read compares checksums
route change has version/epoch
old and new writers cannot both own same key
backfill has watermark and resume point
last reversible point is named
service/DNS rollback is tested
new system data can be exported back

reset 的三道 guard

协调 reset 需要:

PG36_RESET_TOKEN=RESET_CH17_ANALYTICS_FDW_LAB
PG36_RESET_TARGET=pg36_shop/shop_ch17+shop_ch17_ext+fdw

错误 action token:

SQLSTATE P3660
reset refused: invalid ch17 action token

错误 target:

SQLSTATE P3661
reset refused: invalid ch17 target token

存在 application_name LIKE 'pg36-ch17-%' 的其他 worker:

SQLSTATE P3663
reset refused: ch17 workers are active

task 会主动启动一个 sleep worker,观察到 PID 后证明 P3663,再 cancel。

精确 reset

协调端顺序:

DROP VIEW ...
DROP MATERIALIZED VIEW ...
DROP TABLE exact parents/local tables ...
DROP SCHEMA shop_ch17;

DROP USER MAPPING ...  -- six exact mappings
DROP SERVER ...        -- two exact servers
DROP EXTENSION postgres_fdw;
DROP SCHEMA shop_ch17_ext;

不使用 CASCADE。输出:

status=coordinator-reset-ok
remaining_data_schema=0
remaining_extension_schema=0
remaining_ch17_servers=0
retained_shard_databases=pg36_shard_a,pg36_shard_b

每个 remote:

full remote verify
BEGIN
drop sales_fact
drop account_dim
drop fixture_meta
drop schema
COMMIT

输出:

status=remote-reset-ok
remaining_schema=0
database_shell=retained

跨库非原子边界

任务先对三个数据库全部 preflight verify,再按:

coordinator -> shard A -> shard B

退出。每一步各自事务化,但整体不是一个事务。若 A reset 后 B 失败,系统处于 部分退出状态;恢复方式是按 exact identity 继续补偿或完整重建。

这项 limitation 同时写入 lab contract、baseline 和 ADR,不能用脚本“看起来 是一条命令”掩盖。

手工 reset

只有明确需要退出 fixture 时:

export PG36_RESET_TOKEN=RESET_CH17_ANALYTICS_FDW_LAB
export PG36_RESET_TARGET=pg36_shop/shop_ch17+shop_ch17_ext+fdw

PG36_EVIDENCE_DIR="$PWD/evidence/ch17/reset" \
  ./static/labs/ch17/task.sh reset

它会同时处理两个远端 schema。不要在生产、共享开发数据库或身份未知的目标 上执行。

17.6.4 验收采用 checklist:evidence

完整双周期

evidence_dir="$PWD/evidence/ch17/$(date -u +%Y%m%dT%H%M%SZ)"

PG36_EVIDENCE_DIR="$evidence_dir" \
  ./static/labs/ch17/task.sh all

成功输出:

status=ok
fixture=frozen-byte-identical-four-paths
single_node=parallel+index+summary+spill
distributed=tenant-pruning+fdw+two-stage
counterexamples=hash-is-not-modulo+join-not-pushed
failure=healthy-shard-read+global-08001
guards=P3660+P3661+P3663
postgres_fdw=1.2
pigsty_l1=not-run
release_candidate_checksum=3dcb7308cf6983122ee860ad3dc2a4b44651549e3d5631770839bb9a0be450c6

目录:

evidence/ch17/<run>/
├── cycle-1/
├── reset-wrong-token.*
├── reset-wrong-target.*
├── reset-active-worker.*
├── reset-exact/
└── cycle-2/

checklist:evidence

验收项 evidence 通过条件
manifest manifest.txt PG18、FDW 1.2、三库、checksums
source manifest + fixture JSON generator/golden SHA 精确
golden 四份 monthly CSV 与 frozen byte-identical
local-facts fixture-facts.csv 240k、1.2m、2.256m
remote-facts remote-*-state.csv 各 120k + shard checksum
parallel local-parallel-plan.txt planned/launched=2、partial/final
covering-index selective-index-plan.txt 7,500、Heap Fetches 0
spill low/high plans external merge vs quicksort
summary raw/summary plans 240k vs 2,880 input shape
pruning tenant-pruned-plan.txt only dist_1 + remote filters
naive naive plan 240k foreign rows
two-stage two-stage plan 480+480 remote aggregate rows
join-counterexample collocated plan coordinator Hash Join
auth mapping/security catalogs six named mappings, read-only app
app-failure app-write.* exit 3, SQLSTATE 42501
shard-failure shard-failure.* healthy=30k, global 08001
catalog-rollback two server catalogs byte-identical
database-verify three verify outputs coordinator + A + B status ok
final-state final-state.csv release/rows/checksums exact
review review.txt status=review-ok
reset-guards root failure files P3660/P3661/P3663
exact-reset reset outputs schemas/servers zero, DB shells retained
rebuild cycle-2 same complete review

最终状态

final-state.sql 固定:

business_checksum=42fb8ab5444469eba1f104a8e1e529dd
distributed_sales=240000
fixture=ch17-analytics-v1
local_sales=240000
monthly_checksum=644d45544ebbc2a80c42270c38ac6885
naive_transfer_rows=240000
postgres_fdw=1.2
release=1.5-proposal
shard_rows=dist_0:120000,dist_1:120000
summary_rows=2880
tenant3_april=7500:69375.00
two_stage_transfer_rows=960

naive_transfer_rowstwo_stage_transfer_rows 是与计划合同共同审查的固定 事实;如果 SQL 或版本改变,不能只保留硬编码值,必须重新采集 plan 并更新 proposal。

review 输出

status=review-ok
fixture=frozen-byte-identical-four-paths
single_node=parallel+index+summary+spill
distributed=tenant-pruning+fdw+two-stage
counterexamples=hash-is-not-modulo+join-not-pushed
failure=healthy-shard-read+global-08001
business_checksum=42fb8ab5444469eba1f104a8e1e529dd
monthly_checksum=644d45544ebbc2a80c42270c38ac6885
release_candidate_checksum=3dcb7308cf6983122ee860ad3dc2a4b44651549e3d5631770839bb9a0be450c6

失败排查顺序

如果 task 失败:

  1. 保留 evidence,不立即重跑覆盖;
  2. 找到最后产生的 stderr;
  3. 判断是环境 guard、业务 checksum、计划 shape、权限还是故障边界;
  4. 连接三个数据库分别运行 verify;
  5. 检查 server catalog 是否已回滚;
  6. 修复原因,不降低断言掩盖差异;
  7. 新建 evidence 目录完整重跑两个周期;
  8. 比较两个 manifest。

第一轮 index-only 失败就是这个流程的例子:证据显示 Bitmap Heap Scan, 根因是 visibility map precondition,没有把 reviewer 改成接受任意 index 路径。

生产发布仍缺什么

本地 status=ok 之后仍需:

[ ] representative production-scale data and skew
[ ] independent nodes and failure domains
[ ] target network/TLS/identity
[ ] Pigsty L1 inventory and runtime convergence
[ ] actual Citus or selected candidate, not FDW surrogate
[ ] OLTP+OLAP mixed concurrency
[ ] P50/P95/P99 and open-loop throughput
[ ] CPU/memory/I/O/temp/WAL/network cost
[ ] coordinator/worker HA
[ ] backup-to-empty restore checksum
[ ] rebalance interrupt/resume
[ ] schema change mixed-version test
[ ] rolling/major upgrade
[ ] RPO/RTO and partial-result semantics
[ ] migration dual-read and last rollback point
[ ] exit rehearsal

因此 1.5-proposal 是设计与本地机制候选,不是生产批准。

本章最终决策

在冻结工作负载上,当前可支持的结论是:

  1. 单机仍有并行、覆盖索引、spill 治理和汇总空间;
  2. 不能因 24 万行原表扫描直接宣布需要分布式;
  3. tenant_id 对单租户访问有良好局部性;
  4. 分布式全局聚合必须关注计算位置与协调端输入;
  5. 同分片不自动证明 JOIN 下推;
  6. 路由算法不一致会导致静默错误,HASH remainder 不能当整数取模;
  7. 部分失败和跨数据库退出必须成为业务/运维合同;
  8. 下一层应先比较 Pigsty offline replica;若代表性容量仍越界,再在真实 Pigsty Citus L1 上验证分布键、HA、恢复和再平衡;
  9. 专用 OLAP 只有在 PostgreSQL 路径无法满足已定义 SLO,且团队接受第二套 数据管道与值班体系时进入终选。

这就是一份合格选型 ADR 的语气:它不承诺某产品必胜,而是清楚说明当前证据 允许做什么、禁止外推什么,以及什么新证据会触发下一次决策。


上一节:部署最小分布式 PoC · 返回本章目录 · 下一章:万法归宗:PostgreSQL 数据平台与替代边界 · 查看全书目录 · 查看索引中心

18 万法归宗:PostgreSQL 数据平台与替代边界

前 17 章分别回答了许多“PostgreSQL 能不能”的问题:

能不能表达可靠的数据模型
能不能守住事务与并发不变量
能不能把数据库能力交付给应用
能不能用函数、触发器和扩展扩大边界
能不能完成检索、时空与分析工作

第 18 章换一个问法:

即使都能做,哪些能力应当留在 PostgreSQL,哪些只适合试点,哪些应当交给 外部系统;又由谁对组合后的整体服务负责?

这是从“数据库产品”走向“数据平台”的分水岭。

数据库不会因为装了更多扩展就自动成为平台;HA、备份、连接池和监控也不会 因为有了部署脚本就自动成为服务。平台至少还要给出:

service objective   对外承诺什么
authority           每类数据由谁定夺
ownership           谁决策、谁值班、谁付成本
isolation           谁能互相影响
lifecycle           如何申请、变更、升级、退出
evidence            哪些结论已经证明

本章把上卷的功能证据组合成一份 1.6-proposal,同时把所有尚未证明的生产 结论交给下卷 18 个证据门。它不是庆功章,而是一份边界清单。

本章完成后

你应当能够:

  • 把事务、检索、时空、分析和异步任务拆成独立能力,而不是用一个产品名概括;
  • 区分数据/计算、存储、接入、控制与观察平面;
  • 解释为什么多个健康组件仍可能交付一个不健康的端到端服务;
  • 用延迟、新鲜度、正确性、可用性、RPO/RTO、容量和成本描述组合目标;
  • 说明 PostgreSQL 的关系一致性、类型系统、扩展机制和统一查询为何有价值;
  • 同时说明通用数据库中的 CPU、内存、I/O、WAL、连接、锁和维护竞争;
  • 把“扩展已经安装”与“扩展已经生产准入”分开;
  • 为缓存、消息、对象存储、湖仓和外部检索划分逐数据域的权威;
  • 为每个派生副本写出新鲜度、顺序、幂等、重建、对账、删除和退出契约;
  • 用测量触发器而不是技术潮流决定何时引入专用检索、流处理或分析系统;
  • 设计开发、标准 HA、关键 HA 和离线分析四类服务产品;
  • 在 schema、database、instance 和 cluster 隔离之间按信任与故障边界选择;
  • 把连接、存储、临时文件、语句时间和维护窗口变成可执行配额;
  • 说明 Pigsty 4.5 如何映射 PostgreSQL、Patroni、etcd、HAProxy、PgBouncer、 pgBackRest 与观测能力;
  • 分清 Pigsty 开箱能力、环境验收和组织流程;
  • 运行一个完全只读的上卷能力审计;
  • 读懂服务目录、外置数据契约、架构 ADR 与下卷证据门之间的引用关系;
  • 拒绝“无证据宣称 L1 已通过”“缓存成为业务权威”“实验 FDW 准入生产”等 反例;
  • pg36_shop 的当前结论准确表达为架构提案,而不是生产完成声明。

一张平台地图

应用与运维身份
      |
服务契约:write / read-only / offline / admin
      |
PostgreSQL 权威状态
├── 事务、约束、短原子逻辑
├── pg_trgm 检索:accepted
├── vector 语义检索:pilot
├── PostGIS / btree_gist:conditional
└── 汇总 + offline replica:accepted first step
      |
派生与外置能力
├── cache:只持有可丢弃投影
├── event bus:持有投递与重放日志
├── object storage:持有媒体字节
├── lakehouse:持有带 watermark 的分析投影
└── external search:达到触发条件后才启用

图的关键不是方框数量,而是箭头上的合同。只要发生跨系统复制,就必须回答:

authority       冲突时相信谁
freshness       最旧可以多旧
ordering        允许怎样乱序
idempotency     重试如何不重复生效
failure         一侧不可达时怎样退化
reconciliation  如何发现并修复分歧
deletion        删除如何跨副本传播
rebuild         如何从权威重新生成
exit            如何撤掉这个组件

这些字段被固化在 external-data-contracts.json, 不是留给实现阶段再想的“细节”。

当前提案,不是当前承诺

第 18 章的服务目录有四个 offering:

ID 用途 关键边界
pg-dev 有期限的开发/测试 不承诺 HA
pg-ha-standard 默认生产事务服务 目标待下卷证明
pg-ha-critical 更强隔离与同步策略候选 必须先定义故障域
pg-analytics-offline ETL、慢读、交互分析 非权威、非 read-your-writes

service_objectives 中出现的可用性、RPO、RTO 和新鲜度都是 proposal。节点数 不能证明故障域,配置文件不能证明收敛,备份成功不能证明可恢复,仪表盘存在 不能证明告警可行动。

因此蓝图明确写着:

status=architecture-proposal-not-production-approval
postgresql=18.x target
validated_fixture=18.6
pigsty_reference=4.4
pigsty_l1_validation=not-run
lower_volume_gates=18 pending

这个诚实程度是架构质量的一部分。

PostgreSQL 的能力与代价同时成立

PostgreSQL 把关系约束、事务、丰富类型、函数、操作符、索引方法和查询规划器 放在同一个一致性边界中。官方 Extending SQL 列出的扩展点包括函数、聚合、数据类型、操作符、索引操作符类和扩展包。第 14–17 章已经证明这套机制可以把检索、向量、空间与远端数据纳入 SQL。

同一事实还有另一面:

一个 shared_buffers
一组 CPU / I/O / worker 资源
一个 WAL 与 checkpoint 压力面
一组连接与后台维护预算
一条升级和恢复链
一个错误配置可能共享的爆炸半径

PostgreSQL 18 的 Resource Consumption 特别提醒,work_mem 是每个 sort/hash 操作的基础上限;一条复杂查询可有 多个操作,同时还有多个会话。因此“通用”不是“无限”,统一也不是“免费”。

本章不在这两个事实中二选一。架构工作正是保留统一带来的收益,同时给资源 竞争、生命周期和失败传播设边界。

Pigsty 的位置

Pigsty 4.5 的 Architecture 以声明式 inventory 和模块组合交付环境;PGSQL、INFRA、NODE、ETCD 等模块 把 PostgreSQL 与 HA、接入、备份和观察组件连接起来。

本书把它作为参考实现,因为它能让抽象职责落到可读配置:

抽象职责 Pigsty 参考映射
数据服务 PostgreSQL cluster / database / role
HA 控制 Patroni + etcd
服务接入 HAProxy,按需配 PgBouncer / VIP / DNS
恢复 pgBackRest 与仓库策略
主机与软件 NODE / 软件仓库 / inventory
指标与日志 exporter、VictoriaMetrics/Logs、Grafana、Alertmanager
分析隔离 pg_role: offlinepg_offline_query

Pigsty 官方 Service Access 把 service 定义为封装底层拓扑的访问抽象;本书沿用这个语义,不把某个节点 IP 叫作生产服务。

但参考实现不替团队完成:

业务权威划分
SLO 与错误预算审批
威胁模型
容量预测
扩展准入
变更审批
演练与复盘
值班责任

这也是为什么 pigsty-declaration.example.yml 只能叫 proposal。

只读总验收

本章没有 setup,也没有 reset。它只读前面章节已经保留的 fixture:

ch04 关系模型
ch13 原子数据库逻辑
ch14 扩展生命周期
ch15 搜索质量
ch16 时空语义
ch17 分析与 FDW 边界

然后连续抓取两轮:

platform state
extension catalog
schema catalog
role catalog
capability lifecycle
cross-document policy report
negative policy report

两轮必须逐字节一致,最终输出:

status=ok
preflight=ch04+ch13+ch14+ch15+ch16+ch17
cycles=2-byte-identical
documents=catalog+contracts+blueprint+18-pending-gates
counterexamples=7-rejected
pigsty_l1=not-run
mutation=none

通过只表示“当前开发证据与提案内部一致”,不表示任何生产 SLO 已经实现。

本章目录

18.1 从数据库产品到能力组合

18.2 PostgreSQL 的强项与代价

18.3 明确替代边界

18.4 平台服务目录与多租户

18.5 Pigsty 作为参考实现

18.6 实战:设计 pg36_shop 生产蓝图

写作与验收提示

下一章从第一个 pending gate 开始:不谈抽象生产级,而是建立 部署基线与环境验收


上一章:合纵连横:分析加速与分布式选型 · 返回上卷导读 · 下一章:开天辟地:环境规划与部署基线 · 查看全书目录 · 查看索引中心

18.1 从数据库产品到能力组合

当团队说“我们用 PostgreSQL”时,这句话通常混合了三种不同事实:

product
  一个特定版本的 PostgreSQL server

capabilities
  事务、约束、检索、时空、分析、复制、恢复……

service
  带身份、入口、目标、配额、支持和生命周期的对外交付

产品可以启动,不代表所有能力可用;能力可以运行,不代表服务可承诺。平台 设计的第一步,是把这三层重新拆开。

18.1.1 事务、检索、时空、分析与任务能力

从业务问题开始,而不是从组件清单开始

pg36_shop 至少需要处理以下业务问题:

业务问题 需要的能力 首选正确性边界
订单与库存能否保持不变量 关系约束、事务、并发控制 PostgreSQL commit
应用如何安全调用数据能力 角色、schema、prepared SQL、API 合同 DB + 应用
商品如何被关键词找到 全文/模糊检索、排序、质量 golden PostgreSQL 起步
商品如何按语义相似找到 embedding、向量距离、ANN 质量 试点
配送事件在哪里、何时有效 空间、时间、边界和参考系 PostgreSQL 起步
月报如何快速且不过度影响交易 并行、索引、汇总、离线读 PostgreSQL + 隔离
订单事件如何送到其他服务 outbox、relay、消息投递 跨系统合同
图片字节放在哪里 对象存储与元数据协调 按数据域拆分

先写能力,能避免两种常见偷换:

“PostgreSQL 支持” -> “我们的服务已经支持”
“引入了某产品”   -> “业务问题已经解决”

例如,vector 扩展已经在第 14 章的开发 fixture 中安装,<=> 距离查询也在 第 15 章得到正确结果。这证明“语义检索试验可运行”,没有证明:

  • embedding 模型会被稳定版本化;
  • ANN 在生产规模下满足质量与延迟;
  • 索引重建落在维护窗口;
  • 副本、备份、恢复和升级路径全部可用;
  • 团队可以在模型漂移时诊断结果变化。

所以它的生命周期是 pilot,不是 accepted

事务能力是权威状态的锚

对订单、库存和支付,最重要的不是“能存 JSON”或“QPS 很高”,而是所有 写入者看到同一套不可违反的事实:

order total = sum(order item totals)
payment currency = order currency
inventory cannot be consumed below the approved boundary
status transition belongs to a finite allowed graph
duplicate request does not create a second business effect

第 4、10、13 章分别从约束、并发与数据库逻辑证明了这些能力。只要这类状态 仍由 PostgreSQL 定夺,缓存、搜索索引、事件流和湖仓都只能是派生物。

“source of truth” 容易变成口号,更精确的写法是按数据域声明 authority:

product business state          -> PostgreSQL
cache entry bytes               -> cache
order publication intent        -> PostgreSQL outbox
message delivery/replay log     -> event bus
media object bytes              -> object storage
media identity/state/checksum   -> PostgreSQL
analytical projection           -> lakehouse
operational business state      -> PostgreSQL

冲突时相信谁,必须在发生冲突前写清楚。

检索是一组能力,不是一条 LIKE

商品检索至少可以分成:

exact identity lookup
prefix / substring
typo-tolerant fuzzy match
lexical relevance
phrase / field weighting
semantic similarity
filter + rank
faceting / aggregation
highlighting
freshness and deletion

第 15 章的质量 golden 证明 pg_trgm 适合当前模糊检索基线,vector 适合 受控 pilot。它没有把“搜索”宣布为永久留在 PostgreSQL。蓝图保存一个明确 触发器:

当质量、规模、语言能力或独立可用性目标击败 PostgreSQL 基线时,才启用 external-search-projection-v1

这样,外部检索是一个由证据触发、可重建、可退出的投影,而不是架构图里一 开始就存在的时髦方框。

时空能力先统一语义,再比较引擎

空间系统最危险的错误往往不是查询慢,而是答案看起来合理:

SRID 混用
经纬度顺序颠倒
边界包含规则不一致
event time 与 ingest time 混淆
本地时区被当 UTC
有效时间区间重叠
迟到事件被静默丢弃

第 16 章把 EPSG:4326、UTC、区间与边界规则固化为 fixture。PostGIS 让空间 类型、操作符与索引进入 SQL,但业务合同仍然高于扩展:迁移到其他引擎时, 这些语义必须不变。

因此 capability 的结构至少要包含:

{
  "placement": "postgresql",
  "system_of_record": "postgresql",
  "state": "conditional",
  "consistency": "UTC + versioned validity + EPSG:4326",
  "freshness": "event ingest plus measured lateness",
  "externalization_trigger": "retention or throughput misses objective"
}

分析能力先减少工作,再横向扩展

第 17 章已经建立顺序:

正确性 golden
  -> 计划与统计
  -> 索引 / BRIN
  -> 并行
  -> spill 证据
  -> 汇总
  -> OLTP/OLAP 隔离
  -> 分布式门槛

当前蓝图选择“本地汇总 + offline replica”作为第一步,而不是选择 Citus。 这不是永久拒绝分布式;第 26 章的容量证据可以触发新 ADR。

同理,postgres_fdw 的 loopback fixture 只证明过滤、聚合和部分失败的 查询形状。PostgreSQL 官方 Foreign Data 说明外表通过 wrapper 访问外部数据,并通过 user mapping 提供认证信息。 本书实验使用的 password_required=false 没有生产身份、网络与故障域, 所以严格标为 lab-only

任务能力要分事务内与事务外

“数据库里能执行函数”不等于“任何业务任务都应放进事务”。一个实用边界:

留在事务内:

约束检查
小而有界的派生值
幂等状态转换
短路径的 outbox 写入
与当前事务必须原子完成的审计事实

移到事务外:

HTTP / RPC
发送邮件或短信
大批量重算
不确定时长的模型推理
跨系统补偿流程
无限或长期重试

事务内代码失败可以回滚;远端世界通常不能跟着 PostgreSQL 回滚。把两者硬塞 在一起,会产生持锁时间、连接占用、重试重复和不可控级联。

能力清单必须有状态

本章采用以下生命周期词汇:

状态 含义
accepted 已有当前范围证据,仍受版本与服务门约束
accepted-with-scope 只在明确狭窄边界内接受
accepted-first-step 当前首选,但容量触发器仍待观察
conditional 前置门尚未全部通过
pilot 只允许受控试验,不进入默认生产服务
lab-only 教学机制证据,禁止生产准入
deferred 触发条件未出现,不增加系统

没有状态的能力清单会把“听说过”“装得上”“试过一次”和“可值班”放在同一 列,最终无法治理。

18.1.2 计算、存储、接入、控制与观察平面

能力回答“要做什么”,平面回答“由哪类机制完成”。

数据与计算平面

数据/计算平面直接处理请求与业务状态:

PostgreSQL backend
tables / indexes / WAL-visible changes
SQL planner and executor
constraints / functions / triggers
extension types and operators
replica read execution
external projection workers

它的典型证据是:

  • SQL 结果和业务校验和;
  • 执行计划与实际行数;
  • 事务、锁与 SQLSTATE;
  • replica replay 位置;
  • 投影 watermark。

在这个平面上,“成功”意味着某次业务操作按合同完成,不代表平台整体健康。

存储平面

存储平面不仅是 PGDATA

存储 保存什么 关键风险
PostgreSQL data files heap、index、catalog 损坏、容量、延迟
WAL 恢复与复制所需变化 归档缺口、保留爆炸
backup repository base/diff/incr 与 WAL 不可恢复、凭据、同域失效
temp sort/hash 等中间结果 磁盘耗尽、I/O 争用
object storage 媒体或备份对象 清单、版本、删除
cache 可丢弃派生数据 陈旧、穿透、错误权威
analytical/search index 可重建投影 lag、语义漂移、删除遗漏

“数据有三份”不是恢复策略。三份流复制副本会复制同一条误删;三个位于同一 存储故障域的节点也不是三个独立副本。第 21、32、35 章会分别验证备份恢复、 PITR 与损坏取证。

接入平面

接入平面把服务语义变成客户端可连接的端点:

name / VIP / address
port
TLS and authentication
pooling mode
target selector
health check
failover routing
connection and queue budget
session-state contract

一个主库 IP 只是位置,不是服务。客户端需要知道:

这个入口是否可写
是否允许陈旧读
是否 read-your-writes
发生切换时连接怎样断开
事务池能否使用 session feature
取消与超时如何传播

Pigsty 的 Service Access 用端口和 selector 表达 primaryreplicadefaultoffline 等服务。 第 22 章会把这些入口与 PgBouncer 语义、连接预算一起验收。

控制平面

控制平面改变系统的期望状态:

inventory and configuration
package and extension versions
cluster membership
leader election
switchover/failover decision
backup schedule and retention
role / database provisioning
change rollout and rollback

控制平面故障与数据平面故障不同。数据库可以继续处理流量,而 inventory 已经 漂移;也可能数据库本身健康,但错误的健康检查把流量切走。

控制平面必须回答:

  • 谁能改;
  • 改什么对象;
  • 如何审阅;
  • 是否幂等;
  • 如何观察收敛;
  • 哪一步是最后可逆点;
  • 失败时由谁接管。

Ansible 成功退出只说明某次自动化执行没有报告失败;它不是业务 SLO 证据。

观察平面

观察平面收集并解释:

metrics
logs
traces
catalog snapshots
query fingerprints and plans
backup manifests
configuration drift
synthetic probes
business invariants

观察平面不应只回答“图绿不绿”,还要让值班者完成因果链:

用户症状
  -> 受影响的服务入口
  -> 当前拓扑与流量目标
  -> 数据库等待/资源/复制/恢复状态
  -> 最近变更
  -> 可逆且风险最小的动作

第 25 章会验证信号到 runbook 的连接。本章只列出必须覆盖的八类信号,不声称 告警已经有效。

管理平面与业务平面不要共用身份

一个常见反模式:

application connection
  = object owner
  = extension installer
  = backup operator
  = cluster administrator

本书 fixture 已把角色分开:

身份 当前边界
pg36_app LOGIN、非 superuser、运行时
pg36_owner NOLOGIN、对象所有者
postgres 本地正式实验管理员

这还不是完整生产角色模型。第 23 章要继续拆出迁移、监控、备份、复制、审计 与应急身份。

一项能力会穿过多个平面

以“商品模糊搜索”为例:

data/compute  pg_trgm operator + GIN/GiST plan
storage       product table, index, WAL, backup
access        read endpoint, role, timeout
control       extension package/version, CREATE EXTENSION, schema
observe       latency, quality golden, index build, bloat, errors
governance    owner, lifecycle, upgrade and exit

只验证 SQL 正确,会漏掉四个平面;只部署组件,则连第一项也未必正确。

18.1.3 组件组合必须有统一服务目标

局部健康不推出整体健康

假设请求路径是:

client
  -> DNS/VIP
  -> HAProxy
  -> PgBouncer
  -> PostgreSQL primary
  -> transaction
  -> outbox
  -> relay
  -> event bus
  -> consumer

每个组件都有自己的“up”,但业务关心的是:

订单是否只创建一次
提交后多久可查询
事件多久送达
失败后是否可安全重试
是否会丢、重、乱序
能否在目标时间内恢复

如果数据库 20ms 提交、relay 卡 40 分钟,订单 API 的数据库延迟指标仍然很 漂亮,业务事件服务却已经违约。

先定义服务,再分配组件目标

一个服务目标模板:

维度 示例
用户动作 创建订单
成功定义 返回稳定 order_id,状态可读,重复请求无第二次效果
延迟 API P95/P99
正确性 约束、金额、状态、幂等全部成立
可用性 测量窗口、排除项、错误预算
新鲜度 提交后 read-your-writes;事件 publish lag
持久性 故障模型内的 RPO
恢复 场景化 RTO,不只“启动成功”
容量 峰值并发、增长与余量
安全 身份、租户、数据分类
成本 服务单位成本与预算

组件指标从这个目标推导。不要反过来因为某仪表盘有一个指标,就把它升格为 服务 SLI。

可用性相乘只是一种初步直觉

若一条同步路径必须经过多个独立组件,在非常简化的独立假设下:

Apath=i=1nAi A_{path} = \prod_{i=1}^{n} A_i

三个各 99.9% 的串行依赖约为:

0.999399.7003% 0.999^3 \approx 99.7003\%

这提醒我们“组件都三个九”并不保证路径三个九。但不能把这个公式当精确生产 模型,因为:

  • 故障往往相关,共享网络/电源/配置/身份;
  • 重试、缓存和降级会改变路径;
  • 读写操作依赖不同;
  • 维护与区域故障不是独立伯努利事件;
  • 业务正确性失败可能不表现为组件 down。

真正的目标要通过故障模型、演练和真实 SLI 验证。

新鲜度预算也会叠加

派生投影可能经历:

transaction commit
  -> outbox polling
  -> broker publish
  -> consumer queue
  -> indexing
  -> alias visibility

总 lag 不是只看 broker lag。每一段要有时间戳或位置:

阶段 证据
source commit commit LSN / business version
publication intent outbox timestamp
broker partition/offset
consumer acknowledged source identity
projection applied version / watermark
query served generation

没有共同 identity,端到端新鲜度无法对账。

正确性优先于可用性包装

“失败时返回旧缓存”可能提高响应可用性,也可能把已取消订单重新显示为有效。 降级必须按数据和操作分类:

public product description
  可允许有标签的短时陈旧

inventory availability
  陈旧读可能导致超卖,不能默认降级

payment state
  不能用缓存猜测

analytics dashboard
  可显示 last complete watermark

同一个缓存组件不能用一个统一 fallback 策略覆盖所有字段。

SLO 目标必须与证据状态绑定

本章目录中的:

"availability_target": "99.9%-proposal"

故意带有 -proposal。要去掉它,至少要经过:

ch19 environment baseline
ch20 HA failure model and drills
ch21 restore evidence
ch22 endpoint and connection behavior
ch23 security controls
ch24 approved SLI/SLO and ownership
ch25 signal and alert exercise
ch26 representative capacity

一项数字若没有:

measurement
window
population
exclusions
owner
alert
runbook
evidence retention

它只是愿望。

统一目标不等于统一部署

服务目标统一,是为了让组件协作;并不要求把组件放在同一主机、同一进程或 同一团队。

反过来,部署在同一 PostgreSQL cluster 也不自动意味着目标相同:

  • 交易写入需要 read-your-writes;
  • 离线分析允许 60 秒 lag;
  • 备份任务关注可恢复性;
  • 搜索 pilot 关注质量与构建时间。

平台要把这些 workload class 显式分开,并为冲突设优先级。

用一个“合同—证据—动作”闭环

每项服务能力最终应形成:

contract
  owner + semantics + objectives + limits

evidence
  SQL/catalog/metric/log/drill/manifest

decision
  accepted / conditional / pilot / rejected

action
  deploy / isolate / tune / externalize / rollback

review trigger
  version / scale / incident / objective / ownership change

本章的四份 JSON 和一个 ADR 正是在演示这个闭环。它们的价值不在文件格式, 而在于架构结论可以被程序拒绝、被证据更新,也可以在条件变化时退出。


返回本章目录 · 下一节:PostgreSQL 的强项与代价 · 查看全书目录 · 查看索引中心

18.2 PostgreSQL 的强项与代价

PostgreSQL 最容易被两种叙事误读:

“只是一个传统关系数据库”
“装上扩展就能替代所有数据系统”

前者低估它,后者透支它。本节把收益与代价放在同一张账上。

18.2.1 关系一致性、可扩展类型与统一查询

关系模型把错误状态变成不可提交状态

应用校验常写成:

read current state
if valid:
    write new state

当有多个写入者、并发事务、批处理和人工运维时,这段逻辑很容易被绕过。 PostgreSQL 的独特价值不是“也能做校验”,而是把很多不变量放在最终提交 边界:

PRIMARY KEY
UNIQUE
NOT NULL
CHECK
FOREIGN KEY
EXCLUDE
transaction isolation
row and table locks

不合法状态不只是“应用不建议写”,而是任何没有绕过权限边界的写入者都不能 提交。

第 4 章模型的关系校验和:

f8a7bfae59c6d16cd323abecfefe1014

第 18 章每轮只读审计都会重新计算。它不是通用完整性证明,但把平台蓝图锚定 到一份确定的业务数据,而不是空白数据库。

一致性靠多个层次共同完成

“用事务”仍然太笼统。一个可靠模型通常组合:

层次 适合表达
type/domain 单值表示和基本范围
column constraint 必填、局部规则
row CHECK 同一行字段关系
unique/exclusion 跨行唯一或不重叠
foreign key 引用存在与生命周期
transaction 多对象原子变更
lock/isolation 并发可见性与冲突
function/trigger SQL 约束难以表达的短原子规则
application workflow 远端调用、长流程、人机审批

越靠近数据的规则覆盖写入路径越广,但也越应短小、稳定、可解释。第 13 章 保留 accepted-with-scope,就是防止把业务编排全部塞进触发器。

类型不是列上的装饰

PostgreSQL 的类型参与:

storage representation
input/output validation
operator resolution
comparison and ordering
index operator class
planner statistics
function dispatch
wire protocol encoding

所以 timestamptz、range、jsonbvector 和 PostGIS geometry 不只是 不同的文本格式。类型、操作符和索引方法共同决定什么语义可以被查询与加速。

PostgreSQL 官方 Extending SQL 把数据类型、函数、聚合、操作符和索引操作符类都列为扩展点。这使一个扩展 能够进入规划器和执行器,而不只是作为应用旁边的黑盒服务。

第 16 章的:

ST_Intersects(...)
valid_during && ...
EXCLUDE USING gist (...)

之所以能与普通关系条件组合,正是因为空间与 range 语义进入了 PostgreSQL 的类型和索引体系。

统一查询减少一致性缝隙

如果订单、商品、空间和搜索投影都在同一事务边界,应用可以:

SELECT ...
FROM order
JOIN product ...
WHERE tenant_id = $1
  AND search_condition
  AND spatial_condition;

潜在收益:

  • 一个快照;
  • 一套权限;
  • 一个查询计划;
  • 一次网络往返;
  • 一个可解释的事务边界;
  • 少一条跨系统同步链;
  • 少一套重试、对账和删除流程。

这不是说“一条大 SQL 总是最好”,而是跨系统拆分具有固定税:

serialization
network
partial failure
duplicate delivery
ordering
freshness
identity mapping
observability correlation
rebuild
deletion

只有当拆分收益超过这组税,外置才有充分理由。

MVCC 让读写共存,但不消除冲突

PostgreSQL 的多版本并发控制让读者通常不阻塞普通写者,事务按快照看数据。 这使 OLTP、报表和维护可以在同一引擎协作。

但 MVCC 并不表示:

  • 长事务没有代价;
  • DDL 不需要重锁;
  • 所有隔离级别结果相同;
  • 副本读没有延迟;
  • dead tuple 会自动即时消失;
  • 写写冲突不需要处理。

统一引擎减少系统间一致性缝隙,内部仍要管理锁、快照、vacuum、序列化失败与 重试。这些成本在第 10、28、34 章分别展开。

统一系统目录让证据可查询

PostgreSQL 对象不是散落在配置文件中的传说。可以从 catalog 读取:

pg_class
pg_namespace
pg_proc
pg_type
pg_extension
pg_depend
pg_roles
pg_constraint
pg_index
pg_stat_*

本章 extension-catalog.sql 固定扩展名、版本、schema、owner、relocatable 与 comment; schema-catalog.sql 固定教学 schema 的 owner、marker 和对象计数。

这种自描述能力让平台可以做:

inventory
drift detection
privilege audit
upgrade preflight
backup/restore validation
extension ownership review

但 catalog snapshot 只说明数据库内部状态;操作系统 package、共享库和各 副本节点仍需要主机层 inventory。

可移植性要按层讨论

“SQL 标准”不能概括可移植性。至少分:

可能的锁定
schema/SQL PostgreSQL 语法、函数、行为差异
types range、array、JSONB、vector、geometry
indexes GIN/GiST/BRIN/opclass
routines PL/pgSQL 与 trigger
extensions binary/package/version
operations backup、replication、HA、monitoring
semantics collation、time zone、SRID、isolation

有价值的 PostgreSQL 特性不必因为“可能锁定”就不用;应为高锁定能力写 export、 restore、替代与退出测试。锁定被管理,和假装不存在,是两种完全不同的架构。

18.2.2 通用性带来的资源竞争与维护责任

同一进程体系,共享多种稀缺资源

当交易、搜索、空间和分析都进入 PostgreSQL,它们共享:

CPU cores
shared buffer cache
OS page cache
memory address space
storage latency and bandwidth
WAL pipeline
checkpoint budget
background workers
connection slots
locks and snapshots
autovacuum workers
backup and replication bandwidth

“查询彼此不锁”只覆盖其中一类竞争。

内存预算会按节点、worker 和并发放大

PostgreSQL 18 官方 Resource Consumption 说明 work_mem 是一个查询操作开始写临时文件前的基础内存限制;复杂查询可 同时有多个 sort/hash 操作,多个会话也会并行运行。

因此粗略风险模型不是:

memory=work_mem memory = work\_mem

而更接近:

memoryworksessions×operations×workers×effective_per_operation memory_{work} \approx sessions \times operations \times workers \times effective\_per\_operation

它不是精确容量公式,但足以拒绝:

“这一条查询 32 MB 不 spill,
 所以全局 work_mem 设成 32 MB。”

第 17 章的单查询反例只证明两种执行路径,不授权全局参数变更。第 27 章必须 在真实并发预算下做单变量实验。

CPU 并行会把延迟问题变成吞吐问题

并行查询可缩短一个聚合,但 worker 不是免费核心:

one query × 3 processes
ten queries × 3 processes
autovacuum + backup compression + replication

当机器已经饱和,更多并行可能让每条查询和系统总吞吐都变差。平台要同时看:

  • 单查询 latency;
  • 总吞吐;
  • runnable queue;
  • worker 是否实际获得;
  • 交易查询 tail latency;
  • background maintenance 债务。

搜索与空间索引有写放大和生命周期

一个索引的成本不仅是磁盘:

insert/update CPU
WAL volume
cache footprint
vacuum work
backup size
replica replay
build/rebuild window
statistics
upgrade compatibility

GIN、GiST、HNSW、BRIN 与 B-tree 的目标和代价不同。给每个新查询“加个索引” 可能把读延迟转移成写入、恢复与维护事故。

服务目录因此按 extension bundle 准入,而不是允许每个租户自由安装任意扩展。

WAL 是统一持久性的收益,也是共享压力面

在一个 PostgreSQL cluster 内,许多变更进入同一 WAL/复制/归档链。收益是 恢复语义统一;代价是:

  • 大批量索引构建可能推动 WAL;
  • 分析汇总刷新可能影响 replica lag;
  • 一个归档故障会积压整个 cluster;
  • logical slot 可能阻止 WAL 回收;
  • 恢复需要理解所有扩展对象。

把某能力外置也不会让代价消失,只会把它变成跨系统日志与对账。选择应比较 两边完整成本。

长快照会制造维护债务

慢报表在 primary 上运行时,即使是只读,也可能:

延长旧 tuple 可见需求
阻碍 vacuum 清理
增加表/索引膨胀
与 DDL 锁冲突
占用连接和内存
挤压 cache

offline replica 可以隔离一部分 CPU/I/O 与连接,但 replica 上的长查询可能 与 WAL replay 冲突,导致查询取消或 replay 延迟。它是新的合同,不是免费 读扩容。

一个 cluster 的爆炸半径必须被命名

共享 cluster 中,以下事件可能影响多个 database:

server crash
shared_preload_libraries error
disk full
WAL/archive failure
OS/package upgrade
major version upgrade
superuser mistake
host/network failure
HA control-plane error
backup repository problem

schema 隔离挡不住这些;database 隔离也挡不住 cluster 级失败。只有在信任、 性能、升级、恢复或故障影响需要时,才上升到 instance/cluster 隔离。

维护责任不会被“开源免费”消除

每一项能力都要有人承担:

version selection
security advisories
package availability
configuration
monitoring
capacity
backup/restore
replication
upgrade
incident response
deprecation
data export

许可证成本为零,运行责任仍然存在。一个无人负责的扩展,比一个功能较少但 有人值班的基线更危险。

用资源账本评估组合

对每项 workload 建议记录:

峰值 隔离/配额 超限动作
active connections 待测 pool + role limit queue/reject
CPU 待测 service/host class shed/defer
work memory 待测 role/session policy spill/cancel
temp bytes 待测 temp_file_limit fail query
statement time 待测 workload timeout cancel
storage growth 待测 forecast/alarm expand/archive
WAL rate 待测 capacity/repository throttle/fix
replica lag 待测 endpoint freshness remove target
maintenance debt 待测 vacuum window remediate

未知值要写 unknown,然后由第 25–28 章补证据;不要填一个未经测量的舒服 数字。

18.2.3 扩展能力不自动等于生产就绪

CREATE EXTENSION 实际做了什么

PostgreSQL 18 官方 CREATE EXTENSION 说明,命令根据 control 与 SQL 脚本创建函数、类型、操作符、索引支持方法等 对象,并在 catalog 中记录它们的归属。

它还明确提醒:

  • 支持文件必须先安装在 server;
  • 某些扩展需要 superuser;
  • 安装脚本本身属于信任边界;
  • 不安全 search_path/可写 schema 可能带来风险;
  • IF NOT EXISTS 不保证现有同名扩展就是期望对象。

因此:

available != installed
installed != configured
configured != validated
validated in dev != admitted in production
admitted != permanently supported

扩展准入的九道门

需要回答
来源 包来自哪里,如何验证与更新?
版本 PostgreSQL major/minor、扩展版本是否钉住?
节点一致 primary、replica、恢复目标都有相同 binary?
安装安全 trusted/superuser、schema、owner、search_path
配置 是否需要 preload、GUC、worker、restart?
数据行为 类型、索引、collation、序列化语义是否固定?
运行成本 CPU、内存、WAL、vacuum、存储与构建窗口?
恢复升级 dump/physical restore/replica/PITR/major upgrade?
退出 如何导出、降级、替代、删除?

任一关键门为 unknown,生命周期就不能写 accepted

relocatable 只回答 schema 迁移的一小部分

本章 catalog 会看到:

pg_trgm      relocatable=true
vector       relocatable=true
btree_gist   relocatable=true
postgres_fdw relocatable=true
postgis      relocatable=false
plpgsql      relocatable=false

extrelocatable=true 只表示扩展控制文件允许改变其对象所在 schema。它不说明:

  • data portable;
  • binary cross-version compatible;
  • replica package 已存在;
  • upgrade 可回滚;
  • 对象 owner 安全;
  • 业务语义不变。

不要从一个 catalog 布尔值推导整个生命周期。

preload 失败可能阻止实例启动

一些扩展需要 shared_preload_libraries。Pigsty 4.5 的 Extension Config 说明可用 pg_libspg_parameters 声明 preload 与参数,并提醒 preload 库缺失或加载失败可阻止 PostgreSQL 启动,修改 preload 还需要重启。

这把扩展从 database 范围提升到 instance 范围:

一个数据库想用
  -> 每个节点需要 package
  -> instance startup config 改变
  -> rolling restart / HA 行为
  -> 整个 cluster 的故障风险

所以多租户平台不能允许任意 database owner 自助改变 preload。

本书当前扩展账本

只读实验在 PostgreSQL 18.6 上固定:

扩展 版本 schema owner 生命周期
pg_trgm 1.6 shop_ch14 pg36_owner accepted
vector 0.8.4 shop_ch14 postgres pilot
btree_gist 1.8 shop_ch16_ext pg36_owner conditional
postgis 3.6.4 shop_ch16_ext postgres conditional
postgres_fdw 1.2 shop_ch17_ext postgres lab-only
plpgsql 1.0 pg_catalog postgres core

版本表是 fixture 事实,不是对所有 PostgreSQL 18 环境的要求。平台应按自己的 软件仓库与升级策略重新验收。

owner 差异是需要解释的证据

为什么部分扩展 owner 是 postgres,部分是 pg36_owner

  • trusted 与非 trusted 安装权限不同;
  • 扩展脚本可能创建需要高级权限的对象;
  • extension object owner 与内部对象 owner 可能不同;
  • dump/restore 与后续 upgrade 会使用这些身份。

本章只记录现状。第 23、30 章要决定生产 owner 模型,并证明升级和恢复不依赖 一个无人管理的超级用户流程。

物理复制不等于安装包复制

physical replica 会复制 data files 和 catalog 状态,不会替你把共享库包安装 到新主机。若 primary catalog 依赖某 .so,目标节点缺包,相关查询会失败; 若该库需要 preload,实例启动也会失败;逻辑恢复和升主后的业务验收同样无法 通过。

因此节点准入应核对:

OS/repository identity
PostgreSQL package/version
extension package/version
shared library presence
control and SQL update paths
preload order
catalog extversion

第 19 章负责基线,第 30 章负责升级顺序。

备份成功不是扩展恢复成功

要验收扩展恢复,至少在隔离空目标中:

  1. 安装目标 PostgreSQL 与确切扩展 package;
  2. 恢复 base backup / WAL 或 logical dump;
  3. 验证 pg_extension、types、operators、indexes 与 dependencies;
  4. 运行该扩展的业务 golden;
  5. 验证 replica 与应用权限;
  6. 保存 manifest 和校验和。

只看 pgBackRest 命令 exit 0,不能证明 PostGIS geometry、vector index 或 自定义 opclass 可用。

用 bundle 控制组合,而不是逐扩展放任

本章服务目录定义:

core
search-accepted
vector-pilot
spatiotemporal-conditional
federation-lab

offering 引用 bundle,bundle 有生命周期与 gate。这带来三个好处:

  • 同一组相互依赖的 package/config 一起审阅;
  • 服务等级明确允许什么;
  • 升级与退出有完整影响面。

例如 pg-ha-standard 默认不允许 federation-labvector-pilot 只能走 exception;这比“机器上有包,所以谁都能 CREATE”更可治理。

反例:把实验 FDW 变成生产

第 17 章为了在本地 loopback 无密码访问,明确写了:

{
  "password_required_false": true,
  "production_permitted": false
}

第 18 章的负例把 production_permitted 改成 true,validator 必须报:

E_FDW_LAB_ONLY

这个反例传达一种重要写作纪律:实验里为了隔离机制而采用的简化,必须被机器 可见地阻止进入生产蓝图,而不是靠读者记得某段警告。

准入是可撤销决定

即使已经 accepted,也需要 review trigger:

PostgreSQL major/minor change
extension version or package source change
security advisory
workload/scale change
restore or upgrade rehearsal failure
incident
owner/support change
upstream abandonment

生产就绪不是一次性勋章,而是一项持续有证据支持的状态。


上一节:从数据库产品到能力组合 · 返回本章目录 · 下一节:明确替代边界 · 查看全书目录 · 查看索引中心

18.3 明确替代边界

替代边界不是“PostgreSQL 对阵某产品”的功能打勾表。真正的决策单位是:

某一类数据
在某一类操作下
需要某种一致性/时效/规模/故障语义
由某个团队承担

同一个系统可以对一类数据是权威,对另一类数据只是派生副本。

18.3.1 缓存、消息、对象存储与离线湖仓

缓存解决重复读取,不解决权威

缓存适合:

结果计算或读取昂贵
值允许在有限时间内陈旧
miss 可以回到权威
entry 可丢弃并重建
容量与淘汰可接受

缓存不适合被默认为:

库存真相
支付状态真相
唯一事件日志
不可恢复业务数据
跨字段事务约束

本章 product-cache-v1 的 authority:

{
  "product_business_state": "postgresql",
  "cache_entries": "cache"
}

这不是文字游戏。假如缓存中 product.version=8,PostgreSQL 已是 version=10,规则必须保证版本 8 永远不能覆盖版本 10。

cache-aside 的完整状态机

常见读路径:

read cache
  hit -> verify acceptable version/freshness -> return
  miss -> bounded read from PostgreSQL
       -> write versioned cache entry
       -> return

写路径不能只写成“更新 DB 后删缓存”。必须考虑:

DB commit 成功,删除 cache 失败
cache 删除成功,DB transaction 回滚
两个写并发,旧 invalidation 后到
TTL 前后请求同时回源
热点 key 穿透
删除后的旧值复活
cache 整体不可达

因此合同包含:

字段 product-cache-v1
ordering source version 单调,旧版本不覆盖新版本
idempotency product_id + source version
rebuild 丢弃 namespace,从 PostgreSQL 重建
failure miss/outage 回退到有界 PostgreSQL 读
reconciliation 抽样 version + payload checksum
deletion tombstone 或 version bump
exit 关闭 cache 路由并删除派生 namespace

若无法丢弃 namespace,缓存已经悄悄变成了数据库。

消息系统拥有投递,不拥有原事务

事件总线擅长:

异步解耦
一对多消费
保留与重放
按 partition 排序
消费者独立进度
流式处理

但“先更新数据库,再发送消息”有经典双写:

DB commit, publish fail   -> 状态变了,没有事件
publish success, DB fail  -> 有事件,没有状态
retry publish             -> 重复事件

order-events-v1 选择 transactional outbox:

one PostgreSQL transaction
  business state change
  publication intent with stable event_id

external relay
  read pending outbox
  publish at least once
  mark/record progress

原子边界只到 outbox。外部投递是 at-least-once,消费者必须幂等。

“at-least-once”要写清谁至少一次

这几个语义不同:

relay may publish duplicate
broker may redeliver
consumer may process then crash before ack
business side effect may be non-idempotent

稳定 event_id 是起点,不是终点。消费者需要把“已处理 identity”与业务 效果放在同一原子边界,或使用业务幂等键。

全局顺序通常也不应承诺。本章只承诺:

partition by order_id
no global order

否则团队会为一个业务不需要的全序付出吞吐与可用性成本。

对象存储拥有大字节,数据库拥有元数据状态

把图片、视频和归档大对象直接塞入 PostgreSQL 并非绝对错误,但常见代价:

database/backup/WAL volume
cache 污染
复制与恢复时间
CDN/分段下载能力
对象生命周期与访问方式

product-media-v1 采用逐域 authority:

object bytes                         -> object storage
object identity / owner / state /
size / checksum / active generation -> PostgreSQL

可靠上传状态机可以是:

reserve metadata row (uploading)
  -> upload immutable object version
  -> verify size + checksum
  -> finalize metadata (ready)
  -> expose signed access

失败处理:

orphan object   -> quarantine + reconciliation
ready row but missing bytes -> do not return success
retry upload    -> same object_id + checksum
replace media   -> new immutable generation, then switch metadata
delete          -> tombstone, retention-aware object deletion

这里不存在跨 PostgreSQL 与对象存储的传统 ACID transaction,所以显式状态、 幂等与对账比“调用顺序看起来正确”重要。

湖仓拥有分析投影,不拥有在线业务解释

离线湖仓适合:

超长保留
列式大扫描
批处理与多引擎消费
历史快照
低成本冷数据
复杂数据科学管线

analytics-export-v1 的权威拆分:

operational business state -> PostgreSQL
analytical projection      -> lakehouse

每个数据集必须显示:

dataset generation
schema/version
source snapshot identity
source commit position or watermark
complete/incomplete state
freshness
reconciliation result

当 CDC 断裂时,正确行为不是悄悄继续展示旧数据,而是标记 last complete watermark,并根据合同停止或降级消费者。

snapshot + change stream 的一致性接缝

建立新投影通常需要:

take consistent snapshot at position P
load snapshot
consume changes after P
deduplicate/order by source identity
catch up
publish generation

如果 snapshot 与 stream 之间没有稳定接缝,会漏掉或重复一段变化。第 29 章 会把它写成迁移状态机并验收。

外置并不自动降低数据库压力

反例:

  • cache miss storm 反而打爆 primary;
  • CDC slot 积压使 WAL 无法回收;
  • 搜索全量重建持续扫描并推高 I/O;
  • 湖仓导出占据 replica 和网络;
  • object reconciliation 每晚做无界全表 join;
  • outbox relay 用无索引轮询。

每个外置系统都要预算它对 PostgreSQL 的读取、WAL、连接与保留压力。

18.3.2 超大规模检索、流处理与专用分析

“超大规模”必须换成阈值

以下句子都不能触发架构:

以后数据很多
搜索会很复杂
实时要求很高
分析师越来越多
行业都这么做

需要换成可测量的问题:

indexed documents / vectors / bytes
ingest/update/delete rate
query mix and concurrency
P50/P95/P99 latency
quality metrics
language/analyzer needs
faceting/aggregation shape
retention
rebuild time
failure-domain objective
unit cost
team operating capacity

当前值可以是 unknown,但触发器不能是形容词。

外部检索的真正触发器

PostgreSQL 内搜索常在这些条件下很有优势:

  • 数据与交易状态同库,提交即搜索;
  • 过滤与关系 join 很重要;
  • 规模在单 cluster 预算内;
  • 搜索功能较集中;
  • 团队不想承担额外索引同步系统;
  • 正确性与删除要求高。

专用检索可能在以下证据出现后更合适:

语言分析/相关性能力缺口
索引规模和构建时间越界
高扇出 facet/aggregation 压垮 OLTP
独立扩缩容或故障域是硬目标
多源统一搜索成为主需求
ANN 质量/规模/延迟无法满足
搜索团队能承担独立服务

切换前必须用同一 query set 与 relevance golden 比较,不能只比一条延迟。

搜索外置仍要保留 PostgreSQL fallback 分类

不是所有查询都能回退:

查询类 外部搜索故障时
exact product ID 直接 PostgreSQL
简单关键词 可按容量回退 PostgreSQL
复杂 facet 返回有标签旧 generation 或失败
semantic ANN 可能没有等价 fallback
admin reconciliation 延后,不影响交易

fallback 本身也需要容量演练。平时 1% 回源的 primary,未必承受 100% 搜索 流量。

流处理适合持续状态与事件时间计算

PostgreSQL + outbox + worker 可以完成大量异步任务。当需求升级为:

高吞吐多分区事件
大量独立 consumer
长保留重放
event-time window
watermark / late data
stateful join
continuous aggregation
backpressure

专用流平台可能更合适。

但流系统不会自动给出业务 exactly-once。即使框架内部有 exactly-once checkpoint,外部数据库、HTTP 副作用和人工操作仍需幂等与对账。应写:

source delivery semantics
processing state semantics
sink effect semantics
recovery/replay semantics

而不是笼统宣布“端到端 exactly once”。

专用分析的触发器是工作负载边界

单节点或 offline replica 先做:

better model and grain
statistics
index/BRIN
partition pruning
parallelism
summary/materialization
batching
resource isolation

当代表性证据仍显示:

  • scan/compute 越过单节点;
  • retention/压缩成本不可接受;
  • 并发与查询形状冲突;
  • 维护窗口不可满足;
  • OLTP 和分析无法充分隔离;
  • 横向扩展收益足以覆盖分布代价;

才比较 Citus、兼容分布式 PostgreSQL、列式引擎或湖仓。

第 17 章已经展示分布式新增问题:

distribution key
skew
co-location
pushdown
cross-shard transaction
global uniqueness
rebalance
partial failure
remote authentication
version compatibility

专用系统不是“更高级的 PostgreSQL 参数”,而是新的数据与故障模型。

查询兼容和业务兼容要分开

候选声称“PostgreSQL compatible”时,至少核对:

wire protocol
SQL grammar
type semantics
transaction/isolation
constraints
extensions
system catalogs
planner/explain
backup/restore
HA/failure behavior
driver/tooling
operational ownership

能连上 psql 只证明协议入口;能跑 SELECT 只证明一个查询子集。

用两道门防止过早与过晚拆分

进入外置评审:

measured problem
representative workload
PostgreSQL baseline already optimized appropriately
service objective still missed
candidate benefit is causal
owner and budget exist
data contract and exit path written

继续留在 PostgreSQL:

objective met with safe headroom
resource competition bounded
recovery/upgrade path passes
team can operate it
externalization tax exceeds measured benefit

门是双向的。不能因为过去留在 PostgreSQL,就永远拒绝新证据。

18.3.3 用数据所有权、时效和一致性决定分工

第一步:把名词换成数据域

不要问“谁是 source of truth”,问:

order business state?
payment settlement state?
publication intent?
message replay log?
product description?
search ranking projection?
media bytes?
media lifecycle metadata?
analytical dataset?
cache entry?

同一个订单事件流程可能有三个权威:

order state          PostgreSQL
publish intent       PostgreSQL outbox
delivery/replay log  event bus

它们不冲突,因为 authority 的数据域不同。

第二步:为每个读者写时效

“最终一致”没有时钟。改写为:

product cache TTL <= 300s, plus versioned invalidation
offline replica replay lag P99 <= proposed 60s
search indexing lag <= target before activation
analytics dataset labels its last complete watermark
order event publish P99 target set in chapter 24

还要说明超限:

serve stale with label
fallback to authority
reject request
remove replica from routing
stop dataset publication
page operator

第三步:把一致性写成可观察规则

示例:

cache version never replaces a newer PostgreSQL version
product deletion tombstone survives retry and generation swap
event_id stable across relay retry
consumer business effect deduplicated by event_id
analytics latest row selected by source commit position
object ready state requires matching immutable checksum

这些规则可以生成 negative test;“保持一致”无法测试。

第四步:定义失败矩阵

对每个跨系统合同列:

故障 写入结果 读取结果 重试 告警/对账
PostgreSQL down 是否拒绝 cache 是否可陈旧读 谁重试 哪个 owner
external sink down source 是否可提交 是否 fallback backlog 边界 lag
network partition 双方如何判定 是否可能分歧 幂等 identity reconciliation
projection corrupt source 不受影响 停止/旧 generation rebuild checksum
credential expired source 不受影响 外置路径失败 更新 secret auth alert

如果团队只讨论 happy path,外置系统只是把事故推迟到上线后设计。

第五步:写重建,而不只写备份

派生系统最强的恢复策略通常是从权威重建。但“可重建”必须证明:

source retains enough history
consistent snapshot can be taken
change position can be bridged
schema/version transformation is reproducible
capacity allows rebuild within objective
live changes do not get lost
quality/checksum validation exists
generation can be atomically published
old generation can be rolled back

若不能满足,它就不是“随时可丢的副本”。

第六步:删除是一等数据流

创建与更新容易被关注,删除经常遗漏:

cache TTL 让敏感字段继续存在
search rebuild 把已删文档复活
event replay 重新制造旧状态
lakehouse retained versions 留下受监管数据
object storage versioning 保留字节
backup retention 与删除政策冲突

每个合同必须有 deletion 字段,并与法律/业务保留策略对齐。删除不能简单理解 为对每个系统发一条 DELETE;它需要 tombstone、generation、审计和最终 验证。

第七步:退出路线与进入路线同时审批

一个可退出设计至少回答:

如何停止新写入/投影
如何 drain 消费者
最后 position/manifest 在哪里
客户端怎样切回或切到替代服务
如何验证没有遗漏
旧数据何时删除
凭据与资源何时回收
谁宣布完成

本章蓝图包含五条退出原则。validator 的反例把 exit_paths 清空时必须返回:

E_EXIT_PATH

一个通用决策表

问题 留在 PostgreSQL 倾向 外置倾向
是否需与业务写原子提交 需 outbox/saga
是否频繁关系 join/filter 需复制/反规范化
是否可容忍陈旧
是否可从权威重建 非必要 必须
是否需独立扩缩/故障域
单节点资源是否越界 可先优化/隔离 证据越界后强
专用语义是否关键 扩展可满足则强 缺口明确则强
团队是否能多系统值班 简化更强 能力充足才强
删除/对账是否成熟 简单 必须成熟
退出成本是否可接受 必须明示

表不会自动给出答案;它强迫评审把隐性成本说出来。

用合同 ID 连接能力

本章九项 capability 中,外置或可能外置的能力引用:

lexical search        -> external-search-projection-v1
semantic search       -> external-search-projection-v1
spatiotemporal        -> analytics-export-v1
operational analytics -> analytics-export-v1
product cache         -> product-cache-v1
order delivery        -> order-events-v1
media bytes           -> product-media-v1

引用让我们可以回答:

  • 某合同被哪些能力使用;
  • 某合同删除后哪些蓝图断裂;
  • 是否有无人引用的外置系统;
  • 是否所有外置能力都有 owner;
  • 外置触发器是否存在。

validate.py 会拒绝悬空引用,但业务评审仍要判断合同内容是否真实可行。

不选择具体产品也是有效决定

本章故意不为 cache、event bus、object storage、search 或 lakehouse 指定 品牌。原因不是逃避,而是先稳定更长寿的合同:

authority
delivery semantics
freshness
rebuild
security
exit

候选产品要在这些合同下比较。若先选产品,团队很容易把产品默认行为冒充业务 需求。

最终判断句式

一项成熟决定应能写成:

对数据域 X,由系统 A 保持权威;系统 B 以语义 Y 持有派生投影,目标新鲜度 为 Z,失败时按 F 退化,以 identity I 幂等并按 R 对账,可通过步骤 E 重建 和退出。只有触发器 T 出现且 gate G 通过后,B 才进入生产。

能写清这句话,才算划定边界。


上一节:PostgreSQL 的强项与代价 · 返回本章目录 · 下一节:平台服务目录与多租户 · 查看全书目录 · 查看索引中心

18.4 平台服务目录与多租户

如果消费者只能申请“一台 PostgreSQL”,平台交付的是资源,不是服务。

一个可用目录应该让消费者在申请前就知道:

得到什么
不保证什么
允许怎样使用
谁负责什么
怎样计量
何时复审
如何退出

18.4.1 服务等级、规格、版本与扩展套餐

offering 是完整合同,不是机器型号

本章的 service-catalog.json 为每个 offering 保存:

id / status / environment
service owner
consumer owner requirement
topology
service objectives
isolation
backup class
allowed and exception extension bundles
quotas
lifecycle

机器 CPU/内存/磁盘规格当然重要,但它只是实现 offering 的一种资源配置。 同一 pg-ha-standard 可以在不同硬件代际上实现;只要服务合同和经过验证的 容量仍然成立,消费者不应绑定某台主机。

pg-dev

定位:

development-test
single PostgreSQL instance
no HA commitment
explicit expiry
best effort

它允许较广的试验 extension bundle,包括 federation-lab。这不意味着开发 环境可以无治理:

  • 必须有 consumer owner;
  • 连接、存储、temp、statement time 要声明;
  • 必须有到期日;
  • 敏感生产数据不能因为“只是 dev”就复制进去;
  • 实验凭据不能进入 Git;
  • 删除仍需精确目标和恢复需求确认。

开发 offering 的目标是加快安全实验,不是变成永不下线的影子生产。

pg-ha-standard

这是 pg36_shop 的默认生产候选:

primary + two replicas
reviewed failure domains
dedicated database by default
dedicated cluster when risk gate requires
continuous WAL + full/differential backup class

目录中:

availability_target=99.9%-proposal
rpo_seconds=60
rto_minutes=30

这些是要进入第 20、21、24 章验证的目标。它们不能由“三节点”直接推出。

允许的 bundle:

core
search-accepted
spatiotemporal-conditional

vector-pilot 只能走 exception;federation-lab 不允许。

pg-ha-critical

critical 不是把 standard 的数字改得更漂亮。它意味着:

dedicated cluster
explicit synchronous policy
explicit zero-RPO failure-domain scope
hard connection/overload budget
storage includes failure + upgrade headroom
quarterly objective/owner review
architecture and risk approval

目录提出:

99.95%-proposal
RPO 0 in explicitly rehearsed failure domain
RTO 10 minutes

“RPO 0”必须带范围。同步副本若与 primary 共享电源/机房,不能自动覆盖整个 区域故障;若为可用性临时降级同步策略,也可能改变承诺。

pg-analytics-offline

它不是第二个 source of truth:

read-only dependent service
offline replica
separate analytical query SLO
freshness proposal 60 seconds
not read-your-writes
source cluster backup policy
bounded analytics pool

Pigsty 官方 Cluster / Instancepg_role: offline 定位为慢查询、ETL、OLAP 与交互分析专用 read-only replica。隔离的目的不是“让慢查询没人管”,而是不让它默认争用在线 replica。

规格要由容量曲线产生

不要先定义:

small=4c16g
medium=8c32g
large=16c64g

再假设业务会自然匹配。更可靠的顺序:

  1. 定义 workload class;
  2. 固定数据量、并发和增长;
  3. 测量容量曲线和饱和点;
  4. 选定安全 headroom;
  5. 映射到可采购/可部署资源;
  6. 定义升降配条件。

硬件 SKU 可以变化,基准 workload 与目标不应随意变化。

版本是一组矩阵

平台版本不只一个 postgresql=18

OS
kernel / filesystem
PostgreSQL major + minor
extension packages
Patroni
etcd
HAProxy
PgBouncer
pgBackRest
exporters / monitoring
Pigsty release
client drivers
locale / collation

兼容矩阵至少回答:

  • 哪组是当前支持;
  • 哪组是下一升级候选;
  • 哪组已弃用;
  • package repository 能否重建;
  • backup/restore/replica/upgrade 是否覆盖;
  • 安全补丁时限;
  • 谁批准例外。

extension bundle 是服务的一部分

bundle 不只是安装清单:

{
  "id": "vector-pilot",
  "status": "pilot",
  "extensions": [
    {
      "name": "vector",
      "version_policy": "0.8.4 on validated fixture",
      "lifecycle": "pilot"
    }
  ],
  "gates": [
    "model identity and dimension pinned",
    "exact and ANN quality pass",
    "backup, restore, replica, upgrade rehearsed"
  ]
}

offering 只引用已审阅 bundle,避免每个用户自行拼装无法升级的组合。

服务目标要分平台与消费者责任

平台可负责:

cluster availability
service routing
backup execution and restore capability
database engine/extension lifecycle
platform monitoring
capacity envelope
incident coordination

消费者仍负责:

SQL/schema quality
业务不变量
连接/重试/timeout
流量预测
数据分类
应用兼容测试
on-call contact
错误预算决策

责任边界要写 RACI,不能在事故时才争论“数据库没问题还是应用没问题”。

目录本身也需要版本与状态

本章目录:

release=1.6-proposal
status=proposal
review_cycle_days=90

当下卷 evidence 完成后,应产生新 release,而不是原地把历史 proposal 改成 “一直就是通过”。目录是决策记录的一部分。

18.4.2 数据库、模式、实例和集群隔离

四层隔离解决不同问题

层级 隔离了什么 没隔离什么
schema 名称、对象组织、部分权限 database GUC/连接、WAL、资源、故障
database catalog、连接入口、部分设置 instance CPU/I/O/WAL、superuser、升级
instance postmaster、内存、端口、WAL、配置 host/kernel/storage/network
cluster/service HA/恢复/生命周期边界 若共主机/控制平面仍可能相关

“tenant 一个 schema”与“tenant 一个 cluster”不只是成本不同,安全与爆炸半径 完全不同。

schema 隔离

适合:

同一信任域
同一生命周期
需要跨 schema 查询
共享 database 设置可接受
对象数量可控

风险:

search_path 注入
误授权 CREATE
同名对象解析
共享 connection/GUC
owner/superuser 可见
跨 schema 依赖
升级/删除耦合

安全做法:

  • 不给不可信运行时角色在公共解析路径中的 CREATE
  • 对安全定义函数固定安全 search_path
  • 使用显式 schema qualification;
  • 分离 NOLOGIN owner 与 LOGIN runtime;
  • 定期审计 ACL 与 dependencies。

database 隔离

适合:

同一 instance 中的中等信任边界
独立连接、schema/catalog、extension installation
不要求跨 database transaction/join
共享主机与实例故障可接受

优点是 namespace 和连接边界更清楚;代价是:

  • PostgreSQL 原生跨 database 查询不透明;
  • connection pool/监控/迁移对象增多;
  • extension 每 database 管理;
  • 仍共享 WAL、CPU、I/O、backup 与 major upgrade;
  • instance superuser 仍可越界。

database 不是强资源隔离。

instance 隔离

适合:

不同 preload/GUC/major
独立 restart/upgrade
强一些的内存/连接/WAL边界
不同维护窗口
高风险扩展或 workload

同一 host 上多个 instance 仍共享:

CPU
kernel
filesystem/storage
network
host administrator
power/failure

必须配 OS/cgroup/volume/port/backup 与监控隔离,否则只是多个 postmaster 抢 同一资源。

cluster 隔离

适合:

独立 HA and recovery objective
独立故障/升级窗口
高敏感或不互信 tenant
重 workload/noisy neighbor
特殊 extension/kernel
法规或数据驻留
独立成本归属

代价是节点、备份、监控、升级、值班和容量碎片化。不是所有小数据库都值得 一个三节点 cluster。

选择顺序从信任与故障开始

推荐问:

  1. tenant 是否互信?
  2. 数据分类是否允许共实例/共主机?
  3. 是否允许同一个 superuser/平台团队访问?
  4. workload 能否互相制造事故?
  5. 是否需要不同 PostgreSQL/extension/preload?
  6. 是否需要独立 failover/restore/upgrade?
  7. 跨 tenant 查询/事务是否必要?
  8. 成本和运营能力是否承受更高隔离?

安全硬边界优先于便利与成本。

多租户至少有三种模型

shared tables + tenant_id
  最省对象;需要强 tenant predicate/RLS 与索引设计

schema per tenant
  对象分开;迁移、对象数量、search_path 复杂

database/cluster per tenant
  隔离更强;运营和容量成本更高

也可以混合:

small trusted tenants -> shared
larger tenants        -> database
regulated/high-risk   -> dedicated cluster

但迁移路径要提前设计:如何从 shared 提升到 dedicated,identity、sequence、 外键、事件与投影如何移动。

RLS 是纵深防御,不是唯一边界

Row-Level Security 可以根据角色/会话上下文限制行,但需要审计:

table owner / BYPASSRLS
FORCE ROW LEVEL SECURITY
session tenant context injection
pooling mode
prepared statements
security definer functions
COPY / maintenance paths
foreign keys and side channels
backup/replica/admin access

第 23 章会用正负 tenant 测试验证。不能因为 CREATE POLICY 成功,就宣布 隔离完成。

隔离与可观测性必须同粒度

若配额按 tenant,指标却只能看整个 cluster,就无法:

  • 识别 noisy neighbor;
  • 归属成本;
  • 证明公平性;
  • 按 tenant 降级;
  • 预测迁移。

需要在不暴露敏感 SQL/数据的前提下,保留 service/database/role/workload class/tenant 等必要维度,并控制 cardinality。

18.4.3 成本归属、配额和生命周期

没有成本归属,平台会奖励浪费

共享数据库中,使用者容易只看到:

“多一张表”
“多一个索引”
“多跑一条报表”

平台承担的真实成本:

compute
memory
primary + replica storage
WAL + archive
backup repository
network
monitoring retention
upgrade window
on-call labor
recovery time
capacity headroom

成本模型不一定要精确计费,但必须让 owner 看见边际影响。

用服务单位表达成本

可以按组织成熟度从简单到复杂:

allocated cluster share
database GB-month × replica/backup multiplier
connection/CPU class
WAL GB and backup retention
query or workload class
dedicated instance/cluster fixed cost
operator support tier

避免只按主表大小计费:索引、TOAST、replica、backup 与 WAL 可能远大于主表。

配额是正确性保护,不只是节省

重要配额:

配额 保护
connection 防止 backend/内存/调度耗尽
active query 防止过度并发
statement timeout 限制无界执行
lock timeout 限制排队与级联
idle transaction 防止长快照/持锁
work_mem class 限制乘法内存
temp_file_limit 防止 spill 填盘
storage/WAL growth 保护恢复链与容量
logical slot retention 防止 WAL 无限保留
maintenance window 保证 vacuum/index/backup

配额超限行为要确定:

queue
reject
cancel
spill
throttle
degrade
escalate

静默超卖不是服务。

连接预算从端到端计算

不要让每个应用实例都配置:

pool_max = max_connections

预算应满足:

pools+admin+monitoring+maintenance+HA reservesafe backend budget \sum pools + admin + monitoring + maintenance + HA\ reserve \leq safe\ backend\ budget

还要考虑:

  • 每个应用副本数;
  • failover 后流量汇聚;
  • pool retry storm;
  • transaction vs session pooling;
  • offline/replica 独立预算;
  • emergency admin 保留。

第 22、34 章会做饱和与过载演练。

生命周期从申请前开始

完整状态机:

request
  -> classify data/workload/objective
  -> choose offering/isolation/bundles
  -> owner and cost approval
  -> provision
  -> acceptance evidence
  -> operate
  -> change / exception
  -> periodic review
  -> deprecate
  -> drain/export
  -> retain/erase
  -> decommission evidence

若没有 expiry/decommission,临时数据库会永久占用备份、监控和升级路径。

变更要分普通与例外

普通变更:

catalog-defined offering
supported version
allowed extension bundle
within capacity envelope
standard backup/security

例外:

pilot extension in production
custom preload
unsupported version
RPO/RTO override
over-quota
cross-boundary data
lab authentication

例外必须有:

owner
risk
compensating control
evidence
expiry
review date
rollback

“临时允许”但没有到期日,通常等于永久。

删除与退役是高风险动作

本书实验里的 reset 有精确 token、target、marker 和 active-session guard; 生产退役还要更多:

  1. 确认 owner 与法律/业务 retention;
  2. 停止新连接和写入;
  3. 记录最后 backup/manifest;
  4. 导出需要保留的数据;
  5. drain 外部消费者和投影;
  6. 验证 DNS/service/secret/monitoring 依赖;
  7. 双人审批 destructive target;
  8. 擦除或进入保留;
  9. 保存完成证据。

“在 inventory 中删掉一段 YAML”不等于数据已经安全退役。

复审由事件与周期共同触发

周期复审:

owner
objective
usage/cost
capacity
version/support
exceptions
expiry

事件复审:

major workload change
new data class
extension/version change
incident
failed restore/upgrade
ownership change
externalization trigger
provider/platform migration

平台目录最终要可验证

本章 validator 会检查:

  • offering ID 唯一;
  • production offering 有非空 service_objectives
  • allowed bundle 全部存在;
  • blueprint 引用的 offering 全部存在;
  • bundle 与 lower-volume gate 引用闭合;
  • lab-only FDW 不得生产准入。

反例把 pg-ha-standard.service_objectives 置空时,必须得到:

E_SERVICE_OBJECTIVE

机器验证不能判断 99.9% 是否合理,但能阻止“生产服务连目标字段都没有”的 结构退化。


上一节:明确替代边界 · 返回本章目录 · 下一节:Pigsty 作为参考实现 · 查看全书目录 · 查看索引中心

18.5 Pigsty 作为参考实现

本书选择 Pigsty,不是为了把 PostgreSQL 原理隐藏在自动化后面,而是为了让 读者看到一套完整参考实现如何把原理变成可交付环境。

阅读方法始终是双向的:

平台职责 -> Pigsty 参数/组件/动作
Pigsty 现象 -> PostgreSQL/catalog/log/backup/route 证据

18.5.1 把 PostgreSQL、HA、备份、接入和观察组合起来

声明期望状态

Pigsty 4.5 的 Architecture 说明,它用 config inventory 与参数描述部署环境,再由 Ansible playbook 实现。

最小 cluster 声明大致包含:

pg-shop:
  hosts:
    10.10.10.11: { pg_seq: 1, pg_role: primary }
    10.10.10.12: { pg_seq: 2, pg_role: replica }
    10.10.10.13: { pg_seq: 3, pg_role: replica }
    10.10.10.14: { pg_seq: 4, pg_role: offline }
  vars:
    pg_cluster: pg-shop
    pg_version: 18

声明有两个重要作用:

  • 让拓扑、身份、版本与参数进入版本化评审;
  • 让重复执行与漂移修复有一个共同期望。

它没有证明:

  • 四个地址真的跨故障域;
  • 存储与网络满足容量;
  • package repository 完整;
  • secret 已正确交付;
  • RPO/RTO 已演练;
  • 业务应用兼容。

inventory 是控制平面输入,不是验收报告。

模块与职责映射

Pigsty 官方架构列出多个模块。本书关注:

模块/组件 本书中的职责
NODE 主机基线、监控、日志、HAProxy 等节点能力
ETCD HA 的分布式配置与 leader 协调
PGSQL PostgreSQL、Patroni、PgBouncer、pgBackRest、exporter
INFRA 软件仓库、DNS/NTP、指标/日志/告警/可视化
MINIO(可选) S3-compatible 对象/备份仓库候选
REDIS(可选) 缓存候选,但 authority 仍由合同决定

模块安装并不改变业务边界。例如部署 REDIS 不会自动让它成为商品权威;部署 MINIO 也不会自动完成 media 两阶段状态机。

PostgreSQL 仍是数据面核心

自动化完成后,仍要从 PostgreSQL 验证:

SELECT current_setting('server_version');
SELECT pg_is_in_recovery();
TABLE pg_extension;
TABLE pg_roles;
SELECT * FROM pg_stat_replication;
SELECT * FROM pg_stat_wal_receiver;

以及:

database/schema/object owner
ACL
business checksum
extension versions
replication position
archive status
backup restore result

Pigsty 提供实现路径,不改变这些原生事实的语义。

Patroni 与 etcd 形成 HA 控制

Pigsty 的 High Availability 描述了参考链:

PostgreSQL physical streaming replication
  -> Patroni manages member role/process
  -> etcd provides DCS/leader election
  -> Patroni health API exposes current role
  -> HAProxy routes by health/role

理解边界:

  • PostgreSQL replication 决定数据复制位置;
  • Patroni 决定/执行 promotion 与成员管理;
  • etcd 参与 leader 共识,不存业务表;
  • HAProxy 决定新连接去哪,不复制数据;
  • client 必须处理切换期间连接/事务失败。

“自动切换”不是“请求无感成功”。切换中的连接会中断,未决事务需要应用依据 幂等身份判断和重试。

复制不是备份

HA 复制会忠实传播:

DROP TABLE
wrong UPDATE
application bug
malicious committed change
logical corruption

Pigsty 官方 HA 文档也明确区分流复制故障覆盖与人为/软件错误恢复,后者需要 延迟副本或 PITR。

因此平台组合同时需要:

HA: current service continuity
backup/WAL: historical recovery
PITR: select target time/LSN/transaction
forensics: preserve evidence before repair

第 20、21、32、35 章分别承担这些证据。

pgBackRest 形成恢复链,但恢复仍需演练

参考实现可生成 pgBackRest 配置、执行 base/differential/incremental backup、 归档 WAL 并管理 repository。

验收不能止于:

backup command exit 0

还要证明:

repository manifest and retention
WAL continuity
encryption/key recovery
independent failure domain
empty isolated target restore
extension packages
business checksum
RTO under representative size
operator runbook

第 21 章会在隔离目标恢复;第 32 章选择随机 recovery target。

HAProxy 把拓扑封装为服务

Pigsty 官方 Service/Access 说明 service 由访问端点与 selector 组成,并提供默认 primaryreplicadefaultoffline 等服务。

概念映射:

primary endpoint  -> current writable primary
replica endpoint  -> eligible read-only members with fallback policy
offline endpoint  -> offline/analytical candidates
default endpoint  -> default PostgreSQL/PgBouncer path

要进一步验收:

  • health check 与实际 role 是否一致;
  • failover 后多久摘除旧 primary;
  • fallback 是否会把只读流量压回 primary;
  • client DNS/VIP/port 怎样接入;
  • TLS 在哪终止;
  • health endpoint 是否越权;
  • 连接失败与重试风暴怎样受控。

PgBouncer 把连接变成有限资源池

池化可减少 backend 数、平滑短连接,但会引入语义:

session / transaction / statement pooling
prepared statements
temporary tables
session GUC
LISTEN/NOTIFY
advisory locks
server reset
cancel routing
authentication

应用是否兼容,取决于 pool mode 与使用的 session feature。第 22 章会用实际 请求验证,不能只看 PgBouncer 端口可连。

offline replica 实现分析隔离候选

Pigsty 的 Offline Instancepg_role: offline 用于慢查询、ETL、OLAP 与交互查询。

蓝图提出:

primary + 2 replicas + 1 offline

offline 服务合同:

  • read-only;
  • 不保证 read-your-writes;
  • 显示 replay lag;
  • 分析连接池独立;
  • 查询 timeout/temp 配额独立;
  • 不默认承接 online replica 流量;
  • 长查询与 WAL replay 冲突有明确处理。

节点存在不证明这些条件,仍需第 22、26、27 章。

观察栈连接组件信号

Pigsty 4.5 Monitoring 描述了 Grafana、VictoriaMetrics、VictoriaLogs 与 PostgreSQL/PgBouncer/ Patroni/HAProxy/Node 等 exporter/日志源。

平台至少需要关联:

client/service probe
HAProxy backend
PgBouncer queue/pool
PostgreSQL session/query/wait
Patroni role/timeline
replication lag
WAL/archive/backup
host CPU/memory/I/O/network
extension-specific state
business freshness

仪表盘只是表现层。alert owner、阈值依据、抑制、升级与 runbook 仍由团队 定义。

18.5.2 哪些能力开箱可用,哪些仍需组织流程

三层“可用”

讨论开箱能力时应分:

L0 mechanism
  配置/组件/命令存在,能在受控环境执行

L1 environment validation
  目标环境完成身份、版本、行为和复位/恢复证据

L2 service acceptance
  目标、容量、安全、值班、变更、演练和业务签字成立

本章:

direct PostgreSQL fixture L1 = passed for chapter-specific mechanisms
Pigsty mapping = documented
Pigsty L1 = not-run
production L2 = pending chapters 19-36

不要把不同对象的 L1 混在一起:本地 PostgreSQL 18.6 实验通过,不等于目标 Linux/Pigsty cluster 通过。

参考实现可直接提供的机制

按官方能力,Pigsty 可以自动化:

host desired state
software repository/package deployment
PostgreSQL instance/cluster creation
Patroni/etcd HA wiring
HAProxy service definitions
PgBouncer deployment/configuration
pgBackRest configuration and scheduled backup
monitoring/log collection/dashboard/alerts baseline
roles/databases/extensions declarations
offline replica role

“提供机制”的准确含义:

  • 有对应 module/parameter/playbook;
  • 可以在支持环境中声明和部署;
  • 有默认配置和可观察入口。

它不是针对 pg36_shop 的完成证明。

组织必须补齐的工作

工作 为什么不能由工具自动决定
数据 authority 业务语义与冲突裁决
SLO/error budget 业务损失、成本与风险取舍
RPO/RTO scope 故障模型与恢复价值
tenant trust 法规、组织与威胁模型
extension admission 功能收益、生命周期、支持
capacity headroom 真实 workload 与增长
on-call/RACI 人与组织责任
change approval 风险、窗口与可逆性
incident judgment 不完整证据下的取舍
postmortem actions 系统性改进优先级

自动化可以检查字段非空,不能替业务 owner 承诺。

默认值是起点,不是证据

生产环境常见错误:

default topology -> default failure guarantee
default HA timeout -> our RTO
default backup schedule -> our RPO
default dashboard -> complete observability
default password -> acceptable security
default pool size -> safe connection budget
default shared_buffers/work_mem -> tuned

正确流程:

document default
  -> explain why it may fit
  -> measure target
  -> accept/override
  -> validate
  -> monitor drift

secret 永远不属于示例 inventory

本章 pigsty-declaration.example.yml 只放不可用 sentinel:

password: "REPLACE_VIA_APPROVED_SECRET_SOURCE"

正式环境要决定:

secret authority
render/injection path
file permissions
rotation
revocation
backup/log redaction
break-glass
audit

示例中的明文不是“方便”,而是泄露路径。

配置成功后还要独立验收

部署命令成功后,验收从外到内:

inventory identity
host/OS/time/storage/network
package/version
PostgreSQL role and settings
replication/timeline
service selectors and endpoints
pool behavior
backup archive + isolated restore
monitoring and alert path
business golden
failure drill

每项证据要保存 target、时间、版本和执行者。截图可以辅助,不应是唯一机器证据。

环境与组织漂移

技术漂移:

manual ALTER SYSTEM
package patch mismatch
extension extversion mismatch
inventory not applied
certificate expiry
backup schedule disabled
alert rule changed

组织漂移:

owner 离职
on-call 无人
runbook 过期
SLO 与业务不匹配
例外无到期
恢复密钥不可得

平台治理必须同时检测两类。第二类不会出现在 pg_settings

何时可以说“生产就绪”

至少满足:

offering approved
target L1 evidence retained
business acceptance golden passes
RPO/RTO drills pass
security threat model and tests pass
capacity has headroom
SLI/SLO/error budget approved
alerts reach accountable responder
change/rollback/upgrade tested
incident and recovery runbooks exercised
open exceptions bounded and expiring

因此本章不会使用“部署完成,所以生产就绪”的句式。

18.5.3 不把参考实现冒充唯一架构

稳定的是合同,变化的是实现

应尽量稳定:

service endpoint semantics
data authority
consistency/freshness
identity/privilege boundary
RPO/RTO definition
backup/recovery evidence
extension lifecycle
observability requirements
exit path

可以替换:

automation engine
HA controller/DCS
proxy/pooler
backup tool/repository
metrics/log stack
cloud/on-prem substrate

替换实现时,合同成为验收基线。

不要绕过组件理解

Pigsty 把复杂组件组合起来,但值班者仍需知道故障落在哪一层:

症状 可能层
endpoint 不通 DNS/VIP/HAProxy/network
pool queue 高 PgBouncer/connection budget
primary 不明确 Patroni/etcd/network partition
replica lag PostgreSQL/WAL/I/O/long query
backup missing archive/pgBackRest/repository/secret
dashboard blank exporter/collection/storage/query
SQL wrong result schema/data/extension/business semantics

“重跑 playbook”不是通用诊断,更可能覆盖证据或扩大变更。

不要把云托管与自托管简化成好坏

托管服务可能减少:

hardware lifecycle
base engine patching
some HA/backup implementation
control-plane construction

但团队仍负责:

data model
SQL/application behavior
roles/security configuration
SLO and capacity/cost
restore acceptance
extension compatibility
migration/exit
incident collaboration

自托管给予更多控制和透明度,也带来更多直接责任。选择应基于组织能力、法规、 故障模型、成本与退出,而非身份认同。

保持原生证据层

无论实现是什么,都尽量保留:

SQL business golden
catalog inventory
configuration snapshot
backup manifest
restore checksum
service probe
fault timeline
versioned ADR

这些证据比某个 UI 路径更可迁移。

用接口封装平台差异

消费者看到:

service name
endpoint class
database
runtime identity
TLS/auth method
pool/session contract
SLO/freshness
quota
support/escalation

不应依赖:

当前 primary IP
Patroni member name
HAProxy 内部 selector
backup repository layout
Ansible role internals
monitoring storage schema

这样平台升级或替换时,业务应用变更最小。

参考实现也必须有退出路线

从 Pigsty 迁出并不是“一条 pg_dump”:

  1. 冻结 PostgreSQL/extension/locale/role 依赖;
  2. 选择 physical 或 logical 路径;
  3. 重建 service endpoint 与 pooling 语义;
  4. 重建 backup/PITR;
  5. 重建 monitoring/alerts;
  6. 验证 HA 与 failover;
  7. 验证业务 golden;
  8. 切换并保留回退;
  9. 退役旧控制面与 secret。

反向迁入同样需要这些合同。

一个合格的参考映射

本章示例 YAML 明确注释:

not Pigsty L1 validated
example IPs
no real secrets
backup policy pending
synchronous mode not selected
ports/selectors/pool pending chapter 22
extension lifecycle governed outside package list

有意保留 unknown,比填入未经证实的“最佳实践”更专业。

何时偏离 Pigsty 参考

可以偏离,只要有证据:

existing organizational platform already satisfies contracts
managed service is required
unsupported OS/network/security boundary
different HA/recovery model
specialized kernel/distribution
team skills and support model
regulatory requirement
cost/capacity evidence

偏离应写 ADR,包含等价职责、差异、风险与退出,而不是静默拼装。

本书为什么仍然以 Pigsty 实战

因为读者要从 SQL 走到生产,必须面对:

host
package
topology
service endpoint
pool
HA
backup
monitoring
change
incident

Pigsty 给出一个可以落地、查看、运行与破坏性演练的完整对象;PostgreSQL 原生 证据则防止读者只会操作一个封装。两条线并行,才能真正迁移知识。

这一章的最终边界

我们接受:

Pigsty 4.5 是 pg36_shop 下卷的参考实现。

我们尚未接受:

示例 inventory 已经可以部署生产,或提案中的 SLO 已经实现。

后一项只有在下卷 evidence 完成后才可能成立。


上一节:平台服务目录与多租户 · 返回本章目录 · 下一节:实战:设计 pg36_shop 生产蓝图 · 查看全书目录 · 查看索引中心

18.6 实战:设计 `pg36_shop` 生产蓝图

第 18 章实战与前几章不同:

no setup
no DDL
no DML
no reset
no service deployment

它把现有证据读出来,检查蓝图是否有资格进入下卷。风险等级是 L0。

18.6.1 选择保留在 PostgreSQL 内的能力

前置状态

实验要求第 4、13–17 章的最终 fixture 保留在同一本地开发实例:

database=pg36_shop
PostgreSQL major=18
session_user=postgres
pg36_owner=NOLOGIN non-superuser
pg36_app=LOGIN non-superuser
ch04-v1 physical model
ch13 routine guard
ch14 extension lifecycle
ch15 search quality
ch16 spatiotemporal
ch17 analytics/FDW + two shard database shells

缺失时本章直接失败,返回前章重建;它不会悄悄修复。

私有连接

沿用:

[pg36-admin]
host=/path/to/socket-or-host
port=5432
dbname=pg36_shop
user=postgres
chmod 600 /path/to/pg_service.conf
export PGSERVICEFILE=/path/to/pg_service.conf
export PGSERVICE=pg36-admin

密码或其他 secret 放在批准的连接机制中,不放命令行、脚本、evidence 或 Git。

先读实验合同

lab-contract.md 规定:

risk=L0 read-only
target=confirmed local fixture
allowed=catalog reads + JSON validation + evidence files
forbidden=DDL/DML/roles/extensions/deploy/failover/backup/reset
pigsty_l1=not-run

本章脚本没有 setup/reset action。这不是遗漏,而是用接口形状表达安全 边界。

上卷前置复核

task.sh 依次调用:

static/labs/ch04/task.sh verify
static/labs/ch13/task.sh verify
static/labs/ch14/task.sh verify
static/labs/ch15/task.sh verify
static/labs/ch16/task.sh verify
static/labs/ch17/task.sh verify

它们只验证 retained fixture。第 17 章还分别连接 pg36_shard_apg36_shard_b,防止协调端看似正常而远端状态已经漂移。

只读事务

每个 catalog capture 都显式开始:

BEGIN TRANSACTION
ISOLATION LEVEL REPEATABLE READ
READ ONLY;

再 include context.sql

context 验证:

current_database = pg36_shop
server_version_num in 18.x
session_user = postgres superuser
can inspect pg36_owner
owner role = NOLOGIN, non-superuser
app role = LOGIN, non-superuser
ch04 schema_version present

脚本结束 COMMIT,但 read-only 事务没有业务变更。

平台状态

platform-state.sql 输出稳定 key/value:

database=pg36_shop
server_major=18
server_version=18.6 (formal run)
session_user=postgres
in_recovery=false
model_version=ch04-v1
relation_checksum=f8a7bfae59c6d16cd323abecfefe1014
pigsty_reference=4.4
pigsty_l1=not-run
mutation=none

mutation=none 不是只靠自报:all 在前置复核后抓两轮,并对状态与 catalog 逐字节 cmp

能力快照

capability-snapshot.sql 把数据库事实压成九行:

capability lifecycle evidence
relational core accepted ch04-v1
atomic database logic accepted with scope ch13-routine-guard-v1
lexical/fuzzy search accepted pg_trgm:1.6
semantic search pilot vector:0.8.4
spatiotemporal conditional btree_gist:1.8,postgis:3.6.4
analytical federation lab-only postgres_fdw:1.2
search quality fixture accepted ch15-search-v1
spatiotemporal fixture accepted ch16-spatiotemporal-v1
analytics fixture accepted ch17-analytics-v1

这里有意把“扩展安装事实”和“生命周期判断”并列。SQL 能证明版本存在, 生命周期还来自前章的质量、安全与运维边界。

extension catalog

extension-catalog.sql 记录:

extension_name
extension_version
schema_name
owner_name
relocatable
comment

正式 fixture 精确包含六项:

btree_gist 1.8
pg_trgm 1.6
plpgsql 1.0
postgis 3.6.4
postgres_fdw 1.2
vector 0.8.4

教学扩展必须保留 pg36 chXX ... safe to rebuild marker。marker 只用于本书 精确识别,不应照搬成生产对象治理方案。

schema 与 role catalog

schema-catalog.sql 冻结:

shop / shop_private
shop_ch13 / shop_ch14 / shop_ch15
shop_ch16 / shop_ch16_ext
shop_ch17 / shop_ch17_ext

每项必须由 pg36_owner 拥有并保留精确 comment。

role-catalog.sql 只导出三种相关身份,避免把环境中其他角色误收入出版 fixture。审查器验证:

pg36_app   LOGIN, !SUPERUSER, !BYPASSRLS
pg36_owner NOLOGIN, !SUPERUSER
postgres   LOGIN, SUPERUSER (formal local admin)

为什么不探测 Pigsty

当前实例不是本章声明的目标 Pigsty cluster。若脚本从本机进程名或目录猜测 Pigsty 状态,会产生伪证据。

所以蓝图准确写:

Pigsty reference mapping = documented
Pigsty L1 = not-run

第 19 章在明确 target/inventory 后才执行环境验收。

18.6.2 选择外置组件及其数据契约

五份合同先于产品选型

external-data-contracts.json 包含:

product-cache-v1
order-events-v1
product-media-v1
analytics-export-v1
external-search-projection-v1

每份必须有 17 个核心字段,包括:

id / kind / status / owner / authority
source / sink / freshness / delivery / ordering
idempotency / rebuild / failure_mode / reconciliation
deletion / security / exit

这比简单画一条箭头严格得多。

cache 合同

关键规则:

business authority=PostgreSQL
cache identity=product_id + source version
TTL <= 300 seconds proposal
stale version never replaces newer
outage falls back to bounded PostgreSQL reads
namespace can be discarded and rebuilt

validator 专门扫描 cache authority。若把:

"product_business_state": "cache"

则报:

E_CACHE_AUTHORITY

order event 合同

business state + publication intent -> PostgreSQL transaction
delivery/replay log                 -> event bus
delivery                            -> at-least-once
ordering                            -> per order_id, no global order
idempotency                         -> stable event_id

publish lag 仍是 chapter 24 pending。写出 pending 比伪造一个 P99 更准确。

media 合同

bytes -> object storage
identity/owner/state/checksum -> PostgreSQL
immutable object version
visible only after checksum + metadata agree
orphan upload quarantined
two-phase deletion

它是 accepted-boundary,表示“字节外置”这一边界已选择,不表示具体 object provider 已选择或 L1 已通过。

analytics 合同

source=snapshot or CDC
sink=versioned immutable analytical tables
watermark on every dataset
last complete state
row/aggregate/partition checksum
tombstone propagation
new generation rebuild

这份合同会在第 29 章的数据迁移与 CDC 状态机中具体化。

external search 合同

状态:

deferred-until-trigger

进入条件不是“想用”,而是 PostgreSQL 搜索基线在质量、规模、语言或独立 SLO 上失败。

启用前必须证明:

snapshot + idempotent changes
monotonic product version/tombstone
index generation
quality golden
document count/payload hash
alias swap
fallback classification
exit to PostgreSQL

正向文档关系

baseline-v1.6-proposal.json 引用所有合同。每项 capability 又引用它需要的合同。

validator 检查:

blueprint contract set == declared contract set
capability references exist
external/pilot/conditional placement has trigger
every capability has owner and evidence

这能发现拼写、遗漏和结构漂移。

七个对抗性反例

negative-cases.json 不是伪造七份静态错误文件,而是对正确文档做 JSON path mutation:

反例 期望错误
无 evidence 宣称 Pigsty L1 passed E_L1_EVIDENCE
清空全部 exit path E_EXIT_PATH
cache 成为商品业务权威 E_CACHE_AUTHORITY
删除消息合同 rebuild E_CONTRACT_FIELD
loopback FDW 允许生产 E_FDW_LAB_ONLY
删除 production offering objective E_SERVICE_OBJECTIVE
删除 vector pilot gate E_EXTENSION_GATE

测试要求实际错误码与期望码精确相同。若错误文档意外通过,或被另一个更早的 无关规则拦截,negative suite 都失败。

为什么 validator 只用 Python 标准库

validate.py 只依赖:

argparse
copy
hashlib
json
pathlib

目的不是排斥 schema 工具,而是让读者在最小环境中运行并看到业务策略代码。 生产平台可以再加 JSON Schema、OPA、CI policy 或签名。

canonical hash

报告为四份核心文档计算 canonical JSON SHA-256:

sort object keys
compact separators
UTF-8
preserve array order

这避免 indentation/key order 影响内容身份,同时让 gate/capability 顺序仍然 有意义。

注意:canonical hash 证明文档未变,不证明内容正确;内容正确还靠人工决策、 数据库证据和负例。

18.6.3 输出服务目录草案、架构 ADR 与下卷验收问题

资产目录

static/labs/ch18/
├── lab-contract.md
├── architecture-adr.md
├── platform-map.mmd
├── pigsty-declaration.example.yml
├── service-catalog.json
├── external-data-contracts.json
├── baseline-v1.6-proposal.json
├── lower-volume-gates.json
├── negative-cases.json
├── context.sql
├── platform-state.sql
├── extension-catalog.sql
├── schema-catalog.sql
├── role-catalog.sql
├── capability-snapshot.sql
├── validate.py
├── review.py
└── task.sh

没有生成的 evidence 被提交到源码目录。

单独验证文档

python3 static/labs/ch18/validate.py \
  --blueprint static/labs/ch18/baseline-v1.6-proposal.json \
  --catalog static/labs/ch18/service-catalog.json \
  --contracts static/labs/ch18/external-data-contracts.json \
  --gates static/labs/ch18/lower-volume-gates.json

预期:

{
  "status": "ok",
  "counts": {
    "offerings": 4,
    "extension_bundles": 5,
    "contracts": 5,
    "capabilities": 9,
    "lower_volume_gates": 18,
    "exit_paths": 5
  }
}

加负例:

python3 static/labs/ch18/validate.py \
  --blueprint static/labs/ch18/baseline-v1.6-proposal.json \
  --catalog static/labs/ch18/service-catalog.json \
  --contracts static/labs/ch18/external-data-contracts.json \
  --gates static/labs/ch18/lower-volume-gates.json \
  --negative-cases static/labs/ch18/negative-cases.json

预期 case_count=7 且全部 actual/expected code 相等。

单轮 capture

evidence="$(mktemp -d /tmp/pg36-ch18.XXXXXX)"

PG36_EVIDENCE_DIR="$evidence" \
  static/labs/ch18/task.sh capture

输出:

status=capture-ok
mutation=none
evidence=/tmp/...

每个 cycle 的 review.py 验证:

  • manifest target/version/hash;
  • relation checksum;
  • extension/schema/role exact identity;
  • capability lifecycle;
  • normal/negative policy report;
  • stderr 为空。

两轮正式运行

evidence="$(mktemp -d /tmp/pg36-ch18-final.XXXXXX)"

PG36_EVIDENCE_DIR="$evidence" \
  static/labs/ch18/task.sh all

正式 PostgreSQL 18.6 开发 fixture 的结果:

status=ok
preflight=ch04+ch13+ch14+ch15+ch16+ch17
cycles=2-byte-identical
documents=catalog+contracts+blueprint+18-pending-gates
counterexamples=7-rejected
pigsty_l1=not-run
mutation=none
release_candidate_checksum=beec6b6d47075a7b3b4a6aa6ee3ca2902ef8d555547fe6c2b2b009e56c25c9eb

源码后续改变时 checksum 会改变;应以当次 manifest 与 validator report 为准。

两轮比较什么

platform-state.csv
extension-catalog.csv
schema-catalog.csv
role-catalog.csv
capability-snapshot.csv
validation-report.json
negative-report.json
review.txt

manifest.txt 含 capture 时间,故不做 byte compare;其余确定性证据必须一致。

运行后状态不需要复位

本章没有数据库写操作。若运行前后出现状态变化,应当视为:

  • 外部并发变更;
  • 某个前置 verify 实现违反只读预期;
  • capture SQL/任务脚本缺陷;
  • 环境不再适合作为冻结 fixture。

不要用 reset 掩盖,应先保留 evidence 并诊断。

架构 ADR

architecture-adr.md 记录:

context
decision per capability
external contracts
service offerings
Pigsty reference mapping
proposed topology
positive consequences
costs/risks
rejected alternatives
revision triggers

明确拒绝:

everything in PostgreSQL
everything split immediately
loopback FDW as production proof
Pigsty install as production readiness

ADR 的 status 是:

proposed; accepted for lower-volume validation,
not production approval

18 个下卷 gate

lower-volume-gates.json 严格映射第 19–36 章:

gate
19 deployment baseline
20 HA
21 backup/restore
22 access/routing
23 security
24 governance
25 observability
26 capacity
27 tuning
28 vacuum/maintenance
29 migration
30 upgrade
31 incident framework
32 PITR
33 failover/rebuild
34 overload
35 forensics
36 postmortem/platform improvement

每个 gate 有 owner、两个核心问题、required evidence 与 pending 状态。

gate 不是章节阅读打卡

chapter completed 不等于 gate passed。例如读完第 21 章但没有在目标环境 恢复,ch21-backup-restore 仍然 pending。

通过 gate 应产生:

target identity
version
procedure
raw evidence
review result
owner approval
limitations
expiry/review trigger

服务目录如何升级

下卷完成后,不直接覆盖 1.6-proposal。应:

  1. 收集每个 gate evidence;
  2. 修改不成立的 topology/objective/bundle;
  3. 记录 ADR revision;
  4. 生成新的 catalog/blueprint release;
  5. 重新跑正负 policy;
  6. 由 owner 批准;
  7. 保留 proposal 历史。

本章通过后能说什么

可以说:

在 PostgreSQL 18.6 的受控本地 fixture 上,第 4、13–17 章证据仍然成立; pg36_shop 的服务目录、能力决策、五份外置合同和 18 个下卷 gate 引用闭合, 七个危险反例被拒绝,两次只读快照一致。

不能说:

Pigsty 生产 cluster 已部署、SLO 已实现、备份可恢复、HA 可达目标、安全已 通过、容量足够。

这条语言边界,也是本章最后一项验收。

进入下卷

上卷回答:

PostgreSQL 如何正确建模、查询、扩展与交付应用能力

下卷开始回答:

这些能力如何在真实环境中持续、可恢复、可观察、可升级地成为服务

下一步是 第 19 章:环境规划与部署基线: 把 proposal 中的主机、软件、网络、存储、故障域和 Pigsty inventory 变成第一 份目标环境证据。


上一节:Pigsty 作为参考实现 · 返回本章目录 · 下一章:开天辟地:环境规划与部署基线 · 查看全书目录 · 查看索引中心

下卷:运维管理

本卷导读:下卷面向 DBA、平台工程师与生产负责人,沿着“规划交付—高可用与备份—安全接入—可观测运营—迁移升级—事故恢复与复盘”的路径,把 PostgreSQL 知识转化为可持续运行的数据库服务能力。

本卷定位

从生产服务规划到日常运营、事故恢复与改进

第一次系统学习,建议按规划、运营、恢复与改进的顺序推进;已有明确问题时,可从下方索引直接进入相应章节。

本卷索引

第四篇:规划——建设可交付的 PostgreSQL 服务

ch19 开天辟地:环境规划与部署基线

从工作负载、服务目标和责任边界出发,规划并验收 L2 生产仿真环境,而不是从一份默认配置开始。

ch20 狡兔三窟:高可用拓扑与容灾目标

把复制、选主、故障域和业务 RPO/RTO 连接起来,通过一次可重复的切换演练证明高可用,并集中示范架构决策如何落到验收证据。

ch21 未雨绸缪:备份体系与恢复演练

从恢复目标反推备份、WAL 归档、保留和异地策略,并用隔离恢复证明备份可用。

ch22 四通八达:服务接入、连接池与路由

让客户端连接到“具有明确语义的服务”,而不是某台机器;掌握连接预算、池化模式、读写路由与失效行为。

ch23 固若金汤:认证、授权与数据安全

从威胁模型出发建立身份、最小权限、传输保护、行级安全与审计;把连接池会话语义纳入安全设计。

ch24 纲举目张:SLO、SOP 与组织治理

在建设告警之前定义“什么算服务正常、谁负责、证据在哪里、发生变化如何处置”,产出 ch25 的观察与告警契约。

第五篇:运营——用证据驱动日常维护与演进

ch25 望闻问切:监控体系与可观测诊断

实现 ch24 的观察契约,把指标、日志、SQL 统计和告警连接为可行动的诊断系统,而不是堆叠面板。

ch26 胸有成竹:容量规划与压测基线

用可复现工作负载测量资源需求、噪声与余量,形成容量模型;不把一次 pgbench 数字包装成普适性能。

ch27 精益求精:参数调优与资源治理

以已证实的瓶颈为起点,理解参数的资源机制、作用域和变更风险;拒绝无上下文的“万能参数模板”。

ch28 除旧布新:VACUUM、冻结与膨胀治理

把 MVCC 留下的空间债、事务年龄和索引完整性变成可预测的维护工作,并完成分区生命周期触点。

ch29 移花接木:逻辑复制、迁移与异构同步

理解逻辑复制、CDC 和数据搬迁的状态机,用校验与可回退切换完成迁移,而不是把“数据能流动”误当成迁移成功。

ch30 推陈出新:版本升级与回滚策略

把升级视为应用、数据库、扩展、排序规则和平台共同参与的迁移项目,通过彩排决定前滚或回退。

第六篇:出山——按响应目标演练恢复与改进

ch31 事件分级、现场保护与应急决策——枕戈待旦

建立所有事故共用的指挥、保护、取证、变更与升级框架;兼顾单人值守和团队协同,并学会怀疑第一个症状。

ch32 PITR 与误操作恢复——妙手回春

在隔离环境中确定恢复目标、执行 PITR、验证业务正确性并安全回切,并集中示范事故处置如何保护现场、控制风险与闭环验证。

ch33 故障切换与集群重建——力挽狂澜

区分数据库、复制、网络与 DCS 故障,选择切换或重建路径,避免脑裂和错误时间线。

ch34 过载保护与资源故障判型——李代桃僵

从资源症状进入,第一步区分流量型与保留型;只对流量型执行限流、取消、摘流和降级,对保留型实施安全保护并路由到正确章节。

ch35 数据抢救与工程取证——起死回生

面对页、索引、排序规则或逻辑不一致时先保护原始证据,再区分检测、修复、抽取与重建;不把危险技巧包装成常规运维。

ch36 事故复盘、控制固化与平台演进——举一反三

把一次恢复变成长期能力:解释因果链、修复控制缺口、验证改进,并为下一轮架构和版本演进建立优先级。

前后衔接

19 开天辟地:环境规划与部署基线

上卷回答了 PostgreSQL 能做什么;下卷从一个更苛刻的问题开始:

我们准备把什么服务,交付给谁,承诺到什么程度;声明的版本、主机、 初始化与拓扑,是否真的存在?

本章先写需求与资源合同,再冻结 PostgreSQL 初始化选择,最后用 exact Pigsty v4.5.0 在四台 Linux VM 上完成 PostgreSQL 18 部署和 L2 沙箱验收。正式 结果是“带六项例外的沙箱通过”,不是生产批准。

本章目标

读完并完成实验后,你应当能够:

  1. 从业务损失、数据分类、owner、workload 与 RPO/RTO 写服务需求;
  2. 将 CPU、memory、storage、network 与可观察饱和/故障条件连接;
  3. 建立 OS baseline,而不是照抄 sysctl 模板;
  4. 冻结并验证 locale/provider、encoding、checksum、page/WAL、auth 等 PostgreSQL 初始化契约;
  5. 严格区分 node、instance、database cluster、HA cluster、database、 service、pool 与 DCS;
  6. 用 secret-safe Pigsty inventory 声明两个 service unit;
  7. 从 inventory、host、SQL、Patroni/service 四面交叉验收;
  8. 区分 sandbox acceptance、exception 与 production gate;
  9. 为 destructive reset 建立显式 guard,而不把它混入正常检查。

前置与后续

前置:

后续:

  • 第 20 章 高可用拓扑与容灾目标 使用本章保留 baseline 验证 election、fencing、RTO 与数据损失边界;
  • 第 21 章验证 backup/restore;
  • 第 22 章验证 service routing、pooling 与 client semantics;
  • 后续容量、安全、变更和升级 gate 不由本章安装结果代替。

学习路径

service requirement
    -> resource/failure model
        -> host observed baseline
            -> irreversible PostgreSQL initialization contract
                -> topology and stable identity
                    -> secret-safe Pigsty declaration
                        -> live four-plane acceptance
                            -> exceptions + next gates

前三节解决“为什么、需要什么、机器是什么”;19.4–19.6 解决“哪些选择必须 提前冻结、如何声明”;19.7 只读验证声明与事实是否一致。

正式实验结果

target               pg36-l2-vagrant
Pigsty               v4.5.0 exact tag
PostgreSQL           18.6 observed
hosts                4 distinct Ubuntu 24.04/aarch64 guests
service units        pg-meta + pg-test
pg-test              one primary + two streaming replicas
normal validation    passed
negative tests       9/9 rejected as designed
sandbox L2           accepted-with-exceptions
production ch19      pending
mutation by lab      none
reset                not executed

六项例外:

shared hypervisor/power/storage
single etcd
single local backup target
unqualified virtual storage
temporary inventory-based secret handling
three pg-test guests below recommended 2-vCPU/2-GiB floor

这些例外不是脚注;它们逐项阻止 failure-domain、DCS、DR、durability、 secret lifecycle 与 capacity 的生产结论。

本章目录

19.1 先写服务需求

19.2 计算、内存、存储与网络

19.3 操作系统与主机基线

19.4 版本与数据库初始化契约

19.5 拓扑、命名与故障域

19.6 用声明式清单交付两个服务单元

19.7 实战:L2 部署验收

实验入口

正常 all 是只读验收,不包含 deploy、failover、restore 或 reset。

本章最重要的判断

declaration != observation
playbook success != service acceptance
node count != independent failure domains
replica exists != RPO achieved
port open != routing contract
SSL on != transport security complete
sandbox passed != production approved

如果只记住一个方法,就记住:

同一事实必须由合适的 authority 证明;差异必须被拒绝或登记为有范围的 exception,不能被一句“看起来正常”吞掉。


上一章:万法归宗:PostgreSQL 数据平台与替代边界 · 返回下卷导读 · 下一章:狡兔三窟:高可用拓扑与容灾目标 · 查看全书目录 · 查看索引中心

19.1 先写服务需求

部署的第一行不应是:

pg_version: 18

而应是:

这个服务为何存在?
什么数据不能丢?
什么操作不能错?
谁承担结果?
失败多久开始造成不可接受损失?

如果这些问题没有答案,CPU、节点数、同步复制和备份频率都只能靠猜。

19.1.1 业务重要性、数据分类与所有者

先定义业务能力

pg36_shop 提供的不是“一个 database”,而是:

商品浏览与检索
客户身份与地址
订单创建和状态转换
库存与支付事实
配送时空事件
分析与外部投影源

不同能力的故障损失不同:

能力 失败影响 可接受降级
创建订单 直接收入/履约风险 拒绝比重复创建安全
查询支付状态 财务与客服风险 不可用旧缓存猜测
商品描述 转化下降 可短时有标签陈旧
搜索排序 发现能力下降 可回退简单检索
分析报表 决策延迟 可显示 last watermark
图片读取 体验下降 placeholder/重试

一个统一 database 可以承载它们,但 service objective 不能只写一个“重要”。

业务重要性需要损失模型

常用分级:

tier 0 / critical
  停止即产生重大安全、财务或合规损失

tier 1 / important
  核心业务中断,短时可人工/降级

tier 2 / standard
  影响明显,但可在较长窗口恢复

tier 3 / development
  无生产承诺,可重建

分级必须连接动作:

critical standard development
owner/on-call 24×7 明确 支持窗口明确 工作时间
HA 多故障域并演练 按目标设计 可无
backup 跨域、频繁演练 标准恢复 可选且声明
change 强审批/回退 标准流程 自助边界
capacity 高 headroom 正常 headroom best effort
incident 快速升级 标准升级 issue

若分级只改变标签颜色,就没有价值。

数据分类决定安全与恢复边界

分类至少包含:

public
internal
confidential
restricted / regulated
credentials and cryptographic material

对每类记录:

  • 数据 owner;
  • 合法用途;
  • 可访问角色;
  • 是否可复制到 dev/test;
  • 加密与审计要求;
  • 地域限制;
  • retention;
  • 删除时限;
  • backup 中如何处理;
  • incident 通知义务。

本章正式 sandbox 只允许:

synthetic teaching data
production_data_permitted=false
production_traffic_permitted=false

这是硬边界。VM 看起来像生产拓扑,也不能把生产数据“临时导入测试”。

数据 owner 与平台 owner 不同

建议至少区分:

角色 责任
business/data owner 数据用途、正确性、分类、保留、损失
service owner SLO、错误预算、依赖、发布、事件
database platform PostgreSQL/Pigsty、HA、备份、接入、容量
security/privacy 威胁、控制、审计、合规
application team schema/SQL、连接、重试、兼容、流量
incident commander 事件期协调和决策

一个人可以兼任,责任不能消失。

owner 要能作决定

在事故中,DBA 可以说:

“当前恢复到 10:31 会丢 47 秒提交,
 恢复到最新可能保留错误写入。”

但通常不能独自决定哪种业务损失更小。需要提前指定有权选择:

availability versus consistency
restore point
degraded operation
data deletion
customer communication
error-budget spend

“负责人:数据库团队”太宽泛。最终要映射到值班角色、升级路径和替代人。

数据清单要包含派生副本

第 18 章已经定义:

PostgreSQL authoritative business state
cache projection
event delivery/replay log
object bytes
search projection
analytical projection
backup/WAL copies

部署规划不能只列 primary 数据目录。数据流 inventory 应记录:

副本 authority freshness retention deletion rebuild
replica PostgreSQL replay lag current follows WAL base backup
backup historical recovery backup/WAL policy retention expiry restore
cache derived TTL/version eviction tombstone/TTL source refill
event bus delivery log publish lag broker policy workflow replay/snapshot
lake/search projection watermark generation tombstone rebuild

这些副本会影响存储、网络、密钥和故障域规划。

分类要进入 inventory 与 evidence,但不要泄密

配置可保存:

service_class: pg-ha-standard
data_class: confidential
owner: shop-order-team

不应把:

customer data samples
passwords
private keys
recovery secrets
token values

写入基线报告。

本章的 inventory projection 只输出安全 allowlist,记录:

source mode
secret-bearing source fingerprint withheld
safe projection sha256
secret fields redacted count
secret values exported = 0

它证明审计过程知道 secret 存在,同时不把值复制到 evidence。

未知 owner 是阻断项

没有 owner 的 service 不应进入生产。原因很现实:

  • 谁批准 maintenance?
  • 谁判断数据正确?
  • 谁接 incident 电话?
  • 谁接受 RPO?
  • 谁决定退役?
  • 谁支付容量?

可以在 sandbox 用 placeholder,但 production gate 必须把 placeholder 映射到 真实责任人/组和升级路径。

pg36_shop 的当前需求身份

本章 requirements.json 写的是:

service=pg36_shop
data=synthetic teaching only
business owner=placeholder
platform owner=placeholder
target=disposable local Linux sandbox
production approval=false

所以它可以验收部署流程,不能通过真实 pg-ha-standard 的 owner/data gate。

19.1.2 负载形态、增长、峰谷和批处理窗口

“OLTP”不是容量需求

同为 OLTP,可能分别是:

10k tiny point reads/s
500 write transactions/s with 20 indexes
50 large JSON updates/s
100 concurrent long business transactions
bursty checkout traffic
multi-tenant mixed workload

需要一份 workload inventory:

维度
transaction classes browse/order/pay/admin
read/write ratio 分类别
statements/transaction 分布
rows touched P50/P95/P99
connection behavior pool、active、idle
latency P50/P95/P99/timeout
concurrency arrival、active、parallel
WAL bytes/s 与 burst
temp bytes/query 与 aggregate
locks wait、deadlock、long xact
maintenance vacuum/index/backup

第 26 章才正式做容量曲线,本章先确保环境规划有输入。

交易、搜索、空间与分析分开建模

pg36_shop 的四类负载形状:

transactional
  selective, short, latency-sensitive, correctness first

search
  rank/filter, index-heavy, quality + latency

spatiotemporal
  GiST, range, event ingest, retention

analytics
  large scan/aggregate, temp/parallel, freshness-tolerant

如果只用总 QPS,分析扫描和订单写入会互相隐藏。

每类要记录:

endpoint/role
timeout
resource priority
expected concurrency
allowed replica/freshness
degradation
owner

峰值不是日均乘一个系数

峰值来源:

营销活动
整点任务
工资/账单周期
客户端 retry
故障流量转移
缓存失效
批处理重跑
schema migration
backup/checkpoint overlap

真实 peak envelope 至少有:

amplitude
duration
ramp rate
frequency
correlated workloads
recovery tail

一分钟 10 倍峰值与持续 4 小时 3 倍峰值需要不同资源和降级。

流量转移后的单节点峰值

三节点 topology 正常时,读流量可分散;一台 replica 故障后:

[ load_{remaining}

\frac{total\ eligible\ load}{remaining\ eligible\ capacity} ]

若两个 replica 平时各 50%,失去一个后另一个可能接近 100%,也可能 fallback 到 primary。容量必须按 failure state 规划,不只按 happy path。

同理,failover 后:

  • 新 primary 承接全部写;
  • cache 冷;
  • client 重连;
  • replica 重新追赶;
  • backup/maintenance 可能仍在;
  • 旧 primary 需要重建。

增长要分逻辑与物理

逻辑增长:

customers
products
orders/day
items/order
events/day
tenants
retention days

物理增长:

heap
TOAST
indexes
dead tuples/bloat
WAL
temp peak
replicas
backup full/diff/incr
archive retention
monitoring/logs
external projections

一个粗略存储模型:

[ capacity = (heap + toast + indexes + free\ space) \times replica\ factor

  • backup/archive
  • maintenance/upgrade\ headroom ]

不能直接拿业务 CSV 大小乘副本数。

使用增长曲线,不只用线性外推

记录:

daily/weekly sample
seasonality
new feature step changes
largest tenant
retention changes
index additions
compression/archival
confidence interval

容量到达时间:

[ T_{exhaust}

\frac{usable\ capacity - current\ usage - required\ headroom} {growth\ rate} ]

增长率有区间时给出 earliest/expected,而不是一个虚假精确日期。

批处理窗口是一项共享资源预约

批任务包括:

ETL/export
materialized refresh
search/vector rebuild
backup
VACUUM/ANALYZE
index build
partition lifecycle
financial close
data quality reconciliation

每项保存:

字段 意义
earliest start / deadline 可运行窗口
duration distribution 不只平均
CPU/I/O/WAL/temp 资源
locks/snapshot 并发影响
retry 是否会叠加
freshness 延迟后果
owner 谁停止/恢复
conflict priority 与交易冲突时谁让路

“晚上跑”不是窗口。跨时区业务可能没有真正夜间。

维护和业务峰值要画在同一时间轴

建议按 UTC 画一周:

online traffic
batch
backup
checkpoint/WAL archive
autovacuum debt
reporting
deploy
on-call coverage

你可能发现:

业务低谷
  = backup full
  = ETL full scan
  = index maintenance
  = replica lag peak

所谓低谷实际上是数据库最忙时段。

负载可回放性

为了比较环境,保存:

schema/version
data generator or anonymized snapshot identity
query fingerprints
parameter/selectivity distribution
arrival model
connection/pool model
background jobs
warm/cold cache protocol
duration
random seed
success and correctness golden

只保存一条 pgbench -c 100 命令不足以代表业务。

sandbox 的资源结论边界

本章为 Vagrant sandbox 建议每台至少:

>= 2 logical CPUs
>= 2 GiB memory
>= 8 GiB root free
swap = 0

这些只是教学环境的建议下限,不是 pg36_shop 的生产容量规格。正式实验中, 三台 pg-test VM 只有 1 个 vCPU、约 1.9 GiB 内存;部署虽然完成,但必须以 EX19-LAB-RESOURCE-FLOOR 记录偏差,不能把成功运行反推为资源充足。

四台 VM 共享一台 laptop 的:

CPU
memory controller
physical storage
power
hypervisor
host network

因此任何 benchmark 都不能外推生产。

需求表中的 unknown

本章刻意不为以下项目造数字:

production QPS
production data growth
production storage latency
production connection budget
production batch window

它们进入第 24、26、27 章。部署基线的职责是让 unknown 可见,并阻止默认值被 误报为需求。

19.1.3 可用性、RPO、RTO 与维护窗口

四个概念先分开

availability
  服务在测量窗口内按定义成功的比例

RPO
  可接受的数据恢复点损失

RTO
  从场景发生到服务恢复到规定状态的时间

maintenance window
  允许计划变更及其用户影响的时间边界

它们相关,但不能互相替代。

三节点自动 failover 可能有较短可用性中断,却无法恢复昨天误删的数据;一天 一次 full backup 可能可恢复,却不提供当前 primary HA。

先定义成功请求

可用性分母和成功必须明确:

哪些 endpoint
哪些 operation
哪些用户/区域
什么状态码/SQLSTATE
正确性是否计入
延迟阈值
陈旧度阈值
测量位置
计划维护是否排除

如果数据库返回 200/row 但金额错误,不应算可用。

“几个九”换算为时间只是直觉

以 30 天窗口为例:

目标 粗略不可用预算
99% 7h 12m
99.9% 43m 12s
99.95% 21m 36s
99.99% 4m 19s

真正 SLO 仍由事件型 SLI 计算,不能只用服务器 uptime。

RPO 必须绑定故障场景

示例:

single replica loss       RPO 0
primary failover          async lag within measured policy
sync-confirmed commit     RPO 0 only in modeled sync failure domain
operator DROP             PITR target before error
storage corruption        last verified clean recovery point
region loss               cross-domain repository/standby position

“RPO=0”若没有场景和确认语义,就是不完整承诺。

应用还要知道:

client got success -> commit durability promise
client got timeout -> outcome unknown, must query by idempotency key

RTO 从开始点到结束点

RTO 的起点可能是:

physical failure
monitor detects
alert reaches human
incident declared
recovery decision

终点可能是:

database accepts connections
write service healthy
business golden passes
backlog caught up
all clients restored

如果不定义,两个团队报告的 RTO 可以相差整个检测与验证阶段。

建议分解:

[ RTO = detection

  • decision
  • execution
  • validation
  • traffic\ restoration ]

每段都能优化,也都可能失败。

HA RTO 与 restore RTO 不同

场景 路径
primary host loss detect → elect → promote → route → client retry
database deleted stop damage → choose target → restore → replay → validate
corrupt pages preserve evidence → classify → restore/rebuild → validate
region loss activate remote infra → restore/promote → dependencies → DNS

不要用 Patroni failover 的秒数回答整库恢复需要多久。

maintenance 是预算,不是免责

维护窗口应记录:

frequency
duration
notice
allowed impact
rollback deadline
business blackout dates
owner approval
post-check

即使计划维护被 SLO 排除,用户损失仍存在。高成熟度平台会:

  • 滚动维护;
  • 验证连接恢复;
  • 限制每次 blast radius;
  • 保留 rollback;
  • 记录实际中断;
  • 复审窗口是否足够。

升级窗口必须包含回退判断

不只计算安装时间:

preflight
backup/recovery point
traffic drain
package/schema change
restart/failover
application golden
observation
rollback or forward decision

有些 PostgreSQL major upgrade 在数据目录切换后没有简单 rollback;最后可逆 点必须写清。第 30 章专门演练。

服务目标从损失与成本共同推导

目标越强,通常需要:

more independent replicas
synchronous distance/latency trade-off
more recovery copies
more frequent drills
more on-call coverage
more capacity headroom
more change discipline

不能只问“技术上能否做到”,还要问业务是否愿意持续支付,以及组织能否操作。

本章不通过第 20/21 章

第 19 章只验证环境前提:

hosts distinct
versions/initialization uniform
declared and live topology agree
endpoints exist
roles/services active
exceptions explicit

它不注入故障,不执行 failover,不做 restore。因此:

ch20-ha = pending
ch21-backup-restore = pending

sandbox 的准确结论

本章四节点能证明:

one pg-meta member
three pg-test members
one live leader
two live replicas
one offline-query declaration
Pigsty inventory/host/Patroni/SQL facts agree

不能证明:

four production failure domains
production RPO/RTO
production storage durability
production capacity
production secret lifecycle

因为所有 VM 共享物理 laptop,etcd 与 backup target 也各只有一个控制节点。

把例外当结构化结果

requirements.json 固定六项 sandbox exception:

EX19-SHARED-HYPERVISOR
EX19-SINGLE-ETCD
EX19-SINGLE-BACKUP-TARGET
EX19-VIRTUAL-STORAGE
EX19-INVENTORY-SECRETS
EX19-LAB-RESOURCE-FLOOR

通过结果必须写:

sandbox_l2=accepted-with-exceptions
production_ch19_gate=pending

“验收通过”后面没有范围,是一种危险省略。


返回本章目录 · 下一节:计算、内存、存储与网络 · 查看全书目录 · 查看索引中心

19.2 计算、内存、存储与网络

资源规划不是列一张“推荐配置”。它要把 workload 的每种稀缺资源与一个可 观察的饱和点连接起来。

19.2.1 CPU 核数、频率、NUMA 与虚拟化

核数与单核性能解决不同问题

更多核心有利于:

更多并发 backend
parallel query
autovacuum workers
backup compression
replication/application workers
多个实例/服务

更强单核性能有利于:

单条不可并行执行路径
短 OLTP tail latency
锁临界区
表达式/PL 执行
单 WAL 路径中的部分工作

不能用总 vCPU 数替代 CPU 型号、频率、代际与持续性能。

SMT 线程不等于物理核心

操作系统报告 32 CPU,可能是:

16 physical cores × 2 SMT
32 physical cores
oversubscribed 32 vCPU
burstable quota

基线记录:

lscpu --json
nproc
cat /sys/fs/cgroup/cpu.max

以及 hypervisor/cloud 的:

vCPU entitlement
steal time
credit/burst policy
dedicated/shared
pinning

PostgreSQL 并行 worker 数应基于实测吞吐和并发,不按 nproc 自动拉满。

CPU 饱和要看排队

观察:

utilization
runnable queue
steal/throttle
per-process CPU
context switches
frequency
query latency/throughput

CPU 100% 但吞吐继续线性增长,和 CPU 70% 但 cgroup throttling/steal 很高, 不是同一问题。

NUMA 使“总内存/总核心”失去均匀假设

多 socket/NUMA 系统中:

CPU attached to local memory node
remote memory access latency higher
device/interrupt locality differs
kernel allocation can become uneven

记录:

lscpu
numactl --hardware
cat /sys/devices/system/node/node*/meminfo

还要确认 BIOS/VM 的 NUMA 暴露、进程/IRQ/pinning 策略。

不要无条件“禁用 NUMA”。小单节点、超大多 socket、VM、容器的最佳策略可能 不同。变更要有 workload 实验。

本章 Vagrant VM 每台只报告一个 NUMA node,因此只能验证采集与同构,不能 形成大型 NUMA 结论。

虚拟化要记录资源保证

虚拟机的抽象层可能引入:

CPU oversubscription
steal
memory ballooning
host swap
virtual disk cache
noisy neighbor
live migration pause
shared physical failure
time drift

容器还要记录:

CPU quota/cpuset
memory.max
OOM policy
huge page access
ephemeral filesystem
PID/file limits
host network/storage

“8 vCPU/32 GiB”若没有 guarantee 与 failure domain,只是一个接口数字。

指令集与架构是兼容矩阵的一部分

本章正式 sandbox 是:

architecture=aarch64
OS=Ubuntu 24.04.x

所有节点必须相同。生产扩展 package、JIT、compression、加密库与备份恢复 都要覆盖目标架构。不能假设 x86_64 上测试的二进制扩展会在 ARM 恢复目标上 存在。

CPU 基线不是调优

第 19 章只记录:

logical count
model
NUMA node count
virtualization
kernel/cgroup identity

第 26 章测饱和,第 27 章才改变并行和参数。看到核心数不等于知道 max_parallel_workers_per_gather 应设多少。

19.2.2 内存预算、页缓存与 OOM 边界

PostgreSQL 使用多类内存

shared_buffers
WAL buffers
backend private memory
work_mem per operation
maintenance_work_mem
autovacuum_work_mem
temp_buffers per session
extension/background worker memory
connection/process overhead
OS page cache
kernel/network/filesystem
monitoring/backup/proxy

只算 shared_buffers + max_connections × work_mem 仍然过度简化。

页缓存与 shared buffers 共同工作

PostgreSQL 使用自己的 shared buffer cache,也依赖操作系统 cache。官方 Resource Consumption 给出 shared_buffers 的起始建议,同时说明 PostgreSQL 还依赖 OS cache。

这意味着:

  • 不应把全部 RAM 分给 shared_buffers
  • 文件系统/backup/extension 也需要 cache;
  • database cache hit 不等于没有底层 I/O;
  • VM host cache 还可能再加一层;
  • cold/warm benchmark 要定义清楚。

乘法内存必须按峰值并发

近似账本:

[ M_{total}

M_{shared}

  • M_{OS}
  • N_{backend}M_{backend}
  • \sum work\ nodes
  • M_{maintenance}
  • M_{other}
  • headroom ]

work_mem 是每 sort/hash 节点的基础预算;并行 worker 与多个节点会放大。

平台应按 role/workload class 设置,而不是一个全局大值:

OLTP runtime
admin migration
offline analytics
maintenance

max_connections 是内存与调度承诺

每个 PostgreSQL connection 对应 backend process。大量 idle connection 也有:

process/page table
backend state
locks/proc arrays
TLS/socket
extension/session state

大量 active connection 会导致 CPU 排队与 cache 抖动。

规划顺序:

safe active concurrency
  -> backend budget
  -> reserve admin/monitor/replication
  -> pooler server pool
  -> application pool totals
  -> client queue/timeout

不是先把 max_connections 改成 5000。

OOM 不是一种可接受的流量控制

当 Linux OOM killer 选择 PostgreSQL 进程:

  • backend 被杀;
  • postmaster 可能触发所有 backend 重启;
  • client 事务中断;
  • recovery/checkpoint 增加恢复时间;
  • HA 可能触发切换;
  • evidence 可能被噪声覆盖。

应使用:

capacity headroom
cgroup/systemd memory boundary
connection/active query budget
work/temp limit
load shedding
monitoring

在系统被 OOM 前拒绝或排队。

overcommit 与 swap 要显式

记录:

sysctl vm.overcommit_memory
sysctl vm.overcommit_ratio
sysctl vm.swappiness
cat /proc/meminfo
systemctl show ... memory controls

策略取决于宿主/容器/工作负载,但未知是不可接受的。

本章 sandbox 要求 guest SwapTotal=0,同时承认 macOS host 仍可能压缩/交换 VM 内存;VM 内看到无 swap 不证明物理主机不会产生内存压力。

Transparent Huge Pages 与显式 huge pages 不同

PostgreSQL 18 官方 Resource Consumption 说明 Linux THP 在一些环境中会导致性能下降,目前不鼓励;这与 PostgreSQL 显式 huge_pages 不是同一机制。

采集:

cat /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/defrag
cat /proc/meminfo | grep Huge

Pigsty sandbox 的预期:

THP enabled current=never
THP defrag current=never
explicit hugepage count may remain 0

若要启用显式 huge pages,应按 PostgreSQL Managing Kernel Resourcesshared_memory_size_in_huge_pages 计算并验证,不靠固定百分比模板。

内存 floor 与 production sizing 分离

2 GiB 是本章建议的教学下限;正式沙箱对低于该值的三台 VM 记录了 EX19-LAB-RESOURCE-FLOOR,而没有把建议偷偷降格。production 仍需依据:

working set
peak connections
query node memory
maintenance overlap
HA/failover state
OS/agent budget
growth
headroom

通过 floor 不代表容量 gate 通过。

19.2.3 IOPS、吞吐、时延、容量与冗余

四个存储指标互不等价

IOPS       每秒操作数
throughput 每秒字节
latency    单次完成时间及分布
capacity   可用字节与增长空间

小随机 WAL/fsync、索引随机读、大顺序扫描、backup stream 的瓶颈不同。

“云盘 20,000 IOPS”不说明:

  • block size;
  • read/write mix;
  • queue depth;
  • P99 latency;
  • burst duration;
  • fsync/FUA;
  • shared cap;
  • failure behavior。

PostgreSQL 的典型 I/O 路径

路径 特征
WAL write/flush 小、顺序、durability latency 敏感
checkpoint 大量脏页写、可能 burst
heap/index read random/sequential 混合
temp spill 大量短寿命读写
vacuum 扫描 + index cleanup + WAL
backup 大顺序读 + network + repository write
replica WAL receive/write/replay + data I/O
restore repository read + data write + WAL replay

需要同时测生产 workload 和维护/故障状态。

平均延迟会隐藏 tail

保存:

P50/P95/P99/max
queue depth
utilization
read/write latency
fsync latency
throughput
errors/timeouts

交易 commit 通常对 tail latency 更敏感;一次 2 秒 flush 可能制造级联 queue。

文件系统与设备语义要完整记录

基线:

lsblk --json -o NAME,TYPE,SIZE,FSTYPE,MOUNTPOINTS,ROTA,MODEL
findmnt --json
df -B1
mount

同时从基础设施层记录:

local/network/block/object
RAID/replication
write cache and power-loss protection
discard/TRIM
snapshot behavior
encryption
IO scheduler
cloud volume class
burst/credit
failure domain

数据库内部无法证明底层存储真正持久。

fsync=on 仍依赖硬件诚信

PostgreSQL 发出同步请求后,操作系统/设备必须诚实地把数据持久化。虚假 write cache acknowledgment 会破坏 WAL 设计前提。

验收需要:

  • 合格存储/云服务语义;
  • power-loss protection;
  • 厂商/基础设施保证;
  • 故障测试与恢复;
  • checksums/备份/取证作为纵深。

不要用 fsync=off 解决 production latency;那是在更改持久性合同。

容量要给多个并发动作留空间

磁盘不能规划到 95% 常态:

database growth
index build/reindex
VACUUM FULL/table rewrite
major upgrade copy/link strategy
base backup staging
WAL/archive backlog
logical slot retention
temp spill
log/metrics
filesystem reserve

headroom 应按最坏的已批准维护/故障动作计算。

redundancy 不等于 backup

RAID/云盘副本:

覆盖部分设备故障
不覆盖误删、逻辑错误、很多软件损坏

PostgreSQL replica:

覆盖服务成员故障
复制已提交错误

backup/PITR:

提供历史恢复
依赖仓库、WAL、密钥、过程与时间

三者互补。

检查 checksum,但不要过度解读

PostgreSQL 18 initdb 默认启用 data checksums,可用 --no-data-checksums 关闭。checksum 能检测一类 页面静默损坏;它不纠正错误,也不覆盖:

WAL/archive completeness
application logical errors
所有内存/网络/文件损坏
backup recoverability
replica independence

本章要求每个成员 data_checksums=on,并把实际检测与修复留给第 28/35 章。

Vagrant 存储只能做流程验证

本章采集 root 与 /pg mount、总量/free、类型/options,但明确例外:

EX19-VIRTUAL-STORAGE

它不能为 production IOPS、P99、endurance、power-loss 或冗余签字。

19.2.4 时钟、DNS、带宽、防火墙与故障域

时钟是分布式证据的坐标

时钟影响:

TLS/certificate
日志关联
监控窗口
lease/election
backup/PITR target
业务时间
token expiry
incident timeline

每台记录:

timedatectl show
chronyc tracking
chronyc sources -v

验收:

timezone=UTC or Etc/UTC
NTPSynchronized=yes
source/offset within policy

“时间看起来差不多”不够。

数据库通常用 UTC 保存绝对时刻;用户展示时再按业务时区转换。OS 日志也应 统一可换算。

DNS 是服务依赖

记录:

authoritative zone
resolver path
TTL
negative cache
search domain
split-horizon
failover/update authority
monitoring

不要让 PostgreSQL 成员依赖一个单点、不可观察的外部 resolver。

/etc/hosts 适合固定 sandbox,生产服务发现要有 owner 与变更流程。

IP、hostname、service name 分层

node identity     node-1 / host UUID
instance identity pg-test-2
cluster identity  pg-test
service identity  pg-test-primary / replica / offline
business identity pg36_shop

客户端连 service,不连“当前 primary 的 IP”。IP 可以变,service 语义应 稳定。

带宽要算复制与恢复

网络预算:

client request/response
streaming WAL
base backup/rebuild
archive upload
restore download
monitor/log
external CDC/export
package deployment

恢复或新副本同步可能是最大流量。

若:

rebuild timedata byteseffective bandwidth rebuild\ time \approx \frac{data\ bytes}{effective\ bandwidth}

还要加 checksum、compression、I/O、WAL catch-up 与争用。链路标称带宽不是 effective。

网络延迟进入同步提交

同步副本确认需要跨 failure domain 往返。距离越远,commit latency 越高; 距离太近,则可能不覆盖目标故障。

所以同步位置不是“同城最好”或“跨区最好”,而是 RPO、延迟、可用性和故障域 共同取舍。第 20 章实测。

防火墙从允许关系生成

不要先“关防火墙排障”,再忘记打开。建立 flow matrix:

source destination port/protocol purpose auth
app PG service TCP business SQL TLS + role
PG member PG member TCP streaming replication role
Patroni etcd TCP/TLS DCS cert/credential
LB Patroni REST TCP role health network policy
backup repository TCP/TLS archive/restore scoped secret
monitoring exporters TCP metrics network/auth
admin hosts SSH control admin identity

规则要双向核对:inventory 声明、主机 firewall、cloud security group、实际 probe。

Pigsty 4.5 Node Parameters 说明其 firewall 模式与 intranet/public 策略;具体默认仍要从目标配置与主机 事实确认。

“四台机器”不等于四个故障域

故障域清单:

process
instance/VM
host/hypervisor
disk/controller/storage service
rack/power
switch/network
zone/datacenter
region
DNS/IAM/control plane
operator/configuration
backup repository/key

两个 node ID 可能共享除进程外的所有域。

本章四个 machine-id 确实不同,但它们共享:

one physical laptop
one hypervisor
one power source
one physical storage
one host network

所以 validator 同时要求:

machine identities distinct
EX19-SHARED-HYPERVISOR present
production SLO claim false

只通过前一项会制造错误结论。

DCS 与 backup 也有故障域

三节点 PostgreSQL + 单节点 etcd:

data members=3
DCS members=1

不是完整生产 HA。

备份放在同一 control VM/physical host:

backup copy exists
independent disaster copy does not

拓扑图必须画控制与恢复依赖,不只画 PostgreSQL。

本节的验收产物

主机采集器 remote_host_facts.py 只读输出:

hashed machine identity
OS/kernel/architecture/virtualization
CPU/NUMA
memory/swap/THP/overcommit
mount/free space
clock/NTP
addresses/DNS/firewall/listening ports
service and package versions

它记录事实,不自动执行任何 sysctl、mount 或 firewall 调优。


上一节:先写服务需求 · 返回本章目录 · 下一节:操作系统与主机基线 · 查看全书目录 · 查看索引中心

19.3 操作系统与主机基线

主机调优的最大风险,不是漏掉某个神奇参数,而是不知道当前系统实际是什么, 却批量套用一份来源、版本和目标都不明的模板。

19.3.1 文件系统、挂载、预读与透明大页

文件系统选择是一份兼容与恢复合同

记录:

filesystem type/version
mount source and target
mount options
block/sector size
discard
inode capacity
snapshot/reflink
encryption
quota
repair tooling
backup compatibility

选择 XFS、ext4 或其他支持文件系统,要基于目标 OS、存储、运维能力与 PostgreSQL/Pigsty 支持矩阵,不凭论坛结论。

数据、WAL、日志、备份与临时文件的路径

典型职责:

PGDATA
WAL (可能同盘或独立)
tablespaces
PostgreSQL logs
pgBackRest spool/repository
PgBouncer/Patroni logs
temp files (位于 relation tablespace)
monitoring/log storage
package repository

分盘不是目的。要问:

  • 故障是否独立;
  • I/O 是否真正隔离;
  • capacity 是否独立;
  • backup/restore 是否更复杂;
  • mount 缺失时会不会写进 root;
  • 监控是否覆盖每个 filesystem;
  • 权限和 SELinux/AppArmor 是否一致。

防止“挂载没上,目录还在”

危险场景:

/pg/data intended mount absent
directory exists on root filesystem
PostgreSQL starts and writes root disk
root fills
later mount hides wrong data

保护:

systemd RequiresMountsFor
mountpoint validation before service
expected device/filesystem UUID
directory marker
capacity sanity check
monitor filesystem identity, not only path

本章 capture 同时记录 target、source、fstype、options 与 free,不只记录 /pg 存在。

mount option 不应照抄

常见选项需要理解:

noatime
discard / periodic fstrim
barrier/durability defaults
inode/allocation
network filesystem sync/cache semantics

现代文件系统默认行为会变化。任何影响持久性或恢复的选项,必须有官方 文档、目标版本和故障测试证据。

预读依赖访问形状

OS/block device read-ahead 对顺序扫描可能有益,对随机 OLTP 可能放大无效 I/O。

记录:

lsblk -o NAME,TYPE,ROTA,RA,SIZE,FSTYPE,MOUNTPOINTS
blockdev --getra /dev/...

然后用:

OLTP random
analytics sequential
backup/restore stream
replica replay

分别测。不要把某 SSD 模板的 KB 值复制到所有设备。

I/O scheduler 也要与设备匹配

物理旋转盘、NVMe、virtio、cloud block 的队列和 scheduler 不同。

采集:

cat /sys/block/<dev>/queue/scheduler
cat /sys/block/<dev>/queue/nr_requests

改变前后同时看 workload latency、throughput、queue 和 CPU。

THP 与显式 huge pages

当前值:

grep . /sys/kernel/mm/transparent_hugepage/{enabled,defrag}
grep -i huge /proc/meminfo

本章要求 THP never,与 Pigsty node tuning 预期一致。PostgreSQL 官方 Resource Consumption 区分 THP 与显式 huge pages,并指出 THP 对部分 PostgreSQL 环境会造成性能 下降。

显式 huge pages 需要:

shared memory size
page size
nr_hugepages
postgres OS group/lock permissions
startup policy try/on
reboot/fragmentation behavior
monitoring

huge_pages=on 且数量不足,PostgreSQL 会拒绝启动;这是刻意强约束, 不能未经演练启用。

data directory 权限

PostgreSQL 官方 initdb 要求以最终 server owner 运行,不能 root 运行。目录应由 database OS user 拥有并限制权限。

检查:

namei -l "$PGDATA"
stat -c '%U %G %a %n' "$PGDATA"
findmnt -T "$PGDATA"

父目录权限同样重要。不要让普通应用用户读取 data files、WAL 或 backup。

19.3.2 用户、目录、权限、时间同步与日志

OS 身份与数据库身份分开

OS:

admin automation user
postgres service user
backup/repository user
monitoring/log agents

PostgreSQL:

bootstrap superuser
NOLOGIN object owner
runtime roles
monitor/replication/backup/admin roles

同名不代表同一身份。Peer authentication 才把 OS user 映射为 database role。

admin user 的前提要验收

Pigsty multi-node 部署需要管理用户可以:

passwordless SSH to targets
passwordless sudo
run Python/Ansible modules
write expected directories
reach package sources/infra

这是一项强权限。要用:

dedicated identity
key protection/rotation
source restriction
sudo policy
audit
break-glass
offboarding

本章 disposable Vagrant 用 vagrant + nopass sudo,只能在本地 sandbox 接受;production 必须重新设计。

目录清单是接口

记录每个组件:

owner/mode purpose retention
source/inventory admin IaC versioned/private
/pg/data postgres data authoritative
/pg/log postgres/agents component logs policy
/pg/backup backup repository/spool backup policy
/etc/patroni root/postgres HA config config
/etc/pgbouncer service pool config config
/etc/pgbackrest controlled backup config/secret refs config
/etc/haproxy root routing config

不要把目录路径写死进应用;通过 service/config 接口访问。

文件权限要从 threat model 验证

检查:

find ... -maxdepth ... -printf '%m %u %g %p\n'
systemctl cat <service>
systemctl show <service> -p User -p Group -p EnvironmentFiles
getfacl

特别关注:

private keys
password/config files
.pgpass / service files
backup cipher pass
Patroni REST credentials
HAProxy stats credentials
MinIO keys
Ansible inventory/vault

本章 live inventory 必须 0600,projection 不能含 secret value。

时间同步先于集群判断

chrony/其他 NTP 服务需要:

active
source reachable
synchronized
offset/jitter within policy
boot behavior
monitoring

只看 service active 不够;本章读取 timedatectl NTPSynchronized

时间跳变可能影响日志、lease、TLS、业务 timestamp 与 incident timeline。 数据库业务时间语义仍要用第 16 章的 UTC/event-time 合同。

hostname、machine-id 与 address

基线同时保存:

declared address
live hostname
hashed /etc/machine-id
live interface addresses

原因:

  • IP 可能被错误复用;
  • hostname 可能全部相同;
  • SSH config 可能把不同别名指向同一主机;
  • VM clone 可能复制 machine-id;
  • inventory 拼写可能连错环境。

本章准备部署时就发现,常规 SSH config 把 Vagrant 地址重写为本地转发端口; 只看命令参数不够。正式采集强制 ssh -F /dev/null 直连,并以 machine-id hash 证明四个目标不同。

hash 不让 evidence 暴露原始 machine-id,但仍能检测重复。

日志要有来源身份

每条集中日志至少关联:

timestamp + timezone
host/machine
service/component
cluster/instance
process/session
severity
request/query correlation where safe

不能只按 hostname,如果重装/复用会混淆。

日志容量与敏感性

PostgreSQL 日志可能含:

SQL text
parameters/data
roles/database
client addresses
errors
file paths

控制:

log_statement/log_min_duration choice
parameter logging/redaction
access
encryption
retention
export failure buffer
deletion/legal hold

“为了排障全量记录 SQL 与参数”可能制造数据泄露。

logrotate 与磁盘故障

要验证:

rotation
compression
retention
copytruncate vs reopen semantics
agent backpressure
local buffer
disk alert
component restart

日志不能与 PGDATA 互相填满;集中日志不可达也不应无限占用本地磁盘。

19.3.3 基线检查必须记录事实而非套用调优模板

基线与目标分两列

推荐格式:

observed desired status evidence
OS Ubuntu 24.04.4 Ubuntu 24.04.x pass host JSON
THP never never pass sysfs
swap 0 0 pass meminfo
filesystem ext4 virtual qualified prod storage sandbox exception findmnt
NTP synchronized synchronized pass timedatectl

事实不符合时:

fail
accepted exception with owner/expiry
planned remediation

不能把 desired 覆盖 observed 后再宣布通过。

“最佳实践参数”有上下文

网络文章常给:

vm.swappiness=1
vm.dirty_ratio=...
read_ahead_kb=...
noatime
hugepages=...
max_connections=...

每项必须问:

哪个 OS/kernel/filesystem/device?
哪个 PostgreSQL/version?
哪个 workload?
解决什么测量瓶颈?
副作用?
生效范围与重启?
如何回滚?
如何证明改善?

答不出就先记录,不变更。

配置层级要可追踪

一个值可能来自:

kernel default
distribution sysctl
cloud image
hypervisor
Pigsty role default
global inventory
cluster vars
host vars
manual change
runtime command

审计要保存最终事实和来源。只看 Git inventory 无法发现手工漂移;只看 sysctl -a 又无法知道下次重启会恢复成什么。

一次性探测命令也有风险

基线 collector 应:

read-only
bounded timeout
explicit target
no secret output
stable schema
known command allowlist
stderr/exit preserved

避免:

curl | sh
unbounded find /
dump all environment
print full config
copy /etc wholesale
run benchmark on production

本章 collector 不读取原始 machine-id 以外的 secret 文件;machine-id 只输出 SHA-256。

自动修复与验收分开

部署 playbook 可以改变主机;acceptance collector 只读。

好处:

  • 验收失败不会偷偷修复;
  • 能发现 automation 未覆盖的漂移;
  • 证据可由不同身份运行;
  • 重跑不会增加变更;
  • 故障现场可先保留状态。

task.sh all 不会执行 deploy.yml

运行两次不等于幂等证明

Ansible 第二次 changed=0 是强证据,但不是全部:

  • 外部 API 可能有副作用;
  • task 可能每次重启但未报告 change;
  • 数据初始化可能非幂等;
  • template 中动态值可漂移;
  • service 行为可能改变;
  • secrets/certificates 可能轮换。

需要同时比较:

changed/failed counts
service state
SQL/catalog identity
endpoint behavior
files/config hashes where safe

pending reboot 也是事实

包安装可能提示:

new kernel installed, running kernel still old

不要忽略。记录:

running kernel
installed candidate
reboot required
maintenance plan
post-reboot acceptance

是否立即 reboot 取决于授权和维护窗口,不应由 collector 自动作出。

例外要结构化

例外至少:

{
  "id": "EX19-VIRTUAL-STORAGE",
  "reason": "...",
  "production_impact": "...",
  "owner": "...",
  "expiry_or_scope": "sandbox only",
  "compensating_control": "no production claim"
}

本章 requirements 先固定 ID/reason/impact;production 使用时还必须补真实 owner、expiry 与 control。

基线漂移比较

每次变更前后比较:

host identities
OS/kernel/package
mount/storage
sysctl/limits
service versions/state
PostgreSQL init identity
topology/endpoints
exceptions

某些字段天然变化:

capture time
uptime
LSN
metrics counters
log size

比较器要分类,不能要求整个 JSON byte-identical。

本章采集 schema

remote_host_facts.py 输出稳定分类:

identity
kernel/OS/architecture/virtualization
CPU/NUMA
memory/THP/swap/overcommit
root and /pg mount/free
clock
network/firewall/ports
services
packages

动态运行指标留给第 25/26 章;本章是配置与环境基线。

最后的判断

主机基线的合格结论不是:

所有参数都等于模板。

而是:

对每个与服务目标有关的主机事实,我们知道 observed 值、来源、期望、 差异、证据和 owner;没有任何未解释差异被伪装成通过。


上一节:计算、内存、存储与网络 · 返回本章目录 · 下一节:版本与数据库初始化契约 · 查看全书目录 · 查看索引中心

19.4 版本与数据库初始化契约

安装包可以升级,初始化事实却不一定能在线改。版本与初始化契约要在第一次 创建数据目录前冻结,并在创建后从 PostgreSQL 自己读取回来。

本节正式契约是:

Pigsty release       v4.5.0
PostgreSQL major     18
captured patch       18.6 / server_version_num 180006
encoding             UTF8
locale/provider      C.UTF-8 / builtin
data checksums       on
block size           8192 bytes
WAL segment size     16777216 bytes
timezone             Etc/UTC
password verifier    scram-sha-256
TLS                  on

这里的 captured patch 是验收时观察值,不是允许集群成员长期运行不同小版本。

19.4.1 PostgreSQL、locale、collation、编码与 checksum

“PostgreSQL 18”不是完整版本身份

至少要区分:

major version       18
patch version       18.6
package release     distribution/vendor build revision
server build        configure options, compiler, architecture
client version      psql/libpq/driver
extension version   vector/PostGIS/... each separately
automation release  Pigsty 4.5.0

major 决定磁盘格式、系统目录、SQL/行为兼容边界;patch 主要承载 bug 与安全 修复。扩展和操作系统包又有各自版本。只写“PG18”无法重建环境。

从 server 读取:

SELECT version();
SELECT current_setting('server_version') AS server_version,
       current_setting('server_version_num')::integer AS server_version_num;

从 package manager、容器 image digest 或 artifact repository 记录 package 身份。不要用 psql --version 代替 server 版本;它只说明客户端。

database cluster、database 与 locale 的层次

PostgreSQL 官方 initdb 创建一个 database cluster:数据目录、共享系统目录和 postgrestemplate1template0。随后 CREATE DATABASE 通常从模板 复制。

因此初始化默认会向后传播:

initdb default
    -> template databases
        -> new database defaults
            -> per-column/per-expression COLLATE override

不要把 OS 的 LANG、cluster 默认和某个 database 的 locale 混为一谈。

encoding 解决字节到字符,不解决语言排序

UTF8 定义字符编码。它不自动定义:

  • “ä”排在什么位置;
  • 大小写转换规则;
  • 字符类别;
  • 模糊/前缀查询的索引语义;
  • Unicode 版本变化后的排序稳定性。

这些主要属于 collation/provider。

查当前 database:

SELECT datname,
       pg_encoding_to_char(encoding) AS encoding,
       datlocprovider,
       datcollate,
       datctype,
       datlocale,
       datcollversion
FROM pg_database
WHERE datname = current_database();

PG18 的字段反映 provider 与 locale;跨版本工具不要假设每个字段都存在。

collation 是数据语义,不是显示偏好

PostgreSQL 的 Collation Support 说明 collation 会参与:

ORDER BY / comparison
lower / upper / initcap
pattern matching
collatable expression
index ordering and uniqueness semantics

这会产生一个重要结论:

改变 collation provider 或版本,可能改变既有索引所依据的顺序;不能把 它当成客户端展示参数。

生产选择至少比较:

provider 来源 优势 需要管理的变化
builtin PostgreSQL 内建 跨 OS 可预测,部署依赖少 受 PostgreSQL major 语义约束
libc 操作系统 C library 与 OS locale 集成 OS/glibc locale 版本漂移
icu ICU library 多语言与定制能力强 ICU 版本、包与索引刷新

本章选 PG17+ 可用的内建 C.UTF-8。Pigsty 的 资源准备建议 也推荐 PG17+ 以它作为默认。这个选择偏向稳定、可预测的数据库基础排序; 它不声称提供每种自然语言的用户期望顺序。需要语言相关排序时,应明确 column/expression collation,并有业务样例。

C.UTF-8C 不能只看名字

需要记录:

locale string
locale provider
encoding
collation version
PostgreSQL major

相同 C.UTF-8 字符串由不同 provider 提供时,不应靠名称推断行为完全一致。 本章验收同时要求:

encoding=UTF8
locale_provider=builtin
datlocale/datcollate/datctype contains C.UTF-8

初始化时显式选择,创建后从目录复核

PG18 initdb 的 provider 默认仍是 libc;选 builtin 必须显式指定合法 builtin locale。不要因为 Pigsty wizard 给出了合理结果,就误记成 PostgreSQL 自己的默认。

等价意图可以表达为:

--encoding=UTF8
--locale-provider=builtin
--builtin-locale=C.UTF-8

在 Pigsty 中由 pg_encodingpg_locale 及生成的 Patroni bootstrap 配置承载。最终证据仍是 pg_database,不是模板文件。

checksum 检测静默页损坏,不创造副本

data checksum 在读页时帮助发现 I/O 系统造成的静默损坏。它不能:

  • 修复坏页;
  • 替代 backup;
  • 替代 replica;
  • 防止错误 SQL;
  • 证明 storage 持久性;
  • 覆盖 WAL 文件的所有损坏形态。

PG18 initdb 默认启用 checksum,也允许显式 --no-data-checksums。因此不能用“版本是 18”推断某个现存 cluster 已启用。

读取:

SHOW data_checksums;

SELECT datname, checksum_failures, checksum_last_failure
FROM pg_stat_database
ORDER BY datname;

on 表示检测机制启用,不表示从未发生 storage 问题;failure counter、 日志、scrub/backup 验证要一起看。

checksum 可以通过离线工具改变,但那仍是停机、容量和回退都要规划的 maintenance。对新平台,把它当初始化契约更简单。

本章的验证 SQL

postgresql-facts.sql 一次读取:

cluster_name
server_version_num
pg_is_in_recovery()
system identifier / timeline
checksum, block, WAL segment
timezone, password_encryption, SSL
database encoding/provider/locale
installed extensions

它以 JSON 输出,避免人眼从四台机器复制表格时错列。

19.4.2 WAL、页大小、扩展与认证前提

block size 属于二进制与磁盘格式合同

本章观察:

SHOW block_size;       -- 8192

8 KiB 是常见构建值。它影响 relation page、buffer、部分容量公式和工具 兼容。不要假设所有自编译发行版都相同,也不要把 OS filesystem block size 当成 PostgreSQL page size。

恢复、物理复制、低层工具与扩展需要与 server build 兼容。

WAL segment size 只能在初始化时选择

PG18 initdb --wal-segsize 接受 1–1024 MiB 的 2 次幂,默认 16 MiB,而且只能初始化时设置。

本章冻结:

SHOW wal_segment_size; -- 16MB

segment 大小不改变 WAL 逻辑正确性,但会影响:

archive object granularity
directory file count
shipping/retention operations
monitoring formula
tool assumptions

改变它不是调一个 reload 参数,而是重建/迁移问题。

wal_level 等运行参数不是全部初始化事实

要区分三类:

类型 例子 典型改变方式
compile/init page size、WAL segment、system identifier、默认 locale rebuild/init/migrate
restart shared_preload_libraries、部分 WAL/worker 参数 rolling maintenance
reload/session 许多 planner/logging/timeout 参数 controlled reload/role policy

同样一个配置文件中的两行,变更成本可能完全不同。变更系统应记录 context

SELECT name, setting, unit, context, pending_restart, source, sourcefile
FROM pg_settings
WHERE name IN (
  'wal_level',
  'max_wal_senders',
  'max_replication_slots',
  'shared_preload_libraries',
  'password_encryption',
  'ssl'
);

system identifier 界定物理 cluster 身份

pg_control_system() 可读取 system identifier:

SELECT system_identifier,
       pg_control_version,
       catalog_version_no
FROM pg_control_system();

本章要求:

pg-meta members   one system identifier
pg-test members   another system identifier
two clusters      identifiers differ

它能揭露把同一 data directory/复制链误报成两个服务单元的错误。它不是 secret,但属于运维身份;对外报告可只保存关系或受控 evidence。

timeline 不是 cluster identifier。promotion 后 timeline 可以改变,system identifier 保持。第 20 章会使用这个区别。

扩展契约从 package 开始,不从 CREATE EXTENSION 开始

对每个扩展记录:

supported PG majors
OS/architecture packages
package and extension version
shared_preload requirement
dependencies
superuser/trusted install boundary
backup/restore behavior
physical replica parity
rolling/minor/major upgrade path
license/security owner

四个节点能启动 core PostgreSQL,不表示某个动态库在 future promotion candidate 上存在。package parity 应在部署时验收;catalog 中 pg_extension 只说明当前 database 安装了什么。

SELECT extname, extversion, extnamespace::regnamespace
FROM pg_extension
ORDER BY extname;

第 14 章负责扩展生命周期;本章只冻结部署前提。

shared_preload_libraries 是重启边界

本章观察:

pg_stat_statements, auto_explain

预加载库会进入 server 生命周期。新增/移除通常需要 restart,并必须在每个 可能承接 primary 的节点有兼容库。配置字符串一致还不够:

package file exists
linker dependencies resolve
library matches PG major/architecture
startup succeeds
replica parity holds

authentication method 与 password verifier 是两件事

password_encryption=scram-sha-256 控制新密码保存为什么 verifier;真正 允许哪类连接,由:

listen_addresses / port / TLS
pg_hba.conf rule order
database
role
source address
auth method
client driver support

共同决定。

initdb --auth-* 会生成初始 HBA,但 Pigsty 随后声明式管理业务、复制和 管理访问。不要把 initdb 选项当作最终 access policy。

SCRAM 迁移需要客户端矩阵

本章新环境要求:

password_encryption=scram-sha-256
HBA methods use intended SCRAM policy
all drivers/pools support SCRAM
old MD5 verifier rotation is planned

只改 password_encryption 不会重写既有角色密码。role 需要重新设置密码才 生成新 verifier。不要查询或导出 pg_authid.rolpassword 到 evidence。

TLS on 不是 TLS 完成

SHOW ssl = on 仅证明 server 可接受 TLS。完整合同还要在第 31 章验证:

certificate identity/SAN
trust root
private-key permission
expiry/rotation
minimum protocol/cipher
client sslmode and hostname verification
revocation/incident process
plaintext path policy

本章只把 ssl=on 作为部署前提,不宣称传输安全审计已经完成。

不安全的初始化捷径

生产拒绝:

initdb --auth=trust
initdb --no-sync
unknown locale inherited from shell
checksum disabled without ADR
plaintext/generated default password published in Git
mixed PG major physical replicas
extension binary only installed on current primary

官方文档明确把 --no-sync 定位为测试用途;系统崩溃可能让初始化后的目录 损坏。自动化快几秒不是持久性理由。

19.4.3 版本矩阵、升级窗口与勘误入口

一条“版本号”要展开成兼容矩阵

建议基线:

当前身份 兼容/升级问题
OS Ubuntu 24.04.4 aarch64 kernel、glibc、OpenSSL、locale
automation Pigsty v4.5.0 exact tag inventory/schema/playbook behavior
PostgreSQL 18.6 patch rollout、major upgrade
HA Patroni package/version DCS/API/config compatibility
DCS etcd package/version quorum、snapshot、client compatibility
proxy/pool HAProxy/PgBouncer protocol, auth, routing semantics
backup pgBackRest + repo format restore target/version
extensions per extension PG ABI, SQL update scripts
clients driver/pool version protocol, SCRAM, TLS, type behavior
observability exporter/dashboard rules metric name/label changes

矩阵必须标:

supported
tested
deployed
deprecated
exception
owner
next review

“支持 PG18”不等于“当前所有 extension build、driver 和 restore path 已在 PG18.6 测过”。

primary 与 standby 尽量保持同一 patch

PostgreSQL standby planning 指出 physical log shipping 不能跨 major,并建议尽量保持相同 release level。短暂 rolling patch 差异需要:

  • 官方升级说明;
  • package availability;
  • replica-first 顺序;
  • rollback/forward-only 判断;
  • extension parity;
  • promotion eligibility;
  • 限定窗口与监控。

不能把“minor 通常磁盘格式兼容”扩大成无限期混跑承诺。

patch 与 major 使用不同 runbook

patch upgrade 常见路径:

read release notes
stage package parity
upgrade replicas
restart/rejoin/observe
move service or controlled switchover
upgrade former primary
verify

major upgrade可能需要:

pg_upgrade
logical replication
dump/restore
new cluster + migration
extension upgrade
statistics/index refresh
application compatibility
cutover/rollback boundary

本章只建立矩阵;第 30 章执行版本升级。

maintenance window 不只是“可以重启”

窗口要写:

allowed customer impact
change start / latest abort / end
required replicas and headroom
backup/recovery prerequisite
traffic drain and connection behavior
replication catch-up limit
rollback point
owner and incident escalation
post-change observation

如果升级需要 40 分钟,而窗口只有 30 分钟,“尽量完成”不是计划。

source pinning 与 dirty working tree

本章部署没有直接使用维护者本地 dirty Pigsty checkout,而是:

resolve exact tag v4.5.0
record commit 2d5a45f759274048de0c197829228a71d0182e5c
git archive exact tag to private temporary directory
generate private inventory there
deploy from that archive

这避免把未提交修改偷偷带进正式证据。

source pinning 仍不是 supply-chain 完整方案。生产还要验证:

trusted upstream/repository
tag/release artifact authenticity
artifact checksum/signature
package repository snapshot
SBOM/vulnerability response
internal promotion process
retention/rebuild ability

文档与实验也要有版本范围

书中命令页应标:

last verified date
PostgreSQL major/patch
Pigsty release
OS/architecture
lab schema/release
known exceptions

本节验证日期是 2026-07-29。读者使用更新版本时,应先查官方 release note 和参数文档,不应因为 URL 仍可访问就假设行为不变。

勘误入口是一条可执行路径

发现差异时记录:

claim ID
book page/anchor
observed version and platform
reproduction
expected versus actual
official source
severity
workaround
owner
target release

修订后要更新:

  • 正文;
  • lab requirements/baseline;
  • validator 与 negative case;
  • verified matrix/date;
  • migration note。

只改一句 prose 而不改 validator,会让书和实验分叉。

初始化契约的 release gate

本章正式 validator 要求四个 PostgreSQL 成员都满足:

server major=18
encoding=UTF8
provider=builtin
locale=C.UTF-8
checksums=on
block=8192
WAL segment=16777216
timezone=Etc/UTC
password_encryption=scram-sha-256
ssl=on

并运行反例:

disable-data-checksums -> E_PG_INIT

这证明规则能拒绝一个已知错误,不只证明正常样本能被脚本读出来。

通过后的结论仍是:

initialization_contract=accepted-for-this-sandbox
major_upgrade_readiness=not-tested
production_security_gate=pending
production_ch19_gate=pending

上一节:操作系统与主机基线 · 返回本章目录 · 下一节:拓扑、命名与故障域 · 查看全书目录 · 查看索引中心

19.5 拓扑、命名与故障域

三台服务器不是高可用拓扑,除非我们知道三台分别会因什么而一起失败。 “主库、从库、VIP”也不是足够精确的词表,尤其在 promotion 之后。

19.5.1 主节点、同步副本、异步副本与仲裁

primary 是当前角色,不是机器身份

PostgreSQL physical replication 中:

primary   接受写入并生成 WAL
standby   持续恢复并接收/重放 WAL
hot standby  在恢复中提供只读查询

promotion 后,原 standby 可以成为 primary。于是:

pg-test-1 = stable member identity
primary   = mutable runtime role

把 hostname 叫 prod-primary 会在第一次切换后撒谎。

原生证据:

SELECT pg_is_in_recovery();
false -> 当前可写 primary 形态
true  -> 当前处于 recovery 的 standby

它不单独证明 service routing 正确,也不证明另一台没有同时可写。

physical standby 重放同一条 WAL 历史

同一 cluster 的成员应共享:

system identifier
compatible major/build/storage layout
timeline ancestry
WAL history
tablespace paths
extension binary prerequisites

官方 Log-Shipping Standby 强调 primary/standby 应尽量相似,physical log shipping 不跨 major。

本章用 system identifier 检查:

pg-test-1 == pg-test-2 == pg-test-3
pg-meta != pg-test

异步复制的 commit 不等待副本

streaming replication 默认异步。正常低延迟不等于零 RPO:

client receives commit
WAL may still be in primary-only failure domain
primary suffers unrecoverable loss
promotion candidate lacks last records

数据损失窗口由故障时实际 receive/write/flush/replay 位置决定,而不是 平时 dashboard 上“通常 0 MB”决定。

观察 primary:

SELECT application_name,
       client_addr,
       state,
       sync_state,
       sent_lsn,
       write_lsn,
       flush_lsn,
       replay_lsn,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_gap_bytes
FROM pg_stat_replication
ORDER BY application_name;

本章只确认 replica 在 streaming;第 20 章才测 failure-time 数据包络。

同步复制把一部分 commit latency 换成确认

PostgreSQL synchronous replication 可以让 commit 等待一个或多个 standby 反馈。等待层次包括:

remote_write
on / remote flush
remote_apply

它们不是同义词。remote_apply 还等待 replay 可见,延迟与阻塞代价更高。

配置又分:

FIRST n (...)   priority-based
ANY n (...)     quorum-based

同步复制只有在以下条件下才提供期望保护:

  • standby 是真实独立故障域;
  • storage flush 语义可信;
  • synchronous standby 处于 streaming;
  • application 没有降级为更弱 synchronous_commit
  • 超时/降级策略符合业务选择;
  • 失联时是停止写还是降级继续写已经定义。

“有 sync replica”不能省略这些条件。

同步复制也可能让写入不可用

若要求一个同步确认,而没有任何合格 standby:

transaction does work
commit waits
locks may remain held
application timeout/retry
load amplifies

因此 RPO 与 availability 之间要由 owner 选择:

fail closed: wait, preserve durability target
degrade: resume async, accept explicit data-loss risk

自动降级如果没有审计,就是悄悄改变服务合同。

“仲裁”不要与数据副本混淆

PostgreSQL core 没有一个保存业务数据的“仲裁节点”概念。Pigsty 的 HA 参考实现里:

Patroni  管理成员状态、leader lock 与操作
etcd     提供分布式配置存储/共识
PostgreSQL members 保存业务数据/WAL
HAProxy 依据 health checks 路由 service

etcd member:

  • 不保存可 promotion 的 PostgreSQL data;
  • 不确认每个业务 transaction 已持久化;
  • 不替代 backup;
  • 不依据最新 WAL 自动解决所有数据选择;
  • 需要自己的 quorum 与 failure domains。

把一个 etcd 节点叫“第三票”,然后声称两台 PostgreSQL 已实现零数据丢失, 是错误模型。

DCS quorum 与数据库成员数分别计算

三节点 PostgreSQL + 单节点 etcd:

database copies = 3
DCS members      = 1
shared laptop    = 1

三个数字不能互相抵消。单 etcd failure 会影响自动 HA 控制面;同一 laptop failure 会同时失去全部 VM。

本章正式沙箱把这两项登记为:

EX19-SINGLE-ETCD
EX19-SHARED-HYPERVISOR

所以它适合练拓扑与协议,不适合宣称 production availability。

offline replica 是 workload placement

Pigsty 支持专门的 offline instance,也支持在已有 replica 上设置 pg_offline_query: true,把它纳入 offline service。官方 Cluster / Instance 明确说明后一种是资源折中。

本章:

10.10.10.13:
  pg_role: replica
  pg_offline_query: true

它仍是同一 pg-test physical replica,不是独立 analytical authority。 标记不自动限制 CPU/I/O,也不自动阻止用户绕过 service 直连。第 17、22、 26、30 章分别处理分析语义、服务接入、容量和资源治理。

本章拓扑不是第 20 章结论

本章可以证明:

one Patroni leader
two streaming replicas
SQL recovery roles agree
declared endpoints reachable

不能证明:

leader loss detection time
fencing
split-brain exclusion
automatic failover RTO
failure-time RPO
client reconnection semantics
old primary rejoin safety

这些必须用受控 fault drill 取得。

19.5.2 节点、实例、集群、服务的统一词表

同一个“集群”常指四种东西

先建立词表:

本书定义 稳定身份/证据
host/node OS 实体:物理机、VM 或明确容器边界 machine ID、hostname、address
PostgreSQL instance 一个 server process + data directory + port member name、PGDATA、system ID
PostgreSQL database cluster 一个 initdb 产生的数据目录及其中 databases system identifier
HA cluster / Pigsty pg_cluster 一组共享 physical history、可相互接管的 members pg_cluster、Patroni scope
database cluster 内的 SQL database OID/name/owner/locale
service 面向客户端的稳定访问语义 name、port、selector、health check
pool 连接复用/状态边界 PgBouncer endpoint/mode
DCS cluster Patroni 使用的 etcd 共识域 etcd membership/quorum
platform deployment inventory 管理范围 exact inventory/release

PostgreSQL 官方把一个 data directory 称为 database cluster;平台团队又常把 一个 HA group 称 cluster。文档必须说明上下文。

node 不等于 instance

一台 node 可以运行:

PostgreSQL instance
PgBouncer
HAProxy
Patroni
exporters/Vector
infra components
多个独立实例(若平台允许)

一个 PostgreSQL cluster 也可以跨多 node。

“node down”与“Postgres down”故障面不同:

process crash
OS crash
VM pause
host failure
rack/zone loss
network partition
storage loss
control-plane loss

instance identity 不能只用 IP

IP 可以复用,DNS 可以漂移,hostname 可以被自动化改写。本章组合:

declared address
post-convergence hostname
hash(machine-id)
Patroni member name
PostgreSQL system identifier
pg_cluster
pg_seq

实验中发现一个真实陷阱:工作站 ~/.ssh/config 将四个地址映射到本机不同 转发规则,最初所有连接看起来都是 meta。正式采集强制:

ssh -F /dev/null ...

再验证四个不同 machine ID。连接成功不是目标身份成功。

service 是语义,不是 socket

Pigsty 默认 service 可能包括:

5433 primary
5434 replica
5436 primary direct
5438 offline

官方 Service/Access 记录这些默认映射。端口能建立 TCP 只证明 listener/reachability,不证明:

  • primary service 一定落在 current primary;
  • replica service 的 freshness;
  • pool transaction/session 语义;
  • role/database/HBA 正确;
  • TLS hostname 验证;
  • failover 后客户端恢复。

所以本章 endpoint probe 的解释字段明确写:

reachability and Patroni identity only
routing/pooling/failover/saturation belong to chapter 22

cluster name 不应含 current role

推荐稳定层次:

service unit   pg-test
member         pg-test-1 / pg-test-2 / pg-test-3
runtime role   primary / replica
service        pg-test-primary / pg-test-replica / pg-test-offline
database       test
application    pg36_shop

pg-test-primary 可以是 service 名,不应是固定 machine 名。

数据库里的 role 又是另一种 role

避免一句“role 是 replica”同时指:

Pigsty member role       pg_role
Patroni runtime role     Leader/Replica
PostgreSQL recovery role pg_is_in_recovery
SQL authorization role   pg_roles
business owner role      human/team responsibility

表格、变量名和 evidence 都写全称。

声明角色与观察角色都要保存

inventory 声明:

10.10.10.11: { pg_seq: 1, pg_role: primary }
10.10.10.12: { pg_seq: 2, pg_role: replica }
10.10.10.13: { pg_seq: 3, pg_role: replica, pg_offline_query: true }

观察:

Patroni leader/replica + state
SQL in_recovery
service health identity

第一次部署时二者应一致。发生 failover 后,inventory 的 bootstrap role 不应 被天真解释为永恒运行角色;第 20 章会定义 drift 语义。

19.5.3 环境、区域、租户与业务命名

命名要编码稳定属性

适合进入名字的:

environment
service/business domain
service unit
region/site
sequence

不适合进入固定 host 名的:

current primary
healthy
latest
temporary owner
current version
current ticket

稳定属性让名字在 role change 后继续真实。

一份可扩展命名模型

示例:

platform deployment  shop-prod-cn1
service unit         pg-shop-order
members              pg-shop-order-1..3
database             shop
NOLOGIN owner        shop_owner
runtime login        shop_app
services             pg-shop-order-primary
                     pg-shop-order-replica
                     pg-shop-order-offline

名字要满足:

  • PostgreSQL identifier 限制;
  • DNS label 限制;
  • metric label cardinality;
  • certificate SAN;
  • log/search 可读性;
  • automation group syntax;
  • rename cost。

environment 是权限与数据边界

常见:

dev
test
staging
prod
dr

不能只靠名字隔离。还要有:

account/project
network
credentials/CA
backup repository
monitoring tenant
data policy
change authority
failure domain

prod database 和 test database 放在同一个 cluster,通常仍共享 superuser、 WAL、storage、restart、capacity 和 incident blast radius。

region、zone、rack 必须对应可验证故障域

一个 label az-a 不等于独立 zone。登记:

provider/site ID
building/room/rack
power feed
top-of-rack/core network
hypervisor/host group
storage controller/array/replication
DNS/NTP/KMS/CA
DCS and backup dependencies

然后为每个 component 画依赖。相同依赖会形成 correlated failure。

本章四台 VM:

four machine IDs
one physical Mac
one hypervisor
one power source
one storage substrate

所以 failure-domain count 不是四。

tenant 边界不等于 schema 名

租户可能通过:

row
schema
database
cluster
account/project

隔离强度逐步变化,成本也变化。命名中加入 tenant 前,先定义:

  • authentication/authorization;
  • noisy-neighbor;
  • backup/restore granularity;
  • key ownership;
  • data residency;
  • deletion;
  • metrics/audit;
  • exit/migration。

本章 pg-metapg-test 是两个 service unit,不是两个 customer tenant。

业务名与技术名需要映射表

不要让应用团队猜:

业务对象 平台对象
pg36_shop service pg-test teaching service unit
canonical shop DB future pg36_shop provisioning target
control/validation pg-meta
OLTP write endpoint primary service contract
analytical read offline service contract

正式 inventory 当前还会创建 template 自带的 test/meta database。它们是 部署示范对象,不表示上卷的 pg36_shop fixture 已迁入这个下卷沙箱。

name registry 要防冲突与复用

登记:

name
type
immutable ID
environment
owner
created/retired
aliases
DNS/certificate names
monitoring labels
backup stanza
reuse quarantine

立即复用已退役 cluster 名可能让:

  • old DNS/cache 指向新服务;
  • backup stanza 混淆;
  • monitoring time series 串联;
  • client secret 意外生效;
  • automation limit 选错目标。

本章拓扑清单

topology.mmd 表达:

10.10.10.10 pg-meta-1  pg-meta primary + infra/etcd/MinIO
10.10.10.11 pg-test-1  pg-test primary
10.10.10.12 pg-test-2  pg-test replica
10.10.10.13 pg-test-3  pg-test replica + offline query

它同时画出共享 hypervisor。架构图若只画 database arrows、隐藏共同依赖, 会高估 availability。

命名与拓扑验收

validator 交叉检查:

address -> expected post-deployment hostname
address -> distinct machine ID hash
address -> inventory pg_cluster/pg_role/offline
address -> SQL cluster_name/in_recovery/system ID
cluster -> Patroni host/role/state

反例:

reuse-one-machine-identity -> E_HOST_IDENTITY
declare-two-live-leaders   -> E_TOPOLOGY

通过后的精确结论:

四个地址对应四个同构 Linux guest;pg-metapg-test 是两个不同 PostgreSQL system identifier;pg-test 有一个 leader 和两个 streaming replica;全部 guest 仍共享一个物理故障域。


上一节:版本与数据库初始化契约 · 返回本章目录 · 下一节:用声明式清单交付两个服务单元 · 查看全书目录 · 查看索引中心

19.6 用声明式清单交付两个服务单元

声明式交付的核心不是“YAML 很先进”,而是让目标、作用域、版本、差异与 执行证据可以评审。inventory 是意图,不是已经发生的事实。

19.6.1 inventory、参数模板与主机分组

inventory 同时承载图和参数

Pigsty inventory 的结构大致是:

all:
  children:
    infra:
      hosts: ...
    etcd:
      hosts: ...
    pg-meta:
      hosts: ...
      vars:
        pg_cluster: pg-meta
    pg-test:
      hosts: ...
      vars:
        pg_cluster: pg-test
  vars:
    version: v4.5.0
    pg_version: 18

它表达两类信息:

membership graph
  哪些 host 属于哪些 module/cluster

desired parameters
  version, paths, packages, tuning, users, DBs, services, access

混在一个文件里不表示它们的生命周期相同。host identity 可能多年稳定, password 要轮换,service definition 会迭代,初始化参数只在新 cluster 生效。

identity 参数必须明确

Pigsty 的参数层次包含 global、group/cluster、host/instance 等作用域。对 PostgreSQL member,核心 identity 至少有:

pg_cluster: pg-test
pg_seq: 1
pg_role: primary

官方 Pigsty parameter modelpg_clusterpg_seqpg_role 等视为无默认值的 identity 参数。

没有 identity,不应让自动化猜:

  • 这是哪个 HA group;
  • member 名是什么;
  • bootstrap primary 是谁;
  • service/monitor/backup 如何命名。

参数优先级要可解释

同一参数可能来自:

role default
global vars
module/group vars
cluster vars
host vars
extra vars
generated template
runtime dynamic config

最终值要能回答:

value
source
scope
owner
change context
rendered destination
live observed value

“我在 YAML 里搜不到”不等于它使用 PostgreSQL 默认;可能来自 role default 或 template。

本章 live inventory 没有重复声明每一个 role default。验收从 SQL 读取 checksum、locale、timezone 等结果,避免把“省略”误作“不确定”或 “一定是 PostgreSQL 默认”。

参数模板是起点,不是服务等级

pg_conf: oltp.yml 可提供合理 OLTP 起点,node_tune: oltp 可收敛主机 baseline。它们不包含业务 workload evidence。

模板不能自动知道:

peak TPS and query mix
working set
connection fan-out
storage latency
WAL generation
RPO/RTO
maintenance workload
tenant contention

第 26、27 章才通过压力与瓶颈证据调参。

从 exact release 生成配置

本章没有直接运行当前 dirty checkout,而是:

git archive v4.5.0

解到私有临时目录,并记录 tag commit:

2d5a45f759274048de0c197829228a71d0182e5c

然后:

./configure -c ha/full -s -n -g -v 18 -r default

参数含义:

-c ha/full   使用四节点功能演示模板
-s           跳过 IP 探测/替换
-n           非交互
-g           生成随机密码
-v 18        PostgreSQL major 18
-r default   默认上游仓库区域

ha/full 官方定位主要是演示与测试,不是生产拓扑模板;它把 infra、单 etcd、 MinIO 和 pg-meta 放在第一节点,pg-test 放在后面三节点。这正是本章 将其标为 sandbox 的原因。

generator output 必须 review

wizard 给出初稿后,逐项 review:

source release
target addresses and SSH user
OS/architecture
module membership
PostgreSQL major/locale/checksum
storage paths
cluster/member identity
service/offline placement
repo/mirror/proxy
secrets
safeguards
backup target
monitoring retention
firewall/access

configure 成功只表示生成文件,不表示目标可部署。

live inventory 是 secret-bearing artifact

随机密码比模板默认密码安全,但文件仍含 secret。处理:

mode 0600
private temporary/secret-managed path
never paste full file into issue/chat/log
never commit
rotate if exposed
production uses secret authority and lifecycle

本次初始化过程中,首次生成的凭据曾出现在工具输出边界;因此立即重新生成, 正式 live inventory 使用另一组未打印的值。这个事件提醒我们:

redaction 不是最后一步;command output、debug、diff 和 CI log 都是泄露面。

只导出 allowlist projection

inventory_projection.py 读取 live inventory,只保留:

Pigsty/PostgreSQL/locale non-secret globals
host set and group membership
cluster/member role/offline flags
source mode and a withheld source-fingerprint marker
safe projection checksum in the capture manifest
redacted secret-field count
secret_values_exported=0

它不使用“把已知 password 字段替换为星号后整份输出”的 denylist 模式。 denylist 容易漏掉新字段;allowlist 默认拒绝未知内容。

执行:

export PG36_CH19_INVENTORY=/absolute/private/pg36.yml
export PG36_EVIDENCE_DIR=/absolute/evidence/ch19
static/labs/ch19/task.sh project

投影不是可用于部署的 inventory,故意不可逆。

sanitized example 只表示形状

inventory.example.yml 使用 sentinel secret,帮助理解结构。它不应原样部署。

审阅 example 时也要防止:

真实 address/owner accidentally copied
有效 token/password
private key
internal repository credential
production backup endpoint

inventory 是 code,但不等于把一切都放 Git

适合版本化:

schema and groups
non-secret desired parameters
service definitions
role/database declarations without secret values
policy IDs
change history

需要受控 secret store:

password
private key
recovery secret
API token
CA signing key
KMS credential

需要外部事实系统:

asset/failure-domain inventory
IPAM/DNS authority
owner/on-call
certificate issuance
package artifact promotion

声明式不是单文件崇拜,而是让 authority 可追踪。

19.6.2 生产服务与隔离验证服务

先澄清本节标题的边界

生产设计应该区分:

application service unit
platform/control/validation unit

本章用 pg-testpg-meta 演练这种分工,但两者都位于 disposable sandbox:

production_data_permitted=false
production_traffic_permitted=false

所以 pg-test 是 production-shaped teaching service,不是生产服务; pg-meta 也不是合格的生产 control plane。

服务单元一:pg-test

声明:

pg-test:
  hosts:
    10.10.10.11:
      pg_seq: 1
      pg_role: primary
    10.10.10.12:
      pg_seq: 2
      pg_role: replica
    10.10.10.13:
      pg_seq: 3
      pg_role: replica
      pg_offline_query: true
  vars:
    pg_cluster: pg-test

目的:

three-member topology
primary/replica service rehearsal
offline placement rehearsal
chapter 20 fault target
chapter 21 recovery target
chapter 22 endpoint target

当前 bootstrap role:

.11 primary
.12 replica
.13 replica/offline

future failover 后 runtime role 可能变化;inventory declaration 与 observed role 的解释要跟着 chapter 20 contract 更新。

服务单元二:pg-meta

声明:

pg-meta:
  hosts:
    10.10.10.10:
      pg_seq: 1
      pg_role: primary
  vars:
    pg_cluster: pg-meta

同一 node 还承载:

Pigsty infra
single etcd member
MinIO
monitoring/logging
admin source

它可用于控制与验证,但这个共置形成大 blast radius。不要把“组件齐全”解释为 “组件高可用”。

两个 service unit 必须有不同 system identifier

部署时如果错误 clone 同一 cluster,再改名字,表面可能出现两个 group。 因此验收要求:

system_id(pg-meta) != system_id(pg-test)

同时:

system_id(pg-test-1)
= system_id(pg-test-2)
= system_id(pg-test-3)

这是 declaration 和 physical lineage 的交叉检查。

control data 与 application data 不要无意混合

真实平台需要决定:

Pigsty metadata DB 是否与业务 cluster 分离
monitoring outage 是否影响 DB availability
backup repository failure 是否影响 primary
DCS failure 是否影响 existing traffic/new failover
control credentials 是否能访问 business data
control-plane maintenance blast radius

本沙箱共置是资源选择,不是推荐生产答案。

offline 服务不等于隔离环境

.13 是 replica + offline query 标记。它仍:

接收同一 WAL
占同一 laptop CPU/storage
依赖同一 DCS
可能被 direct access
可能因慢查询产生 recovery conflict

要实现更强隔离,可能需要:

dedicated host/failure domain
cgroup/resource limits
separate service/HBA/role
query timeout
replication/freshness SLO
dedicated cluster or analytical system

production topology 要替换六个例外

从本章沙箱走向生产,不是删除 sandbox 字样。至少解决:

independent failure domains
etcd quorum
backup target/DR independence
qualified storage
secret authority/rotation
measured resource capacity

并完成后续章节 gate。

service unit review 表

问题 pg-meta pg-test
authority control/validation teaching application service
members 1 3
current primary .10 .11
replicas 0 .12, .13
offline placement no .13
system ID own shared within three members
production SLO none none
later fault target limited chapter 20

这张表应进入 design review,而不是从 Ansible recap 猜。

19.6.3 幂等部署、差异检查与失败重跑

幂等的正确含义

理想的 idempotent task:

run desired convergence once -> target state
run again without input/drift -> no material change

但整套部署包含:

package repositories
generated secrets/certificates
database initialization
backup creation
monitoring registration
external APIs
service restarts
time-dependent facts

所以不能用“Ansible 是幂等的”替代每个 action 的语义。

Pigsty 官方 Playbooks 说明大多数 playbook 可重复运行,同时指出清理参数和 *-rm.yml 等有重要 caveat。

一次完整部署的受控顺序

本章实际流程:

1. resolve/extract exact release
2. generate and review private inventory
3. direct SSH ping four identities
4. copy exact release + private inventory to admin node
5. bootstrap admin prerequisites
6. run ./deploy.yml -i pg36.yml
7. retain private log and return code
8. capture only safe recap
9. read-only L2 evidence capture
10. positive + negative validation and review

Pigsty deploy.yml 是 core chain 的 one-pass deployment。它完成很多工作, 不意味着每个业务 policy 自动满足。

recap 是执行证据,不是服务验收

本次安全 recap:

target ok changed unreachable failed
10.10.10.10 326 248 0 0
10.10.10.11 174 134 0 0
10.10.10.12 159 120 0 0
10.10.10.13 159 120 0 0
localhost 6 4 0 0

它证明 playbook 没报告 failed/unreachable。它不证明:

correct target identity
correct locale/checksum
one leader
replication healthy
service routing
production capacity
backup restore
HA behavior

这些另行验收。

changed=0 也不是唯一幂等标准

一些 task 合理地每次:

refresh facts
check service
render timestamped artifact
probe endpoint
rotate ephemeral state

也有 task 误报 changed。应关注:

unplanned service restart
config checksum drift
package version change
database reinitialization
role/permission mutation
secret rotation
endpoint outage

二次执行前先查 playbook/tag 的语义,不以追求漂亮 recap 为目标。

失败重跑前先分类

失败类型:

类型 例子 下一步
transient repo timeout、短暂 DNS 保存证据后 bounded retry
declaration wrong group/var 修 inventory,review diff
prerequisite sudo/clock/disk 修前提并重新 preflight
partial init primary created, replica failed 查 cluster state,按 role runbook
incompatible package/extension/OS 停止,修矩阵
destructive drift wrong target/data exists 停止并升级决策
secret exposure log printed credential 先 rotate/contain

不要在不知道已执行到哪里时直接:

rm -rf PGDATA
pgsql-rm
wipe/reconfigure all

失败现场是证据。

限制 scope

Pigsty playbook 支持 Ansible -l 与 tags。例:

./pgsql.yml -i private.yml -l pg-test
./pgsql.yml -i private.yml -l 10.10.10.13
./pgsql.yml -i private.yml -l pg-test -t pg_service

使用前必须确认:

inventory exact path
limit resolves to expected hosts
tag dependencies
check/diff support and limitations
serial/batch behavior
current member role
change authority

-l pg-test 是作用域控制,不是安全沙箱。

diff 不能泄密

安全 diff 分层:

secret-free projected inventory diff
rendered config hash/semantic diff
live pg_settings diff excluding secrets
package/version diff
service membership diff
policy exception diff

不要把 live inventory git diff、Ansible -vvv、template variables 或 .pgpass 直接上传。

automatic reboot 被刻意拒绝

bootstrap 观察到 meta 节点已安装 kernel 与当前 running kernel 有差异。这 可能要求 reboot 才完成 host baseline,但本章没有自动重启:

  • reboot 会改变运行状态;
  • control/infra/DB 共置;
  • 尚未建立第 20 章 HA 行为证据;
  • maintenance authority 未授予;
  • 本章 deployment 成功不要求偷偷消除 warning。

正确做法是登记 exception/change,计划可观察的 reboot,而不是为了让 preflight 变绿直接执行。

normal lab 没有 deploy action

task.sh 只提供:

project
capture
verify
review
all
reset:cluster

其中 all 只读。没有 deploy 是有意设计:

source-controlled 验收脚本不应在读者以为“检查环境”时顺便收敛 package、 重启服务或重建数据库。

部署需要独立变更窗口和 runbook。

removal playbook 是 destructive action

Pigsty pgsql-rm.yml 用于移除 cluster/instance。官方文档说明 production 应显式启用 pg_safeguard,并对 override 格外谨慎。

本章只提供多重 guard 的 reset-cluster.sh,没有运行它。它要求:

exact sandbox target
exact reset token
no production data assertion
clients drained assertion
exact release/inventory
prior machine-ID allowlist
fresh passing evidence
interactive second ACK

这是演示 destructive contract,不是授权。

交付完成定义

声明式交付完成需同时满足:

source pinned
inventory reviewed and secret-safe
target identity proven
playbook rc/recap retained
live host facts pass
PostgreSQL init contract pass
Patroni/SQL/inventory topology agree
endpoint identity observable
exceptions accepted by owners
rollback/reset boundary explicit
later gates remain named

本章环境满足 sandbox L2,带六个 exception;production gate 仍 pending。


上一节:拓扑、命名与故障域 · 返回本章目录 · 下一节:实战:L2 部署验收 · 查看全书目录 · 查看索引中心

19.7 实战:L2 部署验收

这次实战不是模拟输出。本书在四台本地 Ubuntu VM 上用 exact Pigsty v4.5.0 部署 PostgreSQL 18,并执行了正式的只读验收。读者可以复跑同一 合同,但不能把硬件与 secret 路径照抄。

19.7.1 核对版本、拓扑、资源、端点和安全入口

风险分级

正常动作:

project / capture / verify / review / all
risk = L0 read-only against deployed service
local writes = selected evidence directory only

它们不会:

deploy packages
render remote config
restart/fail over service
run DDL/DML
take/restore backup
remove cluster
export secret values

reset:cluster 是独立 destructive action,不属于正常实验。

正式目标

target               pg36-l2-vagrant
platform             local Vagrant Linux sandbox
Pigsty               exact v4.5.0 tag
PostgreSQL           18.6 observed
hosts                4
PostgreSQL clusters  2
members              4
address hostname after convergence service unit observed role
10.10.10.10 pg-meta-1 pg-meta primary
10.10.10.11 pg-test-1 pg-test primary
10.10.10.12 pg-test-2 pg-test streaming replica
10.10.10.13 pg-test-3 pg-test streaming replica/offline

先读实验合同

入口:

先确认:

你控制的是 disposable local sandbox
没有生产数据/流量
inventory 是 private mode-0600 文件
四个地址可以 direct SSH
当前只执行 read-only acceptance

生产环境不能因命令“只读”就跳过访问授权;主机和数据库 fact 本身也可能是 内部信息。

准备私有输入与 evidence 路径

export PG36_CH19_INVENTORY=/absolute/private/path/pg36.yml
export PG36_EVIDENCE_DIR=/absolute/private/path/evidence/ch19-run
export PG36_SSH_USER=vagrant

要求:

inventory file mode = 0600
evidence path outside source control
inventory contains no production credential
SSH user has intended read/sudo ability on this sandbox

本章不提供正式 live inventory。公开的 inventory.example.yml 只有结构和 sentinel。

先做 secret-free projection

static/labs/ch19/task.sh project

预期:

status=projection-ok
secrets=redacted

检查:

python3 -m json.tool \
  "$PG36_EVIDENCE_DIR/inventory-projection.json"

只检查字段,不把输出粘到公共日志。关键值:

status=secret-free-projection
source.mode_octal=0600
source.secret_values_exported=0
version=v4.5.0
pg_version=18
pg_locale=C.UTF-8
host_count=4

direct SSH 避免 alias/forwarding 冒充目标

采集器固定:

ssh -F /dev/null \
    -o BatchMode=yes \
    -o UserKnownHostsFile=/dev/null \
    -o StrictHostKeyChecking=no \
    vagrant@10.10.10.10 ...

禁用本机 config 是本实验对已知 alias 陷阱的防御。StrictHostKeyChecking=no 只适用于这个 disposable、隔离 sandbox;生产必须维护可信 host key,不应 复制这个选择。

正式 evidence 保存 machine ID 的 SHA-256,不保存原值:

four 64-hex hashes
all distinct
address/hostname exact match

hash 只是避免直接扩散标识,不是强匿名化;仍应把 evidence 当内部资料。

host capture 内容

每台生成:

hosts/<address>.json

包含:

OS/kernel/architecture/virtualization
CPU/model/NUMA count
memory/swap/THP/overcommit
root and data backing filesystem/free bytes
timezone/NTP
addresses/DNS/firewall service state/listening ports
selected systemd services
selected package versions

采集器最初尝试跟随 /pg symlink,目标是 PostgreSQL-owned mode-0700 目录,非特权用户得到 PermissionError。修正后采集 /data backing filesystem,不跨越 PGDATA 权限边界。好的 audit 不应为了“读事实”扩大 权限或放松数据目录。

host acceptance 与真实例外

四台均:

Linux / Ubuntu 24.04.x / aarch64
Etc/UTC + NTP synchronized
swap = 0
THP = never
root free >= 8 GiB
required services active

资源实际值:

pg-meta-1   2 vCPU, about 3.8 GiB RAM
pg-test-*   1 vCPU, about 1.9 GiB RAM each

原设计推荐 2 vCPU/2 GiB。没有把阈值悄悄改成“全部合格”,而是:

hard disposable-sandbox acceptance floor = 1 vCPU / 1.8 GiB
recommended teaching floor                = 2 vCPU / 2 GiB
EX19-LAB-RESOURCE-FLOOR                   = accepted exception

这允许验证部署关系,禁止容量推断。

PostgreSQL capture 内容

采集器通过 direct SSH,在每个 node:

sudo -n -iu postgres \
  psql -X -qAt --dbname=postgres --set=ON_ERROR_STOP=1

输入固定 postgresql-facts.sql,不拼接 live secret, 不读取业务 row。

生成:

postgres/<address>.json

验收:

PG 18
checksums on
block 8192
WAL segment 16777216
UTF8 / builtin / C.UTF-8
Etc/UTC
SCRAM verifier setting
SSL on
SQL recovery role
system identifier relation

Patroni 与 endpoint capture

patronictl list --format=json 生成:

patroni/pg-meta.json
patroni/pg-test.json

Patroni 表中的 healthy state 并不全叫 running

leader    state=running
replica   state=streaming

REST 根端点对 replica 可能返回非 2xx,同时带有效 Patroni JSON。采集器会 保存 HTTP status,并在 body 符合身份协议时标记 reachable,而不是把所有 HTTPError 都误判为网络失败。

endpoint 检查:

5432 direct postgres
5433 primary service
5434 replica service
5436 primary direct service
5438 offline service
8008 Patroni REST

只证明 TCP/identity 可观察,不登录业务 service,也不证明路由语义。

执行完整正常实验

static/labs/ch19/task.sh all

正式输出:

status=captured
target=pg36-l2-vagrant
hosts=4
secrets=redacted
production_approval=false

status=ok
target=pg36-l2-vagrant
deployment=pigsty-v4.5.0-postgresql-18
hosts=4-distinct
topology=pg-meta-1-primary+pg-test-1-primary-2-replicas
counterexamples=9-rejected
sandbox_l2=accepted-with-exceptions
production_ch19_gate=pending
mutation=none

任何缺行都不应靠人工补成通过。

19.7.2 从 SQL、主机与 Pigsty 三侧验证同一事实

实际是四个证据面

本节标题说“三侧”,但严谨验收还包括 declaration:

inventory  declared intent
host       machine and service substrate
SQL        PostgreSQL internal fact
Pigsty     Patroni/service implementation observation

四面回答同一问题:

问题 inventory host SQL Pigsty/endpoint
target 是谁 address/group hostname/machine hash cluster name/system ID member/host
版本 pg_version package server version Patroni REST server version
当前 role bootstrap pg_role process/service pg_is_in_recovery Leader/Replica
replica healthy member declared processes/ports recovery identity streaming
locale/checksum desired vars/defaults OS context database/settings n/a
service exists definitions listeners n/a TCP/REST

某一面冲突就停止,不做多数表决。

declaration 不能替代 observation

inventory 写:

pg_version: 18

可能出现:

package install failed
old server still running
wrong inventory targeted
host variable override
partial rerun

所以 SQL 必须返回 major 18。

同理,pg_checksum: true 或 role default 只表达意图,SHOW data_checksums 才表达运行 cluster 事实。

process/service active 不能替代 SQL

systemctl is-active patroni 返回 active,仍可能:

PostgreSQL startup failed and Patroni loops
member in catchup
wrong data directory
wrong cluster
timeline divergence
port occupied by another process

因此 service state 与 SQL/Patroni identity 同时要求。

SQL recovery role 不能替代 global coordination

两台各自执行:

SELECT pg_is_in_recovery();

如果都返回 false,SQL 只能说明两台都不是 standby;不能告诉你哪一台应当 获得流量,也不能自动 fence。Patroni membership/leader、DCS 和 service health 共同揭露冲突。

反例:

declare-two-live-leaders -> E_TOPOLOGY

validator 要求每个 cluster 恰好一个 leader,并与每台 SQL recovery 状态 一致。

system identifier 连接 SQL 与 topology

假设:

三个 Patroni member 名和 address 都对
但 pg-test-3 是错误初始化的新 data directory

role/status 可能短暂看似正常。system ID 检查会拒绝:

members of one cluster have different system identifiers

另一个反例是 pg-metapg-test 共用同一 system ID,也会拒绝。

endpoint open 不等于正确 service

本章所有声明端口 reachable。这是必要条件,不是充分条件。

第 22 章还要验证:

5433 write reaches current primary
5434 is read-only and uses intended replicas
5438 selects offline members
PgBouncer pooling mode/session state
TLS/auth/database/role
drain/reconnect/failover behavior

把端口扫描写成“service verification passed”会过度声明。

source checksum 防止验收脚本漂移

capture-manifest.json 保存 capture 时每个 lab source file 的 SHA-256。 review.py 再对当前 source 计算。

这样拒绝:

先 capture
后改 requirements/validator
用旧 evidence 宣称新规则通过

修改 lab source 后必须重新 capture。

positive test 与 negative test 是两类证据

正向:

当前环境满足规则

反向:

规则会拒绝我们关心的已知坏状态

九个反例:

case expected code
sandbox 声称 production SLO E_PRODUCTION_CLAIM
两地址复用 machine identity E_HOST_IDENTITY
混用 OS E_HOST_UNIFORMITY
clock 未同步 E_CLOCK
memory 低于 hard floor E_RESOURCE_FLOOR
checksum off E_PG_INIT
两 leader E_TOPOLOGY
inventory mode 0644 E_SECRET_FILE_MODE
evidence 导出 secret value E_SECRET_EXPORT

negative-cases.json 只修改内存中的 evidence 副本,不破坏 live cluster。

单独复核已捕获 evidence

export PG36_EVIDENCE_DIR=/absolute/existing/evidence/ch19-run

static/labs/ch19/task.sh verify
static/labs/ch19/task.sh review

verify 重新运行 policy;review 检查:

source checksums
positive report identity/counts/decision
negative code set
four distinct identities
resource exception exact hosts
endpoint evidence
sanitized deployment account
production boundary
reset_executed=false

人工 review 仍不可省

自动 validator 不知道:

  • owner placeholder 是否已变成真实责任;
  • laptop 是否处于受控物理环境;
  • business classification 是否正确;
  • exception 接受者是否有权;
  • package supply chain 是否可信;
  • future production SLO 是否合理;
  • 当前证据是否用于允许的目的。

机器验证 consistency,人类承担 judgment。

19.7.3 产出基线清单、风险例外与 reset:cluster

evidence bundle

一次 all 产出:

inventory-projection.json
capture-manifest.json
hosts/
  10.10.10.10.json
  10.10.10.11.json
  10.10.10.12.json
  10.10.10.13.json
postgres/
  <four member facts>
patroni/
  pg-meta.json
  pg-test.json
endpoints.json
validation-report.json
negative-report.json
review.txt

不包含:

live inventory
password/token/private key
customer rows
full deployment private log
raw machine ID
production approval

evidence directory 应按内部运维资料管理并设置 retention。

sanitized deployment account

deployment-run.json 保存:

exact source tag/commit
generator command
inventory mode and secret export count
bootstrap/Ansible version
deploy command and safe recap
observed acceptance summary
explicit non-claims
reset_executed=false

它方便读书,不替代 fresh capture。host role、package 和 endpoint 会漂移。

六个风险例外

exception 事实 阻止的生产结论
shared hypervisor 全部 VM 共用 laptop 四个独立 failure domains
single etcd DCS 单节点 control-plane HA
single backup target MinIO/control 共置 DR independence
virtual storage 未做 IOPS/latency/durability qualification capacity/durability
inventory secrets 临时 0600 inventory production secret lifecycle
resource floor 三节点低于推荐 2C/2G sizing/concurrency

exception 必须有:

ID
scope
reason
impact
owner/acceptor
expiry/review
remediation
claim blocked

本章 JSON 记录前四类核心字段;真实生产审批系统还要补责任与到期。

验收决策

正式结果:

{
  "sandbox_l2": "accepted-with-exceptions",
  "production_ch19_gate": "pending",
  "next_gate": "ch20-ha"
}

不是:

production-ready
HA passed
RPO/RTO achieved
backup verified
capacity approved
security compliant

reset:cluster 为什么存在

可重复实验必须说明如何回到起点。但删除 cluster:

停止服务
删除 PostgreSQL data
可能删除 backup state
改变 DCS/service/monitor registration
不可由正常 validation 自动恢复

所以 reset 是 destructive exercise,不是 cleanup convenience。

本章提供 reset-cluster.sh,但正式运行没有执行:

deployment-run.boundary.reset_executed=false

reset 多重 guard

需要显式设置:

export PG36_RESET_TARGET='pg36-l2-vagrant/pg-meta+pg-test'
export PG36_CONFIRM_RESET='RESET_CH19_L2_SANDBOX'
export PG36_NO_PRODUCTION_DATA=yes
export PG36_CLIENTS_DRAINED=yes
export PG36_PIGSTY_HOME=/absolute/exact/pigsty-v4.5.0
export PG36_CH19_INVENTORY=/absolute/private/pg36.yml
export PG36_MACHINE_ID_ALLOWLIST=/absolute/private/machine-ids.json
export PG36_RESET_EVIDENCE_DIR=/absolute/new/pre-reset-evidence

然后才可能:

static/labs/ch19/task.sh reset:cluster

脚本还会:

  1. 检查 exact v4.5.0 marker;
  2. 检查 inventory mode 0600;
  3. 要求新 evidence path;
  4. 先执行 fresh read-only all
  5. 将四台当前 machine hash 与预先 review 的 allowlist 比较;
  6. 要求 /dev/tty 再输入 exact token;
  7. 先移除 pg-test,后移除 pg-meta

任何 guard 缺失,exit 77,什么都不删除。

machine allowlist 不能由 reset 当场自我批准

machine-identity-allowlist.example.json 只有 sentinel。真实 allowlist 应:

从一次已接受 evidence 提取
由 operator review address/hostname/asset
存放 source control 外
限制权限
在 VM rebuild 后重新批准

若 reset 先读取当前机器、再自动把当前值当 allowlist,identity guard 就没有 外部 authority。

不要在 production override safeguard

Pigsty removal playbook 支持 pg_safeguard。production inventory 应显式 启用保护。override 是 emergency/destructive authority,不应复制到普通 runbook、CI 或 alias。

本书脚本的 guard 也不能让 production reset 变安全:

target classification、data ownership、recovery、change approval 与现场 判断仍然优先。发现任何可能是 production,立即停止。

本章结束时保留环境

为了第 20–22 章:

do not reset
do not fail over yet
do not restore
do not benchmark to saturation

保留 accepted baseline,下一章才能比较 fault 前后。

handoff 包含:

exact source/deployment account
fresh accepted evidence
six exception IDs
known bootstrap role
system-ID relation
no reset performed
pending kernel reboot observation
production gate pending

读者自检

完成本章后,应能回答:

  1. 为什么 deployment rc=0 不等于 production ready?
  2. 哪些初始化事实必须从 SQL 读取?
  3. 为什么四个 IP 需要 machine identity?
  4. 为什么 one leader + two replica 还不能给出 HA RTO?
  5. 为什么 endpoint open 不证明 routing?
  6. 如何在不泄露 inventory 的情况下 review topology?
  7. 六个 exception 各阻止什么结论?
  8. 为什么 reset 不属于 all

若任何答案只能是“因为 Pigsty 默认这样”,还没有完成环境基线。


上一节:用声明式清单交付两个服务单元 · 返回本章目录 · 下一章:狡兔三窟:高可用拓扑与容灾目标 · 查看全书目录 · 查看索引中心

20 狡兔三窟:高可用拓扑与容灾目标

三台 PostgreSQL 都在运行,不等于“高可用”;一次 patronictl list 显示一个 Leader,也不等于“不会脑裂”;计划切换没有丢行,更不等于 “自动故障转移零 RPO”。

本章把高可用收敛为一个可以审计的命题:

在明确的失败模型、提交语义与权限边界内,系统能否维持一条被授权的 可写历史,并在规定时间内把客户端带回可判断的服务状态?

我们先从 failure model、RPO/RTO 和 degradation target 出发,再进入 PostgreSQL 的 WAL、LSN、timeline、复制槽与同步提交;随后解释 Patroni、 DCS、租约与 fencing 如何组合;最后在第 19 章保留的 Pigsty v4.5.0 四机沙箱上完成一次有客户端证据的计划切换。

本章的正式结论很克制:

健康计划切换             通过,带十项沙箱例外
自动故障转移             未测试
硬件/进程/网络故障       未注入
零 RPO                   未证明
生产 RTO                 未证明
fencing / watchdog       未验收
生产批准                  pending

本章目标

读完并完成实验后,你应当能够:

  1. 先列故障域和共同依赖,再谈节点数与“几副本”;
  2. 正确区分 RPO、RTO、降级目标、维护切换时间与客户端恢复时间;
  3. pg_stat_replicationpg_stat_wal_receiver、LSN 与复制槽解释 物理流复制;
  4. 区分 system identifier、timeline、checkpoint timeline 与当前 WAL timeline;
  5. 解释异步、remote_writeonremote_apply 以及 FIRST/ANY 同步集合的保证和代价;
  6. 说明 Patroni leader lock、DCS、TTL、loop、candidate eligibility、 watchdog 与 fencing 分别解决什么问题;
  7. 区分 planned switchover、automatic/manual failover、rewind 与 rebuild;
  8. 把服务端点、会话断开、结果未知与幂等 token 放在同一个客户端合同中;
  9. 用 Pigsty 交付的角色、服务与原生 PostgreSQL 证据交叉验证;
  10. 设计一场有 preflight、mutation guard、证据、反例和复位的 HA 演练;
  11. 知道一次成功实验不能推出哪些生产结论。

前置与后续

前置:

后续:

学习路径

business loss
  -> failure model + common dependencies
      -> RPO / RTO / degradation contract
          -> WAL transport + replay + timeline
              -> commit acknowledgement policy
                  -> election authority + fencing
                      -> service routing + client semantics
                          -> guarded planned switchover
                              -> evidence + exceptions + next gate

这条路径故意不从“如何敲 failover 命令”开始。命令只是状态迁移的一个 触发器;如果故障、authority、数据风险与客户端完成条件没有先定义,操作 越快,越可能把错误历史更快地交给用户。

正式实验拓扑

target       pg36-l2-vagrant/pg-test
Pigsty       exact v4.5.0 tag
PostgreSQL   18.6
Patroni      4.1.3
DCS          one etcd member (sandbox exception)
members      pg-test-1 / pg-test-2 / pg-test-3
policy       asynchronous; synchronous_mode=false
watchdog     off (sandbox exception)
client       Pigsty primary service, 10.10.10.11:5433

角色迁移:

before          pg-test-1 primary, pg-test-2/3 streaming, timeline 5
forward         pg-test-2 primary, pg-test-1/3 streaming, timeline 6
restored        pg-test-1 primary, pg-test-2/3 streaming, timeline 7
lineage         one unchanged PostgreSQL system identifier

实验通过 Pigsty primary service 写入唯一 token,而不是直接连接“我们以为 是主库”的节点。正式观测:

probe attempts                    120
acknowledged                       95
outcome unknown                    25
acknowledged rows missing           0
unknown committed                   0
unknown reconciled absent          25
duplicate tokens                    0
forward patronictl command      2.735 s
forward action to stable        5.823 s
conservative sampled write gap  6.007 s

6.007 s 是一次健康计划切换下的采样写入间隙。它包含约 0.2 秒的 probe resolution,不包含故障检测,不是生产 RTO 分布,也不是 SLO。

十项例外

沿用第 19 章六项:

EX19-SHARED-HYPERVISOR
EX19-SINGLE-ETCD
EX19-SINGLE-BACKUP-TARGET
EX19-VIRTUAL-STORAGE
EX19-INVENTORY-SECRETS
EX19-LAB-RESOURCE-FLOOR

本章新增四项:

EX20-ASYNC-BASELINE
  synchronous_mode=false;不能声称 zero RPO

EX20-WATCHDOG-OFF
  未验证硬件 watchdog fencing

EX20-CLIENT-PROXY-NO-TLS
  沙箱外部 5433 只以 sslmode=prefer 验证;不能通过生产传输安全

EX20-PLANNED-ONLY
  只做 healthy switchover;不能声称 automatic failover、split-brain
  exclusion 或 failure-time RTO

例外不是“以后再看”的备注,而是直接阻止某类推论的逻辑条件。

本章目录

20.1 从失败模型设计高可用

20.2 物理流复制

20.3 同步策略与提交语义

20.4 选主、DCS 与防脑裂

20.5 切换、故障转移与重加入

20.6 交付并观察 HA 集群

20.7 实战:一次有证据的计划切换

实验入口

安全语义:

capture / verify / review / all
  L0 read-only

drill:switchover
  L2 local sandbox mutation
  exact target + no production data/traffic + explicit confirmation

reset:fixture
  destructive and separate
  never called by all or drill:switchover

普通 all 只验证已有证据。它不会为了“方便”重跑切换。

本章最重要的判断

replica exists             != RPO achieved
three nodes                != three failure domains
leader elected             != old primary fenced
Patroni healthy            != client service recovered
command returned           != application RTO
connection error           != transaction rolled back
planned switchover passed  != unplanned failover passed
no missing ack in one run  != asynchronous zero RPO
HA                         != backup / PITR

如果只记住一句话:

高可用的目标不是“尽快出现一个新主库”,而是在故障与不确定性中,只让 一条可解释、可追溯、被授权的历史继续接受写入。

权威参考


上一章:开天辟地:环境规划与部署基线 · 返回下卷导读 · 下一章:未雨绸缪:备份体系与恢复演练 · 查看全书目录 · 查看索引中心

20.1 从失败模型设计高可用

高可用设计最常见的错误,是先问:

“要两台还是三台?”

正确的第一问是:

“哪些东西会以什么方式一起失败,失败后业务允许留下什么状态?”

节点数是答案的一部分,failure model 才是题目。

20.1.1 进程、主机、磁盘、网络、机房与控制面故障

故障、失效与事故

先统一三个词:

fault
  某个组件偏离预期,例如磁盘超时、进程崩溃、链路丢包

failure
  系统因此无法完成合同中的能力,例如 primary 不能提交

incident
  failure 或高风险状态进入人的响应流程

一个 fault 未必立刻成为 service failure。反过来,组件都显示 running, 客户端也可能因 DNS、pool、证书或连接风暴而无法提交。

所以失败模型的 observation point 必须从“进程是否活着”扩展到:

client -> name/VIP -> proxy -> pool -> PostgreSQL
                                  -> WAL transport/replay
Patroni -> DCS -> member health -> role transition
backup/archive -> independent recovery history

不同 failure domain

层次 例子 可能影响 不能靠什么证明已覆盖
PostgreSQL 进程 crash、OOM、assert 单实例停止 三个 PID 都在
OS/主机 kernel panic、reboot、供电 主机上全部组件 三个 VM 名称
存储 device loss、stall、fs corruption 数据/WAL 不可读或卡死 RAID 标签
网络 丢包、分区、非对称路由 DCS、复制、客户端视图分裂 ping 一次
zone/rack switch/PDU/机架 一组主机共同失效 不同 IP
site/region 机房、运营商、灾害 整个本地 HA 集群 同城三副本
control plane etcd/API/DNS/PKI 不能安全选主或发现服务 PostgreSQL 可查询
client path HAProxy/PgBouncer/driver 数据库正常但业务不可用 Patroni 显示 leader
human/change 错 DDL、错配置、误删 正确地复制错误 replica healthy

每个 failure domain 至少写四件事:

detection
  谁、用什么信号、多久知道?

decision authority
  谁有权停止旧主、选择新主、接受数据风险?

containment/recovery
  restart、reroute、promote、rewind、rebuild 还是 restore?

proof
  什么证据表明业务能力恢复,而不只是组件换色?

“三节点”不是故障域证明

本章沙箱有三台 pg-test VM,但它们:

share one laptop
share one hypervisor
share host power
share much of the storage substrate
share one etcd member

因此:

Nmembers=3⇏Nindependent failure domains=3 N_{members}=3 \quad\not\Rightarrow\quad N_{independent\ failure\ domains}=3

如果宿主机休眠,三台 VM、DCS、proxy 与本地备份目标可能一起消失。形式上 的 replica count 没有覆盖共同依赖。

生产设计应显式记录 placement:

member -> host -> rack/PDU -> AZ -> region
DCS member -> AZ
proxy/VIP -> network domain
backup repository -> storage/account/region
operator access -> identity/control plane

“不在同一台机器”只是最低一层。

网络分区比断网更难

完全断网容易理解;危险的是不同观察者看到不同世界:

old primary can still serve some clients
old primary cannot update DCS
replica can reach DCS and become new primary
some clients continue reaching old address

此时目标不是让两边都“尽量可用”,而是避免两个可写历史同时接受不可合并 的提交。选主只决定谁获得 authority;还必须让失去 authority 的旧主停止 提交,或从客户端路径隔离。

存储故障不只有“磁盘没了”

真实存储 fault 包括:

hard error
silent corruption
latency spike
fsync hang
capacity full
inode exhaustion
read-only remount
controller/cache failure
correlated network storage loss

一个极慢但未死的 primary 可能比 crash 更难处理:

  • health check 仍偶尔成功;
  • leader loop 得不到调度;
  • shutdown 超过租约;
  • client 请求堆积;
  • WAL sender/replay 指标变得陈旧。

因此 failure injection 不能只有 kill -9 postgres。但本章也不会冒险把这些 场景一次性塞进共享 laptop 沙箱;它们被列入 failure-model.json,明确标记为 not-injected

控制面与数据面

至少区分:

data plane
  PostgreSQL 接收查询、提交、发送和重放 WAL

control plane
  Patroni + DCS 决定 leader authority 与配置

service plane
  DNS/VIP/HAProxy/PgBouncer 把客户端带到合适角色

recovery plane
  backup、WAL archive、restore tooling

DCS 不可用时,已有 PostgreSQL 连接可能暂时工作;这不意味着能安全进行 新一轮选主。数据库进程可用和自动 HA control 可用不是同一个布尔值。

共同模式故障

一个严肃的 failure model 会问:

是否共用同一电源?
是否共用同一存储控制器或云账户?
是否共用同一错误配置?
是否由同一自动化一次性重启?
是否共用同一证书、DNS、KMS 或 IAM?
是否都依赖一个人能登录?

“每台主机都健康”无法发现下一次发布会同时把三台配置写坏。

场景卡

每个场景用同一模板:

id: host-loss-primary
trigger: primary host becomes unreachable
scope: one independent host
assumptions:
  - DCS quorum survives
  - one eligible replica survives
  - client service can reach it
observable_start: first failed client write
expected_control_action: old authority expires, eligible replica promoted
data_boundary: defined by acknowledgement/sync policy
client_completion: read-write transactions succeed through stable endpoint
stop_conditions:
  - old primary may still be writable
  - candidate lineage is ambiguous
  - DCS has no decision authority
evidence:
  - DCS/Patroni history
  - system identifier and timeline
  - client tokens
  - old member fencing/rejoin

没有 assumptions 和 stop conditions 的“演练步骤”,更像破坏脚本。

本章实际覆盖什么

九个场景中,正式观察的只有:

planned-primary-maintenance
  no component failure
  healthy current leader
  named caught-up candidate
  controlled switchover
  controlled baseline restore

client-session-loss 得到一次采样观察,但完整 routing contract 留给第 22 章。其余故障不能从本次结果反推。

20.1.2 RPO、RTO、降级目标与数据风险

RPO 是允许退回多远

令:

t_truth  = 故障前业务认可的最新可恢复事实时间
t_point  = 实际恢复点

则时间口径的实际恢复点损失可写为:

RPOactual=ttruthtpoint RPO_{actual}=t_{truth}-t_{point}

但 PostgreSQL 复制更常先观测 WAL byte:

[ gap_{bytes}

LSN_{primary}-LSN_{candidate} ]

两者不能直接互换。相同 1 MiB WAL:

  • 高峰可能只代表几十毫秒;
  • 低峰可能跨越很久;
  • 一条关键订单与一批可重建日志的业务损失不同。

所以 RPO 合同至少要说明:

which acknowledgement class
which failure scenario
which candidate set
time/byte/business-event measurement
whether simultaneous failures are in scope

“RPO < 1 MB”与“任何已确认订单都不会丢”不是同一句话。

RTO 从哪个时刻算到哪个时刻

一个故障路径可拆成:

Trecover=Tdetect+Tdecide+Tfence+Tpromote+Troute+Treconnect+Tapp T_{recover} =T_{detect} +T_{decide} +T_{fence} +T_{promote} +T_{route} +T_{reconnect} +T_{app}

不同仪表测到不同终点:

时钟 开始 结束 能回答什么
component 进程 fault PostgreSQL running 进程恢复
control lease/health failure 新 leader 稳定 控制面迁移
service endpoint first fails 新连接可写 接入恢复
transaction 业务动作发起 可判断结果 用户恢复
backlog 故障开始 积压清空 完整业务恢复

patronictl 返回时间不是 application RTO。HAProxy 健康检查通过也不等于 已有 pool/session 已恢复。

计划切换没有故障检测阶段

本章正式动作由操作者在健康状态发起:

T_detect = 0 by construction
candidate selected in advance
maintenance authority already granted

所以观测的 action-to-stable ≈ 5.823 s 不能代替主机故障 RTO。后者还要 包括 detection、lease expiry、candidate decision、可能的 fencing 与 client retry。

objective、measurement 与 promise

建议用三个字段避免混淆:

objective
  事前目标,例如 sampled write gap <= 15s

observation
  本次结果,例如 6.007s

SLO
  在定义窗口和分位数上的正式承诺,例如
  99% eligible single-host failovers restore writes within 60s

一次 observation 不能建立 percentile。通过一次演练只说明:

one run <= one objective under recorded conditions

degradation target

availability 不是只有 up/down。故障期间可定义:

能力 目标状态 禁止状态
已有读事务 可失败并重连 静默读到错误 authority
新写事务 短暂拒绝 两个 primary 同时接受写
只读查询 可从合格 replica 提供 把陈旧数据冒充强一致
后台任务 暂停/排队 无幂等地重复
管理写 人工冻结 绕过服务直连旧主

一致性优先的系统宁愿短时不可写,也不接受两个历史。降级目标必须由业务 owner 接受,而不是数据库团队在事故中临时猜。

提交风险有三种客户端状态

对一次业务写:

acknowledged
  客户端收到成功;系统必须按合同保护它

rejected-before-send
  明确没有发送,可在业务规则允许时重试

outcome-unknown
  请求可能到达、可能提交,但应答丢失

网络错误或 session 被 HAProxy 关闭,只能告诉客户端“连接失败”,不能自动 证明事务回滚。盲目重试:

charge card
create order
consume coupon
increment balance

可能造成重复业务动作。

本章 probe 为每次尝试生成:

run_id + attempt_no + unique token

结果未知后不把它改成一个新 token 重做,而是在服务稳定后查 token:

present -> committed
absent  -> not present in chosen history

正式运行 25 个 unknown token 全部核对为 absent。这个结果描述本次选择的 最终历史;应用仍需决定 absent 后是否以及如何重试。

数据风险不止“丢几行”

还包括:

acknowledged commit missing
unknown commit duplicated by retry
sequence/external side effect diverged
read-your-writes broken
replica stale read drives wrong decision
two timelines receive writes
logical corruption replicated everywhere

因此 HA acceptance 必须同时观测服务可用性和数据语义。

目标卡

一个可评审的目标:

scenario: one independent primary-host loss
window: 30-day rolling
rpo:
  acknowledged critical writes: 0
  lower-tier events: <= agreed byte/time envelope
rto:
  client read-write: p99 <= 60s
degradation:
  writes may be rejected
  stale reads must be labeled
  duplicate business actions forbidden
dependencies:
  DCS quorum, candidate, proxy, DNS, identity survive
measurement:
  external transaction probe + token reconciliation

是否能实现,要由同步策略、failure placement、服务路径和客户端能力共同 回答。

20.1.3 高可用不等于备份,也不等于零数据丢失

三种能力回答不同问题

能力 主要问题 典型机制 无法独自处理
HA 当前 primary 不能服务怎么办 replica、election、routing 逻辑误删、历史恢复
backup/recovery 要回到过去或异地怎么办 base backup、WAL archive、PITR 秒级接管
data protection 哪些确认写必须存在几份 sync policy、placement、storage 客户端重连

replica 是当前历史的追随者;backup 是可选择的历史恢复材料。

replica 会忠实复制错误

以下操作通常会进入 WAL 并传播:

DROP TABLE orders;
DELETE FROM orders;
UPDATE orders SET status = 'paid';  -- missing predicate

物理副本不会判断这是事故。它的健康意味着复制系统工作,而不是数据仍符合 业务真相。

可以用 delayed replica 降低某些操作错误风险,但它仍不是完整 backup 策略:

  • 延迟窗口有限;
  • 主机/账户/自动化可能共故障;
  • 到点仍会重放错误;
  • 恢复流程与一致性仍需验证;
  • 不能替代离线/跨域历史和 retention。

第 21 章会把 restore proof 作为独立 gate。

异步复制不承诺零 RPO

PostgreSQL 流复制默认异步。primary 可以先向客户端确认,再把最新 WAL 传给 standby。若 primary 的未传输尾部永久丢失:

client saw COMMIT
candidate never received that WAL
candidate promoted
acknowledged transaction absent

PostgreSQL 18 官方文档 明确说明异步 log shipping 存在这类窗口;streaming 可以缩小窗口,不会 用“通常很小”把它变成零。

本章实际配置:

synchronous_mode=false
synchronous_mode_strict=false
synchronous_standby_names=''
pg_stat_replication.sync_state=async

所以即使一次健康 switchover 所有 acknowledged token 都存在,也只能说明:

the named healthy candidate caught up for this planned transition

不能说明:

an unplanned catastrophic primary loss has zero RPO

同步复制也不是魔法

同步提交提升指定失败范围内的 durability,但 guarantee 依赖:

which standby acknowledged
what acknowledgement level
candidate eligibility
simultaneous failure assumptions
failure-domain independence
storage durability
manual override policy

如果 primary 与当前同步 standby 同时失效,或操作者强制提升一个落后 candidate,仍可能丢失 acknowledged history。Patroni 文档也把 synchronous_mode 的数据保护与 write availability 代价放在一起解释, 而不是承诺任何故障组合都零损失。

backup 也不是“文件存在”

备份成立至少需要:

complete base backup
required WAL retained
catalog/metadata intact
credentials and keys available
target infrastructure available
restore procedure tested
business validation passed

本章看到 archive_mode=on 与归档统计,不会因此宣布 PITR 已通过。 archive 命令是否持续成功、repository 是否独立、恢复链是否完整,全部留给 第 21 章实测。

能力矩阵

场景 HA replica backup/PITR application design
primary 进程 crash 主要 兜底 reconnect
primary host 永久损失 主要 兜底 unknown outcome
整个机房损失 取决于跨域 主要 regional failover
误删表 会复制错误 主要 guard/repair
duplicate retry 无法识别业务重复 不能预防 idempotency
数据静默错误 可能传播 历史/校验 invariant
ransomware/credential compromise 可能同受影响 immutable/offline identity/response

三条生产阻断线

以下任一成立,就不能把 HA gate 判为生产通过:

failure domains not mapped
acknowledged-write policy not defined
old primary isolation not demonstrated

同样:

restore not rehearsed -> recovery gate pending
client retry undefined -> service gate pending
secret/TLS unresolved  -> security gate pending

一个 gate 通过不能替别的 gate 签字。

本节检查

请为自己的服务写出:

  1. 六类 failure domain 与共同依赖;
  2. 一个 eligible scenario 的 RPO/RTO 起止点;
  3. 允许的降级和绝不允许的状态;
  4. acknowledged / rejected / unknown 三种提交结果的处理;
  5. HA、backup 与 application 各自 owner;
  6. 三条 stop condition。

如果答案仍是“我们有三台,所以高可用”,这一节还没有完成。

小结

HA design starts with loss, not topology
RPO/RTO need scenario and observation points
planned maintenance is not failure detection
outcome-unknown is a business state
replication follows current history
backup preserves selectable history
exceptions block claims

权威参考


返回本章目录 · 下一节:物理流复制 · 查看全书目录 · 查看索引中心

20.2 物理流复制

PostgreSQL 物理复制不是“把表同步到另一台机器”,而是让另一套数据目录 持续接收并重放同一个 database cluster 的 WAL 历史。

理解 HA,至少要能回答:

WAL 生成到哪里?
发送到哪里?
备库写到哪里?
flush 到哪里?
replay 到哪里?
当前属于哪条 timeline?
旧 WAL 由谁保留?

这些问题都有 PostgreSQL 原生证据,不必靠角色标签猜。

20.2.1 WAL 发送、接收、重放与 LSN

物理复制传的是 WAL

primary 修改 data page 之前,相关变化先以 WAL record 进入 WAL。physical standby 的基本流水线:

primary backend
  -> WAL insert
      -> WAL buffer / local flush
          -> walsender
              -> network
                  -> walreceiver
                      -> standby WAL write/flush
                          -> recovery process replay
                              -> hot standby query visibility

因此“复制到达”至少有三层:

received
written/flushed
replayed

同步提交等待哪一层,由 synchronous_commit 决定;读请求能否看到,由 replay 位置决定。

LSN 是 WAL 地址

Log Sequence Number 写作:

0/170002B0

可理解为单调推进的 WAL byte position。它不是 wall-clock time,也不是 transaction ID。

常用函数:

-- 只在非 recovery 节点调用
SELECT pg_current_wal_lsn();

-- standby 上最后收到/重放的位置
SELECT pg_last_wal_receive_lsn(),
       pg_last_wal_replay_lsn(),
       pg_last_xact_replay_timestamp();

-- byte difference
SELECT pg_wal_lsn_diff('0/170002B0', '0/17000000');

不要在 standby 无条件调用 pg_current_wal_lsn()。本章 ha-facts.sql 先判断 pg_is_in_recovery(), 再选择 primary 或 standby 合适的函数。

primary 观察 walsender

SELECT application_name,
       client_addr,
       state,
       sync_state,
       sent_lsn,
       write_lsn,
       flush_lsn,
       replay_lsn,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)
         AS replay_gap_bytes
FROM pg_stat_replication
ORDER BY application_name;

字段的方向:

sent_lsn    primary 已发送
write_lsn   standby 已写到 OS
flush_lsn   standby 已报告 durable flush
replay_lsn  standby 已重放

本章每个稳定 phase 都要求 primary 看到:

exactly two rows
application_name = current two replicas
state = streaming
sync_state = async
client_addr = declared member address
replay_gap_bytes <= 1 MiB

state=streaming 是必要条件,不是业务 freshness 的充分条件。

standby 观察 walreceiver

SELECT status,
       sender_host,
       sender_port,
       written_lsn,
       flushed_lsn,
       latest_end_lsn
FROM pg_stat_wal_receiver;

每个 standby 应看到一个 receiver,且 upstream 地址是当前 primary。在正式 切换中:

timeline 5: pg-test-2/3 receiver -> 10.10.10.11
timeline 6: pg-test-1/3 receiver -> 10.10.10.12
timeline 7: pg-test-2/3 receiver -> 10.10.10.11

这比只看 Patroni 的 Role=Replica 多证明了一层:PostgreSQL 自己确实在 接收当前 upstream。

lag 不是一个数字

至少区分:

transport lag
  generated - received

flush lag
  generated - durable on standby

replay lag
  generated - applied

visibility lag
  commit on primary - visible to standby query

business freshness
  source event time - projection/report watermark

用 byte gap 的优势是明确;局限是业务时间随 WAL generation rate 变化。 用 now() - pg_last_xact_replay_timestamp() 也有陷阱:没有新事务时, timestamp 看起来越来越“旧”,并不代表 standby 落后。

正确做法是组合:

sender/receiver state
LSN byte gaps
replay timestamp with workload context
WAL generation rate
application watermark where needed

replay 与查询冲突

hot standby 一边 replay,一边允许只读查询。长查询可能与 recovery 需要 清理的 tuple、DDL 或锁冲突。系统必须在:

cancel standby query
delay WAL replay
retain more dead rows on primary via feedback

之间取舍。HA replica 若同时承担分析负载,可能让 replay lag、bloat 与 failover eligibility 互相影响。

本章把 pg-test-3 标为 offline-query placement intent,但不会把标签当成 已证明的 workload isolation。

观测快照不是连续保证

pg_stat_replication 是当前状态。采到 gap=0 只能说明采样点:

the replica caught up at observation time

它不证明上一秒、下一秒或 primary 永久损失时也为零。本章 planned switchover 会选择健康 candidate;这正是它不能代替 catastrophic failover RPO 测试的原因。

20.2.2 timeline、恢复目标与历史分叉

promotion 会创建新 timeline

standby promotion 的本质不是“改角色字段”,而是从共同 WAL 历史的某个点 开始一条新分支。

timeline 5  ------ A ------ promotion
                              \
timeline 6                     B ------ C

如果旧 primary 在分支点后也继续写:

timeline 5                     X ------ Y

B/CX/Y 可能都是各自内部合法的事务,但无法通过普通流复制自动合并。 这就是为什么 authority 与 fencing 比“选主速度”更重要。

system identifier 与 timeline

两个身份不要混:

system identifier
  initdb 时产生,标识一个 PostgreSQL database cluster lineage

timeline ID
  标识该 lineage 中一次 WAL history 分支

本章正式证据:

one unchanged system identifier
timeline 5 -> 6 -> 7

如果成员 system identifier 不同,它不是这个 physical cluster 的合法 replica;如果 system identifier 相同但 timeline 关系不对,则必须检查 history 与分叉。

原生证据:

SELECT system_identifier
FROM pg_control_system();

SELECT timeline_id
FROM pg_control_checkpoint();

第二条查询有一个重要边界,后面单独解释。

timeline history

promotion 会产生 timeline history 文件,描述新 timeline 从哪条父 timeline 的哪个 WAL 位置分叉。recovery 要选择正确 history。

HA standby 通常使用:

recovery_target_timeline = latest

这是 PostgreSQL 默认值,使其能跟随 promotion 后的最新历史。官方 warm standby 文档 明确建议 HA 多 standby 使用 latest。

“latest”不是允许随便选择历史。它仍依赖可达的 archive/stream、history 文件和一个被授权的 upstream。

从 WAL 文件名得到 primary 当前 timeline

WAL 文件名的前 8 个十六进制字符编码 timeline。primary 可以:

SELECT split.timeline_id
FROM pg_split_walfile_name(
       pg_walfile_name(pg_current_wal_lsn())
     ) AS split;

本章把它保存为:

current_wal_timeline_id

并要求它与 Patroni 当前 timeline 一致。

不要在 recovery 中调用 pg_walfile_name(pg_current_wal_lsn());这些 current-WAL 函数不是 standby 当前 replay timeline 的通用接口。

checkpoint timeline 不是 standby 当前 replay timeline

第一次真实演练暴露了一个非常有价值的测量陷阱:

Patroni: pg-test-3 streaming on timeline 3
pg_control_checkpoint().timeline_id on pg-test-3: 1

最终正式运行结束后:

Patroni current timeline: 7
pg-test-3 checkpoint_timeline_id: 3
receiver: streaming from current primary

没有发生“备库卡在旧 timeline”。pg_control_checkpoint() 返回 control file 中最近 checkpoint 的信息;一个 standby 可能已经重放后续 timeline, 但还没有用新的 checkpoint metadata 更新到相同数值。

因此证据模型使用精确名称:

checkpoint_timeline_id

验收只要求:

checkpoint_timeline_idcurrent phase timeline checkpoint\_timeline\_id \le current\ phase\ timeline

而不是错误地要求 standby checkpoint timeline 必须时时等于 Patroni timeline。

这条经验说明:

观测函数名、状态生命周期和适用节点不清楚时,“更多 SQL”也会产生错误 告警。

如何验证 standby 跟随正确历史

组合证据:

Patroni member timeline and state
standby pg_is_in_recovery() = true
pg_stat_wal_receiver.status = streaming
sender_host = current primary
receive/replay LSN advances
primary walsender sees that application
system identifier unchanged

若需要进一步诊断,可检查:

timeline history files
PostgreSQL recovery logs
archive availability
Patroni logs and DCS history

不要用一个 checkpoint 字段替代整个 lineage proof。

promotion 是不可逆状态迁移

promotion 后,新 primary 会产生新 timeline。要把它“变回原样”,不是把 配置里的 role 改回 replica:

  • 若未产生分叉写入且工具能安全处理,仍需验证;
  • 常见路径是 pg_rewind 对齐;
  • rewind 前提不满足或失败时,从新 base backup 重建;
  • 任何时候都必须先确认唯一的 source of truth。

本章的第二次 switchover 又创建 timeline 7;这是恢复“教学角色基线”, 不是把 WAL 历史倒回 timeline 5。

20.2.3 复制槽、归档与 WAL 保留

standby 必须拿到连续 WAL

standby 能继续 recovery 的前提:

从自己的起点到当前目标之间,没有缺失所需 WAL

WAL 来源可以组合:

streaming from primary
local pg_wal
WAL archive via restore_command

如果旧 WAL 已在 primary 被 recycle,archive 也没有,而 standby 仍需要:

reinitialize from a new base backup

三种主要保留机制

机制 依据 优势 风险/局限
wal_keep_size 至少保留一段量 简单 不是按 consumer 精确
physical slot 按 consumer restart LSN 精确追踪需要 consumer 卡住可撑满磁盘
WAL archive 外部历史 catch-up/PITR 共用 需要独立完整性与恢复验证

它们不是互斥。一个成熟系统可能同时:

slot protects online replica
archive protects longer recovery history
wal_keep_size absorbs transient behavior

复制槽的保证与反噬

physical replication slot 告诉 primary:

在 consumer 确认之前,不要移除它仍需要的 WAL

原生查询:

SELECT slot_name,
       slot_type,
       active,
       restart_lsn,
       wal_status,
       safe_wal_size
FROM pg_replication_slots
ORDER BY slot_name;

本章每个 stable primary 都要求两个 active physical slot:

pg-test-1 primary -> pg_test_2, pg_test_3
pg-test-2 primary -> pg_test_1, pg_test_3

slot 名使用下划线,因为 PostgreSQL slot 名只允许特定小写字符集合。

但 slot 的保护方式是不删 WAL。如果 replica 离线很久、网络断开或 consumer 永远不回来:

retained WAL grows
pg_wal filesystem fills
primary can stop accepting writes

官方文档明确警告这一风险,并提供 max_slot_wal_keep_size 作为边界之一。 边界达到后,slot 可能失去可继续恢复所需的 WAL,运维必须在“磁盘安全”和 “无需重建 replica”之间明确取舍。

slot 监控

至少观测:

active
restart_lsn
current_lsn - restart_lsn
wal_status
safe_wal_size
inactive_since where available
pg_wal filesystem used/free
WAL generation rate
consumer identity and owner

告警不能只在 disk 95% 才触发。需要提前估算:

[ time\ to\ full

\frac{free\ bytes}{WAL\ generation\ bytes/s} ]

并考虑 burst、checkpoint、backup 与其他 slot。

archive 是另一条恢复路径

启用:

archive_mode=on

只说明 PostgreSQL 会尝试归档。还要检查:

archive_command/library result
last success and last failure
repository independence
retention
timeline history files
restore_command
end-to-end restore

pg_stat_archiver.failed_count 是累计量;看到历史失败不能直接判定当前坏, 也不能因为最近一次成功就忽略趋势。第 21 章会从 archive 到 restore 闭环。

slot 不是 archive,archive 不是 backup proof

slot
  primary-side retention promise for a replication consumer

archive
  copied WAL history

base backup + archive + tested restore
  才可能形成可用 recovery chain

slot 会随 primary 故障域一起消失;archive 若也在同一主机/账户,就可能 共同消失。

planned switchover 前检查

candidate eligibility 最低检查:

member state = streaming
replay gap inside declared bound
system identifier same
timeline eligible
archive/slot not in dangerous state
candidate not tagged nofailover
no paused HA control
service/drain/maintenance authority ready

本章 executable 将 replay gap 上限固定为 1 MiB,并检查两个 sender、 两个 active slot 与 receiver upstream。这个 byte bound 只用于健康计划 切换 preflight,不等于生产 RPO。

诊断顺序

发现 replica lag:

  1. primary 是否继续生成大量 WAL;
  2. walsender sent/write/flush/replay 哪一段拉开;
  3. standby receiver 是否 streaming、upstream 是否正确;
  4. network throughput/error;
  5. standby disk write 与 recovery apply;
  6. replay conflict/long query;
  7. slot retention 与 pg_wal headroom;
  8. archive 是否能补缺;
  9. 是否已越过必须 rebuild 的 stop line。

不要先 drop slot 或删 pg_wal。“释放空间”的错误动作可能直接删除唯一 可恢复路径。

本节实验查询

在合适的节点使用:

SELECT pg_is_in_recovery();
SELECT * FROM pg_stat_replication;
SELECT * FROM pg_stat_wal_receiver;
SELECT * FROM pg_replication_slots;
SELECT * FROM pg_stat_archiver;
SELECT * FROM pg_control_system();
SELECT * FROM pg_control_checkpoint();

公开实验用一个 allowlisted JSON 查询封装这些证据:

不要把复制 credential、primary_conninfo password 或完整 Patroni config 复制进 evidence。

小结

physical replication follows WAL
receive != flush != replay
LSN byte gap != business time
system identifier != timeline
checkpoint timeline != current standby replay timeline
slots protect consumers by retaining WAL
retained WAL can exhaust primary storage
archive claims require restore proof

权威参考


上一节:从失败模型设计高可用 · 返回本章目录 · 下一节:同步策略与提交语义 · 查看全书目录 · 查看索引中心

20.3 同步策略与提交语义

同步复制不是一个 on/off 开关,而是提交必须等谁、等到哪一步、没有合格 副本时宁愿阻塞还是退化的合同。

在调整参数前,先回答:

哪些成功应答绝不能在目标故障中消失?
最多等几个副本?
副本不可用时,是停止写还是降低保护?
候选人如何限制?
延迟和锁等待由谁承担?

20.3.1 异步、同步与远程应用确认

两个参数回答两个问题

PostgreSQL 同步复制的核心分工:

synchronous_standby_names
  哪些 replication connection 构成同步候选集合,等几个

synchronous_commit
  当前事务要等到哪个 acknowledgement level

如果 synchronous_standby_names='',没有 synchronous standby;即使 session 的 synchronous_commit=on,也只完成本地提交语义,不会凭空等一 台远端。

本章正式 baseline:

synchronous_standby_names = ''
synchronous_commit        = on
pg_stat_replication        = sync_state async
Patroni synchronous_mode  = false

synchronous_commit=on 在这里不能被误读成“同步复制已开启”。

acknowledgement levels

简化比较:

synchronous_commit primary 等待点 远端保证(有同步 standby 时) 典型代价
off 不等本地 WAL durable flush 最低延迟,进程/OS crash 可丢近期提交
local 本地 durable flush 不等 remote 本地 durability
remote_write remote 写到 OS 未要求 remote durable flush 较低跨网延迟、保证较弱
on remote durable flush 同步 standby WAL 已落盘 至少网络 RTT 与 remote storage
remote_apply remote replay standby 查询可见 最大等待,受 replay 影响

PostgreSQL 18 可配置的值只有表中的五种;on 就表示等待同步备库持久化 flush。不要把内部等待阶段或监控标签中的 remote_flush 当成可设置参数。 具体行为以当前版本 官方参数文档 为准。

remote_apply 解决可见性,不自动解决路由

当同步 standby replay 后才放行 commit,应用随后若读到同一 standby, 更容易获得 read-your-writes。但仍需:

read request actually routed to that eligible standby
session/transaction semantics compatible
failover does not choose another stale node
application knows required consistency class

remote_apply 不是把所有 replica 都变成线性一致读。

commit 等待与 transaction 生命周期

top-level commit 等同步确认时:

  • transaction locks 仍可能影响其他 session;
  • write latency 增加;
  • remote storage/network jitter 进入 tail latency;
  • timeout/connection loss仍可能造成 outcome unknown;
  • read-only transaction 与 rollback 不需要相同等待。

同步复制把数据保护成本放进前台写路径。它没有消除成本,只是把风险从 “故障时可能丢”移动到“正常时更慢、故障时可能不可写”。

session 可以覆盖

synchronous_commit 可按系统、database、role、session 或 transaction 设置:

BEGIN;
SET LOCAL synchronous_commit = 'remote_apply';
-- critical write
COMMIT;

这允许分层:

money/ledger       stronger acknowledgement
rebuildable event  lower latency
bulk backfill      separately controlled

但 policy 不能只靠开发者“记得 SET”。建议把 role/database defaults、 connection initialization、审计和测试组合起来。

如何观察

SHOW synchronous_commit;
SHOW synchronous_standby_names;

SELECT application_name,
       state,
       sync_state,
       sync_priority,
       write_lsn,
       flush_lsn,
       replay_lsn
FROM pg_stat_replication;

sync_state 可见当前 connection 是 asyncpotentialsyncquorum 等状态。配置意图必须回到 live view。

20.3.2 多副本同步集合与退化条件

FIRST:优先级集合

synchronous_standby_names = 'FIRST 1 (pg-a, pg-b)'

含义:

按列表优先级选择一个 active synchronous standby
pg-a 可用时优先
pg-a 不可用时 pg-b 可接替

适合有明确低延迟/placement 优先级的场景。缺点是高优先级节点的性能与 抖动更容易决定写 tail。

ANY:quorum 集合

synchronous_standby_names = 'ANY 1 (pg-a, pg-b)'

含义:

任意一个候选确认即可

ANY 2 (...) 则等任意两个。它可以降低单个慢节点对 latency 的影响, 但数据保护与 failover candidate 必须按 quorum history 推理。

数量不是 durability 的全部

考虑:

primary in AZ-a
sync standby 1 in AZ-a
sync standby 2 in AZ-b

等任意一个,通常可能总由同 AZ 的低延迟 standby 确认。若 AZ-a 整体 消失,AZ-b standby 是否一定拥有所有 acknowledged commits,需要根据实际 选择集合和时间分析。

所以配置应连接 placement:

acknowledgement quorum
failure-domain quorum
promotion candidate set

三者未必相同。

Patroni synchronous mode

PostgreSQL 负责 commit wait;Patroni 还要管理 promotion eligibility 与 standby 集合变化。

简化:

synchronous_mode=false
  默认异步 election;可能提升落后 candidate

synchronous_mode=true
  Patroni 只在确认候选包含可能已成功应答的事务时自动提升
  无合格同步 standby 时可能临时退回非同步写,但随后故障不自动提升

synchronous_mode_strict=true
  没有同步 standby 时也不退化;write 会阻塞/不可用

准确行为随 Patroni 版本与 dynamic config 变化,应以 Replication modes 为准。

这体现两个目标:

data safety
write availability

无法无条件同时最大化。

退化必须是显式政策

副本不可用时的选择:

政策 write availability acknowledged data protection
strict block 降低 保持目标
controlled async degrade 保持 降低,必须告警/批准
manual bypass 操作者决定 可能破坏 guarantee
reject critical, allow lower tier 分级 分级

不要让“超时太多,先改成 async”成为无记录的事故操作。需要:

who may degrade
which traffic
maximum duration
customer/business notice
audit event
re-protection completion

nosyncnofailover 与 placement intent

某些 standby 不应承担同步或提升角色:

remote high-latency DR
offline analytical replica
hardware below write requirement
maintenance member
delayed replica

HA manager tags 可以表达候选意图。但标签只是 declaration,仍要验证 live role、routing 和 performance。一个 offline tag 不能代替资源隔离。

多副本并不自动等于多份 durable commit

某一时刻:

replica connected
replay lag 0

不代表每次 commit 都等待它 durable flush。要看:

synchronous_standby_names
transaction synchronous_commit
pg_stat_replication.sync_state
Patroni synchronous policy

同样,“两台 sync”也不证明它们位于独立 failure domain。

candidate eligibility

选主至少考虑:

member health
replication lag
timeline relationship
tags / maintenance
sync safety state
watchdog/fencing ability
DCS authority

本章 baseline:

maximum_lag_on_failover = 1 MiB
check_timeline not asserted by this lab
synchronous_mode=false

因此本章不会把 maximum_lag_on_failover 误写成 zero-loss guarantee。 Patroni 官方说明实际 worst-case 还受采样周期与近期 WAL generation 影响。

20.3.3 延迟、可用性和数据保护的交换

一个不可回避的三角

粗略地:

stronger acknowledged durability
lower write latency
higher write availability during replica/network fault

不能在所有故障条件下同时无代价最大化。

同步 commit latency 下界近似包含:

LcommitLprimary flush+RTT+Lstandby acknowledgement L_{commit} \ge L_{primary\ flush} +RTT +L_{standby\ acknowledgement}

remote_apply,还要加入 replay queue 与 conflict。

P50 不够

同步写路径把远端 tail 带入本地 transaction:

network jitter
standby fsync tail
checkpoint
CPU steal
queueing
replay pressure

需要看:

P50 / P95 / P99 / max
timeout rate
lock hold/wait
WAL bytes/s
sync standby churn
degradation events

平均 RTT 很漂亮,也可能因为偶发 5 秒 stall 让 checkout 大面积超时。

timeout 不等于 rollback

即使同步 commit 等待超时或连接中断,事务可能已经:

  • 在 primary durable;
  • 在 standby durable;
  • 只是应答未到客户端。

应用仍需 token/reconciliation。同步复制提高 durability,不消除 distributed commit outcome uncertainty。

业务分层

示例:

写入类 丢失代价 latency tolerance 建议方向
账务事实 极高 可接受更高 strict sync + independent domains
订单状态 sync 或 durable event contract
clickstream 可重建 async/batched
cache projection 可重建 async
admin migration maintenance explicit stronger setting

这不是固定答案。关键是同一 service 内可能需要不同 acknowledgement class。

用 decision record 替代参数清单

decision: critical writes wait for one remote durable flush
scope:
  failures: one host or one AZ
  simultaneous_region_loss: excluded
standbys:
  candidates: pg-b, pg-c
  placement: separate AZ
postgresql:
  synchronous_standby_names: "ANY 1 (pg-b, pg-c)"
  synchronous_commit: "on"
patroni:
  synchronous_mode: true
  synchronous_mode_strict: true
degradation:
  automatic_async_fallback: forbidden
  operator_override: incident-commander approval
client:
  idempotency_required: true
evidence:
  fault drills, token reconciliation, latency distribution

参数必须从 decision 推导;否则升级或换平台时只剩一堆无法解释的数。

本章为什么保留 async baseline

把沙箱临时改成 sync,可能让实验数据更“好看”,却掩盖第 19 章真实交付的 policy。我们选择:

capture actual policy
accept planned switchover under explicit async exception
block zero-RPO inference
leave production sync decision pending

正式结果:

all 95 acknowledged tokens present
25 unknown tokens reconciled absent

它是有价值的 commit evidence,但不能越过 EX20-ASYNC-BASELINE

从目标反推策略

决策顺序:

  1. 定义具体 failure scenario;
  2. 定义哪些 acknowledgment 不能丢;
  3. 映射独立 failure domains;
  4. 选择同步集合和 acknowledgement level;
  5. 决定失去 standby 时 block 还是 degrade;
  6. 约束 candidate 和 manual override;
  7. 预算正常/故障 latency;
  8. 让 client 支持 timeout、unknown 与 idempotency;
  9. 用故障演练验证;
  10. 用 production observation 持续校准。

不要从 synchronous_mode: true 反推业务目标。

评审问题

哪个成功应答必须存于几处?
这些“几处”是否独立?
同步确认等到 write、flush 还是 apply?
没有合格副本时谁决定停止写?
manual failover 能否绕过 safety?
timeout 后业务如何查结果?
延迟预算是否包含 remote tail?

任何一个“以后再说”,都会在事故中变成临时一致性模型。

小结

synchronous_commit and synchronous_standby_names solve different axes
remote_write != remote flush != remote apply
FIRST priority != ANY quorum
Patroni sync mode constrains automatic promotion
strict protection trades write availability
timeout still permits unknown outcome
one successful async drill cannot prove zero RPO

权威参考


上一节:物理流复制 · 返回本章目录 · 下一节:选主、DCS 与防脑裂 · 查看全书目录 · 查看索引中心

20.4 选主、DCS 与防脑裂

PostgreSQL 原生提供 replication、promotion 与 recovery primitives,但不替 多台实例决定:

谁现在被允许写?
谁最适合接管?
旧主何时必须停止?
客户端如何只找到被授权的主?

Patroni、DCS、watchdog 和 service routing 分别补上这条链的不同环节。 把它们都叫“自动选主”,会丢掉最重要的安全边界。

20.4.1 Patroni、租约、leader lock 与健康判断

leader 是有期限的 authority

Patroni 使用 DCS 中的 leader key/lock 表达:

某 member 在一个租约窗口内拥有 primary authority

它不是永久铭牌。当前 leader 必须周期性更新;失去更新能力时,旧 authority 必须在新的 candidate 可能获得 authority 之前失效。

关键周期:

ttl
  leader lock 的租约尺度

loop_wait
  HA loop 大致运行间隔

retry_timeout
  DCS 操作重试边界

本章实际 dynamic policy:

ttl=30
loop_wait=5
retry_timeout=10
maximum_lag_on_failover=1048576
pause=false
failsafe_mode=true

这些值共同影响 detection 与 decision latency。不能只拿 ttl=30 直接写出 “RTO=30s”,还需 health path、fence、promotion、service check 与 client recovery。

health 有多个观察层

Patroni candidate eligibility 可能考虑:

member API alive
PostgreSQL state
replication state and lag
timeline
tags: nofailover/nosync/...
scheduled maintenance
sync safety state

服务代理又可能使用 Patroni REST endpoint:

/primary
/replica
/read-only

客户端 SQL 则看到:

SELECT pg_is_in_recovery();

三层应当一致,但 authority 不同:

证据 回答
DCS/Patroni 谁拥有 HA authority
PostgreSQL SQL 当前实例是否 recovery、复制事实
service health/routing 新连接会被送到谁
client transaction 业务是否恢复且结果可判断

只看一层无法完成 HA acceptance。

patronictl list 是快照

sudo -iu postgres \
  patronictl -c /etc/patroni/patroni.yml \
  list pg-test

它适合:

member/host
role/state
timeline
lag snapshot
scheduled actions

但输出不是审计历史,也不证明旧主已物理隔离。正式实验把结构化 JSON 与 SQL phase 一起保存,而不是只贴一张终端截图。

candidate “最新”也需要定义

异步集群可能没有一个 candidate 包含 primary 的最后 WAL tail。Patroni maximum_lag_on_failover 控制候选落后上限的一部分,但官方文档指出位置 并非实时连续采样,实际 worst case 还包括最近一个周期生成的 WAL。

所以:

maximum_lag_on_failover=1 MiB

不是:

RPO always <= exactly 1 MiB

更不是 zero RPO。

timeline eligibility

同 system identifier 的旧分支 member 也可能不适合提升。check_timeline 等政策可限制 candidate timeline;本章没有把未观察的配置写成已启用。

正式验收另行要求:

all current members report same Patroni timeline per phase
primary WAL-derived timeline agrees
timeline advances on both switchovers
system identifier never changes

这是针对本次 planned transition 的 lineage proof。

pause 与 maintenance

Patroni paused mode 会改变自动管理行为。任何切换前都要显式查看:

patronictl show-config pg-test

本章 preflight 要求 pause=false。如果 cluster paused:

  • 不应假设自动 failover 会工作;
  • 不应直接照抄 runbook;
  • 先确认是谁、为何 pause,以及安全恢复路径。

DCS 是协调 authority,不是数据真相

DCS 保存 leader lock、dynamic config 和 member metadata;业务行仍在 PostgreSQL,WAL lineage 由 PostgreSQL 证明。

不应:

只因 DCS 里写着 leader 就忽略 SQL recovery state
只因 SQL 可写就绕过 DCS authority
把 DCS backup 当 PostgreSQL backup

HA 要求 coordination truth 与 data-plane truth 对齐。

20.4.2 fencing、watchdog 与旧主隔离

脑裂是什么

不是监控上短暂出现两个 running,而是:

two diverging histories can accept writes

尤其危险的状态:

old primary retains client reachability
old primary lost DCS authority
new primary acquired authority
different clients write both sides

事后不能靠 WAL replication 自动 merge 两边业务。

fencing 的目标

在新 primary 接受写之前,确保旧 primary:

stopped
demoted read-only
power/storage/network fenced
or otherwise unreachable from all write clients

fence 可以在不同层实现:

手段 局限
process Patroni stop/demote PostgreSQL Patroni 若失调度可能失败
host watchdog reset、STONITH 需真实硬件/权限/验证
storage revoke writer attachment/lease 依赖 storage semantics
network/service proxy 不路由旧主 直连可能绕过
application authority token/epoch 应用复杂度高,仍需底层安全

多层互补,不能拿 HAProxy health check 替代 host fencing。

为什么 stop 也可能来不及

Patroni watchdog 文档 列出:

Patroni process crashed/OOM
PostgreSQL shutdown too slow
host load high
VM paused
HA loop not scheduled

此时普通“租约更新失败后 stop PostgreSQL”逻辑可能没有机会及时执行。

watchdog

Linux watchdog 接收 heartbeat;超过窗口未喂狗,系统被 reset。Patroni 在 成为 leader 前可以激活 watchdog,并在 demotion 后禁用。

重要模式:

off
  不提供 watchdog fence

automatic
  可用时使用

required
  不能激活就拒绝成为 leader

准确名称和行为以所用 Patroni 版本为准。

本章实际:

watchdog.mode=off
device=/dev/watchdog
safety_margin=5

所以正式结论必须保留:

EX20-WATCHDOG-OFF
hardware-watchdog fencing unqualified

不能因为 planned switchover 中旧主正常 demote/rejoin,就宣称 VM pause 或 Patroni crash 时也安全。

service routing 是最后一道,但不是唯一一道

Pigsty primary service 通常用 Patroni /primary health check,只把新连接 送给当前 primary。即使旧 PostgreSQL 进程还可接受直连,只要 Patroni API 不再报告 primary,HAProxy 可以停止把 primary service 流量送过去。

这很有价值,但有边界:

direct 5432/6432 connections can bypass service
stale existing sessions may differ from new routing
another network path may still reach old primary
health endpoint itself depends on Patroni process

生产必须治理直连权限与网络路径,而不是只发布“推荐使用 5433”。

fence proof

一个未计划故障演练至少应证明:

old primary loses authority before/when new authority starts
old primary cannot accept write through any authorized path
client service selects only new primary
old primary later rejoins chosen timeline or is rebuilt
logs/DCS timeline explain ordering

本章没有注入这类 fault,因此不声称完成。

手工 promote 是高风险动作

当 DCS/网络视图不清楚时:

pg_ctl promote
patronictl failover --force

不是“恢复服务的快捷键”,而是选择一条新可写历史。操作前必须知道:

old primary state
candidate WAL position/timeline
who has authority
which clients are drained
what data loss is accepted
how old primary will be fenced

不满足就 stop,而不是用 --force 消除不确定性。

20.4.3 DCS 可用性与数据库可用性不是同一件事

两种 availability

database availability
  当前 authorized primary 能否继续完成事务

HA control availability
  系统能否安全更新 lock、选新 primary、改变动态配置

DCS outage 时,已有 primary 可能短时仍有数据服务;同时系统无法安全建立新 authority。这不是矛盾,而是两个 control boundary。

为什么失去 DCS 时通常选择保守

单个 member 无法仅凭“我连不上 DCS”区分:

DCS 全部 down
自己被网络隔离
另一侧仍可达 DCS 并会选新 leader

如果它乐观继续写,另一侧又选主,就可能脑裂。因此传统安全选择是:

cannot renew authority -> demote before lease expiry

Patroni DCS failsafe mode

failsafe_mode 试图在特定 DCS failure 中保留已有 primary:

current leader cannot update DCS
but can reach all known Patroni members via REST
all members acknowledge it
then it may continue as primary

若任何已知 member 不响应,则 demote。Patroni DCS failsafe 文档 强调检查 all members,而不是随意取 Patroni member 多数,因为 DCS 与 PostgreSQL placement quorum 可能不是同一个视图。

本章观察 failsafe_mode=true,但没有让 etcd 失效或切网络,因此:

configured intent observed
behavior under DCS loss not tested

单节点 etcd 不是 HA DCS

正式沙箱只有一个 etcd member:

dcs_endpoint_count=1

它足以验证:

Patroni integration
leader lock path
planned switchover
dynamic config capture

不能验证:

DCS quorum survives one member loss
cross-AZ DCS placement
etcd election latency
split network behavior

这就是 EX19-SINGLE-ETCD 在第 20 章继续生效的原因。

DCS 也有自己的运维合同

生产需要:

odd-sized quorum where appropriate
independent placement
latency budget
capacity/compaction
TLS and identity
backup/restore
version/upgrade
monitoring
access control

不要让一个为数据库提供 HA 的组件,自己成为没人负责的单点。

control-plane outage runbook

建议先判断:

1. 当前 PostgreSQL 客户端服务是否仍然可用?
2. leader lock 最后一次成功更新是什么时候?
3. DCS 是全局 down 还是局部 partition?
4. current leader 能否看到所有 Patroni members?
5. watchdog/fence 是否真实有效?
6. 是否允许保持 current leader,还是必须冻结写?
7. 谁有权执行 manual action?

禁止:

simultaneously restart every Patroni/DCS member
force promote while old primary unknown
delete DCS keys to “reset state”
initialize a new DCS namespace without lineage proof

数据面正常时也要保留事故证据

若 client 暂时无感,不代表无需 incident:

HA redundancy may be gone
next fault may become outage
dynamic config changes may be unavailable
leader authority safety margin is reduced

应记录:

first/last DCS error
member reachability matrix
leader loop logs
lease/TTL
failsafe requests
client SLI
manual actions

本章的停止线

只要出现:

two possible leaders
unknown old-primary write reachability
system identifier mismatch
ambiguous timeline
DCS authority absent and failsafe condition unproven

就不继续 planned switchover,也不自动尝试“恢复”。先冻结写路径、保留证据、 升级 decision authority。

本节证据

当前三台 member 都要报告:

scope=pg-test
member_name exact
patroni_version=4.1.3
dcs_kind=etcd3
dcs_endpoint_count=1
watchdog.mode=off

dynamic config 必须来自 DCS:

ttl=30
loop_wait=5
retry_timeout=10
maximum_lag_on_failover=1048576
synchronous_mode=false
failsafe_mode=true
pause=false
use_pg_rewind=true
use_slots=true

公开采集器只导出 allowlist,不把完整 Patroni YAML 中的 credential 复制进 evidence。

小结

leader is leased authority
health is layered evidence
election without fencing can still split brain
watchdog protects when user-space demotion may not run
service routing is necessary but bypassable
DCS availability != database availability
configured failsafe != tested failsafe
single etcd blocks production HA inference

权威参考


上一节:同步策略与提交语义 · 返回本章目录 · 下一节:切换、故障转移与重加入 · 查看全书目录 · 查看索引中心

20.5 切换、故障转移与重加入

角色变化不是一个动作,而是一条状态机:

preflight
  -> stop/drain or detect failure
      -> choose authority and candidate
          -> promote
              -> route new clients
                  -> reconcile transactions
                      -> rejoin/rebuild old member
                          -> restore redundancy

planned switchover 与 unplanned failover 经过其中不同的路径,不能用同一条 成功记录互相代替。

20.5.1 planned switchover 与 unplanned failover

两个动作的前提不同

维度 planned switchover unplanned failover
current leader 健康、可协调 可能失联/已死/未知
candidate 可事前检查并命名 在不完整信息中选择
client drain 可安排 通常来不及
WAL catch-up 可等待 tail 可能永失
fencing 正常 demotion 可完成 是核心风险
detection 人工发起,无故障检测 必须检测/租约
data-loss risk 通常可控 取决于 sync/lag/failure
purpose 维护、升级、演练 恢复故障服务

Patroni 官方 patronictlswitchover 定位于健康 cluster,把 failover 定位于不健康 cluster,并 明确提醒 failover 可能因 candidate 落后而丢数据。

planned switchover preflight

最低清单:

change authority and window approved
target is exact cluster
current leader uniquely identified
candidate explicitly named
candidate streaming and inside lag bound
system identifier/timeline valid
Patroni not paused
DCS reachable
sync/async policy known
backup/recovery gate status known
client and long transactions assessed
service endpoint observed
rollback/baseline state defined

本章 executable 还固定:

leader pg-test-1 -> candidate pg-test-2
then leader pg-test-2 -> candidate pg-test-1
pg-test-3 never promoted by this lab

若 topology 与合同不一致,拒绝,而不是“选当前看起来最合适的”。

命令是 mutation

正式底层动作:

sudo -iu postgres \
  patronictl -c /etc/patroni/patroni.yml \
  switchover pg-test \
  --leader pg-test-1 \
  --candidate pg-test-2 \
  --force

--force 只跳过 CLI 交互;它不是“强制安全”。脚本之所以可以用,是因为 外层已有:

exact target token
nonproduction assertions
empty evidence directory
fresh chapter-19 preflight
explicit leader/candidate
safe restore condition

生产 runbook 是否允许 --force,要由审批与自动化设计决定。

failover 需要更严格的 stop conditions

在 manual failover 前:

old primary definitely down/fenced?
which candidate has latest safe WAL?
what acknowledged commits may be missing?
DCS has coherent authority?
client write paths drained?
business owner accepts recovery point?

如果旧主状态 unknown,最快的安全动作常是先冻结写,而不是立即 promote。

automatic failover 也需要演练

配置了 Patroni 不代表路径已验证。要测:

failure detection time
leader lock expiry
candidate choice
fencing
promotion
service health convergence
client reconnect
commit outcome
old-member rejoin
redundancy restoration

并覆盖 process、host、network、DCS 等不同故障。一次 systemctl stop postgresql 只覆盖其中一个很温和的分支。

回退不是 timeline 倒退

本章“恢复基线”含义:

final teaching role = pg-test-1 primary

实际历史:

5 -> 6 -> 7

第二次 switchover 没有回到 timeline 5,也不应删除 timeline history。 角色布局恢复,历史继续前进。

planned 结果的正确表述

可以说:

named healthy candidate accepted planned leadership
old primary rejoined streaming
client service resumed sampled writes
all acknowledged test tokens survived
baseline role restored

不能说:

automatic host failover passed
zero RPO under primary loss
watchdog fencing passed
production RTO is six seconds

20.5.2 端点切换、客户端恢复与只读窗口

role 变化不会迁移现有 TCP session

promotion 后:

new primary exists

不代表:

every old session teleported to it

客户端可能经历:

  • connection reset;
  • transaction aborted;
  • pool 中旧连接失效;
  • DNS/VIP/cache 尚未更新;
  • HAProxy health check 尚在 rise/fall window;
  • driver backoff;
  • application circuit breaker;
  • in-flight commit outcome unknown。

所以 client RTO 通常晚于 control-plane stable。

stable endpoint

应用应连接 service identity,而不是把当前 primary IP 写死。

Pigsty 默认服务:

service port 默认语义
primary 5433 HAProxy → current primary pool
replica 5434 read-only replica pool
default 5436 current primary direct PostgreSQL
offline 5438 offline/OLAP route

正式 probe 使用:

host=10.10.10.11
port=5433
target_session_attrs=read-write

任意成员的 HAProxy 都能根据 Patroni /primary health 把新连接送到当前 primary;本次选一个固定 service host,避免 DNS/VIP 额外变量。

target_session_attrs=read-write

libpq 可以在连接后确认目标接受 read-write transaction。它能避免把连接 留在 recovery/read-only 节点,但不替代:

server authority
proxy health
fencing
transaction retry policy

它是 client-side sanity check,不是 election protocol。

健康检查窗口

服务切换时间含:

Patroni role/API update
HAProxy check interval
rise/fall thresholds
old session shutdown
new connect
PgBouncer state
application retry/backoff

Pigsty 当前默认 service 示例会用 Patroni REST /primary,HAProxy 还可在 backend marked down 时关闭 session。准确配置应查看当前 render 后的 HAProxy,而不只照文档默认。

读服务的降级语义

replica service 可继续提供只读,但要回答:

允许多旧?
新 primary 切换时 read replica 跟哪条 timeline?
read-your-writes 是否需要?
replica 不足时是否 fallback primary?
offline replica 是否可承接 online read?

Pigsty default replica service 通常:

prefer regular replicas
use primary/offline as backup according to selectors

业务必须知道 fallback,否则故障时 primary 可能同时承受全部写与回退读。

连接重试与业务重试

分三层:

connect retry
  建立新 TCP/database session

transaction retry
  重做一个明确失败、可安全重做的 transaction

business retry
  再次执行订单/支付等意图

三者不能混成 driver 的无限 retry。

安全结构:

INSERT INTO payment_request(idempotency_key, ...)
VALUES ($1, ...)
ON CONFLICT (idempotency_key)
DO UPDATE SET ... -- 或返回原结果
RETURNING ...;

具体业务必须保存状态和结果,不能只靠本章 synthetic table。

outcome unknown

当客户端在发送后收到 network error:

do not assume commit
do not assume rollback
look up by idempotency token

本章 probe 故意把所有异常保守记为 unknown,然后查表。正式运行:

unknown=25
unknown_committed=0
unknown_absent=25
unreconciled=0

另一次运行 unknown count 可能不同,甚至可能出现 committed。正确性来自 reconciliation,不来自“通常不会”。

sampled write gap

probe 每约 0.2 秒尝试一次。保守 gap:

last acknowledged before action
  -> first acknowledged after Patroni topology stable

正式:

action command           2.735 s
action -> stable         5.823 s
conservative write gap   6.007 s
max adjacent ack gap     5.208 s

为何几个数不同:

  • CLI return 早于完整 topology stable;
  • acknowledged event 受 probe interval 影响;
  • reconnect/HAProxy/PgBouncer 影响 client;
  • conservative metric 刻意使用 stable boundary。

TLS 例外

沙箱外部 port 5433 不接受 TLS,service file 使用:

sslmode=prefer

这只允许本地实验继续,形成 EX20-CLIENT-PROXY-NO-TLS。生产 connection identity、TLS verification 与 secret rotation 在第 23 章完成,不能复制 这个选择。

20.5.3 pg_rewind、重建与时间线验证

旧 primary 为什么不能直接 start

failover 后:

new primary writes new timeline
old primary data directory may contain divergent old-timeline changes

把旧 primary 直接作为 standby 指向新 primary,不能自动擦掉分叉块。需要 让它的数据目录重新成为 chosen history 的一致副本。

路径:

pg_rewind
or
fresh base backup / reinitialize

pg_rewind 做什么

pg_rewind 比较 source 与 target timeline history,找到 divergence point,把 target 中发生变化的 relation block 和必要文件对齐到 source。

典型角色:

source
  chosen current primary / authoritative history

target
  stopped old primary to be converted into standby

不要把方向写反。

前提

target 需要:

data checksums enabled
or wal_log_hints=on
and full_page_writes=on

还要有足够 WAL 到 divergence point,或可从 archive 取回。

本章捕获:

wal_log_hints=on
full_page_writes=on
data checksums enabled from chapter 19
Patroni use_pg_rewind=true

它们证明前提意图,不证明某次 unplanned divergence rewind 已执行成功。 本章 healthy switchover 由 Patroni 正常 demote/rejoin,没有把手工 rewind 作为正式动作。

rewind 不是无风险修复

官方文档警告:若 pg_rewind 中途失败,target data directory 很可能不再 可恢复,推荐重新 base backup。

因此:

never run on the chosen source directory
stop target
verify identities/direction
retain diagnostic evidence
ensure backup/rebuild path
do not repeatedly retry partial rewind blindly

它还会复制 source 的配置文件;重新作为 standby 前要检查 recovery 与 节点特有配置,避免再次启动为错误角色。

rewind 与 rebuild 的选择

条件 倾向
大库、小分叉、前提/WAL完整 rewind
target integrity 可疑 rebuild
rewind 失败 rebuild
缺失 divergence WAL 且 archive 无 rebuild
节点需要顺便换盘/版本 rebuild
source authority 不清 两者都停止

“rewind 更快”不能压过 lineage safety。

rejoin acceptance

旧 member 重新加入后检查:

pg_is_in_recovery()=true
system identifier matches
receiver streams from chosen primary
Patroni state=streaming
current cluster timeline correct
replay gap inside bound
corresponding primary slot active
service selectors correct
no direct write path remains

如果只是 systemctl 变绿,还没有完成。

restore redundancy

failover 后服务可能恢复,但 resilience 降级:

one primary
one fewer eligible replica
slot/WAL growing
backup schedule disrupted
capacity concentrated

incident completion 应区分:

service restored
data reconciled
member rejoined
redundancy restored
root cause/remediation complete

不要在“新主可写”时过早关 incident。

时间线验证

本章正式 sequence:

before             system S, timeline 5
after forward      system S, timeline 6
after restore      system S, timeline 7

同时每 phase:

Patroni one primary
SQL primary current WAL timeline agrees
two WAL receivers point to that primary
two sender/slot identities match replicas

pg-test-3 checkpoint timeline 最终仍为 3,不阻止其在 Patroni timeline 7 上 streaming;详见 20.2.2。

emergency restore 只在单一安全状态触发

本章 drill.py 若 forward 后出错,只会在确认:

pg-test-2 is sole healthy leader

时尝试切回 pg-test-1。若 topology ambiguous,它不会猜。自动 cleanup 不得为了“恢复初始状态”制造第二次错误历史。

切换状态机检查表

[ ] exact target and authority
[ ] unique current leader
[ ] explicit eligible candidate
[ ] data protection policy known
[ ] client service and transaction probe
[ ] controlled action
[ ] new timeline + one system identifier
[ ] old primary streaming/rebuilt
[ ] acknowledged/unknown outcomes reconciled
[ ] redundancy restored
[ ] exceptions and next gates recorded

小结

switchover assumes health
failover handles unhealthy state and may lose data
service sessions do not migrate
connection retry != business retry
unknown commit needs token lookup
role baseline can return while timeline advances
rewind has direction, prerequisites, and failure risk
service restored != redundancy restored

权威参考


上一节:选主、DCS 与防脑裂 · 返回本章目录 · 下一节:交付并观察 HA 集群 · 查看全书目录 · 查看索引中心

20.6 交付并观察 HA 集群

Pigsty 把 PostgreSQL、Patroni、etcd、HAProxy、PgBouncer、监控与配置交付 组合起来。平台的价值不是隐藏原理,而是让同一 HA 合同可以声明、部署、 观察和重复执行。

本节坚持两条线同时存在:

Pigsty declaration and operator entry
PostgreSQL/Patroni native evidence

平台显示与原生事实不一致时,不选一个“更顺眼”的相信,而是停止并解释 差异。

20.6.1 拓扑、同步策略与服务端点声明

第 19 章保留的 service unit

pg-test-1  10.10.10.11  declared primary
pg-test-2  10.10.10.12  declared replica
pg-test-3  10.10.10.13  declared replica + offline intent

关键点是声明 stable identity 与 placement intent,不把 primary 当成永远 属于某个 host 的固定属性。Patroni 运行时可以改变 role。

一个简化、无 credential 的结构示意:

pg-test:
  vars:
    pg_cluster: pg-test
    pg_version: 18
    pg_conf: crit.yml
  hosts:
    10.10.10.11:
      pg_seq: 1
      pg_role: primary
    10.10.10.12:
      pg_seq: 2
      pg_role: replica
    10.10.10.13:
      pg_seq: 3
      pg_role: offline

这不是正式 live inventory;具体 schema 以所用 Pigsty release 和 Cluster / Instance 文档 为准。真实 inventory 可能含 credential,只能保存在 private mode-0600 文件中。

declaration 的边界

inventory 能说明:

desired membership
stable instance identity
initial placement intent
parameter template
service definition

不能单独证明:

live PostgreSQL role
replication caught up
DCS authority
client routing
failure-domain independence
RPO/RTO

所以第 19 章 acceptance 与本章 phase capture 都要读 live state。

同步策略有两处 authority

区分:

local Patroni config
  member-specific bootstrap/connectivity/watchdog/DCS endpoint

dynamic Patroni config in DCS
  ttl, loop_wait, sync mode, failover lag, PostgreSQL parameters

修改 DCS dynamic config 后,只查 inventory 会读到旧意图;只查某台本地 YAML 也可能漏掉 cluster-level state。

本章 capture:

each member local:
  scope/member_name/version
  dcs kind and endpoint count
  watchdog mode
  REST/PostgreSQL connect address

cluster dynamic:
  ttl/loop/retry
  maximum_lag_on_failover
  sync modes
  pause/failsafe
  use_pg_rewind/use_slots

完整 config 可能含 secret,evidence 只导出 allowlist。

service 是对外能力,不是节点别名

Pigsty 默认服务抽象:

primary :5433
  read-write -> current primary -> default target usually PgBouncer

replica :5434
  read-only -> eligible replicas; policy can define fallbacks

default :5436
  admin/direct -> current primary PostgreSQL

offline :5438
  offline/OLAP placement

服务定义包含:

port
destination: pgbouncer/postgres
Patroni health endpoint
member selector
backup selector

参考当前 Pigsty Service/Access

primary service 的数据路径

默认可概括:

client
  -> member/VIP/DNS :5433
      -> HAProxy
          -> backend health on Patroni :8008 /primary
              -> current primary PgBouncer :6432
                  -> PostgreSQL :5432

每一跳都可能影响恢复时间。HAProxy 看到新 primary,不代表 pool 中每条旧 connection 都可继续。

endpoint 也要版本化

服务合同应记录:

name/port/protocol
write/read semantics
pooling mode
TLS identity
health source
fallback selectors
timeouts
max connections/queue
DNS/VIP provider
owner

端口号相同不等于 release 间行为完全相同。升级时应 diff render 后的 HAProxy/PgBouncer/Patroni config。

offline 不是“慢查询免疫”

pg-test-3 的 offline intent 能影响 service selector;但它仍:

  • 共享同一 WAL history;
  • 竞争主机 CPU/memory/storage;
  • 可能因长查询产生 recovery conflict;
  • 在本沙箱共享 hypervisor;
  • 不是 delayed backup。

placement label 必须由 metrics 和 workload policy 验证。

配置同步复制前

不要直接修改一条参数。先形成 decision:

failure scope
acknowledgement class
FIRST/ANY set
candidate tags
strict/degrade behavior
placement
latency budget
test plan
rollback

Pigsty/Patroni 是交付入口,PostgreSQL commit semantics 仍按 20.3 解释。

20.6.2 从 Patroni、SQL 和指标验证角色

第一层:Patroni topology

当前 Pigsty 提供:

pig pt list pg-test
pig pt config show
pig pt status

或原生:

patronictl -c /etc/patroni/patroni.yml \
  list pg-test --format=json

patronictl -c /etc/patroni/patroni.yml \
  show-config pg-test

看:

cluster/member identity
one leader
replica state
timeline
lag
pause
dynamic policy

正式实验使用 exact v4.5.0 部署内的原生 patronictl,避免让后来更新的 wrapper 行为被冒充为当时执行路径。正文同时介绍当前 pig pt,但保留版本 边界。

第二层:SQL role

每个 member:

SELECT pg_is_in_recovery(),
       current_setting('cluster_name'),
       current_setting('server_version_num');

预期:

one false  -> current primary
two true   -> standbys
cluster_name = pg-test
server major = 18

若 Patroni 说 primary、SQL 却 pg_is_in_recovery()=true,不要把它当成 “几秒后会好”直接继续 mutation。

第三层:replication direction

primary:

SELECT application_name, client_addr, state, sync_state,
       sent_lsn, flush_lsn, replay_lsn
FROM pg_stat_replication;

standby:

SELECT status, sender_host, sender_port,
       written_lsn, flushed_lsn, latest_end_lsn
FROM pg_stat_wal_receiver;

正式 validator 不只检查 row count,还检查:

application names = exact nonleaders
client address = declared address
receiver upstream = exact current primary
state = streaming

第四层:lineage

SELECT system_identifier FROM pg_control_system();
SELECT timeline_id FROM pg_control_checkpoint();

primary 另外从 current WAL filename 得到 current timeline。

判定:

same system identifier all members/all phases
one Patroni timeline per phase
primary current WAL timeline equals Patroni
timeline advances on promotion
checkpoint timeline is named and interpreted correctly

第五层:retention

SELECT slot_name, active, restart_lsn, wal_status, safe_wal_size
FROM pg_replication_slots;

stable primary 的 slot 必须与两个 replica 一一对应。另看:

pg_wal filesystem
archive success/failure
WAL generation rate

slot active 不是“永远安全”,只说明 consumer 当前使用。

第六层:service

从 client path:

psql "service=pg36-ch20" -X -w \
  -c "select pg_is_in_recovery(), inet_server_addr();"

不要打印 service file;它含 password。正式 helper 只输出:

status=private-service-created
secret_values_exported=0

client path 要验证:

connect
read-write attribute
server role
transaction
reconnect through transition

第七层:metrics 与 logs

观察面板/指标至少覆盖:

Patroni member/leader changes
WAL generation/send/receive/replay
replication lag
slots/WAL retention
HAProxy backend state and sessions
PgBouncer connections/wait
PostgreSQL transaction/lock/error
host CPU/memory/disk/network
DCS latency/health

但 dashboard 颜色仍要回到 query definition。metric label primary 是从谁 推导的?采样周期多长?切换时有没有 stale series?

角色一致性矩阵

phase Patroni leader SQL primary service write target upstream
before pg-test-1 .11 .11 pool replicas←.11
forward pg-test-2 .12 .12 pool replicas←.12
restored pg-test-1 .11 .11 pool replicas←.11

四列不能只靠一份 patronictl list 填满。

capture hygiene

正式采集固定:

SSH -F /dev/null
BatchMode=yes
allowlisted local config
no arbitrary Patroni tags
no credential output
structured JSON
source SHA-256

禁用本机 SSH config 是因为第 19 章曾发现地址 alias/forwarding 会把多个 目标看成同一 guest。生产不能照抄 StrictHostKeyChecking=no;本选择只为 disposable local sandbox。

20.6.3 演练动作对应的 Pigsty 入口与原生证据

当前 Pigsty 操作入口

在当前文档版本:

pig pt list pg-test
pig pt switchover --plan
pig pt switchover -l pg-test-1 -c pg-test-2
pig pt failover -c pg-test-2 --plan
pig pt config show
pig pt log -f

pig pt 封装常见 patronictl/systemctl 操作。switchover 是计划切换; failover 是不健康 cluster 的 manual failover,不能互换。

reinit 会删除目标 member 数据并重新同步,是破坏性动作:

pig pt reinit pg-test-2 --plan

本章不执行 reinit。

平台入口与原生命令对照

意图 Pigsty 当前入口 原生核心 证据
列成员 pig pt list patronictl list JSON + SQL
看策略 pig pt config show patronictl show-config DCS config
计划切换 pig pt switchover patronictl switchover timeline/client
手工故转 pig pt failover patronictl failover data risk/fence
重加成员 pig pt reinit Patroni reinit base copy/rejoin
服务路径 rendered service HAProxy/Patroni/PgBouncer external probe

wrapper 改善 ergonomics 与 preflight,不改变底层状态迁移的风险等级。

本章为什么用原生 patronictl

正式 target 是 exact Pigsty v4.5.0 archive。实验记录必须说明当时实际可用 的执行机制:

executor=patronictl
config=/etc/patroni/patroni.yml
cluster=pg-test
leader/candidate explicit

当前 pig pt 文档在书写时已经提供更完整 wrapper;它适合读者检查当前 环境,但不能篡改历史 evidence。

正常 all 为什么不调用 switchover

一个危险的工具设计:

task.sh all
  -> capture
  -> switchover
  -> verify

用户可能只想重验报告,却意外移动 primary。

本章语义:

capture   read-only current snapshot
verify    validate retained evidence
review    provenance + interpretation
all       verify + review only

drill:switchover
  separately guarded L2 action

reset:fixture
  separately guarded destructive action

安全应该体现在 interface,而不只写在注释里。

live drill guard

需要全部精确满足:

PG36_CH20_TARGET=pg36-l2-vagrant/pg-test
PG36_CH20_NONPRODUCTION=true
PG36_CH20_PRODUCTION_DATA=false
PG36_CH20_PRODUCTION_TRAFFIC=false
PG36_CH20_CONFIRM=SWITCH_CH20_PG_TEST_1_TO_2_AND_BACK
new empty evidence directory
private mode-0600 chapter-19 inventory

底层 drill.py 再验证:

same exact target/confirmation/authority
service file mode=0600 and not symlink
host/port/database/user/service attributes match contract
output directory empty

defense in depth 防止绕过 wrapper。

preflight 与 postflight

外层动作:

chapter 19 all -> pass
private service generation
chapter 20 drill
positive + ten negative validations
chapter 19 all -> pass
chapter 20 review
temporary secret cleanup

postflight 不是形式主义。它证明:

pg-test-1 returned as unique primary
two replicas stream
host/service baseline not drifted
production gate remains pending

source identity

manifest 保存所有 decision/executable input 的 SHA-256。review.py 要求当前 source 与 run source 一致。

两份 outcome 文件不作为下一次输入:

drill-run.json
migration-effort.json

它们被明确排除 manifest source set,避免“运行结果参与定义自己的输入” 循环;review 仍单独验证其 schema 与解释边界。

反例

正常 report 通过还不够。十个 corruption 必须被拒绝:

claim production from sandbox
switch unreviewed candidate
ignore action failure
foreign system identifier
two leaders
no timeline advance
old primary not rejoined
acknowledged token lost
unknown outcome unreconciled
write gap above objective

这使 validator 不只会接受 happy path,也证明关键 guard 真能失败。

本节操作边界

安全 read-only:

export PG36_EVIDENCE_DIR=/private/evidence/ch20-formal
static/labs/ch20/task.sh all

不要从书页复制 live mutation 到生产。先读:

小结

inventory declares; live planes prove
service abstracts role, not failure semantics
Pigsty wrappers map to Patroni/PostgreSQL primitives
exact release boundaries matter
all must be safe to repeat
mutation, validation, and reset need separate authority
negative tests protect interpretation

权威参考


上一节:切换、故障转移与重加入 · 返回本章目录 · 下一节:实战:一次有证据的计划切换 · 查看全书目录 · 查看索引中心

20.7 实战:一次有证据的计划切换

这是一次真实运行过的实验,不是示例输出。

在第 19 章保留的本地四机沙箱中,本章对 pg-test 执行:

pg-test-1 -> pg-test-2 planned switchover
pg-test-2 -> pg-test-1 planned baseline restore

同时通过 Pigsty primary service 连续写 synthetic idempotency token,保存 PostgreSQL、Patroni、client 与 chapter-19 pre/postflight evidence。

安全结论先写在前面:

local disposable sandbox only
production data/traffic forbidden
planned transition only
no process/host/network/storage/DCS fault
no reinit
no cluster reset
fixture reset not executed
production approval remains pending

20.7.1 预检查、切换、客户端观察与数据核对

风险分级

action risk 远端 mutation
capture L0
verify L0
review L0
all L0
drill:switchover L2 synthetic fixture + 两次 planned switchover
reset:fixture destructive drop test.pg36_ch20 schema

本节只描述如何在指定 local sandbox 重现。生产环境即使也叫 pg-test,也 不能因此获得授权。

先读合同

确认:

target = pg36-l2-vagrant/pg-test
three exact members
initial/final leader = pg-test-1
forward candidate = pg-test-2
production_data_permitted=false
production_traffic_permitted=false

第 19 章基线仍是前提

正式运行前,wrapper 自动执行:

PG36_EVIDENCE_DIR="$run/preflight-ch19" \
  static/labs/ch19/task.sh all

必须看到:

status=ok
hosts=4-distinct
topology=pg-meta-1-primary+pg-test-1-primary-2-replicas
sandbox_l2=accepted-with-exceptions
production_ch19_gate=pending
mutation=none

若第 19 章失败,本章不“顺便修”。先解释 target/host/topology drift。

私密输入

需要第 19 章 private inventory,只用于生成临时 libpq service file:

mode=0600
not committed
not copied into evidence
password never printed

helper 从 exact pg-test user declaration 取 credential,输出:

[pg36-ch20]
host=10.10.10.11
port=5433
dbname=test
user=test
target_session_attrs=read-write
sslmode=prefer

此处故意不展示 password。临时文件是 mode 0600、拒绝 symlink/overwrite, wrapper 退出时删除。

sslmode=prefer 只因本地 5433 没有 TLS,形成命名例外;生产不可复制。

synthetic fixture

setup.sql 建立:

CREATE SCHEMA pg36_ch20;

CREATE TABLE pg36_ch20.write_probe (
    run_id        text        NOT NULL,
    attempt_no    integer     NOT NULL,
    token         text        NOT NULL UNIQUE,
    client_sent_at timestamptz NOT NULL,
    committed_at  timestamptz NOT NULL DEFAULT clock_timestamp(),
    PRIMARY KEY (run_id, attempt_no)
);

正式文件还验证 schema owner、column type/nullability、primary/unique key; 如果已存在但结构漂移,拒绝使用。

fixture 只含 synthetic token。清理不是 acceptance 前提,也不会被 drill 自动 drop。

preflight phase

在任何 DDL 前,底层脚本先 capture before

Patroni exactly one pg-test-1 primary
pg-test-2/3 streaming
one system identifier
SQL roles agree
two sender rows
replay gap <= 1 MiB
dynamic policy exact
pause=false

然后创建/验证 fixture、启动 probe,等第一个 acknowledged write,再 warm up 3 秒并 capture pre-switch

为什么要两次:

before
  证明 mutation 前 baseline

pre-switch
  证明 probe 已运行且 action 紧邻前 topology 仍合格

client probe

运行 24 秒,每 0.2 秒尝试:

INSERT ... RETURNING
  committed_at,
  pg_current_wal_insert_lsn(),
  current WAL filename timeline,
  backend pid,
  pg_is_in_recovery();

每次 token:

<run UUID>:<8-digit attempt>

事件立即 append JSONL 并 fsync 本地 evidence:

attempt start/end monotonic ns
acknowledged or unknown
SQLSTATE/error class without credential
WAL LSN/timeline for acknowledged event

probe 不把异常 token 当成一个新业务动作重试。

执行正式动作

读者使用当前 Pigsty 时,可以先:

pig pt switchover --plan

本书 exact v4.5.0 target 的 formal executor 是:

patronictl -c /etc/patroni/patroni.yml \
  switchover pg-test \
  --leader pg-test-1 \
  --candidate pg-test-2 \
  --force

但不要手工绕过本章 guard。完整入口:

export PG36_CH19_INVENTORY=/absolute/private/path/pg36.yml
export PG36_EVIDENCE_DIR=/absolute/path/to/new-empty/ch20-run
export PG36_CH20_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH20_NONPRODUCTION=true
export PG36_CH20_PRODUCTION_DATA=false
export PG36_CH20_PRODUCTION_TRAFFIC=false
export PG36_CH20_CONFIRM=SWITCH_CH20_PG_TEST_1_TO_2_AND_BACK

static/labs/ch20/task.sh drill:switchover

所有 guard 都必须 exact match。output 已非空则拒绝覆盖。

forward completion

动作完成定义不是 CLI exit:

Patroni member set exact
pg-test-2 sole primary/running
pg-test-1 and pg-test-3 replica/streaming

然后 capture after-forward,等待 probe 完成并 reconcile token。

token reconciliation

SELECT attempt_no, token, committed_at
FROM pg36_ch20.write_probe
WHERE run_id = $1
ORDER BY attempt_no;

分类:

acknowledged_missing
unknown_committed
unknown_absent
duplicate_tokens
unreconciled_unknown

验收:

acknowledged_missing=0
duplicate_tokens=0
unreconciled_unknown=0
persisted = acknowledged + unknown_committed

恢复教学角色基线

probe 完成后:

patronictl ... switchover pg-test \
  --leader pg-test-2 \
  --candidate pg-test-1 \
  --force

再等:

pg-test-1 sole primary
pg-test-2/3 streaming

capture restored。这是第二次 planned switchover,不是 rewind timeline。

异常时的保守恢复

若 forward 后中途失败,脚本只在能确认:

pg-test-2 is the sole healthy leader

时尝试切回 pg-test-1

若 topology ambiguous:

stop automatic recovery
print inspection requirement
preserve evidence

它不会为了“清理实验”猜 leader。

postflight

角色恢复后重新运行第 19 章 all。正式 preflight 与 postflight 都通过。

证据树:

ch20-run/
├── preflight-ch19/
├── drill/
│   ├── phases/
│   │   ├── before.json
│   │   ├── pre-switch.json
│   │   ├── after-forward.json
│   │   └── restored.json
│   ├── forward-action.json
│   ├── restore-action.json
│   ├── client-events.jsonl
│   ├── client-probe.stderr
│   ├── reconciliation.json
│   ├── drill-manifest.json
│   ├── validation-report.json
│   └── negative-report.json
├── postflight-ch19/
└── review.txt

目录应放在 private evidence 存储,不进入 Git。

read-only 重验

export PG36_EVIDENCE_DIR=/absolute/path/to/ch20-run
static/labs/ch20/task.sh all

输出应含:

status=ok
counterexamples=10-rejected
acknowledged_commits=all-present
sandbox_planned_switchover=accepted-with-exceptions
unplanned_failure_drill=not-run
production_ch20_gate=pending
mutation=none

all 不移动 leader。

fixture reset

reset:fixtureDROP SCHEMA pg36_ch20 CASCADE,是独立 destructive action,需要完整 reviewed evidence、drained clients 和另一个 exact token。

正式实验没有执行 reset。保留 fixture 供后续查证没有问题;若确实清理, 先读合同,不要把它和 cluster reset 混淆。

20.7.2 测量实际 RTO、提交风险与恢复时间

先纠正这个标题

本次没有注入 failure,所以没有测量 failure-time “实际 RTO”。它测到的是:

planned action command duration
planned action -> Patroni stable duration
sampled client write gap
baseline restore duration

把这些都标成 RTO,会把检测、租约、fencing 和故障信息缺失从模型中删掉。

四个 monotonic 时钟

A  forward action starts
B  patronictl returns
C  Patroni topology becomes stable
D  first acknowledged client write after C

指标:

command=BA command = B-A action_to_stable=CA action\_to\_stable = C-A conservative write gap=DlastAckBefore(A) conservative\ write\ gap =D-lastAckBefore(A)

probe 使用 monotonic clock,避免 wall clock adjustment 影响 duration。UTC timestamp 只用于人类关联日志。

正式数据

formal wrapper start        2026-07-29T19:39:33Z
forward start               2026-07-29T19:39:46Z
forward command             2734.741 ms
forward stable              2026-07-29T19:39:52Z
action to stable            5822.678 ms
conservative write gap      6007.080 ms
maximum adjacent ack gap    5207.690 ms
restore command             2851.349 ms
restore action to stable    6018.921 ms
wrapper end                 2026-07-29T19:40:17Z
wrapper elapsed             44 s

wrapper elapsed 还包括:

chapter-19 preflight/postflight
four phase captures
24-second client probe
positive/negative validation
review

不能和 service interruption 比较。

为什么保守 gap 大于 command

command exit       2.735s
topology stable    5.823s
client gap         6.007s

差额来自:

Patroni role convergence
old member rejoin
HAProxy health cycle
connection/pool recovery
probe sampling interval

这正说明用 CLI 耗时报告 RTO 会偏乐观。

probe resolution

observed interval:

0.206990 s

采样 gap 至少包含一个 probe interval 的不确定性。若业务 QPS、driver backoff、transaction time 不同,观测会变。

生产 measurement 应:

  • 从真实外部 client vantage;
  • 按 transaction class;
  • 报告 distribution;
  • 记录 retry/backoff;
  • 分开 read/write;
  • 包含 detection;
  • 关联 topology/fence;
  • 说明 measurement resolution。

提交结果

attempts                 120
acknowledged              95
unknown                   25
persisted rows            95
acknowledged missing       0
unknown committed          0
unknown absent            25
duplicate tokens           0
unreconciled unknown       0

身份等式:

events=acknowledged+unknown=95+25=120 events=acknowledged+unknown=95+25=120 persisted=acknowledged+unknownCommitted=95+0=95 persisted=acknowledged+unknownCommitted=95+0=95

所有 token list length 也与 count 交叉验证。

unknown 全 absent 代表什么

本次 transition window 中的 25 个异常尝试,在最终 chosen history 查不到。 它们可能在连接建立前失败,也可能发送但未提交;对应用而言,先统一归为 unknown,再以 token lookup 得到 absent。

它不意味着未来 unknown 都 absent。系统必须支持:

unknown committed -> return existing result, do not duplicate
unknown absent    -> apply business retry policy

acknowledged 全在代表什么

可以严格说:

all 95 writes that returned success in this run exist after forward
switchover and baseline restore

不能说:

the asynchronous cluster has zero RPO

因为 healthy switchover 会协调 caught-up candidate;没有突然永久丢失 primary WAL tail。

timeline token

acknowledged event 从 WAL 文件名记录:

00000005
00000006

证明 client writes 横跨 forward transition 的两个 timeline。restore 在 probe 结束后执行,所以 client probe 不要求看到 7;restored phase 的 SQL 与 Patroni 证明 timeline 7。

objective

事前合同:

minimum acknowledged attempts = 30
maximum conservative gap      = 15000 ms
missing acknowledged          = 0
duplicates                    = 0
unreconciled unknown          = 0

正式通过:

95 >= 30
6007.080 <= 15000
0 / 0 / 0

这个 15 秒目标是教学 sandbox acceptance,不是生产 SLO。

建立真正 RTO 需要什么

另行授权的 unplanned drill 要定义:

fault injection time
failure domain
old-primary fence evidence
lease/detection
candidate eligibility
new authority
service route
client transaction
backlog recovery

并重复足够次数,报告 percentile 和失败 run。第 33 章再做,不在这里偷换。

20.7.3 输出拓扑证据、时间线和改进项

topology evidence

phase leader replicas Patroni timeline primary WAL timeline
before pg-test-1 pg-test-2/3 5 5
pre-switch pg-test-1 pg-test-2/3 5 5
after-forward pg-test-2 pg-test-1/3 6 6
restored pg-test-1 pg-test-2/3 7 7

所有 SQL phase 的 system identifier 相同;公开 summary 只记录 one unchanged identifier,不把机器/cluster identity 无必要地扩散。

sender、receiver 与 slot

每个 stable primary:

two async streaming senders
application name and client address exact
replay gap inside 1 MiB lab bound
two active physical slots named for nonleaders
no WAL receiver

每个 standby:

one streaming WAL receiver
sender_host=current primary
sender_port=5432

这使“old primary rejoined”不是只靠 Patroni label。

checkpoint timeline 反例

正式结束:

cluster current timeline                7
pg-test-3 checkpoint_timeline_id        3
pg-test-3 Patroni state                 streaming
pg-test-3 WAL receiver upstream         pg-test-1

validator 接受 checkpoint 3 <= 7,不要求相等。若早期实现把 pg_control_checkpoint().timeline_id 命名为 timeline_id 并强制等于 current,实验会误报。

这是本章最值得保留的“测量修正”:发现反例后改 evidence schema,再重跑 正式实验,而不是改解释去迁就旧字段。

manifest

schema=pg36-ch20-drill-manifest-v1
run UUID present
mode=planned-switchover-and-planned-baseline-restore
production_approval=false
unplanned_failure_injected=false
secret_values_exported=0
17 source input files hashed

review.py 对比 current source。source 改过以后,旧 run 不能冒充新实现的 证据。

十个反例

case expected rejection
sandbox 冒充生产 E_PRODUCTION_CLAIM
未评审 candidate E_ACTION
忽略 command failure E_ACTION
外来 system identifier E_LINEAGE
两个 leader E_TOPOLOGY
timeline 未推进 E_TIMELINE
旧主未 rejoin E_TOPOLOGY
acknowledged token 丢失 E_COMMIT_EVIDENCE
unknown 未核对 E_COMMIT_EVIDENCE
gap 超过 15 秒 E_WRITE_GAP

正式 10/10 按预期失败。一个 validator 若只在 happy input 上返回绿色, 还没有证明 guard 分支存在。

accepted decision

sandbox_planned_switchover=accepted-with-exceptions
unplanned_failure_drill=not-run
production_ch20_gate=pending

十个 exception ID 与第 19 章/本章合同 exact match。

不通过的生产问题

shared hypervisor
single etcd
single backup target
virtual storage unqualified
temporary inventory secret lifecycle
three guests below recommended resource floor
async replication
watchdog off
external probe path without TLS
planned-only drill

改进项按 gate 排序

HA production gate

independent failure-domain placement
production DCS quorum
tested fencing/watchdog
approved sync/degrade policy
unplanned process/host/network/DCS drills
repeatable RTO/RPO distribution
manual failover runbook and authority

Recovery gate

independent repository
archive continuity
restore + PITR
timeline history
data/business validation

第 21 章负责。

Client gate

DNS/VIP/HAProxy path
pooling behavior
driver timeout/backoff
session reset
idempotency/reconciliation
read consistency and replica fallback

第 22 章负责。

Security gate

TLS verify-full or equivalent
credential authority/rotation
least privilege
host key trust
audit

第 23 章负责。

ADR

ha-adr.md 记录:

accepted: planned switchover behavior
rejected: automatic failover claim
rejected: command duration as RTO
rejected: no missing tokens as zero RPO
rejected: checkpoint timeline as current standby timeline
rejected: all reruns live mutation

这让将来升级时能判断是事实变化,还是 decision boundary 被悄悄扩大。

正式运行摘要

drill-run.json 是 secret-free reference account:

versions
times
topology relation
client counts/metrics
gates
exceptions/decision

它不是完整 evidence 的替代品;完整 bundle 留在 private path。

20.7.4 记录把本章迁移到新版本基线的工时

为什么记录 effort

版本迁移成本不只有:

change version string

还包括:

contract review
CLI/API drift
config schema drift
evidence query drift
lab provisioning
safe live run
counterexample maintenance
prose/source verification

没有记录,就无法判断下一版是“十分钟 bump”还是“重新认证 HA 行为”。

不虚构 human effort

本次可精确测得:

formal wrapper machine elapsed = 44 seconds

不能从 wall-clock timestamp 反推出:

human authoring seconds
operator active attention
review effort

因此 migration-effort.json 明确:

{
  "formal_machine_elapsed_seconds": 44,
  "human_authoring_seconds": null,
  "human_authoring_measurement": "not-instrumented-do-not-infer"
}

null 比一个看似精确但捏造的数字更专业。

effort 维度

下次迁移记录:

category measurement
research release notes、docs、known issues active human time
source adaptation API/query/config change diff + active time
environment build/deploy/converge machine + operator
validation capture/positive/negative machine time
live drill guarded transition exact start/end
diagnosis failed run/root cause active + elapsed
writing prose/citations/diagrams active time if instrumented
review technical/safety/editorial reviewer time

active 与 elapsed 分开:

machine waits 30 minutes
operator active 3 minutes

二者都可能影响排期,但含义不同。

新版本迁移顺序

  1. 固定目标 release/commit,不用 moving branch;
  2. 阅读 PostgreSQL、Patroni、Pigsty release notes;
  3. diff inventory、Patroni dynamic/local config、service definitions;
  4. 检查 SQL catalog/function 字段变化;
  5. 更新 requirements.json,不要先改 validator 放宽;
  6. 更新 capture schema 与 source allowlist;
  7. 用 synthetic evidence 跑 normal/negative;
  8. 在新 disposable target 重做第 19 章;
  9. 做只读 chapter-20 capture;
  10. 获得 L2 authority 后跑 planned drill;
  11. 保留失败 candidate run,不覆盖;
  12. 更新 reference outcome 与正文;
  13. 独立 review;
  14. 只在证据支持时改变 decision。

兼容性问题清单

PostgreSQL:
  server_version_num, catalog/view/function fields
  WAL/timeline semantics
  pg_rewind prerequisites
  sync commit behavior

Patroni:
  CLI flags/output
  dynamic config
  sync/failsafe/watchdog semantics
  REST health endpoints

Pigsty:
  inventory schema
  default service ports/selectors
  wrapper commands
  monitoring labels
  component versions

client:
  libpq/psycopg behavior
  TLS/service file
  target_session_attrs

baseline result不能机械复制

新版本即使同样输出:

status=ok

也要检查:

validator 是否仍在验证同一事实
字段是否变成 null/新语义
默认配置是否改变
错误分支是否仍拒绝
exception 是否增加/消失

升级最危险的不是 test fail,而是旧 test 在新语义下无意义地继续 pass。

版本迁移 stop conditions

source release not exact
private inventory leaks
unknown config migration
old/new evidence schema mixed
member identity/topology ambiguous
client path differs without contract update
reset/reinit needed but not separately authorized
production target substituted for sandbox

遇到即停止,不用“先跑一次看看”跨过 authority。

当前 effort 记录的范围

正式文件只承诺:

work stream start timestamp recorded
formal wrapper start/end recorded
machine elapsed=44s
human authoring not instrumented
scope note prevents inference

这为以后建立更完整度量留下可比较 schema,同时不美化本次过程。

本章最终复核

[x] failure model before operation
[x] exact nonproduction target
[x] chapter-19 pre/postflight
[x] private credential never exported
[x] named leader and candidate
[x] client probe through service
[x] one system identifier
[x] timeline 5 -> 6 -> 7
[x] old primary rejoined
[x] acknowledged/unknown reconciled
[x] ten negative cases rejected
[x] temporary service file removed
[x] production gate remains pending
[ ] unplanned failure
[ ] fencing/watchdog
[ ] DCS quorum failure
[ ] zero RPO
[ ] production RTO/SLO

小结

本次实验真正证明的是:

在 exact Pigsty v4.5.0 / PostgreSQL 18.6 / Patroni 4.1.3 本地沙箱、 异步复制、单 etcd、无 watchdog 的记录条件下,一个健康且明确命名的 candidate 完成计划切换,旧主重新加入,客户端在约 6 秒的采样间隙后 恢复写入;95 个已确认 token 全部存在,25 个结果未知 token 全部核对 为未提交,最终角色基线恢复。

它明确没有证明:

自动故障转移、旧主硬隔离、DCS quorum、零 RPO、生产 RTO、灾难恢复或 生产安全。

把两段话一起交付,才是完整的 HA evidence。

权威参考


上一节:交付并观察 HA 集群 · 返回本章目录 · 下一章:未雨绸缪:备份体系与恢复演练 · 查看全书目录 · 查看索引中心

21 未雨绸缪:备份体系与恢复演练

pgbackrest info 显示 status: ok,不等于数据可恢复;每天都生成备份, 不等于误删后能回到正确时刻;有三台流复制副本,更不等于有一份独立备份。

备份体系真正要交付的是一个可证伪的命题:

当某个已声明的损失场景发生时,团队能否找到一条完整、可信、权限可用的 恢复链,在隔离环境中把 PostgreSQL 带到预定边界,验证数据库与业务不变量, 再以受控方式交付服务?

这句话里没有“备份成功率”这个单一答案。它至少包含:

scenario
  -> recovery point objective
      -> base backup lineage
          -> continuous WAL
              -> repository and key availability
                  -> target selection
                      -> isolated restore
                          -> PostgreSQL consistency
                              -> business validation
                                  -> controlled cutover

本章先从误删、介质丢失、区域故障和合规留存反推 RPO/RTO;再解释物理 基础备份、WAL、timeline 与归档链;随后把 full/diff/incr、过期、加密、 不可变和异地副本放进同一份仓库设计;最后用 Pigsty 与 pgBackRest 完成一次真实的命名恢复点演练。

本章不把成功说大:

fresh full backup                 通过
named-point physical PITR         通过
base + keep / no discard          通过
isolated Unix-socket postmaster   通过
new timeline and same lineage     通过
source cluster remains healthy    通过
arbitrary-time recovery           未测试
missing WAL / lost key            未注入
immutable off-site repository     未证明
production-sized RTO              未证明
regional disaster recovery        未测试
production approval               pending

本章目标

读完并完成实验后,你应当能够:

  1. 从损失场景与业务真相出发,而不是从“每天全备”出发设计恢复;
  2. 区分 RPO、RTO、恢复粒度、保留周期、历史版本数和法律留存;
  3. 说明逻辑备份、物理备份、存储快照、流复制与 CDC 各自能恢复什么;
  4. 解释基础备份为何必须配合一条连续 WAL 链;
  5. backup_label、LSN、WAL 文件名与 timeline history 判断物理血缘;
  6. 设计幂等且不会覆盖不同内容的归档路径,并理解归档积压为何会填满 pg_wal
  7. 正确比较全量、差异、增量的依赖、恢复复杂度与过期语义;
  8. 把加密密钥、不可变、独立凭据、异地副本与恢复权限纳入仓库合同;
  9. 区分“仓库可读”“文件恢复完成”“只读可用”“提升完成”“业务可用”;
  10. 正确选择 time、name、XID、LSN、inclusive/exclusive 与 timeline;
  11. 在 Pigsty 中声明、观察和操作 pgBackRest,而不把平台包装当成原理;
  12. 在不覆盖原集群的前提下完成一次可重放、可审计的隔离恢复;
  13. 输出测量口径、证据、反例、例外与生产准入差距;
  14. 知道成功恢复一次之后,下一次应该故意测试哪些失败路径。

前置与后续

前置:

后续:

  • 第 22 章 服务接入、连接池与路由 处理恢复后如何 把客户端安全带到正确角色;
  • 第 23 章深入身份、传输、凭据与密钥;
  • 后续容量、监控、变更与事故章节会把恢复证据纳入生产治理;
  • 区域级 DR 和真正的 destructive replacement 必须在独立授权的演练中 完成,不由本章沙箱命令暗中代替。

学习路径

business loss scenario
  -> authoritative truth + tolerated loss
      -> RPO / RTO / granularity / retention
          -> logical vs physical vs snapshot vs replica
              -> base backup + WAL continuity + timeline
                  -> repository dependency graph
                      -> select backup and target
                          -> restore into isolation
                              -> wait through promotion
                                  -> database + business proof
                                      -> gaps and next drill

这条路径故意不从复制 pgbackrest restore 命令开始。恢复命令是一个高风险 状态迁移;没有目标语义、血缘、WAL、隔离和验收标准时,命令执行得越顺利, 越可能迅速得到一个“能启动但不该交付”的数据库。

四层恢复证明

把“可恢复”拆成四层,能避免指标替代:

层次 最低问题 常见证据 尚不能推出
仓库层 备份与 WAL 对象能否读取 catalog、checksum、archive range PostgreSQL 能启动
引擎层 能否恢复到一致状态 recovery log、timeline、pg_is_in_recovery() 目标数据正确
数据层 预期事实是否存在/不存在 token、行数、约束、聚合、对账 应用依赖可用
服务层 应用能否安全接入 routing、权限、smoke、backlog 长期 SLO 已满足

本章正式实验走到数据层,并用 rollback-only write probe 证明提升后可写; 它不切换生产路由,因此没有宣称服务层 cutover 通过。

正式实验拓扑

target           pg36-l2-vagrant/pg-test
Pigsty           v4.5.0
PostgreSQL       18.6
pgBackRest       2.59.0
source           pg-test-1, live primary, timeline 7
repository       S3-compatible MinIO, AES-256-CBC, one sandbox target
restore host     pg-test-3
live member      Patroni replica on 5432, unchanged
isolated copy    fresh path + private Unix socket + port 55432
archive push     off
recovery target  named point
target action    promote
target timeline  latest

实验业务边界:

base      committed before fresh full backup       must exist
keep      committed after backup, before target    must exist
target    named restore point
discard   committed after target                    must not exist

正式观测:

backup label                        20260729-201041F
backup command                         2.086 s
pgBackRest check                       0.598 s
logical backup bytes                   36,121,841
repository delta bytes                  4,539,288
restore copy                            2.758 s
start -> first connection               0.963 s
first connection state                  recovery=true, read_only=true
start -> promoted and writable          1.319 s
read-only -> promoted                   0.356 s
source timeline -> restored timeline    7 -> 8
system identifier relation              matches source
source replica lag after drill          0 bytes
counterexamples rejected                14

这些时间是 36 MB 级合成沙箱的一次观测,不是生产 RTO。它们最有价值的 发现反而是:

pg_ctl -w start 返回时,实例可能刚进入 hot standby 的只读可用阶段, recovery_target_action=promote 尚未完成。

所以正式脚本没有把“第一条 SELECT 成功”当成恢复完成,而是继续等待 pg_is_in_recovery() = false,再做一次回滚写入。

十项例外

沿用第 19 章六项:

EX19-SHARED-HYPERVISOR
EX19-SINGLE-ETCD
EX19-SINGLE-BACKUP-TARGET
EX19-VIRTUAL-STORAGE
EX19-INVENTORY-SECRETS
EX19-LAB-RESOURCE-FLOOR

本章新增四项:

EX21-SHARED-RESTORE-HOST
  恢复进程、目录与网络隔离,但与 live replica 共用 guest/hypervisor;
  不能声称主机、内核、设备和故障域隔离。

EX21-REPOSITORY-NOT-IMMUTABLE
  只有本地单 MinIO;未证明 object lock、独立凭据或跨区域副本。

EX21-SMALL-SYNTHETIC-DATA
  数据量很小;不能拿时间结果做生产容量规划。

EX21-NAMED-POINT-ONLY
  只验证一个命名点,并主动切 WAL 后检查;不能代表任意时间点或最坏
  归档间隙 RPO。

例外不是装饰性免责声明。每项都对应一个被禁止的推论,机器验收要求它们 完整保留。

本章目录

21.1 从恢复场景设计备份

21.2 物理备份与 WAL 连续性

21.3 备份仓库与保留策略

21.4 恢复流程与验证

21.5 用 pgBackRest 交付备份策略

21.6 实战:完成一次隔离恢复演练

实验入口

动作语义:

capture / verify / review / all
  L0 read-only

drill:pitr
  guarded local sandbox mutation
  insert markers + full backup + fresh isolated restore + stopped retention

reset:fixture
  destructive and separate
  delete exactly one reviewed run only

all 只重验已有证据,不会为了演示方便再做一份备份或再启动一次恢复。

权威资料

原理优先以当前 PostgreSQL 18 文档为准:

实现与平台入口:

版本相关命令在使用前应回到对应版本文档核对。本章 formal evidence 固定 在 Pigsty v4.5.0、PostgreSQL 18.6 与 pgBackRest 2.59.0;“当前文档入口” 不是“历史版本命令完全相同”的承诺。

本章最重要的判断

backup command succeeded        != recoverable
catalog status ok               != WAL chain complete for every target
replica healthy                 != independent backup
physical restore started        != recovery complete
read-only query succeeded       != promotion complete
PostgreSQL consistent           != business truth correct
one fast sandbox restore        != production RTO
encrypted repository            != ransomware resistance
retained for 14 days            != 14 days of arbitrary PITR
restore directory retained      != service approved

真正的完成条件是:

declared scenario
+ selected truth boundary
+ complete lineage and WAL
+ isolated executable restore
+ engine and business proof
+ controlled service decision
+ explicit residual risk

下一节从第一项开始:先定义究竟要从什么损失中恢复。


上一章:狡兔三窟:高可用拓扑与容灾目标 · 返回下卷导读 · 下一章:四通八达:服务接入、连接池与路由 · 查看全书目录 · 查看索引中心

21.1 从恢复场景设计备份

备份设计最容易犯的错误,是从一个漂亮的日历开始:

周日全备
周一到周六增量
保留 14 天

它回答了“工具何时运行”,却没有回答:

什么会丢?
丢了什么算业务损失?
要回到哪个边界?
由谁判断边界正确?
依赖也丢失时怎么办?
多快恢复到什么能力?

正确顺序是从场景与真相出发,再选择恢复机制、保留和调度。

21.1.1 误删、介质故障、区域故障与合规留存

先定义“损失”

数据库恢复不是把字节放回磁盘,而是重新建立被业务接受的事实。至少区分:

logical loss
  数据库仍运行,但某些正确事实被删除、覆盖或错误变更

physical loss
  数据文件、WAL、设备或整个集群不可用/不可信

site loss
  数据库、控制面、接入层和本地备份一起不可用

historical obligation
  必须证明某个历史版本可恢复、可查询或已按政策删除

不同损失的 truth source 不同。误删一行时,最可信的边界可能来自审计事件; 存储毁坏时,最可信的边界是仓库中最后完整基础备份与连续 WAL;区域故障时, 本地仓库本身不再是可用前提。

场景一:误删与逻辑破坏

典型事件:

DELETE FROM orders;
UPDATE account SET balance = 0;
DROP TABLE customer;
deploy buggy migration;
application writes wrong currency or tenant id;

主库、流复制副本和同步副本都会忠实重放这些操作。HA 可能保持服务在线, 却更快地把错误复制到每份在线副本。

误删恢复要先问:

  1. 错误事务的开始、提交和影响范围是什么?
  2. 目标是整个集群回退,还是只取回一张表/一批行?
  3. 目标时刻之后有哪些正确交易不能丢?
  4. 如何把旧事实与当前事实合并?
  5. 序列、外键、触发器、审计与外部系统如何一致?

常见安全路径不是“原地 PITR 回退生产”,而是:

restore historical cluster in isolation
  -> validate target boundary
      -> export affected logical set
          -> compare with current production
              -> reviewed merge or repair

原地回退会同时删除误操作之后的所有正确提交,通常扩大损失。

场景二:介质、文件系统或集群丢失

典型事件:

data volume lost
filesystem corruption
all HA members share failed storage
operator removes whole cluster
encryption layer/key unavailable
malware changes data and online copies

这时要恢复的是整个物理集群。所需链条通常为:

compatible PostgreSQL runtime
+ one usable base backup
+ every required WAL segment
+ timeline history
+ tablespace/path mapping
+ repository credential and encryption key
+ configuration and identity material

注意最后两项不一定在 PostgreSQL 物理备份内。官方连续归档文档明确提醒, postgresql.confpg_hba.confpg_ident.conf 的手工修改不由 WAL 恢复。平台声明、证书、服务发现、KMS/IAM 与 DNS 也需要独立保存。

场景三:区域或控制域故障

区域故障不是“把同一恢复命令放到另一台机器”:

source region unavailable
local object store unavailable
local DNS/control plane unavailable
operator identity federation degraded
secrets/KMS endpoint unavailable
network dependency changed
capacity not pre-provisioned

如果数据库与备份仓库在同一机房、云账户、管理员凭据或密钥域,所谓 “远程对象存储”仍可能是共同故障。

区域场景的前提矩阵至少包括:

依赖 源域丢失时是否仍可用 证据
基础备份 是/否 异地对象清单、定期读取
WAL 是/否 最大已复制 WAL、延迟
解密密钥 是/否 独立保管与 break-glass 测试
PostgreSQL 包/镜像 是/否 固定版本仓库
Pigsty 声明 是/否 版本化、离站
DNS/证书 是/否 独立控制面
应用依赖 是/否 DR dependency map
人员权限 是/否 异地登录演练

只有“对象在另一个 bucket”远远不够。

场景四:合规留存与法律冻结

合规问题不等于“把备份留久一点”。需要先区分:

operational recovery retention
  为误删、故障与日常恢复保留

historical record retention
  为审计、诉讼、监管或业务档案保留

legal hold
  正常过期规则暂时不能删除特定材料

right-to-delete / minimization
  到期后必须删除或不可再识别

物理备份以整个 cluster 为粒度,单个数据主体的删除很难立刻传播到所有历史 备份。合规方案可能需要:

  • 受控的短期物理恢复窗口;
  • 更长期、字段经过选择与脱敏的逻辑档案;
  • 按租户/数据域分离;
  • 密钥销毁策略;
  • legal hold 与正常过期的冲突处理;
  • 恢复访问的审批、审计和二次删除。

不要让 DBA 独自解释法律语义。数据 owner、安全、法务与平台团队要共同签署 retention class。

场景卡,而不是一句“灾备”

为每个场景写同一张卡:

id: accidental-delete-orders
trigger: confirmed destructive transaction against orders
authoritative_boundary:
  source: audit event + restore point
  timezone: UTC
scope: selected tenant and time range
maximum_tolerated_loss:
  committed_correct_orders: zero
service_target:
  historical cluster queryable: 60 minutes
  repaired rows in production: 4 hours
dependencies:
  - base backup and continuous WAL
  - audit event identity
  - isolated restore capacity
  - reviewed logical merge procedure
stop_conditions:
  - target transaction ambiguous
  - WAL gap
  - post-target correct orders cannot be reconstructed
proof:
  - restored marker boundary
  - row and amount reconciliation
  - approved repair change
owner: data-platform + orders-domain

recovery-scenarios.json 提供本章四类机器可读样例。没有 stop_conditions 的 runbook 很容易在压力下把“不知道”伪装成“应该没问题”。

同一个事故可能需要两条恢复路径

例如误删订单:

path A: service continuity
  current production remains online

path B: historical reconstruction
  isolated PITR -> export -> compare -> repair

例如区域故障:

path A: promote designed DR replica
  lower RTO, possibly different RPO/consistency contract

path B: restore off-site backup
  slower, but independent historical recovery

HA、DR 与 backup 可以组合,但不能互相替名。

21.1.2 RPO、RTO、恢复粒度与保留周期

RPO:允许回退多远

令:

t_loss     事故发生或最后可信事实时间
t_recover  实际可恢复边界

时间口径可写为:

RPOactual=tlosstrecover RPO_{actual}=t_{loss}-t_{recover}

但它不是简单的 backup interval:

daily base backup + continuous WAL
  RPO 主要由 WAL 最后耐久位置决定,不是 24 小时

daily logical dump only
  RPO 可能接近 24 小时

streaming replica
  对主机故障可能接近复制延迟
  对误删可能是 0 秒地复制了错误,无法提供历史点

生产 RPO 必须带场景:

primary process loss RPO
single-AZ loss RPO
repository outage RPO
operator logical error RPO
region loss RPO

“RPO = 5 分钟”却不说明事故类型,是不完整的合同。

WAL archive 的 RPO

连续归档场景中,粗略地:

RPOarchivetfailuretlast durable independent WAL RPO_{archive} \approx t_{failure}-t_{last\ durable\ independent\ WAL}

需要强调 durableindependent

  • WAL 在主库 pg_wal 中,不代表主机丢失后仍可取;
  • archive command 返回成功,不代表对象存储副本已跨域;
  • 对象写入完成,不代表 KMS/credential 在事故中可用;
  • pg_stat_archiver 的累计失败数不等于“当前失败”,要看最近成功/失败时序 和 backlog。

低流量系统还会遇到 segment 未填满。archive_timeout 或显式 pg_switch_wal() 可以缩短时间窗口,但过短会制造大量完整长度的归档对象。

RTO:恢复到哪种能力

完整恢复时间不是 restore 命令运行时间:

RTObusinessTdetect+Tdecide+Tprovision+Tretrieve+Treplay+Tvalidate+Tcutover+Tdependency RTO_{\text{business}} \ge T_{\text{detect}} +T_{\text{decide}} +T_{\text{provision}} +T_{\text{retrieve}} +T_{\text{replay}} +T_{\text{validate}} +T_{\text{cutover}} +T_{\text{dependency}}

可以同时定义多个里程碑:

里程碑 完成条件
repository selected 备份、WAL、key 和 target 已审核
bytes restored pgBackRest 文件阶段完成
consistent PostgreSQL 到达一致恢复点
read-only 可以接受只读查询
promoted pg_is_in_recovery() = false
data validated 引擎与业务不变量通过
service cut over 受控端点连接到正确角色
backlog cleared 积压与补偿完成

本章正式实验测到:

restore copy                  2.758 s
start -> first read-only      0.963 s
start -> promoted             1.319 s

若只记录前两个数字,会漏掉验证、路由、应用依赖和积压,也会把只读窗口误报为 “恢复完成”。

恢复粒度

至少有四种粒度:

cluster
  物理备份/PITR 常见粒度;包含 cluster 内所有数据库

database/schema/table
  逻辑导出或从隔离物理恢复中再导出

row/business object
  比较、审计、补偿与应用语义

service capability
  read-only、read-write、reporting、limited tenant 等

PostgreSQL 物理 WAL 是 cluster 级历史。不能告诉恢复引擎“只重放某张表的 正确事务”。pgBackRest 的 database include/排除能力也不是任意表级 PITR; 选择性恢复的语义必须认真核对限制。

target 精度不等于 truth 精度

PostgreSQL 可按:

time
transaction ID
LSN
named restore point
immediate consistency
end of available WAL

停止恢复。但技术 target 再精确,如果业务事件不明确,也不能证明正确。

例如:

10:00:00 application request sent
10:00:01 database transaction committed
10:00:02 external payment confirmed
10:00:03 audit pipeline wrote event

“恢复到 10:00:02”到底包含什么,取决于时区、提交时间、inclusive 语义与 外部系统。命名点可以降低实验歧义,但生产误删通常只能事后从日志、XID、 LSN 或审计重建边界。

保留周期不是 PITR 窗口的同义词

假设“保留 14 天全备”,仍不能自动推出:

过去 14 天每一秒都可恢复

还需要:

  • 至少一份在目标之前结束的可用基础备份;
  • 从该备份起到目标的连续 WAL;
  • timeline history;
  • 未丢失的依赖备份;
  • 可用的解密密钥和仓库权限;
  • 与目标 PostgreSQL 版本兼容的恢复环境。

真实 PITR window 是这些集合的交集。

恢复点频度、版本数与日历跨度

三个常被混淆的量:

capture cadence
  多久产生一个新逻辑/物理基线

version count
  保留多少组 backup

calendar coverage
  最旧可恢复事实距现在多远

retention_full=2 按 count 解释,与按 time 解释完全不同;差异/增量依赖还会 影响一组备份何时能安全过期。应把政策写成可测试的例子:

at 2026-08-01 12:00 UTC
  must restore any named daily checkpoint since 2026-07-18
  must restore any WAL time since 2026-07-25
  must retain month-end logical archive for 7 years

然后定期真的选最旧目标恢复。

分级服务目标

不是所有数据库都应买同一种恢复成本:

tier 场景示例 RPO RTO 机制
critical 支付主账 秒/分钟级 分钟级 HA + 连续 WAL + 异地仓库 + 高频演练
important 订单业务 分钟级 小时级 连续 WAL + 物理备份 + 选择性恢复
standard 内部应用 小时级 当日 日备 + 合理 WAL/逻辑导出
reconstructable 派生分析 可重算 天级 上游真相 + schema/code + optional backup

分级的结果不是降低严谨性,而是让成本与真实损失匹配。

目标反推设计

对每个 tier 反推:

RPO
  -> WAL archive latency / dump cadence / replication mode

RTO
  -> restore bandwidth / provisioned capacity / runbook automation

granularity
  -> physical, logical, audit or application repair path

retention
  -> backup dependency graph + WAL policy + legal hold

failure domain
  -> repository placement + credential + key independence

proof
  -> drill frequency + oldest target + business invariant

这比先决定“全备还是增量”更接近工程问题。

21.1.3 逻辑、物理、快照和副本的职责边界

没有一种副本解决全部恢复问题

机制 主要内容 典型粒度 优势 主要盲区
pg_dump SQL/归档格式逻辑对象 database/object 可选择、可迁移、可检查 慢;非 cluster 物理 PITR
pg_dumpall 全局对象与多个库的 SQL cluster logical roles/tablespaces 辅助 大规模恢复慢;无 WAL
physical base backup 数据文件物理状态 cluster 快、完整、可配 WAL 版本/平台耦合;粗粒度
WAL archive 变更历史 cluster PITR、连续恢复 必须连续;依赖 base
storage snapshot block/volume volume 快速 capture/clone 一致性、跨卷、WAL 与快照语义
streaming replica 在线物理历史尾部 cluster 低 RTO、读扩展 复制误删;非独立历史
logical replication/CDC 选择性变更流 table/event 异构、下游重建 DDL/序列/删除/slot 等边界

设计的关键是组合,而不是选一个“最好”的工具。

逻辑备份适合什么

pg_dump 在一个一致性快照中读取数据库,可以:

  • 选择 schema/table;
  • 以 custom/directory 格式并行恢复;
  • 跨部分 PostgreSQL 版本迁移;
  • 检视 DDL 与数据;
  • 从隔离 PITR 中导出被误删的对象。

但逻辑备份:

  • 不是数据目录文件副本;
  • 不含用于物理重放的控制信息;
  • 不能接到 WAL archive 上继续重放;
  • 通常不覆盖所有 cluster-wide 配置与运行状态;
  • 大库导出/导入和索引重建可能远慢于物理恢复;
  • 恢复顺序、owner、extension、privilege 与 external dependency 仍要处理。

PostgreSQL 官方文档明确说明 pg_dump/pg_dumpall 不能作为连续归档物理 恢复链的一部分。

物理备份适合什么

物理备份复制 cluster 数据文件,并依靠 WAL 把不一致的文件时间切片恢复到 一致点。它保留:

all databases in cluster
catalogs and physical relation state
transaction status
extension physical objects inside cluster
system identifier lineage

它适合:

  • 大型数据库快速整库恢复;
  • PITR;
  • 创建 standby/clone;
  • 保留完整引擎语义。

但通常要求同一大版本与兼容架构/页格式,并以 cluster 为恢复单位。物理 恢复后再做逻辑选择,是误删恢复常见组合。

PostgreSQL 18 原生增量不要和工具增量混为一谈

PostgreSQL 18 的 pg_basebackup --incremental 依赖:

earlier backup manifest
WAL summaries covering the required LSN interval
pg_combinebackup at restore preparation
all earlier dependent backups
normal WAL recovery requirements

这是一套 PostgreSQL 原生机制。pgBackRest 的 full/diff/incr、block incremental、bundle 与 repository metadata 是另一套实现和依赖图。

讨论“我们做增量备份”时,必须说清:

which tool
which version
dependency chain
restore assembly step
expiration owner

不能因为术语相同就互换 runbook。

快照要回答一致性

单卷快照可能在 block 层原子,但数据库语义还要问:

  • PostgreSQL 是否运行中?
  • 数据、WAL、tablespace 是否跨多个卷?
  • 各卷快照是否同一 consistency group?
  • 是否调用 pg_backup_start/stop 或工具协议?
  • 快照克隆后如何恢复与识别 timeline?
  • 快照控制面与主存储是否共同故障?
  • 是否定期挂载并启动验证?

“云盘快照成功”不是数据库一致性的证据。一个 crash-consistent snapshot 也许能通过 crash recovery,但不能自动提供任意 PITR 或跨卷一致性。

Replica 为什么不是 backup

流复制的目标是把当前历史低延迟复制到其他实例:

primary DELETE wrong rows
  -> WAL
      -> replica replays same DELETE

它可以:

  • 降低实例/主机故障的 RTO;
  • 在同步策略下改善某些故障的 RPO;
  • 分担读;
  • 作为备份来源,降低主库读取压力。

它不能单独:

  • 保留误操作之前的历史点;
  • 对抗影响全部成员的错误配置;
  • 对抗同一账户/区域/恶意管理员;
  • 证明长期 retention;
  • 替代隔离恢复。

延迟副本可增加逻辑错误反应窗口,但依然需要:

delay guarantee
pause/stop authority
monitoring that does not auto-heal away the delay
protection from direct reads/writes
independent backup for other failures

CDC 不是免费备份

事件流或 logical replication 能帮助重建某些数据,但要验证:

  • 初始快照从哪里来;
  • DDL 是否同步;
  • TRUNCATE/DELETE 是否保留足够语义;
  • sequence、large object 与 extension state;
  • slot 丢失或 lag;
  • exactly-once 是否只是消费端幂等;
  • 下游是否共享同一个逻辑错误。

它更适合作为另一条可重建历史,而不是未经证明的整库恢复替代品。

一份常见的组合

HA replicas
  handle selected availability failures

physical full/diff/incr + WAL
  handle cluster restore and PITR

logical export
  handle object-level portability and long-term selected records

audit/event history
  identify business boundary and support repair

off-site immutable copy
  handle control-domain and destructive repository loss

restore drills
  prove that all of the above compose

“多种机制”不等于重复浪费;前提是每种都有明确场景和 owner。

选择问题

面对一个需求,按顺序问:

1. loss is logical, physical, site-wide, or compliance?
2. recover whole cluster or selected facts?
3. need historical point or latest state?
4. how independent must the copy be?
5. what PostgreSQL/version compatibility exists?
6. what target precision can be observed?
7. how will business correctness be proven?
8. what is the controlled return-to-service path?

可能的答案:

selected rows from yesterday
  isolated physical PITR -> logical export -> reviewed merge

whole cluster after storage loss
  physical backup + WAL -> replacement infrastructure

cross-major migration
  logical dump/restore or logical replication, not physical replay

low-RTO host failure
  HA promotion, while independent backup remains separate

seven-year selected archive
  policy-specific logical record, not indefinite operational WAL by default

本节验收问题

在进入物理原理前,应能回答:

  1. 你的前三个恢复场景是什么?
  2. 每个场景的 authoritative boundary 来自哪里?
  3. RPO/RTO 的起止点和完成能力是什么?
  4. 哪些损失会被 replica 同步复制?
  5. 当前 retention 是否真的形成连续 PITR window?
  6. 配置、密钥、声明和应用依赖存放在哪里?
  7. 哪些结论只有实际 restore 才能证明?

如果答案仍是“每天有备份”,还没有完成设计。

小结

恢复体系从业务损失开始:

scenario defines truth
truth defines target
target defines mechanism
mechanism defines dependencies
dependencies define retention and placement
proof defines the drill

下一节进入物理链条:一份在线基础备份为什么能够是不一致的文件切片, WAL 又如何把它变成一个可选择时间点的 PostgreSQL 历史。


返回本章目录 · 下一节:物理备份与 WAL 连续性 · 查看全书目录 · 查看索引中心

21.2 物理备份与 WAL 连续性

物理 PITR 可以写成一条依赖式:

Recoverable(t)=BaseBackup(b)ContinuousWAL(bstart,t)ReachableTimeline(t)RuntimeCompatibility Recoverable(t)= BaseBackup(b) \land ContinuousWAL(b_{start},t) \land ReachableTimeline(t) \land RuntimeCompatibility

其中任何一项缺失,都不是“少恢复一点”,而可能是整条恢复链不可执行。

21.2.1 基础备份、检查点与一致性起点

为什么运行中的文件可以备份

在线复制数据目录时,文件并不处在同一个瞬间:

relation A copied at 10:00:01
relation B page modified at 10:00:02
relation B copied at 10:00:03
catalog copied at 10:00:04

单看文件副本,它可能不一致。PostgreSQL 的保证来自:

  1. 备份在一个已知 checkpoint 边界开始;
  2. 保存恢复所需的 backup metadata;
  3. 保存备份期间及之后所需的 WAL;
  4. 恢复时从 redo 起点重放到一致状态。

所以物理备份不是“恰好一致的一篮子文件”,而是:

recoverable file image + exact WAL obligations

checkpoint 在做什么

checkpoint 把一个恢复起点写入控制信息,并推动 dirty buffer 写盘。它不是 “把所有事务历史压成备份”,也不是备份完成点。

在线备份通常在 checkpoint 起点建立 redo requirement:

checkpoint redo LSN
  <= backup start LSN
      <= copied data interval
          <= backup stop LSN
              <= selected recovery target

恢复必须拥有从要求的 redo/WAL 起点到目标的连续记录。

pg_backup_start / pg_backup_stop

低层协议的核心顺序:

SELECT pg_backup_start(label => 'reviewed-label', fast => false);
-- keep this session alive while the file copy runs
SELECT * FROM pg_backup_stop(wait_for_archive => true);

关键语义:

  • 调用 pg_backup_start 的连接必须保持;
  • fast=true 请求立即 checkpoint,可能增加 I/O;
  • 文件复制期间数据库可以继续写;
  • pg_backup_stop 产生重要的 backup_label / tablespace_map 内容;
  • primary 上默认等待所需 WAL 归档;
  • 自制工具必须逐步验证,不能只在最后看 exit code。

正式系统通常使用 pg_basebackup 或 pgBackRest,而不是重新实现低层协议。 理解协议是为了判断工具证据。

backup_label 不是备注

backup_label 告诉恢复过程:

which backup session
start WAL file
start LSN
checkpoint location
start time

它和 tablespace mapping 是恢复输入,不是方便人看的注释。随意修改、漏拷或 把另一份备份的 label 混入,都会破坏血缘。

PostgreSQL 12 以后在线备份 label 通常作为 pg_backup_stop 输出交给备份工具, 而不是永久留在运行中 primary 的数据目录。不要照搬旧版“拷走 backup_label 文件”的手册。

pg_basebackup

pg_basebackup 通过复制协议取得运行中 cluster 的物理基础备份。常见能力:

plain or tar output
server/client compression
backup manifest
WAL streaming or fetch
rate limiting
tablespace mapping
standby signal/config generation
checkpoint mode
progress reporting

它需要具备 REPLICATION 权限或 superuser,并满足 pg_hba.confmax_wal_senders。它只备份整个 cluster,不能只备份一个 database。

示意:

pg_basebackup \
  --host=source \
  --pgdata=/safe/new/path \
  --format=plain \
  --wal-method=stream \
  --checkpoint=fast \
  --progress \
  --verbose

这不是本章 formal executor;正式实验使用 Pigsty 已交付的 pgBackRest。

--wal-method

概念上:

none
  backup output 不带 WAL;必须另有完整 archive

fetch
  在备份末尾从 source pg_wal 取所需 WAL;
  必须确保 WAL 未被回收

stream
  另开 replication connection 同步流式接收;
  需要额外 wal sender

即使 backup 包含让自身达到一致点的 WAL,若要恢复到更晚时间,仍需要后续 连续 archive。

backup manifest 与校验

manifest 记录文件、大小、checksum、WAL range 等,可用 pg_verifybackup 验证一份 pg_basebackup

pg_verifybackup /path/to/basebackup

它能证明文件与 manifest 一致、所需 WAL 结构满足其检查,却仍不能证明:

  • repository credential 在事故时可用;
  • PostgreSQL 能在目标环境启动;
  • target 之后/之前的业务边界正确;
  • extension/OS/配置依赖齐全;
  • 服务能够切换。

校验是 restore proof 的一层,不是替代。

data checksum 的边界

PostgreSQL data checksum 可以在读页时发现某些 page corruption。备份工具也 可对仓库对象做 checksum。两者都重要,但:

checksum matches
  means bytes match expected checksum

checksum matches
  does not mean business values are semantically correct

错误事务生成的页面 checksum 完全正确。

tablespace 与外部路径

物理备份必须覆盖 tablespace。恢复环境要检查:

  • pg_tblspc symlink;
  • target path 是否存在、为空、owner 正确;
  • 不同主机路径是否需要 remap;
  • mount 是否真的是预期设备;
  • tablespace 与数据目录是否落在同一快照 consistency group。

忽略 tablespace 常导致“主数据目录恢复成功,启动时才发现一半对象缺失”。

从 standby 备份

从 replica 取备份可降低 primary I/O,但带来不同限制:

  • 备份期间 standby 不能被 promote;
  • backup 与 primary timeline/WAL archive 仍要协调;
  • restartpoint 不等于 primary checkpoint;
  • 低活动时某些增量条件可能不成立;
  • replication lag 影响备份包含的历史;
  • 工具必须知道如何从正确节点取得并验证 WAL。

“从副本备份”是容量/可用性选择,不自动增加备份独立性。

PostgreSQL 18 原生增量

PostgreSQL 18 支持:

pg_basebackup --incremental=/path/to/prior/backup_manifest ...

服务端依据 pg_wal/summaries 中的 WAL summary 判断改变的 block。恢复前用 pg_combinebackup 把 full 与后续 incremental 合成为可启动的 synthetic full。

依赖链:

prior full
  -> prior/current manifests
      -> every required intermediate incremental
          -> WAL summaries at backup time
              -> pg_combinebackup
                  -> normal WAL recovery

PostgreSQL 不会替你管理哪些旧备份仍被新 incremental 依赖。过期策略删除 一个祖先,就可能让后代全部不可恢复。

版本兼容

物理备份通常用于同一 PostgreSQL 大版本。恢复环境还要匹配:

CPU architecture and page format expectations
PostgreSQL major
extension shared libraries
collation/locale providers
tablespace layout
configuration parameters needed during recovery

本章开发实验故意把 max_connections 从 source 的 500 降到 20, PostgreSQL 18 拒绝恢复:

recovery aborted because of insufficient parameter settings
max_connections = 20 is lower than on the primary, where it was 500

这不是性能调优问题,而是 recovery safety check。正式 runner 携带 source 的:

max_connections
max_worker_processes
max_wal_senders
max_prepared_transactions
max_locks_per_transaction

不能因为“验证实例很小”就任意降低 WAL 所要求的上限。

21.2.2 归档、timeline history 与恢复链

WAL 是有序的物理历史

WAL record 在数据页落盘前耐久化,支持 crash recovery、streaming replication 与 archive recovery。LSN 是逻辑日志位置,例如:

0/200002D0

通常每个 segment 16 MiB,但 segment size 可在 initdb 时选择。WAL 文件名 编码 timeline、log 与 segment,不应靠截字符串之外的自造规则做跨配置运算; 使用 PostgreSQL 函数:

SELECT pg_current_wal_lsn();
SELECT pg_walfile_name(pg_current_wal_lsn());
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), '0/20000000');
SELECT pg_switch_wal();

连续性要求

一份 base backup 要恢复到目标 tt,必须有:

[LSNrequired start,LSNt] [LSN_{required\ start}, LSN_t]

上的每一段 WAL,以及中途 timeline 切换需要的 history。

缺一段不会得到“少几秒数据”;恢复通常停在 gap:

base -> WAL A -> WAL B -> [missing C] -> WAL D -> target

即使 D 在仓库里,也不能跨过 C。

archive_modearchive_commandarchive_library

归档要求:

wal_level >= replica
archive_mode = on
archive_command or archive_library configured

shell archive command 中:

%p  source path relative to data directory
%f  WAL filename

最重要的返回值合同:

exit 0
  PostgreSQL believes WAL is durably archived and may recycle local file

nonzero
  PostgreSQL retries

错误地返回 0 是数据丢失风险;持续返回非零则形成 backlog 与磁盘风险。

归档必须幂等,但不能覆盖不同内容

crash 后 PostgreSQL 可能再次提交同一个 WAL 文件。正确 archive sink 应:

target absent
  atomically write and durably persist

target exists with identical content
  return success

target exists with different content
  return failure and alert

绝不能无条件覆盖。两个不同 system identifier 的 cluster 若误用同一 archive namespace,可能产生相同 WAL 文件名却内容不同。

stanza、repository path、cluster identity 和权限隔离是防碰撞设计的一部分。

timeline 为什么存在

一次恢复或 promote 会从旧历史分叉:

timeline 7: A -> B -> C -> D
                         \
timeline 8:              C' -> E -> F

新 timeline 保留父 timeline 与分叉 LSN 的 history。这样旧历史不会被 “回到过去再向前写”覆盖。

需要区分:

system identifier
  initdb 生成的物理 cluster 血缘标识

timeline
  同一物理血缘中的历史分支

checkpoint timeline
  最近 checkpoint 记录的 timeline

current WAL timeline
  当前写入 WAL filename 所在 timeline

恢复出来的物理副本应与 source system identifier 相同;完成 promotion 后 应进入一个新 timeline。本章 evidence 只记录关系:

system identifier relation = matches source
timeline 7 -> 8

不公开原始 identifier。

recovery 输入

现代 PostgreSQL 通过 signal file 进入恢复:

recovery.signal
  archive recovery / PITR

standby.signal
  standby mode, can continue waiting for WAL/stream

常见设置:

restore_command = 'fetch %f into %p'
recovery_target_time = '...+00'
recovery_target_name = '...'
recovery_target_xid = '...'
recovery_target_lsn = '...'
recovery_target_inclusive = on|off
recovery_target_timeline = 'latest'
recovery_target_action = 'pause'|'promote'|'shutdown'

同一轮只能选择一种 target kind。没有 target 时通常重放到可用 WAL 尾部。

target form

target 优势 风险/前提
time 人和事故日志易理解 时区、提交时间、精度
XID 对目标事务明确 wraparound/识别来源;inclusive
LSN 物理边界精确 业务语义难读
name 预先设置,教学/发布边界清晰 事故后无法补建
immediate 最快到一致点 不是最新业务状态
end of WAL 尽量最新 可能包含逻辑错误

命名点:

SELECT pg_create_restore_point('before_risky_change');

只在 primary 上有意义,并写入 WAL。它不是 backup;没有 base 与 archive, 名字本身什么也恢复不了。

inclusive / exclusive

恢复到 time、XID、LSN 时,要明确目标 record/transaction 是否包含。误删场景 常想恢复到 destructive transaction 之前

target transaction known
  recovery_target_xid = destructive xid
  recovery_target_inclusive = false

但必须在实验中验证具体 target type 的语义。不要只凭自然语言“到某时刻”。

target timeline

恢复链可能包含多次 promote。latest 让恢复沿 archive 中可达的最新 timeline 继续;current/具体数字会限制选择。

危险例子:

choose an old backup
target time belongs to a child timeline
force target_timeline=current

结果可能根本到不了目标,或走错历史。选择前应画出 lineage:

backup timeline
  -> history file
      -> parent fork LSN
          -> target timeline

恢复到只读与 promotion

hot standby 到达 consistent state 后可先接受只读连接,然后才到 target 并 执行 action。本章实际日志顺序:

consistent recovery state reached
database system is ready to accept read-only connections
recovery stopping at restore point
selected new timeline ID: 8
archive recovery complete
end-of-recovery checkpoint
database system is ready to accept connections

因此完成条件应为:

SELECT pg_is_in_recovery();          -- must be false
SHOW transaction_read_only;          -- must be off
BEGIN;
CREATE TEMP TABLE write_probe(x int);
INSERT INTO write_probe VALUES (1);
ROLLBACK;

只读 SELECT 成功不是 promotion proof。

配置文件不在 WAL 历史里

恢复到过去不会自动恢复:

postgresql.conf external changes
pg_hba.conf
pg_ident.conf
Patroni YAML/DCS policy
TLS certificates
Pigsty inventory
DNS/proxy config
application secrets

这些必须由 versioned declaration、配置备份与平台自动化重建。把配置也塞进 data directory 不是充分答案,因为事故可能同时损坏或需要在新环境重写。

21.2.3 复制槽、归档失败与 WAL 保留者

谁让 pg_wal 不能回收

WAL retention 的常见“持有人”:

checkpoint/recovery requirement
archive not yet successful
physical replication slot
logical replication slot
wal_keep_size
backup in progress
standby/restartpoint needs

它们并不是同一种保护,也不能相互替代。

archive backlog

当 archive command 失败:

completed segment remains needed
  -> retry
      -> pg_wal grows
          -> filesystem full
              -> PostgreSQL PANIC/offline

数据库继续运行一段时间不代表故障无害。官方文档指出,pg_wal 所在文件系统 填满会导致 PANIC;事务不会因此神奇地归档到远端。

监控:

SELECT archived_count,
       last_archived_wal,
       last_archived_time,
       failed_count,
       last_failed_wal,
       last_failed_time,
       stats_reset
FROM pg_stat_archiver;

解释要注意:

failed_count = 21
last successful archive is after last failure

可能表示历史上失败过、当前已经恢复。不能仅因累计 count > 0 就报“当前失败”。 反之,长时间没有新 WAL 的系统,last_archived_time 老也未必故障。结合:

  • current WAL segment;
  • archive queue/backlog;
  • WAL generation rate;
  • repository maximum;
  • disk free;
  • recent command errors。

archive lag 与数据风险

两个间隙:

operational backlog
  current WAL - last archived WAL

disaster data gap
  source lost时,last business truth - last independently durable WAL

可用 byte 估计:

SELECT pg_wal_lsn_diff(
         pg_current_wal_lsn(),
         'last known archived LSN'
       );

但 repository 通常按 segment 报告,边界内还有 partially filled segment。 生产 RPO 应用时间和业务 token 做补充。

为什么低流量也有 archive delay

archive command 通常对完成的 segment 工作。若写入很少:

important transaction commits
segment remains open
no archive object yet

选择:

  • archive_timeout 定期强制 switch;
  • 关键变更后 pg_switch_wal()
  • pgBackRest check 触发/验证;
  • streaming WAL 到独立系统。

代价是更多 segment object 和带宽。策略应由 RPO 与成本反推。

physical replication slot

physical slot 保护某个 consumer 尚未接收的 WAL:

SELECT slot_name,
       slot_type,
       active,
       restart_lsn,
       wal_status,
       safe_wal_size,
       inactive_since
FROM pg_replication_slots;

如果 consumer 永久消失而 slot 保留,WAL 可无限增长,除非 max_slot_wal_keep_size 设限。设限后 slot 也可能变为不可继续,需重建 replica。

槽保护 streaming consumer,不等于 archive 成功:

slot retained WAL on primary disk
  != independent disaster copy

logical slot 更容易被忽视

logical slot 还关联 catalog horizon:

  • 保留 WAL;
  • 可能保留 dead tuples/catalog rows;
  • consumer lag 影响磁盘与 vacuum;
  • failover/同步 slot 有版本与配置前提。

监控 slot 要看:

active
restart_lsn
confirmed_flush_lsn
wal_status
safe_wal_size
xmin/catalog_xmin
owner and consumer

没有 owner 的 slot 是容量事故候选。

wal_keep_size

wal_keep_size 是最近 WAL 的最低保留量,帮助无 slot standby 应对短暂断开。 它:

  • 不是硬上限;
  • 不保证某个 consumer 的准确位置;
  • 不写远端仓库;
  • 不提供长期 PITR;
  • 不替代 slot 或 archive。

多个保留者叠加

最终 pg_wal 需要保留到最老需求:

LSNrecycle frontier=min(LSNcheckpoint,LSNarchive,LSNslots,LSNbackup,LSNstandby) LSN_{recycle\ frontier} =\min( LSN_{checkpoint}, LSN_{archive}, LSN_{slots}, LSN_{backup}, LSN_{standby} )

概念上谁最老,谁控制回收边界。因此磁盘告警时不要只看“archive 正常”, 还要看所有 slot、backup lock 与 standby 状态。

本章沙箱事实

正式演练前后观察到:

archive_mode=on
archive command configured
repository stanza status=ok
one S3-compatible repository
target restore-point segment <= repository maximum segment
source timeline remains 7
restore timeline becomes 8

累计 archiver 历史包含早期失败,但最近成功晚于最后失败;本章没有把累计 失败数误判为当前中断。

故障处理顺序

archive backlog:

1. stop unsafe cleanup/expiration
2. measure pg_wal free space and growth rate
3. identify exact archive error and repository availability
4. preserve source WAL
5. restore archive path/credential/capacity
6. confirm backlog drains and repository maximum advances
7. prove a restore target, not just count successes
8. write incident and prevention

不要先:

delete pg_wal files
drop unknown slots
reset stanza
expire repository aggressively

这些动作可能把可恢复性问题变成不可恢复。

本节原生检查单

SHOW wal_level;
SHOW archive_mode;
SHOW archive_command;       -- do not copy secrets into tickets/evidence
SHOW archive_timeout;
SHOW wal_keep_size;
SHOW max_slot_wal_keep_size;

SELECT * FROM pg_stat_archiver;

SELECT slot_name, slot_type, active, restart_lsn,
       confirmed_flush_lsn, wal_status, safe_wal_size
FROM pg_replication_slots;

SELECT pg_current_wal_lsn(),
       pg_walfile_name(pg_current_wal_lsn());

SELECT system_identifier
FROM pg_control_system();    -- compare securely; do not publish raw id

SELECT timeline_id, redo_lsn, checkpoint_lsn
FROM pg_control_checkpoint();

在 replica 上不能调用 primary-only current WAL 函数;使用 replay/receive 位置并标注 observation point。

小结

物理 recoverability 的核心不是“有一个 tar 包”,而是:

one reviewed physical lineage
+ complete base image
+ exact backup metadata
+ continuous WAL
+ reachable timeline history
+ compatible recovery runtime

下一节把这些对象放进仓库依赖图:full、diff、incr 应如何保留,怎样防止 正确的自动过期删除仍被后代依赖的祖先,以及为什么加密不等于不可变。


上一节:从恢复场景设计备份 · 返回本章目录 · 下一节:备份仓库与保留策略 · 查看全书目录 · 查看索引中心

21.3 备份仓库与保留策略

仓库不是“放备份的目录”,而是一张有依赖、权限、密钥、过期与故障域的 历史图。一个对象存在,不代表它独立可恢复;一个对象过期,也可能让一串 后代失去意义。

21.3.1 全量、差异、增量与过期

三种备份的依赖

以 pgBackRest 术语:

full
  不依赖同仓库的更早 backup set

diff
  依赖最近 full,保存相对 full 的变化

incr
  依赖最近一次可作为父级的 backup,保存相对父级的变化

示意:

F1
├── D1
│   ├── I1
│   └── I2
├── I3
└── D2
    └── I4

F2
└── I5

恢复 I4 需要 F1 + D2 + I4;恢复 I2 需要对应链。工具 catalog 负责 依赖,但 retention policy 必须与这张图一致。

“增量”有多个层面

不要只看备份类型标签:

logical incremental
  应用/CDC 按变化导出

file-level incremental
  只复制 mtime/size/checksum 判定变化的文件

block incremental
  只保存改变的数据块

PostgreSQL 18 native incremental
  WAL summaries + manifests + pg_combinebackup

pgBackRest incr
  pgBackRest repository dependency and restore semantics

同一个 incr 单词不保证恢复步骤相同。

full 的价值

full 优点:

  • 依赖图短;
  • 恢复选择更直接;
  • 祖先损坏影响范围小;
  • 容易做离线复制或长期固定点。

代价:

  • 读取和传输量大;
  • checkpoint/I/O 影响;
  • repository 增长;
  • 大库窗口可能长。

但开启 block incremental、bundle 或 dedup 后,“full backup label”不一定 意味着仓库再次保存一份完整未去重字节。要区分:

logical database size
backup delta read
repository delta written
repository total referenced size

本章 formal full:

logical size             36,121,841 bytes
repository delta          4,539,288 bytes

这反映当前 pgBackRest block/bundle/compression 配置,不代表任何生产数据的 压缩率。

differential 的恢复深度

diff 只依赖 full,所以常用于:

weekly full
daily diff
intra-day incr

恢复最近日点可能只需 full + diff,而不是一长串 daily incremental。代价是 diff 随 full 之后的累计变化变大。

incremental 的恢复深度

incr 降低单次备份量,但:

  • chain 更深;
  • 任一依赖损坏影响后代;
  • restore 需要更多 metadata/object;
  • catalog 与过期规则更关键;
  • 小对象延迟可能抵消节省;
  • 高频变化数据未必节省很多。

优化 backup window 不能以恢复路径不可测为代价。

backup cadence 与 WAL replay

备份越旧,恢复到“现在”通常需要重放更多 WAL:

TrecoveryTretrieve backup+Tassemble+Treplay WAL+Tcheckpoint T_{recovery} \approx T_{retrieve\ backup} +T_{assemble} +T_{replay\ WAL} +T_{checkpoint}

因此:

  • 更频繁 base/diff 可缩短 replay;
  • 更频繁备份增加 source/repository 工作;
  • WAL 生成率、CPU、storage latency 影响 replay;
  • parallel restore 不等于 parallel WAL replay 无限扩展。

通过生产规模演练测量,而不是从 backup duration 推算 restore duration。

count 与 time retention

pgBackRest repo1-retention-full-type 决定 repo1-retention-full 的解释:

count
  保留多少 full backup set

time
  以天为阈值保留 full

按 count=2 时,新 backup 要先成功,之后才可能 expire 最旧,因此瞬时可见 三份 full。按 time=20 时,需要存在至少一份达到对应年龄的 full 才形成过期 条件。不要把配置数字直接写成没有验证的“覆盖天数”。

differential retention

repo1-retention-diff 按数量控制 diff。依赖被过期时,相应 incremental 也要一起处理。

政策样例:

full: weekly, retain by time 35 days
diff: daily, retain 7
incr: every 6 hours
WAL: follow oldest retained recovery anchor
month-end logical archive: separate 13 months

这只是示例。真正参数要由场景、数据变化、仓库容量和 restore test 得出。

WAL expiration

PITR 需要连续 WAL。pgBackRest 可随 backup expiration 删除不再被保留 backup 所需的 archive。更激进的 archive retention 能省空间,却可能缩短 PITR window。

原则:

expire backup dependency graph first
derive which WAL is no longer useful
dry-run aggressive archive expiration
never delete WAL merely because it is "old"

一段 WAL 只有相对于某个可用 base 与目标才“有用”。孤立 WAL 不能独立恢复, 但错误删除 bridge segment 会破坏整个窗口。

自动过期的安全条件

在启用 expire 前,至少验证:

  1. 目标 retention 用例已写成例子;
  2. catalog 能画出 full/diff/incr dependency;
  3. newest backup 成功后才触发过期;
  4. repository 容量允许一次失败重试与过渡峰值;
  5. legal hold 不会被普通规则删除;
  6. off-site/immutable copy 的过期独立受控;
  7. 定期恢复最旧目标;
  8. dry-run 输出有人审阅;
  9. 时钟、时区与对象 lifecycle policy 一致;
  10. 删除权限与写入/恢复权限分离。

对象存储 lifecycle 的隐形删除

即使 pgBackRest retention 正确,bucket lifecycle 也可能:

  • 提前删除 object/version;
  • 转冷存储导致 RTO 激增;
  • 删除 multipart/metadata;
  • 与 legal hold 冲突;
  • 在 repository catalog 不知情时改变可用性。

基础设施 lifecycle 必须成为同一份恢复设计的受控输入。

最旧目标演练

只恢复 latest backup 不能验证 retention:

choose oldest required target
  -> identify required backup chain
      -> retrieve cold/off-site objects
          -> obtain historical key
              -> replay full WAL span
                  -> validate business marker

生产演练轮换:

latest target
oldest target
random time target
target across timeline switch
target just before destructive transaction
target whose objects are in cold tier

21.3.2 校验、加密、不可变与异地副本

四个不同属性

integrity
  bytes 未损坏/未被替换

confidentiality
  未授权者不能读取

immutability
  在保留窗口内,包括高权限主体也不能轻易删除/改写

availability
  事故时对象、密钥、网络和权限仍可用

checksum 提供部分 integrity;加密提供 confidentiality;它们都不自动提供 immutability 或 availability。

校验的层次

层次 例子 能发现 不能发现
transport TLS/object ETag 传输破坏的一部分 业务错误
repository pgBackRest checksum object/file mismatch key 丢失
PostgreSQL page data checksum page corruption 正确写入的错值
backup manifest pg_verifybackup 文件/WAL manifest mismatch target 选择错误
restore start recovery log 链条可重放 业务不变量
business token/对账 目标事实差异 所有未来依赖

每层都必要,但结论边界不同。

pgbackrest check

check 用于验证 stanza 配置与 archive path。典型:

sudo -iu postgres \
  pgbackrest --stanza=pg-test --log-level-console=info check

注意 pgBackRest 2.59.0 的 check 不接受 --repo=1;本章第一次 formal 尝试正因把 backup/info 的 repo selector 机械复制给 check,以 exit 31 在恢复前安全失败。这个失败保留了两个教学点:

same tool != every command accepts same option
successful check != successful restore

正式成功运行修正命令后继续。

加密在哪里

可能的层次:

client-side/repository encryption by backup tool
object storage server-side encryption
disk/volume encryption
transport TLS
application/column encryption

每层保护不同攻击面。pgBackRest repository cipher 需要 cipher_pass;如果 配置、备份与密码一起丢失,加密会非常成功地阻止所有人恢复。

密钥生命周期

备份 retention 往往比当前应用 key 生命周期长。密钥设计要回答:

  • 谁能加密、谁能解密、谁能删除?
  • rotation 后旧备份如何恢复?
  • key version 与 backup label 如何关联?
  • KMS/secret store 在区域事故中是否独立?
  • break-glass 如何审批、审计和定期测试?
  • 员工离职、账户冻结和组织恢复如何处理?
  • legal deletion 是否通过 key destruction 实现,证据是什么?

不要把 cipher pass 复制到 evidence、工单或书中。本章 evidence 只记录 cipher=aes-256-cbc,不导出密码。

不可变不是只读 ACL

普通权限:

backup writer can put
restore reader can get
operator can delete

若同一 credential 能写、覆盖、expire 与删除,攻击者拿到它就能破坏历史。

更强设计可能包括:

  • object lock / WORM retention;
  • versioning;
  • 独立账户/项目;
  • MFA-delete 或审批;
  • writer 无 delete;
  • lifecycle role 与 restore role 分离;
  • retention policy 受治理;
  • audit log 写入另一安全域;
  • 定期从不可变副本恢复。

“S3-compatible”不等于实现并启用了这些特性。

不可变也会制造治理问题

设置错误的长期 object lock 会:

  • 无法删除敏感数据;
  • 产生不可控成本;
  • 阻塞环境清理;
  • 与法律删除义务冲突。

因此需要:

retention class
legal hold process
minimum/maximum lock
authorized bypass
evidence and audit
test bucket before production

不可变是政策与控制面组合,不是一个布尔开关。

异地副本的独立性

“异地”至少检查:

physical region
cloud/account/project
identity provider
KMS/key
network/control plane
operator role
automation blast radius
object lifecycle
billing/organization dependency

两 bucket 位于不同 region,但由同一高权限脚本执行 recursive delete,仍有 共同控制域。

3-2-1 只是启发式

常见经验:

3 copies
2 media/system types
1 off-site

它提醒独立性,但不能替代场景合同。三份都被同一 credential 删除,数量没有 意义;一份真正不可变、离站且可恢复的副本,可能比十份同域复制更有价值。

repository namespace

避免不同 cluster 冲突:

repository
  -> stanza
      -> database/system-id lineage
          -> archive id
              -> timeline/WAL

不要把另一个 initdb 出来的 cluster 伪装成旧 cluster 继续向同一 archive namespace 写入。pgBackRest stanza metadata 与 system ID 检查是保护层; 权限与路径隔离仍要做。

沙箱边界

本章仓库:

one local MinIO
S3-compatible
AES-256-CBC repository cipher
shared laptop/hypervisor context
object lock not validated
cross-region copy not validated
independent credential domain not validated

所以正式结论带:

EX21-REPOSITORY-NOT-IMMUTABLE

能够真实恢复,并不抹掉仓库共同故障。

21.3.3 容量预算、失败告警与责任人

容量不是数据库大小乘份数

粗略预算:

Capacity=Bfull+Bdiff+Bincr+WALwindow+Metadata+Versions+SafetyMargin Capacity = B_{full} +\sum B_{diff} +\sum B_{incr} +WAL_{window} +Metadata +Versions +SafetyMargin

还受:

database growth
change rate and full-page images
compression/dedup
block incremental
bundle
index churn
vacuum/rewrite
WAL generated by bulk load/DDL
object versioning
failed/in-progress backup
multipart residue
cold tier overhead

用历史指标和压力场景建模,不只用当前 pg_database_size

两个增长率

data growth rate
  determines future full/restore size

WAL generation rate
  determines archive bandwidth, RPO exposure and replay work

一个 1 TB 数据库每天只改 1%,与每天 rewrite 500 GB 的数据库,备份策略不同。

原生观测:

SELECT now(),
       pg_current_wal_lsn(),
       pg_walfile_name(pg_current_wal_lsn());

SELECT archived_count, failed_count,
       last_archived_wal, last_archived_time,
       last_failed_wal, last_failed_time
FROM pg_stat_archiver;

配合定时 LSN sample 估算 WAL rate。

带宽预算

需要同时考虑:

source read throughput
source network egress
repository write/read throughput
archive sustained throughput
restore download throughput
WAL replay CPU/storage
shared production contention

backup 能在 4 小时窗口内完成,不代表事故时 restore 也能在 4 小时内完成: 方向、并发、cold retrieval 和 target infrastructure 都不同。

容量水位

至少建立:

repository used / total
growth per day/week
forecast days to full
oldest/newest backup
oldest/newest recoverable target
WAL archive backlog
pg_wal filesystem free
backup duration and bytes
restore duration by representative size
object lifecycle transitions

阈值应给行动时间:

warning when forecast leaves enough time to provision
critical before next backup/archive can exhaust capacity

固定 80%/90% 可能对高速增长系统太晚。

备份失败不是一个布尔告警

分类:

scheduler did not start
backup started but failed
backup completed but required WAL missing
repository inaccessible
archive delayed
checksum/integrity failure
retention did not expire
retention expired too much
credential/key near expiry
restore drill failed
business validation failed

每种 owner 与紧急程度不同。

freshness SLI

示意:

age_of_latest_successful_base
age_of_last_independently_archived_wal
duration_of_archive_backlog
days_since_last_successful_restore
age_of_oldest_proven_restore_target

比“backup job success rate 99%”更接近 recoverability。

一次失败后先保护什么

当 backup job 失败:

  1. 确认 archive 仍连续;
  2. 确认 pg_wal 与 repository 容量;
  3. 保存错误上下文与 catalog;
  4. 判断现有 restore window 是否仍满足;
  5. 修复依赖后重试;
  6. 不要先 expire “腾空间”;
  7. 若 RPO 已越线,升级 incident。

当 archive 失败:

  1. 以磁盘耗尽预测为首要风险;
  2. 保护未归档 WAL;
  3. 恢复 repository path;
  4. 确认最大 WAL 前进;
  5. 安排隔离 restore 验证链条。

RACI

一份实际 owner map:

能力 Accountable Responsible Consulted
业务 RPO/RTO 业务 owner SRE/平台 DBA、安全
PostgreSQL backup config 平台 owner DBA/平台 SRE
repository capacity 存储/平台 owner 平台 DBA
credential/key 安全 owner 安全/平台 DBA
retention/legal hold 数据治理 平台/法务 业务
restore runbook 平台 owner DBA/SRE 应用
business validation 业务 owner 应用团队 DBA
production cutover incident/change owner SRE/平台 全方

如果 backup 告警只有“DBA 群”负责,区域、密钥、应用验证很可能无人负责。

每日、每周、每季

示意节奏:

continuous
  WAL/archive/capacity alert

daily
  latest backup, duration, bytes, catalog status

weekly
  dependency/retention review, sampled checksum

monthly
  automated isolated latest/selected restore

quarterly
  representative business validation and oldest target

annually or major change
  regional/break-glass/cutover exercise

频率按 tier 调整,但“从不恢复,只看 job”不属于任何成熟 tier。

可审计政策模板

service: orders
tier: critical
scenarios:
  logical_error:
    rpo: named/audited transaction boundary
    rto_historical_query: 60m
  region_loss:
    rpo: 5m
    rto_read_write: 4h
backup:
  implementation: pgBackRest
  full: weekly
  diff: daily
  incr: 6h
  wal_archive: continuous
repository:
  primary: object-store-a
  immutable_copy: object-store-b
  failure_domain: separate account and region
  encryption_key: independent-dr-key
retention:
  operational_pitr: 35d
  monthly_logical: 13m
validation:
  latest_restore: monthly
  oldest_target: quarterly
  regional_cutover: annual
owners:
  platform: team-db
  business_validation: team-orders
  key: team-security

每个字段都应能找到 evidence,而不只是配置愿望。

小结

仓库的工程对象是:

backup dependency graph
+ continuous WAL window
+ integrity
+ confidentiality
+ immutability
+ independent availability
+ capacity
+ ownership

下一节进入恢复动作本身:怎样从候选 backup 中选择真正能到达 target 的一份, 如何在启动前验证 lineage/WAL,以及为什么 PostgreSQL “一致”仍不足以交付。


上一节:物理备份与 WAL 连续性 · 返回本章目录 · 下一节:恢复流程与验证 · 查看全书目录 · 查看索引中心

21.4 恢复流程与验证

恢复是一场受控的数据分叉:

select historical lineage
  -> copy base
      -> replay WAL
          -> stop at boundary
              -> possibly promote onto new timeline
                  -> decide what may consume this history

每一步都可能生成一个能启动、却不应交付的实例。因此流程必须先写停止条件, 再写命令。

21.4.1 选择备份集、目标时间和恢复位置

先冻结事故事实

开始恢复前,保留:

incident start and detection time in UTC
suspected destructive transaction/request
source cluster identity and current timeline
current repository catalog
archive maximum and gaps
relevant application/audit logs
last known correct business marker
authority and requested outcome

不要一边猜 target,一边让日志、WAL、对象 lifecycle 和源状态继续变化而没有 快照证据。

target 不是一句“十分钟前”

把 target 写成结构化决策:

kind: name
value: before_release_20260729
inclusive: not-applicable
timezone: UTC
source_evidence:
  - pg_create_restore_point result
  - release change record
expected:
  present:
    - schema version 41
    - business token A
  absent:
    - migration transaction B
approved_by:
  - incident commander
  - data owner

时间 target 应包含 offset:

2026-07-29 20:10:44.000+00

避免依赖 session/local timezone。

target 类型选择

known pre-created boundary
  name

known destructive transaction
  xid + inclusive=false, after verifying identity

known physical log point
  lsn

only trusted event time
  time + explicit timezone/inclusive

need earliest consistent database
  immediate

need latest independently archived state
  end of archive, with RPO evidence

XID 会 wrap;日志中同一个数字必须与正确 cluster/epoch/时间关联。LSN 精确但 不直接表达业务。time 易懂但可能有 clock/commit ambiguity。

选择能到达 target 的 backup

候选 base 必须:

finish before or otherwise be valid for target
belong to same physical lineage
have all dependent backup objects
have continuous required WAL to target
have reachable timeline history
have usable encryption key
fit target runtime/version

对 time target,pgBackRest 可以选择一份结束时间早于 target 的可用 backup。 对 name/XID,工具不能总是自动知道 target 落在哪份历史中;pgBackRest User Guide 明确提示 name/XID target 无法自动选择 backup。正式 run 因此用 --set=<exact-label> 固定刚创建的 full。

“最新备份”可能太新

误删发生在 10:00:

backup A ends 09:00
backup B starts 11:00

选择 B 无法回到 10:00 之前,因为其文件状态已经包含误删;需要 A 加 WAL。 “latest”不是无条件正确。

backup set 与 recovery target 分离

--set
  chooses base/dependency set

--type / --target
  chooses where WAL replay stops

一个是起点,一个是终点。混淆会导致:

  • 起点晚于目标;
  • 自动选择错误;
  • 恢复到 WAL 尾部而不是事故前;
  • 以为 backup label 就是业务时刻。

恢复位置

优先顺序:

new host/failure domain
  best isolation

same host, new path/process/socket/port
  useful lab compromise with explicit exception

overwrite original data directory
  destructive replacement only, separate authority

日常验证和误删取数应选择新环境。覆盖原集群会:

  • 消灭当前证据;
  • 影响服务;
  • 把 target 错误变成二次事故;
  • 与 Patroni/DCS 冲突;
  • 让失败回退更难。

空间预算

至少:

SpaceRestoredData+WALWorkingSet+Temp+Logs+SafetyMargin Space \ge RestoredData +WALWorkingSet +Temp +Logs +SafetyMargin

若保留源数据副本,可能需要两倍以上。tablespace、sparse file、reflink、 object cache 与 filesystem reserved blocks 都要计入。

恢复前:

df -h
df -i
findmnt
lsblk

确认 path 真正位于预期设备,而不是 root filesystem 上一个空目录。

权限与隔离

恢复目录:

owner postgres
mode 0700
not symlink
new and empty
not /pg/data
not a Patroni member path

接入:

listen_addresses=''
private Unix socket mode 0700
custom restrictive pg_hba
distinct port
no service registration
no DNS/VIP/proxy route

数据副本本身可能含生产敏感数据;“测试恢复”不能降低访问控制。

archive push 隔离

PITR promote 会产生新 timeline。验证实例若继续向 source repository archive,可能污染共享历史。pgBackRest 官方指南建议,对会 promote 但不成为 新 primary 的 reporting/testing cluster 使用:

--archive-mode=off

正式实验既在 restore option 中设置,又查询有效:

SHOW archive_mode;  -- off

仅修改 archive_command='' 会让 WAL 积在本地,并不等同于清晰的验证实例 policy。

authority

恢复至少分三种授权:

read-only catalog/capture
  no database or repository mutation

isolated restore drill
  synthetic source writes + new backup/path/postmaster

production replacement/cutover
  stops/overwrites/reroutes real service

本章 formal 只有第二种沙箱授权。不能拿它的确认 token 去生产。

21.4.2 启动前核对时间线与 WAL 完整性

preflight 清单

postgres 启动前确认:

exact source/stanza/system lineage
exact backup label and type
backup status and dependency completeness
backup start/stop WAL
target kind/value/inclusive
target WAL covered by archive
timeline history available
restore_command configured
recovery.signal present
standby.signal absent unless desired
tablespace path and ownership
PostgreSQL major/binaries/extensions
recovery-critical max settings
network/HBA/archive isolation
log destination writable

把它写入 evidence,不要只在人的终端滚过。

repository catalog

pgBackRest:

sudo -iu postgres \
  pgbackrest --stanza=pg-test --repo=1 --output=json info

检查:

stanza status code
database/repository id
PostgreSQL version
backup label/type/error
start/stop timestamp
start/stop archive
logical/repository bytes
archive min/max by archive id
locks

不要把输出中的 repository secret 或 raw system identifier 复制进公开 evidence。

WAL 文件名比较的限制

同一 archive ID、固定 segment size 和 timeline 语境中,固定长度 WAL filename 可帮助判断 target segment 是否不晚于 max。更完整的验证需要:

  • 确认中间没有 gap;
  • 确认 history file;
  • 让 restore path 实际逐段读取;
  • 对 object checksum;
  • 记录 archive ID 与 lineage。

只有 max >= target 不能证明中间全在。正式实验同时跑 pgbackrest check 和真实 restore;真实 replay 是最终 gap detector。

restore_command

恢复过程请求 WAL:

restore_command = 'pgbackrest ... archive-get %f "%p"'

返回合同与 archive push 类似:

0
  requested file delivered

nonzero
  file unavailable; PostgreSQL may try pg_wal/stream/next source as applicable

不要让 command 把其他 cluster 同名 WAL 放入 %p

signal file 与 generated settings

pgBackRest restore 会生成 postgresql.auto.conf recovery settings,并创建 recovery.signal。检查:

test -f "$PGDATA/recovery.signal"
test ! -f "$PGDATA/standby.signal"   # for this promoted PITR design
sed -n '1,200p' "$PGDATA/postgresql.auto.conf"

输出可能包含路径或凭据参数,证据应做 secret-safe 投影,而不是无脑上传。

同一 system identifier

物理 backup 和 source 应同一 lineage:

SELECT system_identifier
FROM pg_control_system();

比较关系:

source == restored

如果不同:

  • backup 来自另一 cluster;
  • stanza/path 混淆;
  • evidence 目标错误。

不要“修” system identifier 让它相等;停止并调查。

timeline 必须可达

记:

backup timeline = Tb
target timeline = Tt

要求存在从 TbT_bTtT_t 的合法 history path。PITR promote 后的新 timeline TnT_n 应满足:

Tn>Tt T_n > T_t

本章:

source/target timeline 7
restored promoted timeline 8

如果 promotion 后仍报告 7,要确认观察的是 current WAL timeline、checkpoint timeline 还是 recovery 尚未完成。

checkpoint timeline 会滞后

pg_control_checkpoint() 报最近 checkpoint。一个 replica 可能已在接收/重放 更新 timeline,但其 checkpoint control info 仍较旧。第 20 章已遇到这种 情况。

因此:

primary current WAL filename timeline
Patroni TL
replica receive/replay evidence
checkpoint timeline

要标注 observation semantics,不能强行要求所有数字在任意瞬间相等。

recovery-critical 参数

WAL 记录某些 source 参数。恢复实例不能把它们设得更低。重点:

max_connections
max_worker_processes
max_wal_senders
max_prepared_transactions
max_locks_per_transaction

正式脚本先查询 source,再在 isolated start 中显式携带。第一次开发启动因 max_connections=20 < 500 被 PostgreSQL 正确拒绝,未修改源集群。

这类失败的 SOP:

stop
read PostgreSQL FATAL/DETAIL/HINT
compare with captured source settings
correct isolated runtime
start as a new reviewed attempt
never weaken source or edit WAL/control data

extension 与 preload

物理 backup catalog 可能引用 extension。恢复环境若缺 .so

  • startup 可能因 shared_preload_libraries 失败;
  • 查询对象可能缺函数/type;
  • background worker 可能连接外部系统。

验证实例需要决定:

install exact extension packages
or explicitly disable reviewed preload components

本章 synthetic fixture 不依赖 preload,因此 isolated instance 设:

shared_preload_libraries = ''

这不能证明生产 extension 全部可用;属于本章小数据边界。

不让恢复实例自动加入平台

同一 cluster_name 不是 Patroni membership,但为避免混淆,本章使用:

cluster_name = 'pg36-ch21-restore'

同时:

no Patroni process
no DCS registration
no HAProxy service
no exporter discovery
no archive push

如果目标是正式替换 cluster,加入平台是另一个经过审查的阶段。

日志是证据

关注顺序:

starting backup recovery
restored history/WAL from archive
starting point-in-time recovery to ...
redo starts
consistent recovery state reached
ready to accept read-only connections
recovery stopping at target
selected new timeline
archive recovery complete
end-of-recovery checkpoint
ready to accept connections

错误:

requested WAL not found
recovery ended before configured target
invalid checkpoint record
insufficient parameter settings
could not load library
tablespace path failure
permission denied

不要只保存最后一行 database system is ready

两阶段 readiness

正式 runner:

  1. pg_ctl -w 等到第一条连接;
  2. 立即记录 pg_is_in_recovery()transaction_read_only
  3. 继续轮询到 recovery=false;
  4. 查询 effective isolation;
  5. 做 rollback-only write;
  6. 才声明 promotion complete。

观测:

first connection:
  in_recovery=true
  transaction_read_only=true

355.896 ms later:
  in_recovery=false
  transaction_read_only=false

这不是理论角落,而是本章真实运行结果。

21.4.3 数据库一致不等于业务数据正确

引擎一致性

PostgreSQL recovery complete 能说明:

  • WAL record 可重放到一致点;
  • control/catalog/transaction state 满足引擎;
  • 数据库可按当前模式接受连接;
  • promotion 时建立了新 timeline。

不能说明:

  • target 是事故前正确边界;
  • 所有正确交易都在;
  • 错误交易都不在;
  • 外部系统一致;
  • 应用 schema/code 匹配;
  • 秘密与权限适合交付;
  • 报表金额正确。

业务不变量

为每个服务维护可执行 invariant:

orders
  no orphan order lines
  order total = sum(lines)
  accepted payment token unique
  known checkpoint token present
  destructive release token absent

ledger
  debits = credits per journal
  immutable entries not missing
  sequence/event continuity

tenant SaaS
  no row crosses tenant boundary
  tenant counts match reference
  row-level security policies installed

SQL 示例:

SELECT order_id
FROM order_line l
LEFT JOIN orders o USING (order_id)
WHERE o.order_id IS NULL
LIMIT 1;

SELECT journal_id
FROM ledger_entry
GROUP BY journal_id
HAVING sum(debit) <> sum(credit)
LIMIT 1;

零行是某项证据,不是万能健康。

正向与反向标记

一个强 boundary test 同时要求:

before target marker present
at/allowed target marker present
after target marker absent

本章:

base     present
keep     present
discard  absent

只验证 keep 存在,可能实际恢复到了 WAL 尾部,discard 也在;反向断言能 发现 overshoot。

token 要不可混淆

正式 marker:

run_id + stage
unique token
database commit timestamp
primary key(run_id, stage)
unique(token)

恢复后按 exact run ID 查询,避免把旧演练行当成本轮成功。

验证 schema 与语义

除了行:

  • schema version;
  • extension version;
  • constraint/index validity;
  • owner/privilege;
  • RLS policy;
  • sequence;
  • collation;
  • function/trigger;
  • materialized view freshness;
  • partition attachment;
  • large object。

有些对象在物理 backup 中存在,但应用新版本可能期待更晚 schema。历史 database 与当前 application code 不能直接组合。

外部系统

数据库可能回到 10:00,消息队列、对象存储、支付平台仍在 10:30:

database says payment pending
payment provider says captured

database row absent
object file already created

database event offset rewound
consumer has processed later events

恢复计划必须决定:

  • 外部系统也回退?
  • 数据库追赶?
  • 做补偿/对账?
  • 暂停哪些写入?
  • replay event 是否幂等?

PITR 不能跨系统自动保持分布式一致。

误删取数的安全合并

推荐路径:

isolated historical restore
  -> validate historical target
      -> export only affected data
          -> normalize identifiers/format
              -> compare against current
                  -> reviewed repair transaction
                      -> audit and reconcile

不要把历史 cluster 暴露给普通应用写入;它的 sequence、outbox 与定时任务 可能重新发出旧动作。

可在隔离实例中:

disable external network
disable schedulers/background workers
use read-only role for analysts
export with COPY/pg_dump
record checksum/count

本章为了验证 promotion 做一次 temporary table 回滚写,不产生持久业务写。

service cutover 是另一个 gate

在真正 replacement 场景:

freeze or fence old writer
validate candidate
configure identity/secrets
register monitoring
update routing
drain/reconnect clients
run smoke/invariants
reconcile unknown outcomes
monitor backlog
retain old evidence

本章不执行 routing change,所以:

database/data proof = accepted with exceptions
service cutover = not run

第 22 章继续连接与服务合同。

验证矩阵

维度 检查 失败动作
lineage same system ID relation stop
timeline new child after promote stop
target expected present/absent stop/reselect
engine recovery=false, writable inspect logs
isolation no TCP, archive off stop immediately
schema version/extensions/constraints repair environment
business domain invariants reject candidate
external reconciliation keep isolated
source original cluster unchanged incident
shutdown private postmaster stopped force safe stop

证据包

至少包含 secret-safe:

authority and exact target
source preflight
repository/catalog projection
backup label and WAL range
target decision
restore options and path
recovery phase timestamps
effective settings
lineage relation and timeline
business queries/results
source postflight
shutdown state
exceptions
counterexamples
review hashes

不要包含:

repository key
cipher pass
database password
raw secret inventory
unnecessary raw system identifier

失败也是证据

本章有两次开发/正式前失败:

development:
  max_connections lower than source
  PostgreSQL rejects recovery start

first formal attempt:
  pgBackRest check given unsupported --repo=1
  pgBackRest exits 31 before restore

两次都没有覆盖 source,也没有留下运行中的 isolated postmaster。我们修复 runbook,而不是删除失败痕迹后假装一次成功。

成熟流程会统计:

failed drills
failure phase
time to diagnose
unsafe side effects
runbook correction
repeat proof

完成定义

restore command exit 0                      insufficient
PostgreSQL accepts read-only                insufficient
PostgreSQL promoted                         engine milestone
business boundary passes                    data milestone
source remains healthy                      safety milestone
isolated instance stopped                   containment milestone
service controlled and reconciled           service milestone

本章正式到 containment milestone,服务 cutover 保持未运行。

小结

恢复的核心动作不是“启动一个旧数据库”,而是连续回答:

which history?
which target?
which evidence?
which isolation?
which completion state?
which business truth?
which authority to expose it?

下一节把流程映射到 Pigsty 和 pgBackRest:仓库、调度、凭据、观察入口以及 为什么平台自动化应承载合同,而不是掩盖 PostgreSQL 原生证据。


上一节:备份仓库与保留策略 · 返回本章目录 · 下一节:用 pgBackRest 交付备份策略 · 查看全书目录 · 查看索引中心

21.5 用 pgBackRest 交付备份策略

Pigsty 负责把 PostgreSQL、pgBackRest、仓库、归档、调度、日志和监控组合成 可交付基线;pgBackRest 负责 backup/archive/restore 机制;PostgreSQL 仍然 定义 WAL、recovery、timeline 与业务数据语义。

三层不要混淆:

PostgreSQL
  physical history and recovery semantics

pgBackRest
  repository, backup sets, archive transport, restore orchestration

Pigsty
  declarative delivery, scheduling, service integration and observability

21.5.1 仓库、策略、调度与凭据

从 Pigsty 声明开始

当前 Pigsty 的核心入口包括:

pgbackrest_enabled: true
pgbackrest_method: minio       # or local/custom
pgbackrest_repo:
  minio:
    type: s3
    s3_endpoint: sss.pigsty
    s3_region: us-east-1
    s3_bucket: pgsql
    path: /pgbackrest
    storage_port: 9000
    block: y
    bundle: y
    bundle_limit: 20MiB
    bundle_size: 128MiB
    cipher_type: aes-256-cbc
    retention_full_type: time
    retention_full: 14

这里只展示非 secret 结构。s3_key_secretcipher_pass 必须来自受控 secret source,不能提交到公开仓库、截图或 evidence。

参数随 Pigsty 版本演进。使用时以目标 tag 的参数参考为准,不能拿当前网页 替历史安装保证。

local 与 MinIO

Pigsty 文档给出的常见方式:

local
  local POSIX repository, default path /pg/backup

minio
  S3-compatible repository, optional MinIO service

custom
  pgBackRest supports other repository backends/config

职责差异:

方式 优势 必须显式接受的风险
local same host/storage 简单、低延迟 主机/磁盘共同故障
shared local/NFS 集中 mount/锁/网络/同域
MinIO/S3-compatible object API、独立部署可能性 endpoint、credential、object semantics
cloud object store 跨域与 durability 选择 账户/KMS/费用/egress/lifecycle

pgbackrest_method=minio 不自动证明 MinIO 位于独立故障域。

stanza

pgBackRest stanza 把一个 PostgreSQL cluster 与 repository metadata 关联:

pgbackrest --stanza=pg-test stanza-create
pgbackrest --stanza=pg-test check
pgbackrest --stanza=pg-test info

Pigsty 在启用备份组件时交付配置/stanza。current 文档给出:

./pgsql.yml -t pg_backup

用于对已存在 cluster 启用相关 subtask;真正执行前:

  • 固定 Pigsty repo/tag;
  • 使用正确 inventory 与 limit;
  • 先 diff/plan 变更;
  • 确认不会删除/重建现有 stanza;
  • 保护 secret output。

删除 cluster 与 stanza

Pigsty 文档提示,移除 primary 时可能删除 pgBackRest stanza,可用相应参数保留 backup。删除 cluster 的动作与删除 backup 历史是两个授权:

remove database service
  != authorize deleting recovery history

所有 pgsql-rm.yml / pg_rm_backup 动作都应进入 destructive review。本章 不执行。

backup 命令

Pigsty 提供 /pg/bin/pg-backup

pg-backup          # documented default/incremental behavior
pg-backup full
pg-backup diff
pg-backup incr

原生 pgBackRest:

sudo -iu postgres \
  pgbackrest --stanza=pg-test --repo=1 \
             --type=full --log-level-console=info backup

使用 wrapper 的好处是平台约定一致;使用原生命令的好处是 exact option 清晰。本章 formal 固定原生命令并把版本、label、duration 与 source hash 写入 evidence。

调度

Pigsty 用 pg_crontab 声明:

pg_crontab:
  - '00 01 * * 1 /pg/bin/pg-backup full'
  - '00 01 * * 2-7 /pg/bin/pg-backup'

本章沙箱实际观察:

Monday 01:00 full
other days 01:00 default backup

调度要回答:

  • 时区;
  • primary role 切换后谁执行;
  • concurrent/overlap lock;
  • missed schedule;
  • retry/backoff;
  • load window;
  • full/diff/incr policy;
  • output/log rotation;
  • alert source。

cron entry 存在不是 job 成功证据。

primary 与 role change

HA cluster 中 backup job 不应因为原 primary 降为 replica 就盲目继续。平台 wrapper/配置应识别角色与 pgBackRest topology。演练切换后检查:

which node owns cron
which node is repository stanza's database endpoint
archive push source
backup-from-standby policy

第 20 章切回原基线后,本章在 pg-test-1 执行 formal full。

async archive 与 spool

pgBackRest 可用 async archive push/get:

PostgreSQL archive command
  -> pgBackRest spool queue
      -> repository worker

好处是降低 postmaster archive command 的同步等待;代价是多一层队列状态。

观察:

spool path
archive-push async log
queue size/age
repository max WAL
pg_stat_archiver
pg_wal free

验证 restore 时应使用独立 spool path,避免测试实例与 live archive worker 共享状态。本章:

/data/pg36-ch21-restore/<run>/spool

凭据

典型 secret:

S3 access key/secret
repository cipher pass
repository TLS client material
remote host key
database backup/replication credential
KMS token

配置文件最低要求:

  • owner/group 与 mode;
  • 不被普通 exporter/log collector 读取;
  • command line/log 自动 redaction 验证;
  • rotation;
  • break-glass restore;
  • 不与 source destruction role 共用;
  • 不进入 pgbackrest info 的公开 projection。

本章 remote pgBackRest console 会把 secret 显示为 <redacted>,但 formal evidence 根本不保存原始 console,只保存结构化无 secret 结果。

仓库 TLS 与 CA

S3-compatible endpoint 应验证:

TLS enabled
expected CA
hostname/SAN
certificate rotation
clock
no silent downgrade

本章沙箱配置有 repository CA path,但未把完整 transport security 作为 生产验收;第 23 章继续。

声明与有效状态

三份证据:

inventory declaration
generated /etc/pgbackrest config
effective command/catalog behavior

不能只看其中一个。生成配置可能漂移,声明可能未应用,运行命令也可能被环境 变量或额外 conf.d 覆盖。

安全投影应保留:

stanza
repo type/path class
endpoint class
cipher type
retention type/value
block/bundle
archive async
spool path

去掉所有 secret value。

21.5.2 备份状态、归档状态与容量观察

pgbackrest info

人读:

sudo -iu postgres \
  pgbackrest --stanza=pg-test --repo=1 info

机器读:

sudo -iu postgres \
  pgbackrest --stanza=pg-test --repo=1 --output=json info

关注:

stanza status code/message
backup/restore locks
repository id/cipher
database version and repository key
archive id/min/max
backup label/type/error
timestamp start/stop
archive start/stop
logical and repository bytes
prior/reference dependency

JSON 仍可能含 raw system ID;公开 evidence 应转成 equality relation。

label 解读

示例:

20260729-201041F

常见 suffix:

F full
D differential
I incremental

不要只解析名字推断成功;同时看 catalog error=false、status、archive 和 实际恢复。

check

sudo -iu postgres \
  pgbackrest --stanza=pg-test --log-level-console=info check

它能触发/检查 archive path 与配置。不同 command 的 options 不完全相同:

info/backup/restore accept --repo=1
check in pgBackRest 2.59.0 rejects --repo=1

把一串“通用参数”复制给所有命令是危险习惯。

PostgreSQL 原生交叉验证

SELECT archived_count,
       last_archived_wal,
       last_archived_time,
       failed_count,
       last_failed_wal,
       last_failed_time
FROM pg_stat_archiver;

SELECT pg_current_wal_lsn(),
       pg_walfile_name(pg_current_wal_lsn());

对照:

PostgreSQL last archived
pgBackRest archive max
current WAL
spool queue

不同 observation 可能短暂错开,要用时间戳和 queue 解释。

累计失败数

本章沙箱:

archived_count > 0
failed_count = 21
last successful time > last failed time

这说明过去有失败,当前最近归档成功。正确告警不是:

failed_count > 0 forever critical

而是:

failure counter increased recently
and/or last failure later than last success
and/or archive maximum stops advancing while WAL advances
and/or spool/backlog/free-space breaches

backup freshness

计算:

now - latest successful backup stop

还要按类型:

latest full age
latest diff age
latest any backup age
oldest retained full

一份最新 incremental 可能依赖过旧 full;只看 latest label 隐藏 chain 风险。

archive freshness

低流量时:

last archive time old
current WAL same segment

不一定 backlog。高流量时:

last archive time recent
but repository is many segments behind

仍可能越过 RPO。结合 rate 和 segment distance。

容量

源端:

df -h /pg/data /pg
du -sh /pg/data/pg_wal

repository:

used bytes
object count
growth forecast
version/lock overhead
cold tier
retention expiry

restore target:

df -h /data
df -i /data

本章 .13/data 有充足空间,正式 restore 只有约 36 MB;这不能证明 生产大库空间规划。

日志

Pigsty 常见 pgBackRest log path:

/pg/log/pgbackrest/

分类:

backup
expire
archive-push-async
archive-get-async
restore

日志要集中,但 redaction 后再进入平台。错误排查保留 exec-id、command version、stanza 与 phase。

dashboard 与 alert

Pigsty 提供 PGSQL PITR 等 dashboard/monitoring 入口。图表用于趋势与定位, 机器验收仍回到:

  • PostgreSQL catalog;
  • pgBackRest JSON;
  • repository/storage fact;
  • real restore evidence。

看板绿不等于 restore proof。

观察矩阵

信号 warning critical/action
latest full age 接近 policy 越过恢复合同
archive max lag 上升 RPO 越线/磁盘风险
pg_wal free forecast shrink 可能 PANIC
repository free forecast shrink 下一备份不可完成
backup duration trend regression window/RTO risk
failed count delta new failure inspect/retry
restore drill age 接近周期 recoverability unproven
key expiry rotation window future restore blocked

本章 evidence capture

capture.py 默认只读:

PG36_EVIDENCE_DIR=/new/path \
  static/labs/ch21/task.sh capture

它采集:

Patroni topology
PostgreSQL archive/current WAL/settings
sanitized pgBackRest catalog
no raw system ID
no credential

它明确输出:

recoverability = not-proven-by-capture

因为 capture 没有执行 restore。

21.5.3 在隔离目标而不是原集群上恢复

Pigsty 的恢复入口

当前 Pigsty 文档提供:

manual pg-pitr prompt/script path
pgsql-pitr.yml playbook path

文档说明,熟悉配置时可使用自动 playbook,否则建议逐步手工方式。其本质 原因是恢复 target、source 与 destructive action 需要人理解,不应让 automation 隐藏。

示意:

./pgsql-pitr.yml \
  -e '{"pg_pitr": {"time": "2026-07-29 20:10:44+00"}}'

这类 playbook 可能停止、清空、重建目标 instance。不要在 live cluster 上 为了练习运行。

当前文档 target forms

Pigsty pg_pitr 映射:

pg_pitr: {}
pg_pitr: { time: "2026-07-29 20:10:44+00" }
pg_pitr: { lsn: "0/200002D0" }
pg_pitr: { xid: "250000", exclusive: true }
pg_pitr: { name: "before_release" }
pg_pitr: { type: "immediate" }

使用前核对目标 Pigsty tag 的 exact behavior,以及 exclusiverecovery_target_inclusive 的映射。

为什么本章不直接调用 destructive playbook

本章目标是验证备份,而不是替换服务。正式设计:

source cluster remains running
restore under a brand-new path
no Patroni
no DCS
no TCP
no service endpoint
archive_mode=off
stop after proof
retain directory

Pigsty 提供配置、pgBackRest 与 repository;本章用底层 pgBackRest 对 fresh path 恢复,以精确控制隔离边界。

formal restore command

结构化示意:

sudo -iu postgres pgbackrest \
  --stanza=pg-test \
  --repo=1 \
  --set=20260729-201041F \
  --type=name \
  --target=pg36_ch21_<run>_keep \
  --target-action=promote \
  --target-timeline=latest \
  --archive-mode=off \
  --pg1-path=/data/pg36-ch21-restore/<run>/data \
  --spool-path=/data/pg36-ch21-restore/<run>/spool \
  --log-path=/data/pg36-ch21-restore/<run>/log \
  restore

不要直接复制 placeholder。正式 runner 生成并验证 exact run ID/path,拒绝 已存在目录。

启动覆盖

live Pigsty PostgreSQL config 含:

hba_file=/pg/data/pg_hba.conf
ident_file=/pg/data/pg_ident.conf
log path under /pg/log/postgres
TLS path under /pg/cert
listen_addresses=0.0.0.0
port=5432
standby primary_conninfo/slot

若直接启动 restored config,会碰 live path、网络与复制配置。正式 runner 显式覆盖:

listen_addresses = ''
port = 55432
unix_socket_directories = '<private>/socket'
unix_socket_permissions = 0700
hba_file = '<private>/pg_hba.restore.conf'
ident_file = '<restored>/pg_ident.conf'
ssl = off
archive_mode = off
primary_conninfo = ''
primary_slot_name = ''
shared_preload_libraries = ''
logging_collector = off
cluster_name = 'pg36-ch21-restore'

同时携带 source recovery-critical maxima。

same-host isolation 的边界

formal restore 放在 pg-test-3,而 .13 同时继续运行 live replica:

live:
  /pg/data
  port 5432
  Patroni member pg-test-3

isolated:
  /data/pg36-ch21-restore/<run>/data
  Unix socket only
  internal port 55432
  no Patroni

这是 process/path/network isolation,不是 host/device/failure-domain isolation,形成 EX21-SHARED-RESTORE-HOST。生产 restore 应使用独立主机。

启动完成不能只看 pg_ctl

pg_ctl -D "$restore/data" -w start

可能在只读 consistent state 返回。正式脚本随后:

SELECT pg_is_in_recovery(),
       current_setting('transaction_read_only'),
       current_setting('archive_mode'),
       current_setting('listen_addresses');

等待:

recovery=false
transaction_read_only=false
archive_mode=off
listen_addresses=''

再做 rollback write。

停止并保留

验证后:

pg_ctl -D "$restore/data" -w -m fast stop

检查:

postmaster.pid absent
Unix socket absent
TCP listener absent
restore directory present
live replica still streaming on 5432

保留目录支持审计,但不是长期服务。删除由单独 reset:fixture 授权。

防误操作设计

task.sh

capture|verify|review|all
  no mutation

drill:pitr
  requires exact target + four nonproduction guards + confirmation

reset:fixture
  separate destructive token + exact run ID

all 永远不调用 drill:pitr。这避免 CI/读者为了“跑全套检查”意外再做备份 或启动恢复。

版本迁移

迁移到新版本时,工时不只改命令:

review PostgreSQL recovery setting changes
review pgBackRest command options/output schema
review Pigsty variables/playbooks
recreate sanitized baseline
run negative guards
perform fresh restore
compare readiness phases
update exceptions and evidence

migration-effort.json 列出 production 仍需补的 evidence,不把沙箱通过升级为生产。

小结

Pigsty 与 pgBackRest 的价值,是把可恢复性机制交付成一致、可观察、可自动化 的系统。正确使用方式是:

declare with Pigsty
inspect with pgBackRest + PostgreSQL
prove with isolated restore
govern with explicit authority

下一节执行完整实验,并逐项解释 formal output、两个安全失败、十四个反例和 生产 gate。


上一节:恢复流程与验证 · 返回本章目录 · 下一节:实战:完成一次隔离恢复演练 · 查看全书目录 · 查看索引中心

21.6 实战:完成一次隔离恢复演练

这是一次真实运行过的物理恢复,不是伪造的示例输出。

实验完成:

fresh synthetic marker
fresh full pgBackRest backup
post-backup marker
named restore point
post-target marker
forced WAL switch + pgBackRest check
exact-label restore to a fresh isolated path
read-only and promotion phase measurement
positive/negative business boundary
lineage and timeline proof
isolated shutdown
source postflight
14 adversarial counterexamples

安全边界:

local nonproduction sandbox only
production data/traffic forbidden
source cluster not stopped
live /pg/data not touched
no DCS/Patroni membership for restored copy
no TCP listener
archive push disabled
no routing change
no cleanup during acceptance
production approval pending

21.6.1 创建已知业务检查点并执行备份

先读合同

四个入口:

exact target:

pg36-l2-vagrant/pg-test
Pigsty v4.5.0
PostgreSQL 18.6
pgBackRest 2.59.0
pg-test-1 primary
pg-test-2/3 streaming replicas

任何 version/member/role drift 都先停止。

风险分级

action risk 行为
capture L0 只读采集 source/repository
verify L0 重验已有 evidence
review L0 hash、边界与 secret review
all L0 verify + review
drill:pitr L2 marker、full backup、fresh isolated restore
reset:fixture L3 删除一个 run 的 rows 与 retained directory

普通 all 不包含 drill 或 reset。

无授权必须拒绝

PG36_EVIDENCE_DIR=/new/empty/path \
  static/labs/ch21/task.sh drill:pitr

结果:

exit=77
refusing PITR drill: exact target, nonproduction, data, traffic,
and confirmation guards are required

拒绝发生在任何 marker/backup/restore 前。

chapter-19 gate

formal wrapper 先运行:

PG36_CH19_INVENTORY=/absolute/mode-0600/reviewed.yml \
PG36_EVIDENCE_DIR="$run/preflight-ch19" \
  static/labs/ch19/task.sh all

要求:

hosts=4-distinct
pg-meta one primary
pg-test one primary + two streaming replicas
secret values redacted
sandbox_l2=accepted-with-exceptions
production_ch19_gate=pending

private inventory 不复制到 evidence。它用于验证 exact declaration,不应使用 公开 placeholder 去做真实部署。

source preflight

在 DDL 前采集:

Patroni exact member set
pg-test-1 only leader/running
pg-test-2/3 replica/streaming
replay lag <= 1 MiB
source in_recovery=false
archive_mode=on
current WAL segment/timeline
recovery-critical settings
sanitized pgBackRest catalog

source system identifier 只保存在 runner 内存用于 equality,evidence 不导出 raw value。

fixture

setup.sql

CREATE SCHEMA IF NOT EXISTS pg36_ch21;

CREATE TABLE IF NOT EXISTS pg36_ch21.recovery_probe (
    run_id       text        NOT NULL,
    stage        text        NOT NULL
                             CHECK (stage IN ('base','keep','discard')),
    token        text        NOT NULL UNIQUE,
    committed_at timestamptz NOT NULL DEFAULT clock_timestamp(),
    PRIMARY KEY (run_id, stage)
);

每轮 run ID:

run_YYYYMMDDTHHMMSSZ_<8 hex>

路径与 SQL token 只接受这个 allowlist,避免 path/command injection。

三阶段顺序

insert base
  -> take full backup
      -> insert keep
          -> pg_create_restore_point(name)
              -> insert discard
                  -> pg_switch_wal
                      -> pgBackRest check

顺序的逻辑:

stage 文件基础备份 后续 WAL target 结果
base 已包含或由 backup 所需 WAL 一致化 present
keep backup 后 target 前 present
discard backup 后 target 后 absent

marker 不是 sleep

我们不靠:

sleep 2

猜测时间边界,而是保存:

run ID
unique token
database commit timestamp
restore-point LSN
target WAL filename

因此验证的是 transaction/WAL 边界。

fresh full

formal command:

sudo -iu postgres pgbackrest \
  --stanza=pg-test \
  --repo=1 \
  --type=full \
  --log-level-console=info \
  backup

runner 在前后读取 JSON catalog,要求恰好新增一份

type=full
error=false
stanza status code=0

正式结果:

label                 20260729-201041F
backup command        2085.526 ms
logical bytes         36,121,841
repository delta       4,539,288
backup archive        timeline 7

不公开 repository key/cipher pass。

restore point

runner:

SELECT pg_create_restore_point(
  'pg36_ch21_run_20260729T201040Z_961665aa_keep'
);

具体名称由 run 生成,不应手工照抄。evidence 保存:

name
LSN
WAL segment
created_at UTC

强制并检查 archive

SELECT pg_switch_wal();

然后:

sudo -iu postgres \
  pgbackrest --stanza=pg-test --log-level-console=info check

注意没有 --repo=1。正式成功:

check duration          598.471 ms
target WAL              ...000020
repository max WAL      ...000021
target_wal_covered      true

公开结果可以省略完整 WAL 名;完整 secret-safe evidence 保留以审计。

第一次 formal 尝试为何失败

最初 runner 对 check 传了:

--repo=1

pgBackRest 2.59.0 返回:

ERROR [031]: option 'repo' not valid for command 'check'

失败发生在 restore path 创建前:

backup succeeded
markers retained
no isolated directory
no postmaster
source healthy

我们没有删除失败 backup/marker 来伪装“一次通过”,而是修正 command-specific option,再做一轮 fresh formal evidence。

formal 入口

只在 exact local sandbox:

export PG36_CH19_INVENTORY=/absolute/mode-0600/reviewed.yml
export PG36_EVIDENCE_DIR=/absolute/new-empty/ch21-run

export PG36_CH21_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH21_NONPRODUCTION=true
export PG36_CH21_PRODUCTION_DATA=false
export PG36_CH21_PRODUCTION_TRAFFIC=false
export PG36_CH21_CONFIRM=BACKUP_AND_ISOLATED_PITR_CH21

static/labs/ch21/task.sh drill:pitr

evidence dir 非空则拒绝覆盖。

21.6.2 恢复到隔离集群,核对数据库与业务不变量

restore root

formal:

/data/pg36-ch21-restore/
  run_20260729T201040Z_961665aa/
    data/
    socket/
    log/
    spool/
    pg_hba.restore.conf

preflight:

root exact allowlist
root does not exist
not symlink
port 55432 unused
owner postgres
mode 0700

已存在 path 不做 --delta 覆盖,直接拒绝。

exact backup 与 target

formal restore 绑定:

--set=20260729-201041F
--type=name
--target=<this run keep restore point>
--target-action=promote
--target-timeline=latest
--archive-mode=off

这同时固定:

which base
where replay stops
what happens at target
which timeline path
whether recovered fork can archive

文件恢复

结果:

restore_copy_ms = 2758.206
files/bytes      from exact backup catalog
recovery.signal  present
standby.signal   absent

copy 完成不算 PostgreSQL 恢复完成。

隔离启动

runner 用 exact PostgreSQL 18 pg_ctl,覆盖:

listen_addresses=''
port=55432
private Unix socket mode=0700
private HBA
ssl=off
archive_mode=off
primary_conninfo=''
primary_slot_name=''
shared_preload_libraries=''
logging_collector=off
cluster_name=pg36-ch21-restore

live replica:

/pg/data
port 5432
cluster_name=pg-test
Patroni streaming

两者路径与控制面不相交。

recovery-critical maxima

source 观测:

max_connections=500
max_worker_processes=24
max_wal_senders=50
max_prepared_transactions=0
max_locks_per_transaction=500

formal restore 携带这些值。

开发时曾尝试:

max_connections=20

PostgreSQL 在重放前 FATAL 拒绝。修复方法不是降低 source 或编辑控制文件, 而是让隔离 runtime 满足 source WAL 记录的要求。

两个 readiness 时刻

第一时刻:

start -> first connection = 962.980 ms
pg_is_in_recovery()       = true
transaction_read_only     = true

第二时刻:

start -> promoted         = 1318.876 ms
pg_is_in_recovery()       = false
transaction_read_only     = false

差:

355.896 ms

所以:

pg_ctl -w returned
  != target-action promotion complete

rollback write probe

promotion 后:

BEGIN;
CREATE TEMPORARY TABLE pg36_ch21_writable_probe(value integer);
INSERT INTO pg36_ch21_writable_probe VALUES (1);
ROLLBACK;

结果:

rollback-write-ok

它证明当前连接可写,不给历史副本增加持久业务事实。

business boundary

exact run:

base     present  true
keep     present  true
discard  present  false
unexpected stages 0

如果只看 row count=2,仍可能误拿其他 run;runner 对 exact token 比较。

lineage

system identifier relation = matches source
raw identifier recorded     = false
source timeline             = 7
restored timeline           = 8
timeline increment          = 1

这是同一物理血缘的一次合法 PITR fork。

isolation proof

运行中:

TCP listener        false
Unix socket         true
socket mode         0700
socket owner        postgres:postgres
postmaster.pid      true
archive_mode        off
Patroni managed     false

仅把 port 改成 55432 不算隔离;若 listen_addresses=0.0.0.0,仍会向网络暴露。

停止

pg_ctl -D <exact-data> -w -t 30 -m fast stop

停止后:

postmaster.pid              false
Unix socket                 false
TCP listener                false
restore directory retained  true

目录保留供审查,未自动删除。

source postflight

恢复后重新执行 chapter-19 gate,并在 drill 内检查 .13 live instance:

pg-test-1 leader/running
pg-test-2 replica/streaming
pg-test-3 replica/streaming
all Patroni timeline 7
source system ID unchanged
pg-test-3 in_recovery=true
pg-test-3 replay paused=false
pg-test-3 port=5432
replica lag=0 bytes

isolated timeline 8 不会加入 source Patroni cluster。

不做什么

no pgsql-pitr destructive replacement
no Patroni reinit
no failover/switchover
no /pg/data write
no service routing
no repository expire
no restore directory deletion

这使实验回答“backup 能否恢复”,而不是把多个高风险动作揉成一个结果。

21.6.3 输出 RPO/RTO 实测、证据链和失败处理 SOP

reference result

restore-run.json

{
  "backup_command_ms": 2085.525583,
  "restore_copy_ms": 2758.205792,
  "start_to_first_connection_ms": 962.980167,
  "first_connection_in_recovery": true,
  "start_to_promoted_ms": 1318.876208,
  "first_connection_to_promoted_ms": 355.896041,
  "base_present": true,
  "keep_present": true,
  "discard_present": false,
  "source_timeline": 7,
  "restored_timeline": 8,
  "isolated_postmaster_stopped": true,
  "production_ch21_gate": "pending"
}

它不含 credential 或 raw system ID。

这次 RPO 证明了什么

证明:

fresh full ended before keep
keep WAL and named point were archived
restore stopped at named point
post-target discard did not replay

可以说:

named-point selection for this run passed

不能说:

production RPO = 0
any time in 14 days is recoverable
worst-case archive delay is 0
region loss loses no data

因为实验主动 pg_switch_wal()check,没有模拟 source 在 open segment 尚未归档时突然毁坏。

这次 RTO 测量了什么

已测:

backup command
repository check
file restore copy
start to first read-only
start to promotion

未测:

incident detection
human decision/approval
new host provisioning
cold object retrieval
large production bytes
full business validation
application configuration
DNS/proxy/client cutover
unknown transaction reconciliation
backlog clearing

所以:

2.758s+1.319sRTOproduction 2.758s + 1.319s \not= RTO_{\text{production}}

甚至简单相加也不完整,因为时钟阶段与执行方式要明确。

四类时钟

建议输出:

T_backup
T_restore_copy
T_recovery_consistent/read_only
T_recovery_promoted
T_database_validation
T_business_validation
T_service_cutover
T_backlog_clear

本章前四项中的三个有正式观测,后四项保持未测。

evidence tree

<run>/
├── preflight-ch19/
├── drill/
│   ├── drill-manifest.json
│   ├── source-before.json
│   ├── fixture.json
│   ├── backup.json
│   ├── recovery.json
│   ├── isolated-shutdown.json
│   ├── source-after.json
│   ├── validation-report.json
│   └── negative-report.json
├── postflight-ch19/
└── review.txt

manifest 记录所有 source input SHA-256。restore-run.jsonmigration-effort.json 是 outcome,不参与输入 hash,但 reviewer 会把 published outcome 与 validation report 对齐。

十四个反例

negative-cases.json 要求拒绝:

反例 policy code
从沙箱声称生产通过 E_PRODUCTION_CLAIM
恢复到未审目标 E_TARGET
仓库状态失败 E_REPOSITORY
backup 标记失败 E_BACKUP
target WAL 未覆盖 E_ARCHIVE
recovered archive push 开启 E_ISOLATION
restore 有 TCP listener E_ISOLATION
只读阶段当 promotion 完成 E_PROMOTION
keep 缺失 E_BOUNDARY
discard 存在 E_BOUNDARY
system lineage 不同 E_LINEAGE
timeline 未前进 E_TIMELINE
isolated postmaster 未停止 E_SHUTDOWN
source cluster 退化 E_SOURCE_HEALTH

formal:

case_count=14
actual_code == expected_code for every case
status=ok

为什么测反例

一个 validator 若只接受正确 evidence,可能只是检查文件存在。让每个关键 断言被单独破坏并得到预期错误码,证明 decision rule 真正约束:

positive proof
+ negative falsification

这比“脚本 exit 0”更有审计价值。

read-only 重验

formal 结束后:

PG36_EVIDENCE_DIR=/absolute/completed/ch21-run \
  static/labs/ch21/task.sh all

输出:

status=validation-ok
sandbox_named_pitr=accepted-with-exceptions
counterexamples=14-rejected
status=review-ok
secret_values_exported=0
raw_system_identifiers_exported=0
isolated_postmaster=stopped
restore_directory=retained
production_ch21_gate=pending
mutation=none

它不会重跑 backup/restore。

失败 SOP:backup

backup command fails
  -> preserve console/log exec-id
      -> inspect stanza/repository/archive
          -> check source and disk health
              -> do not select partial label
                  -> retry only after cause and policy review

若已插入 base,它只是 synthetic evidence;不要为“干净”自动删。

失败 SOP:archive gap

target segment > repository max or actual restore requests missing WAL
  -> stop
      -> preserve source pg_wal
          -> inspect spool/archive errors/capacity
              -> recover missing WAL if possible
                  -> choose earlier target only with business approval

不能跳过 WAL 或把“最新可到达点”擅自当成批准 target。

失败 SOP:restore/start

restore fails
  -> retain exact directory/log
      -> ensure no postmaster remains
          -> inspect permission/tablespace/key/WAL
              -> create a new run/path for retry

不要用 --delta 在不清楚内容的目录上反复覆盖。

失败 SOP:target mismatch

base/keep missing or discard present
  -> reject candidate
      -> do not expose service
          -> review target/backup/inclusive/timeline
              -> select a new target with data owner
                  -> restore to another fresh path

PostgreSQL 能启动不降低严重性。

失败 SOP:isolation

若发现:

TCP listener
archive_mode=on
wrong HBA
Patroni/DCS membership
path overlaps /pg/data

立即停止 isolated instance,保留证据,确认 source repository 和 routing 未污染,再调查。不要“验证完再关”。

reset

删除不是 acceptance 的一部分。单独入口要求:

exact target
nonproduction guards
exact run ID
PG36_CH21_RESET_CONFIRM=DELETE_ONE_CH21_SANDBOX_RUN
no postmaster.pid
no Unix socket
path exact allowlist
not symlink

然后只删除一个 retained directory 与对应 run rows。本文 formal 没有执行 reset。

生产 gate

沙箱通过后,生产仍需:

  1. 代表性生产规模与 change/WAL rate;
  2. 独立 production-class restore infrastructure;
  3. immutable、off-site、独立 credential/key 证明;
  4. 任意 time/XID/LSN 与 inclusive 语义;
  5. 最旧 retention target;
  6. missing WAL、lost key、repository outage;
  7. extension/tablespace/config/PKI 完整性;
  8. 应用与外部系统不变量;
  9. service cutover、client outcome 与 backlog;
  10. region loss 与 break-glass access;
  11. 多次样本分布,而不是一次秒数;
  12. 生产 change/incident authority。

migration-effort.json 把这些保持为 required_next_evidence

本章最终判定

sandbox named PITR
  accepted with ten explicit exceptions

PostgreSQL physical lineage
  matched

target boundary
  base + keep, no discard

isolation
  path/process/socket/archive isolated
  shared restore host exception remains

source safety
  healthy before and after

production
  pending

小结

一次可信恢复演练同时具备:

known business boundary
+ fresh or exactly selected backup
+ continuous WAL evidence
+ isolated target
+ two-phase readiness
+ lineage/timeline proof
+ positive and negative invariants
+ source postflight
+ shutdown
+ residual-risk ledger

这才把“我们有备份”升级为“我们曾在明确边界内证明它可以恢复”。

下一章继续服务面:当一个 PostgreSQL 实例真的可写之后,连接池、代理、 路由、会话与客户端重试如何决定用户何时恢复。


上一节:用 pgBackRest 交付备份策略 · 返回本章目录 · 下一章:四通八达:服务接入、连接池与路由 · 查看全书目录 · 查看索引中心

22 四通八达:服务接入、连接池与路由

应用需要的不是“连接 10.10.10.11”,而是:

把这笔短事务交给当前可写主库
把这批可容忍陈旧的查询交给合格副本
让迁移工具跟随主库,但不要经过事务池
把重型分析限制在指定离线副本

机器地址只回答“TCP 包送到哪里”,服务端点还必须回答:

role          选择主库、副本还是指定成员
consistency   允许多旧,是否要求 read-your-writes
path          经过代理、连接池还是直达 PostgreSQL
session       客户端会话是否稳定绑定一个 backend
capacity      最多建立多少 client/server connection,在哪里排队
failure       旧连接如何结束,新连接何时恢复,提交结果如何判定
security      使用哪个身份、HBA/TLS/证书与审计边界
discovery     一个地址、VIP、DNS、多 host 还是驱动拓扑发现

只要其中一项没有写清,端口号就是一个未经定义的偶然实现。

本章把 PostgreSQL backend、PgBouncer、HAProxy、Patroni 和客户端驱动放进 同一条证据链。学习顺序不是先背 5433/5434/5436/5438,而是先定义服务 语义,再计算连接预算,选择池化模式,最后验证 Pigsty 的具体映射与切换行为。

本章目标

完成本章后,你应当能够:

  1. 把连接 URI 当作版本化服务合同,而不是主机别名;
  2. 区分主写、只读、同步读取、离线读取和直连管理端点;
  3. 解释异步副本为什么不能天然提供 read-your-writes;
  4. 用 LSN、sticky-primary 或业务一致性 token 设计读取策略;
  5. 说明一个 PostgreSQL backend 的进程、内存、锁、事务和会话成本;
  6. 拒绝用调大 max_connections 代替容量规划;
  7. 把应用进程数、应用池、PgBouncer pool 与数据库保留槽放进同一预算;
  8. 正确比较 session、transaction 与 statement pooling;
  9. 判断临时表、会话 GUC、LISTEN、咨询锁和安全上下文是否兼容事务池;
  10. 区分协议级 prepared statement 与 SQL PREPARE
  11. SHOW POOLS 证明排队、复用与服务端连接上限;
  12. 阅读 HAProxy 的 Patroni 角色健康检查、backup 选择和连接关闭策略;
  13. 为切换中的断连、退避、抖动和提交结果未知编写客户端合同;
  14. 识别角色已经正确、但池化后端仍保留旧角色状态的情况;
  15. 在 Pigsty 中声明、渲染、校验和重载服务,而不是手改产物;
  16. 完成一次带端点、池化、异步可见性和双向计划切换的可重放演练。

前置与后续

前置:

后续:

  • 第 23 章把身份、HBA、TLS、RLS、审计与池化上下文纳入安全模型;
  • 第 24 章把端点合同、切换 SOP 和例外变成组织治理;
  • 第 25、26、27 章分别补齐指标、容量压测与参数治理;
  • 第 31、34 章会复用本章的 queue、timeout、reserve 与 reconnect 控制点 处理事故和过载。

学习路径

业务动作
  -> 一致性和会话要求
      -> 语义服务端点
          -> PostgreSQL backend 成本
              -> 全局连接与并发预算
                  -> PgBouncer 模式及兼容性
                      -> HAProxy/Patroni 角色路由
                          -> 断连、重试与结果判定
                              -> Pigsty 声明和渲染
                                  -> 端到端演练与生产差距

这条路径故意把“端口”放在后面。5433 不是 PostgreSQL 标准语义;只有 当声明、渲染配置、健康检查、池状态和 SQL 观察相互吻合时,它才是当前 环境里的主写服务。

五层连接证明

要回答的问题 本章证据
声明 服务本来应当选择什么 Pigsty inventory/defaults、ADR
渲染 实际代理与池配置是什么 HAProxy service file、SHOW CONFIG
运行 当前哪些 backend 合格、池是否排队 Patroni、HAProxy health、SHOW POOLS
SQL 客户端最终落到什么角色与会话 recovery/read-only、PID、GUC、token
故障 角色变化时确认/未知结果如何收敛 client event、timeline、token reconcile

任意一层单独通过都不够:

Patroni 有 leader
  != 应用主写端点可用

HAProxy backend UP
  != PgBouncer 身份、会话语义正确

SHOW POOLS 有空位
  != 业务延迟和数据库并发安全

连接失败后重试成功
  != 上一次写入一定没有提交

Pigsty 默认服务语义

本章 exact v4.5.0 沙箱保留四个服务:

名称 入口 目标 Patroni 检查 本章用途
primary 5433 当前主库 PgBouncer :6432 /primary 短 OLTP 读写
replica 5434 副本优先 PgBouncer :6432 /read-only 可容忍陈旧的只读
default 5436 当前主库 PostgreSQL :5432 /primary 管理、迁移、会话敏感工具
offline 5438 指定离线副本 PostgreSQL :5432 /replica 受控 OLAP/ETL

replica 的 primary 是 backup,offline 的普通 replica 也是 backup。 因此名称表达“选择偏好与降级策略”,不是永恒承诺。客户端仍要用 target_session_attrs、只读事务和业务策略防止错误落点。

这些都是可修改的 Pigsty 默认值,不是 PostgreSQL 标准端口。实际系统必须 读取自己的声明与渲染产物。

正式实验

target          pg36-l2-vagrant/pg-test
entry           10.10.10.11 HAProxy
Pigsty          v4.5.0
PostgreSQL      18.6
PgBouncer       1.25.2, transaction mode
HAProxy         3.4.2
fixture         database/user test/test, schema pg36_ch22
initial         pg-test-1 primary, timeline 9
forward         pg-test-2 primary, timeline 10
restored        pg-test-1 primary, timeline 11

池化实验临时把入口节点的默认 server pool 从:

default_pool_size=50
reserve_pool_size=30
query_wait_timeout=120

改为:

default_pool_size=2
reserve_pool_size=0
query_wait_timeout=5

所有值在角色切换前精确恢复。正式观测:

endpoint semantics                      4 / 4 matched
concurrent clients                      12
maximum active PostgreSQL backends       2
maximum waiting clients                 10
fastest / slowest query              254 / 1519 ms

session SET stayed with backend          yes
same state leaked to another client      yes
original client got another backend      yes

protocol prepared iterations            12 / 12 correct
backend reassignment                     observed
SQL PREPARE after reassignment           SQLSTATE 26000

replica token visibility                 11.092 ms
interpretation                           one observation only

forward command                          2.774 s
restore command                          2.766 s
acknowledged writes                      339
unknown outcomes                         48, all absent after lookup
acknowledged missing                       0
duplicate token                            0
unreconciled unknown                       0
maximum conservative write gap          8.510 s
counterexamples rejected                  15

最重要的观测不是某个毫秒数字,而是一个跨层失配:

pg-test-2 的 PostgreSQL 已经是只读副本,Patroni 和 HAProxy 健康检查 也正确,但 PgBouncer 的既有 pool 仍可能保留上一次角色周期的状态。 RECONNECT test 让服务端连接重新发现当前角色,端点验证才重新通过。

正式切换因此把三节点 RECONNECT test 计入恢复路径,并要求刷新后出现新的 确认写入。它不是隐藏的实验准备,更不是生产 SLO。

结论边界

four service paths on one entry          通过
two-slot queue and backpressure          通过
transaction-pool session hazard          通过
protocol prepared exact version pair     通过
SQL PREPARE cross-backend failure        通过
one async visibility observation         通过
two healthy planned switchovers          通过
token reconciliation                     通过
original pool settings/final leader       恢复

redundant HAProxy/VIP/DNS entry           未证明
client/server TLS                         未验收
representative production load           未测试
driver/ORM version matrix                 未测试
unplanned failover/fencing                未测试
bounded replica staleness                 未承诺
production approval                       pending

十四项例外

沿用第 19 章六项与第 20 章四项,本章新增:

EX22-SINGLE-HAPROXY-ENTRY
  只从 10.10.10.11 进入;不能声称入口冗余。

EX22-ASYNC-READ-OBSERVATION
  只采样一个 token;不能声称 read-your-writes 或 bounded staleness。

EX22-SYNTHETIC-LOAD
  负载小且运行在 laptop sandbox;不能推出生产容量。

EX22-RUNTIME-POOL-OVERRIDE
  只临时改变一个 PgBouncer 进程并恢复;不是声明式生产策略验收。

本章目录

22.1 服务端点的语义

22.2 连接的服务端成本

22.3 PgBouncer 池化模式

22.4 路由与故障切换

22.5 连接预算与过载边界

22.6 Pigsty 服务接入层

22.7 实战:写入、只读与管理三类接入

实验入口

task.sh all 只重验既有证据,不连接 fixture、不改配置、不切换角色,也不 删除对象。drill:servicereset:fixture 是两条独立、精确守卫的路径。

参考资料


上一章:未雨绸缪:备份体系与恢复演练 · 返回下卷导读 · 下一章:固若金汤:认证、授权与数据安全 · 查看全书目录 · 查看索引中心

22.1 服务端点的语义

数据库连接串经常被当成部署细节:

postgresql://user@host:port/database

但每个字段都在选择行为:

host/port                入口和发现机制
database/user            catalog、身份和 pool key
target_session_attrs     可接受的服务端角色
sslmode/certificate      传输与身份验证
connect_timeout          一次建立连接最多占用多久
options/startup params   会话初始语义

所以 URI 是应用与数据平台之间的 API。修改它可能改变一致性、会话、性能、 安全与故障行为,不能当作无语义的运维替换。

22.1.1 主写、只读、同步只读与直连管理端点

从业务动作定义端点

先列动作,再列端口:

动作 必要语义 不必要或有害的假设
创建订单 当前可写主库、提交结果可判定 固定机器
查看刚下的订单 read-your-writes 任意异步副本
浏览商品目录 可接受少量陈旧、只读 必须占用主库
生成日报 大查询、资源隔离、允许陈旧 普通 OLTP 副本
执行迁移 跟随主库、稳定 backend、完整会话能力 事务池
调查故障 保留管理槽、短超时、可审计 与应用共享无界池

由此可以得到几类服务,而不是几个机器别名。

主写服务

主写服务至少承诺:

new connection
  -> currently accepted writable role
  -> transaction_read_only = off
  -> write identity and permissions valid

它不承诺:

existing connection survives promotion/demotion
in-flight transaction transparently migrates
connection success means the next commit cannot fail
network error means the last commit did not happen

客户端可用 libpq 的:

target_session_attrs=read-write

作为最后一道角色检查。它不能选主,也不能修复代理;它只是在连接建立后拒绝 不满足属性的 session。

对写端点还要定义:

  • 事务被断开后是否自动重试;
  • 哪些动作有 idempotency key;
  • 提交结果未知时如何查询;
  • 是否允许 session pooling;
  • 应用侧 pool acquire/connect/statement timeout;
  • 最大并发和排队位置。

只读服务

只读端点有两个不同维度:

role semantics
  当前 session 不能写

freshness semantics
  数据最多允许落后多少

transaction_read_only=on 只证明第一项,不证明第二项。一个停止 replay 数小时的副本仍然只读。

客户端可以组合:

target_session_attrs=read-only

以及事务级护栏:

BEGIN READ ONLY;
SELECT ...;
COMMIT;

前者防错误落点,后者把意图交给 PostgreSQL。二者都不能自动提供 bounded staleness。

只读服务还要明确降级:

replica unavailable
  -> fail closed
  -> fall back to primary as read-only workload
  -> return cached/stale result
  -> shed optional traffic

Pigsty 默认 replica service 把 primary 作为 backup。因此它表达 “副本优先”,而不是“永远只去副本”。如果业务必须隔离主库,声明与客户端 都要 fail closed,不能只相信服务名称。

同步只读不是“副本端口”的同义词

同步复制控制提交确认条件。例如:

primary commit waits until chosen standby has durable WAL

这仍不自动表示:

任意只读副本已经 replay 到该提交
客户端下一次连接一定选择那个同步副本
同步副本没有降级或被替换
查询开始前 replay 已越过业务 token

真正的“同步只读服务”至少要同时约束:

  1. 哪些 standby 进入同步集合;
  2. commit 等待到 write、flush 还是 apply;
  3. 端点只选择哪个集合;
  4. 同步状态丢失时 fail closed 还是降级;
  5. 客户端如何携带上次写入边界。

synchronous_commit=remote_apply 且读取端点只选择参与确认的已 apply 副本,可以收紧窗口,但切换、负载均衡和事务起点仍需单独证明。

离线/分析服务

“replica”表示复制角色,“offline”通常表达调度意图:

普通 OLTP 查询不选它
重型 OLAP/ETL 优先选它
资源参数、索引或延迟容忍可能不同

Pigsty 的 offline service 使用 /replica 检查,并通过 inventory selector 优先选 pg_offline_query 成员。它仍会 replay WAL,长查询、临时文件与 I/O 可能拖慢 replay;“离线”不表示与主集群完全隔离。

应额外写:

  • 最大可接受 replay lag;
  • 查询并发、statement timeout、temp file 限额;
  • 是否允许 hot standby conflict 取消查询;
  • 普通副本能否作为 backup;
  • replay 明显落后时是否摘除。

直连管理端点

直连管理并不一定是固定主机。更实用的是:

client -> semantic primary proxy -> PostgreSQL 5432

它跟随当前主库,但绕过事务池,适合:

  • schema migration;
  • 需要 session advisory lock 的部署工具;
  • LISTEN/NOTIFY consumer;
  • 大型 COPY 或特殊驱动工作流;
  • 需要稳定 backend 的诊断;
  • CDC/逻辑复制管理,在完成额外评审后。

绕过池不等于无限连接。管理端点通常应有更小、更受控的来源网段、身份、连接 预算与审计。

Pigsty 默认 default service 是这种路径:HAProxy 仍用 /primary 选择主库,但目标是 PostgreSQL 5432 而不是 PgBouncer 6432

写一份端点合同

一个最小合同可以写成:

service: pg36_shop_rw
purpose: short OLTP read-write transactions
role: primary
path: haproxy -> pgbouncer(transaction) -> postgres
client_guard: target_session_attrs=read-write
consistency: primary transaction semantics
session_features:
  allowed: [SET LOCAL, protocol_prepare_tested]
  forbidden: [LISTEN, session_advisory_lock, persistent_temp_state]
budget:
  app_instances: 8
  app_pool_per_instance: 12
  pgbouncer_server_pool: 40
  database_reserved_for_platform: 60
timeouts:
  acquire: 200ms
  connect: 2s
  statement: 3s
failure:
  retry: capped exponential backoff with jitter
  write_outcome: reconcile by idempotency token
security:
  tls: verify-full
  role: pg36_shop_app
owner: shop-platform

端口只是这个合同的实现字段之一。

22.1.2 复制延迟、一致性与 read-your-writes

“刚写完却读不到”为什么完全正常

异步流复制的路径是:

primary commit
  -> WAL generated/flushed
      -> sender transmits
          -> standby receives/writes/flushes
              -> startup process replays
                  -> read query starts with a snapshot

写请求收到成功,只表示其提交满足当前 synchronous_commit 规则。若规则不 等待目标副本 apply,随后的 replica query 可能先到。

形式化地,令:

L_commit = 写事务之后主库的提交边界
L_replay = 读取开始前目标副本的 replay LSN

要让该副本具备读取边界,至少需要:

LreplayLcommit L_{\text{replay}} \ge L_{\text{commit}}

这仍不代表业务一定读到目标行:查询条件、事务 snapshot、权限、分区、 soft delete 和应用 cache 都可能改变结果。

四种常用策略

策略一:写后粘主

write success
  -> same request/session reads primary
  -> or tenant/user sticks to primary for bounded time

优点是简单;缺点是时间窗口不是因果证明,可能过长浪费主库,也可能过短。

适合:

  • 用户刚提交后查看详情;
  • 一次 HTTP request 内写后读;
  • 没有 LSN token 基础设施的普通应用。

策略二:携带一致性 token

写事务完成后,从主库取得一个 WAL 边界:

SELECT pg_current_wal_flush_lsn();

客户端把它作为 opaque token 传给后续读取。读取端点在副本上观察:

SELECT pg_last_wal_replay_lsn();

只有 replay 越过 token 才查询,否则:

short wait
  -> primary fallback
  -> fail with retryable freshness error

注意:

  • LSN 必须来自提交之后;事务内提前读取可能落在 commit record 之前;
  • timeline 改变后,不能只做字符串大小比较;
  • token 协议要绑定 cluster identity;
  • 等待必须受 request deadline 限制;
  • connection pool 可能在两次语句间换 backend;
  • 读取事务 snapshot 必须在 replay 达标之后建立。

事务池场景最好把“等待边界 + 业务查询”放在同一只读事务或服务端函数里, 避免检查和查询落到不同副本。

策略三:同步 apply 与限定路由

可以让提交等待同步副本 replay,再让读取只去被确认的集合。它增加写延迟, 并把副本可用性纳入提交路径。

必须定义降级时:

block writes
reduce synchronous quorum
route reads back to primary
declare consistency downgrade

没有明确降级合同的“同步”会在故障时悄悄变成另一个语义。

策略四:业务版本/事件边界

有些系统不用裸 LSN,而用:

aggregate version
event sequence
updated_at + unique version
outbox offset

副本读到至少该业务版本才返回。这更贴近业务,但底层仍需可靠映射和超时。

延迟指标的几个阶段

副本延迟不是一个数字:

阶段 PostgreSQL 观察 能推出什么
sent primary sent_lsn WAL 已发到何处
write primary write_lsn standby OS 收到/写入
flush primary flush_lsn standby 持久化
replay primary replay_lsn / standby replay LSN 查询可见边界接近何处
query 业务 token 查询 目标事实是否可见

replay_lag=0 是某次采样,不是未来保证。低流量时 timestamp lag 也可能显得 陈旧,LSN gap 与 wall-clock delay 要结合读。

本章的一个样本

正式实验:

write path       10.10.10.11:5433 -> primary PgBouncer
read path        10.10.10.11:5434 -> replica PgBouncer
selected replica pg-test-2
token visible    11.092 ms
poll count       1

它只证明:

在这个时刻、这个 token、这个小型异步沙箱和当前负载下,副本在约 11.092 ms 后返回了该行。

它不能证明:

P99 replica lag
切换期间延迟
高 WAL 吞吐下延迟
网络抖动下延迟
所有读取 read-your-writes

因此 evidence 把 EX22-ASYNC-READ-OBSERVATION 作为必需例外。

读端点失败也是语义

本章准备实验时曾观察到:

Patroni topology          correct
direct PostgreSQL state   recovery=true, read_only=true
HAProxy health            UP
target_session_attrs      rejects one pooled path

根因位于 PgBouncer server pool 的角色状态,而不是 PostgreSQL 复制本身。 执行受控的:

RECONNECT test;

让空闲/完成的服务端连接重新建立后,端点属性恢复。

这说明一致性证据必须从客户端走完整路径。只在副本上执行 SELECT pg_is_in_recovery(),不能证明应用的 5434 连接会得到同样 session。

22.1.3 DNS、VIP、代理与客户端发现

四种入口解决不同问题

固定节点地址

host=10.10.10.11
  • 优点:简单、可诊断。
  • 缺点:节点故障就是入口故障;即使 HAProxy 能路由数据库角色,客户端也到 不了它。

本章 formal run 只验证这种单入口,因此明确保留例外。

DNS

DNS 可以:

  • 把名称指向 VIP/代理;
  • 返回多个 A/AAAA 记录;
  • 在切换时修改地址;
  • 为不同服务提供稳定名称。

但 DNS 不迁移既有 TCP 连接。还要考虑:

authoritative TTL
resolver/client cache
JVM/driver cache policy
negative caching
record order and happy-eyeballs
DNS control-plane availability

“TTL 5 秒”不等于 5 秒内所有应用都换地址。

VIP

VIP 把一个网络地址移动到健康入口。它解决入口地址连续性,不决定数据库主库。

需要证明:

  • 谁持有 VIP,使用什么仲裁;
  • split brain 时是否可能双持;
  • gratuitous ARP/NDP 与交换网络收敛;
  • 跨网段/跨 AZ 是否可达;
  • VIP manager 与 Patroni 状态如何组合;
  • 入口节点的 HAProxy/PgBouncer 是否健康。

数据库主库、VIP owner 和 HAProxy backend 是三个状态机,不能假设天然一致。

客户端多 host

libpq 连接串可以列多个 host:

host=proxy-a,proxy-b
port=5433,5433
target_session_attrs=read-write
connect_timeout=2

客户端按规则尝试,target_session_attrs 拒绝错误角色。这减少单一入口依赖, 但每个应用/驱动的:

  • host 顺序;
  • DNS 展开;
  • 并行或串行尝试;
  • overall deadline;
  • pool 的 address refresh;
  • TLS hostname verification;

都要实测。

专用代理/服务发现

外部 HAProxy、云负载均衡、Kubernetes Service 或服务网格可以提供入口。 仍要验证:

L4 vs L7 protocol handling
idle timeout and TCP keepalive
health check semantics
connection draining
source IP and HBA
TLS termination/passthrough
backend queue
control-plane blast radius

一个完整发现链

application logical name
  -> resolver / service discovery
      -> one or more reachable proxy addresses
          -> role-aware health check
              -> PgBouncer or PostgreSQL destination
                  -> SQL role assertion

每一步都有独立的 stale state:

DNS cache stale
VIP owner stale
HAProxy health stale
PgBouncer backend state stale
application pool holds old TCP connection

切换测试必须穿过整个链,而不是只验证最末端的数据库角色。

target_session_attrs 的位置

它是客户端接受条件:

any        任意 session
read-write 必须可写
read-only  必须只读
primary    不是 hot standby
standby    是 hot standby
prefer-standby 优先 standby,必要时接受其他

具体取值以当前 libpq 官方文档为准。它不能:

  • 限制副本延迟;
  • 保证连接经过或绕过 PgBouncer;
  • 替代 TLS hostname 检查;
  • 让失败事务自动迁移;
  • 识别业务上的“正确集群”。

因此连接串还需要正确的 host、database、user、证书和 cluster identity 治理。

入口验收矩阵

场景 要执行的动作 通过条件
正常主库 从每个入口连接写服务 都选同一可写 leader
正常副本 连接只读服务 只读,成员在允许集合
一个入口故障 停止/隔离入口 客户端转向另一入口
DNS 变化 修改记录并保留旧连接 新连接收敛,旧连接行为已定义
数据库切换 planned role change 入口不变,新连接到新主库
proxy reload 配置校验后 reload 现有/新连接行为符合 draining 合同
stale pool 角色变化后保留 server pool 能检测并安全 refresh
TLS rotation 轮换 CA/cert 新旧窗口与 hostname 验证通过

本章只执行正常四端点和数据库 planned switch。入口节点故障、DNS/VIP 和 TLS 留给生产准入矩阵,不能由一次成功连接替代。

本节检查表

[ ] 每个服务按业务动作命名,而不是按主机命名
[ ] role、freshness、session、path、budget、failure 全部成文
[ ] 写端点有 target role 与 outcome-unknown 策略
[ ] 读端点写清 primary fallback 和 staleness 边界
[ ] 分析端点有资源与 replay 保护
[ ] 管理端点跟随主库但有独立预算/权限
[ ] DNS/VIP/multi-host 的失败域经过测试
[ ] SQL 观察走完整客户端路径
[ ] 角色变化后重新验证池化 session

参考资料


返回本章目录 · 下一节:连接的服务端成本 · 查看全书目录 · 查看索引中心

22.2 连接的服务端成本

PostgreSQL 不是把所有客户端请求放进一个无状态线程池。经典架构中,每个已 认证客户端连接对应一个 backend process:

client TCP/socket
  -> postmaster accepts
      -> one backend process
          -> one session state
              -> zero or one current transaction

因此“连接数”同时占用身份、进程、内存、文件描述符、共享结构与调度能力。 连接池的目标不是让数据库接受无限请求,而是把大量客户端等待放到比 backend 更便宜、更可控的位置。

22.2.1 后端进程、内存、事务与会话状态

一个连接得到什么

backend 建立后持有:

  • OS process、PID、栈和私有地址空间;
  • 与 shared memory 的映射;
  • database、role、application name、client address;
  • session GUC 和 prepared statement;
  • 临时 schema、临时表与 cursor;
  • LISTEN registration;
  • session-level advisory lock;
  • relation/catalog cache;
  • 当前事务、snapshot、lock 与 resource owner;
  • socket buffer、日志上下文和统计状态。

可以观察:

SELECT pid,
       usename,
       datname,
       application_name,
       client_addr,
       backend_start,
       xact_start,
       state,
       wait_event_type,
       wait_event
FROM pg_stat_activity
WHERE backend_type = 'client backend'
ORDER BY backend_start;

state='idle' 只表示当前没有执行 query,不表示连接免费。它仍占用 backend slot 和 process,并保留 session state。

connection、session、transaction、statement

四个边界不能混用:

connection
  一条客户端到 PostgreSQL/PgBouncer 的协议连接

session
  登录后到断开前的逻辑上下文

transaction
  BEGIN/COMMIT 或一个 autocommit statement 的原子边界

statement
  一条 SQL 执行

直连 PostgreSQL 时:

client connection ~= PostgreSQL backend session

事务池时:

client connection A
  transaction 1 -> backend X
  transaction 2 -> backend Y

client connection B
  transaction 1 -> backend X

应用仍觉得自己有一个长连接,但 server session 已不是它的私有状态容器。

私有内存与共享内存

连接成本不能简化成固定的“每连接 10 MB”。大致有:

baseline private process memory
+ catalog/relation cache growth
+ query executor memory
+ sort/hash/work_mem consumers
+ temp_buffers actually touched
+ protocol/result buffers
+ extension/PL runtime state
+ kernel socket/process overhead

work_mem 不是每个连接一次,而可能是每个执行节点、并行 worker 各自一次:

Mquerymemory nodework_mem×workers M_{\text{query}} \approx \sum_{\text{memory node}} work\_mem \times \text{workers}

若 200 个 backend 同时执行多 hash/sort 查询,work_mem=64MB 并不意味着 最多使用 200×64MB200 \times 64\text{MB};实际可能更高。

本章沙箱观察:

max_connections                  500
superuser_reserved_connections    10
reserved_connections               0
work_mem                         64 MB
temp_buffers                      8 MB
max_locks_per_transaction        500

这些是配置事实,不是“500 个并发重查询安全”的证明。

共享结构也随连接上限变化

某些 shared memory 与数组在启动时按:

max_connections
max_prepared_transactions
max_locks_per_transaction
max_worker_processes

等参数估算。提高 max_connections 可能要求重启,并增加:

  • backend/proc array;
  • lock table 的潜在规模;
  • per-backend shared bookkeeping;
  • snapshot、predicate lock 等间接压力。

更隐蔽的是 CPU scheduler:大量 runnable backend 会争抢 CPU 和 cache, 吞吐可能下降而不是上升。

事务状态比连接数更危险

最常见的坏状态是:

idle in transaction

它可能:

  • 保留 snapshot,妨碍 vacuum 清理 dead tuple;
  • 持有 row/table/advisory lock;
  • 阻塞 DDL;
  • 增长 xmin horizon;
  • 长期占用事务池 backend。

观察:

SELECT pid,
       usename,
       now() - xact_start AS xact_age,
       state,
       wait_event_type,
       wait_event,
       left(query, 120) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

护栏:

ALTER ROLE pg36_shop_app IN DATABASE pg36_shop
  SET idle_in_transaction_session_timeout = '30s';

本章沙箱全局值是 600 秒。生产应按 workload 和 role 收紧,不要把长 ETL 与 OLTP 共用一个默认。

建连本身也有成本

一次新 PostgreSQL connection 包含:

TCP + TLS
authentication
postmaster fork/exec or process setup
startup parameters
catalog/role/database initialization
extension/session initialization

若请求每次新建直连,建连成本和认证压力会被放大。连接池复用 server connection,既减少延迟,也把认证、TLS 和 backend churn 从每请求移开。

但复用不能隐藏身份边界:不同 database/user 通常形成不同 server pool, 不同 TLS、startup parameter 和 session feature 也可能阻止复用。

22.2.2 max_connections 不是容量规划答案

ceiling、budget 与 concurrency

区分三个数字:

max_connections
  PostgreSQL 接受的普通 backend slot 上限

connection budget
  分配给应用、运维、复制/扩展和紧急访问的合同

active concurrency
  某时刻真正占用 CPU/I/O/lock 执行工作的事务数

把 ceiling 调高,只改变第一项。

先保留逃生通道

PostgreSQL 有:

superuser_reserved_connections
reserved_connections

达到普通上限后,拥有相应资格的角色仍可连接。PostgreSQL 18 中, reserved_connectionspg_use_reserved_connections 可以为非 superuser 的受控运维角色保留槽。

预算示意:

max_connections                 500
- superuser reserved             10
- platform/monitor/admin          40
- logical replication/maintenance 20
- incident reserve               30
= maximum allocatable app slots 400

但这仍不是建议同时运行 400 条重查询。PgBouncer server pool 应进一步把 active backend 控制在 CPU、I/O 和 workload 能承受的范围。

Little’s Law 先给量级

稳态系统中:

L=λW L = \lambda W

其中:

  • LL:系统内平均并发请求;
  • λ\lambda:每秒到达/完成请求数;
  • WW:平均服务时间。

例如:

2000 transactions/s
average database service time 10 ms

则平均 active database concurrency 约:

2000×0.010=20 2000 \times 0.010 = 20

这不表示 pool 只设 20:要考虑 P95/P99、burst、锁等待、连接抖动和 headroom。 但它说明 500 backend 不一定比 40 更快。

排队不可消灭,只能选择位置

当 arrival rate 超过 service capacity:

application queue
PgBouncer cl_waiting
HAProxy queue
PostgreSQL lock wait
CPU run queue
storage queue

总会有一个地方排队。好的设计让它:

  • 边界明确;
  • 有上限;
  • 能超时;
  • 可观察;
  • 不占用昂贵 backend;
  • 能按租户/服务隔离;
  • 过载时先拒绝低价值工作。

坏的设计把 20,000 个客户端全部变成 PostgreSQL backend,再让 CPU scheduler 充当连接池。

max_connections 提高后的反效果

常见链条:

pool acquire timeout
  -> raise max_connections
      -> more active queries
          -> CPU/cache contention + I/O queue
              -> service time grows
                  -> connections live longer
                      -> pool still exhausted

这是正反馈。正确诊断要看:

arrival rate
transaction service time
active vs idle connection
pool waiting
lock wait
CPU run queue
I/O latency
result size/client consumption
retry amplification

不能只看“连接占了 95%”。

何时真的需要提高上限

可能合理的场景:

  • 大量长期 idle session,活跃并发低,且 session 语义无法池化;
  • 多租户/多 database 需要更多独立最小 pool;
  • 运维、复制、监控预算被明确分离;
  • 经压测证明 backend 增长不破坏延迟/内存;
  • 迁移到更大 CPU/内存并更新所有 guardrail。

变更前至少证明:

memory worst-case reviewed
shared memory/startup impact reviewed
reserved slots preserved
OS pid/fd limits sufficient
pool multiplication calculated
load test latency/throughput acceptable
rollback and restart plan present

22.2.3 应用池、代理池与数据库预算

三层池的乘法

假设:

application replicas            A
pool max per replica             P
deployment versions/environments D

潜在客户端连接:

Cclient=A×P×D C_{\text{client}} = A \times P \times D

如果每个客户端直连 PostgreSQL,这也是潜在 backend 数。

经过 PgBouncer transaction pool 后,server connection 通常按:

database × user × pool configuration × PgBouncer instance

形成。粗略上限:

Cserverpool keys(default_pool_size+reserve_pool_size) C_{\text{server}} \le \sum_{\text{pool keys}} (default\_pool\_size + reserve\_pool\_size)

还要受:

max_db_connections
max_user_connections
global process/FD capacity
PostgreSQL max_connections

约束。

default_pool_size 是每个 pair,不是全局

本章 PgBouncer:

default_pool_size=50
reserve_pool_size=30
max_db_connections=100
max_user_connections=100

若有:

10 database × 5 user

不能理解成“总共 50 条 PostgreSQL connection”。默认 pool size 会对每个 实际活跃 pair 生效,再被 db/user 上限裁剪。

因此 role 爆炸、database-per-tenant 和多 PgBouncer 实例会改变预算。

一个预算例子

业务:

8 app instances
每实例 max client pool 16
突发 worker 4 × pool 8
后台任务 2 × pool 6

潜在客户端:

8×16 + 4×8 + 2×6 = 172

设计 PgBouncer:

pg36_shop_app server pool        32
pg36_shop_worker server pool      8
pg36_shop_report server pool      4
reserve shared/limited            8

数据库预算:

application active backend       52
monitor/exporter                  8
admin/migration                  10
maintenance/extension            10
incident reserve                 20
other databases                  80
headroom                         20
total                           200

数字必须由压测和生产 profile 校准,但计算结构应先存在。

应用 pool 不应等于线程数

每个 HTTP worker 都持一个数据库连接,常造成:

100 app pods × 20 workers = 2000 client connections

如果每次请求只有 5 ms 数据库时间,真实 active concurrency 可能很低。

应用 pool 应由:

  • 每实例数据库并发;
  • acquire deadline;
  • request fan-out;
  • transaction duration;
  • burst headroom;
  • PgBouncer queue 目标;

决定,而不是由 CPU thread 数机械复制。

多层 timeout 要共同设计

如果:

HTTP deadline           2s
application pool wait   5s
PgBouncer query wait    120s
statement_timeout       0

请求已取消后,数据库工作仍可能运行数十秒甚至无限。预算不是只有连接数, 还包括“连接占用多久”。

更合理的关系:

pool acquire < connect < database work < request deadline

具体顺序会因重试和事务而变化,但下游不能普遍比上游 deadline 更长且不传播 取消。

按 workload 分池

不要让所有动作共享一个无差别 pair:

oltp app
background worker
reporting
migration/admin
monitor

独立 role/pool 可以提供:

  • 不同 pool size;
  • 不同 statement/lock/idle timeout;
  • 不同权限;
  • 不同端点;
  • 不同监控标签;
  • 过载时独立降级。

但 role/pool 太多又会造成 pool multiplication。目标是按失败域与资源合同 分组,不是每个微服务随意造一个 50-connection pool。

预算表

消费者 endpoint client 上限 server 上限 active 目标 queue timeout 备注
shop API primary pooled 128 32 24 200 ms 短事务
worker primary pooled 32 8 6 1 s 可退避
catalog read replica pooled 64 12 8 300 ms 允许陈旧
report offline direct/pool 8 4 2 2 s 长查询限额
migration default direct 2 2 1 fail fast 独立身份
monitor/admin direct/private 10 10 low fail fast 保留槽

client 上限 可以大于 server 上限,差值由有界队列吸收。若 arrival 持续 超过 capacity,队列必须超时/拒绝,不能无限累积。

观察预算是否成立

PostgreSQL:

SELECT usename,
       datname,
       state,
       count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY usename, datname, state
ORDER BY count(*) DESC;

PgBouncer:

SHOW POOLS;
SHOW STATS;
SHOW DATABASES;
SHOW USERS;

重点列:

cl_active / cl_waiting
sv_active / sv_idle / sv_login
maxwait / maxwait_us
total_xact_count / total_query_count
avg_xact_time / avg_query_time / avg_wait_time

应用:

pool in-use/idle/waiters
acquire latency and timeout
request deadline/cancel
retry count
database call duration

三个视角必须用同一 service/database/user/application_name 对齐。

本节检查表

[ ] 知道 idle backend 仍然有成本
[ ] active transaction 与 connection 数分开观测
[ ] work_mem 按执行节点/worker 而非连接一次估算
[ ] max_connections 保留运维和事故槽
[ ] 用吞吐×服务时间估算 active concurrency 量级
[ ] 应用实例×pool×版本的乘法已计算
[ ] PgBouncer 按 database/user/instance 的乘法已计算
[ ] queue 有上限、超时和指标
[ ] workload 按资源/失败域分池,不过度碎片化
[ ] load test 同时看 throughput、tail latency 和资源

参考资料


上一节:服务端点的语义 · 返回本章目录 · 下一节:PgBouncer 池化模式 · 查看全书目录 · 查看索引中心

22.3 PgBouncer 池化模式

PgBouncer 的核心价值是把:

many client connections

复用到:

fewer PostgreSQL server connections

复用边界越短,利用率通常越高;但 client session 能拥有的 server state 越少。选择 pool mode 本质上是在效率和会话语义之间选合同。

22.3.1 session、transaction、statement pooling

session pooling

client connects
  -> obtains one server connection when needed
      -> keeps it until client disconnects

特点:

  • client session 稳定绑定同一个 PostgreSQL backend;
  • 大部分 PostgreSQL session feature 可用;
  • server connection 复用发生在客户端 session 之间;
  • 大量长连接会长期占住 server slot,即使 idle。

适合:

  • 依赖 session state 的旧应用;
  • LISTEN/NOTIFY
  • session advisory lock;
  • persistent temp table;
  • 无法修改的 driver/tool;
  • 需要逐 session 安全上下文且已经审查。

它减少建连 churn,却不一定显著减少同时 backend 数。

transaction pooling

client transaction begins
  -> borrows one server connection
      -> transaction ends
          -> returns pool

下一个事务可能得到另一个 backend。优点:

  • idle client 不占 PostgreSQL backend;
  • 短事务 workload 复用率高;
  • 可以在 PgBouncer 处排队;
  • 应用连接数与 database active concurrency 解耦。

代价:

  • arbitrary session state 不能视为 client 私有;
  • backend PID 会变化;
  • session-scoped feature 可能错误、泄漏或失效;
  • driver behavior 必须按协议和版本测试。

Pigsty 默认 pgbouncer_poolmode: transaction,本章 exact 运行也是 transaction mode。

statement pooling

one statement
  -> one server connection
      -> immediately returned

它提供最强复用,也最严格:

  • multi-statement transaction 不可作为一般能力;
  • transaction-level state 都难以保留;
  • 许多应用和 driver 不兼容;
  • 显式 BEGIN 通常会被禁止。

除非 workload 真正是独立 statement 且通过完整测试,不应只为追求更少连接 就使用。

模式比较

能力 session transaction statement
client 稳定绑定 backend 事务期间 单 statement
multi-statement transaction 否/受限
idle client 占 server 常见
arbitrary session SET 通常可 不可依赖 不可依赖
session advisory lock 不可依赖 不可
LISTEN 不可依赖 不可
persistent temp table 风险高 不可
server connection 复用 最高
应用兼容成本 中/高

具体能力矩阵必须以当前 PgBouncer feature map 为准,不能把表格跨版本永久化。

pool mode 是接口版本

从 session 改 transaction,不是性能参数微调,而是 API breaking change:

backend identity changes
session state lifetime changes
prepared behavior changes
cancel path changes
security context risk changes

需要:

  1. inventory/配置 diff;
  2. driver/ORM feature inventory;
  3. integration test;
  4. canary;
  5. pool 与 SQL 双层观察;
  6. rollback;
  7. release note。

长事务会抵消事务池

transaction pool 只有在事务短时才有效:

server pool occupancyarrival rate×transaction duration \text{server pool occupancy} \approx \text{arrival rate} \times \text{transaction duration}

如果应用:

BEGIN
call remote API
wait user input
stream large response
COMMIT

它仍长期独占 backend。优化 pool mode 不能替代缩短事务。

22.3.2 临时表、会话 GUC、监听与咨询锁

会话 GUC:状态跟 backend,不跟 client

危险例子:

SET search_path = tenant_42, public;
COMMIT;

SELECT * FROM orders;

transaction pool 中,第二个事务可能:

  • 落到另一 backend,没有 tenant_42
  • 另一 client 借到第一条 backend,继承 tenant_42
  • 与 PgBouncer tracked parameter 行为交互。

本章正式实验强制两个 backend:

client A, backend 65057: SET search_path=pg_catalog; COMMIT
client B, backend 65057: sees pg_catalog
client A, backend 65058: sees "$user", public

两个失败方向都出现:

state loss     A 不能依赖它
state leakage  B 收到它

安全替代:

BEGIN;
SET LOCAL search_path = tenant_42, public;
SELECT ...;
COMMIT;

或:

  • fully qualified object name;
  • 把 context 作为 SQL 参数;
  • 使用 PgBouncer 明确支持/跟踪的 startup parameter;
  • 为必须 session state 的工作使用 session/direct endpoint。

SET LOCAL 生命周期被限制在事务内,与 transaction pooling 边界一致。

server_reset_query 不能想当然

本章 PgBouncer 配置:

server_reset_query=DISCARD ALL
server_reset_query_always=0
pool_mode=transaction

看到 DISCARD ALL 不能立刻得出“每个 transaction 后一定清理”。具体执行 条件与 pool mode 受 PgBouncer 配置语义约束。本章故意用实验验证,而不是 从配置名推断。

若将 server_reset_query_always=1 作为补救,还要评估:

  • 每事务额外成本;
  • prepared statement 与 cache;
  • extension/session cleanup;
  • 是否真正覆盖所有业务状态;
  • 当前版本行为。

更安全的原则仍是:不要跨事务依赖未声明的 server session state。

临时表

PostgreSQL temporary table 通常属于 session:

CREATE TEMP TABLE staged (...);
INSERT INTO staged ...;
COMMIT;
SELECT * FROM staged;

transaction pool 中后续事务可能到另一 backend,表不存在;另一 client 也 可能得到保留该 temp schema 的 backend。

可选策略:

  • 在一个显式事务内创建、使用并 ON COMMIT DROP
  • 使用普通 staging table + run/tenant key + 权限/清理;
  • 选择 session/direct endpoint;
  • 把计算改成 CTE、unnest、COPY 到受控表;
  • 对 driver/ORM 的隐式 temp table 做集成测试。

即使 ON COMMIT PRESERVE ROWS 在 feature map 中有特定支持描述,也不要把 “某些操作可工作”升级成“temp session semantics 完整保留”。

LISTEN/NOTIFY

LISTEN channel 注册在 PostgreSQL session。transaction pool 释放 backend 后,client 不再稳定拥有那个 registration。

消费者应使用:

  • session pooling;
  • direct endpoint;
  • 专用少量连接;
  • reconnect 后重新 LISTEN
  • 通知丢失后的 durable catch-up。

NOTIFY 不是持久消息队列。断连与切换时要从表/outbox/offset 补齐。

咨询锁

区分:

pg_advisory_lock(...)       -- session level
pg_advisory_xact_lock(...)  -- transaction level

事务池中,优先使用 transaction-level lock,并让整个受保护动作处于同一事务。

session lock 的危险:

client A acquires on backend X
transaction ends, X returns pool
client B gets X and inherits lock ownership
client A gets backend Y and cannot reliably unlock X

部署工具常用 session advisory lock 保证单实例迁移;这类工具应走直连管理 端点,不能在没有验证时经过 transaction pool。

cursor、portal 与 COPY

一般原则:

只要协议对象必须跨 transaction 存活,就怀疑 transaction pooling
  • WITH HOLD cursor 跨事务;
  • 某些 ORM server-side cursor;
  • streaming result;
  • COPY 双向协议;
  • replication protocol;

都要按具体 driver/PgBouncer 版本测试。不要只看 SQL 文本。

安全上下文

尤其危险:

SET app.tenant_id = '42';
SET ROLE tenant_role;

若 RLS policy 或函数依赖这些 session GUC,而 transaction pool 没有可靠 设置/清理,可能形成跨租户泄漏。

更安全:

BEGIN;
SET LOCAL app.tenant_id = '42';
SET LOCAL ROLE tenant_role;
... all protected queries ...
COMMIT;

并:

  • deny-by-default policy;
  • 每事务显式设置;
  • missing/invalid context 立即失败;
  • 注入 backend reassignment 测试;
  • pool 与 security review 联动。

第 23 章会深入该问题。

22.3.3 预备语句支持必须绑定 PgBouncer 与驱动版本

为什么旧结论互相矛盾

常见说法:

transaction pooling 不支持 prepared statements

另一种新说法:

PgBouncer 已支持 prepared statements

两句都过度概括。至少要区分:

protocol-level named prepared statement
SQL text PREPARE / EXECUTE / DEALLOCATE
unnamed statement
client-side statement cache
driver emulation/simple protocol

协议级 prepared statement

PostgreSQL extended query protocol 使用:

Parse -> Bind -> Execute

现代 PgBouncer 在:

max_prepared_statements > 0

时可以跟踪/重写协议级 prepared statement,并在 client 换 backend 时准备 对应 server statement。

但结论必须绑定:

  • PgBouncer version;
  • max_prepared_statements
  • driver version;
  • driver prepare threshold/cache;
  • query string identity;
  • pool mode;
  • failover/reconnect;
  • ORM query mode。

本章 exact matrix:

PgBouncer                   1.25.2
max_prepared_statements     256
psycopg                     3.2.9
prepare_threshold           1
pool mode                   transaction
iterations                  12
server backends             2
correct results             12

实验先在 backend 65171 建立协议 prepared 状态,再占住它,让同一 client 去 65172。所有结果仍正确。这只接受该版本组合。

SQL PREPARE

PREPARE add_one(integer) AS SELECT $1 + 1;
COMMIT;
EXECUTE add_one(41);

这些是普通 SQL text。PgBouncer 不按协议 prepared statement 的方式重写 它们。名称只存在于创建它的 PostgreSQL session。

正式实验:

PREPARE backend       65285
another client holds  65285
EXECUTE backend       65286
result                InvalidSqlStatementName
SQLSTATE              26000

这是正确的负面结果。不能因为协议级测试通过,就允许 SQL PREPARE 跨 transaction。

driver 可能悄悄改变协议

需要检查:

  • simple vs extended query;
  • auto prepare threshold;
  • named vs unnamed statements;
  • statement cache size/lifetime;
  • pooler compatibility option;
  • binary parameter/result;
  • multi-statement batch;
  • connection reset hook。

升级 driver 或 PgBouncer 后,应把同一 compatibility suite 重跑。版本说明 不能替应用自己的 query shape。

prepared statement 的容量成本

max_prepared_statements 也不是免费开关。PgBouncer 要维护映射,PostgreSQL backend 要保存 prepared plan。大量唯一 SQL text、动态注释或 query literal 可能造成:

  • mapping/cache 增长;
  • server-side prepared statement 增长;
  • deallocation churn;
  • generic/custom plan 行为变化;
  • schema change 后 invalidation。

监控并限制 query shape,参数化而不是把值拼进 SQL。

一个兼容性测试矩阵

driver versions       current, previous, candidate
pool mode             direct, session, transaction
prepare behavior      disabled, threshold, forced
query types           scalar, array, COPY, cursor, batch
role change           reconnect, planned switch
schema change         invalidate/reprepare
error cases           timeout, cancel, backend close

输出应写“这个矩阵通过”,而不是“prepared statements 支持”。

22.3.4 池等待、服务时间与背压

SHOW POOLS 是瞬时状态

关键列:

cl_active    正在使用/等待 server 的 client
cl_waiting   等待分配 server 的 client
sv_active    正在服务 client 的 server connection
sv_idle      可立即借出的 server connection
sv_login     正在建立的 server connection
maxwait      最老等待者等待时间
pool_mode    当前 pool 模式

采样一次 cl_waiting=0 不证明没有排队。要:

  • 周期采样;
  • 导出 Prometheus 指标;
  • 记录 acquire/wait histogram;
  • 与应用和 PostgreSQL active backend 对齐。

本章两槽实验

配置在第一个 test/test 客户端连接前临时变为:

default_pool_size=2
reserve_pool_size=0
query_wait_timeout=5

12 个 client 同时执行:

SELECT pg_sleep(0.25), pg_backend_pid();

结果:

completed clients              12
unique backend PIDs             2
maximum sv_active               2
maximum cl_waiting             10
minimum duration          254.451 ms
maximum duration         1519.423 ms

最慢请求大约经历 6 个 250 ms 服务批次。它说明 pool 正在做有界排队,不是 性能 SLO;SSH 采样、调度和连接开销也包含在时间里。

queueing latency

当:

arrival rate < sustainable service rate

短 burst 可以排队后恢复。

当:

arrival rate >= service rate for long enough

队列长度和延迟持续增长。必须:

  • 超时;
  • 拒绝;
  • 降级;
  • 限流;
  • 减少工作;
  • 或增加经过验证的容量。

不能靠无限 max_client_conn 吸收持续过载。

query_wait_timeout

PgBouncer 的 query_wait_timeout 限制 client 等待 server connection 的 时间。超时会断开 client,从而:

  • 释放无限排队;
  • 给应用一个可观察失败;
  • 迫使请求遵守 deadline。

它要小于业务还能接受的剩余 deadline,并与应用 acquire timeout 协调。

过小:

健康短 burst 也被拒绝

过大:

过期请求占队列
上游已经取消,下游还在等
恢复时形成陈旧洪峰

reserve pool

reserve_pool_size 允许等待超过 reserve_pool_timeout 后额外建立 server connection。它适合有限 burst headroom,不是永久绕过预算。

要问:

  • reserve 乘以多少 database/user pair;
  • 多个 PgBouncer instance 的总和;
  • PostgreSQL 是否仍有保留槽;
  • burst 激活时 CPU/I/O 是否安全;
  • reserve 使用是否告警。

背压应向上游传播

一个健康链条:

PgBouncer wait grows
  -> app pool acquire grows
      -> concurrency limiter rejects optional work
          -> HTTP returns retryable overload
              -> client uses bounded jitter/backoff

一个危险链条:

pool timeout
  -> immediate retry × N layers
      -> reconnect storm
          -> more auth/backend pressure
              -> longer timeout

第 22.5 节会把 timeout、breaker 和负载削减放进同一控制面。

恢复配置

实验使用 finally

snapshot 50 / 30 / 1 / 120
override 2 / 0 / 1 / 5
run probes
restore  50 / 30 / 1 / 120
verify exact equality
only then allow switchover

恢复失败是 stop condition,不能“继续看看切换会怎样”。生产变更应通过 Pigsty 声明管理,本章 runtime override 只为低噪声教学实验。

本节检查表

[ ] pool mode 作为接口版本管理
[ ] 长事务不会长期占满 transaction pool
[ ] session GUC 使用 SET LOCAL 或专用端点
[ ] temp table/LISTEN/advisory lock/cursor 逐项盘点
[ ] RLS/tenant context 做 backend reassignment 测试
[ ] prepared 结论绑定 PgBouncer、driver 和配置版本
[ ] SQL PREPARE 与 protocol prepare 分开测试
[ ] cl_waiting、sv_active、maxwait 有指标
[ ] query_wait_timeout 与 request deadline 对齐
[ ] reserve pool 纳入全局预算
[ ] 配置实验有 exact rollback 和 verification

参考资料


上一节:连接的服务端成本 · 返回本章目录 · 下一节:路由与故障切换 · 查看全书目录 · 查看索引中心

22.4 路由与故障切换

高可用控制面决定“谁应当是主库”,服务接入层决定“客户端新连接实际去了 哪里”。二者相关,却不是同一个状态机:

Patroni/DCS       membership, leader, promotion/demotion
HAProxy           health sample, eligible backend, TCP lifecycle
PgBouncer         client/server pool, authentication, session/protocol state
client pool       cached address, existing socket, retry/backoff
PostgreSQL        transaction, commit, recovery/read-only state

故障切换必须让这五层收敛。只看到 Patroni leader 变化,不能宣告应用恢复。

22.4.1 HAProxy 健康检查与角色判断

角色健康接口

Patroni REST API 可以按当前角色返回健康状态。Pigsty 默认 HAProxy service 使用 HTTP health check:

/primary    只选择当前 primary
/replica    选择 replica
/read-only  接受可提供只读的成员

本章渲染配置的核心形态:

listen pg-test-primary
    bind *:5433
    mode tcp
    option httpchk
    http-check send meth OPTIONS uri /primary
    http-check expect status 200
    server pg-test-1 10.10.10.11:6432 check port 8008
    server pg-test-2 10.10.10.12:6432 check port 8008
    server pg-test-3 10.10.10.13:6432 check port 8008

数据流是 TCP 到 6432,健康检查却到 Patroni 8008。不要把 destination port 与 check port 混为一谈。

“UP”表示什么

对这种配置,HAProxy backend UP 表示:

最近若干次 Patroni HTTP 检查满足期望状态

它不直接证明:

  • PostgreSQL 对业务 role 的认证成功;
  • PgBouncer userlist/auth query 正确;
  • pool 有可用 server slot;
  • session 角色属性满足客户端;
  • 数据足够新;
  • SQL 可以提交;
  • TLS hostname/CA 正确。

因此需要 SQL 端到端 probe。

检查节奏和抖动

本章实际 default-server:

inter=2s
fastinter=1s
downinter=2s
rise=3
fall=3
timeout check=3s
slowstart=30s
maxconn=3000
maxqueue=128
on-marked-down shutdown-sessions

粗略地,状态发现时间受:

[ T_{\text{detect}} \approx \text{interval} \times \text{rise/fall}

  • \text{request/timeout jitter} ]

但切换总时间还包括:

Patroni decision/promotion
HAProxy next health samples
old connection close
PgBouncer backend refresh
client reconnect/backoff
application readiness

不能从 fall=3, inter=2s 单独推导应用 RTO。

shutdown-sessions

当 backend 被标记 down,HAProxy 可以关闭其活动 session。这会让客户端尽快 离开旧主库,避免长连接继续停留在错误角色。

代价是:

  • in-flight transaction 中断;
  • client 收到连接错误;
  • commit 结果可能未知;
  • reconnect 同时发生;
  • cancel/cleanup 不一定完成。

它是故障收敛手段,不是透明迁移。

backup 不是注释

本章 replica service:

pg-test-2, pg-test-3 normal
pg-test-1 primary backup

offline service:

pg-test-3 offline normal
pg-test-2 ordinary replica backup

当 normal backend 不可用时,backup 改变 workload 去向。因此降级时可能:

  • 只读流量回到 primary;
  • 分析流量回到普通 replica;
  • 原有容量隔离消失。

告警不能只说“服务仍然可用”,还要指出“服务正在使用备用后端”。

健康检查本身也要保护

检查太宽松:

TCP 端口开 -> 误把错误角色当健康

检查太严格:

非关键依赖抖动 -> 反复摘除健康数据库

角色服务应检查决定路由所需的最小语义。业务 readiness 可以另有端点,避免 把每个业务依赖都塞进数据库角色检查。

从 HAProxy stats 取证

可以通过受控 stats socket/API 观察:

pxname / svname
status
check_status / check_code
lastchg
queue/current sessions
selected backup

不要把 stats credential 或完整配置中的密码写进证据。正式实验只投影服务、 地址、端口、健康路径、backup flag 和安全参数。

22.4.2 旧连接、重连风暴与客户端退避

新连接路由不迁移旧连接

HAProxy 更新 backend 选择后:

new connections -> new eligible backend
existing TCP     -> old backend until closed

旧主库 demote/restart、HAProxy shutdown session 或 PostgreSQL close 才会让 旧连接离开。应用 pool 可能继续把坏 socket 发给请求,直到 validation 或 query 暴露错误。

因此 client pool 需要:

  • borrow 前/失败后的 connection validation;
  • 最大 connection lifetime;
  • idle timeout;
  • broken connection eviction;
  • address/DNS refresh;
  • pool warmup 限速;
  • 切换后的 error budget。

reconnect storm

假设 200 个 app instance,每个 pool 20:

role change
  -> 4000 sockets fail
      -> every worker immediately reconnects
          -> proxy accept/auth/server-login storm

即使数据库已恢复,风暴也可能把它再次压垮。

客户端退避:

dn=min(dmax,d02n)×J d_n = \min(d_{\max}, d_0 2^n) \times J

其中 JJ 是随机 jitter,例如 [0.75, 1.25]

还要:

  • overall request deadline;
  • 最大尝试次数;
  • 每 host connect timeout;
  • circuit breaker;
  • global concurrency limiter;
  • retry budget;
  • readiness 与 background reconnect 分离。

什么可以重试

读:

  • 幂等 SELECT 通常可在新 transaction 重试;
  • 仍要考虑 snapshot、timeout 和 side-effect function。

写:

连接前失败
  较可能没有发送,但仍按 driver stage 判断

执行中/commit 时断开
  结果未知:可能提交,也可能没有

不能把所有 connection error 当作“未提交”,否则重试可能重复扣款、下单或 发券。

安全模式:

INSERT INTO request_log(idempotency_key, ...)
VALUES ($1, ...)
ON CONFLICT (idempotency_key)
DO UPDATE ...
RETURNING ...;

断连后用同一个 token 查询。重试同一个业务动作时,唯一性与状态机必须使其 幂等;不要生成新 token 假装是新动作。

本章 client probe

六个 worker:

short connection per attempt
target_session_attrs=read-write
unique token per logical attempt
0.15 s normal interval
exponential backoff + jitter on failure
24 s total

每个 event 记录:

worker/attempt/token
monotonic start/end
acknowledged or unknown
error class + SQLSTATE, no credential/message
backend/postmaster identity for acknowledged

失败 token 不盲目重发。最终统一查询数据库:

acknowledged_missing
unknown_committed
unknown_absent
duplicate_tokens
unreconciled_unknown

正式结果:

events                  387
acknowledged            339
unknown                  48
unknown committed         0
unknown absent           48
acknowledged missing      0
duplicates                0
unreconciled              0

“48 个 unknown 都 absent”是事后 lookup 结果,不应在异常发生瞬间假设。

probe 不是生产 driver

它使用 Psycopg、短连接和 synthetic INSERT。生产应用还要按实际:

  • driver;
  • pool;
  • ORM;
  • transaction wrapper;
  • retry middleware;
  • service mesh;
  • load balancer;
  • request deadline;

运行矩阵。否则 middleware 可能在你不知道的地方二次重试。

22.4.3 故障切换中的 DNS、连接池和事务失败

一次切换的状态序列

t0   old primary serves writes
t1   switchover/failure decision
t2   old connections interrupted or rejected
t3   candidate promotes
t4   Patroni topology converges
t5   HAProxy health converges
t6   PgBouncer server pool reflects new role
t7   client reconnects after backoff
t8   first acknowledged business write

应用恢复点是 t8,不是 t3

DNS 不处理 transaction

即使 DNS 立即指向新入口:

  • 已解析地址仍在 client cache;
  • 已建立 socket 不重新解析;
  • pool 可能只在耗尽时建新连接;
  • in-flight transaction 已经失败;
  • old primary commit outcome 仍未知。

DNS 是 discovery 层,不是 transaction continuity。

PgBouncer role-state refresh

本章发现:

Patroni              replica/running
PostgreSQL direct    recovery=true, transaction_read_only=true
HAProxy health       backend UP
PgBouncer path       target_session_attrs rejects

对具体 database 执行:

RECONNECT test;

后,新的 server connection 重新发现当前角色,连续属性检查恢复。

正式切换因此在每次 topology 稳定后:

  1. 对三台 PgBouncer 发 RECONNECT test
  2. 等待每条旧 server connection 在安全边界关闭/重建;
  3. 要求 client probe 出现一次新的 acknowledged write;
  4. 把这段时间计入 conservative write gap。

不要把它泛化成“任何切换都必须手工 RECONNECT”。正确结论是:

角色切换后必须端到端验证 pooled path;若 pool 保留错误角色/协议状态, 应有受控、可观测的 refresh 机制。

自动 callback、PgBouncer restart、database reconnect 或 connection lifetime 各有不同 blast radius,需按平台设计。

pool refresh 的风险

RECONNECT database

  • 让对应 database 的 server connection 在释放后重新连接;
  • 不应误操作所有 database;
  • 会增加短时 server login/auth;
  • 可能让等待者暂时增加;
  • 要与 client backoff 协同;
  • 多 PgBouncer 实例必须全覆盖。

正式实验只操作 sandbox test,并记录三成员 action 与刷新后的首笔确认。

正向和回切证据

initial   pg-test-1, timeline 9
forward   pg-test-2, timeline 10
restored  pg-test-1, timeline 11

forward patronictl command       2.774 s
restore patronictl command       2.766 s
forward conservative write gap   6.995 s
restore conservative write gap   8.510 s

为何 command time 小于 write gap:

command return
  != all replicas streaming
  != HAProxy health converged
  != pool refreshed
  != client backoff ended
  != first write committed

8.510 秒是一个 sandbox observation,不是 RTO。

planned switch 不能证明 unplanned failover

本章没有注入:

  • primary process crash;
  • host power loss;
  • network partition;
  • DCS loss;
  • storage stall;
  • split brain;
  • watchdog/fencing failure;
  • proxy entry failure。

planned switch 知道 leader/candidate,成员健康且可协调。unplanned failure 的 检测、仲裁、RPO 和 fencing 风险完全不同。

故障时的 transaction 分类

client 观察 可安全推出 不能推出
connect refused 此次连接未建立 前一请求未提交
read-only error session 角色不满足 集群没有主库
serialization/deadlock 当前事务回滚 可无界立即重试
connection lost during query 结果未知 一定未执行
connection lost during COMMIT 结果未知 一定提交/未提交
unique token already exists 同 token 有结果 业务 payload 必然一致

token 表还要验证 payload hash/状态,防止同 key 被不同请求误用。

切换验收不变量

topology:
  exactly one primary
  expected member set
  replicas streaming
  timeline advances

service:
  write endpoint read-write
  read endpoint read-only/allowed member
  direct endpoint follows primary
  pool state refreshed/observable

client:
  acknowledged rows present
  unknown reconciled
  duplicates zero
  retry/backoff bounded

restore:
  final intended leader
  pool configuration exact
  postflight baseline passes

任何一层失败都不该被“Patroni 已正常”覆盖。

旧主库恢复后的流量

旧主库变成 replica 后:

  • primary health 应摘除它;
  • replica/offline 策略可能纳入它;
  • 原 server connection/session 必须重新评估角色;
  • replay lag 要回到门槛;
  • connection storm 不应阻塞 rewind/rejoin;
  • 监控要区分新的 timeline。

回切会再经历一次完整过程,不是把 timeline 倒回去。本章 9 -> 10 -> 11 证明每次 promotion 都生成新 timeline。

本节检查表

[ ] HAProxy destination 和 Patroni check port 分开理解
[ ] backend UP 不被当成业务 SQL 可用
[ ] rise/fall/interval 不被直接冒充应用 RTO
[ ] backup backend 激活有告警
[ ] old TCP connection 行为已定义
[ ] reconnect 有 jitter、上限、deadline 和 retry budget
[ ] 写入结果未知用 token reconcile
[ ] 角色变化后验证 pooled session 属性
[ ] pool refresh 覆盖所有实例且有首笔恢复证据
[ ] planned 与 unplanned 结论严格分开
[ ] 回切被当成第二次 promotion/timeline

参考资料


上一节:PgBouncer 池化模式 · 返回本章目录 · 下一节:连接预算与过载边界 · 查看全书目录 · 查看索引中心

22.5 连接预算与过载边界

连接治理的目标不是让所有请求最终都能排到数据库,而是:

正常负载低延迟通过
短 burst 在便宜位置有限排队
持续过载尽早拒绝或降级
高价值与控制流保留能力
取消与 deadline 向下传播
恢复时不产生重试洪峰

这需要同时预算 connection、active transaction、queue length、等待时间和 重试。只设置一个 max_connections 没有形成过载边界。

22.5.1 按服务分配连接、并发与队列

先分服务,再分数字

一个数据库集群常同时承担:

critical OLTP writes
interactive reads
background jobs
reporting/ETL
migration/admin
monitoring/backup/control

它们的价值、服务时间和失败策略不同。共享一个 pool 意味着:

报表占满 backend
  -> 下单请求排队
      -> health/readiness 也超时
          -> orchestration 重启更多实例
              -> reconnect storm

按服务分配至少包括:

维度 问题
client connections 应用能保持多少逻辑连接
server connections 最多占多少 PostgreSQL backend
active concurrency 同时执行多少事务/查询
queue length/time 多久后拒绝,最多积压多少
resource CPU、I/O、work_mem、temp、lock
priority 过载时谁先被削减
reserve 谁能在拥塞时进入控制面

connection budget 不是 concurrency budget

transaction pool 可以有:

1000 client connections
40 server connections
20 typical active transactions

三者都合理,只要:

  • client process/FD/TLS 成本可承受;
  • server pool 不超过数据库预算;
  • active workload 经压测;
  • 960 个等待者不会无限停留;
  • deadline 和拒绝策略有效。

把所有实例相加

预算必须按全局 deployment:

blue version
green version
autoscaling maximum
cron workers
manual jobs
disaster-recovery warm instance

例如:

normal 20 pods × pool 8     = 160 clients
deploy overlap another 20   = 160
autoscale headroom 10       =  80
workers 8 × pool 4          =  32
total potential             = 432

若 PgBouncer server pool 是 32,客户端能排队;若直连,则一次 blue/green 发布就可能把 backend 翻倍。

database/user pair 的隔离

PgBouncer pool key 通常包含 database 与 user。可以用:

pg36_shop_app
pg36_shop_worker
pg36_shop_report
pg36_shop_migrate

建立不同预算与权限。

不要无界创建 role:

100 tenants × default_pool_size 20

即使每个 pair 很小,乘法也可能超过 PostgreSQL。可通过:

  • max_db_connections
  • max_user_connections
  • per-database/user override;
  • group role + application context;
  • tenant 分片;
  • idle pool cleanup;

控制。

负载并发门槛

连接池限制的是 backend 数,不直接限制一条连接里 query 的资源。还需要:

  • application concurrency semaphore;
  • job worker count;
  • query governor;
  • statement timeout;
  • role/database resource policy;
  • workload isolation/offline replica。

例如 8 条并行 hash join 可能比 32 条短索引查询更重。server pool size 要按 workload mix 压测。

queue 的接受条件

定义:

Q_max      最大等待者
W_max      最大等待时间
λ          到达率
μ          每 server slot 服务率
c          server slots

长期稳定至少要求:

λ<cμ \lambda < c\mu

否则任何有限 queue 最终都会满。

短 burst 的近似吸收能力:

Qneededburstmax(0,λ(t)cμ(t))dt Q_{\text{needed}} \approx \int_{\text{burst}} \max(0,\lambda(t)-c\mu(t))dt

这不是精确 queueing model,但迫使团队问“burst 多大、持续多久”,而不是把 max queue 随手设成 10,000。

Pigsty/HAProxy/PgBouncer 三处上限

本章渲染配置含:

HAProxy service maxconn          5000
HAProxy per backend maxconn      3000
HAProxy per backend maxqueue      128
PgBouncer max_client_conn       20000
PgBouncer default_pool_size        50
PgBouncer reserve_pool_size        30
PgBouncer max_db_connections      100
PgBouncer max_user_connections    100
PostgreSQL max_connections         500

大数字不表示应使用到它。真正的有效边界是这些限制、活跃 pair 和所有实例 总和的组合。

如果 HAProxy 允许 5000、PgBouncer 允许 20000,而应用 deadline 2 秒, 仍应在更早层用 application concurrency/query wait timeout 拒绝过期工作。

一份分配账本

cluster_backend_ceiling: 500
reserved:
  superuser: 10
  platform_admin_monitor: 40
  incident_control: 20
  maintenance_replication: 30
allocatable_workload: 400
services:
  shop_rw:
    server_pool: 40
    reserve: 8
    active_target: 24
    queue_timeout: 200ms
  shop_ro:
    server_pool_per_replica: 20
    active_target: 12
    queue_timeout: 300ms
  report:
    server_pool: 4
    active_target: 2
    statement_timeout: 10min
  migration:
    direct_connections: 2
headroom:
  unallocated: 100

headroom 不是浪费,而是吸收估算误差、maintenance 和 incident action。

22.5.2 超时层级、取消传播与熔断

一次请求经过多个时钟

user deadline
HTTP/RPC deadline
application pool acquire timeout
DNS/connect/TLS timeout
HAProxy queue/connect timeout
PgBouncer query_wait_timeout
PostgreSQL lock_timeout
PostgreSQL statement_timeout
idle_in_transaction_session_timeout
client read/write socket timeout

若各自独立设置,会出现:

上游 2 秒取消
下游 pool 还等 120 秒
SQL 继续跑 10 分钟
失败后 middleware 再重试

资源在用户离开后继续消耗。

deadline budget

把请求总预算 DD 分解:

D=Tacquire+Tconnect+Tqueue+Tlock+Texecute+Treturn+Tmargin D = T_{\text{acquire}} +T_{\text{connect}} +T_{\text{queue}} +T_{\text{lock}} +T_{\text{execute}} +T_{\text{return}} +T_{\text{margin}}

各层不一定串行,有些重叠;这个式子是设计账本,不是精确 profiler。

例如 2 秒交互请求:

app acquire             150 ms
connect/role check      300 ms
database statement     1200 ms
return/margin           350 ms

PgBouncer query_wait_timeout=120s 显然不匹配该请求。可以按服务 override, 或让应用 acquire/overall deadline 更早终止并正确 cancel。

connect timeout 不是 failover timeout

多 host 串行尝试时:

[ T_{\text{connect worst}} \approx \sum_{\text{host}} T_{\text{connect host}}

  • DNS/TLS/backoff ]

三个 host × 5 秒可能已经超过 request deadline。驱动是否并行尝试、每 host 还是全局 timeout,要按版本确认。

statement_timeout

PostgreSQL 从收到命令开始计时;达到后取消当前 statement。它不一定包含:

  • 应用 pool 等待;
  • PgBouncer queue;
  • DNS/TCP/TLS;
  • 客户端处理结果;
  • 上一次 idle transaction。

按 role/database 设置比一个全局值更实用:

ALTER ROLE pg36_shop_app IN DATABASE pg36_shop
  SET statement_timeout = '3s';

ALTER ROLE pg36_shop_report IN DATABASE pg36_shop
  SET statement_timeout = '10min';

事务级可以:

BEGIN;
SET LOCAL statement_timeout = '800ms';
...
COMMIT;

lock_timeout

它只限制等待 lock 的时间,不限制 query 总执行时间。

DDL/migration 常用:

SET lock_timeout = '1s';
SET statement_timeout = '30min';

意图是:

拿不到锁快速失败,不在生产长队列中等待;
拿到锁后允许受控操作运行。

不要把 lock timeout 设置得比 statement timeout 更长而期待它生效。

idle transaction timeout

idle_in_transaction_session_timeout

切断已开始事务但长期不发 query 的 session。它是 vacuum/lock 保护,不应拿来 清理普通 idle pool connection。

还有普通 idle_session_timeout,但连接池可能把 idle connection 视为资产; 使用前要评估 reconnect churn 和 middleware 兼容。

取消传播

客户端取消 PostgreSQL query 通常需要单独 cancel request/connection path。 经过 pool/proxy 时要验证:

  • cancel 能定位正确 backend;
  • client 已换 backend 后不会 cancel 别人;
  • proxy 是否转发;
  • timeout 后 transaction 是否处于 aborted;
  • connection 是否应丢弃;
  • server query 是否确实停止。

不能只看到 HTTP 499/timeout 就认为数据库工作结束。

熔断器

breaker 保护的是下游与自身:

closed     正常请求
open       快速拒绝,停止放大故障
half-open  少量探针判断恢复

触发信号应比“任意 SQL error”精细:

  • pool acquire timeout;
  • connection/role check failure;
  • sustained queue;
  • downstream saturation;
  • known infrastructure outage。

不要因业务约束错误、syntax error 或唯一冲突打开数据库 breaker。

retry budget

如果原始请求率为 λ\lambda,平均重试 rr 次:

λdownstream=λ(1+r) \lambda_{\text{downstream}} = \lambda(1+r)

故障时 r 往往上升,正好放大最脆弱的下游。为服务定义:

maximum retries per request
maximum retry traffic percentage
backoff+jitter
non-retryable SQLSTATE
outcome-unknown reconciliation

load shedding

在数据库彻底饱和前:

  1. 拒绝可选报表/推荐;
  2. 降低 background worker;
  3. 使用缓存/较旧副本;
  4. 限制 expensive endpoint;
  5. 保留写入/控制面;
  6. 必要时进入只读或功能降级。

load shedding 是业务决策,不应完全交给随机 connection timeout。

22.5.3 为 ch34 的止血动作预留控制点

第 34 章会处理连接风暴、CPU、内存、磁盘和 I/O 过载。本章要提前提供可用 控制点,否则事故中只能粗暴重启。

控制点一:客户端并发

feature flag
rate limiter
per-tenant quota
worker concurrency
queue consumer pause
autoscaler maximum
retry switch

优点:最接近业务价值,能在请求进入数据库前止血。

控制点二:应用 pool

max size
min idle
acquire timeout
connection lifetime
idle timeout
warmup rate
validation

动态缩 pool 可能只影响新借用,不能立即终止 in-flight transaction。变更行为 按具体 driver 验证。

控制点三:PgBouncer

管理 console 可观察/控制:

SHOW POOLS / STATS / CLIENTS / SERVERS
PAUSE / RESUME
DISABLE / ENABLE
RECONNECT
RELOAD
SET changeable_setting
KILL database

这些动作风险不同:

  • PAUSE 等待 server connection 释放,可用于维护;
  • RECONNECT database 刷新 server connection;
  • KILL 更具破坏性;
  • runtime SET 会形成声明漂移;
  • global action blast radius 可能跨服务。

必须有 exact database/instance/role guard 和复位证据。

控制点四:HAProxy

disable/enable server
backend weight
maxconn/maxqueue
drain
health threshold
service removal

把流量移到副本/其他主库前,先确认目标容量与一致性。转移过载往往只是移动 事故。

控制点五:PostgreSQL role/database

ALTER ROLE ... CONNECTION LIMIT ...;
ALTER DATABASE ... ALLOW_CONNECTIONS false;
ALTER ROLE ... SET statement_timeout ...;
ALTER ROLE ... SET work_mem ...;
REVOKE CONNECT ...;
SELECT pg_cancel_backend(...);
SELECT pg_terminate_backend(...);

其中 revoke/terminate 可能影响业务或锁,属于受控事故动作。不要把示例 SQL 做成无 guard 的一键脚本。

控制点六:保留管理路径

过载时最怕 DBA 也连不进去。预留:

  • superuser/reserved connection;
  • 独立 admin role;
  • direct endpoint;
  • source network allowlist;
  • 小而独立的 admin pool;
  • break-glass credential;
  • out-of-band host access。

监控 exporter 也不应完全依赖已满的普通业务池。

每个控制点都要有回滚

事故动作表:

动作 目的 成功证据 副作用 复位
降 worker 减少 arrival queue/DB active 降 backlog 增 分阶段恢复
缩 app pool 限制 client acquire wait 可控 request reject 恢复声明
pool PAUSE 维护/切换 server released client wait RESUME
RECONNECT db 刷新 backend role/path probe 通过 login burst 无持久配置
HAProxy drain 摘除 backend sessions 收敛 容量下降 enable/weight
cancel query 释放资源 query 消失 事务 abort 应用重试
terminate 强制止血 backend 结束 outcome unknown reconcile

“止血成功”不等于“事故解决”。必须保留证据、找根因和复位。

最小过载仪表盘

应用:

request rate/error/latency
pool waiters/acquire timeout
retry rate
in-flight by endpoint

PgBouncer:

cl_active/cl_waiting
sv_active/sv_idle
maxwait
login/error

PostgreSQL:

active/idle in transaction
wait events/locks
CPU/run queue
I/O latency
temp bytes
WAL/replication lag

代理:

backend status/backup use
current sessions/queue
connect errors

端到端 correlation 必须有 service、database、user、application_name 和时间。

本节检查表

[ ] service 按价值、服务时间和失败策略分预算
[ ] client/server/active/queue 四种上限分开
[ ] blue-green/autoscale/worker 乘法已计入
[ ] sustained overload 有拒绝而非无限排队
[ ] timeout 形成一份 deadline budget
[ ] cancel 到 PostgreSQL backend 已实测
[ ] retry 有 SQLSTATE 分类、budget 和 jitter
[ ] optional workload 能独立 shed
[ ] admin/monitor 有保留连接与直连路径
[ ] PgBouncer/HAProxy/PostgreSQL 控制动作有 guard 与复位
[ ] ch34 可以复用这些控制点,而不是事故时发明

参考资料


上一节:路由与故障切换 · 返回本章目录 · 下一节:Pigsty 服务接入层 · 查看全书目录 · 查看索引中心

22.6 Pigsty 服务接入层

Pigsty 不发明 PostgreSQL 的主库、副本或 session 语义。它把:

inventory intent
  -> Patroni role API
      -> HAProxy service
          -> PgBouncer/PostgreSQL destination
              -> DNS/VIP/client service material
                  -> metrics and administration

组合成可交付实现。

理解 Pigsty 服务层的关键不是记命令,而是能把任何观察反向映射到原生组件。

22.6.1 服务定义、角色选择与端口

默认变量

本章参考实现的关键声明形态:

pgbouncer_enabled: true
pgbouncer_port: 6432
pgbouncer_poolmode: transaction
pgbouncer_sslmode: disable

pg_service_provider: ''
pg_default_service_dest: pgbouncer
pg_default_services:
  - { name: primary, port: 5433, dest: default,
      check: /primary, selector: "[]" }
  - { name: replica, port: 5434, dest: default,
      check: /read-only, selector: "[]",
      backup: "[? pg_role == `primary` || pg_role == `offline` ]" }
  - { name: default, port: 5436, dest: postgres,
      check: /primary, selector: "[]" }
  - { name: offline, port: 5438, dest: postgres,
      check: /replica,
      selector: "[? pg_role == `offline` || pg_offline_query ]",
      backup: "[? pg_role == `replica` && !pg_offline_query]" }

版本和自定义配置可能不同。读取实际 inventory、role defaults 与 rendered file,不要把这段当成跨版本常量。

dest

服务 destination 可以表达:

default     使用 pg_default_service_dest
postgres    PostgreSQL pg_port,常见 5432
pgbouncer   PgBouncer pgbouncer_port,常见 6432
number      指定端口

因此:

primary/replica dest=default

在本章 pg_default_service_dest=pgbouncer 时走连接池;如果用户改成 postgres,同一个 5433/5434 就绕过池。

端口名不能替实际路径。

check

check 是 Patroni REST health path:

/primary
/replica
/read-only

HAProxy 对成员的 8008 检查,数据流则去 dest。角色判断来自 Patroni, 不是 HAProxy 解析 PostgreSQL protocol。

selector

selector 从 cluster member inventory 中选普通 backend。

selector: "[]"

表示全集。

offline 示例只选择:

pg_role == offline OR pg_offline_query

这让平台能把特定 replica 标为重查询目标。selector 是期望集合,运行时健康 检查仍可能摘除不合格成员。

backup

backup selector 形成 HAProxy backup server。它决定正常集合不可用时是否 降级。

要把 backup 语义写进服务合同:

  • replica 回 primary 是否允许;
  • offline 回普通 replica 是否允许;
  • backup 激活是否告警;
  • 目标是否有容量;
  • client target_session_attrs 会接受还是拒绝。

pg_service_provider

默认空值通常在每个 PostgreSQL node 上交付 local HAProxy service。

也可指定专用 HAProxy node group。此时要重新设计:

  • provider 高可用;
  • provider 到数据库网络;
  • DNS/VIP/multi-host;
  • config rollout;
  • source IP/HBA;
  • stats/metrics;
  • 故障域。

把 HAProxy 从数据库节点移出,不自动获得入口 HA。

VIP 与 DNS

相关声明包括:

pg_vip_enabled: false
pg_vip_address: 127.0.0.1/24
pg_vip_interface: auto
pg_dns_suffix: ''
pg_dns_target: auto

这是交付入口的机制选择。启用前要按第 22.1.3 节验证网络、仲裁、DNS cache 与证书,不能因为变量存在就宣称通过。

自定义业务服务

可以在 pg_services 添加服务,而不是修改默认列表。例如概念上:

pg_services:
  - name: shop-ro
    port: 5444
    dest: pgbouncer
    check: /read-only
    selector: "[? pg_role == `replica` && !pg_offline_query]"

生产声明还应补:

  • backup/fail-closed;
  • maxconn;
  • balance;
  • options/rise/fall;
  • owner 和用途;
  • TLS/网络;
  • driver endpoint。

不要为每个应用随意开端口;只有语义或资源/失败域不同才需要新服务。

22.6.2 PgBouncer、HAProxy 与数据库的证据链

第一步:声明证据

从 reviewed inventory 提取 secret-free projection:

cluster/member addresses
pg_role/pg_offline_query
pg_services/default services
default destination
PgBouncer mode/port/budget
VIP/DNS/provider
declared users and pgbouncer participation

凭据值不进入报告,只记录:

present
source class
rotation owner

第二步:rendered HAProxy

本章 /etc/haproxy/pg-test-*.cfg 投影:

primary  :5433 -> all members :6432, /primary
replica  :5434 -> members :6432, /read-only, primary backup
default  :5436 -> all members :5432, /primary
offline  :5438 -> pg-test-3 :5432, pg-test-2 backup, /replica

同时核对:

bind/mode/maxconn
balance
health method/path/status
inter/fastinter/downinter
rise/fall
shutdown-sessions
slowstart
backend maxconn/maxqueue
member address/destination/check port/backup

只核对文件 diff 仍不够;进程可能未 reload 或 runtime state 不同。

第三步:HAProxy runtime

从 stats socket/API 看:

frontend OPEN
backend UP/DOWN
health code
last state change
sessions/queue
backup activation

敏感 stats user/password 不输出。使用 local protected socket 比把管理页面凭据 写入脚本更安全。

第四步:PgBouncer config

在 local Unix admin socket:

SHOW CONFIG;
SHOW DATABASES;
SHOW USERS;
SHOW POOLS;
SHOW STATS;

本章 safe projection:

pool_mode=transaction
listen_addr=0.0.0.0
listen_port=6432
max_client_conn=20000
default_pool_size=50
reserve_pool_size=30
reserve_pool_timeout=1
query_wait_timeout=120
max_db_connections=100
max_user_connections=100
max_prepared_statements=256
server_reset_query=DISCARD ALL
server_reset_query_always=0
client_tls_sslmode=disable
unix_socket_dir=/run/postgresql

不要采集:

  • password;
  • SCRAM verifier;
  • auth file 内容;
  • inventory secret;
  • admin credential。

数据库 LOGIN 不等于池化身份已交付

本章开发过程中故意撞到一个重要边界:

CREATE ROLE pg36_ch22_app LOGIN PASSWORD ...
direct PostgreSQL auth works
PgBouncer auth fails

因为本章 Pigsty 默认:

pgbouncer_auth_query: false

PgBouncer authentication surface 由声明式用户清单管理。只有数据库 catalog 里存在 role,不等于 pooler 的 auth file/query 已经认识它。

生产用户应在 Pigsty pg_users 中声明并明确:

- name: pg36_shop_app
  password: <secret reference/material>
  pgbouncer: true

实际字段与 secret workflow 以当前版本文档和组织规范为准。不要手改 userlist.txt 制造不可追踪漂移。

本章 formal run 因此使用既有、Pigsty 已声明的 nonproduction test 用户, 只创建专属 schema/table;脚本永不修改或删除该 role。

若启用 pgbouncer_auth_query,还要评审:

  • auth_user 与查询权限;
  • query 在 replica/primary 的行为;
  • password rotation;
  • role expiration;
  • auth database;
  • failover;
  • secret exposure。

第五步:PostgreSQL 原生状态

对每个 member:

SELECT pg_is_in_recovery(),
       current_setting('transaction_read_only'),
       current_setting('cluster_name'),
       current_setting('port'),
       pg_postmaster_start_time();

并观察连接预算:

SELECT name, setting, unit, source
FROM pg_settings
WHERE name IN (
  'max_connections',
  'superuser_reserved_connections',
  'reserved_connections',
  'idle_in_transaction_session_timeout',
  'statement_timeout',
  'max_locks_per_transaction',
  'work_mem',
  'temp_buffers'
);

pg_postmaster_start_time() 在本章用来把经过 local Unix socket 的 PgBouncer session 映射回具体 member;inet_server_addr() 对 Unix backend 可能为空。

第六步:从 client 走完整路径

每个 service 用真实 database/user:

SELECT pg_is_in_recovery(),
       current_setting('transaction_read_only')::boolean,
       current_setting('cluster_name'),
       current_setting('port')::integer,
       pg_backend_pid(),
       pg_postmaster_start_time();

预期:

service recovery read_only path
primary 5433 false false HAProxy → PgBouncer
replica 5434 true true HAProxy → PgBouncer
default 5436 false false HAProxy → PostgreSQL
offline 5438 true true HAProxy → PostgreSQL

再到每台 PgBouncer SHOW POOLS,证明 pooled endpoint 真正在对应 process 形成了 database/user pool。

第七步:行为证据

配置与角色通过后仍要测:

  • 12-client/2-server queue;
  • backend reassignment;
  • session state 丢失/泄漏;
  • protocol prepared;
  • SQL PREPARE negative;
  • async token visibility;
  • planned switch/reconnect;
  • final config/topology restore。

这才完成从声明到用户体验的链。

证据矩阵

Claim 声明 渲染 runtime SQL/client
5433 主写 service check/dest primary cfg backend status writable
5434 副本优先 backup/selector replica cfg selected pool read-only/member
事务池 pool mode pgbouncer ini SHOW CONFIG/POOLS PID reassignment
2 server cap runtime override N/A sv_active ≤ 2 12 clients complete
prepared 支持 max_prepared SHOW CONFIG two server PID correct protocol results
switch recovery Patroni/service health config topology/pool refresh token reconcile

22.6.3 配置变更、reload 与连接行为验证

不要直接编辑 rendered file

错误流程:

vim /etc/haproxy/pg-test-primary.cfg
systemctl reload haproxy

问题:

  • inventory 不知道;
  • 下次 automation 覆盖;
  • 多节点不一致;
  • review/rollback 不完整;
  • secret/权限可能漂移。

正确流程:

edit reviewed Pigsty declaration
  -> render diff/plan
      -> validate generated config
          -> staged reload
              -> runtime observation
                  -> client behavior test
                      -> commit evidence/rollback

service tag

参考代码中服务生成/reload 由 pg_service 相关 task/tag 管理,典型调用形态:

./pgsql.yml -l pg-test -t pg_service

生产执行前必须按当前 Pigsty 版本查看 help/plan、限定 inventory 和 host。 不要从书中复制命令直接指向未知集群。

render task 会生成 service config,并在 reload 前运行 HAProxy config check。

配置校验

原生检查:

haproxy -f /etc/haproxy/haproxy.cfg -c -q

它证明语法/引用可加载,不证明路由语义正确。

PgBouncer reload:

RELOAD;
SHOW CONFIG;

不是所有配置都支持在线改变;某些需要 reconnect/restart。SHOW CONFIG 的 changeable 列与当前文档共同决定。

reload 与现有连接

必须回答:

old HAProxy process 是否 drain
existing TCP 是否保留
new connections 是否使用新 config
PgBouncer existing client/server pool 是否继承
authentication file rotation 对既有 session 是否影响
pool size 改变对已有 server connection 如何收敛

“reload 成功”不能替代这些答案。

role change 与 config change 是两类变更

config change:

declaration -> render -> reload

role change:

Patroni/DCS -> health convergence -> pool/client refresh

二者可能同时发生,但 rollback 不同。故障切换时不应顺手修改持久配置, 否则难以分辨恢复来自哪项动作。

本章 runtime pool override 只用于实验,并在切换前恢复,正是为了隔离变量。

staged rollout

多入口环境:

  1. 选一个无生产或低流量 provider;
  2. render/check;
  3. reload;
  4. direct health + client probe;
  5. 观察 queue/error/session;
  6. 扩到下一 provider;
  7. 完整端点矩阵;
  8. 保留旧配置与回滚。

若所有 provider 同时 reload,错误配置会同时摧毁入口冗余。

变更后的强制验证

declaration projection equals reviewed intent
rendered files equal expected member/dest/check/backup
all proxy instances loaded intended config
runtime backend states make sense
PgBouncer config/pools within budget
primary endpoint writable
replica/offline endpoint readonly + allowed member
direct endpoint bypasses pool
session/prepared compatibility suite passes
old/new connection behavior matches change plan

如果变更涉及 role/promotion,再执行 pool role-state refresh 检查。

回滚

回滚不是把文件复制回去:

restore declaration
render/check
staged reload
verify runtime
verify client path
close/refresh incompatible existing sessions if needed
record final state

如果数据库 role 已在期间改变,旧 rendered config 的成员角色仍由 health check 动态判断,但 selector/backup/destination 可能不再合适,要重新评审。

secret 与证据

服务变更会接触:

  • inventory password;
  • PgBouncer userlist/verifier;
  • HAProxy stats auth;
  • TLS key;
  • HBA/identity。

证据只保留:

hash/projection/presence
mode/owner
rotation metadata
behavioral result

不要把整个 inventory、auth file 或 config 原文无差别上传。正式 lab 对 临时 credential inventory 要求 mode 0600,使用后删除副本,报告 secret_values_exported=0

本节检查表

[ ] 实际版本的 pg_default_services 已读取
[ ] dest/check/selector/backup 分别解释
[ ] local/dedicated service provider 的失败域明确
[ ] PostgreSQL LOGIN 与 PgBouncer auth delivery 分开验收
[ ] rendered HAProxy 与 runtime stats 都检查
[ ] SHOW CONFIG/POOLS 不导出敏感材料
[ ] client probe 映射到具体 member/role
[ ] 变更来自 inventory,不手改渲染产物
[ ] config check 只是语法门,不是完成条件
[ ] staged reload 验证 old/new connection
[ ] role change 后重新验证 pool state
[ ] rollback 恢复声明、runtime 与 client behavior

参考资料


上一节:连接预算与过载边界 · 返回本章目录 · 下一节:实战:写入、只读与管理三类接入 · 查看全书目录 · 查看索引中心

22.7 实战:写入、只读与管理三类接入

本节把前六节变成一个可重放验收:

baseline gate
  -> declared identity + private service material
      -> four endpoint semantics
          -> two-slot queue
              -> transaction-session counterexample
                  -> prepared-statement matrix
                      -> async visibility sample
                          -> exact pool rollback
                              -> forward/restore planned switch
                                  -> role-aware pool refresh
                                      -> token reconciliation
                                          -> postflight + adversarial review

它在本地 Pigsty nonproduction sandbox 执行 L1/L2 动作。不要把 guard 改掉后 指向生产。

22.7.1 为 pg36_shop 配置端点和连接预算

先写生产设计,后映射 sandbox

pg36_shop 的概念设计:

logical service 用途 role/path session freshness
pg36_shop_rw API/worker 短写事务 primary pooled transaction primary
pg36_shop_ro catalog/非因果读 replica pooled transaction 声明 staleness
pg36_shop_admin migration/诊断 primary direct full session primary
pg36_shop_olap 报表/ETL offline direct/受控 pool workload-specific 可陈旧

本章不创建真实 pg36_shop database,而把它映射到保留沙箱:

logical service    pg36_shop
sandbox database   test
declared user      test, pgbouncer=true
fixture schema     pg36_ch22
fixture table      route_probe

为什么不临时创建一个 LOGIN:

PostgreSQL role exists
  != Pigsty/PgBouncer authentication surface delivered

正式 runner 从 private reviewed Pigsty inventory 读取既有 test credential, 写入 mode 0600 的临时 libpq service file,结束后删除;credential 不打印、 不 hash 到报告、不进入 Git。

生产 identity 应如何声明

生产应在 reviewed Pigsty inventory/secret workflow 中声明:

pg_databases:
  - name: pg36_shop

pg_users:
  - name: pg36_shop_app
    password: <approved secret material/reference>
    pgbouncer: true

字段与 secret 语法按当前 Pigsty 版本确认。还要设置:

  • owner/group role 与 login role 分离;
  • least privilege;
  • connection limit;
  • default privilege;
  • role/database timeout;
  • TLS/HBA;
  • rotation;
  • application_name;
  • direct admin role;
  • PgBouncer per-user/database budget。

不要把书中 placeholder 作为可用 secret。

libpq service file

概念结构:

[pg36-shop-rw]
host=pg36-shop.example
port=5433
dbname=pg36_shop
user=pg36_shop_app
sslmode=verify-full
target_session_attrs=read-write
connect_timeout=2

[pg36-shop-ro]
host=pg36-shop.example
port=5434
dbname=pg36_shop
user=pg36_shop_app
sslmode=verify-full
target_session_attrs=read-only
connect_timeout=2

[pg36-shop-admin]
host=pg36-shop.example
port=5436
dbname=pg36_shop
user=pg36_shop_migrate
sslmode=verify-full
target_session_attrs=read-write
connect_timeout=2

密码应来自 .pgpass、secret manager 或受控 service material。文件权限:

directory 0700
service/pgpass 0600
no symlink
no stdout/log

本章沙箱 PgBouncer client TLS 是 disable,使用 sslmode=prefer 只为匹配 事实,并保留 EX20-CLIENT-PROXY-NO-TLS;生产必须另做 TLS 验收。

预算草案

假设:

API pods                  12
API client pool           12 each
worker pods                6
worker client pool         6 each
read pods                 12
read client pool           8 each
migration/admin            2

客户端上限:

rw clients      12×12 + 6×6 = 180
ro clients      12×8         =  96
admin direct                   =   2

不是 278 个 backend。一个候选 server budget:

rw app/worker server pool       40 + reserve 8
ro per eligible replica         20
admin direct                      2
monitor/platform/incident        separately reserved

需要在生产规模压测后定稿。

fixture 合同

setup.sql 创建:

CREATE SCHEMA pg36_ch22 AUTHORIZATION postgres;

CREATE TABLE pg36_ch22.route_probe (
    run_id         uuid        NOT NULL,
    worker_no      integer     NOT NULL,
    attempt_no     integer     NOT NULL,
    token          text        NOT NULL UNIQUE,
    client_sent_at timestamptz NOT NULL,
    committed_at   timestamptz NOT NULL DEFAULT clock_timestamp(),
    PRIMARY KEY (run_id, worker_no, attempt_no)
);

它验证:

  • existing schema owner/comment;
  • exact columns/type/nullability;
  • primary/unique constraints;
  • declared login safe attributes;
  • grants only USAGE/SELECT/INSERT;
  • role 未被 runner 创建、修改或接管。

fixture 是 synthetic data,drill 不自动删除它。

四端点预期

primary  5433 -> writable pg-test-1 through PgBouncer
replica  5434 -> read-only pg-test-2/3 through PgBouncer
default  5436 -> writable pg-test-1 direct
offline  5438 -> read-only pg-test-3 direct

SQL 同时记录 postmaster start time,与直连三成员的基线映射,解决 PgBouncer local Unix backend 下 inet_server_addr() 可能为空的问题。

22.7.2 验证会话状态、预备语句与只读一致性

风险分级

动作 风险 改动
capture L0 只读快照
verify/review/all L0 重验既有证据
schema setup L1 synthetic schema/table
pool override L1 一个 PgBouncer process runtime 值
queue/session/prepare/visibility L1 synthetic connection/row
planned switch + restore L2 Patroni role/timeline
reset:fixture L3 删除 synthetic schema

all 从不:

创建连接
SET pool config
RECONNECT
写行
切换
删除

preflight

在任何 mutation 前,第 19 章 gate 验证:

exact target pg36-l2-vagrant
Pigsty v4.5.0 declaration
PostgreSQL 18
four distinct hosts
pg-test-1 primary
pg-test-2/3 replicas
required exceptions accepted
production approval false

第 22 章 capture 再验证:

timeline/member/lag
package versions
listeners
four rendered services
all three PgBouncer configs
all three PostgreSQL connection settings

任何 drift 先停。

pool role-state baseline

在第一个应用 probe 前:

RECONNECT test;

在三台 PgBouncer 分别执行,清除上一轮角色周期遗留的服务端连接状态。

这一步来自真实失败发现:

pg-test-2 direct PostgreSQL was read-only
but its pooled target_session_attrs check rejected
RECONNECT test restored repeated checks

它只影响 sandbox teaching database,且是 evidence-bearing action。

临时两槽 pool

先 snapshot:

default_pool_size       50
reserve_pool_size       30
reserve_pool_timeout     1
query_wait_timeout     120

再 runtime SET:

default_pool_size        2
reserve_pool_size        0
reserve_pool_timeout     1
query_wait_timeout       5

为什么 runtime:

  • 让 12-client queue 低噪声可观察;
  • 避免为实验 saturate 50+30;
  • 不修改 rendered file;
  • exact finally rollback。

生产 pool policy 必须回到 Pigsty declaration,不照抄 runtime SET。

endpoint probe

每个 service 连接后:

SELECT pg_is_in_recovery(),
       current_setting('transaction_read_only')::boolean,
       current_setting('cluster_name'),
       current_setting('port')::integer,
       pg_backend_pid(),
       pg_postmaster_start_time();

同时三节点 SHOW POOLS 证明 test/test pooled path。

saturation probe

12 个 client 同时:

SELECT pg_sleep(0.25),
       pg_backend_pid(),
       current_setting('transaction_read_only')::boolean;

管理 console 周期采样:

SHOW POOLS;

验收:

completed=12
max sv_active<=2
max cl_waiting>=1
unique backend PID<=2
all transactions read-write

正式:

max sv_active=2
max cl_waiting=10
unique PID=2
fastest=254.451ms
slowest=1519.423ms

session counterexample

为确定性分配:

  1. RECONNECT test
  2. A 在 backend X SET search_path=pg_catalog,commit;
  3. B 借到 X 并保持 transaction;
  4. A 被迫借 backend Y;
  5. 比较 PID 与 search_path;
  6. 关闭 client,RECONNECT test 清理实验状态。

正式:

A first       PID 65057, pg_catalog
B borrowed    PID 65057, pg_catalog
A reassigned  PID 65058, "$user", public

既证明 state leakage,也证明 state loss。

protocol prepared

Psycopg:

prepare_threshold=1
12 parameterized executions
hold first backend
force same client to second backend

正式:

PIDs        65171, 65172
results     12/12 correct

接受范围:

PgBouncer 1.25.2
max_prepared_statements=256
psycopg 3.2.9
transaction pooling
exact tested query

SQL PREPARE negative

PREPARE pg36_ch22_sql(integer) AS SELECT $1 + 1;
COMMIT;

占住创建 backend,再:

EXECUTE pg36_ch22_sql(41);

正式:

prepared PID  65285
execute PID   65286
SQLSTATE      26000
class         InvalidSqlStatementName

这是必须出现的失败。

replica visibility

写端点插入 unique token,commit 后取得主库 LSN;只读端点轮询 exact token, 记录:

selected member
recovery/read-only
replay LSN
elapsed
polls/connection rejections

正式:

member        pg-test-2
visible       true
delay         11.092 ms
polls         1
rejections    0

验收只要求在 5 秒 sandbox window 内看见,不形成 freshness SLO。

pool rollback gate

以上任一步成功或失败,finally 恢复:

50 / 30 / 1 / 120

读取 SHOW CONFIG exact compare。只有:

restored_before_switch=true

才允许 L2 切换。

22.7.3 注入切换与连接风暴,观察退避和恢复

这里“注入”的边界

本节只执行:

healthy planned switchover
pg-test-1 -> pg-test-2 -> pg-test-1

不注入 process/network/storage/DCS failure。标题中的“连接风暴”是小型 6-worker 重连探针,不是生产规模压力。

exact guards

需要两份 private input:

PG36_CH19_INVENTORY
  第 19 章 exact baseline gate 使用,mode 0600

PG36_CH22_CREDENTIAL_INVENTORY
  包含已声明 test/pgbouncer user credential,mode 0600

在普通环境两者可以来自同一 reviewed inventory 的安全副本。本书 local sandbox 的已部署 v4.5 baseline 与当前工作目录声明版本不同,因此 formal run 明确分离,避免用新声明冒充旧部署。

执行:

export PG36_EVIDENCE_DIR=/absolute/private/path/to/new-empty/ch22-run
export PG36_CH19_INVENTORY=/absolute/private/path/to/baseline.yml
export PG36_CH22_CREDENTIAL_INVENTORY=/absolute/private/path/to/credential.yml

export PG36_CH22_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH22_NONPRODUCTION=true
export PG36_CH22_PRODUCTION_DATA=false
export PG36_CH22_PRODUCTION_TRAFFIC=false
export PG36_CH22_CONFIRM=POOL_ROUTE_SWITCH_AND_RESTORE_CH22

static/labs/ch22/task.sh drill:service

所有值 exact match。output 非空、inventory 缺失/权限错误、topology drift 都会拒绝。

client workload

六个 worker,24 秒:

INSERT INTO pg36_ch22.route_probe
  (run_id, worker_no, attempt_no, token, client_sent_at)
VALUES (...)
RETURNING committed_at, pg_backend_pid(), pg_postmaster_start_time();

每 attempt 短连接,service:

host=10.10.10.11
port=5433
target_session_attrs=read-write
connect_timeout=2

失败:

same token is not blindly resubmitted
outcome recorded unknown
worker uses capped exponential backoff + jitter

forward

exact executor:

patronictl -c /etc/patroni/patroni.yml \
  switchover pg-test \
  --leader pg-test-1 \
  --candidate pg-test-2 \
  --force

完成条件:

pg-test-2 sole primary/running
pg-test-1/3 replica/streaming
all timeline 10
lag within gate

然后三节点:

RECONNECT test;

必须出现一次 refresh 之后的 acknowledged write,才能进入回切。

restore

patronictl ... switchover pg-test \
  --leader pg-test-2 \
  --candidate pg-test-1 \
  --force

完成:

pg-test-1 sole primary/running
pg-test-2/3 replica/streaming
all timeline 11

再次刷新三节点 pool,并要求首笔确认。

pool refresh evidence

正向三成员 action:

pg-test-1  172.318 ms
pg-test-2  276.016 ms
pg-test-3  175.812 ms
first acknowledged after final refresh action  140.283 ms

回切:

pg-test-1  176.102 ms
pg-test-2  167.016 ms
pg-test-3  173.996 ms
first acknowledged after final refresh action  1697.326 ms

这些 action time 只是管理命令耗时;write gap 还包括 topology、health、 server login 和 client backoff。

reconcile

结束后查询本 run 的所有 worker row:

SELECT worker_no, attempt_no, token, committed_at
FROM pg36_ch22.route_probe
WHERE run_id = $1
  AND worker_no > 0;

分类:

acknowledged token -> must exist
unknown token      -> lookup says committed or absent
duplicate token    -> must be zero

正式:

events                        387
acknowledged                  339
unknown                        48
persisted                     339
acknowledged missing            0
unknown committed               0
unknown absent                 48
duplicate                       0
unreconciled                    0
distinct postmaster generations 3

unknown_absent=48 不是失败;它们已被确定分类。若 unknown committed > 0, 也可以通过,只要 token lookup 明确且业务不重复执行。真正不允许的是 unreconciled。

时间口径

forward command                    2.774 s
forward conservative write gap     6.995 s
restore command                    2.766 s
restore conservative write gap     8.510 s
maximum adjacent ack gap           7.653 s

conservative gap:

last ack before action start
  -> first ack after stable topology and pool refresh

它包含 probe interval、connection attempt 和 backoff,不是纯数据库 promotion 时间,也不是 production RTO。

postflight 和反例

第 19 章 postflight 再次通过。十五个 evidence mutation 必须被指定错误码拒绝:

production claim
primary routed read-only
replica routed writable
offline wrong member
sticky session claim
broken protocol prepare
SQL PREPARE cross-backend success
pool server cap exceeded
no waiter observed
pool config not restored
acknowledged write missing
unknown unreconciled
write gap over objective
wrong final leader
degraded source

反例不是额外单元测试装饰,它防止 validator 只检查“文件存在”。

evidence tree

ch22-run/
├── preflight-ch19/
├── drill/
│   ├── before.json
│   ├── endpoint-observations.json
│   ├── fixture.json
│   ├── pool-settings.json
│   ├── pool-saturation.json
│   ├── session-semantics.json
│   ├── prepared-statements.json
│   ├── replica-visibility.json
│   ├── phases/
│   │   ├── pre-switch.json
│   │   ├── after-forward.json
│   │   └── restored.json
│   ├── switch-forward.json
│   ├── switch-restore.json
│   ├── pool-refresh-actions.json
│   ├── client-events.jsonl
│   ├── reconciliation.json
│   ├── after.json
│   ├── drill-manifest.json
│   ├── validation-report.json
│   └── negative-report.json
├── postflight-ch19/
└── review.txt

完整证据含 token 和运行细节,应放 private evidence store,不提交 Git。 仓库只保留 secret-free 聚合 connection-run.json

read-only 重验

export PG36_EVIDENCE_DIR=/absolute/path/to/ch22-run
static/labs/ch22/task.sh all

应输出:

status=review-ok
endpoints=4
pool_active_max=2
waiters_max=10
acknowledged=339
unknown=48
missing=0
duplicates=0
unreconciled=0
counterexamples=15-rejected
production_ch22_gate=pending
mutation=none

reset

reset 与 drill 完全分离:

export PG36_CH22_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH22_NONPRODUCTION=true
export PG36_CH22_PRODUCTION_DATA=false
export PG36_CH22_PRODUCTION_TRAFFIC=false
export PG36_CH22_RESET_CONFIRM=DROP_CH22_SYNTHETIC_SCHEMA_AND_ROLE

static/labs/ch22/task.sh reset:fixture

确认 token 为兼容已发布的实验接口保留旧名称,但当前 reset 只:

terminate application_name like pg36_ch22_% for user test
DROP SCHEMA pg36_ch22 CASCADE
preserve declared role test

它不回滚 timeline、不清理 evidence、不改 pool。删除前仍应阅读脚本并确认 exact target。

失败时保守恢复

脚本:

  • pool override 已开始就尝试恢复 baseline;
  • switch 未开始则不触碰 topology;
  • pg-test-2 是唯一稳定 leader,允许计划切回;
  • topology ambiguous/degraded 时不猜、不 force;
  • 保留 failure manifest;
  • 不自动 drop fixture。

finally 能降低风险,不能替代 operator inspection。

生产准入差距

本章 sandbox contract:

accepted-with-exceptions

生产仍需:

  1. 在 reviewed Pigsty inventory 声明 database/user/service/budget;
  2. 验收 client/server TLS 与证书轮换;
  3. 证明 VIP、DNS 或 multi-host entry failover;
  4. 跑真实 driver/ORM/query-mode matrix;
  5. 在 production-class 资源做容量和 reconnect load test;
  6. 注入 unplanned failure、partial network 与 cancel;
  7. 为每个 replica workload 定义 consistency contract;
  8. 把 pool refresh 自动化、告警化并限定 blast radius;
  9. 把结果纳入 SLO/SOP/change review;
  10. 由业务 owner、安全与平台共同签署。

不要把本章 8.510 秒写进生产 SLO。

本章完成定义

读者应能独立解释并证明:

为什么应用连服务而不是机器
四个端点选择什么角色/路径
异步副本为何无天然 read-your-writes
连接预算如何跨应用/pool/database 相乘
transaction pooling 会丢失/泄漏什么状态
两类 prepared statement 为什么结论不同
SHOW POOLS 如何证明排队和 backend cap
HAProxy health 为什么不等于 SQL 可用
切换后 pool state 为什么必须重验
write outcome unknown 如何 reconcile
何时只能说 sandbox accepted-with-exceptions

若只能背端口和 RECONNECT 命令,本章还没有完成。

参考资料


上一节:Pigsty 服务接入层 · 返回本章目录 · 下一章:固若金汤:认证、授权与数据安全 · 查看全书目录 · 查看索引中心

23 固若金汤:认证、授权与数据安全

数据库安全不是在系统外面再围一堵墙,而是让每一次越过边界都留下可验证的 答案:

谁在连接
  -> 从哪里、通过哪条加密路径
      -> 以哪个 login 通过认证
          -> 当前使用哪个 effective role
              -> 对哪个对象有什么权限
                  -> 哪些行可见、哪些新值可写
                      -> 谁能变更这些规则
                          -> 事件能否被安全地调查和撤销

只检查其中一层会产生危险的“半安全”:

SCRAM 成功              != 网络中的服务器身份正确
TLS 已加密              != 客户端验证了证书名称
HBA 匹配                != 角色有对象权限
GRANT SELECT            != 能看见全部行
RLS 生效                != table owner / superuser 也受约束
密码已经修改            != 旧连接已经断开
PostgreSQL role 已创建   != PgBouncer 已交付该身份
日志很多                != 有完整、受保护、可检索的审计链

本章先建威胁模型,再沿认证、授权、行级安全、密钥和审计一路向内。最后把 第 22 章的 transaction pool 纳入模型:同一个 PostgreSQL backend 会先后 服务不同客户端,因此 session 级安全上下文不仅会“丢失”,还可能泄漏给下一 个租户。

本章目标

完成本章后,你应当能够:

  1. 用资产、主体、入口、信任跨越和失败后果编写数据库威胁模型;
  2. 区分终端用户、应用 login、effective role、object owner 与 break-glass;
  3. pg_hba_file_rules 解释 first-match,而不是凭配置片段猜认证结果;
  4. 区分认证方法、密码存储、传输加密和服务器身份校验;
  5. 使用 SCRAM,理解 channel binding、密码轮换与客户端兼容边界;
  6. 说明 sslmode=requireverify-caverify-full 分别证明什么;
  7. pg_stat_ssl、证书 SAN、HBA 和真实连接共同验收 TLS;
  8. 把 LOGIN、group、owner、migrate、runtime、readonly 角色拆开;
  9. 正确使用 PostgreSQL 16+ membership 的 ADMININHERITSET
  10. 设计 schema/table/sequence/function/default privilege 的最小权限;
  11. 识别 PUBLIC、owner、search_pathSECURITY DEFINER 和预定义高权 角色造成的越权路径;
  12. 为共享表设计 RLS 的 USINGWITH CHECK
  13. 解释 default-deny、permissive OR、restrictive AND 与完整表操作边界;
  14. 使用 FORCE ROW LEVEL SECURITY 约束 owner,并明确 superuser/ BYPASSRLS 仍会绕过;
  15. 通过事务级 SET LOCAL ROLE 和 tenant context 支持 transaction pool;
  16. 复现 session SET 跨客户端泄漏,并证明事务局部状态在提交后消失;
  17. 设计生成、分发、双版本轮换、撤销和应急回收的凭据生命周期;
  18. 区分普通运行日志、对象/会话审计、平台审计和合规证据;
  19. 在 Pigsty 中声明用户、HBA 和接入层,再从渲染产物与运行事实反查;
  20. 对一个环境给出“通过、带例外通过、待整改或拒绝”的诚实安全结论。

前置与后续

前置:

后续:

  • 第 24 章把安全例外、owner、轮换、SOP 和审批纳入治理;
  • 第 25 章把连接、认证失败、角色变更与审计事件接入可观测系统;
  • 第 29、30 章处理复制/迁移/升级中的身份与双版本兼容;
  • 第 31 章把泄露、越权、凭据失陷与取证放进事件响应;
  • 第 32–35 章会再次约束备份、恢复、故障操作和抢救身份。

学习路径

资产与主体
  -> 信任边界和攻击路径
      -> HBA/认证/TLS
          -> login 与 effective role
              -> 对象所有权和最小权限
                  -> RLS 行边界
                      -> transaction-pool 上下文
                          -> secret 生命周期
                              -> 日志、审计和脱敏
                                  -> Pigsty 声明/渲染/运行差异
                                      -> 双租户对抗性验收

顺序很重要。若先写一条 RLS policy、最后才问“tenant id 从哪里来”,就可能 把用户自己提交的 tenant id 原样写入 GUC,得到一套语法正确却可随意越权的 系统。

六层安全证明

要证明的问题 PostgreSQL / Pigsty 证据
暴露面 哪些端口和网络能到达 listener、防火墙/安全组、HAProxy、HBA
传输 对端是谁、链路是否加密 sslmode、CA/SAN、pg_stat_ssl、PgBouncer TLS
认证 login 是谁、凭据是否有效 HBA first-match、SCRAM/cert/外部身份、认证日志
授权 current role 能做什么 role graph、ACL、owner、default privilege
数据 哪些行可见、哪些新值可写 RLS flag、policy、正负测试、FORCE RLS
治理 谁能变更、撤销、调查 inventory、审批、secret manager、audit/retention

这六层不能互相代替。例如 PostgreSQL hostssl 只要求连接使用 TLS;客户端若 选择不校验证书名称,仍可能把密码发给错误的服务器。反过来,verify-full 只能验证连接到证书所代表的服务器,不能证明这个 login 应当读取某个租户。

角色分层

本章采用一个可复用的角色图:

login identity
  ├─ SET TRUE, INHERIT FALSE -> runtime NOLOGIN
  └─ SET TRUE, INHERIT FALSE -> readonly NOLOGIN

migration identity
  -> migrate NOLOGIN
      -> SET TRUE, INHERIT FALSE -> owner NOLOGIN

break-glass
  -> 独立控制;不属于应用正常路径

职责:

角色 LOGIN 主要权限 明确不应拥有
application login 只允许切换到批准的 runtime role owner、DDL、ADMIN OPTION
runtime USAGE + 必要 DML schema CREATE、TRUNCATE、BYPASSRLS
readonly USAGE + SELECT 写入、迁移
migrate 可切换到 owner 日常服务流量、凭据
owner 拥有应用对象和 policy 日常 LOGIN
break-glass 独立 紧急高权动作 无审批、无时限、无审计

PostgreSQL 16 起,membership 自身有 ADMININHERITSET 选项。 本章使用:

GRANT pg36_ch23_runtime TO test
WITH ADMIN FALSE, INHERIT FALSE, SET TRUE;

因此 test 登录后不会隐式得到 runtime 权限,但能在批准的事务中 SET LOCAL ROLE;它也不能把 runtime 身份再授予别人。

租户事务合同

共享表 RLS 的最小请求序列:

BEGIN;
SET LOCAL ROLE pg36_ch23_runtime;
SELECT set_config('app.tenant_id', $1, true);

-- 所有业务 SQL;$1 必须来自已认证、已授权的应用身份映射

COMMIT;

第三个参数 true 表示 transaction-local。提交或回滚之后,角色和 tenant context 都不应继续生效。

表同时使用:

ALTER TABLE pg36_ch23.account ENABLE ROW LEVEL SECURITY;
ALTER TABLE pg36_ch23.account FORCE ROW LEVEL SECURITY;

policy 分开描述读写:

SELECT            USING
INSERT            WITH CHECK
UPDATE            USING + WITH CHECK
owner             USING + WITH CHECK,且 FORCE RLS

USING 回答“旧行能否进入操作”;WITH CHECK 回答“新行版本能否存在”。 只写其中一边,常会允许把一行从本租户改到另一个租户,或者插入不可见数据。

正式实验

target          pg36-l2-vagrant/pg-test
Pigsty          v4.5.0
PostgreSQL      18.6
PgBouncer       1.25.2, transaction mode
database        test
fixture         schema pg36_ch23, two tenants, four synthetic rows
topology        pg-test-1 primary, pg-test-2/3 streaming replicas
timeline        11 before and after

角色与对象:

five synthetic roles             all NOLOGIN after drill
superuser/CREATEDB/CREATEROLE     false
REPLICATION/BYPASSRLS             false
runtime table ACL                 SELECT, INSERT, UPDATE
readonly table ACL                SELECT
schema CREATE for runtime         false
RLS / FORCE RLS                   true / true
policies                           5
tenant row counts                  2 + 2

RLS 观测:

runtime tenant A                  exactly 2 A rows
runtime tenant B                  exactly 2 B rows
missing context                   0 rows
malformed context                 SQLSTATE 22P02
cross-tenant INSERT               SQLSTATE 42501
cross-tenant UPDATE               SQLSTATE 42501
runtime disable RLS               SQLSTATE 42501
runtime CREATE/TRUNCATE           SQLSTATE 42501
readonly INSERT                   SQLSTATE 42501
row_security=off                  SQLSTATE 42501, not a bypass
raw sandbox login                 SQLSTATE 42501
owner without context             0 rows under FORCE RLS
owner with tenant A               2 A rows
superuser break-glass             all 4 rows

连接池反例临时把入口 PgBouncer 从:

default_pool_size=50
reserve_pool_size=30
reserve_pool_timeout=1
query_wait_timeout=120

改为:

default_pool_size=1
reserve_pool_size=0
reserve_pool_timeout=1
query_wait_timeout=15

客户端 A 用 session 级 set_config(..., false) 设置 tenant A;关闭后,客户端 B 在同一个 backend、没有设置 tenant 的情况下仍读到 tenant A 的两行。这是 有意注入的失败,不是支持方式。

刷新 pool 后,四个事务依次在同一个 backend 上得到:

tenant A local context        A 的 2 行
missing context               0 行
tenant B local context        B 的 2 行
missing context               0 行

这证明隔离来自事务边界,而不是恰好换了 backend。随后四个 pool 参数精确 复位,并对三个节点执行 RECONNECT test 清理注入的 session 状态。

TLS 与认证观测:

PostgreSQL ssl                          on
minimum protocol                       TLSv1.2
direct sslmode=require                  TLSv1.3 / AES-256-GCM
direct verify-full                      成功
wrong certificate name                 拒绝
verify-full + channel_binding=require   成功
direct sslmode=disable                  成功,生产缺口
PgBouncer client TLS                    disable
pooled sslmode=disable                  成功
pooled sslmode=require                  拒绝,生产缺口
HBA parser errors                       0
server private-key mode                 0600
certificate SAN                         覆盖各节点 DNS 与 IP
CRL file/directory                      未配置

凭据轮换使用一个不进入 PgBouncer userlist 的 direct-only synthetic role:

secret v1 new connection               成功
pool connection                        失败;身份面未声明
change to secret v2
secret v1 new connection               失败
secret v2 new connection               成功
already-authenticated v1 session       仍可用
ALTER ROLE ... NOLOGIN
new connection                         失败
already-authenticated session          仍可用
final PASSWORD NULL + NOLOGIN          已验证

密码值、SCRAM verifier、raw userlist 和 private key 都没有进入证据。

生产结论

本章 formal sandbox 结论是:

identity/role separation                通过
object ACL                              通过
two-tenant FORCE RLS                    通过
transaction-local pool context          通过
credential lifecycle semantics          通过
public cert and key-mode checks          通过
topology/pool restoration                通过

direct business TLS enforcement          未通过
PgBouncer client TLS                     未通过
CRL/revocation drill                     未完成
client CA distribution/rotation          未完成
pgAudit                                  未安装/未加载
log bind-parameter policy                待整改
production approval                      pending

这不是“Pigsty 不安全”的概括,而是对这一份 dev/test inventory 和运行状态的 精确判断。Pigsty 默认面向可信内网的开发、测试和演示;生产必须依据自己的 威胁模型收紧密码、网络、HBA、证书、审计与 secret 管理。

本章例外

在前四章下卷例外之外,本章保留:

EX23-TRUSTED-INTRANET-NO-TLS
  普通业务 HBA 使用 host + SCRAM;内网明文 TCP 可成功。

EX23-PGBOUNCER-CLIENT-TLS-DISABLED
  池化客户端入口没有 TLS;不能通过生产传输门禁。

EX23-NO-CRL-OR-ROTATION-DRILL
  证书命名正确,但没有执行 CA/cert/CRL 双版本轮换与撤销。

EX23-NO-PGAUDIT
  shared_preload_libraries 没有 pgAudit,扩展也未安装。

EX23-FULL-NONERROR-BIND-PARAMETERS
  log_parameter_max_length=-1;与慢 SQL 日志组合时可能记录完整 bind 值。

EX23-SYNTHETIC-TWO-TENANT
  只有四行合成数据、一个 schema;不能推出复杂产品的 policy 正确。

EX23-MULTIPLEXED-TEST-LOGIN
  为复用已有 PgBouncer 声明,formal lab 用 test 切换 runtime/readonly;
  生产应为 workload 配置独立 login 与 credential。

本章目录

23.1 威胁模型与信任边界

23.2 认证与连接准入

23.3 角色与最小权限

23.4 行级安全与连接池上下文

23.5 密钥、审计与敏感信息

23.6 Pigsty 安全基线

23.7 实战:隔离两个租户

实验入口

task.sh all 只重验既有证据,不登录应用身份、不改 role/password/pool/HBA/ certificate,也不删除 fixture。drill:securityreset:fixture 是两条 完全分离、精确守卫的路径。

参考资料


上一章:四通八达:服务接入、连接池与路由 · 返回下卷导读 · 下一章:纲举目张:SLO、SOP 与组织治理 · 查看全书目录 · 查看索引中心

23.1 威胁模型与信任边界

安全设计的起点不是“打开 TLS”或“创建一个只读用户”,而是回答:

保护什么
防谁做什么
跨过哪条边界
造成什么后果
由哪一层阻止、发现、限制和恢复

没有威胁模型,最小权限就没有“最小”的参照;审计也不知道该记录什么。

本章不要求先写一份几十页的合规文档。对一个数据库服务,先把下面六列填满 就足以发现大部分架构空洞:

资产 主体 入口 不允许的动作 首要控制 验收证据
租户订单 API runtime pooled primary 跨租户读写 ACL + RLS 正负 SQL
模式定义 migration pipeline direct primary 未审批 DDL owner 分离 role graph + log
备份/WAL backup agent repository 未授权读取/删除 专用身份 + 存储策略 restore/audit
凭据 application/deployer secret channel 泄露、长期有效 轮换/撤销 双版本演练
运行日志 operator/SIEM log pipeline 敏感值扩散 脱敏 + ACL config + sample

23.1.1 用户、应用、运维、平台与第三方

一个请求里有不止一个“用户”

典型 API 请求至少包含五种身份:

human end user
  -> application service identity
      -> PostgreSQL session_user
          -> PostgreSQL current_user
              -> business tenant / subject

它们不可互换。

session_user 是连接时通过 PostgreSQL/PgBouncer 认证的 login。current_user 是当前做权限检查的 effective role;SET ROLE 后二者可以不同。终端用户往往 根本没有数据库 login,其身份由应用认证系统维护。tenant 又可能是组织、项目、 账户或数据域,并不一定等于人或数据库角色。

本章实验刻意记录:

SELECT session_user, current_user;

在 runtime 事务中应类似:

session_user = test
current_user = pg36_ch23_runtime

这能证明数据库执行权限被收窄,却不能证明终端用户是谁。后者需要应用把 request id、actor id、授权结果与数据库 transaction 关联到受保护的审计链。

终端用户

终端用户可以被信任去:

  • 提交业务输入;
  • 持有自己的认证因子;
  • 发起自己被授权的动作。

不能被信任去:

  • 声明“我属于 tenant B”后直接控制数据库上下文;
  • 选择 effective database role;
  • 决定查询是否绕过 RLS;
  • 控制审计字段、来源 IP 或 application_name 的安全含义。

因此:

HTTP header X-Tenant-ID
  -> 只能作为一个待校验输入
  -> 应用根据已认证 actor 和授权关系求出 authorized tenant
  -> 再用 bind parameter 写入 transaction-local database context

若直接做:

SET app.tenant_id = request.headers["X-Tenant-ID"]

RLS 只是把越权选择高效地执行了一遍。

应用与批处理

“应用”也不是一个主体。至少拆成:

workload 需要 不需要
API runtime 短事务、必要 DML owner、DDL、TRUNCATE
async worker 特定队列对应的 DML 全库后台权限
read API SELECT 写入
report/ETL 受控只读、资源预算 主写高权
CDC replication/slot 的精确能力 SUPERUSER
migration object owner 或受控 DDL 常驻 serving credential

若它们共用一个 login:

  • 一处泄露扩大到所有能力;
  • 无法按 workload 撤销;
  • 日志难以归因;
  • 连接预算和 timeout 无法分开;
  • 临时授予会悄悄变成永久默认。

本章角色模型先按“能力”拆 NOLOGIN role,再让独立 login 以明确 membership 获得其中一项。实验为了复用已交付的 PgBouncer test 身份,在同一个沙箱 login 上挂 runtime 和 readonly;这是有标签的实验例外,不是生产模板。

运维人员

运维需要的不是“平时就是超级用户”,而是两条路径:

routine operator
  read catalogs / metrics / logs
  run approved bounded procedures
  no arbitrary data access by default

break-glass operator
  time-bound elevation
  ticket + reason + peer/after-the-fact review
  short credential lifetime
  complete action evidence
  explicit revoke

PostgreSQL superuser 可以绕过对象 ACL 和 RLS,访问敏感 catalog,执行服务器 文件/程序相关能力;它是信任根,不是普通管理员的方便模式。

还要区分 OS root、PostgreSQL superuser 和平台控制面:

OS root                    可以读数据目录和进程内存
PostgreSQL superuser       可以绕过数据库权限
Pigsty/Ansible controller  可以改 inventory 并重渲染大量节点
secret administrator       可以改变认证材料
backup administrator       可能读出全量历史数据

把五者授给同一个长期账号,会让数据库内最精细的 GRANT 失去意义。

平台自动化

平台被信任去:

  • 根据受评审声明创建 role/database/HBA/service;
  • 在限定主机和阶段收敛配置;
  • 输出变更记录;
  • 检查 drift;
  • 回收明确属于平台管理的对象。

平台不应被默认信任去:

  • 猜测现有手工对象能否覆盖;
  • 在 production 看到差异就无条件“强制收敛”;
  • 把 secret 展开到日志、diff 或工单;
  • 用一个全局账号服务所有 workload;
  • 把“playbook 成功”当成应用授权语义通过。

声明是 desired state,运行 catalog/HBA/连接实验才是 actual state。二者都要 保留,差异本身就是安全事件或变更线索。

第三方、扩展与外部系统

第三方包括:

  • PostgreSQL extension;
  • 备份/归档存储;
  • APM、日志、SIEM;
  • BI/ETL/CDC;
  • cloud/KMS/secret manager;
  • 外包运维和供应商 support bundle。

每个集成至少回答:

它获得什么数据和 metadata
credential 存在哪里、有效多久
是否能进一步委托
失败时是否 fail open
日志/备份保存在哪里
删除与撤销如何传播
供应链版本如何验证

安装 trusted extension 不等于“其维护者、发行包、依赖和升级以后都可信”。 将数据库日志送往 SaaS 也不自动满足数据驻留和删除要求。第三方边界必须进入 数据流图,而不是写在采购附件里。

主体—能力矩阵

一个评审可从这个矩阵开始:

主体 connect data DML DDL/owner secret backup audit admin
API runtime 必要子集 只读自身
read workload SELECT 只读自身
migration 窗口内 验证所需 受控 短期 产生日志
operator 受控 默认无 SOP 子集 默认无 检查 只读
break-glass 临时 临时 临时 受审批 临时 不得删改
backup agent 专用 只读自身 写仓库
audit collector 专用 只读自身 写不可变目标

不是“所有权限”,每一格还要落到 endpoint、role、ACL、network 和证据。

23.1.2 网络、凭据、SQL、备份和日志攻击面

用数据流而不是组件清单建模

“我们有 PostgreSQL、PgBouncer 和防火墙”不是威胁模型。先画流:

client
  -> DNS / VIP / load balancer
      -> HAProxy
          -> PgBouncer
              -> PostgreSQL primary / replica
                  -> WAL archive / backup repository
                  -> logs / metrics / traces

对每条箭头问:

  1. 谁发起;
  2. 如何认证对端;
  3. 是否加密;
  4. 是否可以重放;
  5. metadata 会泄露什么;
  6. 失败时转向哪里;
  7. 谁能修改路由或信任根;
  8. 证据由谁保存。

第 22 章已经说明代理和池化会改变 session 与故障语义。本章再加一项:它们也 是独立认证面。PostgreSQL role 新建成功,不代表 PgBouncer 的 auth_fileauth_query 或 HBA 已经接受它。

网络攻击面

网络层包括的不只是公开 5432

  • PostgreSQL、PgBouncer、HAProxy 服务端口;
  • Patroni REST API;
  • etcd/DCS;
  • SSH/Ansible;
  • exporter、Grafana、日志和备份端点;
  • DNS、VIP、cloud load balancer;
  • 同机 Unix socket;
  • 容器/overlay 网络和跨区链路。

常见失败:

0.0.0.0 listen + broad security group
intranet CIDR 被当成永久可信主体
TLS 可用但客户端允许降级
证书验证了 CA,却没有验证 hostname
管理面与业务面共用网络和 credential
监控接口可读 SQL 文本、role、database 和拓扑
DCS/API 被暴露后可以影响选主

listen_addresses、主机防火墙、安全组、HBA、代理 ACL 和应用身份是串联控制。 任一层收紧都能缩小暴露面,但不能宣称另一层不再需要。

凭据攻击面

凭据不仅是 PostgreSQL password:

database password / SCRAM verifier
client private key
server private key / CA private key
SSH key / sudo authority
Patroni / etcd / backup credentials
cloud access token
application secret-manager token
session cookie / OAuth token

需要同时保护:

  • 生成时的随机性;
  • 存储位置和文件权限;
  • 注入过程;
  • 进程环境、命令行、core dump;
  • CI 日志、shell history、debug output;
  • 备份和旧版本;
  • 轮换期间的双版本窗口;
  • 撤销后的既有 session。

SCRAM verifier 不是明文,但仍是敏感认证材料。raw PgBouncer userlist、完整 inventory 和 CA private key 不应进入普通 evidence bundle。

SQL 攻击面

SQL 注入只是其中一类:

路径 例子 控制
值注入 拼接用户输入 bind parameter
标识符注入 动态表/schema 名 allowlist + identifier API
search path 同名恶意函数/操作符 受控 path + qualified name
definer 提权 PUBLIC EXECUTE secure path + revoke/grant
owner 提权 runtime 拥有表 owner/login 分离
role 链 ADMIN/SET 过宽 membership options + graph test
RLS 绕过 owner/superuser/BYPASSRLS FORCE + 独立 break-glass
policy 错误 USING 正确、WITH CHECK 缺失 正负 DML 测试
DoS 极端查询、锁、临时文件 timeout + resource governance

安全测试必须包含“有效但不该允许的 SQL”。语法错误只证明 parser 工作,不 证明授权边界正确。

备份和 WAL 攻击面

数据库表做了 RLS,不代表备份按租户隔离。物理备份和 WAL 通常包含整个 cluster 的历史状态:

  • 已删除或更新前的数据可能仍在;
  • credential/catalog 也会进入;
  • repository 管理员可能读到所有租户;
  • retention 超过业务删除期限;
  • object storage versioning 会延长实际寿命;
  • restore 到隔离区后会出现新的明文副本;
  • support bundle 可能携带配置、日志和样本数据。

因此备份安全至少包括:

repository identity
encryption at rest/in transit
key separation
immutable/retention policy
delete/legal-hold semantics
restore sandbox access
evidence cleanup

第 21 章验证的是恢复能力;本章补上谁能读取、删除和恢复。

日志与可观测攻击面

日志既是证据,也是数据外泄渠道。可能出现:

  • SQL literal;
  • extended protocol bind value;
  • error context;
  • connection string;
  • tenant/user/email/order id;
  • DDL 中的 password 或 secret;
  • backup path 和内部地址;
  • application_name 中的用户输入。

监控也会泄露:

  • pg_stat_activity.query
  • query sample;
  • role/database/schema 名;
  • replication/topology;
  • dashboard screenshot;
  • alert payload。

安全目标不是“少记录”,而是:

记录足以调查的 actor/action/resource/outcome/time/correlation
不记录不必要的 secret 和敏感 payload
限制谁能读、改、删
确保时间、完整性、保留和检索可用

可用性也是安全属性

认证和授权控制也能造成拒绝服务:

  • 外部 IdP 不可用导致所有新连接失败;
  • CRL/OCSP 依赖超时;
  • 密码轮换不同步导致连接风暴;
  • HBA 错序锁死管理员;
  • audit 全量记录填满磁盘;
  • RLS policy 中的昂贵子查询放大每次访问;
  • brute-force 占满认证和连接槽。

威胁模型要写 fail-open/fail-closed 和应急路径。不能为了“高可用”悄悄回退 到弱认证,也不能为了“安全”在没有管理恢复入口时一次性切断所有访问。

从攻击路径生成测试

把抽象威胁变成实验:

wrong server name
  -> verify-full connection must fail

client disables TLS
  -> production endpoint must fail;本章 sandbox 反而成功,所以 gate pending

runtime attempts ALTER TABLE
  -> SQLSTATE 42501

tenant A writes tenant B row
  -> WITH CHECK rejects

session tenant context survives pool reuse
  -> reproduce, then replace with transaction-local context

old password after rotation
  -> new connection fails;existing connection remains and must be drained

new PostgreSQL role through pool
  -> absent from pool auth surface, connection fails

每个测试都要记录目标、路径、预期 SQLSTATE/事实、清理和解释边界。

23.1.3 数据分级、租户边界与应急权限

分级决定控制,而不是标签颜色

一个实用分级至少回答:

维度 问题
confidentiality 泄露给谁会造成什么
integrity 被改错/伪造的后果
availability 最长可中断多久
residency 可以存放在哪些区域/供应商
retention 保存多久、何时必须删除
audit 哪些访问和变更必须可追溯
recovery 恢复副本需要什么同等级控制

同一行可以混合不同级别:公开商品名、内部成本、个人地址和支付 token 不应因 都在 orders 表里就采用同一日志/访问策略。

数据库实现可以组合:

  • schema/table/column privilege;
  • view 或 security-invoker API;
  • RLS;
  • application-level field policy;
  • tokenization/encryption;
  • 独立 database/cluster/account;
  • 备份与日志分级。

不要把“加密列”写成万能答案。密钥与数据库若由同一长期高权主体控制,主要 价值可能只是介质或下游暴露面收缩,而不是防数据库管理员。

租户边界的四种常见形态

形态 优点 主要代价/风险
shared table + tenant key/RLS 密度高、统一迁移 policy/上下文错误影响面大
schema per tenant 对象和迁移边界更清晰 对象爆炸、search_path/运维复杂
database per tenant catalog/连接/备份边界更强 连接、升级、监控规模增加
cluster/account per tenant 故障/管理员/资源隔离最强 成本和平台复杂度最高

选择不是“RLS 安全不安全”,而是:

tenant 数量和规模
监管/密钥/驻留要求
故障与 noisy-neighbor 边界
备份/恢复粒度
迁移频率
operator 信任模型
成本

RLS 适合共享表的数据库内 defense-in-depth。它不隔离 shared buffer、CPU、 WAL、backup、superuser,也不自动提供每租户 PITR。

RLS 之外的隐蔽通道

即使行不可见,仍可能通过以下方式推断:

  • unique/foreign-key 冲突;
  • sequence/identity 变化;
  • timing、lock wait、row count;
  • error message;
  • query plan/statistics;
  • aggregate 或 rate limit;
  • log/metric label;
  • object name。

PostgreSQL 的 referential integrity 检查会绕过 RLS 以维护完整性。不要向低权 用户返回“该 email 已被另一个租户使用”之类能够确认全局存在性的细节,除非 这是明确业务合同。

数据边界必须贯穿派生物

租户边界要追到:

primary row
  -> indexes / materialized views / search index
  -> logical replication / CDC
  -> cache
  -> analytics warehouse
  -> backup / PITR restore
  -> logs / traces / support evidence

源表 RLS 不会自动复制到这些系统。每个 consumer 要重新定义 identity、filter、 retention 和删除传播。

应急权限是一套协议

break-glass 至少包含:

触发条件       正常路径不可用且存在明确风险
批准者         谁能批准,单人还是双人
身份           独立账号,禁止共享
时限           自动过期
范围           cluster/database/action/source
证据           ticket/reason/session/action/outcome
约束           禁止删审计、禁止无关数据浏览
退出           revoke/terminate/rotate
复盘           为什么需要、正常能力缺什么

仅把 superuser password 放进保险箱不够。取出后谁知道、已有 session 如何回收、 PgBouncer 是否仍接受、使用了哪些命令、何时换新,都必须可执行。

应急时的优先级

凭据疑似泄露时,一个保守序列:

1. 限制暴露面和新认证
2. 保存时间线、连接、日志与配置证据
3. 判断是 login、role、host、CA 还是控制面失陷
4. 创建/验证替代凭据和管理路径
5. 双版本切换合法客户端
6. NOLOGIN / HBA reject / revoke
7. 终止仍有风险的既有 session
8. 轮换上下游与 PgBouncer
9. 验证旧凭据失败
10. 查找未授权动作并恢复

直接 ALTER ROLE ... PASSWORD 只影响后续认证,不会杀死已认证 session。本章 实验明确证明 password change 和 NOLOGIN 后旧连接仍能执行 SELECT 1

什么时候升级为安全事件

至少这些情况不应作为普通工单悄悄修复:

  • 未知主体获得高权 membership;
  • production 出现 trust/意外 broad HBA;
  • private key、password、SCRAM verifier 进入日志或仓库;
  • RLS/ACL drift 造成跨租户可见;
  • audit pipeline 被停用或删改;
  • backup/restore 落入未批准位置;
  • CA/secret manager/Ansible controller 身份失陷;
  • operator 使用 break-glass 但无批准或证据。

第 31 章会展开事件指挥。本章先保证检测项和回收动作在平时可练。

最小威胁模型模板

service: pg36_shop
asset:
  - tenant orders
  - credentials
  - backups and logs
subjects:
  - api runtime
  - migration pipeline
  - operator
trust_crossings:
  - client -> pooled endpoint
  - login -> runtime role
  - runtime role -> tenant row
abuse_cases:
  - wrong tenant context
  - stolen password
  - session state reused by another client
  - unapproved DDL
controls:
  - verify-full + SCRAM
  - SET LOCAL ROLE
  - FORCE RLS
  - separate owner
  - rotation and session drain
evidence:
  - HBA/TLS/role/policy projections
  - positive and negative transactions
  - immutable audit correlation
owner: data-platform
reviewers: application-owner, security
production_exceptions: []

模板的价值不在 YAML,而在于让每个控制都对应威胁、owner 和可重放证据。

本节检查表

[ ] 列出数据、凭据、备份、日志和控制面资产
[ ] 区分 end user、service identity、session_user、current_user、tenant
[ ] 每个 workload 有独立能力与撤销边界
[ ] OS、database、platform、secret、backup 管理权没有无意合并
[ ] 画出 client 到 database、backup、logs 的每条信任跨越
[ ] 网络位置不被当作充分身份
[ ] SQL、role、owner、RLS 的攻击路径都有负向测试
[ ] 备份、WAL、日志和 support evidence 纳入数据分级
[ ] 租户隔离选择覆盖 backup/recovery/noisy-neighbor
[ ] break-glass 有触发、时限、证据、撤销和复盘
[ ] 凭据失陷流程会处理既有 session 与 PgBouncer
[ ] production exception 有 owner、期限和补偿控制

参考资料


返回本章目录 · 下一节:认证与连接准入 · 查看全书目录 · 查看索引中心

23.2 认证与连接准入

客户端拿到数据库连接之前,至少要连续通过五道关:

network reachability
  -> listener / proxy entry
      -> TLS negotiation and peer verification
          -> HBA first matching record
              -> authentication method
                  -> database CONNECT privilege

这五道关回答的问题不同。防火墙放行不等于 HBA 放行,HBA 选中 scram-sha-256 不等于密码正确,认证成功也不等于角色拥有 CONNECT,更不等于它可以读取业务表。安全评审必须逐层给出证据,不能用 “我连上了”概括全部连接准入。

23.2.1 pg_hba.conf 的匹配顺序与证据

HBA 是有序规则,不是规则集合

PostgreSQL 对一条新连接从上到下检查 HBA:

  1. 找到第一条在连接类型、数据库、用户和来源地址上都匹配的记录;
  2. 使用这条记录指定的方法认证;
  3. 认证失败就拒绝,不会继续尝试后面的记录;
  4. 没有任何记录匹配也会拒绝。

因此下面两段配置语义完全不同:

# A:先拒绝高风险网段,后允许业务网段
host    appdb    +app_login    10.20.30.0/24    reject
hostssl appdb    +app_login    10.20.0.0/16     scram-sha-256
# B:宽规则已经接住连接,后面的 reject 永远不会命中
hostssl appdb    +app_login    10.20.0.0/16     scram-sha-256
host    appdb    +app_login    10.20.30.0/24    reject

HBA 不是防火墙 ACL 的“最具体规则优先”,也没有失败后回退。官方文档明确 规定了 first-match 语义;任何生成器、模板或平台都不能改变这一点。 参见 PostgreSQL:客户端认证配置文件

一条记录匹配哪些维度

常见 record type:

类型 传输条件 典型用途
local Unix-domain socket 节点本地管理或 peer 认证
host TCP,TLS 与非 TLS 都可 仅当两种传输都明确允许
hostssl TCP 且已经建立 TLS 业务和远程管理的常见下限
hostnossl TCP 且未使用 TLS 显式拒绝或受控兼容例外
hostgssenc TCP 且使用 GSS 加密 采用 GSSAPI 的环境

匹配列还包括:

database -> user -> client address -> authentication method/options

几个容易误判的细节:

  • all 很宽,不表示“最末默认规则”;
  • sameusersamerole 等数据库关键字有特定语义;
  • user 列中的 +role_name 匹配该角色的直接或间接成员,而不是匹配字符串 前缀;
  • database 列匹配的是客户端请求的数据库;
  • hostname 规则需要正反向名称解析,延迟和失败模式不同于 CIDR;
  • replication 连接有专门的 database 关键字和权限要求;
  • hostssl 只说明客户端到该 PostgreSQL listener 的这段链路用了 TLS。

如果入口是 PgBouncer,客户端首先连接的是 PgBouncer。此时:

client -> PgBouncer HBA/auth/TLS
PgBouncer -> PostgreSQL HBA/auth/TLS or local socket

这是两条独立的准入链。PostgreSQL 的 pg_hba.conf 不会替 PgBouncer 过滤客户端来源;PgBouncer 的 HBA 也不会自动约束绕过代理、直连 PostgreSQL 的流量。

pg_hba_file_rules 是解析证据

不要只读取模板文件。PostgreSQL 提供 pg_hba_file_rules,可把当前 HBA 文件解析为行:

SELECT
    rule_number,
    file_name,
    line_number,
    type,
    database,
    user_name,
    address,
    netmask,
    auth_method,
    options,
    error
FROM pg_hba_file_rules
ORDER BY rule_number NULLS LAST, file_name, line_number;

它能证明:

  • PostgreSQL 从哪些文件和行解析出规则;
  • include 后的最终顺序;
  • 方法、地址、选项是否符合预期;
  • 是否存在语法或解析错误。

它不能单独证明:

  • 某条规则可从目标网段实际到达;
  • 防火墙、安全组、HAProxy 或 PgBouncer 是否放行;
  • DNS 名称匹配是否如预期;
  • 密码、证书或外部身份提供方是否可用;
  • 连接最后究竟命中了哪条规则。

因此验收还要从允许与禁止的真实来源分别连接,并把时间、目标地址、目标 数据库、login、TLS 属性和结果关联起来。生产系统不应为了测试负例而从未知 公网来源扫描数据库;应使用预先批准的测试节点。

HBA 不负责对象授权

下面的连接可能通过 HBA 和 SCRAM,却仍被数据库拒绝:

REVOKE CONNECT ON DATABASE appdb FROM app_login;

反过来,CONNECT 只是进入数据库:

GRANT CONNECT ON DATABASE appdb TO app_login;

它没有授予:

  • schema 的 USAGE
  • table 的 SELECT
  • sequence 的 USAGE
  • function 的 EXECUTE
  • 切换到某个业务角色的 membership。

这也是为什么 HBA 不能被称为“权限配置”。它选择认证方法并执行连接准入, 对象授权要在 23.3 单独证明。

安全变更顺序

HBA 变更采用“声明—解析—负例—正例—回滚”:

1. 从 inventory / policy source 生成候选配置
2. 检查宽规则、shadowed rule、host/hostssl 与来源 CIDR
3. 在节点上进行语法/解析检查
4. 保留当前管理连接与独立 break-glass 路径
5. reload,不把 reload 误写成 restart
6. 从允许来源做正例,从禁止来源做负例
7. 核对 pg_hba_file_rules 和认证日志
8. 失败则恢复上一份已验证配置并 reload

在 Pigsty 中,应同时检查 PostgreSQL 与 PgBouncer 的 HBA 声明和渲染产物。 23.6 会把这条流程映射到 pg_hba_rulespgb_hba_rules 及默认规则。

23.2.2 SCRAM、证书与外部身份

先区分四件事

“数据库密码安全”常把四个问题混在一起:

问题 典型机制
服务器保存什么 SCRAM verifier、外部身份映射、客户端证书映射
线上如何证明身份 SCRAM exchange、certificate、GSS/SSPI、LDAP、OAuth
链路是否加密 TLS 或 GSS encryption
客户端是否找对服务器 CA chain + hostname/IP identity verification

只把 password_encryption 设为 scram-sha-256,不会自动启用 TLS;只使用 TLS 也不会自动把数据库中旧的 MD5 verifier 变成 SCRAM。

SCRAM 的角色

PostgreSQL 使用 SCRAM-SHA-256 时,服务器保存的是 salted verifier,而不是 可直接用于登录的明文密码。新密码应在:

SHOW password_encryption;

返回 scram-sha-256 的受控环境中设置。不要通过命令行参数、shell history、 CI 日志或 Git 文件传递明文:

bad:  psql postgresql://user:plain-password@host/db
bad:  ALTER ROLE user PASSWORD 'plain-password';  # copied into ticket/log
good: secret manager -> short-lived private file/fd/env contract -> client

环境变量也不是天然的 secret manager:它可能被子进程继承、被诊断工具采集, 或留在流水线元数据中。重点是限制创建、读取、传递和销毁它的主体与时间。

PostgreSQL 18 已将 MD5 密码支持标记为弃用。迁移时可以先把 HBA 目标方法改为 SCRAM-compatible 路径,再逐个重置用户密码生成 SCRAM verifier,并验证所有 驱动。官方迁移说明见 PostgreSQL:密码认证

channel binding

SCRAM channel binding 把认证交换绑定到当前 TLS channel,降低凭据交换被代理 到另一条 TLS 会话的风险。支持它的 libpq 客户端可以要求:

sslmode=verify-full
channel_binding=require

这里两个选项不可互相替代:

verify-full          验证证书链和目标名称
channel_binding      把 SCRAM 认证绑定到已建立的 TLS channel

部署前必须确认驱动版本、TLS 库和中间代理是否支持。不能因为服务端支持 SCRAM 就假设所有客户端都支持 SCRAM-SHA-256-PLUS

本章沙箱的直连实验证明 verify-full + channel_binding=require 可以成功; 这是一条兼容性证据,不代表 PgBouncer 客户端入口也自动具备同样属性。

客户端证书

cert 认证由 TLS 客户端证书证明身份,通常还需用 map= 把证书主体映射到 PostgreSQL role。它适合:

  • 节点间或服务间受管身份;
  • 有成熟 CA、签发、吊销和轮换系统的环境;
  • 不希望长期共享密码的管理链路。

它并不自动适合每个终端用户。必须解决:

  • private key 存放与文件权限;
  • 客户端证书分发;
  • SAN/subject 与 role 的映射;
  • 有效期和轮换重叠期;
  • 离职、设备丢失与 CRL/OCSP;
  • 代理终止 TLS 后如何继续传递可信身份。

如果 PgBouncer 终止客户端 TLS,PostgreSQL 后端看到的是 PgBouncer 的连接, 不能凭空看到原始客户端证书。身份终止点必须在架构图和审计模型中明确。

外部身份不是“无密码”捷径

PostgreSQL 还可以接入 LDAP、GSS/SSPI、PAM、RADIUS、OAuth 等方法,具体可用 范围取决于版本和构建。它们把一部分认证判断交给外部系统,但数据库仍需定义:

external principal -> PostgreSQL login role -> effective business role

评审时要问:

  • 外部主体如何唯一映射,是否会因重名或大小写碰撞映射错误;
  • 身份提供方不可用时是 fail-closed 还是出现旁路;
  • token/ticket 的 audience、issuer、有效期和撤销如何验证;
  • 数据库本地 break-glass 是否独立保管;
  • PgBouncer 是否支持该认证方法,还是需要 auth_query/代理集成;
  • 外部组变化多久才能反映到数据库 session;
  • 已建立 session 在外部身份撤销后何时终止。

身份联邦减少的是一类凭据管理,不会消除 role graph、对象 ACL、RLS 和审计。

PgBouncer 的身份交付面

PgBouncer 需要知道如何验证 client login,并以何种 server identity 连接 PostgreSQL。常见入口包括:

auth_file       PgBouncer 本地认证材料
auth_query      从受控数据库函数/视图获取认证材料
auth_user       执行 auth_query 的受限身份

这意味着“PostgreSQL 中创建了 LOGIN role”并不必然让 PgBouncer 接受该用户。 本章轮换探针刻意创建了一个未进入池认证面的临时 login:

direct PostgreSQL new authentication     succeeds
PgBouncer authentication                 fails

这个负例证明两套身份面必须分别验收。不要为了让探针通过而临时把高权用户 加入 PgBouncer userlist。

23.2.3 TLS 验证、吊销与密钥轮换

sslmode 分别证明什么

libpq 的主要模式可理解为:

sslmode 加密 验证 CA 验证目标名称 适用判断
disable 只用于明确受控的非 TLS 路径
allow 不保证 不保证 不保证 兼容优先,不是生产安全基线
prefer 不保证 不保证 不保证 默认兼容行为,不是证明
require 通常不证明名称 防窃听,不充分防冒充
verify-ca 对端由受信 CA 签发
verify-full 生产客户端通常应达到

require 能加密,但若不核对服务器名称,客户端可能把密码交给持有另一张受信 证书或被错误路由的服务器。生产应用应优先使用 verify-full。libpq 的精确 行为和 root certificate 兼容细节见 PostgreSQL:SSL 支持

名称验证依赖 SAN 和连接名

verify-full 校验的是连接参数中的 host 与证书身份。证书应在 Subject Alternative Name 中声明实际使用的 DNS name 或 IP:

application DSN host=pg-primary.example.com
certificate SAN DNS:pg-primary.example.com

若客户端使用 VIP、HAProxy 名称或 Kubernetes service name,证书必须覆盖这个 稳定入口;只给后端节点名签证书并不能验证入口名称。

本章沙箱节点证书包含 localhost、集群名、节点名、loopback 与节点 IP 的 DNS/IP SAN。正式实验从证书解析 SAN,再执行三种连接:

sslmode=require       success, TLS 1.3
sslmode=verify-full   success with matching name
verify-full wrong name rejected

第三条负例和前两条正例同等重要。没有负例,无法排除客户端根本没有执行名称 校验。

pg_stat_ssl,但不要只看它

服务器端可把 session 与 TLS 属性关联:

SELECT
    a.pid,
    a.usename,
    a.application_name,
    a.client_addr,
    s.ssl,
    s.version,
    s.cipher,
    s.bits,
    s.client_dn,
    s.issuer_dn
FROM pg_stat_activity AS a
JOIN pg_stat_ssl AS s USING (pid)
WHERE a.pid = pg_backend_pid();

正式直连观察到:

ssl       true
version   TLSv1.3
cipher    TLS_AES_256_GCM_SHA384
bits      256

pg_stat_ssl.ssl=true 只证明 PostgreSQL 看到的这一跳使用 TLS。若拓扑是:

client ==TLS==> PgBouncer --Unix socket--> PostgreSQL

PostgreSQL 看到的后端连接自然是 ssl=false,不能据此断言客户端链路未加密。 反之,后端 TLS 为 true 也不能证明 client-to-proxy 使用 TLS。两跳要分别测。

CA、CRL 与 private key

服务端最少要治理:

  • server certificate;
  • server private key;
  • trusted client CA(若验证客户端证书);
  • CRL 或其他撤销机制;
  • 文件 owner、mode 和可读取主体;
  • reload/restart 语义;
  • 到期时间、提前轮换窗口和告警。

PostgreSQL 要求 private key 权限受到严格限制。本章节点上的服务端 key 为 0600。这只是静态权限证据,还需确认备份、配置管理缓存、工单附件和监控 采集器没有复制密钥。

沙箱的 CRL file/dir 未配置,因此不能声称已经具备客户端证书撤销闭环。证书 有效期很长也不等于安全或不安全;必须根据签发自动化、暴露面和撤销能力制定 生命周期,而不是只看 expiry date。

双版本轮换,而不是瞬间替换

CA、服务器证书和客户端证书/密码轮换都应保留重叠窗口:

prepare
  -> distribute trust for old + new
      -> deploy new identity material
          -> reload/reconnect
              -> prove new works
                  -> prove fleet migrated
                      -> revoke old
                          -> prove old fails

对于 CA:

client trust store: old CA + new CA
server certificate: switch old-signed -> new-signed
fleet evidence: all active clients trust and use new chain
client trust store: remove old CA

对于密码:

create new secret version
change database verifier
roll clients to new version
terminate/drain old sessions when policy requires
destroy old secret version

PostgreSQL role 只有一个当前 password verifier,没有天然的“双密码同时有效”。 应用侧重叠通常要借助两个 login、连接池分批切换,或把数据库密码变更与快速 客户端 rollout 精确协调。

凭据撤销不等于 session 撤销

本章做了一个容易被忽略的实验:

1. password v1 建立连接                         success
2. 改为 password v2
3. 使用 v1 建立新连接                           rejected
4. 使用 v2 建立新连接                           success
5. 原来用 v1 建立的 session 继续查询             success
6. ALTER ROLE ... NOLOGIN
7. 新认证                                       rejected
8. 已建立 session 继续查询                       success
9. 最终 PASSWORD NULL,NOLOGIN                  verified

结论是:

credential revocation != session revocation

若处置的是泄露或人员离职,还要:

  • 在应用池和代理层停止新借用;
  • 找出目标 role/session;
  • 评估事务影响后终止连接;
  • rotate downstream secret;
  • 检查复制、备份、日志和导出物;
  • 保存不含秘密的取证证据。

本章沙箱的诚实结论

形式化验收同时发现:

项目 运行事实 结论
PostgreSQL TLS 开启,最低 TLS 1.2 基础能力存在
直连 verify-full 成功,错误名称失败 名称校验可用
SCRAM channel binding 直连成功 该客户端路径兼容
业务 HBA 内网存在 host 规则 非 TLS 直连仍可成功
PgBouncer client TLS 禁用 客户端到池入口不加密
PgBouncer backend 本地 Unix socket 该跳不使用 TLS,符合本地链路事实
CRL 未配置 吊销闭环缺失

所以沙箱适合验证机制,不满足本章定义的生产安全门槛。正确结论不是因为发现 缺口就隐藏实验,而是输出:

mechanism proof        PASS
production security    PENDING remediation

下一节在已经确定 login 身份之后,继续回答 effective role 与对象权限问题。


上一节:威胁模型与信任边界 · 返回本章目录 · 下一节:角色与最小权限 · 查看全书目录 · 查看索引中心

23.3 角色与最小权限

认证确认的是 login identity,授权判断的却是当前 effective role。把应用密码 直接挂在对象 owner 身上,看起来省去了一层 SET ROLE,实际上把“能够连接” 和“能够改变安全边界”绑在了同一个身份上。

本节的目标是建立一条清晰的权限链:

human/workload identity
  -> LOGIN role
      -> explicitly SET approved NOLOGIN role
          -> object ACL
              -> row policy

每一条边都要有理由,每一个高权角色都应尽量不可登录。

23.3.1 login、group、owner 与 runtime role

PostgreSQL 只有 role 这一种主体

PostgreSQL 的 user 和 group 都建立在 role 上:

CREATE ROLE app_login LOGIN;
CREATE ROLE app_runtime NOLOGIN;

CREATE USER 只是默认带 LOGIN 的语法别名。所谓 group role 通常只是 NOLOGIN role,用 membership 聚合权限。

关键属性包括:

LOGIN
SUPERUSER
CREATEDB
CREATEROLE
REPLICATION
BYPASSRLS
CONNECTION LIMIT
VALID UNTIL

正常应用身份通常全部关闭高权属性:

CREATE ROLE app_login
  LOGIN
  NOSUPERUSER NOCREATEDB NOCREATEROLE
  NOINHERIT NOREPLICATION NOBYPASSRLS;

VALID UNTIL 只约束密码认证的有效期,不会让既有 session 自动断开,也不 约束所有外部认证方式。它是凭据控制的一部分,不是完整账户生命周期。

四类角色不要合并

本章使用:

类型 LOGIN 用途 为什么拆开
workload login 认证应用实例 可单独轮换、禁用和归因
runtime 正常 DML 不拥有对象,不做 DDL
readonly 受控查询 与写路径独立授权、撤销
migrate 否或临时身份切入 发布窗口 不进入日常流量
owner 拥有 schema、table、policy 隔离隐式 owner 权力
break-glass 独立 限时应急 不属于应用 role graph

推荐图:

app_login
  ├─ SET TRUE / INHERIT FALSE -> app_runtime
  └─ SET TRUE / INHERIT FALSE -> app_readonly

release identity
  -> app_migrate
      -> SET TRUE / INHERIT FALSE -> app_owner

不推荐:

app_login LOGIN
  -> owns schema
  -> owns tables
  -> can ALTER/DROP policies
  -> credential copied to every app instance

owner 的能力来自所有权,不完全来自 ACL。撤销 table 上的 ALL 不能撤销 owner 的 ALTERDROP、授权和 policy 管理能力。要收回这些能力,必须改变 owner 或改变运行身份。

session_usercurrent_user

连接建立后:

SELECT session_user, current_user, current_role;

初始通常相同。执行:

SET ROLE app_runtime;

之后:

session_user   仍是完成认证的 login
current_user   变为权限检查使用的 effective role
current_role   与 current_user 对应

审计时应保留两者。只记录 current_user=app_runtime 会丢失是哪个 workload login 使用了该能力;只记录 login 又可能误判 SQL 实际以何权限执行。

membership 的三个开关

PostgreSQL 16 起,一条 role membership 有三个独立选项:

GRANT app_runtime TO app_login
WITH ADMIN FALSE, INHERIT FALSE, SET TRUE;

语义:

选项 问题 本章默认
ADMIN member 能否继续授予/撤销该 membership FALSE
INHERIT member 是否自动使用目标角色权限 FALSE
SET member 能否 SET ROLE 到目标角色 TRUE

这种组合要求应用显式进入受控事务:

BEGIN;
SET LOCAL ROLE app_runtime;
-- business statements
COMMIT;

如果 INHERIT TRUE,login 在没有 SET ROLE 时就可能使用 runtime ACL,破坏 “没有声明上下文就失败”的设计。如果 SET FALSE,即便是 member 也不能切换 到该 role。完整语义见 PostgreSQL:角色成员关系SET ROLE

检查 membership 不应只看成员名称:

SELECT
    parent.rolname AS granted_role,
    member.rolname AS member_role,
    m.admin_option,
    m.inherit_option,
    m.set_option
FROM pg_auth_members AS m
JOIN pg_roles AS parent ON parent.oid = m.roleid
JOIN pg_roles AS member ON member.oid = m.member
ORDER BY 1, 2;

还要检查 role 自身属性:

SELECT
    rolname, rolcanlogin, rolsuper, rolcreatedb, rolcreaterole,
    rolreplication, rolbypassrls, rolconnlimit, rolvaliduntil
FROM pg_roles
WHERE rolname LIKE 'app_%'
ORDER BY rolname;

SET LOCAL ROLE 的边界

SET LOCAL 只在事务中有局部效果:

BEGIN;
SET LOCAL ROLE app_runtime;
SELECT current_user;
COMMIT;
SELECT current_user;  -- 回到原 login

它特别适合 transaction pooling,因为角色状态在事务结束时回收。应用不能把 切换角色与业务 SQL 分在两个独立事务里:

transaction A: SET LOCAL ROLE app_runtime; COMMIT
transaction B: business query

第二个事务可能落在不同 backend,且局部角色早已消失。23.4 会把 role 与 tenant context 放进同一个事务合同。

23.3.2 schema、table、sequence、function 权限

权限是一组相互独立的门

一条:

SELECT id FROM app.account;

至少受这些条件影响:

database CONNECT
schema USAGE
table SELECT
column privilege, if table privilege is absent
RLS policy
role membership / ownership / bypass attributes

因此“给了表权限却仍报 permission denied”并不奇怪。应从外到内定位,而不是 直接 GRANT ALL

database 与 schema

database 常见权限:

CONNECT
CREATE
TEMPORARY

schema 常见权限:

USAGE   可以按名称访问 schema 中已获授权对象
CREATE  可以在 schema 中创建对象

runtime 通常只需:

GRANT CONNECT ON DATABASE appdb TO app_runtime;
GRANT USAGE ON SCHEMA app TO app_runtime;
REVOKE CREATE ON SCHEMA app FROM app_runtime;

USAGE 不会自动授予表权限,CREATE 却是一条重要越权路径:若可写 schema 出现在高权函数的 search_path 前部,攻击者可能创建同名函数、operator 或 对象劫持解析。

安全基线通常包括:

REVOKE CREATE ON SCHEMA public FROM PUBLIC;

但执行前要盘点依赖。已有应用可能把 public 当作共享可写工作区,直接撤销 会暴露历史设计问题。先发现、迁移,再收紧。

table 与 column

table 权限主要有:

SELECT INSERT UPDATE DELETE
TRUNCATE REFERENCES TRIGGER
MAINTAIN

不要把 TRUNCATE 当成普通 DELETE。它绕过逐行语义,不触发 ON DELETE trigger,且不受 RLS policy 逐行过滤。runtime 通常不应拥有它。

REFERENCES 允许创建引用约束,TRIGGER 允许在表上创建 trigger,二者也 不属于日常 DML。最小 runtime grant 示例:

GRANT SELECT, INSERT, UPDATE ON TABLE app.account TO app_runtime;
REVOKE DELETE, TRUNCATE, REFERENCES, TRIGGER
ON TABLE app.account FROM app_runtime;

如果只授权部分列:

GRANT SELECT (id, display_name) ON app.account TO reporting_role;

要同时检查 view、function、COPY、returning expression 和新增列的暴露方式。 列级 grant 不是数据脱敏系统。

sequence 不随 table 自动授权

使用 identity/serial 的 INSERT 可能还要访问 sequence:

GRANT USAGE, SELECT ON SEQUENCE app.account_id_seq TO app_runtime;

table 上的权限不会自动扩展到 sequence。常见症状是:

INSERT permission okay
nextval(...) -> permission denied for sequence

USAGE 允许 currval/nextvalSELECT 涉及 currvalUPDATE 可影响 setval。runtime 通常不应随意 setval

function 默认可执行

新 function/procedure 通常会把 EXECUTE 授给 PUBLIC,除非创建者通过 默认权限改变。对安全敏感函数,应在同一事务内创建和撤销:

BEGIN;

CREATE FUNCTION app.rotate_secret(...)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, app_private
AS $function$
...
$function$;

REVOKE ALL ON FUNCTION app.rotate_secret(...) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION app.rotate_secret(...) TO security_operator;

COMMIT;

不要让函数在“已创建但仍对 PUBLIC 开放”的窗口被调用。

SECURITY INVOKER 使用调用者权限,是默认和首选。SECURITY DEFINER 使用 函数 owner 权限,必须:

  • owner 不可登录且不是不必要的 superuser;
  • 固定安全 search_path,把 pg_catalog 和受控 schema 放入;
  • 避免引用可被调用者替换的对象;
  • 撤销 PUBLIC EXECUTE
  • 验证参数、tenant identity 和动态 SQL;
  • 对返回错误与日志进行脱敏;
  • 定期审计 owner 和函数定义。

官方安全写法见 PostgreSQL:CREATE FUNCTION

PUBLIC 是隐式全体角色

每个角色都隐式属于 PUBLIC。审计 ACL 时不能只搜索显式 app_runtime

effective privilege =
  PUBLIC
  + direct grant
  + inherited membership
  + owner rights
  + special attributes / predefined roles

这解释了为什么“ACL 里没有这个用户”不等于没有权限。

用权限函数做行为验收

ACL 文本适合审计来源,has_*_privilege 适合回答结果:

SELECT
    has_database_privilege('app_runtime', 'appdb', 'CONNECT') AS db_connect,
    has_schema_privilege('app_runtime', 'app', 'USAGE') AS schema_usage,
    has_schema_privilege('app_runtime', 'app', 'CREATE') AS schema_create,
    has_table_privilege('app_runtime', 'app.account', 'SELECT') AS can_select,
    has_table_privilege('app_runtime', 'app.account', 'TRUNCATE') AS can_truncate;

二者都不能替代实际负例。例如 has_table_privilege(..., 'SELECT')=true 不会 告诉你 RLS 最终能看到哪些行。

23.3.3 默认权限、所有权迁移与越权路径

default privilege 只影响未来对象

下面语句不是给现有表授权:

ALTER DEFAULT PRIVILEGES
FOR ROLE app_owner
IN SCHEMA app
GRANT SELECT, INSERT, UPDATE ON TABLES TO app_runtime;

它表示:

以后由 app_owner 在 app schema 创建的 table
  -> 自动给 app_runtime 指定权限

三个限定都很重要:

  1. future objects,不追溯现有对象;
  2. creating role 是 app_owner
  3. schema scope 是 app

如果迁移工具实际以 release_login 创建对象,而没有先 SET LOCAL ROLE app_owner,owner 和 default privilege 都可能偏离设计。 角色 membership 的权限不会自动替创建者的默认权限生效。官方细节见 PostgreSQL:ALTER DEFAULT PRIVILEGES

一个完整初始化事务通常同时处理当前和未来对象:

BEGIN;
SET LOCAL ROLE app_owner;

REVOKE CREATE ON SCHEMA public FROM PUBLIC;

GRANT USAGE ON SCHEMA app TO app_runtime, app_readonly;
GRANT SELECT, INSERT, UPDATE
  ON ALL TABLES IN SCHEMA app TO app_runtime;
GRANT SELECT
  ON ALL TABLES IN SCHEMA app TO app_readonly;
GRANT USAGE, SELECT
  ON ALL SEQUENCES IN SCHEMA app TO app_runtime;

ALTER DEFAULT PRIVILEGES IN SCHEMA app
  GRANT SELECT, INSERT, UPDATE ON TABLES TO app_runtime;
ALTER DEFAULT PRIVILEGES IN SCHEMA app
  GRANT SELECT ON TABLES TO app_readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA app
  GRANT USAGE, SELECT ON SEQUENCES TO app_runtime;
ALTER DEFAULT PRIVILEGES IN SCHEMA app
  REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;

COMMIT;

实际语句需按应用操作矩阵裁剪,不能机械复制。

所有权迁移是安全迁移

把一个旧 login 改为 NOLOGIN 之前,要盘点它拥有的对象:

SELECT
    n.nspname,
    c.relname,
    c.relkind
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
JOIN pg_roles AS r ON r.oid = c.relowner
WHERE r.rolname = 'legacy_app'
ORDER BY 1, 2;

还要覆盖:

database, schema
table, sequence, view, materialized view
function, procedure
type, domain
publication/subscription
large object
default privileges
extension-owned dependencies

REASSIGN OWNED BY legacy_app TO app_owner 只作用于当前 database 中的对象, 其他数据库要分别执行。DROP OWNED 会撤销 grant、并可能删除对象,是破坏性 动作,不能拿来“顺手清理”生产账号。

推荐迁移:

inventory all databases
  -> create NOLOGIN owner
      -> transfer ownership in a reviewed change
          -> recreate/verify default privileges
              -> run positive and negative tests
                  -> stop old workload
                      -> NOLOGIN + PASSWORD NULL
                          -> terminate old sessions if required

常见越权路径

最小权限评审至少检查:

路径 风险
SUPERUSER / BYPASSRLS 绕过大多数数据库内控制
CREATEROLE / membership ADMIN 扩展角色图
owner login 日常凭据可改变对象和 policy
INHERIT TRUE 未显式进入业务角色也能使用其 ACL
writable search_path schema 对象名称劫持
SECURITY DEFINER + PUBLIC EXECUTE 以 owner 权限执行攻击输入
table owner without FORCE RLS owner 默认绕过 RLS
pg_read_all_data / pg_write_all_data 跨 schema 广泛读写
pg_read_server_files 读取数据库服务器可见文件
pg_write_server_files 写入服务器文件
pg_execute_server_program 执行服务器程序
extension install/control 引入高权代码
untrusted procedural language 数据库进程内执行不受信代码

预定义角色是方便的能力包,不是低风险标签。它们随版本演进,升级评审必须 重新阅读目标版本的 预定义角色说明

本章权限矩阵

正式实验收敛到:

行为 raw login runtime readonly owner break-glass
schema USAGE owner
schema CREATE
table SELECT owner
INSERT/UPDATE owner
DELETE/TRUNCATE owner 能力
管理 RLS policy
绕过 RLS FORCE 后否

五个 synthetic role 在演练结束时全部:

NOLOGIN
NOSUPERUSER
NOCREATEDB
NOCREATEROLE
NOREPLICATION
NOBYPASSRLS

负例实际得到:

raw login SELECT table       SQLSTATE 42501
runtime CREATE               SQLSTATE 42501
runtime TRUNCATE             SQLSTATE 42501
readonly INSERT              SQLSTATE 42501

这比一张手工填写的权限表更强,因为它同时证明“应该成功的能成功”和“不该 成功的确实失败”。下一节再把 table ACL 与 RLS 行边界组合起来。


上一节:认证与连接准入 · 返回本章目录 · 下一节:行级安全与连接池上下文 · 查看全书目录 · 查看索引中心

23.4 行级安全与连接池上下文

共享表多租户系统最常见的事故不是 SQL 不会写,而是某一条 SQL 忘了写:

WHERE tenant_id = $tenant

RLS(Row-Level Security)把这个条件从每条业务 SQL 下沉为表级策略。但这 只是第一步。若 $tenant 来自用户可篡改的请求字段,或者作为 session 状态 残留在 transaction pool 的 backend 上,policy 本身完全正确,系统仍会越权。

安全链必须完整:

authenticated end-user/workload
  -> authorized tenant mapping
      -> transaction-local database context
          -> effective role
              -> table ACL
                  -> RLS USING / WITH CHECK
                      -> positive + negative + reuse tests

23.4.1 RLS policy、owner bypass 与强制 RLS

RLS 是 ACL 之后的行过滤

RLS 不替代普通权限。一次查询要先有 schema/table 权限,再由 policy 决定 哪些行可见:

table SELECT denied      -> permission denied
table SELECT allowed
  + RLS policy true      -> row visible
  + RLS policy false     -> row silently absent

启用:

ALTER TABLE app.account ENABLE ROW LEVEL SECURITY;

如果没有适用于当前 command/role 的 policy,PostgreSQL 使用 default-deny:

0 visible rows / no modifiable rows

这是一项很有价值的 fail-closed 属性。但如果 RLS 根本没有启用,已经创建的 policy 不会生效。验收要同时检查:

SELECT
    n.nspname,
    c.relname,
    c.relrowsecurity,
    c.relforcerowsecurity
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.oid = 'app.account'::regclass;

以及:

SELECT
    policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'app'
  AND tablename = 'account'
ORDER BY policyname;

USING 看旧行,WITH CHECK 看新行

四类 DML 的核心语义:

command 旧行可见/可操作 新行可写入
SELECT USING 不适用
INSERT 不适用 WITH CHECK
UPDATE USING WITH CHECK
DELETE USING 不适用

多租户 UPDATE 必须约束两边:

CREATE POLICY account_runtime_update
ON app.account
FOR UPDATE
TO app_runtime
USING (
    tenant_id = app.current_tenant()
)
WITH CHECK (
    tenant_id = app.current_tenant()
);

只写 USING 容易忽略“修改后的行能否移到另一个租户”;只考虑 WITH CHECK 又没有明确旧行选择边界。PostgreSQL 对某些 policy 会在省略 WITH CHECK 时复用 USING,但安全代码应把双边意图写清楚。

还要覆盖复杂命令:

  • UPDATE ... RETURNING 同时涉及 SELECT/UPDATE policy;
  • INSERT ... ON CONFLICT 会触发 SELECT、INSERT,走 update path 时还会 触发 UPDATE policy;
  • MERGE 按实际 action 应用相关 policy;
  • BEFORE ROW trigger 可先修改新行,再执行 WITH CHECK
  • policy 不适用于 TRUNCATEREFERENCES 这类整表操作。

因此 runtime 还必须在 ACL 层失去 TRUNCATE。完整 command matrix 见 PostgreSQL:CREATE POLICY

多个 policy 怎样组合

policy 默认为 PERMISSIVE,多个适用 policy 用 OR

Ppermit=P1P2Pn P_{\text{permit}} = P_1 \lor P_2 \lor \cdots \lor P_n

RESTRICTIVE policy 用 AND,并与至少一个 permissive policy 组合:

[ P_{\text{effective}}

(P_1 \lor \cdots \lor P_n) \land R_1 \land \cdots \land R_m ]

这意味着“再加一条 permissive policy”是在扩大可见集合。比如:

CREATE POLICY tenant_rows
ON app.account
FOR SELECT TO app_runtime
USING (tenant_id = app.current_tenant());

CREATE POLICY support_all_rows
ON app.account
FOR SELECT TO app_runtime
USING (true);

第二条会让 app_runtime 看见全部行,不是对第一条的补充限制。策略评审必须 查看某 command/role 的完整 policy 集合,而不是逐条认为“看起来都合理”。

owner、superuser 与 BYPASSRLS

默认情况下:

ordinary role                  subject to applicable RLS
table owner                    normally bypasses RLS
SUPERUSER                      always bypasses RLS
role with BYPASSRLS            always bypasses RLS

要让 owner 也受 policy 约束:

ALTER TABLE app.account FORCE ROW LEVEL SECURITY;

本章把 owner 设为 NOLOGIN,仍启用 FORCE:

CREATE POLICY account_owner_all
ON app.account
FOR ALL TO app_owner
USING (tenant_id = app.current_tenant())
WITH CHECK (tenant_id = app.current_tenant());

ALTER TABLE app.account ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.account FORCE ROW LEVEL SECURITY;

正式测试证明:

owner, no tenant context       0 rows
owner, tenant A context        exactly 2 A rows
superuser break-glass          all 4 rows

FORCE 不是 superuser containment。若 threat model 包含数据库管理员恶意行为, 要依赖组织职责分离、主机/平台控制、不可变审计和密钥治理,不能只依赖 RLS。

row_security=off 不是旁路

这个设置常被误读:

SET row_security = off;

它不会给普通角色绕过 RLS;当查询结果本应被 policy 过滤时,它会报错。这样 备份工具可以避免悄悄导出不完整数据。本章 runtime 负例得到 SQLSTATE 42501,正好证明它不是绕过按钮。

约束与 policy 的隐蔽信道

primary key、unique 和 foreign key 等 referential integrity 检查会绕过 RLS 以维护一致性。攻击者可能从错误差异推断“不可见行是否存在”:

insert guessed email
  -> unique violation       may reveal an invisible matching row

多租户唯一性设计应明确范围:

UNIQUE (tenant_id, external_key)   -- tenant-local uniqueness
UNIQUE (email)                     -- intentionally global uniqueness

如果业务要求全局唯一但不能暴露存在性,应用错误映射、接口语义、重试和审计 都要一起设计,RLS 本身不能消除这个 channel。

policy 表达式若查询其他表,还可能出现并发 snapshot/race 和权限问题。优先 让 policy 只依赖当前行与稳定、简单的事务上下文;复杂授权图要专门做并发 安全评审。官方 RLS 文档详细说明了 referential-integrity 与并发风险: PostgreSQL:行安全策略

view 可能改变 RLS 主体

普通 view 默认按 view owner 的权限访问底层 relation,底层 RLS 也默认使用 view owner 的 policy。PostgreSQL 支持:

CREATE VIEW app.account_visible
WITH (security_invoker = true)
AS
SELECT ... FROM app.account;

此时底层权限与 RLS 使用调用者身份。不能因为 base table 有 RLS,就假设所有 view 路径都等同于直接访问;应检查 view owner、security_invokersecurity_barrier、函数安全属性和 grant。参见 PostgreSQL:CREATE VIEW

23.4.2 租户身份通过事务参数传递

tenant id 必须来自授权结果

下面的 API 是危险的:

{
  "tenant_id": "user-supplied-value",
  "operation": "list_accounts"
}

如果应用不经验证就执行:

SELECT set_config('app.tenant_id', $1, true);

RLS 只会忠实地允许 $1 对应租户。正确来源应是:

verified token / mTLS / session
  -> immutable principal id
      -> server-side authorization mapping
          -> authorized tenant id
              -> database transaction context

请求 body、URL path 或 header 中的 tenant id 可以作为“用户想访问谁”,但 必须与服务端授权集合比对,不能成为信任根。

还要明确 RLS 的 threat boundary:

风险 共享 login + 可设置 tenant GUC 是否能防
开发者漏写 tenant WHERE
ORM 某条查询未注入 scope
普通 readonly 报表误查全表
外部用户篡改请求 tenant,应用正确鉴权
应用进程被攻陷,可任意设置 GUC 不能
数据库 login 凭据泄露,可选择任意 tenant 不能
superuser / BYPASSRLS 恶意访问 不能

如果必须防住被攻陷的单个 tenant workload,就应使用每租户 login/role、独立 数据库或 schema,或者让数据库从不可伪造的连接身份映射 tenant,而不是让 共享 login 自报 tenant id。

自定义 GUC 是载体,不是鉴权器

本章 helper:

CREATE FUNCTION app.current_tenant()
RETURNS uuid
LANGUAGE sql
STABLE
PARALLEL SAFE
SET search_path = pg_catalog
RETURN NULLIF(
    pg_catalog.current_setting('app.tenant_id', true),
    ''
)::uuid;

设计意图:

  • missing_ok=true:缺少设置时返回 NULL
  • NULLIF(..., ''):显式 reset/空值也转成 NULL
  • cast to uuid:非法格式直接失败;
  • STABLE:一条 statement 中按稳定表达式处理;
  • 固定 search_path:不从可写 schema 解析对象。

policy:

tenant_id = app.current_tenant()

当 context 缺失:

tenant_id = NULL -> UNKNOWN -> row rejected

正式观察:

missing context       accepted query, 0 rows, current tenant NULL
malformed context     SQLSTATE 22P02

这叫 fail-closed。它仍不是鉴权器,因为能够执行 set_config 的 session 可以 尝试设置任意值。可信度来自调用它之前的身份授权流程。

policy helper 的安全属性

helper 能保持 SECURITY INVOKER 就不要使用 SECURITY DEFINER。如果必须从 授权表查询:

  • 使用不可登录、最小权限 owner;
  • 固定 search_path
  • schema-qualified 所有对象;
  • 收紧 PUBLIC EXECUTE
  • 避免动态 SQL;
  • 处理并发快照和授权撤销延迟;
  • 对高频查询评估性能;
  • 为输入与返回值建立负例。

把一个复杂的 definer function 塞进每行 policy,可能同时引入越权路径和 严重性能成本。

context 还应携带什么

根据审计需求,同一事务还可设置:

application_name
request/correlation id
actor id
authorization decision id
tenant id

不要把 access token、password、完整个人信息或业务秘密放进 GUC。它们可能 出现在:

  • pg_stat_activity
  • error context;
  • statement/config logs;
  • diagnostics;
  • monitoring snapshots。

context value 应短小、不可变、可关联,并有明确的数据分类。

23.4.3 transaction pooling 下使用 SET LOCAL

为什么 session SET 会泄漏

PgBouncer transaction pooling 的基本语义:

client transaction begins
  -> assign one PostgreSQL server connection
      -> execute transaction
          -> COMMIT / ROLLBACK
              -> return server connection to pool
                  -> next client may receive it

如果 client A 执行 session-level:

SELECT set_config('app.tenant_id', 'tenant-a', false);

第三个参数 false 让值在 backend session 中持续。client A 断开不等于 PostgreSQL backend 断开;client B 复用它时可能继承 tenant A。

不能把 server_reset_query = DISCARD ALL 当成当然成立的保护。PgBouncer 在 transaction pooling 下并不默认依赖每次事务后的 session reset,且所有入口、 版本和配置必须分别证明。支持矩阵见 PgBouncer featuresPgBouncer configuration

完整事务合同

支持的请求序列:

BEGIN;

SET LOCAL ROLE app_runtime;

SELECT set_config(
    'app.tenant_id',
    $1,      -- server-authorized tenant id
    true     -- transaction-local
);

-- all business statements for this request

COMMIT;

四条不可拆:

  1. 必须显式 BEGIN
  2. effective role 与 tenant context 在同一事务设置;
  3. 所有依赖它们的业务 SQL 在同一事务;
  4. 任一错误都 ROLLBACK,不能把 aborted transaction 放回应用池。

SET LOCAL ROLEset_config(..., true) 都在 commit/rollback 后结束。即使 下一个请求复用同一个 backend,也会回到 login 身份和无 tenant context。

应用框架的实现位置

不要让每个 repository method 自己记住设置上下文。应在统一 transaction boundary 中:

authenticate request
  -> authorize tenant
      -> borrow logical client
          -> BEGIN
              -> SET LOCAL ROLE
              -> set tenant context
              -> execute callback/unit of work
          -> COMMIT or ROLLBACK
      -> release client

需要验证框架是否会:

  • 因 autocommit 把每条语句拆成独立事务;
  • 在 transaction callback 之前执行隐式查询;
  • retry 时更换连接但漏掉初始化;
  • nested transaction/savepoint 时改变上下文;
  • 把 readonly 与 runtime role 混用;
  • 在异步任务/streaming cursor 生命周期中提前提交;
  • SET LOCAL 参数当成 SQL identifier 拼接;
  • 发生 timeout/cancel 后未 rollback。

tenant value 必须参数绑定;role 名称不能直接来自用户输入。本章只允许固定 allowlist 中的 runtimereadonly

长事务与租户上下文

transaction-local 并不意味着请求可以无限长。长事务会:

  • 长时间占用 pool server connection;
  • 延迟授权撤销生效到下一事务;
  • 放大 idle-in-transaction 风险;
  • 持有 snapshot/lock,影响 vacuum;
  • 让一次错误上下文影响更多工作。

应为业务事务设置 deadline,拆分批处理,并让授权变化的 SLA 与最长事务时间 一致。

prepared statement 与缓存

transaction pool 支持哪些 prepared statement、temporary object、advisory lock 和 session feature,取决于 PgBouncer 版本与配置。安全原则不变:

plan/cache reuse may be allowed
authorization context must be established per transaction

不要把“同名 prepared statement 可复用”误解为角色或 tenant context 也可 跨事务复用。权限、RLS 与当前 GUC 必须在执行时接受测试。

23.4.4 验证复用连接不会泄漏上一个租户状态

让复用成为确定事件

随机并发测试可能碰巧用了不同 backend,从而给出假阴性。本章在确认沙箱无 活动业务 client 后,临时把 test 池从:

default_pool_size       50
reserve_pool_size       30
reserve_pool_timeout    1
query_wait_timeout      120

改为:

default_pool_size       1
reserve_pool_size       0
reserve_pool_timeout    1
query_wait_timeout      15

这样先后两个 client 会确定复用同一 server connection。实验在 finally 中精确恢复四项原值,并对三节点执行受控 RECONNECT test。这类临时调整只能 用于明确的 nonproduction 空闲池;不能在生产流量中强行把 pool size 改成 1。

先证明漏洞存在

反例:

client A
  session set tenant A
  BEGIN
  SET LOCAL ROLE runtime
  SELECT
  COMMIT
  disconnect

client B
  does not set tenant
  BEGIN
  SET LOCAL ROLE runtime
  SELECT
  COMMIT

实测:

client A backend pid                 72521
client B backend pid                 72521
same backend                         true
client B effective tenant            tenant A
client B visible rows                2 rows of tenant A

这不是理论警告,而是一条跨逻辑客户端的数据泄漏。它也说明“client disconnect 时清理状态”的假设在 transaction pool 中为什么错误。

再证明合同成立

清理实验 backend 后,用 transaction-local 合同依次执行:

tenant A request
missing-context request
tenant B request
missing-context request

四次都复用了 PID 72578,结果:

请求 effective tenant 可见行
A tenant A 2 条 A
missing after A NULL 0
B tenant B 2 条 B
missing after B NULL 0

这里最强的证据不是 A/B 正例,而是两个 missing-context 负例:它们证明前一个 事务的 tenant 状态没有留在同一 backend。

完整负例矩阵

共享表至少要自动测试:

case 预期
tenant A SELECT 只返回 A
tenant B SELECT 只返回 B
context missing 0 rows
malformed context 格式错误
A INSERT B row policy violation
A UPDATE row into B policy violation
readonly INSERT permission denied
raw login without effective role permission denied
runtime TRUNCATE permission denied
runtime disable RLS permission denied
owner without context under FORCE 0 rows
row_security=off as runtime error, not bypass
superuser break-glass all rows, separately audited
same backend after commit no previous tenant
same backend after rollback/error no previous tenant

本章已验证其中核心 12 类并由 validator 校验 SQLSTATE;生产实现还应加入应用 驱动层的 rollback、timeout、cancel、retry 和并发测试。

不要只断言行数

若两个租户恰好都有两行,错误地返回 B 也会满足 count(*)=2。证据至少包括:

row count
minimum tenant id
maximum tenant id
backend pid
effective tenant context
session_user / current_user

敏感字段不应为了证明隔离而导出。本章证据只投影 synthetic tenant/account id 与 display name,并明确:

secret_note_exported = false

上线门槛

RLS 上线前必须同时满足:

[ ] tenant source is server-authorized
[ ] login cannot inherit object grants without intended role
[ ] table ACL and RLS policies both reviewed
[ ] ENABLE + FORCE flags match design
[ ] views/functions/partitions alternate paths reviewed
[ ] transaction-local initialization is centralized
[ ] missing/malformed/cross-tenant tests pass
[ ] same-backend reuse test passes
[ ] rollback/timeout/retry paths pass
[ ] break-glass path is separate and audited
[ ] policy performance is measured on production-like cardinality

RLS 是强大的纵深防御,但只有当身份来源与连接生命周期同样严格时,才会成为 真正的租户边界。


上一节:角色与最小权限 · 返回本章目录 · 下一节:密钥、审计与敏感信息 · 查看全书目录 · 查看索引中心

23.5 密钥、审计与敏感信息

数据库安全材料不只是一串密码:

password and SCRAM verifier
TLS private key and certificate
CA private key, trust bundle and CRL
OAuth client secret / token
LDAP bind credential
backup encryption key
replication credential
PgBouncer authentication material
break-glass credential

其中任何一项若进入 Git、命令行历史、日志、监控标签或实验产物,后续的 权限设计都可能失效。另一方面,为了“绝不记录敏感信息”而关闭所有日志,也 会让越权事件无法发现和调查。

本节处理的是这组张力:秘密必须最小暴露,安全行为必须留下足够而受保护的 证据。

23.5.1 凭据生成、存放、轮换和撤销

先建立 secret inventory

每类 secret 都要有 owner 和生命周期:

字段 要回答的问题
identity 它代表哪个人、服务或组件
scope 能访问哪些入口、数据库和角色
source 谁生成,熵和算法是否合格
storage secret manager、HSM、受限文件还是其他载体
delivery 哪个 workload 如何获得
readers 哪些人、服务账户和进程可读
lifetime 创建、启用、到期和最大使用时长
rotation 是否支持重叠版本,多久轮换
revocation 如何阻止新认证
session eviction 如何处理既有连接
downstream 是否复制到代理、CI、备份或灾备
evidence 如何证明已完成且不导出秘密

如果连“有几份副本”都不知道,就无法声称秘密已经撤销。

生成

机器凭据应由密码学安全随机源生成,避免:

human memorable password
service-name + environment + year
one shared password for all replicas/apps
copy production password to staging

长度和字符集要兼容客户端、URI、配置格式与 secret manager;不要为了规避 转义问题而把熵降得过低。优先通过结构化参数或独立字段传递,避免把密码拼进 连接 URI。

证书与 key 的生成还要固定:

key algorithm and size
signature algorithm
SAN identities
extended key usage
issuer and path length
validity and renewal window
private-key exportability

CA private key 与数据库 server key 不应由同一批日常运维主体任意读取。

存放和交付

首选工作流:

secret manager / HSM
  -> authenticated workload
      -> short-lived retrieval
          -> memory or private runtime file
              -> database driver

如果使用文件:

  • 明确 owner/group;
  • 通常使用 0600 或经过评审的 0640
  • 目录同样不可遍历;
  • 不写入镜像层、共享 volume 或备份;
  • 不把内容输出到 diagnostics;
  • 用完安全删除临时副本,并考虑文件系统/快照语义。

.pgpass 要求严格权限,它适合受控本地客户端,不是企业 secret manager。 环境变量适合某些运行时注入,但必须处理进程继承、crash dump、support bundle 和调度平台元数据风险。

最危险的交付路径通常很方便:

psql "postgresql://app:plaintext@db/app"

URI 可能进入 process list、shell history、trace、错误和工单。即使工具会 隐藏部分内容,也不应把安全性押在每个中间层都正确脱敏。

数据库密码变更

交互式 psql 可使用:

\password app_login

它在客户端提示密码并发送 verifier,避免明文出现在命令历史和 server log。 官方说明见 psql \password。直接执行:

ALTER ROLE app_login PASSWORD 'plaintext';

可能让明文进入 client history 或 server log;PostgreSQL 官方也明确警告 这一点,参见 ALTER ROLE

自动化系统应通过 secret-safe API、受控 stdin/fd 或专门管理函数完成,且 验证流水线不会回显命令与异常。

轮换是状态机

密码轮换不能只有“ALTER 成功”:

S0 old active
  -> S1 new generated and stored
      -> S2 database accepts new
          -> S3 workloads use new
              -> S4 old new-auth rejected
                  -> S5 old sessions drained/terminated
                      -> S6 old copies destroyed

每个状态需要证据与回滚条件。若 PostgreSQL role 只能保存一个当前 verifier, S2 与 S3 之间的兼容窗口可以采用:

  • 蓝绿两个 login role;
  • 应用小批量快速 rollout;
  • 代理/身份系统支持的双版本机制;
  • 计划内短暂重连窗口。

不要假装一个 role 可以同时接受两个普通 PostgreSQL 密码。

撤销新认证与终止旧会话是两件事

本章的受控临时 login 实测:

password v1 new auth                  success
change to password v2
password v1 new auth                  rejected
password v2 new auth                  success
session opened with v1                still usable
ALTER ROLE ... NOLOGIN
all new auth                          rejected
existing session                      still usable
final NOLOGIN + PASSWORD NULL         verified

因此应急撤销至少有两条动作:

ALTER ROLE compromised_login NOLOGIN PASSWORD NULL;

以及在识别范围并评估事务影响后:

SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE usename = 'compromised_login'
  AND pid <> pg_backend_pid();

第二条是有破坏性的:会中断事务,产生 commit outcome uncertainty,并可能 触发重连风暴。必须先阻止代理和应用继续获取旧 secret,再终止既有 session。

代理层也要轮换

PgBouncer 的 auth_fileauth_queryauth_user 和 server login 都可能 持有或派生认证材料。轮换时要明确:

client -> PgBouncer identity
PgBouncer -> PostgreSQL identity

哪一段在变。更新 PostgreSQL verifier 后:

  • PgBouncer 是否缓存旧认证材料;
  • 是否需要 RELOAD
  • 是否需要 RECONNECT server pools;
  • 已有 client connection 是否继续使用;
  • 所有 PgBouncer 节点是否完成;
  • 直连和池化入口是否给出一致的允许/拒绝结果。

本章临时 role 直连新认证成功,但池入口拒绝,因为它没有进入 PgBouncer 认证面。这是预期的最小暴露,不是轮换失败。

证书和 CA 轮换

证书轮换同样要区分:

trust distribution
certificate/key deployment
service reload
new connection negotiation
old connection lifetime
old trust removal
revocation publication

PostgreSQL reload 新 server certificate 不会让所有现有 TLS session 自动 重新握手。PgBouncer 两侧又各有 TLS 状态。验收必须建立新连接并核对证书 fingerprint/issuer/SAN,而不是只看文件 mtime。

CA rollover 先扩充 trust bundle,再切 server/client certificate,最后等 fleet 全部迁移后删除旧 CA。倒序操作会把尚未更新的客户端全部拒绝。

应急凭据

break-glass credential 应:

  • 与日常 workload secret 分开;
  • 双人或受审批取用;
  • 短时激活;
  • 不进入普通自动化;
  • 使用后立即轮换;
  • 关联 incident/change id;
  • 从数据库外部收集不可篡改证据;
  • 定期演练“能取出、能使用、能回收”。

一个从未测试、到事故时才发现过期的应急密码,不是恢复能力。

secret-free 证据

证明轮换无需保存 secret:

role name
credential version id / hash reference
change timestamp
new-auth accepted boolean
old-auth rejected boolean
existing-session observation
NOLOGIN/password-present boolean
operator/change id
artifact hash

本章证据显式拒绝:

plaintext passwords
SCRAM verifiers
raw PgBouncer userlist
server/CA private keys
connection strings containing credentials

它检查“secret signature 不存在”,同时对证据文件使用 0600。文件权限不能 替代内容最小化;二者都要做。

23.5.2 审计目标、日志范围与访问控制

从调查问题反推记录

先定义要回答的问题:

who authenticated, and by which original identity
which login and effective role executed
from which source and entrypoint
which database/object/action
when it began and ended
whether it succeeded
which rows/records were affected, at safe granularity
which policy/config/role changed
which approved request/incident authorized it
whether evidence could have been altered

“记录所有 SQL”并不能自动回答这些问题,反而可能产生无法检索的海量敏感 数据。

四类证据互相补充

来源 擅长回答 局限
PostgreSQL standard log 连接、错误、慢 SQL、DDL、运行事件 不是完整对象审计
pgAudit 结构化 session/object audit classes 有容量成本,superuser 不可可靠自审
Pigsty/config repository 谁声明、评审、发布了配置 不证明运行实例已收敛
host/proxy/secret manager/IdP 网络入口、secret 读取、外部身份 不知道 SQL 对象语义

还应关联:

application audit       end-user and business action
database audit          login/effective role and SQL object
platform audit          configuration/deployment/operator
security audit          secret access and identity events

共享应用 login 下,数据库看不到每个终端用户,应用必须提供受信 actor/request 关联。不要把用户可自行填写的 application_name 当成强身份。

standard log

PostgreSQL 18 可分别记录 connection receipt、authentication、 authorization、setup duration,以及 disconnect。还可记录:

  • error statement;
  • DDL/DML/all statement classes;
  • duration threshold 或 sampling;
  • lock/recovery/autovacuum/checkpoint;
  • SQLSTATE、session id、transaction id、query id;
  • application/database/user/client address。

log_line_prefix 至少要支持跨行关联,例如:

timestamp session-id pid user database application client SQLSTATE

具体字段按日志格式和数据分类选择。若使用 JSON/CSV,仍要确保 collector、 rotation、磁盘满和转发失败被监控。PostgreSQL logging collector 为避免丢 消息可能在落后时阻塞 backend;syslog 则可能选择丢消息。这也是容量设计, 不是简单开关。

pgAudit 能补什么

pgAudit 将行为分为 READWRITEFUNCTIONROLEDDLMISC 等审计类,并能提供更适合对象审计的记录。部署要点:

matching pgAudit branch/version for PostgreSQL major
shared_preload_libraries includes pgaudit
restart completed
CREATE EXTENSION pgaudit before setting pgaudit.log
selected classes/object audit configured
volume and sensitive-data test passed

只安装 package 或只执行 CREATE EXTENSION 都不等于审计已经运行。官方项目 说明见 pgAudit

不要默认 pgaudit.log=all

  • 高频 SELECT 会产生巨大日志;
  • bind parameter 可能含 PII/secret;
  • 日志 IO/转发可能成为负载瓶颈;
  • 噪声可能淹没 role/DDL 等关键事件;
  • retention 成本和访问面迅速扩大。

应从审计目标选择 session classes 或 object audit,并对新表纳入策略做持续 检查。

superuser 不能可靠地审计自己

pgAudit 官方明确指出,不能可靠审计 superuser。superuser 能改变设置、停用 扩展、修改日志路径或干预本机数据。解决思路不是再加一条数据库内 trigger, 而是:

restrict direct superuser
  -> named operator identity
      -> approved, time-bounded escalation
          -> external control-plane/host audit
              -> remote immutable-ish log copy
                  -> post-use review

“不可变”要具体:谁拥有 bucket retention policy、谁能删除 collector、日志 在源端滞留多久、断网时如何缓冲、时间如何同步,都要写进控制设计。

角色和授权变更

高价值事件:

CREATE / ALTER / DROP ROLE
GRANT / REVOKE role membership
GRANT / REVOKE object privilege
ALTER ... OWNER
ALTER DEFAULT PRIVILEGES
ENABLE / DISABLE / FORCE RLS
CREATE / ALTER / DROP POLICY
SECURITY DEFINER function changes
HBA / TLS / proxy authentication changes
secret reads and rotations
break-glass use

仅记录成功 DDL 不够。还需:

  • 失败尝试;
  • 变更前后投影或配置 diff;
  • 审批 id;
  • 执行主体;
  • 节点收敛状态;
  • 正负验收;
  • 回滚结果。

日志本身是敏感数据

日志可能包含:

user and client IP
database/schema/table names
SQL text and literal values
bind parameters
error context
tenant/request identifiers
certificate distinguished names
security policy and topology clues

因此要实施:

  • 最小读权限,读日志本身也审计;
  • 传输和静态加密;
  • retention 与合法删除;
  • 环境/租户隔离;
  • 索引系统访问控制;
  • 防止下载到个人设备;
  • incident legal hold;
  • collector health 与 ingestion gap 告警。

本章环境结论

沙箱运行事实:

pgAudit installed/preloaded       no
standard log_statement            ddl
log_min_duration_statement        100 ms

它能支持实验和部分运维诊断,不能被描述为完整生产审计。生产 gate 因此保留 pending,不是把“有日志”写成“满足审计要求”。

23.5.3 参数、SQL 文本与日志脱敏

参数绑定解决注入,不保证不落日志

应用正确使用:

SELECT *
FROM app.account
WHERE email = $1;

能避免把 $1 当 SQL 语法解释,但 extended query protocol 的 Bind value 仍可能被 statement/duration/audit/error logging 记录。安全评审必须把:

SQL construction safety
log data exposure

当成两个问题。

PostgreSQL 的参数日志开关

PostgreSQL 18:

log_parameter_max_length
  -1   非错误 statement log 可记录完整 bind 参数
   0   禁止在这类日志记录 bind 参数
  >0   每个参数截断到指定字节

log_parameter_max_length_on_error
   0   error message 不附 bind 参数
  -1   可附完整参数
  >0   截断

精确行为见 PostgreSQL:错误报告与日志

本章沙箱:

log_parameter_max_length             -1
log_parameter_max_length_on_error      0

含义是:

  • error path 默认不附 bind values;
  • 非 error 的 statement/duration logging 路径可能保留完整 bind values。

因此它被标记为生产差距。不能只因为 error 参数为 0 就得出“参数不进日志”。

截断不是脱敏

把每个参数截断到 64 bytes 仍可能完整暴露:

password
API token prefix
email
phone
credit-card number
small JSON secret

0 能阻止特定 PostgreSQL log path 记录 bind 参数,但:

  • SQL literal 仍在 statement text;
  • application log 可能记录参数;
  • pgAudit/extension 行为要单独验证;
  • error message 可能引用业务值;
  • trigger/function 自己可能 RAISE LOG
  • proxy/APM/driver trace 可能复制 SQL。

脱敏必须是端到端数据流评审。

不要把秘密写进 SQL literal

高风险语句:

ALTER ROLE app PASSWORD 'secret';
INSERT INTO integration_config(api_token) VALUES ('secret');
SELECT call_remote_service('secret');

即便业务表有 RLS,这些 literal 也可能进入 statement、DDL、audit、客户端 历史或 trace。对于 credential provisioning,使用专门的 secret-safe 通道; 对于业务 secret,使用参数绑定并缩小数据库日志范围。

在源头分类字段

建议把请求数据分成:

类别 示例 日志策略
public operational version、region、status 可结构化记录
internal identifier request id、tenant surrogate id 最小化、受控保留
personal/confidential email、address、business data 默认不记 value
credential/cryptographic password、token、private key 永不记录
regulated/highly sensitive payment/health/government id 专门政策与审计

数据库团队不能只靠列名猜分类。应用 schema、数据目录和日志 policy 要共享 同一份分类元数据。

SQL fingerprint 与 value 分离

性能分析通常不需要参数值。优先保留:

normalized query / query id
duration
rows
wait/error class
database/user/application
safe request correlation id
plan/statistics reference

而不是:

full statement + all bind values for every request

第 25、26 章会用 pg_stat_statements、query id 和计划证据分析性能;这些 方法能显著减少为了可观测而复制业务值的必要。

pipeline 脱敏是第二道防线

collector 侧可以:

  • 删除已知敏感字段;
  • token/password pattern 检测;
  • 限制异常样本和 payload;
  • 对 identifier 做受控 pseudonymization;
  • 阻止包含 private key/verifier 的事件;
  • 记录 redaction rule version。

但 regex 不能成为唯一控制。SQL 语法、编码、嵌套 JSON、base64 和未知字段 会绕过它。首要措施仍是在源端不产出秘密。

脱敏测试

上线前使用纯 synthetic canary:

unique fake password marker
unique fake token marker
unique fake PII marker

执行经过批准的测试请求,然后检查:

PostgreSQL local logs
PgBouncer / HAProxy logs
central log index
APM traces
application logs
CI artifacts
support bundles
chapter/evidence output

期望是 credential marker 零命中,其他 marker 只出现在预先批准的位置。不要 用真实 secret 做日志泄漏测试。

审计与隐私的验收

最终不是“多记”或“少记”,而是:

security event has enough attributable evidence
AND
credential/business values are absent unless explicitly required
AND
evidence readers and retention are controlled
AND
ingestion gaps and tampering attempts are observable

这是安全日志与普通调试日志的根本区别。


上一节:行级安全与连接池上下文 · 返回本章目录 · 下一节:Pigsty 安全基线 · 查看全书目录 · 查看索引中心

23.6 Pigsty 安全基线

Pigsty 能把角色、HBA、服务入口、证书和日志参数声明化,但“声明化”不等于 “使用默认值即可满足任何生产 threat model”。官方安全说明明确指出,默认 配置面向可信内网中的开发、测试和演示;生产环境要按自身威胁模型配置凭据、 网络边界、认证、证书、备份和审计。

本节建立四层对账:

policy intent
  -> Pigsty inventory
      -> rendered files and service configuration
          -> PostgreSQL/PgBouncer runtime facts
              -> end-to-end positive and negative observations

只有最下面一层能证明客户端实际经历了什么;只有最上面一层能解释为什么这样 设计。

23.6.1 角色、HBA、证书与服务入口声明

角色声明

Pigsty 用:

pg_default_roles    环境级共享角色
pg_users            集群级业务角色/用户

用户按数组顺序创建,因此被引用的 group role 应先定义。下面是结构示例, 不是可直接投产的 secret 文件:

pg_users:
  - name: app_owner
    login: false
    superuser: false
    createdb: false
    createrole: false
    inherit: false
    replication: false
    bypassrls: false
    comment: "NOLOGIN owner for app objects"

  - name: app_runtime
    login: false
    inherit: false
    comment: "NOLOGIN normal DML capability"

  - name: app_readonly
    login: false
    inherit: false
    comment: "NOLOGIN read-only capability"

  - name: app_login
    login: true
    password: "<SCRAM-VERIFIER-FROM-PRIVATE-SECRET-PIPELINE>"
    superuser: false
    createdb: false
    createrole: false
    inherit: false
    replication: false
    bypassrls: false
    connlimit: 100
    roles:
      - {name: app_runtime, admin: false, inherit: false, set: true}
      - {name: app_readonly, admin: false, inherit: false, set: true}
    pgbouncer: true
    pool_mode: transaction
    pool_connlimit: 80
    comment: "application authentication identity"

示例标记必须由私密交付机制替换,不能把真实明文或 verifier 提交到本书、Git、 工单或聊天记录。Pigsty 支持 plaintext 或 SCRAM verifier,但官方也把 plaintext inventory 标为不推荐。角色字段与 membership object 格式见 Pigsty:User/Role

这里还要注意:

  • roles 管理是 additive;未声明的历史 membership 不会自动消失;
  • 要撤销旧 membership,应显式使用 state: absent
  • PostgreSQL 16+ 才支持 membership 的 set/inherit 细分;
  • pgbouncer: true 会把 login 纳入 PgBouncer 认证面;
  • NOLOGIN owner/runtime/readonly 不应加入池用户列表;
  • connlimit 与 PgBouncer pool limit 是不同层的限制。

已存在集群修改角色:

bin/pgsql-user <cluster> <username>

它是声明式、可重复入口。删除角色的 state: absent 会涉及断开连接、转移 ownership 和 DROP ROLE,属于破坏性变更;必须先 dry-run/依赖审查和审批, 不能把“脚本支持安全删除”理解为无需变更控制。官方管理流程见 Pigsty:用户管理

membership 与对象 ACL 分开

Pigsty pg_users 可声明 cluster role 和 membership;schema/table/function ACL、owner、default privilege 与 RLS policy 仍应由经过版本控制的 migration 完成:

Pigsty inventory
  -> identity attributes, membership, pool admission

application migration
  -> schema/table owner, ACL, default privileges, RLS policies

不要在多个无序启动脚本里同时管理同一份 grant。平台和应用必须明确各自 owner 和收敛时点。

PostgreSQL 与 PgBouncer HBA

Pigsty 有四组参数:

参数 范围 作用
pg_default_hba_rules global PostgreSQL 环境默认规则
pg_hba_rules global/cluster/instance PostgreSQL 增量规则
pgb_default_hba_rules global PgBouncer 环境默认规则
pgb_hba_rules global/cluster/instance PgBouncer 增量规则

规则会按 order 排序,数值越小越靠前。未显式指定通常进入 1000+ 区域; 如果前面已有宽泛默认 allow,它可能永远匹配不到。

生产应用入口的概念示例:

pg_hba_rules:
  - title: "app direct path from approved application subnet"
    user: app_login
    db: appdb
    addr: "10.20.10.0/24"
    auth: ssl
    order: 50

  - title: "reject app direct path from all other sources"
    user: app_login
    db: appdb
    addr: world
    auth: deny
    order: 51

pgb_hba_rules:
  - title: "app pooled path from approved application subnet"
    user: app_login
    db: appdb
    addr: "10.20.10.0/24"
    auth: ssl
    order: 50

  - title: "reject app pooled path from all other sources"
    user: app_login
    db: appdb
    addr: world
    auth: deny
    order: 51

auth: ssl 的当前 Pigsty alias 会渲染为 hostssl ... scram-sha-256;生产变更 仍应检查目标版本的渲染结果,不能永久依赖书中的 alias 解释。HBA 字段、 alias、role filter 和 order 规则见 Pigsty:HBA Rules

若应用只允许经 pool/service 入口访问,可进一步在网络与 PostgreSQL HBA 中拒绝应用子网直连 5432,只允许本机 PgBouncer 或指定代理身份访问后端。 这能避免客户端绕过 PgBouncer 的连接限制、认证面与事务池合同。

intra 只是地址别名

Pigsty 的 intra/intranet 通常展开为 RFC 1918:

10.0.0.0/8
172.16.0.0/12
192.168.0.0/16

这些地址不是天然可信。企业办公网、VPN、容器网、其他租户 VPC 和开发环境都 可能落在其中。生产规则应尽量使用准确的应用 subnet/security group,而不是 把整个 RFC 1918 视为一个 trust zone。

刷新而非手工改文件

修改 inventory 后:

bin/pgsql-hba <cluster>

会重新渲染并 reload PostgreSQL/PgBouncer 相关 HBA。不要直接编辑:

/pg/data/pg_hba.conf
/etc/pgbouncer/pgb_hba.conf

下次 playbook 会覆盖手工改动,而且 inventory 与运行事实从此分叉。紧急手工 变更若无法避免,也必须同步回声明源、记录例外并尽快恢复收敛。

证书声明和使用

Pigsty 默认基础设施 CA 会为 PostgreSQL、Patroni、etcd、MinIO、Nginx 等 内部服务签发证书。生产评审要区分:

CA exists
server has certificate
listener supports TLS
HBA requires TLS
client trusts the intended CA
client verifies the intended DNS/IP name
client certificate, if required, is mapped and revocable

这六件事不能合并成“开启 SSL”。

证书 SAN 应覆盖客户端实际使用的:

  • HAProxy/VIP DNS name;
  • PostgreSQL direct service name;
  • 节点名/IP(若允许直连);
  • planned disaster-recovery endpoint。

不要让应用用 hostaddr 绕过期望的 DNS name verification,除非同时提供可 校验的 host 语义并完成测试。

CA private key 所在目录是根信任资产。需要离线/受限备份、读取审计和恢复演练。 证书签发与客户端安装流程见 Pigsty:CA and Certificates

两段 TLS 分别声明

若应用经 PgBouncer:

client ==TLS policy A==> PgBouncer
PgBouncer ==TLS/socket policy B==> PostgreSQL

Pigsty 的 PostgreSQL server TLS 默认开启,不表示 HBA 默认要求 TLS; PgBouncer client TLS 默认也不是开启状态。当前官方安全说明明确列出:

PostgreSQL TLS supported
default intranet HBA may not require it
PgBouncer TLS controlled separately by pgbouncer_sslmode
Patroni REST TLS controlled separately

生产要逐入口决定:

链路 加密 对端验证 允许来源 认证主体
app → HAProxy/PgBouncer required service name app subnet app login
PgBouncer → PostgreSQL TLS 或受控 local socket node/service proxy nodes server login
admin → PostgreSQL required admin endpoint bastion/infra named admin
replica → primary required or isolated equivalent node identity cluster only replication role

service entrypoint

第 22 章区分了 direct PostgreSQL、PgBouncer、HAProxy primary/replica/offline 等入口。安全基线要为每个入口建立:

purpose
listener address/port
source allowlist
HBA/authentication
TLS name and CA
allowed roles/databases
pool mode
rate/connection limit
audit label
owner

未使用入口应关闭或从网络上不可达。保留一个“以后可能调试”的公网 5432 会变成长期旁路。

23.6.2 管理面、监控面和数据库面的网络边界

先画流量矩阵

至少区分:

平面 典型组件 主要主体 失陷后风险
管理面 meta/Ansible、SSH、sudo、secret store 平台管理员/自动化 改写全部配置与密钥
控制面 Patroni REST、etcd cluster agent 错误选主、拓扑控制
数据面 HAProxy、PgBouncer、PostgreSQL 应用/分析/迁移 数据读写与租户越权
监控面 exporter、Prometheus、Grafana、日志 monitoring identities 查询/拓扑/日志泄露
备份面 pgBackRest repo、WAL/archive backup identities 全量数据泄露或恢复破坏

“都是内网服务”不是边界设计。每条 flow 应记录:

source identity/network
destination service/address/port
direction
TLS/mTLS
authentication
authorization scope
availability dependency
log/audit
owner and review date

管理面

管理节点通常能:

  • 通过 SSH 到所有节点;
  • sudo/root;
  • 读取 inventory 或 vault integration;
  • 运行 Ansible/playbook;
  • 访问 CA material;
  • 变更防火墙、HBA 和 service。

因此它不是普通“运维跳板”。应:

named human identity + MFA
short-lived SSH certificate/key
no shared permanent root password
separate automation service identity
command/change audit
restricted egress and inbound sources
workstation and bastion hardening
backup/recovery for control repository

把数据库 superuser 密码从 inventory 移走,却允许所有工程师无审计地 sudo -iu postgres,并没有实现职责分离。

控制面

Patroni REST 和 etcd 决定 cluster topology。它们不应暴露给业务 subnet。 控制面规则通常只允许:

cluster members
approved management/monitoring nodes

health check 入口与管理 API 要区分。HAProxy 为角色判断访问 Patroni health endpoint,不表示应用客户端也应访问完整 Patroni API。

控制面 TLS、认证和 ACL 必须按目标 Pigsty 版本验证。不能因为 etcd 使用 TLS, 就推断 Patroni REST 也已经使用 TLS。

数据面

建议把应用路径收敛为:

application subnet
  -> primary/replica service VIP/DNS
      -> HAProxy
          -> local/cluster PgBouncer
              -> PostgreSQL

然后按实际需要开放 direct path:

management/migration subnet -> PostgreSQL direct
replication nodes            -> PostgreSQL replication
backup/monitor               -> dedicated minimum roles

网络控制至少三层:

cloud security group / network ACL
host firewall
listener + HBA

HBA 不是防 DDoS 的边界:连接已经到达 PostgreSQL 才会检查。安全组/防火墙也 不理解 database/user。二者互补。

Pigsty node_firewall_mode=zone 的默认可信 intranet 必须与真实边界对账。 官方建议也明确提醒 RFC 1918 范围可能过宽,生产 demo 配置通常应移除无必要 的 5432 暴露。参见 Pigsty:Security Recommendations

监控面

monitor role 看起来只读,但往往能访问:

  • pg_stat_activity 查询文本;
  • replication/topology;
  • database/object names;
  • slow-query samples;
  • connection source/user;
  • logs and dashboards。

因此:

  • monitor 不应使用 superuser;
  • exporter query 必须受版本控制;
  • dashboard 与 Prometheus API 要认证;
  • label 不能含 password/token/高基数 PII;
  • 跨环境 metrics/logs 要隔离;
  • support snapshot 要脱敏;
  • 监控不可用不能使数据库认证 fail-open。

备份面

backup repository 持有跨 RLS、跨租户的完整数据与 WAL。网络上只允许备份 主体和恢复路径;读取、删除、retention 改动应分权。数据库 RLS/HBA 无法保护 一份已被复制到对象存储的备份。

恢复环境同样要隔离:把生产备份恢复到宽松开发网,常比攻击生产数据库更容易 造成泄露。第 21、32、33 章会继续处理备份与恢复的控制。

egress 也要控制

数据库节点出站能力可能被:

  • extension/FDW;
  • COPY PROGRAM 高权能力;
  • untrusted language;
  • backup/archive command;
  • 运维脚本;
  • 被攻陷进程

用于外带数据。仅做 inbound firewall 不够。生产需要明确允许的软件源、 backup endpoint、DNS/NTP/monitoring 等 egress,并监控异常。

23.6.3 从配置渲染到运行事实的差异检查

四份状态

对同一项控制记录:

desired       reviewed inventory/policy
rendered      node-local file/service config
runtime       catalogs, settings, sockets, process state
observed      actual client connection or rejected action

示例:

控制 desired rendered runtime observed
app TLS auth: ssl hostssl pg_hba_file_rules disable fails, verify-full succeeds
role NOINHERIT generated SQL pg_roles raw login SELECT fails
membership SET true grant statement pg_auth_members SET LOCAL succeeds
RLS migration table/policy DDL pg_class/pg_policies cross-tenant writes fail
pool identity pgbouncer: true userlist presence SHOW USERS projection pool auth succeeds

任何一列缺失都不能宣告闭环。

inventory 评审

先检查:

  • 是否把 secret 直接写入 Git;
  • role 高权 flag;
  • inherit 和 membership admin/set/inherit
  • pgbouncer 暴露范围;
  • HBA order 与宽规则;
  • host/hostssl
  • world/intra alias 展开;
  • primary/replica/offline role filter;
  • listener/service port;
  • PostgreSQL/PgBouncer/Patroni TLS;
  • logging/audit;
  • firewall intranet。

inventory diff 应显示结构,不应把 password/verifier 展开到 PR。

渲染检查

变更前先生成/审查候选,应用后检查节点:

/pg/data/pg_hba.conf
/etc/pgbouncer/pgb_hba.conf
PgBouncer main/user options, secret-redacted projection only
PostgreSQL include/config source
HAProxy listener/backend projection
certificate public metadata
host firewall projection

不要把 /etc/pgbouncer/userlist.txt 原文放进证据;它可能含 verifier。

HBA 应验证:

SELECT *
FROM pg_hba_file_rules
ORDER BY rule_number;

并确认 error IS NULL、顺序与 inventory 相符。

runtime 检查

角色:

SELECT
    rolname, rolcanlogin, rolsuper, rolcreatedb, rolcreaterole,
    rolreplication, rolbypassrls, rolconnlimit, rolvaliduntil
FROM pg_roles
ORDER BY rolname;

membership:

SELECT
    granted.rolname AS granted_role,
    member.rolname AS member,
    m.admin_option,
    m.inherit_option,
    m.set_option
FROM pg_auth_members AS m
JOIN pg_roles AS granted ON granted.oid = m.roleid
JOIN pg_roles AS member ON member.oid = m.member;

对象与 RLS:

owners
schema/table/function/sequence ACL
default ACL
relrowsecurity / relforcerowsecurity
pg_policies
view/function security attributes

TLS:

SHOW ssl / ssl_min_protocol_version
certificate SAN, issuer, validity, fingerprint
private key owner/mode, never contents
pg_stat_ssl on real sessions
verify-full positive and wrong-name negative

日志:

log_statement
duration/sample thresholds
parameter logging limits
connection/disconnection settings
shared_preload_libraries
pg_extension
collector/forwarder health

observed 检查

必须从与真实 workload 相同的网络和 driver 路径执行:

allowed source + right login + right DB     succeeds
wrong source                               rejected
wrong password                             rejected
sslmode=disable where TLS required          rejected
verify-full matching name                  succeeds
verify-full wrong name                     rejected
pool login                                independently succeeds
direct bypass where prohibited             rejected
raw login object access                    rejected
approved SET LOCAL role                    succeeds
cross-tenant action                        rejected

只在 database node 上用 Unix socket psql,无法验收远程网络、proxy HBA、 TLS name 或客户端 CA distribution。

本章沙箱差异

在 Pigsty v4.5.0 nonproduction sandbox 中,正式捕获发现:

控制 期望/能力 运行事实 判定
PostgreSQL TLS server 可加密 on,最低 TLS 1.2 能力通过
cert identity SAN + verify-full 正例成功,错名失败 通过
direct channel binding SCRAM over TLS 成功 通过
business direct HBA 生产应强制 TLS +dbrole_readonly 内网为 host 待整改
PgBouncer client TLS 生产应按 threat model 启用 disabled 待整改
pool → PostgreSQL 受控链路 local Unix socket 事实符合设计
CRL 撤销路径 file/dir unset 待整改
pgAudit 目标要求对象审计 absent/not preloaded 待整改
bind logging secret-minimized 非错误参数上限 -1 待整改

它诚实地同时证明:

TLS mechanism works
AND
non-TLS direct business connection is still admitted

第一条不能抵消第二条。

分阶段整改

安全变更也会造成可用性风险,应分阶段:

Phase 1 inventory
  client versions, source networks, DNS names, CA trust, pool entrypoints

Phase 2 prepare
  issue correct SAN certs
  distribute CA
  make clients use verify-full
  establish metrics and rollback

Phase 3 enforce client-to-pool TLS
  enable PgBouncer TLS
  positive/negative tests
  roll clients

Phase 4 enforce PostgreSQL HBA
  add high-priority hostssl allow
  retain controlled exception if necessary
  prove non-TLS rejection

Phase 5 audit and secret minimization
  install matching pgAudit if required
  select classes/objects
  reduce bind parameter logging
  validate volume and redaction

Phase 6 remove exceptions
  expire compatibility HBA/CA/password
  terminate old sessions
  update runbook and evidence

一次性把所有 host 改成 hostssl,如果客户端尚未安装 CA 或仍用 IP 不匹配 证书,会把安全整改变成全站故障。

例外不是口头备注

暂时保留非 TLS/旧客户端时,例外记录至少包含:

exact source/destination/role/database
business reason
threat and data classification
compensating network control
owner and approver
created/expires
remediation milestone
monitoring
tested rollback

无到期日的例外就是新默认。

生产判定

最终报告只允许:

判定 含义
pass 所有必须项有运行与负例证据
pass-with-exception 明确、批准、限时、受补偿控制的差距
pending 机制可用,但必需控制尚未实施/证明
reject 存在不可接受暴露或证据冲突

本章沙箱是 pending。它完全适合教学实验,却不能被包装成生产审批。这种区分 正是安全工程成熟度的一部分。Pigsty 当前生产注意事项见 Pigsty:Security Considerations


上一节:密钥、审计与敏感信息 · 返回本章目录 · 下一节:实战:隔离两个租户 · 查看全书目录 · 查看索引中心

23.7 实战:隔离两个租户

本节把前六节压成一条可重放的安全证明:

deployment baseline gate
  -> threat and authority boundary
      -> role graph and object ACL
          -> forced RLS for two synthetic tenants
              -> direct TLS and HBA observations
                  -> deterministic pool-state leak
                      -> transaction-local repair
                          -> password rotation/session survival
                              -> exact pool restore
                                  -> postflight topology gate
                                      -> formal validation and adversarial mutations

实验面向第 19 章保留的 Pigsty nonproduction sandbox。它会保留 synthetic schema/roles,临时改变一个 PgBouncer runtime pool,并短暂启用一个轮换探针 login。不能删除 guard 后指向生产。

实验合同:

23.7.1 建立应用角色、迁移角色和只读角色

环境与范围

正式环境:

target          pg36-l2-vagrant/pg-test
Pigsty          v4.5.0
PostgreSQL      18.6
PgBouncer       1.25.2
database        test
leader          pg-test-1
replicas        pg-test-2, pg-test-3
timeline        11 before and after
data            four fixed synthetic rows
production      data=false, traffic=false

实验明确不做:

production approval
CA/private-key rotation
HBA/firewall mutation
real customer data
malicious root test
topology change

风险分级

action 风险 行为
capture L0 只读当前快照
verify / review / all L0 只重验既有 evidence
drill:security L2 建 fixture、临时 pool/rotation mutation
reset:fixture L3 删除本章 schema 和五个 synthetic role

all 的含义不是“执行所有实验”,而是:

validate existing drill evidence
  + adversarial review
  + no database/pool/topology mutation

这种命名刻意防止 CI 或读者误触有状态演练。

两份私密输入

完整 drill 需要:

PG36_CH23_CREDENTIAL_INVENTORY
  已部署 sandbox 的既有 test login credential
  private mode 0600

PG36_CH19_INVENTORY
  第 19 章冻结 baseline gate 使用的 inventory
  private mode 0600

普通情况下两者可以来自同一份经过评审的 private inventory。书中正式运行把 它们分开,因为本地当前声明已与第 19 章冻结样本发生演进;不能拿新声明冒充 旧部署的 baseline。

runner 只读取既有 test login 的 credential。它不:

  • 输出 credential;
  • hash credential 到报告;
  • 修改 test 的 password 或 role attributes;
  • 将 private inventory 复制进仓库。

exact mutation guards

使用一个全新的空 evidence directory:

export PG36_EVIDENCE_DIR=/absolute/private/path/to/new-empty/ch23-run
export PG36_CH23_CREDENTIAL_INVENTORY=/absolute/private/path/to/credential.yml
export PG36_CH19_INVENTORY=/absolute/private/path/to/baseline.yml

export PG36_CH23_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH23_NONPRODUCTION=true
export PG36_CH23_SYNTHETIC_DATA_ONLY=true
export PG36_CH23_PRODUCTION_TRAFFIC=false
export PG36_CH23_CONFIRM=SECURITY_RLS_ROTATION_CH23

static/labs/ch23/task.sh drill:security

target、authority、confirm 任一不完全相同,inventory 缺失/权限不安全,或 output 非空,runner 都拒绝。执行前还会跑完整第 19 章 preflight;结束后再跑 postflight。

角色图

fixture 使用已有 login 与五个 synthetic role:

test LOGIN, existing sandbox identity
  ├─ ADMIN false, INHERIT false, SET true -> pg36_ch23_runtime NOLOGIN
  └─ ADMIN false, INHERIT false, SET true -> pg36_ch23_readonly NOLOGIN

pg36_ch23_migrate NOLOGIN
  -> ADMIN false, INHERIT false, SET true -> pg36_ch23_owner NOLOGIN

pg36_ch23_rotate
  -> LOGIN only during direct credential probe
  -> final NOLOGIN PASSWORD NULL

五个 synthetic role 都要求:

NOSUPERUSER
NOCREATEDB
NOCREATEROLE
NOREPLICATION
NOBYPASSRLS

setup 在复用同名 role/schema 前,检查 owner、comment、属性与完整 fixture shape。若发现同名非实验对象,不会接管。

schema 与 synthetic data

对象:

CREATE SCHEMA pg36_ch23
  AUTHORIZATION pg36_ch23_owner;

CREATE TABLE pg36_ch23.account (
    tenant_id       uuid        NOT NULL,
    account_id      uuid        NOT NULL,
    display_name    text        NOT NULL,
    balance_cents   bigint      NOT NULL CHECK (balance_cents >= 0),
    secret_note     text        NOT NULL,
    created_at      timestamptz NOT NULL DEFAULT clock_timestamp(),
    PRIMARY KEY (tenant_id, account_id)
);

固定 fixture:

tenant A  11111111-1111-4111-8111-111111111111  2 rows
tenant B  22222222-2222-4222-8222-222222222222  2 rows

脚本验证 exact columns、constraints、row ids、owner 与 comment;同 schema 中 出现第五条或未知 id 就拒绝。所有数据都是 synthetic,evidence 不导出 secret_note

完整 SQL 见 setup.sql

权限

先撤销 PUBLIC、raw test login 和 Pigsty 宽 group role 对实验 schema/ table/function 的权限,再授予:

runtime
  schema USAGE
  table SELECT, INSERT, UPDATE
  function EXECUTE current_tenant()

readonly
  schema USAGE
  table SELECT
  function EXECUTE current_tenant()

明确不授予:

schema CREATE
DELETE
TRUNCATE
REFERENCES
TRIGGER
ALTER/DISABLE RLS

owner 拥有对象但不可登录,migrate 能显式切为 owner 而不继承/转授 owner。

policy

helper:

CREATE FUNCTION pg36_ch23.current_tenant()
RETURNS uuid
LANGUAGE sql
STABLE
PARALLEL SAFE
SET search_path = pg_catalog
RETURN NULLIF(
    current_setting('app.tenant_id', true),
    ''
)::uuid;

五条 policy:

account_runtime_select
account_readonly_select
account_runtime_insert
account_runtime_update
account_owner_all

其中 UPDATE 和 owner 明写 USINGWITH CHECK,最后:

ALTER TABLE pg36_ch23.account ENABLE ROW LEVEL SECURITY;
ALTER TABLE pg36_ch23.account FORCE ROW LEVEL SECURITY;

default privileges 同时约束未来由 owner 创建的 table/function,防止下一次 migration 恢复 PUBLIC 暴露。

23.7.2 通过 PgBouncer 事务池验证 RLS

应用事务

runner 模拟应用:

connection.execute("BEGIN")
connection.execute("SET LOCAL ROLE pg36_ch23_runtime")
connection.execute(
    "SELECT set_config('app.tenant_id', %s, true)",
    (authorized_tenant_id,),
)
rows = connection.execute(
    """
    SELECT tenant_id, account_id, display_name
    FROM pg36_ch23.account
    ORDER BY tenant_id, account_id
    """
).fetchall()
connection.execute("COMMIT")

role 来自固定 allowlist,tenant id 使用参数绑定,并被标记为 synthetic server-authorized mapping。实验不把终端用户输入直接信任为 context。

正例

通过 primary pooled entry:

10.10.10.11:5433
database=test
login=test
pool_mode=transaction

观测:

effective role/context 结果
runtime + tenant A 2 条且全是 A
runtime + tenant B 2 条且全是 B
runtime + missing 0 条,context NULL
owner + missing, FORCE RLS 0 条
owner + tenant A 2 条且全是 A
migrate SET ROLE owner session/current role 链正确
superuser break-glass 4 条

最后一项单独从节点本地受控管理路径观察,不是应用能力。

负例

实际 SQLSTATE:

malformed tenant UUID              22P02
cross-tenant INSERT                42501
cross-tenant UPDATE                42501
runtime DISABLE RLS                42501
runtime CREATE in schema           42501
runtime TRUNCATE                   42501
readonly INSERT                    42501
row_security=off as runtime        42501
raw login SELECT                   42501

cross-tenant UPDATE 使用 A context,尝试把 A 行的 tenant_id 改为 B:

UPDATE pg36_ch23.account
SET tenant_id = $tenant_b
WHERE tenant_id = $tenant_a
  AND account_id = $known_a_row;

它专门证明 WITH CHECK,不是只证明不可见行被 USING 过滤。

为什么缺失 context 返回 0 而不是报错

helper 的 missing value 是 NULL

tenant_id = NULL -> UNKNOWN -> policy rejects row

对读路径,这能 fail-closed。malformed value 则因 UUID cast 报错。应用层仍应 把 missing context 视为 bug 并告警,不能因为数据库返回空集合就安静吞掉。

同时确认 transport 事实

RLS 测试经过 pool,但沙箱 PgBouncer client TLS 是 disabled。runner 没有把 这条路径包装成生产安全链,而是分别测试:

direct PostgreSQL sslmode=require             success
direct verify-full matching name              success
direct verify-full wrong name                 rejected
direct verify-full + channel_binding=require  success
direct sslmode=disable                        success, known gap
pooled sslmode=disable                        success, known gap
pooled sslmode=require                        rejected, known gap

直连协商为 TLS 1.3 / TLS_AES_256_GCM_SHA384 / 256 bits。证书 public metadata 包含节点对应 DNS/IP SAN,private key 只检查 mode 0600,从不读取内容。

23.7.3 注入会话状态泄漏与越权访问并修复

临时单 backend pool

为让复用确定发生,runner 先确认 test pool 无活动 client,再保存:

default_pool_size       50
reserve_pool_size       30
reserve_pool_timeout     1
query_wait_timeout     120

临时设置:

default_pool_size        1
reserve_pool_size        0
reserve_pool_timeout     1
query_wait_timeout      15

然后在三节点对 test pool 执行 RECONNECT。无论后续成功或失败,finally 都会恢复四项 exact baseline,再次 reconnect 并比较 SHOW CONFIG

泄漏注入

client A 在事务外执行 session setting:

SELECT set_config(
    'app.tenant_id',
    '11111111-1111-4111-8111-111111111111',
    false
);

然后 A 和一个新 client B 都只在事务内设置 runtime role,不再设置 tenant。

结果:

client A backend pid             72521
client B backend pid             72521
same backend                     true
B context                        tenant A
B visible rows                   2 tenant-A rows

B 从未声明 tenant A,却继承 A 的 session state。这条反例是 validator 的必须 项;如果没有复现,实验不会用“可能没复用”蒙混通过。

修复验证

清理 server connection 后,四个逻辑 client 依次执行完整事务合同:

A
missing
B
missing

全部复用 PID 72578

A         context A,    2 A rows
missing   context NULL, 0 rows
B         context B,    2 B rows
missing   context NULL, 0 rows

因此修复证据同时包含:

same backend reused
AND
previous transaction state absent

若只验证两个 backend PID 不同,不能证明 transaction-local 合同。

密码轮换注入

pg36_ch23_rotate 只用于 direct PostgreSQL 认证,不进入 PgBouncer 声明面。 runner 用随机、只存在内存/私密进程输入中的 v1/v2:

enable LOGIN with v1
new direct auth v1                       success
pooled auth                              rejected
change verifier to v2
new direct auth v1                       rejected
new direct auth v2                       success
session authenticated with v1            still usable
set NOLOGIN
new direct auth                          rejected
existing session                         still usable
set PASSWORD NULL
final role state                         NOLOGIN/password absent

这同时证明:

  1. PostgreSQL 与 PgBouncer authentication surface 不相同;
  2. password rotation 控制新认证;
  3. NOLOGIN 不终止既有 session;
  4. 演练结束没有留下可登录的 rotation role 或 verifier。

失败时的恢复边界

脚本可以安全自动恢复的只有已知、可比较状态:

PgBouncer four runtime settings
rotation role -> NOLOGIN PASSWORD NULL

它不自动:

drop fixture
change HBA/firewall/certificates
force topology
hide failed evidence

若拓扑不再唯一稳定或 pool restore compare 失败,报告失败并保留证据,交由 operator 检查;不猜测性修复。

23.7.4 输出权限矩阵、轮换证据与应急回收步骤

evidence tree

完整私密证据:

ch23-run/
├── preflight-ch19/
├── drill/
│   ├── before.json
│   ├── inventory-projection.json
│   ├── fixture.json
│   ├── tls-tests.json
│   ├── rls-tests.json
│   ├── pool-context.json
│   ├── rotation-tests.json
│   ├── pool-restore.json
│   ├── after.json
│   ├── drill-manifest.json
│   ├── validation-report.json
│   └── negative-report.json
├── postflight-ch19/
└── review.txt

所有 drill evidence file 要求 mode 0600。完整 bundle 含 internal addresses、 role graph、public certificate metadata 和 backend PID,应放 private evidence store,不提交 Git。仓库只保留 secret-free 聚合 security-run.json

manifest

manifest 关联:

run id and timestamps
exact target/release
source SHA-256
evidence SHA-256
declared mutations
restoration state
known production gaps
production gate

validator 会重新计算 evidence hash,防止在运行后悄悄替换观测文件。source hash 让报告对应到具体 runner/contract 版本。

权限矩阵

正式投影:

capability raw login runtime readonly owner superuser
table SELECT ACL implicit
INSERT implicit
UPDATE implicit
DELETE implicit
TRUNCATE implicit
schema CREATE
policy management
rows without context 无 ACL 0 0 0 under FORCE 4

“owner implicit”不表示生产 owner 应日常执行 DML;它不可登录,仅由 migration 在审批窗口显式切换。

read-only 重验

拿到一个既有完整 bundle 后:

export PG36_EVIDENCE_DIR=/absolute/private/path/to/ch23-run
static/labs/ch23/task.sh all

应给出:

status=review-ok
tenant_rls=pass
pool_session_leak=reproduced
transaction_local_context=pass
credential_rotation=pass
rotation_role_final_state=nologin-password-null
pool_settings=restored
final_leader=pg-test-1
counterexamples=20-rejected
production_ch23_gate=pending
secret_material=absent
mutation=none

对抗性反例

negative-cases.json 要求 validator 拒绝 20 类篡改,包括:

wrong target/topology
production gate falsely passed
synthetic superuser/BYPASSRLS/LOGIN
membership ADMIN OPTION
RLS/FORCE omitted
missing tenant shows all
cross-tenant writes accepted
leak counterexample omitted
session SET claimed supported
pool override not restored
password change claimed to terminate session
rotation role still usable
secret/verifier/private key/raw userlist in evidence
sslmode=require called server authentication
non-TLS/TLS gaps hidden
ordinary logs called complete audit
unguarded destructive reset

测试 validator 对坏证据的拒绝能力,才能避免“只要 JSON 字段存在就 pass”的 自证循环。

应急回收

生产 credential suspected compromised:

1. freeze secret distribution and identify exact identity/scope
2. block new auth at IdP/proxy/PostgreSQL
3. set role NOLOGIN and revoke password/token/certificate
4. stop app pools from reconnecting with old material
5. enumerate existing sessions across direct and proxy paths
6. assess in-flight transactions and terminate sessions
7. rotate downstream/shared credentials
8. inspect role grants, RLS/DDL, exports, logs and backups
9. validate old auth fails and new controlled auth succeeds
10. preserve secret-free evidence and start incident review

应急时不能只运行:

ALTER ROLE ... NOLOGIN;

本章已经证明既有 session 仍能查询。

destructive reset

正常 drill 不调用 reset。只有明确要删除 synthetic fixture 时:

export PG36_CH23_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH23_NONPRODUCTION=true
export PG36_CH23_SYNTHETIC_DATA_ONLY=true
export PG36_CH23_RESET_CONFIRM=DROP_CH23_SYNTHETIC_SECURITY_FIXTURE

static/labs/ch23/task.sh reset:fixture

reset 会先检查:

exact schema owner/comment
exact five role comments/attributes
no active synthetic-role sessions
known memberships

然后只删除:

schema pg36_ch23
five pg36_ch23_* roles
memberships introduced by this lab

它保留已有 test login 和全部非 fixture 对象。该命令是破坏性操作;本书正式 验收没有执行它,fixture 留作复查。

生产门槛

实验正式通过:

role separation                 pass
object minimum privilege        pass
forced two-tenant RLS           pass
transaction-local pool context  pass
password rotation semantics     pass
pool restore                    pass
topology unchanged              pass
secret-free evidence            pass

但生产仍为 pending

business direct HBA permits non-TLS
PgBouncer client TLS disabled
CRL absent
client CA distribution/rotation not exercised
pgAudit absent
non-error bind values may be fully logged

这些差距与实验通过不矛盾。前者说明机制被正确验证,后者说明当前 sandbox 还不是生产安全批准。

本章完成定义

读者应能独立解释并证明:

HBA first-match 为什么必须看最终顺序
SCRAM、TLS 加密与 verify-full 分别证明什么
login、effective role、owner 和 break-glass 为什么分开
membership ADMIN/INHERIT/SET 如何影响权限路径
USING 与 WITH CHECK 如何约束旧行和新行
FORCE RLS 能约束谁、不能约束谁
tenant context 为什么必须来自授权映射
session SET 如何跨 transaction-pool client 泄漏
SET LOCAL 如何与事务所有权边界对齐
密码/NOLOGIN 为什么不终止既有 session
Pigsty 声明、渲染、运行、观测如何对账
为什么 sandbox pass 仍可以是 production pending

如果只能创建一条 policy,却无法证明身份来源、复用连接、负例、轮换和日志 边界,本章还没有完成。

参考资料


上一节:Pigsty 安全基线 · 返回本章目录 · 下一章:纲举目张:SLO、SOP 与组织治理 · 查看全书目录 · 查看索引中心

24 纲举目张:SLO、SOP 与组织治理

数据库平台最危险的状态,不是“什么都没有”,而是看起来什么都有:

三节点
有备份
有监控
有告警
有值班群
有操作文档

却没人能回答:

用户究竟获得什么服务?
什么事件才算成功?
一个实例宕机是否已经影响用户?
数据错了一行,能否被 99.9% 的平均值原谅?
计划维护为什么可以从报表中消失?
告警响起后的第一个安全动作是什么?
谁能批准切换,谁能执行,谁负责停止?
“备份成功”以外,恢复过吗?
哪份证据证明这次操作的目标、输入与结果?

本章把这些问题组织成一条可执行的治理链:

用户旅程
  -> 服务目录与责任人
      -> SLI / SLO / 控制目标
          -> 错误预算与变更政策
              -> 观察与告警契约
                  -> SOP / Runbook / Drill / Change Plan
                      -> 权限、停止线与回退
                          -> 证据、审计与保留
                              -> 自动验证与对抗性反例

治理不是在 PostgreSQL 外面增加一层审批表。它的任务是把“正常”“异常” 和“谁有权改变什么”变成可计算、可否证、可追责的服务合同。

本章目标

完成本章后,你应当能够:

  1. 区分数据库实例、集群、入口、应用和用户服务;
  2. 为服务、数据、平台与安全隐私分别指定 accountable owner;
  3. 用义务而不是“金银铜”标签定义服务等级;
  4. 画出依赖、失效语义、值班与升级路径;
  5. 解释为什么 postgres_up = 1 不是业务可用性;
  6. 把 SLI 写成 good events / eligible events
  7. 区分 SLI、SLO、SLA、错误预算和控制目标;
  8. 为可用性、延迟、新鲜度选择靠近用户的测量点;
  9. 把正确性和恢复就绪从可平均的错误预算中分离;
  10. 计算事件预算、等价时间预算和 burn rate;
  11. 设计滚动窗口、低流量策略与不可追溯篡改的排除项;
  12. 用错误预算真实约束发布节奏,而不是装饰仪表盘;
  13. 区分 SOP、故障 Runbook、恢复演练和单次 Change Plan;
  14. 把变更拆成申请、评审、执行、验证、回退或前滚与关单;
  15. 为 L2/L3 高风险动作设计独立批准、精确目标和停止线;
  16. 解释双人控制、延迟确认与 break-glass 的不同作用;
  17. 为每个 SLI 固定数据源、查询、维度与缺失语义;
  18. 区分页型症状、诊断原因和容量工单;
  19. 使用 multiwindow、multi-burn-rate 作为可调起点;
  20. 让每个 page 绑定用户影响、所有者、Runbook 和首个安全动作;
  21. 监控监控系统自身,而不把“没有数据”解释为健康;
  22. 为配置、变更、访问、恢复、SLO 与事件建立证据目录;
  23. 区分普通日志、审计记录、决策证据与合规结论;
  24. 用哈希、时间、身份、最小化和保留政策建立证据链;
  25. 自动检查治理不变量,并用反例证明检查真正会失败;
  26. 对一个环境给出“合同通过、生产待决”的诚实结论。

本章不做什么

本章不会把组织设计冒充 PostgreSQL 参数调优,也不会:

  • 承诺一个普适的 99.99%;
  • 用书中的角色标识代替真实值班表;
  • 把沙箱一次成功切换称为生产 RTO;
  • 把备份任务退出码称为恢复证明;
  • 把现成组件指标直接拼成业务 SLO;
  • 发布真实告警、发送真实 page;
  • 以“合规”为名收集密码、密钥或完整客户行;
  • 让审批替代技术停止线;
  • 让自动化替代业务和风险授权。

pg36_shop 在本章仍是 synthetic teaching service。数值是可计算的政策输入, 不是与真实业务 owner 谈判后的生产承诺。

前置与后续

本章收束第 19–23 章已经获得的事实:

这些章节证明了机制,但机制不会自动成为服务承诺。例如:

ch20 observed write gap       != production RTO
ch21 one successful restore   != ongoing recoverability
ch22 pool capacity sample     != production concurrency limit
ch23 RLS mechanism passes     != organization has approved data policy

下一章 第 25 章 监控体系与可观测诊断 将实现本章输出的 指标和规则。先定语义、后写查询,是两章之间最重要的边界。

一张图看完整合同

service card
  service / data / platform / security owner
  user journeys / dependency / tier obligations
  escalation / known gaps
        |
        v
SLO policy
  eligible event / good event / measurement point
  target / rolling window / exclusion
  error-budget consequence
        |
        v
observation contract
  source / query / labels / missing semantics / fallback
        |
        +------------------------+
        |                        |
        v                        v
symptom & integrity alerts       component telemetry
page / ticket / runbook          PG / pool / host / control plane
        |                        |
        +------------+-----------+
                     v
SOP / runbook / drill / change plan
  authority / target / stop line / verify / rollback-or-roll-forward
                     |
                     v
evidence manifest
  source / collector / time / target / hash / decision / retention

任何断点都会制造假治理:

断点 表面现象 真正风险
无服务卡 大量组件指标 不知道为谁服务、谁决策
无 SLI 语义 有阈值 不知道分子、分母和缺失意味着什么
无预算政策 有 SLO 图 可靠性结果不影响发布决策
无动作合同 有 page 值班只能临场猜测
无停止线 有 SOP 文档只会推动动作,不能阻止事故
无验证 命令成功 状态是否正确仍未知
无证据边界 日志很多 不能证明目标、授权与结果,还可能泄密

五种容易混淆的对象

服务目录项

描述“谁向谁提供什么能力,以及依赖、等级和责任”。它不是机器清单。

SLI

一个实际测量值。例如:

$$ \text{availability SLI}

\frac{\text{good eligible order attempts}} {\text{all eligible order attempts}} $$

SLO

对某个窗口内 SLI 的目标。例如“滚动 28 天内至少 99.9%”。它不是法律赔偿 条款;后者通常属于 SLA。

控制目标

不适合用平均错误率淡化的要求。例如“没有未解释的账实不符”与“90 天内有 一次通过的隔离恢复”。一次数据串租不能因为本月另有一千万次正确读取而变得 可以接受。

错误预算政策

当可靠性好或差时,组织具体改变什么。没有后果的错误预算只是 KPI:

healthy      -> 正常评审节奏
watch        -> 减少并行高风险变更
constrained  -> 暂停非必要高风险发布,例外需共同批准
exhausted    -> 冻结非紧急高风险变更,优先修复可靠性

本章采用的目标

本章服务卡定义五个目标:

ID 类型 目标
SLO-AVAILABILITY ratio SLO 被接纳的下单尝试得到可核对的成功结果
SLO-LATENCY ratio SLO 被接纳的下单尝试在 250 ms 内完成
SLO-FRESHNESS ratio SLO 带已知 commit token 的读取在 5 s 内可见
CTRL-CORRECTNESS control 无未解释的重复、串租、金额或状态错误
CTRL-RESTORE-READINESS control 90 天内有通过的隔离恢复证据

它们有意不使用:

PostgreSQL process is running
Patroni reports one leader
HAProxy backend is UP
replica replay timestamp looks recent
backup job exited zero

这些都是有价值的组件事实,但只能解释服务为什么好或坏,不能单独回答用户 是否获得了正确服务。

可计算的错误预算

可用性目标为 99.9%,滚动窗口为 28 天:

allowed bad ratio=10.999=0.001 \text{allowed bad ratio} = 1 - 0.999 = 0.001

若窗口内有 10,000,000 个 eligible events:

event budget=10,000,000×0.001=10,000 \text{event budget} = 10{,}000{,}000 \times 0.001 = 10{,}000

若为了直觉把比例换算成连续时间:

28×24×60×0.001=40.32 minutes 28 \times 24 \times 60 \times 0.001 = 40.32\text{ minutes}

40.32 分钟只是等价解释。request-based SLO 的实际预算仍是事件,不应把一 小时的低流量故障与一小时的流量高峰当成同一件事。

burn rate 定义为:

$$ \text{burn rate}

\frac{\text{observed bad-event ratio}} {1-\text{SLO target}} $$

对 99.9% 目标,14.4 倍 burn 对应 1.44% bad ratio。Google SRE Workbook 给出的 multiwindow 起点是:

route 长窗 短窗 burn 按本章 28 天窗口约消耗预算
page 1 h 5 min 14.4x 1 h 内 2.14%
page 6 h 30 min 6x 6 h 内 5.36%
ticket 3 d 6 h 1x 3 d 内 10.71%

这是起点,不是常数。实际规则必须按流量、后果和 notification cost 调整。 原始推导见 Google SRE Workbook 的 Alerting on SLOs

观察与告警的边界

Pigsty v4.5 提供:

VictoriaMetrics       time-series ingestion / storage / query
VictoriaLogs          structured log storage / query
VMAlert               rule evaluation
Alertmanager          grouping / inhibition / routing / notification
Grafana               dashboards and investigation entry

PostgreSQL、PgBouncer、HAProxy、Patroni 和主机事实通过 clsinsip 等身份维度关联。当前实现说明以 Pigsty Monitoring SystemPGSQL Monitoring 为准。

但平台不能凭空产生业务语义。第 25 章还需要实现:

pg36_shop_request_outcomes_total
pg36_shop_request_duration_seconds
pg36_shop_commit_visibility_probes_total
pg36_shop_reconciliation_mismatches
pg36_shop_restore_evidence_age_seconds

这些名称是本书的应用合同,不是 Pigsty 当前内置指标。将来改名可以,改变 eligible/good/missing 语义则必须重新评审 SLO。

四类操作文档

文档 何时使用 核心区别
SOP 可重复的日常动作 已知输入、稳定步骤、例行验证
Incident Runbook 症状已经发生 先保安全、边诊断边决策
Recovery Drill 证明恢复路径 隔离、预设验收、保留计时与结果
Change Plan 一次具体变更 精确目标、窗口、版本、批准与回退

“切主 SOP”这个名称可能掩盖两种完全不同的动作:

planned switchover
  current leader healthy
  authority and candidate known
  client gap can be measured

unplanned failover
  failure and write authority may be ambiguous
  fencing comes before promotion
  unknown outcomes must be reconciled

不能因为两者最终都出现“新主库”,就复用同一套前提和停止线。

高风险动作的基本不变量

本章把动作分成 L0–L3:

等级 典型动作 基本要求
L0 只读观察、验证证据 精确目标,不改变远端状态
L1 有界、可逆、低影响变更 预览、验证、回退
L2 权限、模式、池或运行态变更 独立批准、停止线、证据
L3 删除、恢复、拓扑和大影响动作 双人控制、延迟确认、强制门禁

L2/L3 使用不同的 requester、approver 与 executor。双人控制并不是两个人 盯着同一条未核对的命令按回车;合格的独立批准人必须检查:

  • 目标是否精确;
  • 影响半径是否可信;
  • 前置事实是否新鲜;
  • 动作是否与已批准 artifact hash 一致;
  • stop condition 是否机器可判定;
  • 回退/前滚是否真的可执行;
  • 未知结果是否会被错误重试。

NIST SP 800-53 Rev. 5.1 的变更访问限制包含 dual authorization。它是风险控制的参考,不意味着每个 组织必须机械照搬同一审批流。

正式实验

本章提供一套纯 L0 实验:

正式运行:

run id          34909737-527a-460c-927c-d9d71c93aa13
captured        2026-07-29T21:57:40.539Z
target          pg36-l2-vagrant/pg-test
mode            read-only
mutation        none
service owners  4 functions
objectives      3 ratio + 2 control
alerts          7 accepted + 1 actionless rejected
SOPs            4
evidence types  6
counterexamples 20 rejected
upstream runs   ch20–ch23, bound by run id and SHA-256
production      pending

重新执行第 19 章只读 gate 后,四台主机与四个 PostgreSQL member 仍通过 accepted-with-exceptions;六项沙箱例外没有被隐藏。

形式化验证故意尝试:

  • 宣称 production SLO;
  • 删除 platform owner;
  • 把 process alive 当服务健康;
  • 把目标改成 100%;
  • 排除计划维护;
  • 把缺失数据当健康;
  • 让 exhausted budget 没有后果;
  • 给指标加入未约束客户标识;
  • 接受没有动作的 page;
  • 让 cause/capacity 直接 page;
  • 把 backup exit 0 当恢复证明;
  • 让一人自批自执行;
  • 让 break-glass 跳过目标与证据;
  • 允许 evidence 保存 secret;
  • 伪造上游 restore run id;
  • 把一次沙箱切换称为生产证明。

二十个变体全部被拒绝。实验没有部署告警,也没有联系真实值班人。

本章目录

24.1 服务目录与责任模型

24.2 SLI、SLO 与错误预算

24.3 SOP、Runbook 与变更治理

24.4 观察与告警契约

24.5 证据、审计与合规

24.6 实战:把 pg36_shop 纳入服务治理

权威资料

这些资料提供方法和组件事实;pg36_shop 的服务语义、阈值和治理政策仍由 本章合同负责。

章末验收

不要用“文档已经发布”验收本章。应当能回答:

  • 能否从一次用户动作追到 eligible/good 事件定义?
  • 能否指出这个定义在哪个测量点实现?
  • 没有数据时,系统是 unknown、failed 还是有独立 fallback?
  • 正确性错误是否会被平均比例掩盖?
  • 计划维护是否仍反映在用户体验中?
  • 错误预算状态是否改变变更权限和速度?
  • 每个 page 是否有 owner、runbook、第一安全动作和恢复验证?
  • cause metric 是否只用于诊断,而不会重复 page?
  • capacity 是否形成有期限的 owned ticket?
  • 高风险动作能否被同一身份申请、批准和执行?
  • break-glass 是否仍绑定目标、时限、证据和轮换?
  • backup success 之外,是否有隔离恢复与应用验收?
  • evidence 是否能证明 source、target、time、collector 和 hash?
  • evidence 是否明确不保存 secret 与不必要的个人数据?
  • 能否运行反例并看到 validator 真实拒绝?
  • 是否清楚哪些结论仍然 production pending

如果这些问题没有答案,再多告警、审批单和仪表盘也只是组织噪声。


上一章:固若金汤:认证、授权与数据安全 · 返回下卷导读 · 下一章:望闻问切:监控体系与可观测诊断 · 查看全书目录 · 查看索引中心

24.1 服务目录与责任模型

PostgreSQL 集群是技术对象,服务是责任对象。

technical object
  cluster pg-test
  members pg-test-1/2/3
  ports 5433/5434/5436/5438
  database test

service object
  pg36_shop
  journey place-order / read-order
  owner / dependency / SLO / escalation / evidence

一个集群可以承载多个服务;一个服务也可以依赖多个数据库、队列和外部接口。 若资产目录只记录 IP、实例和版本,事故发生时仍不知道谁能决定:

  • 是否暂停下单;
  • 是否允许读到旧数据;
  • 是否执行 failover;
  • 是否接受恢复到某个时间点;
  • 是否通知受影响用户;
  • 是否冻结发布;
  • 谁能关闭事件。

本节先把这些决定分配给职责,再讨论服务等级、依赖与健康证明。

24.1.1 服务所有者、数据所有者与平台所有者

owner 不是“出问题时帮忙的人”

owner 是对某类决定最终负责的职能。它可以把任务委托给执行者,但不能把 accountability 变成“大家共同负责”。

本章采用四个稳定职能:

职能 最终负责什么 不应独自决定什么
service owner 用户旅程、SLO、发布优先级、业务降级 数据含义、底层恢复细节
data owner 数据语义、质量、保留目的、隐私分类 集群拓扑与数据库执行步骤
platform owner PostgreSQL/Pigsty、容量、备份恢复、平台执行 用户可接受的损失与业务优先级
security/privacy 访问政策、安全例外、证据披露、隐私升级 正常业务功能取舍

这四者不是四个超级管理员。理想状态恰恰相反:

service owner          does not need database superuser
data owner             does not need shell access
platform owner         does not invent data meaning
security/privacy       does not run every routine migration

权限和责任要能分开。第 23 章已经把 login、runtime、owner 与 break-glass 角色拆开;本章把组织责任也做同样的拆分。

service owner

service owner 负责从用户视角定义合同:

journey:
  place-order

eligible:
  authorized, valid request admitted by the application

good:
  success returned and idempotency token reconciles to one committed order

degradation:
  stop new writes, preserve read-only status lookup

它不能只写“数据库可用”。若应用在 PostgreSQL 正常时返回 500,用户仍然 没有获得服务。

service owner 还要在错误预算耗尽时决定:

  • 暂停哪些功能发布;
  • 哪些可靠性工作优先;
  • 是否启用产品降级;
  • 哪些业务风险值得例外。

这些决定必须与 platform owner 共同评审技术可行性,但不能完全下放给 DBA。

data owner

data owner 负责说明“一行数据代表什么”和“错误意味着什么”:

order_id          是否全局唯一
tenant_id         谁能看见
amount            使用什么货币和舍入规则
status            允许哪些状态迁移
committed order   用户是否已经获得不可撤销承诺

恢复时,platform owner 可以证明 PostgreSQL 启动、system identifier 正确、 恢复目标已经到达;data owner 仍要判断:

  • 目标时间是否符合业务容损;
  • keep 应当存在、discard 应当不存在是否足够;
  • 订单、库存和支付是否需要跨系统核对;
  • 哪些差异要停止业务而不是继续恢复。

因此“数据库恢复成功”与“业务恢复可接受”是两个验收签名。

platform owner

platform owner 维护的是可交付数据库服务:

  • Pigsty inventory 和渲染产物;
  • PostgreSQL 初始化与运行参数;
  • Patroni/etcd 角色与控制面;
  • HAProxy/PgBouncer 入口;
  • pgBackRest/WAL 恢复路径;
  • 容量、升级、安全基线;
  • 指标、日志、告警与值班执行。

它必须能把平台事实翻译成服务后果。例如:

pg-test-2 replay lag rises
  -> affected path: replica reads
  -> user consequence depends on read routing and freshness contract
  -> primary writes may still be healthy
  -> do not page the service owner merely because one cause metric is high

平台 owner 也不能用“基础设施都绿”关闭事故。关闭条件应来自用户 SLI、 数据核对与恢复验证。

security/privacy owner

数据库治理产生敏感证据:

  • 谁请求、批准和执行;
  • 哪个身份获得了什么权限;
  • 哪条查询失败;
  • 哪个租户或事件受影响;
  • 备份和日志保留多久;
  • incident evidence 向谁披露。

security/privacy owner 决定访问、例外和披露边界。它不意味着把所有日志永久 保存。收集过多原始 SQL、bind value 或客户行,会让“审计系统”变成新的数据 泄露面。

incident commander 不是第五个永久 owner

incident commander 是事件期间的协调角色:

declares severity
maintains decision log
assigns technical lead
coordinates communication
checks stop conditions
decides next checkpoint
closes or hands off incident

它可以由 service owner 职能担任,也可以按事件规模另行指定。技术 lead 负责执行和验证,不应同时独占业务影响判断、通信和所有批准。

使用职能身份,不把人名写死在合同里

服务卡中的:

{
  "function": "platform",
  "role_id": "database-platform-owner",
  "on_call_route": "route://db-platform-oncall"
}

描述稳定接口。真实系统再把 role_id 映射到:

  • 身份目录 group;
  • 当前 primary/secondary on-call;
  • 可达的通知渠道;
  • 时区与交接;
  • 替补和 manager escalation。

只写“找小王”有三个问题:

  1. 人员变化会让文档瞬间过期;
  2. 不知道非工作时间找谁;
  3. 个人名字不能证明权限和当班状态。

反过来,只写一个永远没人测试的 db-oncall@example 也不算 owner。路由必须 通过 notification canary 定期证明可达。

不要把 RACI 做成责任迷宫

RACI 可以帮助列出 Responsible、Accountable、Consulted、Informed,但最常见 的失败是每个格子都填满:

five accountable owners
ten consulted teams
no person authorized to stop

对一个决定,应当尽量有一个最终 accountable function:

决定 A R C
修改用户 SLO service reliability analyst platform/data
执行 planned switchover platform qualified operator service
接受恢复点 data platform recovery lead service/security
暂停下单 service application operator data/platform
批准高权访问 security/privacy identity admin platform/data

“A” 不是可以跳过技术门禁的权力。service owner 可以要求恢复服务,但不能 要求 platform operator 在 fencing 未知时强制 promote。

所有权的验收问题

每个 owner 都应回答三类问题:

decision
  我能批准或拒绝什么?

evidence
  我需要看见哪些事实?

absence
  我不在时由哪条已测试路径接替?

若答案只有“出了事群里讨论”,责任模型尚未建立。

24.1.2 等级、规格、依赖、值班与升级路径

服务等级是一组义务

goldsilverbronze 如果没有义务,只是颜色:

Gold PostgreSQL
  ? on-call coverage
  ? SLO
  ? restore frequency
  ? RTO/RPO
  ? upgrade deadline
  ? security review
  ? capacity headroom
  ? evidence retention

可执行的 tier 应当打包:

维度 需要固定的内容
availability 用户 SLI、目标、窗口、预算政策
recovery 恢复类型、证据新鲜度、演练频率
support 值班时段、确认目标、升级链
change 风险级别、批准、窗口、冻结规则
capacity headroom、预测周期、扩容 lead time
security 认证、加密、轮换、审计与例外
lifecycle PostgreSQL/Pigsty 版本和升级时限
evidence 保留期、访问、完整性与删除

本章的 sandbox-reference 明确写:

production tier        false
on-call                illustrative route only
capacity               no laptop sizing inference
restore                one retained sandbox run
change                 L2/L3 independent authority
security               mechanism evidence, production gaps retained

这比给沙箱贴上“Silver”诚实得多。

规格不是只有 CPU 和内存

数据库 service specification 至少有四组:

用户规格

  • 关键旅程;
  • 可用性、延迟、正确性、新鲜度;
  • 写后读、一致性和降级行为;
  • 支持时段和沟通。

数据规格

  • 数据分类;
  • 主权和保留;
  • 容许丢失和恢复粒度;
  • tenant/authorization 边界;
  • 核对不变量。

技术规格

  • PostgreSQL major 与扩展;
  • 拓扑、同步策略、入口;
  • pool mode 与连接预算;
  • 备份、WAL 和恢复设施;
  • 监控、日志和依赖。

运营规格

  • owner/on-call;
  • SOP 和演练;
  • 变更冻结;
  • 证据与审计;
  • 已接受例外和到期日。

只记录 4C16G500G,不能回答任何恢复、安全和服务问题。

依赖必须写 failure semantics

依赖表不能止于“uses PostgreSQL”:

依赖 关系 失败语义
application runtime 发出用户事件、提交事务 PG 健康时应用仍可拒绝或错误提交
HAProxy/PgBouncer 承担入口与池化 直连主库成功不代表应用入口成功
PostgreSQL 事务与持久状态 连接成功不代表业务事务正确
Patroni/etcd 角色和切换控制 控制面受损可先阻止安全切换
pgBackRest/WAL 恢复路径 backup exists 不代表可恢复
Victoria stack 观察和告警 telemetry missing 会把状态变成 unknown

failure semantics 回答:

dependency fails
  -> which journey is affected?
  -> fail closed, fail open, degrade, or become unknown?
  -> who owns first diagnosis?
  -> what is the first safe action?

例如 etcd 暂时不可用时,现任 primary 可能继续服务;真正受损的是安全 role change 能力。若把“etcd member down”直接等同于用户 outage,会制造误报; 若完全忽略,又会在需要 failover 时才发现控制面失效。

把共享故障域写进依赖

第 19 章的四台 VM 看起来有四个地址,但共享:

  • 一台 laptop;
  • 一个 hypervisor;
  • 一套供电和网络;
  • 同一底层存储;
  • 单节点 etcd;
  • 单一 MinIO/control host。

因此拓扑图中的三节点不能推出三个独立 failure domain。依赖模型应同时表达:

logical redundancy = 3 PostgreSQL members
physical independence = not established

这也是为什么服务卡保留 known production gaps,而不是把它们埋在实验日志里。

值班路径需要输入与输出合同

一条值班 route 至少固定:

input
  severity
  service id
  user impact
  start time
  alert id
  dashboard
  runbook

output
  acknowledged by whom
  incident id
  technical lead
  next update time
  decision log

确认时限不是修复时限:

acknowledge 5 minutes
  != resolve 5 minutes
  != RTO 5 minutes

ack 只证明有人接管。恢复目标必须按故障类别、数据边界和服务合同另行定义。

escalation 不是把同一消息抄送更多人

有效升级改变权力、资源或沟通:

SEV-2
  service on-call + platform on-call
  bounded user degradation

SEV-1
  incident commander
  data owner for correctness/recovery decision
  security/privacy for breach or evidence handling
  executive/customer communication when required

升级触发可以来自:

  • 影响范围扩大;
  • 数据正确性或保密性受威胁;
  • 错误预算快速燃烧;
  • 操作超过停止线;
  • recovery path 不确定;
  • 事件超过时间阈值;
  • 现有 authority 不足。

不要只按“CPU 超过 90%”升级。CPU 是原因候选,不是业务严重度。

依赖与升级的服务卡片段

本章完整文件见 service-card.json。其中一项依赖:

{
  "id": "pgbackrest-repository",
  "kind": "recovery-system",
  "relationship": "stores base backup and WAL needed for recovery",
  "failure_semantics": "a recent backup job does not prove the selected recovery point is usable",
  "owner_function": "platform"
}

owner_function 负责第一技术响应;最终恢复点是否可接受仍需要 data/service owner。

服务目录的最小字段

identity
  service_id / display_name / environment / catalog_status

purpose
  business capability / customer journeys / critical operations

scope
  data class / production boundary / database / cluster / entrypoints

ownership
  service / data / platform / security

tier
  explicit obligations

dependency
  relation / failure semantics / owner

health
  user contract and component layers

escalation
  severity / trigger / role / acknowledgement

governance
  SLO policy / SOP catalog / evidence policy / known gaps

每次更改服务卡,应检查对应的 SLO、告警 route、SOP 与权限是否一起更新。

24.1.3 实例健康不等于业务服务健康

六层证明

本章使用六层健康链:

1 process
  expected daemon exists

2 endpoint
  declared client path is reachable and routes correctly

3 authentication
  intended identity over intended transport can authenticate

4 transaction
  intended operation can commit and its outcome can be reconciled

5 correctness
  domain, tenant and authorization invariants hold

6 durability/recovery
  acknowledged state survives declared failures and can be restored

没有任何一层单独足够。服务健康还必须满足用户合同。

process alive 只证明进程事实

systemctl is-active postgresql 或 exporter 的 up 指标能证明采集时进程存在。 它不能证明:

  • postmaster 接受连接;
  • 监听的是正确地址;
  • HBA 允许目标身份;
  • server 是正确集群;
  • 当前节点可写;
  • 磁盘还有空间;
  • transaction 能提交;
  • 应用 SQL 正确。

进程指标适合组件诊断和自动重启,不应直接成为业务 availability 分子。

pg_isready 也有边界

pg_isready 检查 PostgreSQL server 的连接状态,返回 accepting、rejecting 或 no response。它不需要提供正确用户名、密码或数据库才能获得 server status;错误参数还可能在服务器日志留下失败连接。官方边界见 pg_isready

因此:

pg_isready success
  -> a PostgreSQL server at this endpoint is accepting connection attempts

not proven
  -> intended application can authenticate
  -> intended database and role are correct
  -> intended transaction succeeds

SELECT 1 证明得更多,但仍不够

使用真实入口、真实 TLS 约束、合成身份执行:

SELECT
  current_database(),
  session_user,
  current_user,
  pg_is_in_recovery(),
  current_setting('server_version');

能证明:

  • 网络和 PostgreSQL 协议;
  • 认证;
  • database/role 身份;
  • 当前节点 recovery 状态;
  • 简单 query path。

它仍然绕过:

  • 业务表;
  • lock/constraint/RLS;
  • write permission;
  • commit;
  • connection pool session semantics;
  • 应用序列化和错误映射。

transaction probe 必须处理未知结果

更接近用户的 probe:

generate unique synthetic token
  -> call declared application or database write path
      -> commit
          -> query token through declared result path
              -> classify exactly one of:
                   committed once
                   absent
                   duplicate
                   unknown

连接在 COMMIT 附近断开时,客户端可能不知道事务是否已经提交。直接重试会 造成重复。probe 和 Runbook 都必须使用幂等 token 核对结果。

第 20、22 章的切换演练正是按:

acknowledged
unknown committed
unknown absent
duplicate
unreconciled

分类,而不是把所有连接错误都计为“数据库没写入”。

correctness 不能由事务成功代替

下面都可能 commit 成功:

  • 订单写到了错误 tenant;
  • 金额舍入错;
  • 同一个 idempotency token 生成两张订单;
  • RLS context 泄漏;
  • 状态从 cancelled 跳回 paid
  • 应用返回失败但后台已提交。

所以 good availability event 必须包含结果核对,correctness control 还要独立 运行领域 reconciliation。

PostgreSQL constraint、unique index、foreign key、RLS 与 transaction isolation 是防线,不是所有业务正确性的完整证明。

replica “时间延迟”会在空闲时误导

常见查询:

SELECT now() - pg_last_xact_replay_timestamp();

在没有新 WAL 的空闲系统中,这个差值会持续增大,即使 replica 已完全追平。 它回答“最后一个已 replay transaction 的时间距现在多久”,不自动等于当前 replication lag。

对写后读新鲜度,应使用:

commit-correlated token
  write through declared write path
  record commit outcome
  poll declared read path
  measure visibility bound

诊断时再结合:

  • primary 与 replica WAL LSN distance;
  • pg_stat_replication
  • pg_stat_wal_receiver
  • replay pause/conflict;
  • route 选择;
  • 客户端时钟。

PostgreSQL 18 的统计视图目录见 Monitoring Database Activity

cluster healthy 也不等于 entrypoint healthy

可能出现:

Patroni:
  one leader + two streaming replicas

but:
  HAProxy still routes old leader
  PgBouncer holds stale server connections
  DNS/VIP is unreachable from application network
  client certificate name mismatches
  pool user is absent

因此健康检查要从用户网络和声明入口执行。直接 SSH 到 primary 后 psql 成功 只能作为绕过入口的诊断事实。

Pigsty 提供组件视角,不替应用发明 good event

Pigsty 能关联:

典型事实
host CPU、memory、disk、network、kernel
PostgreSQL activity、transactions、locks、WAL、I/O
PgBouncer clients、servers、wait、pool
HAProxy frontend/backend/session/health
Patroni/etcd role、timeline、control state
pgBackRest backup/check/log
observability scrape、rule evaluation、notification

clsinsip 让这些事实能关联到同一个 Pigsty 对象。应用仍需要提供:

  • operation class;
  • eligible/good outcome;
  • user latency;
  • commit token;
  • domain reconciliation;
  • service/environment。

不要在时序标签里放 order_idtenant_id、完整 SQL 或 error message。它们会 造成无界 cardinality,也可能泄露数据。

三个判断练习

情形 A:一台 replica down,用户指标正常

user availability       healthy
freshness               healthy
remaining redundancy    degraded

处理:

  • 不必重复 page 所有业务 owner;
  • platform ticket 或受控事件;
  • 检查另一 replica、WAL 保留与恢复能力;
  • 若 redundancy policy 已触及耐久性停止线,可升级。

情形 B:三台 PostgreSQL 都绿,下单 100% 失败

database component      healthy
service availability    failing

处理:

  • page service symptom;
  • 检查应用 release、identity、pool entry、schema compatibility;
  • 不要因为 PG dashboard 绿色而关闭;
  • platform 指标用于定位原因。

情形 C:请求 99.999% 成功,但出现一条跨租户数据

ratio availability      excellent
correctness control     failed
severity                potentially SEV-1

处理:

  • 冻结相关写入;
  • 保留 reconciliation boundary;
  • security/privacy 与 data owner 加入;
  • 不能用充足错误预算继续发布。

健康矩阵

观测 可以证明 不能证明
process active daemon 存在 endpoint/auth/transaction
pg_isready server 接受连接尝试 身份、业务 SQL
authenticated SELECT 1 入口、认证、query write/commit/correctness
write token visible 某次事务与可见性 全量业务正确
reconciliation zero 已覆盖不变量未见差异 未覆盖规则、未来恢复
restore drill 某份证据下可恢复 所有故障、生产 RTO
user-event SLI 窗口内用户结果 根因

这张表说明为什么需要两套视角:

SLI tells whether users are hurt
component telemetry tells why and what to do

服务卡审查清单

  • service_id 是否稳定且不等于某台机器?
  • 是否列出关键 user journey 与 operation class?
  • service/data/platform/security 是否各有明确 accountability?
  • owner 是否映射到可测试 route,而不是个人名字?
  • tier 是否列义务,而不是颜色?
  • 每个依赖是否写 failure semantics 和 owner?
  • logical redundancy 与 physical failure domain 是否分开?
  • severity 是否来自用户、数据和恢复后果?
  • instance、cluster、endpoint 和 service health 是否分开?
  • synthetic probe 是否使用真实入口、身份和业务路径?
  • commit 附近的 unknown outcome 是否可核对?
  • correctness 和 durability 是否有独立 control?
  • known production gaps 是否在服务卡而非某个聊天记录里?

完成这张服务卡后,下一节才能计算 SLO。否则百分比没有分子、分母、责任人或 决策后果。


返回本章目录 · 下一节:SLI、SLO 与错误预算 · 查看全书目录 · 查看索引中心

24.2 SLI、SLO 与错误预算

“数据库必须高可用”不是 SLO。它没有说明:

  • 测什么;
  • 从哪里测;
  • 什么算好;
  • 什么进入分母;
  • 多长窗口;
  • 允许多少失败;
  • 没有数据怎么办;
  • 未达标后改变什么。

可执行的 SLO 至少是:

service + journey
  + eligible event
  + good event
  + measurement point
  + target
  + window
  + exclusions
  + missing-data semantics
  + error-budget policy

本节从用户事件开始,而不是从 PostgreSQL exporter 已经有什么指标开始。

24.2.1 可用性、延迟、正确性与数据新鲜度

先分清五个术语

术语 含义 例子
SLI 实际测量值 28 天 good/eligible = 99.93%
SLO SLI 的内部目标 28 天至少 99.9%
SLA 对外承诺与后果 未达 99.9% 触发服务补偿
error budget 目标允许的 bad events 0.1% eligible events
control objective 不适合平均掉的控制 无未解释数据差异

SLA 可能引用 SLO,但两者不是同义词。本章只设计内部服务治理合同,不起草 法律或商务条款。

ratio SLI 的基本形式

Google SRE Workbook 建议优先把 SLI 写成 good events 与 total/eligible events 的比率,因为它自然落在 0–1,并能与预算相连:

$$ \text{SLI}

\frac{\text{good events}} {\text{eligible events}} $$

详见 Implementing SLOs

关键不是公式,而是 event classification。

可用性:成功必须能核对

本章的下单可用性:

eligible
  authorized
  syntactically valid
  admitted at application boundary

good
  declared success response
  AND idempotency token resolves to exactly one committed order

事件分类:

情形 eligible good 原因
合法请求,提交并返回成功 完整成功
合法请求,应用 500 用户失败
合法请求,连接在 commit 后断开,尚未核对 否,直到核对 不把 unknown 假装成功
合法请求,返回成功但查不到订单 错误承诺
合法请求,创建两张订单 幂等性失败
语法不符合已发布 API 不适用 未被服务接纳
正确拒绝无权限身份 不适用 安全合同正常工作
应有权限却因配置错误被拒绝 服务失败,不是“安全排除”
服务接纳后客户端取消 依实际结果 不能事后从分母删除

HTTP 2xx、SQL 无异常或 transaction commit 任一单独都不够。good event 应当 表达用户获得的承诺。

不要用“成功查询数”混合所有操作

高流量健康查询会淹没低流量关键操作:

health endpoint     100,000,000 good
place-order              1,000 all failed
combined SLI                99.999%

所以 operation class 必须按后果拆分:

  • place-order
  • read-own-order
  • admin-report
  • background-reconcile

不能为得到好看的总数,把不同用户旅程放入同一个分母。

延迟:问“多少事件够快”,不要只看平均值

本章延迟目标:

eligible:
  same admitted order attempts as availability

good:
  final reconciled response completes within 250 ms

target:
  99% over rolling 28 days

相应 SLI:

$$ \text{latency SLI}

\frac{ \left|\left{e \in E: good(e) \land latency(e) \le 250\text{ ms} \right}\right| }{ |E| } $$

其中 EE 是所有 eligible events;快速失败虽然耗时很短,也不进入分子。

快速失败不应自动成为 latency good。若一个请求 5 ms 返回 500,它在用户旅程 上既不可用,也没有完成目标动作。

平均延迟会掩盖尾部:

99 requests * 10 ms
1 request    * 10 s
average        109.9 ms

平均值看似低于 250 ms,但最慢用户等了十秒。比例 SLO 或分位数更能表达尾部。 用于预算时,固定阈值的 good/eligible 比例比“p99 的月平均”更容易严格累计。

histogram 边界必须在采集前确定

若 histogram 没有 0.25 秒 bucket,事后无法从聚合数据精确回答“多少请求 低于 250 ms”。因此 SLO 先于 instrumentation:

SLO threshold 250 ms
  -> histogram bucket includes 0.25
  -> counter labels fixed
  -> recording rule fixed
  -> alert query fixed

这正是本章先写观察契约、下一章才实现指标的原因。

正确性:不要用 99.9% 原谅数据错误

某些正确性可以定义为比例,例如非关键搜索结果质量。但本章的订单不变量:

  • 一个 idempotency token 只对应一张订单;
  • tenant A 看不到 tenant B;
  • 金额和状态迁移合法;
  • acknowledged order 不丢失;

使用 control objective:

success:
  scheduled or incident reconciliation finds zero unexplained mismatch

failure:
  page
  freeze affected writes
  preserve reconciliation boundary
  investigate and repair under authority

这不是声称软件永远不会出错。它是规定“发现一条此类错误时,组织不能用剩余 错误预算继续正常发布”。

正确性 control 需要:

  • 明确覆盖哪些 invariant;
  • 固定 input boundary;
  • 记录 job/version/query hash;
  • 区分 known exception 与 unexplained mismatch;
  • 防止 repair 覆盖原始证据;
  • 定期证明 reconciliation 本身仍运行。

数据新鲜度:必须与某次 commit 关联

“replica lag 小于 5 秒”有至少三种含义:

  1. 接收 WAL 与 primary 的 byte distance;
  2. replay 记录的 timestamp 距当前 wall clock;
  3. 用户的一次已提交写入在读取路径上何时可见。

用户关心第三种。前两种用于诊断。

本章 freshness event:

eligible
  probe has a known committed token
  probe uses the declared read path

good
  token becomes visible within 5 seconds

流程:

write unique token
  -> reconcile commit
      -> poll replica/read endpoint
          -> visible_at - committed_at

这样能覆盖:

  • WAL 传输和 replay;
  • HAProxy route;
  • PgBouncer;
  • query path;
  • cache;
  • 应用序列化。

now() - pg_last_xact_replay_timestamp() 在空闲时会增长,不能替代这个 probe。

恢复就绪不是可用性 SLO

服务连续运行 28 天,不代表能从灾难恢复。恢复 control:

success:
  isolated restore drill passed within 90 days
  AND after material recovery-path changes

verification:
  backup identity
  required WAL
  target marker
  application invariants
  isolated listener
  stopped postmaster

backup completed 只能作为输入证据。第 21 章已经证明:

backup
  -> WAL coverage
      -> restore
          -> start in recovery
              -> reach target
                  -> promote in isolation
                      -> verify data
                          -> stop

缺任一阶段,不能把 recovery readiness 标成 good。

四类目标的组合

目标 用户问题 主要测量 失败后果
availability 能否完成动作 outcome counter + reconciliation consume budget/page
latency 是否足够快 edge histogram consume budget/page
freshness 已提交状态何时可见 commit-token probe consume budget/route change
correctness 数据是否可信 invariant reconciliation freeze/page
restore readiness 能否恢复 isolated drill evidence age block risky change

一个绿色目标不能抵消另一个红色 control。

24.2.2 测量点、统计窗口与排除条件

测量点越靠近用户,覆盖越完整

一条请求的观测点:

client
  -> CDN / gateway
      -> application
          -> HAProxy
              -> PgBouncer
                  -> PostgreSQL
测量点 能覆盖 看不到
PostgreSQL SQL、transaction、lock、I/O gateway/app/network errors
PgBouncer pool wait、server assignment app correctness
application user operation、business outcome client edge/network
gateway user-visible HTTP result commit correctness unless correlated
client/synthetic end-to-end experience 所有真实用户分布

通常使用:

primary SLI          application edge or gateway
correctness join     commit/outcome reconciliation
fallback             independent synthetic path
diagnosis            Pigsty + PostgreSQL component telemetry

若应用不能立刻增加指标,可以暂用 synthetic probe,但要记录 coverage gap。不要 因为 PostgreSQL 指标更容易获得,就悄悄把 SLO 测量点向内移动。

事件记录与时序指标各有用途

原始事件适合核对:

event_time
operation_class
eligible
outcome_class
latency
release
synthetic/real
correlation_token_hash

时序 counter/histogram 适合在线聚合与告警。不要把每个 order id 放进 metric label;需要 drill-down 时,用受控 event/log store 通过 correlation id 连接。

一个教学用关系表:

CREATE TABLE slo_event (
    observed_at      timestamptz NOT NULL,
    service_id       text        NOT NULL,
    operation_class  text        NOT NULL,
    eligible         boolean     NOT NULL,
    good             boolean,
    latency_ms       integer,
    outcome_class    text        NOT NULL,
    release_id       text        NOT NULL,
    synthetic        boolean     NOT NULL
);

生产上不一定用 PostgreSQL 保存所有事件;这里用 SQL 展示语义。

过去 28 天:

WITH eligible AS (
    SELECT *
    FROM slo_event
    WHERE service_id = 'pg36_shop'
      AND operation_class = 'place-order'
      AND observed_at >= clock_timestamp() - interval '28 days'
      AND eligible
)
SELECT
    count(*)                                              AS eligible,
    count(*) FILTER (WHERE good)                          AS good,
    count(*) FILTER (WHERE NOT coalesce(good, false))     AS bad,
    count(*) FILTER (WHERE good)::numeric
      / NULLIF(count(*), 0)                               AS sli
FROM eligible;

good IS NULL 若表示 unknown,预算查询应暂按 bad 或单独阻断,而不能由 count(*) FILTER (WHERE good) 自动消失后仍声称完整。

延迟:

SELECT
    count(*) FILTER (
        WHERE good
          AND latency_ms <= 250
    )::numeric / NULLIF(count(*), 0) AS latency_sli
FROM slo_event
WHERE service_id = 'pg36_shop'
  AND operation_class = 'place-order'
  AND observed_at >= clock_timestamp() - interval '28 days'
  AND eligible;

这里要求 availability good 且足够快,避免快速错误通过延迟目标。

滚动窗口与日历报告是两件事

本章采用滚动 28 天:

at every evaluation:
  [now - 28 days, now]

优点:

  • 每天都代表同样长度;
  • 没有月初“预算重置”错觉;
  • 适合持续 alert/budget decision。

28 天是四周,不是自然月。财务、客户或合规可能要求 calendar month/quarter 报告,应单独生成,不要把两个窗口混成一个数字。

推荐节奏:

continuous     recording and alerts
weekly         service/error-budget summary
quarterly      objective and policy review
event-driven   review after major incident or architecture change

Google SRE 的 SLO 实施章节也讨论四周滚动窗口、周汇总和季度报告。这些是实践 起点,不是对所有组织的强制周期。

长窗口防止遗忘,短窗口缩短响应

只有月窗:

  • 稳定但反应慢;
  • 一次快速事故可能在总体比例中暂时不显眼。

只有 5 分钟窗:

  • 反应快;
  • 流量低时一个错误就剧烈波动;
  • 容易被短暂 blip 打扰。

multiwindow 同时要求长窗和短窗超过同一 burn threshold:

long window   proves material budget consumption
short window  proves condition is still active

这比简单 error_rate > 1% for 5m 更贴近 SLO 后果。

排除条件应在事件发生前定义

本章默认排除:

  • 在 admission 前被正确拒绝的语法错误;
  • 正确拒绝的越权身份;
  • 服务接纳工作前的客户端取消。

默认不排除:

  • 计划维护;
  • 发布导致的错误;
  • 内部依赖故障;
  • operator mistake;
  • 容量不足;
  • “已知问题”;
  • 未核对的 unknown result。

原因很简单:用户并不会因为中断有 change ticket 就得到服务。

若产品真的有公开维护窗口,应在服务合同中定义独立承诺,而不是事故后从事件 表删除数据。

例外必须不可追溯篡改

一个 SLO exception 至少记录:

exception_id
approved_before_event
exact start/end
affected operation classes
reason
independent approver
event count before exclusion
event count after exclusion
evidence manifest

禁止:

SLO missed
  -> label incident "maintenance"
      -> recompute report
          -> target met

这种做法破坏了 SLO 作为决策工具的价值。

telemetry 缺失不能等于零错误

PromQL 查询经常在 series 消失时返回空向量。若 dashboard 把空值渲染为 0, 会出现:

exporter down
ingestion broken
rule evaluation broken
dashboard shows 0 errors

每个 SLI 必须定义:

  • expected series cadence;
  • no-traffic 与 missing telemetry 如何区分;
  • absent() 或 freshness check;
  • 独立 synthetic fallback;
  • metamonitoring route。

本章统一原则:

missing = unknown-and-monitored
missing != healthy

低流量服务不能照搬高流量阈值

若 5 分钟只有两个请求,一个失败就是 50% error rate。可以:

  1. 运行 end-to-end synthetic probe;
  2. 延长窗口;
  3. 合并后果相同的 operation,但不能用无关流量稀释;
  4. 使用明确评审的 time-based 或 user-minute SLI;
  5. 对关键 batch 用“最近两次应完成周期”定义 freshness;
  6. 将零容忍 correctness control 独立出来。

降低告警灵敏度不能变成不观察低流量关键路径。

标签维度既要可诊断,也要有界

建议:

service
operation_class
environment
region / cell
outcome_class
synthetic

谨慎:

release_id     有界保留或 exemplar
sql_fingerprint
error_class

禁止无界:

customer_id
tenant_id
order_id
raw_sql
error_message

Pigsty 组件关联使用 clsinsip;应用 SLI 使用 service 维度。不要把 实例 identity 当成服务 identity。

24.2.3 错误预算如何约束变更速度

预算不是允许主动制造错误

目标 99.9% 不表示可以计划使用 0.1% 伤害用户。预算用于在可靠性与变化之间 做有数据的选择:

budget healthy
  -> can take reviewed change risk

budget burns fast
  -> stop adding correlated risk
  -> investigate user-impacting failures

budget exhausted
  -> prioritize reliability
  -> only emergency/security/legal/direct-repair exceptions

100% 通常不是合适的 ratio target:

  • 它没有 error budget;
  • 任一测量噪声都成为违约;
  • 团队会隐藏错误或停止变化;
  • 它仍不能保证 correctness 和 recovery。

“永不丢数据”这样的要求应拆成 durability/control design,而不是伪装成 100% request ratio。

事件预算

若目标 $T$,eligible event 数 $N$:

Bevents=N(1T) B_\text{events}=N(1-T)

本章:

N = 10,000,000
T = 0.999
B = 10,000 bad events

观察到 7,500 bad:

budget consumed=750010000=75% \text{budget consumed} = \frac{7500}{10000} = 75\%

剩余 25%,进入 constrained 边界。

等价时间预算

时间可用性解释:

Btime=W(1T) B_\text{time}=W(1-T)

28 天、99.9%:

window          2,419,200 seconds
budget          2,419.2 seconds
                40.32 minutes

不要把它用于篡改 event-based 结果。高峰五分钟可能消耗的用户失败数远大于 低谷四十分钟。

burn rate

$$ \text{burn}

\frac{\text{observed bad ratio}} {1-T} $$

99.9% 的 allowed bad ratio 是 0.001:

observed bad burn
0.05% 0.5x
0.10% 1x
0.60% 6x
1.44% 14.4x
10% 100x

1x 持续整个窗口恰好用完预算;14.4x 若持续,会非常快地耗尽。

为什么 fast burn 使用 AND

可用性 page:

bad_ratio_1h > 14.4 * 0.001
AND
bad_ratio_5m > 14.4 * 0.001

长窗证明这不是一个无关紧要的点;短窗证明问题仍在。若使用 OR:

  • 长窗已受历史事故影响但当前恢复,仍持续 page;
  • 短窗单个噪声也会 page。

具体查询和 for 将在第 25 章实现。

四状态政策

本章 policy:

healthy:剩余大于 50%

  • 正常评审发布;
  • 仍要投资预防性可靠性;
  • 不能因为预算充足跳过变更控制。

watch:25%–50%

  • 减少并发 L2/L3;
  • 每周分析最大预算消费者;
  • 提前安排修复。

constrained:0%–25%

  • 暂停非必要高风险功能;
  • material change 需 service + platform 共同例外;
  • 可靠性修复优先;
  • 加强观察窗口。

exhausted:小于或等于 0

  • 冻结非紧急高风险变更;
  • 打开 incident 或 reliability review;
  • 只允许直接恢复可靠性、安全、法律或紧急动作;
  • 恢复节奏需要记录共同决定。

policy 必须说明哪些变化仍能进行

“冻结所有变更”可能阻止修复。应按意图和风险判断:

变化 exhausted 时
新推荐功能 通常冻结
无关 UI 文案 可按低风险政策评审
修复当前错误原因 允许,但仍需安全门禁
安全凭据紧急轮换 允许,不能跳过证据
扩容避免迫近 outage 允许,需验证与回退
重新定义 eligible 排除错误 禁止
降低 SLO 让报表变绿 先与 stakeholder 重新谈判,不能追溯

错误预算影响速度,不取消风险管理。

预算的所有者

service owner
  owns user priority and release trade-off

platform/reliability owner
  owns measurement integrity and technical risk

data/security owner
  can impose non-budget controls for correctness/privacy

不能由开发团队单方面改分母,也不能由 DBA 单方面降低服务目标。

防止四种 gaming

事后改变 eligibility

错误发生后把它归类为“不算用户请求”。

把事故改名 maintenance

变更单不能让用户中断消失。

把测量点移到内部

gateway 错误很多时,改看 SELECT 1

用大流量稀释关键路径

把失败的下单和成功的健康检查合并。

防线:

  • versioned SLO policy;
  • event classifier tests;
  • before/after counts;
  • independent approval;
  • immutable exception;
  • change log;
  • 定期抽样原始结果。

从 SLO 到变更门禁

变更请求要读取当前预算:

change risk       L2 schema release
budget state      constrained
purpose           new feature
decision          defer

change risk       L2 schema release
budget state      constrained
purpose           remove source of current failures
decision          allow with joint approval, narrow blast radius,
                  canary, stop line and rollback

门禁输入必须固定时间:

budget snapshot at approval
budget snapshot immediately before execution

审批后若 fast-burn page 触发,执行器应自动停止,而不是拿旧截图继续。

本章 SLO 文件

完整机器可读政策:

重点不是 JSON 语法,而是同一 objective id 在三份文件中保持一致:

SLO-AVAILABILITY
  definition in slo-policy
  source/query/missing in observation-contract
  burn alerts in alert-candidates

validator 会拒绝 missing objective、100% target、planned-maintenance exclusion、 missing=healthy 和 exhausted-with-no-action。

SLO 评审清单

  • journey 与 operation class 是否明确?
  • eligible 是否能由代码或查询判定?
  • good 是否包含用户真正获得的结果?
  • unknown outcome 如何核对?
  • 快速失败会不会错误通过 latency?
  • correctness 是否被独立 control 保护?
  • freshness 是否与 commit token 关联?
  • restore readiness 是否来自恢复而非 backup job?
  • 测量点是否尽可能靠近用户?
  • rolling window 与 calendar report 是否分开?
  • exclusion 是否事先定义、不可追溯?
  • missing telemetry 是否变成 unknown?
  • 低流量是否有 synthetic 或其他明确策略?
  • event budget 算术是否有自动测试?
  • budget state 是否改变变更行为?
  • 是否禁止用改分母、改测量点和混流量 gaming?
  • 目标是否由真实 stakeholder 批准,还是仍为教学输入?

当这些问题有答案时,SLO 才能驱动告警和变更。下一节把这些决定放进可执行 操作流程。


上一节:服务目录与责任模型 · 返回本章目录 · 下一节:SOP、Runbook 与变更治理 · 查看全书目录 · 查看索引中心

24.3 SOP、Runbook 与变更治理

一份只有命令的文档,会让操作更快,却不一定更安全:

ssh node
run command
wait
run next command

它没有回答:

  • 这是哪种故障或变更?
  • 当前事实符合文档前提吗?
  • 操作者有权改变哪个目标?
  • 影响哪些用户和数据?
  • 哪一步必须停?
  • 命令超时后是失败、成功还是未知?
  • 如何证明结果,而不是只看 exit code?
  • 应当 rollback 还是 roll forward?
  • 哪些证据可以保留,哪些 secret 不能导出?

可执行文档必须同时是动作合同、停止合同和证据合同。

24.3.1 日常操作、故障处置与恢复演练

四类文档解决不同不确定性

文档 环境 不确定性 主要形态
SOP 日常、重复 输入和结果基本已知 线性步骤
Incident Runbook 已有症状 原因和状态未知 分支与决策点
Recovery Drill 受控演练 恢复路径能否工作未知 假设、计时、验收
Change Plan 一次变更 版本、目标、窗口特定 精确 artifact 与批准

同一个动作可以出现在不同文档中,但语境不同。

例如 credential rotation:

routine SOP
  planned age-based dual-version rotation

incident runbook
  suspected disclosure; old identity cannot be re-enabled

drill
  prove clients, pool and revoke path can rotate without secret export

change plan
  rotate pg36_shop production identity at a named time and target

不能把 routine SOP 原样用于凭据泄露事故,因为 rollback 到旧 secret 在事故中 不是安全选择。

SOP:稳定输入下的可重复流程

一份合格 SOP 至少包含:

identity
  id / owner / version / review date

scope
  exact supported environments and versions

authority
  who may run / who approves / what role is used

trigger
  scheduled or event condition

preconditions
  facts that must be true

procedure
  bounded, idempotent phases

stop conditions
  facts that prohibit continuing

verification
  technical and service postconditions

rollback-or-roll-forward
  decision boundary and procedure

evidence
  what to capture, redact, hash, retain

SOP 中最重要的句子往往是:

STOP if ...

而不是最长的命令。

Runbook:从症状出发,而不是假设根因

告警是:

PG36ShopAvailabilityFastBurn

Runbook 不应开头就执行 failover。合理顺序:

1 establish user impact and current window
2 freeze concurrent risky changes
3 reconcile unknown order outcomes
4 check recent release/config/access/topology events
5 inspect application -> entry -> pool -> PostgreSQL
6 choose the smallest safe intervention
7 verify user SLI and data control
8 preserve decision and evidence

若先看到 replica lag 就 promote,可能:

  • 切错根因;
  • 扩大 write gap;
  • 引入 split-brain 风险;
  • 让原本健康的 write path 中断;
  • 丢失调查证据。

Runbook 应提供 decision tree:

user availability burns?
  no  -> diagnose component, ticket/capacity path
  yes -> correctness at risk?
           yes -> freeze affected writes, data/security path
           no  -> entry path failing?
                    yes -> route/pool diagnosis
                    no  -> transaction/lock/load diagnosis

它不能穷举所有根因,但必须约束高风险捷径。

Recovery Drill:证明“能恢复”

backup SOP 和 restore drill 不同:

backup SOP
  run backup
  repository receives objects
  check reports healthy

restore drill
  select a recovery objective
  prove backup + required WAL coverage
  restore into isolation
  start in recovery
  reach target
  promote only inside isolation
  verify application invariants
  stop recovered postmaster

演练要在开始前固定 success criteria。若恢复后才决定“只要能连上就算成功”, 结果会向已有事实偏移。

本章的 SOP-BACKUP-RESTORE 要求:

  • source cluster 和 repository 明确;
  • recovery target 固定;
  • destination 无 production listener;
  • 验证 query 与预期先冻结;
  • WAL 缺失、identity 模糊或 isolation 失败立即停止;
  • 时间只记为沙箱 observation,不称 production RTO。

Drill 不应依赖临场英雄主义

有效演练测试:

  • 文档是否完整;
  • 权限是否可用;
  • secret 是否能安全获得;
  • 工具版本是否兼容;
  • 依赖是否存在;
  • 验收是否机器可判;
  • 证据是否完整;
  • 交接是否能继续。

只让原作者凭记忆完成,证明的是个人能力,不是组织恢复能力。可在后续轮次让 另一位合格 operator 按文档执行,原作者只观察和记录歧义。

Change Plan:把抽象 SOP 绑定到一次动作

SOP 可以写:

planned switchover procedure

Change Plan 必须写:

change_id            CHG-...
service              pg36_shop
environment          production
cluster              exact cluster identity
current leader       fact captured at T0
candidate            exact member
artifact hash        reviewed command/config version
window               exact start/end
requester            durable identity
approver             different durable identity
executor             different durable identity
stop lines           current values and thresholds
rollback/forward     decision
observation          SLI + probe + component facts

“按切换 SOP 执行”不能替代这些单次事实。

版本和环境必须受支持

文档应写:

tested:
  Pigsty v4.5.0
  PostgreSQL 18.6
  Patroni 4.1.3
  PgBouncer 1.25.2

not implied:
  all earlier/later versions
  cloud-managed PostgreSQL
  a different DCS
  session pool mode

命令、输出字段和行为会随版本改变。升级计划必须同时检查 SOP 与 validator。

日常、故障和演练的证据粒度不同

类型 关键证据
routine target、输入版本、执行结果、postcondition
incident timeline、user impact、decision、unknown outcome
drill hypothesis、predefined success、timing、exception
change request/approve/execute identity、artifact hash、closeout

不能因为 incident 需要更多证据,就把所有 routine 日志永久保存;也不能因 routine 简洁,在安全事故中只留一个 shell exit code。

24.3.2 申请、评审、执行、验证与回退

完整生命周期

本章使用十一阶段:

request
  -> classify risk and authority
      -> freeze preconditions and stop lines
          -> preview and test
              -> approve
                  -> execute
                      -> observe
                          -> verify
                              -> rollback or roll forward
                                  -> retain evidence
                                      -> close and review

“批准”不是生命周期的起点,也不是结束。

1. Request:先说明目的,不要从命令开始

请求至少回答:

why
  user/business/reliability/security need

what
  desired state, not just command

where
  exact service/environment/cluster/object

when
  proposed window and dependency

impact
  users/data/capacity/recovery/security

success
  measurable postconditions

反例:

please run ALTER TABLE tonight

合格:

expand pg36_shop order schema with nullable external_ref;
old and new app versions remain compatible;
no table rewrite; lock wait stops after 2 s;
roll back by disabling new writer before semantic cutover.

2. Classify:按实际影响,不按团队习惯

本章风险级别:

L0:只读

  • 查询统计视图;
  • 渲染配置 diff;
  • 验证已有 evidence;
  • 不改变数据库、pool、topology、credential 或 route。

L0 仍要绑定目标,避免拿错误环境的证据做结论。

L1:有界、可逆、低影响

  • 增加 dashboard;
  • 发布 disabled alert;
  • sandbox 中增加窄权限 NOLOGIN role。

需要 preview、verification 和 rollback。

L2:material state

  • credential rotation;
  • online schema expansion;
  • pool limit;
  • role/permission;
  • 生产参数 reload;
  • 业务路由变化。

需要独立批准、停止线、完整证据。

L3:destructive/topology/recovery

  • failover;
  • restore/promotion;
  • data deletion;
  • cluster reset;
  • 大范围不可逆迁移。

需要最强 target binding、双人控制、延迟确认或等价门禁。

风险由 blast radius、可逆性、未知结果与数据后果共同决定,不是“SQL 只有 一行所以低风险”。

3. Freeze preconditions:审批的是事实快照

preflight 可以包括:

target identity
current leader/timeline
replica state
backup and WAL coverage
disk and capacity headroom
blocking sessions
error-budget state
current release
config hash
credential and authority

每个事实要有:

  • source;
  • captured_at;
  • freshness limit;
  • expected value;
  • stop behavior。

批准后到执行前可能发生变化。执行器必须重新采集关键事实:

approved leader = pg-a
current leader  = pg-b
=> stop, do not reinterpret the plan

4. Preview and test:使用相同 artifact

好的 pipeline:

render artifact
  -> hash
      -> test same hash
          -> approve same hash
              -> execute same hash

不要测试一套手写 SQL、执行另一套临时编辑内容。

preview 可能包括:

  • Pigsty inventory projection 和 diff;
  • SQL parse/plan;
  • lock/rewrite analysis;
  • restore dry-run facts;
  • target list;
  • expected state transition;
  • synthetic data replay;
  • rollback simulation。

preview 不能证明生产一定成功,但能拒绝明显不符合合同的输入。

5. Approve:批准目标、动作、窗口,不是空白授权

批准记录要绑定:

change id
artifact hash
exact target
risk class
blast radius
start/end
preconditions
stop lines
operator identity

“同意处理”或聊天表情不能作为 L3 授权。

6. Execute:每一步都应可观察

执行器:

  • 使用个人 durable identity;
  • 不共享 root/DBA 密码;
  • 不把 secret 放在 shell 参数;
  • 记录 phase start/end;
  • 每一阶段检查 stop condition;
  • 不并行执行计划外动作;
  • 超时后先分类结果。

脚本的 set -e 只能在命令非零时停止。它不能判断:

  • command 返回 0 但目标错误;
  • client timeout 后 server 已完成;
  • failover command 成功但路由未刷新;
  • ALTER TABLE 成功但 application 不兼容;
  • backup 成功但不可恢复。

7. Observe:动作期间看用户与组件

观察至少两条线:

service
  availability / latency / freshness / correctness probe

component
  topology / connections / locks / WAL / pool / host

只看执行命令输出,会错过用户影响;只看 SLI,又无法及时识别安全停止线。

8. Verify:状态正确,而不是命令完成

postcondition 应与 request success 一一对应。

planned switchover:

one leader
expected timeline progression
streaming replicas
client entry routes current leader
acknowledged writes present
unknown outcomes reconciled
no duplicate token

schema release:

old app works
new app works
constraint/reconciliation passes
lock/WAL within bounds
feature flag state correct

credential rotation:

new authentication works
old new-session authentication fails
existing sessions explicitly handled
pool declarations converge
secret absent from evidence

9. Rollback or roll forward:不是所有状态都能倒回

rollback 适合:

  • application feature flag;
  • additive configuration;
  • reversible grant;
  • 在 semantic cutover 前保留旧结构。

roll forward 更适合:

  • timeline 已推进的 failover;
  • 已泄露 credential;
  • 已有新版本写入的数据格式;
  • 外部系统已消费的事件;
  • commit outcome unknown。

PostgreSQL DDL 的边界

许多 PostgreSQL DDL 可以在 transaction 中回滚,但不能据此声称发布总可逆:

  • CREATE DATABASE 等命令不能在 transaction block 内运行;
  • CREATE INDEX CONCURRENTLY 不能在 transaction block 内运行,失败还可能 留下 INVALID index;
  • data backfill 已被新应用读取后,SQL rollback 不能撤回外部影响;
  • table rewrite/WAL/replica lag 已经发生;
  • application 与 schema 的兼容窗口可能关闭。

具体行为见 PostgreSQL CREATE INDEX 和第 11 章的安全模式变更。

回退计划要说明“在哪个决策点之前可回退”,而不是只写 ROLLBACK

10. Retain evidence:保存证明,不保存秘密

每次变化至少形成 manifest:

source facts and hashes
target identity
request/approve/execute identities
phase timestamps
artifact hash
observations
verification
rollback-or-forward decision
exception
final decision

不要保存:

  • cleartext password;
  • SCRAM verifier;
  • private key;
  • 完整 credential URI;
  • 无边界 bind values;
  • 不必要的客户行。

11. Close and review:关闭是一个结论

关单条件:

  • service postcondition 通过;
  • component postcondition 通过;
  • unknown outcomes 为零或有明确 owner/deadline;
  • 临时权限、route、pool/config 已恢复或正式纳管;
  • evidence 完整;
  • follow-up 有 owner 与 due date;
  • budget/incident 状态更新。

“维护窗口结束”不能自动关闭失败的变更。

一个 change record

本章要求字段:

{
  "change_id": "CHG-...",
  "service_id": "pg36_shop",
  "risk_class": "L2",
  "exact_target": "environment/cluster/object",
  "blast_radius": "declared users and data",
  "requester_identity": "workforce://...",
  "approver_identity": "workforce://...",
  "executor_identity": "workforce://...",
  "planned_start_and_end": ["...", "..."],
  "preconditions": ["..."],
  "stop_conditions": ["..."],
  "execution_hash": "sha256:...",
  "verification": ["..."],
  "rollback_or_roll_forward": "...",
  "decision": "accepted|rejected|accepted-with-exceptions",
  "evidence_manifest": "evidence://..."
}

真实系统还要验证三个 identity 不同且当时有效。

24.3.3 高风险动作的双人或延迟确认

双人控制解决什么

高风险动作容易同时发生三类错误:

intent error
  本来不应做

target error
  对错环境/集群/对象做

execution error
  步骤、参数或时机错误

独立批准人应使用独立证据检查意图与目标;执行器负责按批准 artifact 操作。 两人同时复制同一个错误命令,不构成有效独立控制。

NIST SP 800-53 Rev. 5.1 在 access restrictions for change 中描述 dual authorization:两名合格个人批准 和实施选定高风险变化,并对变化负责。组织还可轮换职责,降低串通风险。

本章将它转化为:

requester_may_approve     false
approver_may_execute      false
shared_accounts           false
qualified approver        true
target + blast radius     required
machine preconditions     required

独立不只意味着用户名不同

无效:

  • 同一人使用两个账号;
  • 两人共享一个 password;
  • approver 没有理解 PostgreSQL/Pigsty 风险;
  • approver 只看 requester 的截图;
  • 执行 artifact 在批准后被修改;
  • 两人都受同一个未验证假设影响。

有效独立性包括:

  • durable workforce identity;
  • 不同职责;
  • 足够专业能力;
  • 能访问 source-of-truth;
  • artifact hash;
  • 能拒绝;
  • 拒绝不会被绕过;
  • 事后记录。

延迟确认解决冲动和误目标

destructive action 可要求 cooldown:

request generated
  exact target + action + impact + hash
      -> independent review
          -> wait bounded interval
              -> refresh target/preconditions
                  -> typed confirmation bound to same target/hash/window

延迟让:

  • 执行者有时间发现环境错误;
  • 依赖 team 有时间反馈;
  • 自动备份/导出有时间完成;
  • 用户影响窗口再次确认。

它不适用于所有 incident。真正紧急时可以缩短 normal wait,但不能跳过 target、 authority、stop line 和 evidence。

confirmation token 必须绑定语义

差:

CONFIRM=YES

较好:

target       prod/eu-west/pg-shop
action       promote reviewed recovery target
artifact     sha256:...
window       2026-...
confirmation RECOVER_PG_SHOP_TO_...

脚本还应从运行时重新解析 target identity,而不是完全信任环境变量字符串。

break-glass 不是无规则

break-glass 允许:

  • 跳过正常排期;
  • 获取时限高权身份;
  • 执行恢复服务所需的最小动作。

它不允许:

  • 不记录目标;
  • 无止境保留高权;
  • 关闭审计;
  • 将 credential 发进聊天;
  • 跳过事后 review;
  • 使用后不轮换;
  • 将未知结果当成功。

本章合同:

may_skip_normal_wait          true
may_skip_target_and_evidence  false
time_bounded_identity         true
independent_after_review      true
credential_rotation_after_use true

emergency change 仍要选择最小动作

事故中的压力会放大 scope:

one blocked query
  -> restart all databases

Runbook 应给出干预阶梯:

observe
  -> cancel one query
      -> terminate one session
          -> isolate one application path
              -> controlled role change
                  -> restart component
                      -> recover/fail over

每一级需要新的证据和 authority。不能因为已经进入 SEV-1,就自动获得所有 destructive action 的授权。

自动化能做什么

适合机器执行:

  • 解析 inventory;
  • 验证 mode/owner;
  • 比较 target identity;
  • 采集 leader/timeline/LSN;
  • 计算 lock、WAL、budget;
  • 检查 artifact hash;
  • 拒绝非空 evidence directory;
  • 执行 bounded probe;
  • 核对 postcondition;
  • 生成 manifest;
  • 扫描 secret material。

仍需组织决定:

  • 用户损失是否可接受;
  • recovery point 是否符合业务;
  • 是否暂停业务;
  • 谁拥有 change authority;
  • 合规/隐私边界;
  • 何时允许例外。

自动化可以证明 guard 成立,不能发明授权。

guard 不是一串容易伪造的环境变量

高风险 runner 应结合:

declared target token
live target identity
inventory allowlist
environment classification
production traffic flag
data classification
approval record
artifact hash
private input mode
empty output directory

只检查 NONPRODUCTION=true 不够;操作者可以错误设置。第 19–23 章的实验 runner 还会核对 host、cluster、topology 和 fixture identity。

超时与 unknown outcome

高风险 API/SQL 超时后:

do not retry yet
  -> inspect durable state
      -> classify:
           completed
           not started
           partially applied
           unknown
      -> choose idempotent continuation or repair

例子:

  • COMMIT timeout:查 idempotency token;
  • switchover timeout:查 Patroni leader/timeline;
  • revoke timeout:从新 session 测认证与 role attributes;
  • DDL timeout:查 catalog/lock/index validity;
  • backup timeout:查 repository manifest,不凭 client exit 猜。

重试本身是一项 change,需要可幂等证明。

高风险 review 的三次停止机会

before approval
  design or authority is wrong

immediately before execution
  facts or target changed

during execution
  stop threshold or unknown outcome reached

批准人不能提前放弃后两次停止。机器 guard 也不能因为 approval 存在而忽略 live drift。

本章的 SOP 与变更政策

四个 SOP 覆盖:

backup and restore
planned switchover and failover
access and credential rotation
schema release

每份都有 precondition、stop、verification、rollback/roll-forward 和 evidence。 validator 会拒绝:

  • backup exit 0 作为全部恢复验证;
  • L2/L3 同人自批自执行;
  • break-glass 跳过 target/evidence;
  • 沙箱运行被标为 production proof。

文档演练清单

  • 文档类型是否正确,还是把 incident 写成线性 SOP?
  • scope 和版本是否明确?
  • trigger 与 authority 是否明确?
  • exact target 能否由 live evidence 验证?
  • precondition 是否带 freshness?
  • 每个高风险 phase 前是否有 stop condition?
  • 命令 timeout 后是否先核对 durable state?
  • success 是否由 postcondition 定义?
  • rollback 与 roll-forward 的决策点是否明确?
  • external side effect 是否考虑?
  • requester/approver/executor 是否真正独立?
  • break-glass 是否有时限、审查和轮换?
  • evidence 是否 secret-free?
  • 另一个合格 operator 能否只凭文档完成?
  • 文档、脚本、目标版本变化后是否重新演练?

下一节会把 SLO 与这些 Runbook 连接起来:一个告警若不能把值班人带到安全的 第一动作,就不应成为 page。


上一节:SLI、SLO 与错误预算 · 返回本章目录 · 下一节:观察与告警契约 · 查看全书目录 · 查看索引中心

24.4 观察与告警契约

监控系统不会自动知道“什么对用户重要”。它只会忠实地计算你交给它的数值。

wrong semantic + perfect query
  = precisely wrong alert

因此告警规则之前要有 observation contract:

objective
  -> source
      -> metric/event type
          -> good/total selector
              -> dimensions
                  -> query
                      -> missing semantics
                          -> fallback
                              -> owner/action

本节产出第 25 章的实现合同,而不提前声称指标和 page 已经部署。

24.4.1 每个 SLI 的数据源、查询、维度和缺失语义

一个 SLI 需要哪些字段

最小 observation contract:

字段 问题
objective_id 它实现哪个 SLO/control?
source 谁产生原始事实?
metric_type counter、histogram、gauge 还是 event?
good_selector 哪些事件进入分子?
total_selector 哪些事件进入分母?
dimensions 按哪些有界维度分解?
query_template 如何从窗口计算?
missing_semantics series 消失代表什么?
fallback 主 observation path 失败后看什么?
metric_status 已存在、待实现还是 deprecated?

少一个字段就可能改变结论。

本章的五个来源

objective source type 状态
availability pg36_shop_request_outcomes_total counter 应用待实现
latency pg36_shop_request_duration_seconds histogram 应用待实现
freshness pg36_shop_commit_visibility_probes_total counter synthetic probe 待实现
correctness pg36_shop_reconciliation_mismatches gauge 核对任务待实现
restore readiness pg36_shop_restore_evidence_age_seconds gauge 证据导出器待实现

这些是本书定义的应用 metric contract,不是 Pigsty 内置指标。metric_status 故意保留“to implement”,防止目录文档冒充运行事实。

availability query

原始 counter:

pg36_shop_request_outcomes_total{
  service="pg36_shop",
  operation_class="place-order",
  environment="production",
  eligible="true",
  outcome="good|bad|unknown"
}

bad ratio 模板:

1 -
sum(
  rate(pg36_shop_request_outcomes_total{
    service="pg36_shop",
    operation_class="place-order",
    eligible="true",
    outcome="good"
  }[$window])
)
/
clamp_min(
  sum(
    rate(pg36_shop_request_outcomes_total{
      service="pg36_shop",
      operation_class="place-order",
      eligible="true"
    }[$window])
  ),
  1
)

unknown 在核对前不进入 good,所以会消耗预算。后续事件若核对为 committed once,可通过事件管道作有审计的最终分类;不能在 dashboard 手工改值。

clamp_min 只防除零,不解决 telemetry missing。no traffic、counter absent 和 ingestion broken 要由独立 freshness/metamonitoring 判断。

latency query

histogram 需要 le="0.25" bucket:

1 -
sum(
  rate(pg36_shop_request_duration_seconds_bucket{
    service="pg36_shop",
    operation_class="place-order",
    eligible="true",
    availability_good="true",
    le="0.25"
  }[$window])
)
/
clamp_min(
  sum(
    rate(pg36_shop_request_duration_seconds_count{
      service="pg36_shop",
      operation_class="place-order",
      eligible="true"
    }[$window])
  ),
  1
)

这里示意把快速失败留在 denominator 而不进入 good bucket。实际 instrumentation 也可以统一用 outcome counter 与 duration histogram 通过 recording rule 对齐, 但必须有自动测试证明:

eligible count in availability
  == eligible count in latency

否则两个 SLO 使用不同分母,会产生无法解释的预算。

freshness query

每个 probe:

write token
commit reconciled
poll declared read path
within_bound = true/false
read_path = replica|primary|application

bad ratio:

1 -
sum(
  rate(pg36_shop_commit_visibility_probes_total{
    service="pg36_shop",
    eligible="true",
    within_bound="true"
  }[$window])
)
/
clamp_min(
  sum(
    rate(pg36_shop_commit_visibility_probes_total{
      service="pg36_shop",
      eligible="true"
    }[$window])
  ),
  1
)

probe absence:

unknown + probe-pipeline failure

不能用 pg_last_xact_replay_timestamp() 填补,因为它没有对应当前 commit。

correctness query

max(
  pg36_shop_reconciliation_mismatches{
    service="pg36_shop"
  }
)

需要配套:

  • last_success_timestamp
  • reconciliation input boundary;
  • rule/query version;
  • invariant;
  • result hash。

只导出 0 而不监控 job freshness,会在核对任务停摆后永远显示“零差异”。

missing semantics:

stale or absent reconciliation = control failure

restore evidence age

max(
  pg36_shop_restore_evidence_age_seconds{
    service="pg36_shop",
    recovery_class="named-pitr"
  }
)

阈值 90 天:

<= 7,776,000 seconds   control current
>  7,776,000 seconds   evidence stale
absent                 failed control, not infinite freshness

导出器必须只接受验证通过且完整性 hash 正确的 restore manifest。不能因为目录中 有一个新文件,就把 evidence age 归零。

PostgreSQL statistics 用于解释原因

PostgreSQL 18 提供:

  • pg_stat_activity:backend、状态、query/transaction 时间;
  • pg_stat_database:transaction、block、tuple 与 session 统计;
  • pg_stat_replication:sender、state、LSN 与同步状态;
  • pg_stat_wal_receiver:standby receiver;
  • pg_stat_archiver:归档成功/失败;
  • pg_stat_io:按 backend/object/context 的 I/O;
  • pg_stat_ssl:连接 TLS;
  • lock 与 progress views。

完整目录见 PostgreSQL Monitoring Database Activity

这些视图回答:

what PostgreSQL is doing

不直接回答:

whether pg36_shop users completed place-order correctly

访问统计应使用 pg_monitor 等受控预定义角色或更窄授权,而不是让 exporter 成为 superuser。预定义角色边界见 Predefined Roles

Pigsty v4.5 的实现层

当前 Pigsty 监控栈:

组件 职责
VictoriaMetrics 时序 ingestion、storage、query
VictoriaLogs 结构化日志
VMAlert 规则评估
Alertmanager 聚合、抑制、路由、通知
Grafana dashboard 与调查入口
exporter/agent 暴露数据库、主机和组件事实

Pigsty v4 已从旧的 Prometheus/Loki 存储迁到 VictoriaMetrics/VictoriaLogs; VMAlert 仍使用 PromQL-compatible 规则。不要复制旧文档后声称 v4 仍以 Prometheus server 存储时序。当前架构见 Monitoring System

PostgreSQL、PgBouncer、host、load balancer 尽量通过:

cls   cluster identity
ins   instance identity
ip    address identity

关联,详见 PGSQL Monitoring

维度:服务与组件分别建模

应用 SLI:

service
operation_class
environment
region/cell
outcome class
synthetic

组件 telemetry:

cls
ins
ip
database
user/role (bounded and privacy reviewed)

连接方式:

service catalog:
  pg36_shop -> pg-test

metric correlation:
  service="pg36_shop"
  dependency_cls="pg-test"

不要强行把 service 填成 ins,也不要把所有 database metric 复制一份 customer label。

cardinality 是可靠性和隐私问题

禁止 label:

  • customer_id
  • tenant_id
  • order_id
  • raw SQL;
  • error message;
  • stack trace。

原因:

  • series 数无界增长;
  • query 和 rule 变慢;
  • monitoring storage 自身失稳;
  • 用户数据进入广泛可见系统;
  • label 变化让 aggregation 不可靠。

使用:

  • bounded error class;
  • normalized SQL fingerprint;
  • exemplar/correlation token;
  • 受控日志查明具体事件。

missing-data truth table

traffic/probe SLI series 结论
有预期流量 存在 计算 SLI
有预期流量 缺失 observation failure
无真实流量 synthetic 存在 limited synthetic evidence
无真实流量 synthetic 缺失 unknown
exporter 存在 app SLI 缺失 component observable, service unknown
app SLI 存在 Alertmanager canary 失败 health known, notification path broken

“没有错误 series”与“error counter value is zero”不是一回事。

24.4.2 告警必须绑定用户影响、首个安全动作和所有者

page 的门槛

Prometheus 官方 alerting practices 总结为:

  • 保持简单;
  • 对症状告警;
  • 用良好 console 定位原因;
  • 避免无事可做的 page。

Alerting practices

本章把 page 定义为:

urgent
important
actionable
real
owned

缺一项就应考虑 dashboard、ticket 或删除。

一个 alert contract

每个 accepted alert 至少包含:

id
class
objective
expression
long/short window
severity and route
for
user impact
owner
route id
runbook
first safe action
verification
dashboard
missing semantics
silence policy
test
review expiry

这比:

alert: DatabaseHighCPU
expr: cpu > 80
severity: critical

多出来的内容,正是值班可行动性的来源。

user impact 要具体

差:

database is unhealthy

好:

eligible order attempts are failing or unreconciled fast enough
to spend 2% of the 28-day availability budget in one hour

差:

replica lag high

好:

commit-correlated reads on the declared replica path miss
the five-second visibility bound

first safe action 不是最终修复

fast-burn availability page:

first safe action:
  freeze latest risky release
  reconcile unknown order outcomes before retrying writes

它没有假设根因。之后 Runbook 才检查应用、入口、pool、PostgreSQL、锁和拓扑。

correctness page:

freeze affected writes
preserve reconciliation boundary before repair

不能先运行“修复 SQL”,否则会覆盖事故证据。

metamonitoring page:

establish an independent blackbox view
before changing the monitored database

监控断了时先恢复观察,不要凭空重启数据库。

owner 必须有 route

owner_function=service 仍不够。规则要带:

route_id
schedule
escalation
notification grouping
repeat interval

route 要用 canary 验证:

synthetic alert
  -> rule evaluator
      -> Alertmanager
          -> receiver
              -> acknowledgement record

不能只看 Alertmanager 进程 up。

for 防短暂抖动,但会增加检测延迟

Prometheus/VMAlert 风格规则可以使用:

for: 2m

条件必须持续 2 分钟才 firing。它适合滤除短 blip,但不是越大越好:

evaluation interval
+ query window behavior
+ for
+ notification delay
+ human acknowledgement
= practical detection time

correctness mismatch 可以 for: 0m,因为单个已确认 mismatch 的后果不同。

当前 Prometheus 规则语义还支持 keep_firing_for,用于条件短暂消失后继续 firing 一段时间;使用前要确认 VMAlert 当前版本的兼容和行为,并通过 rule test 验证。官方字段见 Alerting rules

silence 不是关闭问题

silence 必须有:

  • incident/change id;
  • owner;
  • reason;
  • exact matcher;
  • start/end;
  • replacement observation;
  • review。

禁止:

silence service=* severity=critical for 30 days

计划维护也应尽量使用 route/inhibition 和用户 SLO 的明确政策,而不是让所有 信号消失。

verification 决定何时恢复

fast-burn 恢复:

long and short windows recover
AND sampled idempotency tokens reconcile
AND no correctness control fails

只看到 alert resolved 可能是:

  • series 消失;
  • label 改变;
  • rule reload 失败;
  • traffic 归零;
  • silence;
  • query error。

Runbook 必须检查 missing semantics 和用户结果。

alert 要有生命周期

规则不是写完永存:

owner
created
last tested
last fired
false-positive review
runbook validity
expiry/review date
replacement/deprecation

orphaned route 或过期 runbook 应使 CI/治理 review 失败。

24.4.3 症状告警、原因告警与容量预测分开

症状 page

症状直接表示用户或数据后果:

  • availability fast burn;
  • latency fast burn;
  • commit-correlated freshness violation;
  • correctness mismatch;
  • imminent durability loss;
  • observation path loss 导致服务状态不可知。

它回答:

why wake a human now?

原因 telemetry

原因帮助定位:

  • CPU;
  • disk latency;
  • lock waits;
  • pool queue;
  • replica WAL distance;
  • cache hit ratio;
  • autovacuum backlog;
  • connection count;
  • Patroni member state。

同一用户症状可能有多个原因;同一原因也可能被 redundancy 吸收而没有用户 影响。若每个原因都 page:

one incident
  -> CPU page
  -> lock page
  -> pool page
  -> latency page
  -> replica page

值班收到五个 notification,却没有更多信息。

原因应进入:

  • linked dashboard;
  • diagnostic annotation;
  • bounded ticket;
  • automatic enrichment。

两个允许越过“用户症状”的例外

完整性/保密性

一条跨租户数据或 confirmed corruption 即使用户尚未报告,也需要立即处理。

迫近耐久性

例如所有可恢复副本/备份路径都失效且继续运行会导致不可恢复数据风险。此时 page 的依据是已定义的 durability control,不是随意的 component threshold。

例外仍要 owner、Runbook、action 和 verification。

容量是有期限的 ticket

容量预测:

forecast_days_to_capacity < lead_time + safety_margin

通常不是当前 user incident,因此:

  • 创建 owned ticket;
  • 带 forecast confidence;
  • 检查 demand/query mix/retention;
  • 给 due date;
  • 不在凌晨 page。

若容量已经造成 latency/availability,则症状 SLO 会 page;capacity facts 作为 诊断。

本章候选:

PG36ShopCapacityHorizon
  route ticket
  forecast horizon 14 days
  first action validate demand/headroom/retention/query mix

metamonitoring

监控系统也会失败:

  • exporter 停止;
  • agent 无法发送;
  • VictoriaMetrics ingestion/query 失败;
  • VMAlert rule evaluation error;
  • Alertmanager route 失败;
  • receiver 不可达;
  • dashboard query 误导。

有效 metamonitoring:

whitebox
  ingestion errors / rule errors / queue

blackbox
  expected series freshness
  external service probe
  notification canary end-to-end

Prometheus alerting practices 也建议用贯穿 PushGateway/Prometheus/ Alertmanager/email 的 blackbox 测试,而不是只盯每个组件进程。Pigsty v4 中要 把这个思想映射到 VictoriaMetrics/VMAlert/Alertmanager。

分类矩阵

signal class route 例子
user event bad ratio symptom page/ticket by burn availability
data invariant mismatch integrity page duplicate/cross-tenant
WAL distance cause dashboard replication diagnosis
pool queue cause dashboard latency diagnosis
CPU cause dashboard/ticket capacity/diagnosis
days-to-full capacity ticket disk/storage
notification canary overdue metamonitoring page blind operation

本章明确拒绝的 page

候选:

PostgresInstanceDownWithoutAction

被拒绝,因为:

  • 一台 instance 可因 offline maintenance 合理下线;
  • redundancy 可能仍满足服务;
  • 没有声明用户影响;
  • 没有 owner;
  • 没有 Runbook;
  • 没有第一安全动作。

替代:

retain instance state on topology dashboard
correlate endpoint/user-event symptoms
escalate through redundancy/durability policy if needed

validator 会故意把它的 decision 改成 accepted,并确认合同拒绝。

24.4.4 产出供 ch25 实现的告警规则清单

七个 accepted candidates

1. PG36ShopAvailabilityFastBurn

class       symptom
route       page / SEV-1
windows     1h + 5m
burn        14.4x
for         2m
action      freeze latest risky release;
            reconcile unknown writes before retry

对 99.9%:

14.4×(10.999)=0.0144 14.4 \times (1-0.999)=0.0144

即两个窗口 bad ratio 都高于 1.44%。

2. PG36ShopAvailabilitySlowBurn

route       page / SEV-2
windows     6h + 30m
burn        6x
for         5m
action      stop concurrent risky changes;
            segment by operation and release

阈值 0.6% bad ratio。

3. PG36ShopAvailabilityBudgetTicket

route       ticket
windows     3d + 6h
burn        1x
for         15m
action      open owned budget review

用于慢性消耗,不打扰夜间值班。

4. PG36ShopCorrectnessMismatch

class       integrity
route       page / SEV-1
condition   mismatch > 0
for         0m after confirmed reconciliation
action      freeze affected writes;
            preserve boundary

不使用 burn rate。

5. PG36ShopFreshnessFastBurn

class       symptom
route       page / SEV-2
windows     1h + 5m
burn        14.4x against 99% freshness target
action      route affected read-after-write journey to primary
            while preserving probe tokens

不要把所有 read traffic 永久移到 primary;这是有界安全动作,恢复后再评审 read policy。

6. PG36ShopCapacityHorizon

class       capacity
route       ticket
condition   reviewed forecast < 14 days
action      validate demand/headroom/retention/query mix

预测没有足够历史时,创建 evidence-quality ticket,而不是编造 forecast。

7. PG36MonitoringPathBroken

class       metamonitoring
route       page / SEV-2
condition   expected probe missing
            OR rule evaluation failure
            OR notification canary overdue
action      establish independent blackbox view first

rule skeleton

第 25 章可从下面开始,但必须使用 recording rules 和测试后的真实 label:

groups:
  - name: pg36-shop-slo
    rules:
      - alert: PG36ShopAvailabilityFastBurn
        expr: |
          (
            pg36_shop:sli_availability_bad_ratio:rate1h
              > 14.4 * (1 - 0.999)
          )
          and
          (
            pg36_shop:sli_availability_bad_ratio:rate5m
              > 14.4 * (1 - 0.999)
          )
        for: 2m
        labels:
          severity: sev1
          service: pg36_shop
          class: symptom
        annotations:
          summary: "pg36_shop availability burns fast"
          runbook: "runbook://RB-USER-SYMPTOM"
          dashboard: "dashboard://pg36-shop-slo"

这段只是规则语义骨架,不能直接部署,因为:

  • recording rules 尚未实现;
  • production environment/region labels 尚未固定;
  • route 与 receiver 仍是教学标识;
  • real owner 尚未批准;
  • rule evaluation interval 尚未纳入检测时间;
  • test fixture 尚未建立。

第 25 章必须交付什么

Instrumentation

  • counters/histograms 的真实采集;
  • eligible/good classifier tests;
  • commit token probe;
  • reconciliation freshness;
  • restore evidence exporter;
  • bounded labels。

Recording rules

  • 5m、30m、1h、6h、3d 等窗口;
  • label-preserving aggregation;
  • reset 和 counter semantics;
  • no-traffic/missing 分支;
  • rule unit tests。

Alert rules

  • 七个 accepted candidates;
  • actionless candidate 保持 rejected;
  • for 和 evaluation delay;
  • route/inhibition/silence;
  • review expiry。

Dashboards

  • SLI、target、budget remaining;
  • numerator/denominator;
  • eligible classification;
  • recent changes;
  • component cause drill-down;
  • missing telemetry state。

Delivery tests

  • synthetic rule firing;
  • label/annotation assertions;
  • Alertmanager grouping;
  • notification canary;
  • acknowledgement;
  • resolved 与 missing 的区分。

交接门槛

在实现规则前逐项确认:

  • objective id 与 SLO policy 一致;
  • metric status 从“待实现”变更有 evidence;
  • good/total selector 经过代码测试;
  • histogram 有目标 bucket;
  • unknown outcome 不会被算 good;
  • missing series 有独立规则;
  • labels 有界且不泄露数据;
  • Pigsty identity 使用 cls/ins/ip
  • component facts 不冒充 user SLI;
  • multiwindow 使用 AND;
  • page 有 owner、route、Runbook、action 和 verification;
  • capacity 进入 ticket;
  • cause 进入 dashboard;
  • correctness/durability 例外有独立 control;
  • notification path 自身被测试;
  • real pager 测试获得明确授权;
  • sandbox 规则没有被称为 production-approved。

完整输入见:

下一节处理这些指标、变更和操作留下的证据:证据必须足够证明决定,却不能把 秘密和个人数据无边界地复制到治理系统。


上一节:SOP、Runbook 与变更治理 · 返回本章目录 · 下一节:证据、审计与合规 · 查看全书目录 · 查看索引中心

24.5 证据、审计与合规

“日志里应该有”不是证据策略。

一份可用于运营决策的证据至少要回答:

what fact
from which source
about which exact target
collected by which identity/tool
at what time
under which authority
with which integrity check
used for which decision
retained where and for how long
disclosed to whom
deleted how

同时还要回答:

what must never enter this evidence

数据库 evidence 很容易包含 credential、SQL bind value、tenant data、backup 内容和人员身份。收集越多,不等于治理越好。

24.5.1 配置、变更、访问和恢复证据

日志、审计、证据和合规不是同义词

对象 主要用途 例子
operational log 调试运行行为 PostgreSQL error/log line
audit record 记录受关注主体动作 role/DDL/object access
evidence 支撑一个具体结论 target+hash+result manifest
compliance assessment 将证据映射到外部控制 control tested/effective/gap

一个系统可以日志很多但证据不足:

log:
  ALTER ROLE completed

missing:
  requester
  independent approver
  exact reviewed artifact
  previous state
  intended target
  new-session verification
  existing-session decision
  secret handling

反过来,保存一份结构化的 secret-free manifest 可能比复制整个日志目录更适合 日常 change evidence。

配置证据:声明、渲染、运行事实

Pigsty 管理的 PostgreSQL 配置有三层:

declared
  inventory / policy source

rendered
  generated config, HBA, service definition

observed
  pg_settings / pg_hba_file_rules / listener / service state

只保存 inventory 不能证明部署生效;只保存 SHOW 不能证明变更来源和评审。

配置 evidence:

service/environment/cluster
source revision and hash
sanitized inventory projection
rendered artifact hash
live target identity
observed setting + source + pending_restart
drift and accepted exception
collector/tool version
captured_at UTC

需要特别区分:

  • desired value;
  • current effective value;
  • reload-pending;
  • restart-pending;
  • session override;
  • role/database override。

PostgreSQL pg_settingssourcesourcefilepending_restart 等字段有助于 解释运行值,但路径和配置内容仍需按敏感性处理。官方视图见 pg_settings

变更证据:意图、授权、执行、结果

完整 change evidence:

intent
  request / desired state / user reason

authority
  requester / approver / executor / time / role

artifact
  exact SQL/config/script hash

preflight
  target / topology / budget / capacity / stop lines

execution
  phases / timestamps / tool outcome

observation
  SLI / component facts / unknown outcomes

verification
  postconditions

decision
  accept / reject / exception / rollback / roll-forward

命令历史不能替代这个链:

  • shell history 可编辑;
  • shared account 无法归因;
  • command line 可能泄露 secret;
  • 它不记录独立批准;
  • 它不证明 target;
  • 它不证明结果。

访问证据:身份链而不是密码

第 23 章区分:

workforce/workload identity
  -> login role
      -> effective role
          -> object permission
              -> RLS context

访问 evidence 可以保存:

  • durable identity id;
  • authentication mechanism/result;
  • login/effective role;
  • membership option;
  • target object/action;
  • approval;
  • SQLSTATE;
  • session id/correlation;
  • start/end/revoke;
  • policy version。

不能保存:

  • cleartext password;
  • SCRAM verifier;
  • private key;
  • session token;
  • credential-bearing URI;
  • raw PgBouncer userlist;
  • 无必要的 SQL parameter。

credential rotation evidence 保存 secret version id,而不是 secret value:

old version v17
new version v18
new auth pass
old new-session auth rejected
existing sessions handled by decision CHG-...
final role LOGIN=false

ordinary PostgreSQL logs 的边界

PostgreSQL logging 可以记录:

  • connection/disconnection;
  • statement duration;
  • SQLSTATE 和错误;
  • DDL/statement;
  • prefix 中的 user/database/application/session;
  • lock、checkpoint、autovacuum 等运行事件。

配置见 Error Reporting and Logging

但普通日志不是自动完整 audit:

  • 采样和阈值会省略事件;
  • statement 与 bind value 记录受参数影响;
  • superuser/host admin 可能改配置;
  • owner/role 语义需要额外关联;
  • log storage、access、integrity、retention 仍需治理;
  • 记录过多参数会泄露数据。

pgAudit 可以提供更结构化的 session/object audit,但仍需安装、preload、配置、 容量和审查。官方项目见 pgaudit/pgaudit。它也不能替代 change request、业务授权和 evidence chain。

恢复证据:从 backup 到 application

恢复 evidence 至少有:

source identity
backup identity
repository identity
target time/name/LSN
required WAL coverage
restore destination and isolation
start-in-recovery
target reached
promotion decision
system identifier relation
application marker/invariant checks
network/listener boundary
postmaster stopped
timings and their interpretation

第 21 章正式摘要:

run id       run_20260729T201040Z_961665aa
named PITR   accepted-with-exceptions
production   pending

本章只引用其 run id 与 SHA-256,不复制 private raw bundle,也不把单次沙箱 timing 升格为生产 RTO。

SLO 和告警证据

SLO decision 需要:

  • policy version;
  • numerator/denominator;
  • query/recording rule hash;
  • evaluation window;
  • exclusion records;
  • missing-data intervals;
  • budget state;
  • release/change markers;
  • alert firing/resolved;
  • route delivery 和 acknowledgement;
  • owner decision。

只有 dashboard screenshot 不够:

  • 时间范围可能隐藏;
  • query 可能后来改变;
  • series 可能 missing;
  • panel 可手工选择过滤;
  • screenshot 不能复算。

可以保留 screenshot 作为沟通附件,但 canonical evidence 应能机器重算。

incident evidence

事件证据:

first signal
declared severity
user/data impact
decision log
commands and target
topology/config changes
unknown outcome reconciliation
communications
recovery verification
follow-up

时间线要区分:

event time
observed time
recorded time

避免事后把“后来知道的根因”写成当时已知事实。

六类 evidence policy

本章定义:

类别 教学保留 分类
configuration 400 d internal
change 400 d confidential-operational
access 400 d confidential-security
recovery 400 d confidential-operational
SLO/alert 400 d internal
incident 730 d confidential-incident

这些数字只是教学政策,用于验证每类都有正数、访问和删除合同。真实保留期必须 经过法律、监管、隐私、调查和成本评审,不能照抄。

24.5.2 保留、不可抵赖与隐私边界

chain of custody

证据链:

collect
  source / target / collector / UTC / tool version

preserve
  immutable or object-locked original where required

manifest
  canonical file list + SHA-256

derive
  redacted review copy, original retained separately

access
  role-based and audited

hold/export
  authority and recipient recorded

delete
  policy-driven and audited

“文件还在”不能证明它没被改。

hash 证明什么

SHA-256 可以证明:

current bytes match the bytes whose digest was recorded

不能单独证明:

  • 谁采集;
  • source 真实;
  • 采集前没有被篡改;
  • timestamp 可信;
  • manifest 没被一起替换;
  • 结论正确。

增强方式:

  • signed manifest;
  • append-only/object lock store;
  • independent timestamp;
  • durable collector identity;
  • separation of duties;
  • multiple source correlation;
  • access and deletion audit。

不要把“有 hash”写成绝对不可抵赖。

不可抵赖依赖个人身份

shared postgresroot account 最多证明“某个拥有共享 credential 的人”。 要提高 accountability:

person authenticates with durable workforce identity
  -> obtains time-bounded privileged session
      -> executes under unique session/change id
          -> command and target recorded
              -> approval and result linked

仍要考虑:

  • 身份被盗;
  • host compromise;
  • log admin;
  • 时钟;
  • collusion;
  • privacy。

所以工程上更准确的说法是“提高归因与篡改检测能力”,而不是宣称数学意义上的 绝对不可抵赖。

UTC、时钟与顺序

多节点 PostgreSQL/Pigsty 证据需要:

  • UTC timestamp;
  • NTP status;
  • monotonic duration;
  • event sequence/correlation;
  • clock uncertainty。

wall clock 可调整,耗时应使用 monotonic clock。跨系统排序若只靠毫秒 timestamp 可能出错,应用事件应有 correlation/idempotency token。

最小必要收集

问四次:

purpose
  这个字段证明哪项控制?

scope
  能否用 aggregate/fingerprint 替代原值?

access
  谁真正需要?

retention
  目的结束后何时删除?

例子:

原始材料 更安全替代
password secret version id
certificate private key public certificate fingerprint
full SQL values normalized fingerprint + SQLSTATE
customer row count + synthetic marker
full connection URI host/db/user + redacted auth
raw system identifier everywhere relation matches/differs,必要时私密保存

evidence store 不是 backup repository

backup 本身包含业务数据,访问和保留应按数据分类;治理 evidence 只需记录:

backup label
repository identity
size/count aggregate
coverage
integrity result
restore result

不要把 backup archive 复制到工单附件。

正常生命周期:

created -> retained -> expired -> reviewed -> deleted -> deletion recorded

事件/诉讼 hold:

hold authority
scope
start
reason
access
release authority

hold 不能变成永久保存所有数据的借口;解除后恢复原 retention decision。

redaction 要保留原始与派生关系

安全流程:

original private evidence
  hash A
  restricted access

redacted review copy
  hash B
  derived_from A
  redaction tool/version/rules

直接覆盖 original 会破坏调查能力;把 original 给所有 reviewer 又扩大泄露面。

日志参数与隐私

PostgreSQL 的 statement/parameter logging 可以帮助诊断,也能记录:

  • password reset SQL;
  • token;
  • email/phone;
  • tenant data;
  • health/financial data;
  • application secret。

第 23 章沙箱观察到普通非错误 statement logging 可能保留完整 bind parameter, 且 pgAudit 未安装/preload。这个 gap 在治理层必须:

  • 有 owner;
  • 有整改或接受期限;
  • 限制日志访问;
  • 评审 retention;
  • 避免在 SQL 中传 secret;
  • 对敏感语句/值设计脱敏;
  • 不谎称“审计完整”。

合规是外部控制映射

NIST、ISO、行业或地区监管可以要求:

  • change control;
  • dual authorization;
  • audit generation/review;
  • retention;
  • least privilege;
  • incident evidence;
  • recovery testing。

本章合同可以成为控制 evidence,但不能自行宣布:

compliant
certified
meets every jurisdiction

正式 assessment 还需要:

  • applicable scope;
  • control owner;
  • external requirement version;
  • test procedure;
  • evidence period/sample;
  • deficiency;
  • compensating control;
  • assessor decision。

本章参考 NIST SP 800-53 Rev. 5.1 的 change/audit 思路,不把它当通用认证印章。

24.5.3 用自动检查减少人工表格

自动化检查不变量

最有价值的自动化不是生成更多空表格,而是拒绝错误状态。

本章 validator 检查:

service card
  exact owner functions
  dependencies and failure semantics
  six health layers
  production=false

SLO
  target in (0,1)
  event/time budget arithmetic
  planned maintenance included
  missing=unknown
  correctness/recovery controls

alerts
  objective references
  owner/runbook/action/verification
  symptom/cause/capacity separation
  multiwindow burn set
  actionless candidate rejected

SOP/change
  four capabilities
  precondition/stop/verify/rollback/evidence
  L2/L3 identity separation
  break-glass boundary

evidence
  six categories
  no secrets
  hash/access/retention

交叉引用比格式更重要

JSON 能 parse 不代表治理一致:

SLO objective id
  must exist in observation contract
  must be referenced by alerts

SOP reference run id
  must match retained upstream artifact

upstream production gate
  must remain pending

source hash
  must match exact validator and policy bytes

这类关系很难靠人工逐页检查,适合 CI。

对抗性验证证明 validator 不是摆设

positive fixture 全通过仍可能说明检查从未真正失败。每条关键 policy 要有 negative case:

change target to production
delete platform owner
set SLO target to 1
set missing to healthy
accept actionless page
make capacity a page
remove restore verification
allow one identity to self-approve
allow break-glass to skip evidence
permit secret values
invent upstream run id

本章二十个 mutation 全部必须产生至少一个 failure。若一个 mutation 被接受, validator 即失败。

hash binding

正式 evidence 保存:

source_sha256
  service-card.json
  slo-policy.json
  observation-contract.json
  ...
  validate.py
  review.py
  task.sh

upstream_references
  path
  schema
  run_id
  sha256
  production_gate

这样以后修改合同再验证旧 evidence,会因 source hash drift 失败。不能拿新版 规则解释旧运行而不声明差异。

live gate 与历史证据分开

本章正式运行做两件事:

current:
  rerun chapter-19 read-only deployment gate

historical:
  bind retained ch20–ch23 run summaries by hash

它没有重跑:

  • failover;
  • restore;
  • pool mutation;
  • credential/RLS drill。

因此结论:

current baseline        rechecked
historical references   identity-bound
historical freshness    not automatically renewed
production proof        no

这是 evidence freshness 的重要边界。

secret scanner

review 扫描 private evidence:

  • SCRAM verifier signature;
  • PEM private key;
  • clear password JSON field;
  • credential-bearing PostgreSQL URI。

scanner 不能发现所有 secret,也会有误报/漏报。还需要:

  • source schema allowlist;
  • credential input 与 output 分离;
  • mode 0600
  • no shell args;
  • redacted projection;
  • 人工 review;
  • secret manager policy。

mode 与位置

正式 evidence directory:

  • 新建且为空;
  • umask 077
  • 文件 group/world 不可读;
  • 不进入 repository;
  • private inventory 不复制、不 hash;
  • repository 只保存 secret-free summary。

本章的 governance-run.json 只保留摘要、run id、计数和 source/upstream hash。

自动化不能决定什么

validator 能发现:

target is 1.0
owner missing
hash mismatched
secret field allowed

它不能决定:

  • 99.9% 是否符合真实用户;
  • 250 ms 是否合理;
  • 400 天是否符合法律;
  • 哪个人有组织授权;
  • 一次 data mismatch 的客户影响;
  • 是否应该恢复到某个时间点;
  • 一个 exception 是否值得接受。

这些需要真实 owner 与 authority。自动化的价值是让他们在同一组可信事实上 决策。

将手工表格转换为规则

手工问题:

请确认有回退方案: [x]

更强的机器合同:

rollback_or_roll_forward nonempty
stop_conditions >= 3
verification >= 3
artifact hash exists
approver != executor
preflight age <= policy

不是所有内容都能计数,但能机器判定的部分不应只靠勾选。

CI 分层

PR time
  parse/schema/cross-reference/math/negative cases

pre-deploy
  target/hash/authority/budget/preflight

during deploy
  stop lines + service/component observation

post-deploy
  verification + evidence manifest

scheduled
  owner/route expiry
  restore evidence age
  notification canary
  exception expiry

把所有检查塞进夜间报表会错过执行门槛。

本章正式结果

run id             34909737-527a-460c-927c-d9d71c93aa13
mode               read-only
source artifacts   8 core + scripts/policies
objectives         5
accepted alerts    7
SOPs               4
negative cases     20 rejected
upstream hashes    4
ch19 live gate     accepted-with-exceptions
secret material    absent
production gate    pending

公开摘要见 governance-run.json,正式原始 evidence 位于 私密临时目录,不进入仓库。

Evidence review 清单

  • 结论要证明什么?
  • source、target、collector、time、tool 是否明确?
  • desired/rendered/observed 是否分开?
  • requester/approver/executor 是否可归因?
  • artifact 是否按 hash 绑定?
  • command success 与 postcondition 是否分开?
  • access evidence 是否不含 credential?
  • recovery evidence 是否包含 application verification?
  • SLO numerator/denominator/query/exclusion 是否可复算?
  • missing intervals 是否保留?
  • original 与 redacted copy 是否可追溯?
  • retention 是否有目的、访问、hold 和删除?
  • hash 是否被错误宣传为绝对 authenticity?
  • shared account 是否破坏归因?
  • raw SQL/bind/customer rows 是否最小化?
  • negative cases 是否真的失败?
  • 历史 evidence 的 freshness 是否明确?
  • compliance 结论是否由适用的正式评估给出?

下一节把服务卡、SLO、告警、SOP 和 evidence 串成一个完整的 pg36_shop 治理实验。


上一节:观察与告警契约 · 返回本章目录 · 下一节:实战:把 pg36_shop 纳入服务治理 · 查看全书目录 · 查看索引中心

24.6 实战:把 `pg36_shop` 纳入服务治理

本节把全章压成一条可重放的 L0 证明:

machine-check service card
  -> calculate SLO and control objectives
      -> bind observation sources and missing semantics
          -> accept actionable alerts
              -> reject actionless page
                  -> bind four SOPs to upstream drills
                      -> enforce high-risk authority
                          -> enforce evidence retention/redaction
                              -> rerun current ch19 deployment gate
                                  -> hash-bind ch20–ch23 summaries
                                      -> reject 20 adversarial mutations

实验不会:

  • 创建或修改 PostgreSQL 对象;
  • 改 PgBouncer/HAProxy;
  • 切换 leader;
  • 运行 restore;
  • 修改 role/credential/HBA/TLS;
  • 部署 VMAlert rule;
  • 发送真实 page;
  • 删除或重置数据。

all 在本章表示“采集只读 gate 并验证全部合同”,不是“执行所有被引用的 高风险演练”。

实验合同:

24.6.1 发布服务卡、SLO、责任人与升级路径

目标环境

service             pg36_shop
target              pg36-l2-vagrant/pg-test
environment         l2-sandbox
data                synthetic teaching data
production traffic  false
Pigsty              v4.5.0
PostgreSQL major    18
host count          4
PG member count     4 across pg-meta and pg-test

服务卡明确:

catalog_status       teaching-reference
production_tier      false
production_slo       false

因此 validator 会拒绝任何把它改成 production SLO 的 mutation。

文件布局

static/labs/ch24/
├── requirements.json
├── service-card.json
├── slo-policy.json
├── observation-contract.json
├── alert-candidates.json
├── sop-catalog.json
├── change-policy.json
├── evidence-retention.json
├── governance-adr.md
├── dependency-map.mmd
├── lab-contract.md
├── negative-cases.json
├── build_evidence.py
├── validate.py
├── review.py
├── task.sh
└── governance-run.json

核心合同八份,其他文件负责解释、验证和保存公开摘要。

服务卡

service-card.json 记录:

customer journeys   place-order, read-order
owner functions     service, data, platform, security/privacy
dependencies        6
health layers       6
escalation           SEV-1, SEV-2, ticket
known gaps          6

四个 owner:

function role id accountable
service shop-service-owner journey、SLO、release、业务决定
data shop-data-owner 语义、保留、质量、隐私分类
platform database-platform-owner PG/Pigsty、容量、恢复、执行
security/privacy security-privacy-duty access、例外、披露、隐私

这些是 role contracts,没有写个人名字。route://... 也只是教学 route id, 不声称有真实 pager。

依赖图

user
  -> pg36_shop application boundary
      -> HAProxy / PgBouncer
          -> PostgreSQL pg-test
              -> Patroni / etcd
              -> pgBackRest / WAL repository

all observable through
  VictoriaMetrics / VictoriaLogs / VMAlert / Alertmanager / Grafana

Mermaid 源文件: dependency-map.mmd

每项依赖带 failure semantics。例如:

victoria-observability fails
  -> service state becomes unknown
  -> do not interpret missing telemetry as healthy

六层健康

process
endpoint
authentication
transaction
correctness
durability_and_recovery

所有层的 sufficient_for_service_health=false,服务卡另有:

"service_health_requires_all_layers_and_user_contract": true

validator 会把第一层改成 true,确认“process alive 即服务健康”被拒绝。

SLO policy

slo-policy.json 定义:

ID kind target
SLO-AVAILABILITY ratio 99.9% / rolling 28d
SLO-LATENCY ratio 99% under 250 ms / rolling 28d
SLO-FRESHNESS ratio 99% under 5 s / rolling 28d
CTRL-CORRECTNESS control zero unexplained mismatch
CTRL-RESTORE-READINESS control passing isolated restore ≤ 90d

算术验证:

window seconds              2,419,200
availability target         0.999
sample eligible events      10,000,000
sample error budget events  10,000
equivalent time budget      2,419.2 s / 40.32 min

Python validator 使用容差重新计算,而不是信任 JSON 中的答案。

exclusion

三个默认排除:

  • admission 前的语法错误;
  • 正确拒绝的未授权身份;
  • admission 前客户端断开且没有 server outcome。

明确:

planned_maintenance_excluded  false
retroactive_exclusion_allowed false

一个新例外必须记录 id、时间、operation、independent approver、reason 和 before/after count。

error-budget state

healthy      > 50%
watch        > 25% and <= 50%
constrained  > 0%  and <= 25%
exhausted    <= 0%

每个状态都有 action。validator 把 exhausted actions 清空,确认“没有后果的 预算”被拒绝。

只做合同 lint

不连接任何 sandbox:

static/labs/ch24/task.sh lint

输出:

status=validation-ok
schema=pg36-ch24-validation-report-v1
production_ch24_gate=pending
status=validation-ok
schema=pg36-ch24-negative-report-v1
counterexamples=20-rejected
production_ch24_gate=pending
status=lint-ok
counterexamples=20-rejected
mutation=none
production_ch24_gate=pending

lint 使用私密临时目录保存报告,并在结束时删除;不产生 repository 文件。

24.6.2 为备份、切换、权限和发布建立 SOP

四份 SOP

sop-catalog.json

ID capability risk owner / approver
SOP-BACKUP-RESTORE backup and restore L3 platform / data
SOP-ROLE-CHANGE switch/failover L3 platform / service
SOP-ACCESS-ROTATION access/credential L2 security / platform
SOP-SCHEMA-RELEASE schema release L2 service / platform

owner 与 approver 不同。每份包含:

  • trigger;
  • exact target;
  • blast radius;
  • prerequisites;
  • procedure phases;
  • stop conditions;
  • verification;
  • rollback/roll-forward;
  • evidence;
  • review interval。

恢复 SOP

输入:

source cluster
backup repository
recovery objective
target time/name
isolated destination
application invariants
space/WAL/authority

停止:

  • identity/target 模糊;
  • destination 可接 production traffic;
  • WAL/integrity 失败;
  • 验收未事先固定。

通过:

  • target marker 存在;
  • post-target marker 不存在;
  • database identity 正确;
  • recovered server 已停止;
  • timing 只称 sandbox observation。

validator 会把 verification 替换为:

backup command exited zero

并拒绝。

role change SOP

先区分:

planned switchover
unplanned failover

共同停止线:

  • 多写主可能;
  • DCS/fencing unknown;
  • candidate 不符合 durability policy;
  • unknown writes 不能核对。

验证:

one leader
timeline progression
acknowledged rows present
unknown classified
pool routes current leader

rollback 文案刻意写:

prefer safe roll-forward to one fenced leader;
a planned return is a new change

timeline 已推进后,不能把“切回”当撤销历史。

access rotation SOP

步骤:

new secret version out-of-band
  -> new authentication test
      -> bounded client/pool migration
          -> old new-session auth rejected
              -> revoke login/credential
                  -> explicit existing-session decision
                      -> verify final role and secret-free evidence

若 suspected disclosure,不能 rollback 到旧 secret,只能再次向前轮换。

schema release SOP

expand schema
  -> old/new app compatible
      -> bounded idempotent backfill
          -> constraint/reconciliation
              -> feature switch
                  -> contract old representation later

停止:

  • lock 超时;
  • WAL/lag/disk/budget 越界;
  • old app 提前不兼容;
  • backfill 不可恢复或不幂等。

上游实验绑定

三份 SOP 引用第 20、21、23 章;第 22 章作为入口/池证据也在本章 upstream manifest 中绑定。

run id SHA-256 production
ch20 475e9b47-bc35-4687-87da-012f1d5ea455 602e932b...9d9a5d pending
ch21 run_20260729T201040Z_961665aa c0b3589d...a212ca pending
ch22 87a63891-d6bf-46b3-bb65-d70a8d7bac3a 444a6521...e6a2 pending
ch23 64b857a6-8d8f-46e2-9462-3f097a95a69f e1548658...cb59 pending

完整 digest 见 governance-run.json

绑定证明:

this governance contract references these exact public summaries

不证明:

raw private evidence is embedded
the runs are fresh today
the actions were repeated in chapter 24
the sandbox results are production performance

变更政策

change-policy.json 固定 L0–L3。

L2/L3:

requester_may_approve                 false
approver_may_execute                  false
requester_approver_executor_distinct  true
shared_accounts_allowed               false
independent_approver_qualified        true
exact_target_and_blast_radius         true
machine_preconditions                 true
confirmation_binds target/action/time true

break-glass:

skip normal wait          allowed
skip target/evidence      forbidden
time-bounded identity     required
after-action review       required
credential rotation       required

正式 L0 运行

需要第 19 章 private mode-0600 inventory:

export PG36_CH19_INVENTORY=/absolute/private/baseline.yml
export PG36_EVIDENCE_DIR=/absolute/private/new-empty/ch24-run

static/labs/ch24/task.sh all

runner:

1 reject nonempty evidence directory
2 run ch19 task.sh all
3 build governance-evidence.json
4 hash current source files
5 bind ch20–ch23 summaries
6 positive validation
7 negative mutation validation
8 review permissions and secret patterns

没有 reset action。

当前第 19 章 gate

正式运行观测:

captured        2026-07-29T21:57:40Z
Pigsty          v4.5.0
PostgreSQL      major 18
hosts           4
PG members      4
sandbox L2      accepted-with-exceptions
exceptions      6
production      pending
mutation        none

六项例外:

  • shared hypervisor;
  • single etcd;
  • single backup target;
  • virtual storage;
  • sandbox inventory secret handling;
  • lab resource floor。

再次通过 gate 没有消除这些例外。

正式 evidence

run id       34909737-527a-460c-927c-d9d71c93aa13
captured     2026-07-29T21:57:40.539Z
mode         read-only-contract-and-live-baseline-binding
mutation     none

private evidence 保存:

  • ch19 current capture;
  • governance evidence;
  • positive report;
  • negative report;
  • review output。

公开仓库只保存 governance-run.json

24.6.3 输出观察契约,并拒绝没有动作的告警候选

观察契约

observation-contract.json 为五个目标绑定:

source
metric type
good/total selector
dimensions
recording rule
query template
missing semantics
fallback

Pigsty component labels:

cls / ins / ip

application labels:

service / operation_class / environment

禁止:

customer_id / tenant_id / order_id / raw_sql / error_message

component telemetry

四组:

ID sources purpose
PG-COMPONENT-ACTIVITY activity/database/io concurrency/error/I/O diagnosis
PG-COMPONENT-REPLICATION replication/receiver/LSN replay and WAL risk
PG-COMPONENT-ARCHIVE archiver/pgBackRest recovery-path risk
PIGSTY-CORRELATION Victoria stack correlate PG/pool/LB/host/log

它们不替代 user SLI。

accepted alerts

alert-candidates.json

alert class route
PG36ShopAvailabilityFastBurn symptom page SEV-1
PG36ShopAvailabilitySlowBurn symptom page SEV-2
PG36ShopAvailabilityBudgetTicket symptom ticket
PG36ShopCorrectnessMismatch integrity page SEV-1
PG36ShopFreshnessFastBurn symptom page SEV-2
PG36ShopCapacityHorizon capacity ticket
PG36MonitoringPathBroken metamonitoring page SEV-2

其中:

symptom/integrity pages  4
metamonitoring pages     1
tickets                  2

所有 accepted item 都有:

  • user impact;
  • owner function;
  • route;
  • runbook;
  • first safe action;
  • verification;
  • dashboard;
  • missing semantics;
  • silence policy;
  • test id;
  • review expiry。

rejected alert

PostgresInstanceDownWithoutAction

字段:

proposed_route     page
decision           rejected
user_impact        null
owner              null
runbook             null
first_safe_action  null

replacement:

correlate endpoint and user-event symptoms;
retain instance state on topology dashboard

这不是漏填;它是一个特意保留的反例。若有人将 decision 改为 accepted, validator 失败。

三个 diagnostic-only causes

PG36ReplicaReplayDistance
PG36PoolQueueDepth
PG36HostCpuHigh

都不 page。它们进入 dashboard,只有 correlated user/integrity/durability 合同触发后才帮助定位。

metamonitoring

要求:

  • application series freshness;
  • VMAlert evaluation failures;
  • Alertmanager notification canary;
  • VictoriaMetrics query/ingestion;
  • external blackbox probe。

missing_is_healthy=false

二十个反例

ID mutation 被哪类不变量拒绝
N01 宣称 production SLO scope
N02 删除 platform owner ownership
N03 process 即服务健康 health chain
N04 SLO target = 100% ratio target
N05 排除 planned maintenance exclusion
N06 instance health = user SLI measurement
N07 missing = healthy missing semantics
N08 exhausted 无 action budget policy
N09 availability series 缺失算健康 observation
N10 允许 unbounded labels cardinality/privacy
N11 page 无 first action actionability
N12 cause 直接 page alert class
N13 capacity 直接 page route
N14 接受 actionless instance page rejection
N15 backup exit 0 即 restore recovery evidence
N16 一人申请批准执行 authority
N17 break-glass 跳过 target/evidence emergency boundary
N18 access evidence 允许 secret privacy
N19 伪造 ch21 run id upstream identity
N20 沙箱切换称 production proof claim boundary

正式结果:

case_count      20
rejected_count  20
failure_count   0

review 输出

status=review-ok
run_id=34909737-527a-460c-927c-d9d71c93aa13
service_card=complete
objectives=3-ratio+2-control
accepted_alerts=7
actionless_alerts=1-rejected
sops=4
counterexamples=20-rejected
ch19_live_gate=accepted-with-exceptions
upstream_runs=4-bound-by-hash
mutation=none
production_ch24_gate=pending
secret_material=absent

为什么 production 仍 pending

机器验证通过,不等于生产批准。仍缺:

  1. 真实 service/data/platform/security owner;
  2. 真实 on-call schedule 与 notification test;
  3. 真实用户 traffic 的 eligible/good classifier;
  4. 生产 failure domains;
  5. 生产容量和 workload;
  6. 多次切换/恢复的分布,而非一次观察;
  7. 真实 RTO/RPO 谈判;
  8. 法律/隐私 retention authority;
  9. production secret/audit 路径;
  10. 第 25 章真实 instrumentation/rules/dashboards;
  11. 生产等价环境中的重复演练;
  12. 正式 exception 与 expiry。

所以本章结论:

governance contract       pass
machine validation        pass
adversarial validation    pass
current sandbox baseline  accepted-with-exceptions
upstream identity binding pass
real alert delivery       not run
production SLO evidence   absent
production approval       pending

重验已有 evidence

export PG36_EVIDENCE_DIR=/absolute/private/existing/ch24-run
static/labs/ch24/task.sh verify
static/labs/ch24/task.sh review

修改任何受 hash 保护的 policy/script 后,旧 evidence 验证会失败。应:

retain old evidence with old source
create new empty evidence directory
run current contract again
compare decisions explicitly

不能覆盖旧 evidence 让历史“自动符合”新政策。

本章验收

  • service card 有 4 owner、6 dependencies、6 health layers;
  • 3 个 ratio SLO 和 2 个 control objective 可计算;
  • planned maintenance 未被隐藏;
  • missing telemetry 不算健康;
  • error budget 真实影响 change policy;
  • 四类 SOP 有停止线与验证;
  • L2/L3 权力分离;
  • break-glass 仍需 target/evidence;
  • 五个 observation source 有 query/missing/fallback;
  • 7 个 accepted alerts 可行动;
  • cause、capacity 与 symptom 分开;
  • actionless page 明确拒绝;
  • 六类 evidence 不允许 secret;
  • ch19 live gate 重新只读通过;
  • ch20–23 run 按 id/hash 绑定;
  • 20 个 adversarial mutations 全拒绝;
  • production gap 与 pending gate 保留。

第 25 章的任务不是重新发明这些语义,而是把它们实现为真实的应用 metric、 Pigsty recording/alert rules、dashboard、notification route 和 rule tests。


上一节:证据、审计与合规 · 返回本章目录 · 下一章:望闻问切:监控体系与可观测诊断 · 查看全书目录 · 查看索引中心

25 望闻问切:监控体系与可观测诊断

“有监控”很容易,“知道发生了什么”很难。

一个看起来成熟的平台可能同时拥有:

26 个 PostgreSQL 仪表盘
3,000 多种指标名
数百条记录规则
几十条告警规则
集中日志
追踪后端
值班通知

事故发生时却仍然只能说:

CPU 高了
连接多了
复制慢了
面板红了

这些句子描述了现象,没有回答五个决定性问题:

  1. 用户旅程是否真的失败、变慢、读旧或产生错误结果?
  2. 这是首发症状、伴随现象,还是已经被证据支持的机制?
  3. 观察数据是零、尚未刷新、被重置、被采样,还是根本缺失?
  4. 哪个 owner 应采取什么首个安全动作
  5. 什么证据能够证明恢复,而不是仅仅“面板变绿”?

第 24 章先定义了服务、SLI、SLO、控制目标、缺失语义和告警治理。本章做下一步:

observation contract
  -> 指标、日志、事件和追踪
      -> PostgreSQL 原生统计与 SQL 基线
          -> Pigsty 采集、存储、规则、面板和通知
              -> 可行动告警
                  -> 有界诊断包
                      -> 合成规则与离线路由演练
                          -> 覆盖表、盲区与生产门禁

这条链的重点不是“多收集一些数据”,而是让每个结论都能说明:

question       想回答什么
source         哪个系统产生事实
semantics      值、窗口、标签、reset 和缺失是什么意思
cost           采集、查询和保留会付出什么代价
action         谁可以做什么
verification   怎样证明结论与恢复
boundary       什么仍然不知道

本章目标

完成本章后,你应当能够:

  1. 从用户、入口、数据库和主机四层组织观察问题;
  2. 区分症状信号、原因信号、控制信号与监控系统自身信号;
  3. 说明指标、日志、事件和追踪各自适合回答什么;
  4. 为 label cardinality、采样、保留和查询成本建立预算;
  5. 把“没有数据”区分为无流量、采集故障、查询错误、延迟与真实零值;
  6. 正确读取 pg_stat_activitypg_locks 与 wait event;
  7. 使用 pg_stat_databasepg_stat_iopg_stat_walpg_stat_checkpointerpg_stat_archiver
  8. 解释累计计数、瞬时状态、估算值和进度视图的不同时间语义;
  9. 区分 WAL distance、时间 lag 与 commit-correlated freshness;
  10. 观察 autovacuum、冻结年龄、dead tuple、对象增长和维护进度;
  11. 正确解释 pg_stat_statements 的聚合键、reset、deallocation 与权限;
  12. 解释为什么 normalized query text 仍不能随意进入证据;
  13. 为慢语句、锁等待、临时文件和错误日志设置有界政策;
  14. 评估 auto_explainANALYZE、timing、采样和参数泄露成本;
  15. 把第 24 章 SLO 合同变成 multiwindow、multi-burn-rate 规则;
  16. 区分 page、ticket、diagnostic 与 proposed test route;
  17. 为 alert 的 for、分组、抑制、恢复和缺失语义写测试;
  18. 防止 fast burn、slow burn 与预算工单形成重复风暴;
  19. 防止 metamonitoring 抑制独立用户症状或正确性告警;
  20. 理解 Pigsty v4 的 VictoriaMetrics、VictoriaLogs、VictoriaTraces、 VMAlert、Alertmanager、Grafana、pg_exporter 与 Vector;
  21. clsinsip、database 与 queryid 在不同粒度间下钻;
  22. 从仪表盘回到 PostgreSQL SQL、日志和主机事实复核;
  23. 自动保存有界、私密、可复验的诊断包;
  24. 区分首发症状、相关现象、候选机制与根因;
  25. 限制诊断查询的 timeout、并发、结果量和权限;
  26. 用隔离时间序列验证 pending、firing、recovery 与 missing;
  27. 用空 receiver 离线验证路由,不触碰真实 pager;
  28. 输出覆盖矩阵,诚实标出尚未实现的应用 SLI;
  29. 对当前沙箱给出“机制通过、生产待决”的可审计结论。

本章不做什么

本章不是一个“复制几十条 PromQL 就上线”的规则包,也不会:

  • 把当前沙箱阈值包装成所有生产环境的通用答案;
  • 修改在线 VMAlert、Alertmanager、Grafana 或 PostgreSQL;
  • 向在线 Alertmanager 提交合成告警;
  • 接入 webhook、邮件、短信、Slack 或真实 pager;
  • 运行 EXPLAIN ANALYZE、压测、统计 reset 或数据库故障注入;
  • 导出 query text、bind value、日志正文、client address 或凭据;
  • pg_up=1 证明订单服务可用;
  • pg_lag=0 证明 read-your-writes;
  • failed_count>0 直接证明归档仍在失败;
  • 用“当前无告警”证明通知链可达;
  • 用一次仪表盘相关性宣布 root cause;
  • 声称 pg36_shop 已经具有真实应用埋点。

实验使用 pg36_shop 这一 synthetic teaching service。应用层五种信号仍是刻意 保留的缺口:

request outcome counter
request duration histogram
commit-correlated freshness probe
domain reconciliation gauge
restore-evidence age gauge

缺口不是失败的写作,而是本章必须保留的事实。如果没有应用事件,平台不能从 数据库组件指标“推算”出一个看似完整的用户 SLO。

从监控到可观测诊断

监控回答已知问题

监控通常从一个已知条件出发:

if bad_ratio_1h > threshold
and bad_ratio_5m > threshold
for 2m
then page

它适合稳定、可计算、可自动执行的问题:

  • SLO 是否快速燃烧;
  • exporter 是否持续不可达;
  • 规则是否持续报错;
  • 恢复证据是否超过政策期限;
  • 容量预测是否进入评审窗口。

可观测诊断解释未知状态

诊断从“不知道为什么”出发,需要沿不同证据层缩小假设:

user latency burn
  -> entry queue or rejection?
      -> pool saturation or connection churn?
          -> PostgreSQL active wait or lock?
              -> queryid cost or plan drift?
                  -> storage, WAL, checkpoint, vacuum or host constraint?

可观测性不是一个产品名,也不是“拥有 logs + metrics + traces”自动获得的属性。 它要求系统输出足够的、语义明确的证据,让操作者能区分多个竞争解释。

诊断不等于根因

本章采用四层语言:

层次 可以说什么 例子
首发症状 最早被可靠观察到的服务偏离 availability fast burn 先触发
伴随现象 与症状同窗出现 pool queue、lock wait 同时升高
候选机制 现象与某机制一致 长事务可能阻止 vacuum 推进
根因证据 机制被复现/独立证实,反例被排除,修复验证闭环 释放特定锁后等待消失且合成路径恢复

“两条线一起升高”最多是相关性。要升级为根因,至少需要:

mechanism
independent corroboration or reproduction
falsification attempts
repair verification

四层问题,而不是四套孤岛面板

本章把信号按问题分为四层:

主要问题 首选事实
用户 旅程是否成功、及时、正确、足够新鲜 eligible events、合成探针、domain reconciliation
入口 请求在哪排队、被路由、重试或拒绝 应用 edge、HAProxy、PgBouncer
数据库 PG 状态能否解释症状 activity、locks、I/O、WAL、maintenance、queryid
主机 资源或基础设施是否构成约束 CPU、memory、disk、network、clock

从下往上推断很危险:

CPU 90%
  therefore users are slow          # 不成立

one replica is down
  therefore service is unavailable  # 不成立

pg_up == 1
  therefore orders are correct       # 不成立

从上往下诊断更稳健:

user symptom is real
  -> locate affected operation and path
  -> correlate entry and database evidence
  -> test competing mechanisms
  -> choose the least harmful action
  -> verify user and control signals

正确性与恢复就绪又是特殊控制:

one unexplained cross-tenant row
  cannot be averaged away

old or missing restore evidence
  cannot be replaced by backup job success

四种信号,各有边界

信号 擅长 不擅长 必须声明
metric 趋势、比率、聚合、规则 高维上下文、单请求故事 type、unit、label、reset、missing
log 离散事件、错误上下文、状态迁移 完整总体比率、无界扫描 schema、采样、脱敏、保留
event 发布、切换、配置和所有权时间线 单独证明因果 actor、target、result、clock
trace 单次跨组件路径 未采样总体、长期预算 sample、baggage、PII、correlation

四者不是竞争关系:

metric detects
event bounds the change window
trace follows one affected request
log explains a discrete failure
SQL verifies PostgreSQL state

同样,也不应强迫每个问题都使用四种信号。一个能够由累计计数精确回答的问题, 不需要先扫描全部日志;一个需要参数上下文的问题,也不应把 error text 做成 metric label。

时间语义比数值更重要

观察数据至少有五种时间性质:

类型 示例 典型陷阱
当前状态 pg_stat_activity.state 一次采样漏掉短暂事件
累计计数 pg_stat_database.xact_commit 把总数当速率、忽略 reset
滚动窗口 rate(counter[5m]) 窗口过短、采样不足
估算值 n_dead_tup 当成精确 bloat
当前进度 pg_stat_progress_vacuum 没有行不等于从未运行

还要处理采集链延迟:

database state time
  -> exporter scrape time
      -> storage ingestion time
          -> rule evaluation time
              -> route grouping delay
                  -> receiver delivery time

如果数据源有意延迟 30 秒,而规则在“当前时刻”查询,最新样本可能尚未可见。 VMAlert 支持 evaluation delay;正确值取决于实际采集和存储延迟,不能机械照抄。

本章的规则分层

实验生成 18 条记录规则和 13 条告警规则。它们分成三类:

第 24 章已经接受的七条

告警 路由 目的
PG36ShopAvailabilityFastBurn page 1h + 5m,14.4x
PG36ShopAvailabilitySlowBurn page 6h + 30m,6x
PG36ShopAvailabilityBudgetTicket ticket 3d + 6h,1x
PG36ShopFreshnessFastBurn page commit-correlated freshness
PG36ShopCorrectnessMismatch page 不允许平均稀释的正确性
PG36ShopCapacityHorizon ticket 经评审预测才进入工作队列
PG36MonitoringPathBroken page 观察或通知路径不可证明工作

仍待治理接受的六条

latency burn
restore evidence stale
active archive risk
long transaction horizon
freeze-age horizon
expected traffic but SLI missing

它们有完整规则和测试,但统一标记:

route: test
severity: candidate
governance_status: proposed-not-accepted

“代码写好了”不等于“组织接受了 page/ticket 政策”。这个显式不一致检查很重要: 第 24 章定义了 latency SLO,却没有接受 latency alert candidate。本章没有暗中 补齐生产政策,而是把它暴露为待决项。

诊断记录

复制距离、长事务、freeze age、dead tuples、exporter 状态、VMAlert rule error 和 notification failure 首先是诊断或控制输入。它们不会因为“容易写阈值”就 自动变成 page。

当前沙箱的真实快照

正式实验公共摘要: observability-run.json

采集时间为 2026-07-29T22:48:21Z。快照只代表该时刻:

项目 观察值
Pigsty v4.5.0
PostgreSQL 18.6
pg_exporter v1.4.0
VictoriaMetrics v1.148.0
VictoriaLogs v1.52.0
VictoriaTraces v0.9.4
Alertmanager 0.33.1
VictoriaMetrics series 44,842
live VMAlert groups 17
live alert rules 50
live recording rules 698
live rule errors 0
current VMAlert alerts 0
pg_up / pg_exporter_up instances 4 / 4
pg36_shop_* application SLI series 0

目标身份通过三层交叉确认:

host            pg-test-1
Patroni scope   pg-test
PostgreSQL      cluster_name=pg-test
role            primary
replication     pg-test-2 + pg-test-3, async streaming
observed WAL gap 0 + 0 bytes

0 bytes 是当时的发送—回放距离,不是 read-your-writes 证明。

pg_stat_statements 快照:

extension version      1.12
schema                 monitor
rows                   194
calls                  122,303
query text exported    false
stats reset            retained

归档快照有一个关键反例:

failed_count           21
last failure           18:57:58Z
last successful archive 22:27:54Z

如果规则只是:

pg_archiver_failed_count > 0

它会在系统已经恢复后永久报警,直到统计被重置。候选规则因此同时检查:

15m 内出现新失败
AND 最近成功归档已经停滞

再回到 pgBackRest 与恢复证据复核。累计 counter、当前故障和恢复就绪是三个 不同结论。

实验为什么分成在线与隔离两部分

在线只读基线

在线部分只做:

  • HTTP health/API/metrics 读取;
  • VictoriaMetrics 即时查询;
  • 通过元节点进入真实 pg-test-1
  • statement_timeout=5slock_timeout=500ms 的只读 SQL;
  • 聚合 activity、locks、I/O、WAL、checkpointer、archiver、 replication 和 pg_stat_statements
  • 记录版本、reset、freshness 与缺口。

不会 reset、reload、写表、运行计划、制造负载或读取 query text。

隔离规则与路由

规则文件上传到沙箱元节点的:

/tmp/pg36-ch25.XXXXXXXX

随后:

  1. vmalert -dryRun 检查规则语法;
  2. vmalert-tool 启动 loopback-only VictoriaMetrics;
  3. 注入合成时间序列;
  4. 验证 normal、pending、firing、recovery 和 missing;
  5. amtool check-config 检查 Alertmanager 配置;
  6. 用八组标签离线解析到空 receiver;
  7. 用五个用例验证抑制边界;
  8. 清理临时目录并确认目录不存在。

它不会接触在线 VMAlert 或在线 Alertmanager。

本章目录

25.1 从问题选择可观测信号

25.2 PostgreSQL 核心运行信号

25.3 SQL 可观测基线

25.4 把观察契约变成告警

25.5 Pigsty 可观测体系

25.6 从告警到诊断包

25.7 实战:实现并演练观察契约

官方资料

本章技术语义优先回到原始文档:

下一章 第 26 章 容量规划与压测基线 会在这些观察 语义之上建立需求模型、容量水位和可比较基线;第 28 章 VACUUM、冻结与膨胀治理 再深入维护信号。第 31 章把本章的诊断包作为故障排查入口,处理慢查询、锁、 连接、复制与磁盘等具体事件。


上一章:纲举目张:SLO、SOP 与组织治理 · 返回下卷导读 · 下一章:胸有成竹:容量规划与压测基线 · 查看全书目录 · 查看索引中心

25.1 从问题选择可观测信号

先选指标,再问它能说明什么,是监控系统膨胀的主要原因。

有 PostgreSQL
  -> 收集所有 pg_* 指标
      -> 导入所有 dashboard
          -> 给红色曲线加阈值
              -> 事故时再猜它们与用户有什么关系

更可靠的顺序是:

decision
  -> question
      -> evidence
          -> semantics
              -> collection cost
                  -> action and verification

例如,“副本 lag 多大”不是一个完整问题。它可能对应三种完全不同的决定:

决定 真正问题 需要的信号
是否把 read-after-write 流量路由到副本 已知 commit 是否在该读取路径可见 commit token probe
是否有 WAL 保留风险 primary 与 replica 的 LSN 距离是否持续扩大 replication position + WAL retention
是否解释查询结果陈旧 用户查询走了哪条路径、返回哪个版本 routing event + application semantics

一个 pg_lag 无法同时替代这三个答案。

本节建立一份问题驱动的信号合同。可执行版本见 signal-contract.json

25.1.1 用户体验、服务入口、数据库与主机四层

第一层:用户是否获得了正确服务

用户层不是浏览器 RUM 的同义词,而是最接近服务承诺的测量点。对 pg36_shop

journey       place-order
eligible      authorized and valid request admitted by the app
good          success returned and token reconciles to one committed order
timely        final result within 250 ms
fresh         committed token visible on declared read path within 5 s
correct       no unexplained invariant mismatch

这些定义直接决定分子和分母:

$$ \text{availability bad ratio}

\frac{\text{failed or unreconciled eligible attempts}} {\text{all eligible attempts}} $$

如果只收 HTTP 2xx/5xx,会漏掉:

  • 返回 200,但事务后来失败;
  • 客户端超时,但写入已经提交;
  • 重试创建了两个订单;
  • 响应成功,但订单属于错误租户;
  • primary 写入成功,随后从副本读取不到;
  • 应用在 admission 前正确拒绝无效请求。

所以用户层信号常常要组合两个测量点:

application edge outcome
  +
commit outcome / idempotency reconciliation

eligible event 必须先定义

没有 eligible 分母,错误率会随着流量分类任意变化。例如:

all inbound requests
  includes scanners, malformed requests, health checks, unauthorized calls

admitted order attempts
  excludes pre-admission syntax and authorization rejection
  includes server outcomes after admission

“用户断开”是否计入也不能临场决定:

disconnect before admission and no server outcome
  -> may be excluded by a predeclared rule

disconnect after transaction may have committed
  -> unknown outcome, must reconcile

正确性不是可用性的一部分

如果 10,000,000 个订单中有一行串租:

availability could still be 99.99999%
security and correctness are still failed

因此正确性使用 control signal:

pg36_shop_reconciliation_mismatches > 0

它需要:

  • invariant 是有界枚举;
  • reconciliation 有输入边界;
  • 结果有 hash 或不可变运行记录;
  • stale/missing 本身是控制失败;
  • 修复后由独立核对确认,而不是把 gauge 手工设为零。

第二层:请求在哪个入口发生了什么

入口层回答的是路径问题:

client
  -> application edge
      -> HAProxy service port
          -> PgBouncer pool
              -> PostgreSQL session

每个入口有不同的拒绝、排队和重试语义:

入口 关键问题 典型证据
application edge 请求是否 admission、是否重试、最终 outcome request counter、duration histogram、release id
HAProxy 选择了哪个 backend、健康检查如何判断 backend/session/queue、routing event
PgBouncer 等待 server connection 还是正在执行 client/server/pool state、wait duration
PostgreSQL backend 在执行、等待还是 idle in transaction activity、wait event、locks

“连接数高”可能代表:

healthy concurrency
idle application connections
pool wait
session leak
long transaction
blocked query fan-out
maintenance workers

如果没有入口身份与状态,单个总数无法区分。

用 operation class,避免用 endpoint 爆炸

应用指标需要能分段,但不能把完整 URL 做标签:

good:
  operation_class="place-order"
  operation_class="read-order"
  operation_class="reconcile-order"

bad:
  path="/tenant/123/order/9a7..."

operation class 是有界业务语义;原始 path 可能包含 tenant、order 和 token, 既造成 cardinality 爆炸,也造成数据泄露。

第三层:PostgreSQL 能否解释用户症状

数据库层分成当前状态与累计事实:

current
  pg_stat_activity
  pg_locks
  progress views
  pg_stat_replication

cumulative
  pg_stat_database
  pg_stat_io
  pg_stat_wal
  pg_stat_checkpointer
  pg_stat_archiver
  pg_stat_statements

它们回答:

  • 请求是否在 PostgreSQL 内;
  • backend 在 CPU 上运行还是等待;
  • 等待是 lock、I/O、client、WAL、buffer pin 还是其他类别;
  • transaction 已持续多久;
  • 哪个 queryid 消耗时间、I/O、临时块或 WAL;
  • checkpoint 写入和同步成本如何变化;
  • WAL 生成、发送、归档和回放是否推进;
  • vacuum/freeze 是否被阻止;
  • dead tuple、对象大小和统计新鲜度如何变化。

它们不直接回答:

  • 用户请求是不是 eligible;
  • 应用是否返回了正确结果;
  • 哪个 tenant 受到影响;
  • 用户是否走了 replica read path;
  • 恢复点是否被业务接受。

数据库信号是解释层,不是服务层的替代品。

current 与 cumulative 必须分开

下面两个事实可以同时成立:

pg_stat_activity now shows no lock wait
pg_stat_database deadlocks counter increased in the last hour

前者是“现在没有”,后者是“窗口内曾经发生”。不能因为 current view 已经恢复, 就否定累计异常;也不能因为累计 counter 非零,就宣称当前仍在发生。

第四层:主机是否构成资源约束

主机层观察:

  • CPU utilization、run queue、steal;
  • memory pressure、swap、OOM;
  • filesystem capacity、inode、mount state;
  • block-device latency、queue、throughput;
  • network loss、retransmit、bandwidth;
  • clock synchronization;
  • process、cgroup、systemd 和 kernel event。

主机指标要与 PostgreSQL 语义交叉:

high CPU
  + PostgreSQL active non-waiting backends
  + queryid execution time rises
  -> consistent with compute saturation

high CPU
  + user SLI healthy
  + batch window declared
  -> may be expected work

disk latency rises
  + pg_stat_io read_time rises
  + shared reads rise
  -> storage path becomes a stronger candidate

disk latency rises
  + PG reads unchanged
  -> investigate other processes or filesystem activity

主机指标很适合 falsify 假设。例如,若 PostgreSQL 认为 I/O 时间增加,而设备 层没有对应变化,可能是 OS cache、采集时间窗、虚拟化层或统计口径不同,而不是 直接得出“磁盘坏了”。

四层不是固定下钻顺序

通常从用户症状向下,但也有例外:

durability control
  archive stopped before user impact
  -> page is justified by imminent durability loss

metamonitoring
  rule evaluation failed
  -> health becomes unknown
  -> establish independent observation first

freeze-age horizon
  no current impact
  -> ticket before emergency anti-wraparound work

正确原则不是“永远只看用户”,而是:

page requires current user impact,
integrity/durability emergency,
or loss of the observation path.

原因和容量信号默认进入 diagnostic 或 ticket。

建立问题卡

每个新信号先填一张问题卡:

question: 已知 commit 是否在 replica read path 五秒内可见?
decision: 是否临时把 read-after-write journey 路由到 primary?
layer: user
source: commit-correlated synthetic probe
good: token visible within 5s
bad: token not visible within 5s
missing: unknown and probe-path failure
dimensions: [service, read_path, environment]
fallback: direct primary-path probe
owner: service
first_safe_action: preserve tokens and use primary path
verification: new tokens meet bound on declared path

若填不出 decision、missing 和 action,这个信号还不适合变成告警。

反例:从副本时间戳推用户新鲜度

常见快捷方式:

clock_timestamp() - pg_last_xact_replay_timestamp()

在一个空闲副本上,最近没有 WAL,它可能显示“很久以前”;但副本其实完全 追平。持续写入时,它又只能说明最后一次 replay 的时间,不知道用户关心的 commit 是否已经可见。

新鲜度需要:

write unique token
capture commit outcome
poll declared read path
measure until token visible

WAL distance 和 replay timestamp 仍有诊断价值,但不能替代 commit correlation。

25.1.2 指标、日志、事件和追踪各回答什么

metric:把总体变成可计算时间序列

metric 最适合:

  • event ratio;
  • latency distribution;
  • rate、increase 和 trend;
  • 容量 horizon;
  • 规则自动评估;
  • 多实例聚合。

四种常见类型:

类型 语义 PostgreSQL/Pigsty 示例 主要陷阱
counter 只增,进程/reset 后重置 transaction、deadlock、WAL byte 直接比较总值
gauge 可升可降的当前/最近值 connections、lag、object size 对瞬时噪声 page
histogram bucket counter + count/sum request duration bucket 不一致、聚合错误
summary 客户端计算 quantile 某些应用延迟 quantile 难以跨实例聚合

counter 要转成窗口

rate(pg_db_xact_total[5m])
increase(pg_archiver_failed_count[15m])

不要:

pg_archiver_failed_count > 0

除非语义明确要求“生命周期内从未失败”。大多数运行告警关心的是新失败与当前 推进,而不是历史存在。

gauge 需要稳定条件

pg36_shop_restore_evidence_age_seconds > 90 * 24 * 60 * 60

evidence age 是 gauge,但它不是“数据库坏了”。它是恢复控制未满足,应进入 change gate 或 ticket,除非业务政策另有紧急定义。

histogram 要从 bucket 算事件比率

延迟 SLO 目标是 99% 在 250 ms 内:

$$ \text{latency bad ratio}

1 - \frac{\text{rate}(\text{bucket}_{le=0.25})} {\text{rate}(\text{count})} $$

它与 p99 不完全等价。event-based SLO 直接计算“多少 eligible events 超标”; quantile 是分布位置,适合探索,但不一定能直接算错误预算。

metric contract 的最小字段

name
type and unit
producer
labels and cardinality bound
counter reset or gauge freshness
scrape interval
retention
missing semantics
query examples
owner
deprecation plan

没有 type,就不知道能否 rate();没有 reset,就不知道突然下降是改善还是 重启;没有 missing,就可能把 absence 变成健康。

log:保存离散上下文

日志适合回答:

  • 哪个错误类别发生;
  • 状态何时迁移;
  • lock wait 在 deadlock_timeout 后是否被记录;
  • 哪个 temporary file 被创建;
  • Patroni 何时改变角色;
  • pgBackRest 哪一步失败;
  • 配置 reload 或连接认证发生什么。

日志不适合作为默认总体统计:

scan all PostgreSQL logs
count matching strings
divide by all lines

因为:

  • 日志行不等于 eligible event;
  • multiline 和格式变化影响计数;
  • sampling 会丢事件;
  • rotation/retention 改变分母;
  • 查询成本随数据量增长;
  • 原始 SQL、参数和用户信息可能泄露。

structured log 仍然需要 schema

推荐将字段分为:

stable dimensions
  cluster / instance / database / severity / error_code

bounded correlation
  release / operation_class / incident_id

restricted payload
  message / statement / detail / parameter

restricted payload 不应自动复制到 alert annotation、ticket 或公开 evidence。 “JSON 格式”只解决解析,不解决敏感性。

event:把变化放进时间线

发布、切换、配置和容量变化最好是结构化 event:

{
  "event_id": "change-20260729-017",
  "kind": "application-release",
  "target": "pg36_shop/l2-sandbox",
  "actor_role": "shop-release-operator",
  "started_at": "...",
  "completed_at": "...",
  "result": "success",
  "rollback_ref": "..."
}

它能帮助回答:

symptom started at 10:02
release completed at 09:58
pool config changed at 10:01
failover did not occur

event 本身仍不证明因果。一个发布接近事故,只是强候选,需要机制和修复验证。

event 的 clock 与 identity

事件至少记录:

  • stable event id;
  • actor role,而不是随意字符串;
  • exact target;
  • UTC 时间和时间源;
  • request、approval、execution、result;
  • rollback/roll-forward reference;
  • source integrity。

若主机时钟相差两分钟,所谓“先发生”会被颠倒。诊断包要同时保存:

collector clock
database clock
rule evaluation clock

trace:跟随单次路径

trace 能展示:

edge span
  -> service span
      -> pool wait
          -> database call
              -> downstream service

它适合:

  • 一次请求在哪里耗时;
  • 重试和 fan-out 如何展开;
  • 哪个 dependency 返回错误;
  • sampled slow path 与正常 path 有何不同。

它不适合单独算完整 SLO:

  • 采样意味着不是全部事件;
  • tail sampling 会改变总体;
  • trace backend 丢失不能解释为“无慢请求”;
  • baggage 可能携带 tenant、token 或 PII;
  • 数据库 span 未必包含真实执行等待。

不要把 SQL 文本塞进 span

推荐:

db.system=postgresql
db.namespace=test
db.operation.name=SELECT
db.query.summary=read-order
db.queryid=<bounded hash identity if policy allows>

谨慎或禁止:

db.statement=<raw SQL with literals>
bind.parameters=<customer data>
connection.string=<credential>

queryid 也不是绝对安全身份:它是 hash,可能冲突;相同文本在不同 search_path 下语义也可能不同。它的价值是聚合和关联,不是授权或数据分类。

四种信号如何组合

一个 latency fast burn 的调查:

metric
  availability healthy, latency bad ratio burns

event
  pool size changed four minutes before onset

trace
  sampled requests spend time waiting for a server connection

log
  PgBouncer reports pool saturation but no auth errors

SQL
  PostgreSQL active sessions and query cost are stable

host
  CPU and storage are stable

这组证据支持“入口池排队”而不是“数据库执行变慢”。如果回滚池配置后:

  • queue 恢复;
  • trace pool wait 恢复;
  • user latency windows 恢复;
  • 没有 correctness mismatch;

因果证据才明显增强。

选择最便宜的充分信号

同一问题可能有多种来源:

count transaction commits
  pg_stat_database counter        cheap, aggregate
  parse every log line            expensive, context-rich
  trace every transaction         very expensive, sampled

如果只需要总体 rate,优先 counter;若要解释一类错误,再查询有界日志;若要 追单次跨服务路径,再使用 trace。不要因为存储便宜就永久收集一切。

25.1.3 标签基数、采样、保留与缺失数据

cardinality 是维度乘积

一个 metric 的 series 数近似为:

seriesi=1nlabeli×histogram buckets \text{series} \approx \prod_{i=1}^{n}\left|\text{label}_i\right| \times \text{histogram buckets}

假设:

service             20
environment         3
operation_class     30
status_class        6
release             10 retained concurrently
histogram bucket    15

则:

20×3×30×6×10×15=1,620,000 20 \times 3 \times 30 \times 6 \times 10 \times 15 = 1{,}620{,}000

如果再加:

tenant_id  100,000
order_id   unbounded

系统不仅会爆炸,还会把业务标识复制到监控存储。

bounded label allowlist

本章允许:

Pigsty identity
  cls / ins / ip

service identity
  service / operation_class / environment

bounded optional
  read_path / invariant / recovery_class / objective_id / release

禁止:

customer_id / tenant_id / order_id / idempotency_token
trace_id / raw_sql / raw query text / error_message
client_addr / password / token

release 虽然有界,也要限制同时保留多少版本;滚动发布若不断产生唯一 commit SHA,而旧 series 长期不消失,仍会增长。

Pigsty 的 pg_query_* 指标有一个需特别说明的例外:label 名为 query,值是 数值型 queryid,而不是 SQL 文本。它的上限受 pg_stat_statements.max 约束; 仍要结合 database,并禁止把 raw query text 填进同名 label。

label 与 annotation 的边界

label 用于:

  • series identity;
  • query aggregation;
  • alert grouping/routing;
  • silence matcher。

annotation 用于人读的说明,不参与 series identity。但 annotation 也不能放 秘密。一个安全模式:

labels:
  service: pg36_shop
  operation_class: place-order
  environment: production
  objective_id: SLO-AVAILABILITY

annotations:
  summary: availability budget burning
  runbook: RB-USER-SYMPTOM
  dashboard: dashboard://pg36-shop-slo

不要:

labels:
  order_id: 8f...
  query: SELECT ...
annotations:
  error: password authentication failed for ...

先测 cardinality,再加维度

增加 label 前回答:

  1. 值域是否有硬上限?
  2. 谁控制它?
  3. 每个值保留多久?
  4. query 与 dashboard 是否真实使用?
  5. alert 是否需要它来路由?
  6. 是否能在日志/trace 中按需查,而不进入 metric?
  7. 该字段是否是 PII、secret 或业务主键?

若只是“以后可能有用”,默认不加。

sampling 必须改变措辞

日志或 trace 被采样后,允许说:

sampled slow requests show pool wait

不允许说:

all slow requests are caused by pool wait

采样合同至少包括:

  • head、tail 或 probabilistic;
  • sample rate;
  • error 是否强制保留;
  • rate 随流量是否变化;
  • dropped count;
  • decision point;
  • 是否能重建总体;
  • 变更历史。

PostgreSQL log_min_duration_samplelog_statement_sample_rate 也是采样政策。 如果前者关闭,后者值为 1 并不代表所有语句都会被采样记录。

retention 决定能否回答窗口问题

若 SLO 是 rolling 28d,而原始 SLI 只保留 7d:

cannot recompute 28d objective from raw data

可以使用长期 recording rule,但必须记录:

  • 原始数据保留多久;
  • 聚合数据保留多久;
  • rule expression 的版本;
  • label 是否在聚合时丢失;
  • reset/缺口如何进入结果;
  • 回填是否发生。

诊断与治理保留期也不同:

数据 典型目的 关注点
高频 raw metric 近期诊断 容量大、粒度高
recording rule 长期趋势/SLO 语义版本
log body 事件上下文 敏感、访问、删除
trace sampled path 成本与 PII
incident evidence 决策与复盘 完整性、最小化

“永久保存以备万一”通常同时违反成本和隐私原则。

缺失数据至少有七种解释

1. 真实没有事件
2. producer 没有初始化零 series
3. exporter 失败
4. scrape/ingestion 延迟
5. storage/query 失败
6. label 或 metric rename
7. service / instance 已按计划退役

还有 counter reset、staleness marker 和查询窗口不足。

absent() 不是万能答案

判断 SLI missing 需要一个独立期望:

pg36_expected_service_traffic == 1
unless on (service, environment)
pg36_sli_sample_fresh == 1

如果服务夜间没有流量,request counter 没新样本不一定故障。可以用:

  • 独立 synthetic probe;
  • admission counter;
  • deployment/inventory declaration;
  • scrape target freshness;
  • expected schedule。

关键是不能用被监控对象自身同时证明“应该有数据”和“数据存在”。

zero、empty、NaN、stale 与 error

结果 含义
0 series 存在,当前值或计算结果为零
empty vector selector 没匹配 series
NaN 运算未定义,例如 0/0
stale 时序被标记过期
query error 规则没有获得结果

把 empty vector 用 or vector(0) 填零可能很危险:

bad_ratio or vector(0)

如果 bad ratio 消失是 exporter 故障,这会把“未知”变成“100% 健康”。只有在 零值语义、identity join 与独立 freshness 都被证明时,才能安全补零。

低流量与分母

本章记录规则没有用一个任意常数把分母抬高:

bad_rate / total_rate

低流量时要显式决策:

  • 使用更长窗口;
  • 同时要求 minimum event count;
  • 使用 synthetic probe;
  • 保持 unknown;
  • 用 ticket 而不是 page;
  • 聚合到更稳定的 operation class。

若写:

bad_rate / clamp_min(total_rate, 1)

每秒不到一个请求时,会改变真实 event ratio。clamp_min 可以防数值问题, 不能免费替代低流量政策。

counter reset

对 counter 使用 rate()/increase() 可以处理正常 reset,但仍要观察:

  • reset 是否过于频繁;
  • target identity 是否变化;
  • scrape window 是否跨越长缺口;
  • process restart 是否是事故的一部分;
  • 历史 recording rule 是否有断点。

PostgreSQL 统计也有 reset:

pg_stat_* stats_reset
pg_stat_statements_info.stats_reset
per-row stats_since
minmax_stats_since

数值突然变小,必须先问 reset,而不是直接说“负载下降”。

监控系统也要被监控

至少观察:

  • exporter/scrape freshness;
  • VictoriaMetrics query 与 ingestion;
  • VMAlert group/rule error;
  • missed evaluation;
  • Alertmanager notification failure;
  • 独立 canary 的 receipt;
  • external blackbox。

本章现场快照显示:

VMAlert rule errors                  0
VMAlert missed iterations nonzero   0
Alertmanager notification failures  0
current alerts                      0

这只能证明计数器当前没有记录失败。没有一条最近被 receiver 确认收到的 canary, 不能宣称真实通知链可达。

current lab cardinality snapshot

同一快照中:

VictoriaMetrics total series            44,842
total label-value pairs                 387,724
distinct metric names                   3,078
vmalert recording-rule series           698

这些是容量基线,不是目标上限。应当记录随时间的:

series growth rate
top metric by series
top label by values
unused/high-cost metric
query latency and cache pressure
retention impact

一个新 exporter 可能“工作正常”,但在一周内把 series 增长十倍。

信号引入评审

上线前用下面的表:

问题 必须通过
question 能写成一个可证伪问题
decision 观察结果会改变具体决定
source producer 和采集路径明确
type counter/gauge/histogram/event/log/trace 明确
labels 有界、必要、无 secret/PII
time scrape、window、delay、reset 明确
missing 不会默认为健康
cost series、bytes、query、retention 有预算
access 最小权限与脱敏明确
action owner、首个安全动作、停止线明确
test 正常、异常、缺失、恢复均可重放
retirement rename/deprecation 有迁移方案

本节验收

你应当能够对任意一个候选指标回答:

它服务于哪一个决定?
属于用户、入口、数据库还是主机层?
它是症状、原因、控制还是 metamonitoring?
数值是 current、counter、window、estimate 还是 progress?
哪些 label 有界,哪些绝对不能进入?
缺失、reset、NaN 与低流量分别怎么处理?
采集和保留成本是多少?
谁看到它后可以安全地做什么?
哪个独立事实可以复核?

如果只能回答“Grafana 上有这条线”,信号合同还没有建立。


返回本章目录 · 下一节:PostgreSQL 核心运行信号 · 查看全书目录 · 查看索引中心

25.2 PostgreSQL 核心运行信号

PostgreSQL 自带两类观察接口:

dynamic current state
  当前 backend、锁、复制、进度

cumulative statistics
  自 reset 以来的 transaction、I/O、WAL、maintenance、statement

查询视图很简单,正确解释并不简单。以下事实可以同时成立:

pg_stat_activity 当前没有 lock wait
过去五分钟 lock wait 曾导致用户超时

pg_stat_archiver.failed_count = 21
当前归档已经恢复并持续成功

pg_stat_io.read_time 增加
物理磁盘没有等量读取,因为 OS page cache 参与

replica WAL distance = 0
应用仍可能因为路由、事务快照或缓存读到旧结果

n_dead_tup = 0
表仍可能存在已分配但未归还给操作系统的空间

本节目标不是记住所有列,而是掌握一套读法:

view
  -> source and update path
      -> current/cumulative/estimate/progress
          -> reset and snapshot
              -> independent corroboration
                  -> safe action

完整视图以当前版本官方文档为准: PostgreSQL 18 Monitoring Stats

25.2.1 会话、事务、等待与锁

先确认统计功能是否开启

关键设置:

SELECT name, setting, unit, source
FROM pg_settings
WHERE name IN (
  'track_activities',
  'track_counts',
  'track_functions',
  'track_io_timing',
  'track_wal_io_timing',
  'stats_fetch_consistency'
)
ORDER BY name;

它们不是同一个开关:

设置 作用 关闭后的含义
track_activities 当前命令与开始时间 activity 信息受限
track_counts 数据库/表等累计活动 autovacuum 也依赖它
track_functions 函数调用统计 不代表函数没有运行
track_io_timing 数据文件 I/O timing 时间未测量,不是零成本
track_wal_io_timing WAL I/O timing WAL 时间未测量
stats_fetch_consistency 一个事务内统计读取一致性 影响缓存/快照行为

统计有开销,timing 尤其依赖平台时钟成本;但关闭后必须把“未测量”保留下来。 绝不能把:

track_wal_io_timing=off
wal_write_time=0

解释为 WAL 写入没有花时间。

本章沙箱:

track_activities       on
track_counts           on
track_functions        all
track_io_timing        on
track_wal_io_timing    off
stats_fetch_consistency cache

pg_stat_activity 是当前 backend 视图

先使用不导出 query text 的聚合:

SELECT
  backend_type,
  state,
  wait_event_type,
  count(*) AS sessions,
  max(clock_timestamp() - xact_start)
    FILTER (WHERE xact_start IS NOT NULL) AS max_xact_age,
  max(clock_timestamp() - query_start)
    FILTER (WHERE query_start IS NOT NULL) AS max_query_age
FROM pg_stat_activity
GROUP BY backend_type, state, wait_event_type
ORDER BY backend_type, state NULLS LAST, wait_event_type NULLS LAST;

为什么带 backend_type?PostgreSQL 18 里不只有 client backend:

autovacuum launcher / worker
background writer
checkpointer
walwriter / walsender / walreceiver
io worker
slotsync worker
logical replication worker

如果把后台进程与 client backend 混在一起,某个长期运行的 background worker 可能被误判为“用户 SQL 运行几小时”。

statewait_event 独立

常见错误:

state = active
  therefore CPU is executing

实际:

state=active, wait_event is null
  -> backend 正在运行,或刚好未被采样到等待

state=active, wait_event is not null
  -> SQL 仍是 active,但正在等待

state=idle, wait_event_type=Client
  -> 等客户端发下一条命令

state=idle in transaction
  -> 事务仍开着,可能保留 snapshot/lock/xmin

所以等待查询写成:

SELECT
  pid,
  backend_type,
  state,
  wait_event_type,
  wait_event,
  clock_timestamp() - xact_start AS xact_age,
  clock_timestamp() - query_start AS query_age
FROM pg_stat_activity
WHERE backend_type = 'client backend'
  AND state = 'active'
  AND wait_event IS NOT NULL
ORDER BY query_start;

这条查询没有读取 query 列。需要 SQL 上下文时,应在受限交互会话中按 queryid、application、database 和 owner 缩小范围,避免把全文复制进工单。

wait event 是“正在等什么”,不是“根因”

wait_event_type 先把等待分大类:

Lock
LWLock
IO
Client
IPC
Activity
Timeout
BufferPin
Extension

同一种等待可能有多种机制:

Lock
  application transaction contention
  DDL conflicting with queries
  idle in transaction retaining locks

IO
  cache miss
  sequential scan
  checkpoint-related work
  WAL read/write
  extension access

Client
  server waits for client
  slow consumer
  application not reading results

因此:

wait type
  -> affected backend/queryid
  -> blocker/resource
  -> user path
  -> corroborating counter/host evidence

才形成诊断。

PostgreSQL 18 的异步 I/O 引入 io worker 等 backend type 和相应等待。升级后 不要假设旧版 wait event 列表仍完整;dashboard 和规则要按当前版本校验。

当前统计在一个事务里可能保持不变

累计统计不是每次访问都无条件读取最新值。PostgreSQL 会把统计写入共享内存, 各进程最迟按一定节奏 flush;访问者又可能在当前事务内缓存读取结果。

这段会造成困惑:

BEGIN;
SELECT xact_commit FROM pg_stat_database WHERE datname = current_database();
-- 等待或在其他连接产生工作
SELECT xact_commit FROM pg_stat_database WHERE datname = current_database();
COMMIT;

stats_fetch_consistency=cache 下,同一事务后续读取可能继续看到缓存值。 诊断时优先:

每次采样使用短事务
不要在长事务里刷新 dashboard 数据
必要时调用 pg_stat_clear_snapshot()
记录采样时间和 stats_fetch_consistency

pg_stat_clear_snapshot() 清的是当前 session 的统计 snapshot,不是重置全局 统计;不要与 pg_stat_reset* 混淆。

统计更新也有时间边界

累计统计通常在 transaction 完成后才反映:

active transaction
  current activity can show it
  cumulative table/database changes may not yet be flushed

因此调查进行中的大事务:

  • activity 看当前 transaction age;
  • locks 看当前持有/等待;
  • progress 看支持的维护动作;
  • WAL/IO counter 看累计变化;
  • 不等待累计表统计“先证明它存在”。

crash、恢复与复制会改变统计历史

PostgreSQL 正常关闭会保存累计统计;非正常关闭、从 base backup 恢复或 PITR 可能导致统计 reset。跨 failover 比较时:

same metric name
  does not imply same counter history

必须同时保存:

  • member/timeline;
  • stats reset;
  • postmaster start;
  • role transition;
  • source instance;
  • sampling window。

long query 与 long transaction 不同

query_age = now - query_start
xact_age  = now - xact_start

场景:

状态 query age xact age 风险
active long query 约等于或短于 xact 执行/等待资源
idle in transaction 当前 query 已结束 lock、xmin、vacuum
active in old transaction 当前 query 短 很长 snapshot/业务批次
idle 上条 query 的开始时间不代表在执行 无 transaction 通常只是连接

因此不要用 query_start 对所有 state 排序然后自动 cancel。

idle in transaction 为什么危险

它可能:

  • 保留 row/table lock;
  • 持有旧 snapshot;
  • 阻碍 dead tuple 回收;
  • 拉长 backend_xmin
  • 占用 connection/pool slot;
  • 让后续应用错误更难定位。

诊断字段:

SELECT
  pid,
  datname,
  usename,
  application_name,
  state,
  clock_timestamp() - xact_start AS xact_age,
  wait_event_type,
  wait_event,
  backend_xid,
  backend_xmin
FROM pg_stat_activity
WHERE backend_type = 'client backend'
  AND state = 'idle in transaction'
ORDER BY xact_start;

取消或终止是变更动作,不属于本章 L0 采集。先确认:

  • owner/application;
  • transaction 是否仍有不可重试副作用;
  • pool mode;
  • unknown commit outcome;
  • cancel 与 terminate 的差异;
  • rollback/重连影响;
  • 用户症状是否关联。

pg_locks 是锁申请,不是完整业务解释

安全聚合:

SELECT locktype, mode, granted, count(*) AS locks
FROM pg_locks
GROUP BY locktype, mode, granted
ORDER BY locktype, mode, granted;

找 blocker 可使用 pg_blocking_pids()

SELECT
  a.pid AS waiting_pid,
  a.datname,
  a.usename,
  a.application_name,
  a.wait_event_type,
  a.wait_event,
  clock_timestamp() - a.query_start AS wait_age,
  pg_blocking_pids(a.pid) AS blocking_pids
FROM pg_stat_activity AS a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0
ORDER BY a.query_start;

这个函数给出 blocker PID,但仍要判断:

direct blocker or blocker behind blocker?
transaction or prepared transaction?
DDL, row lock, advisory lock, relation extension?
which user journey?
is blocker making progress?
what is safe to cancel?

锁图而不是最长列表

事故中更有用的是:

waiting backend
  -> direct blocker
      -> root blocker
          -> owner / transaction age / state

并保存:

  • edge 采样时刻;
  • blocker state;
  • backend_xid/xmin
  • queryid,而不是默认 query text;
  • application/release;
  • lock type/mode;
  • user impact。

一条锁边可能瞬间消失。诊断包应保存有界快照,而不是事后只看当前视图。

deadlock 与普通阻塞

普通 lock wait 可以持续;deadlock 是一个等待环,PostgreSQL 会检测并中止其中 一个 transaction。

观察:

pg_stat_database.deadlocks       cumulative
log_lock_waits                   waits beyond deadlock_timeout
deadlock error log               discrete event
application retry/outcome        user semantics

log_lock_waits=on 只在等待超过 deadlock_timeout 后记录,短等待不会出现。 日志“没有 lock wait”不能证明没有短暂锁竞争。

权限边界

普通用户只能看到其他 session 的有限信息。pg_read_all_stats 能读取全库统计和 其他 session 的更多信息,但这仍然是高敏感可观测权限:

  • query text 可能含业务值;
  • application name 可能带身份;
  • client address 暴露拓扑;
  • activity 能推断业务行为。

建议:

exporter role
  stable, narrow, machine-only

interactive diagnostic role
  time-bounded, reviewed, pg_read_all_stats or narrower

evidence export
  aggregate and redact, no query text/client address

不要因为它不是 superuser 就把它当低风险权限。

查询本身也会进入观察结果

读取 pg_stat_activity 时,你自己的查询也是 active;访问许多系统视图也会拿 AccessShareLock。本章正式快照出现的 relation locks 就包括采集查询本身。

因此:

  • 标识 collector application;
  • 从结果中区分自身;
  • 限制 statement timeout;
  • 不在 tight loop 高频轮询;
  • 避免一次展开所有 query text;
  • 将 observer effect 写进证据。

25.2.2 缓冲、I/O、WAL、检查点与复制

数据路径不是“内存或磁盘”二选一

一个 PostgreSQL page 读取可能经过:

PostgreSQL shared buffers
  -> operating-system page cache
      -> filesystem / block layer
          -> physical or virtual storage

所以:

PostgreSQL read()
  may be satisfied by OS cache

shared buffer hit
  does not require an OS read

device read
  may be caused by another process

pg_stat_io 明确不区分物理磁盘和 OS page cache。必须结合 node/block-device 证据。

pg_stat_database 给出数据库级累计轮廓

常用字段:

SELECT
  datname,
  numbackends,
  xact_commit,
  xact_rollback,
  blks_read,
  blks_hit,
  temp_files,
  temp_bytes,
  deadlocks,
  blk_read_time,
  blk_write_time,
  stats_reset
FROM pg_stat_database
WHERE datname IS NOT NULL
ORDER BY datname;

它适合:

  • database-level rate;
  • hit/read 变化;
  • temp spill 趋势;
  • rollback/deadlock 变化;
  • reset-aware baseline。

不适合:

  • 归因到具体 query;
  • 直接推物理 IOPS;
  • blks_hit / (blks_hit + blks_read) 单独判断内存是否足够;
  • 比较不同 reset 区间的裸总数。

cache hit ratio 不是性能分数

$$ \text{hit ratio}

\frac{\Delta hits} {\Delta hits + \Delta reads} $$

即使正确用窗口增量,也受 workload 影响:

  • 大表顺序扫描天然产生 reads;
  • 小表热点容易高命中;
  • OS cache 命中仍记为 PostgreSQL read;
  • 低 hit 可能是合理批处理;
  • 高 hit 不代表 CPU、lock 或 plan 健康。

把它作为 workload 特征,不要设一个跨服务的“低于 99% 就 page”。

pg_stat_io 按谁、什么对象、什么上下文拆分

PostgreSQL 18 可按:

backend_type
object
context

观察:

reads / read_bytes / read_time
writes / write_bytes / write_time
extends / extend_bytes / extend_time
hits / evictions / reuses
fsyncs / fsync_time

先聚合非零工作:

SELECT
  backend_type,
  object,
  context,
  sum(reads) AS reads,
  sum(read_bytes) AS read_bytes,
  sum(read_time) AS read_ms,
  sum(writes) AS writes,
  sum(write_bytes) AS write_bytes,
  sum(write_time) AS write_ms,
  sum(fsyncs) AS fsyncs,
  sum(fsync_time) AS fsync_ms
FROM pg_stat_io
GROUP BY backend_type, object, context
HAVING
  coalesce(sum(reads), 0)
  + coalesce(sum(writes), 0)
  + coalesce(sum(fsyncs), 0) > 0
ORDER BY backend_type, object, context;

窗口诊断需要两次快照求 delta 或 exporter counter rate。裸累计值只说明 reset 以来总量。

timing 要与 byte/count 一起读

read_time rises, read_bytes rises
  -> more work or slower work

read_time per read rises
  -> average operation cost rises, but distribution unknown

read_bytes rises, device reads stable
  -> OS cache may serve more reads

timing is zero and tracking is off
  -> unknown, not free

平均值会掩盖 tail。需要时结合:

  • block-device latency histogram;
  • request latency histogram;
  • trace sampled tail;
  • queryid-level I/O;
  • workload mix。

buffer、backend 与 checkpoint 写入

dirty buffer 可能由 backend、background writer、checkpointer 等路径写出。 pg_stat_io 帮助按 backend type 区分;pg_stat_checkpointer 给 checkpoint 累计结果。

PostgreSQL 18:

SELECT *
FROM pg_stat_checkpointer;

重要字段包括:

num_timed
num_requested
num_done
restartpoints_*
write_time
sync_time
buffers_written
slru_written
stats_reset

解释:

requested checkpoint rate rises
  -> workload/config/administrative events may force checkpoints

write_time dominates
  -> checkpoint spreads writes

sync_time spikes
  -> fsync phase or storage path needs correlation

buffers_written rises
  -> more dirty data, not automatically a problem

不要只看一次 write_time 总值。计算每窗口的 checkpoint count、buffer、write 和 sync delta,再与用户 latency、WAL rate 和 host I/O 对齐。

checkpoint 不是越少越好

过频可能增加写入压力和 full-page image;过稀可能:

  • 增加 crash recovery 时间;
  • 需要更多 WAL;
  • 在 checkpoint 集中更多工作;
  • 改变恢复和容量特征。

任何调参要结合:

checkpoint completion
WAL generation
dirty buffer path
storage latency
recovery objective
memory and workload

本章只观察,不修改 checkpoint_timeoutmax_wal_size 或 completion target。

pg_stat_wal 是 WAL 生成轮廓

SELECT
  wal_records,
  wal_fpi,
  wal_bytes,
  wal_buffers_full,
  stats_reset
FROM pg_stat_wal;

用途:

  • WAL byte rate;
  • full-page image 比例变化;
  • WAL buffer full;
  • 与 write workload、checkpoint、replication、archive 对齐。

不能直接说明:

  • 哪个 query 产生 WAL;
  • WAL 是否已归档;
  • replica 是否已回放;
  • recovery 能否成功。

query 归因需要 pg_stat_statements.wal_bytes 等;归档与恢复需要另外的证据。

full-page image 的上下文

checkpoint 后 page 首次修改可能记录 full-page image,以支持恢复。wal_fpi 变化与:

  • checkpoint 频率;
  • 工作集;
  • full_page_writes
  • page 修改模式;
  • compression;
  • backup/recovery policy

相关。不要看到 FPI 高就关闭安全机制。

pg_stat_archiver 同时有 counter 和最后事件

SELECT
  archived_count,
  last_archived_wal,
  last_archived_time,
  failed_count,
  last_failed_wal,
  last_failed_time,
  stats_reset
FROM pg_stat_archiver;

三种不同问题:

历史是否失败过?
  failed_count since reset

最近是否出现新失败?
  increase(failed_count[window])

当前是否推进?
  last success age + WAL generation + archive queue

沙箱快照:

failed_count           21
last_failed_time       18:57:58Z
last_archived_time     22:27:54Z

最近成功晚于失败,说明历史 counter 非零不能证明当前仍失败。

候选告警:

pg36:archive_failures:increase15m > 0
and on (cls, ins, ip)
(time() - pg_archiver_finish_time) > 900

这仍只是 recovery risk candidate。下一步要复核:

  • 当前 WAL 是否继续生成;
  • archive command/pgBackRest 状态;
  • repository;
  • latest archive;
  • backup/WAL coverage;
  • 最近 restore drill。

“归档恢复”不等于“恢复就绪”。

replication 有位置、时间、状态三套语义

primary:

SELECT
  application_name,
  state,
  sync_state,
  sent_lsn,
  write_lsn,
  flush_lsn,
  replay_lsn,
  pg_wal_lsn_diff(sent_lsn, replay_lsn) AS sent_replay_gap_bytes,
  write_lag,
  flush_lag,
  replay_lag
FROM pg_stat_replication
ORDER BY application_name;

replica:

SELECT
  status,
  sender_host,
  sender_port,
  written_lsn,
  flushed_lsn,
  latest_end_lsn,
  last_msg_send_time,
  last_msg_receipt_time,
  latest_end_time
FROM pg_stat_wal_receiver;

公开/长期证据不一定应保存 sender/client address;可以保留 member identity、 state、sync_state 和位置差。

位置 gap

sent - write    network/receiver write path
write - flush   replica flush path
flush - replay  replay/apply path

位置差按 WAL byte 计,不是秒:

catch-up timeWAL gap bytescurrent generation rate \text{catch-up time} \ne \frac{\text{WAL gap bytes}}{\text{current generation rate}}

因为 replay throughput、workload、conflict、I/O 和 future WAL 都会变化。它可 用于容量与趋势,不应直接承诺 RTO。

时间 lag 可能是 NULL

write_lagflush_lagreplay_lag 是最近同步交互产生的测量,并不保证 持续给出“当前落后秒数”。空闲系统可能为 NULL;这不等于零,也不等于故障。

使用:

  • LSN distance;
  • connection/state;
  • last message;
  • known commit probe;
  • workload/WAL generation

共同解释。

本章正式 SQL 快照中,两条 streaming async replica 的 LSN gap 都为 0,而 lag interval 为 NULL。这正好说明:

NULL time lag
  can coexist with zero position gap in an idle/current sample

sync_state 与 commit durability

sync_state=async 表示它不是当前同步确认的一部分。即使 gap 为零:

  • 后续 commit 仍可能尚未复制;
  • primary 立即丢失会有 write gap 风险;
  • 应用 commit acknowledgment 语义取决于 synchronous_commit 和配置;
  • user freshness 取决于读取路径。

pg_stat_replication 与第 20 章 HA 合同一起读,不要从瞬时 gap 倒推出 durability guarantee。

replication slot 与 WAL retained risk

除了 replica gap,还要观察:

SELECT
  slot_name,
  slot_type,
  active,
  wal_status,
  safe_wal_size,
  pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
    AS retained_bytes
FROM pg_replication_slots
ORDER BY slot_name;

slot identity 可能属于 extension/consumer;不要自动删除 inactive slot。风险:

  • WAL retention 填满磁盘;
  • logical consumer 落后;
  • slot 失效;
  • consumer 被误认作废弃。

容量规则默认 ticket,动作需 owner 和 consumer 证据。

复制冲突

hot standby 查询可能与 replay 冲突。观察:

  • pg_stat_database_conflicts
  • replica activity;
  • query cancellation log;
  • feedback/delay 设置;
  • retained xmin/WAL;
  • user read path。

降低冲突的设置可能增加 bloat 或 freshness lag,不能只优化一张图。

25.2.3 vacuum、冻结、膨胀与对象增长

vacuum 有四个主要目的

常规 vacuum 不是“清空表”,而是:

  1. 回收 dead row version,使空间可在表内复用;
  2. 更新 visibility map,支持 index-only scan 等;
  3. 防止 transaction ID wraparound;
  4. 维护统计/冻结等运行状态。

ANALYZE 更新 planner statistics;它与 vacuum 可以一起运行,但目的不同。

autovacuum 依赖统计

track_counts=on 不只是“多收指标”。autovacuum 使用累计活动决定何时处理表。 关闭它会影响维护机制。

每表触发近似由:

vacuum threshold
  base threshold + scale factor * relation tuples

analyze threshold
  base threshold + scale factor * relation tuples

决定,并受 insert threshold、per-table storage parameter、cost limit、worker 数、 全局设置等影响。

不能只看“autovacuum process 存在”。要看:

  • table change pressure;
  • last vacuum/autovacuum;
  • dead tuples;
  • analyze freshness;
  • blockers;
  • progress;
  • freeze age;
  • runtime/duration。

表级统计是 estimate + counter

SELECT
  schemaname,
  relname,
  n_live_tup,
  n_dead_tup,
  n_mod_since_analyze,
  n_ins_since_vacuum,
  last_vacuum,
  last_autovacuum,
  last_analyze,
  last_autoanalyze,
  vacuum_count,
  autovacuum_count,
  analyze_count,
  autoanalyze_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC NULLS LAST
LIMIT 50;

n_live_tupn_dead_tup 是估算;count 是自 reset 累计。它们适合找候选,不 适合直接计算“精确 bloat 百分比”。

dead tuple 不等于 bloat

dead tuple
  an old row version no longer visible to current snapshots

reusable free space
  vacuum processed space available for future tuples

table bloat
  allocated pages exceed what current data/layout needs

filesystem size
  file blocks currently allocated

vacuum 后 n_dead_tup 可能下降,但文件通常不缩小,因为普通 vacuum 将空间 留给表内复用。需要归还操作系统的操作通常更重,涉及 rewrite/lock/extra disk; 不能因为文件没缩就说 vacuum 失败。

长事务如何阻止回收

MVCC 需要保留旧版本给仍可能看见它的 snapshot。长 transaction、prepared transaction、replication slot/feedback 等可能拉住 xmin:

updates/deletes create dead versions
  -> vacuum evaluates global visibility horizon
      -> old xmin still needs them
          -> cannot remove
              -> dead tuples/object size grow

观察:

SELECT
  pid,
  datname,
  usename,
  application_name,
  state,
  backend_xid,
  backend_xmin,
  clock_timestamp() - xact_start AS xact_age
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

还要查:

  • prepared transactions;
  • replication slots;
  • replica feedback;
  • vacuum progress;
  • table-level age。

“找到最老 PID 就 terminate”不是安全策略。

freeze age 是剩余空间,不是普通 latency

transaction ID 是有限循环空间。旧 tuple 必须被 freeze,避免 wraparound 后 可见性灾难。

数据库级:

SELECT
  datname,
  age(datfrozenxid) AS xid_age,
  mxid_age(datminmxid) AS multixact_age
FROM pg_database
ORDER BY xid_age DESC;

表级:

SELECT
  n.nspname,
  c.relname,
  age(c.relfrozenxid) AS xid_age,
  mxid_age(c.relminmxid) AS multixact_age,
  pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY xid_age DESC
LIMIT 50;

不要把阈值写成与配置无关的魔法数字。应比较:

current age
autovacuum_freeze_max_age
table override
consumption rate
vacuum throughput
blocker
remaining time with uncertainty

本章候选 PG36FreezeAgeHorizon 使用固定数字只是 isolated lab 的测试输入, 明确标为 proposed;生产规则应由当前配置和容量政策生成。

anti-wraparound autovacuum 的特殊性

为防 wraparound 启动的 autovacuum 通常不会像普通 autovacuum 那样轻易被冲突 动作自动打断。不要把它当“可以随时 kill 的后台噪声”。

如果已经进入紧急区:

  • 停止增加风险的长事务;
  • 找出不能推进的表与 blocker;
  • 评估 I/O/空间/锁;
  • 按 runbook 控制维护;
  • 不并行执行未经评估的 rewrite;
  • 保留 evidence。

最优策略是在容量 horizon 阶段用 ticket 解决,而不是等 emergency page。

progress view 是当前进度,不是历史

vacuum:

SELECT
  pid,
  datid,
  relid,
  phase,
  heap_blks_total,
  heap_blks_scanned,
  heap_blks_vacuumed,
  index_vacuum_count,
  num_dead_item_ids,
  max_dead_item_ids
FROM pg_stat_progress_vacuum;

PostgreSQL 还为:

  • ANALYZE
  • CREATE INDEX / REINDEX
  • CLUSTER / VACUUM FULL
  • COPY
  • base backup

提供相应 progress view,具体列按版本文档。

没有行只表示当前没有该动作被报告,不表示:

  • 从未运行;
  • 上次成功;
  • 下一次会成功;
  • 没有被瞬间启动后失败。

历史需要日志、事件和累计 count。

progress 百分比可能不单调

不同 phase 使用不同总量;并行、索引清理和 dead item cycle 也会改变解释。 不要把:

heap_blks_scanned / heap_blks_total

当成整个 vacuum 的精确完成百分比。应同时显示 phase 和相关量。

analyze freshness

planner statistics 变旧会导致估算偏差。候选信号:

n_mod_since_analyze
last_analyze / last_autoanalyze
table size
query plan estimate vs actual
statistics target
column distribution

n_mod_since_analyze 是估算/累计线索,不是每行精确 change counter。对热点、 分区和高度偏斜列,需要 workload-aware 策略。

对象增长要拆分

SELECT
  n.nspname,
  c.relname,
  pg_relation_size(c.oid) AS heap_bytes,
  pg_indexes_size(c.oid) AS index_bytes,
  pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY total_bytes DESC
LIMIT 50;

增长可能来自:

  • 正常业务数据;
  • index 数量;
  • TOAST;
  • dead/reusable space;
  • fillfactor;
  • 分区保留;
  • 临时/中间对象;
  • rewrite;
  • 失控 batch。

不要只按总大小 page。需要:

growth rate
retention expectation
free-space horizon
maintenance/rewrite headroom
business volume
owner plan

容量是预测和计划问题,默认 ticket。

partition 会改变聚合方式

父表和各分区的:

  • size;
  • table stats;
  • autovacuum;
  • analyze;
  • index;
  • freeze age

需要分别观察,再按业务分区策略聚合。只看父表可能近乎空;只列每个分区又会 产生巨大 cardinality。

推荐:

metric
  aggregate by parent / age band / size band

dashboard
  top-N and drill-down

SQL
  on-demand exact partition inventory

不要把每个临时分区名永久做成高基数 alert label。

bloat estimate 的边界

extension 或 SQL 估算 bloat 常依赖:

  • row width;
  • null bitmap;
  • alignment;
  • fillfactor;
  • statistics;
  • page sample;
  • index type。

它是排序候选,不是字节级财务账。要做重操作前:

  1. 复核对象大小与增长趋势;
  2. 判断空间能否复用;
  3. 找出生成机制和 blocker;
  4. 评估 rewrite/lock/replication/WAL/backup 影响;
  5. 准备额外磁盘和回退/前滚;
  6. 在维护窗口验证。

本章不会自动 VACUUM FULLREINDEX

把三类维护信号分开

类别 问题 典型动作
运行正确性 wraparound 是否接近 高优先级维护/停止风险来源
性能卫生 dead tuple、stats 是否影响 workload vacuum/analyze 调整与 blocker 修复
容量 对象/索引/WAL 是否耗尽空间 retention、扩容、结构优化、rewrite 计划

它们的 severity、owner 和时间尺度不同。一个“表大”告警不能同时代表三者。

沙箱快照如何读

正式采集在 pg-test-1 观察到:

user tables             1
estimated dead tuples   0
max table freeze age    1,050

这不是“vacuum 永远健康”的证明:

  • synthetic workload 很小;
  • 只有一次瞬时快照;
  • 没有长期增长率;
  • 没有生产 transaction rate;
  • 没有生产配置与 margin;
  • estimate 可能变化。

可以得出的结论只有:

at captured_at,
the declared sandbox target reported this small current baseline.

PostgreSQL 信号最小关联表

用户症状 PG 入口 原生证据 外部复核
latency active/wait activity、locks、queryid pool、host、trace
errors rollback/deadlock database counter、logs app outcome
stale read replica path replication position/state commit token probe
write latency WAL/checkpoint wal、checkpointer、I/O device、app histogram
query spill temp bytes database、statement、logs plan/work_mem policy
vacuum delay old xmin activity、table stats、progress workload/change event
freeze risk XID age database/class age rate/horizon/capacity
recovery risk archive archiver + WAL pgBackRest + restore drill

本节验收

你应当能解释:

  1. active 为什么仍可能在等待;
  2. 同一 transaction 为什么可能读到缓存的累计统计;
  3. track_wal_io_timing=off 为什么不能把时间解释为零;
  4. pg_stat_io 为什么不等于物理磁盘 I/O;
  5. failed_count>0 为什么不等于当前归档失败;
  6. time lag NULL 与 WAL gap 0 为什么可以同时出现;
  7. WAL gap 0 为什么不能证明用户新鲜度;
  8. n_dead_tup 为什么不等于精确 bloat;
  9. 普通 vacuum 为什么通常不缩小文件;
  10. progress view 没有行为什么不证明历史成功;
  11. query、lock、vacuum 和 freeze 信号分别对应哪种动作;
  12. 任何取消、终止、reset、vacuum 或配置变更为什么不属于 L0 观察。

上一节:从问题选择可观测信号 · 返回本章目录 · 下一节:SQL 可观测基线 · 查看全书目录 · 查看索引中心

25.3 SQL 可观测基线

数据库“忙”只是结果,SQL workload 才是来源之一。

要回答:

哪类语句消耗了时间?
调用量还是单次成本改变?
时间花在执行、计划、I/O、JIT、WAL 还是临时块?
慢是一直慢,还是 tail 中少量异常?
统计从什么时候开始?
query identity 是否稳定?
日志和计划采集付出了多少额外成本?
证据是否泄露业务值?

需要组合三个接口:

pg_stat_statements
  聚合 workload population

bounded logging
  离散慢语句、错误、锁等待、临时文件

auto_explain
  对满足政策的一部分执行自动记录计划

三者都有盲区,也都有成本。最危险的做法是为了“可观测”无界记录所有 SQL 和 参数,结果既拖慢系统,又制造一份高密度敏感数据库。

25.3.1 pg_stat_statements 的统计口径与重置

它不是默认自动完整可用

pg_stat_statements 需要:

  1. 出现在 shared_preload_libraries
  2. PostgreSQL 重启后模块被加载;
  3. 每个需要查询视图的 database 安装 extension;
  4. query identifier 可用;
  5. 查询角色有足够权限;
  6. 容量、track policy 和 reset 被声明。

检查:

SELECT name, setting, source
FROM pg_settings
WHERE name IN (
  'shared_preload_libraries',
  'compute_query_id',
  'pg_stat_statements.max',
  'pg_stat_statements.track',
  'pg_stat_statements.track_utility',
  'pg_stat_statements.track_planning',
  'pg_stat_statements.save'
)
ORDER BY name;

extension 可能不在 public

SELECT
  e.extname,
  e.extversion,
  n.nspname AS extension_schema
FROM pg_extension AS e
JOIN pg_namespace AS n ON n.oid = e.extnamespace
WHERE e.extname = 'pg_stat_statements';

Pigsty 沙箱中的结果:

shared_preload_libraries        pg_stat_statements, auto_explain
compute_query_id                auto
pg_stat_statements.max          10000
pg_stat_statements.track        all
pg_stat_statements.track_utility off
pg_stat_statements.track_planning off
pg_stat_statements.save         on
extension version              1.12
extension schema               monitor

所以查询使用:

monitor.pg_stat_statements
monitor.pg_stat_statements_info

不要硬编码成 public.pg_stat_statements

聚合键不是 query text

一行主要按以下身份聚合:

dbid
userid
queryid
toplevel

这意味着:

  • 同一 queryid 在不同 database 分开;
  • 不同执行角色分开;
  • top-level 与 nested statement 分开;
  • query text 是一个代表性 normalized text,不是主键;
  • 同一可见文本仍可能因语义环境不同获得不同 queryid;
  • hash collision 在理论和实践上都可能发生。

因此关联时保留:

cluster / instance
dbid
userid or role class
queryid
toplevel
stats_since

只保存 queryid 而丢掉 database/user/toplevel,会把不同 workload 合并。

normalization 不是脱敏承诺

常量通常会被替换:

SELECT * FROM orders WHERE order_id = 123;

-- representative normalized form
SELECT * FROM orders WHERE order_id = $1;

但不能据此认为 query 列可以公开:

  • object 名可能包含客户或项目身份;
  • comment 可能含敏感上下文;
  • dynamic SQL 结构可能暴露值;
  • utility statement 的行为不同;
  • query text 与日志/错误组合可能重新识别业务;
  • 权限边界本身说明它被视为敏感。

本章 evidence 明确:

query text exported   false
bind values exported  false
client address        false

queryid 是关联键,不是密码学身份

queryid 是内部 hash:

  • 算法可能随 major version 改变;
  • collision 可能发生;
  • object identity 与 search_path 会影响语义;
  • 相同显示文本可能代表不同解析对象;
  • failover/upgrade 前后要重新建立 baseline。

不要用 queryid:

  • 做访问控制;
  • 证明 SQL 完全相同;
  • 做永久跨版本业务 ID;
  • 取代 plan fingerprint;
  • 取代 application operation class。

它适合:

  • workload 聚合;
  • before/after 比较;
  • dashboard 下钻;
  • 与受限日志/plan 关联;
  • top-N 诊断。

从总时间拆成频率与单次成本

$$ \text{total execution time}

\text{calls} \times \text{mean execution time} $$

top total time 可能来自:

very frequent cheap query
rare extremely slow query
both

基本查询不要一开始读 text:

SELECT
  dbid,
  userid,
  queryid,
  toplevel,
  calls,
  round(total_exec_time::numeric, 2) AS total_exec_ms,
  round(mean_exec_time::numeric, 3) AS mean_exec_ms,
  round(min_exec_time::numeric, 3) AS min_exec_ms,
  round(max_exec_time::numeric, 3) AS max_exec_ms,
  rows,
  shared_blks_hit,
  shared_blks_read,
  temp_blks_written,
  wal_bytes,
  stats_since,
  minmax_stats_since
FROM monitor.pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 50;

注意:

  • min/max 易受单次异常影响;
  • mean 掩盖分布;
  • row count 的语义随 statement 类型变化;
  • block 是 PostgreSQL block,不是任意存储 byte;
  • I/O timing 依赖开关;
  • WAL byte 不等于 commit durability;
  • top-N 会漏掉排名外 workload。

用 delta,而不是跨 reset 比裸值

两个采样点:

t0: calls_0, total_exec_0, stats_since_0
t1: calls_1, total_exec_1, stats_since_1

只有 identity 与统计窗口连续时才计算:

Δcalls=calls1calls0 \Delta calls = calls_1 - calls_0

$$ \text{window mean}

\frac{\Delta total_exec_time} {\Delta calls} $$

如果:

  • row 消失;
  • stats_since 改变;
  • postmaster restart;
  • extension reset;
  • entry deallocated 后重新创建;
  • failover 到另一成员;

就不能直接减。

pg_stat_statements_info 是解释入口

SELECT *
FROM monitor.pg_stat_statements_info;

核心:

dealloc
stats_reset

dealloc 增长表示 statement entry 因容量压力被丢弃。此时 top workload 可能 被 churn 影响;应评估:

  • pg_stat_statements.max
  • query shape 数量;
  • dynamic SQL;
  • reset;
  • 内存成本;
  • 是否需要更稳定的 application query pattern。

沙箱正式快照:

dealloc              0
stats_reset          2026-07-29T18:57:02Z
statement rows       194
calls                超过 100,000

这是一次窗口基线,不是永久容量结论。

每行还有自己的时间边界

PostgreSQL 18 的 statement 行包含:

stats_since
minmax_stats_since

如果只 reset 某条或只 reset min/max,不同 entry 的窗口可能不同。一个 dashboard 不能只在标题写全局“过去 24 小时”,却忽略每行实际开始时间。

reset 是破坏性观察动作

函数可以全局或选择性 reset;当前版本还支持只 reset min/max。它很有用,但 会销毁比较基线。

原则:

incident capture
  never reset to make the graph easier

planned benchmark
  may reset in an isolated target with explicit evidence

production baseline
  prefer snapshots/deltas and recording rules

本章 L0 采集明确禁止:

pg_stat_statements_reset
pg_stat_reset*

如果必须 reset:

  • 记录 request/approval;
  • exact target;
  • old reset time;
  • snapshot/hash;
  • reason;
  • expected observation gap;
  • downstream dashboard impact;
  • new baseline start。

planning 与 execution 并非一一对应

pg_stat_statements.track_planning=on 可以收集 plan count/time,但:

  • 默认通常为 off;
  • 对高并发相同 query,plan 统计更新会有明显开销;
  • prepared/cached plan 改变 plan/execute 次数关系;
  • 执行失败与计划失败的记录条件不同;
  • utility 和 nested track policy 影响 population。

沙箱 track_planning=off,所以:

total_plan_time = 0

表示未收集,而不是规划不耗时。不要为了填满 dashboard 直接在生产开启;先做 负载测试和决策评审。

track=all 的语义

top 只跟踪 top-level;all 还跟踪 nested statement。沙箱设置 all,再用 toplevel 区分。否则 stored procedure 或 function 内 SQL 可能看不见,或被 误与 top-level 混合。

top-N 查询要保护系统

观察查询也消耗:

  • shared memory lock;
  • sort;
  • format/round;
  • dashboard 并发;
  • network;
  • 结果存储。

生产查询建议:

SET LOCAL statement_timeout = '5s';
SET LOCAL lock_timeout = '500ms';

-- 只读、限制列、限制行、先 aggregate identity

不要每 5 秒在每个 database:

SELECT * FROM pg_stat_statements ORDER BY total_exec_time DESC;

尤其不要自动导出完整 query text。

建立 workload baseline

基线至少分:

calls rate
total/mean/max execution
rows per call
shared hit/read/write
temp blocks
WAL bytes
JIT time/count
planning if explicitly enabled
stats reset / entry age

按:

service operation
database
role class
queryid
release/change window

关联。不要仅按 instance;failover 后 workload 会移动。

25.3.2 慢语句、锁等待、临时文件与错误日志

慢日志是离散样本

核心设置:

SELECT name, setting, unit, source
FROM pg_settings
WHERE name IN (
  'log_min_duration_statement',
  'log_min_duration_sample',
  'log_statement_sample_rate',
  'log_duration',
  'log_statement',
  'log_lock_waits',
  'deadlock_timeout',
  'log_temp_files',
  'log_min_error_statement',
  'log_parameter_max_length',
  'log_parameter_max_length_on_error'
)
ORDER BY name;

log_min_duration_statement

记录执行时间达到阈值的语句;0 表示记录全部,-1 关闭。

优点:

  • 对超过阈值的 population 较完整;
  • 可以看到离散 tail;
  • 能与 queryid、错误和时间线关联。

代价:

  • I/O 与格式化;
  • log volume;
  • SQL/参数泄露;
  • 高频略慢语句形成洪水;
  • 日志系统成为新瓶颈。

log_min_duration_sample

达到 sample threshold 后,再按 log_statement_sample_rate 采样。它适合控制 高流量环境的日志量,但“未出现”不能解释为“未发生”。

如果:

log_min_duration_sample = -1
log_statement_sample_rate = 1

采样路径仍是关闭的。不要只看 rate。

duration 的边界

语句 duration 与用户 end-to-end latency 不同。用户时间可能包括:

network
application queue
pool wait
server execution
result transfer
application processing
retry
commit reconciliation

PostgreSQL 慢日志只覆盖 server 侧语句范围。

lock wait 日志

log_lock_waits=on 在等待超过 deadlock_timeout 时记录。沙箱:

log_lock_waits   on
deadlock_timeout 50ms

这意味着:

  • 超过约 50 ms 的 lock wait 有机会被记录;
  • 更短等待可能很多但没有日志;
  • deadlock detection cadence 与日志量相关;
  • 不能为了更多日志随意降低 timeout;
  • 日志是离散事件,当前 lock view 是即时状态。

调查组合:

user latency window
pg_stat_activity current wait
pg_locks / pg_blocking_pids
lock wait logs
deadlock counter
application transaction boundary

temporary file

log_temp_files 在临时文件删除时记录超过阈值的文件。沙箱为:

1024 kB

这类日志说明:

  • 某操作产生了 temp file;
  • 大小达到记录政策;
  • 日志时刻可能是删除时刻,不是创建/峰值时刻。

关联:

  • pg_stat_database.temp_files/temp_bytes
  • pg_stat_statements.temp_blks_read/written
  • queryid;
  • plan;
  • sort/hash/window;
  • workload concurrency;
  • work_mem policy。

不要看到 temp 就全局提高 work_mem。它按 operation/node/worker 使用,高并发 会把一个小改动放大成内存风险。

error log 与用户 outcome

PostgreSQL error 包含 SQLSTATE、severity、detail、context 等;应用可能:

  • retry 后成功;
  • 返回失败;
  • 超时但 commit;
  • 屏蔽错误;
  • 将一个 DB error 映射成不同业务 outcome。

所以错误日志要与应用 outcome 关联,而不是用 log line 数直接做 availability 分子。

稳定聚合维度:

cluster / instance / database
severity / SQLSTATE class
application / operation class
release

不适合作 label:

full message
detail
statement
parameter
customer/order/tenant

structured CSV/JSON 不会自动安全

沙箱使用:

logging_collector on
log_destination csvlog
log_directory /pg/log/postgres
log_filename postgresql-%a.log

CSV 方便 Vector 解析,但:

  • statement 字段仍可能敏感;
  • detail/context 可能含值;
  • file permission/collector group 需要评审;
  • 集中存储扩大读取面;
  • retention 必须独立设置。

从 metric 到 log,而不是无界 log query

正确流程:

metric identifies:
  service, environment, operation, time window

event identifies:
  release/change boundary

log query:
  bounded window + stable fields + maximum rows

result:
  aggregate error classes, do not export bodies

本章诊断包规定:

log query limit  1000
body export      forbidden

需要正文时,应在受限界面临时查看并按事件政策处理,而不是复制进公共工单。

日志缺失也有语义

没有日志可能是:

  • 事件未发生;
  • threshold 未达到;
  • sampling 丢失;
  • collector/Vector 失败;
  • rotation/retention;
  • parse schema drift;
  • 权限或查询错误;
  • 日志写入阻塞;
  • service 在另一 instance。

必须同时观察 log pipeline。

25.3.3 auto_explain 的采样、嵌套语句与开销

auto_explain 解决什么

pg_stat_statements 告诉你:

which queryid is costly

EXPLAIN/EXPLAIN ANALYZE 告诉你:

how one plan is structured and executed

auto_explain 在满足政策时自动把 plan 写入日志,适合捕捉难以手工复现的慢 执行。

它不是零成本“打开即可”。

最重要的开关

SELECT name, setting, unit, source
FROM pg_settings
WHERE name LIKE 'auto_explain.%'
ORDER BY name;
设置 问题
log_min_duration 多慢才记录;默认 -1 不启用
sample_rate 满足阈值的 statement 采样多少
log_analyze 是否实际执行统计进入 plan
log_timing ANALYZE 时是否逐节点计时
log_buffers 是否记录 buffer 使用
log_wal 是否记录 WAL 使用
log_nested_statements function 内语句是否记录
log_parameter_max_length 参数记录长度
log_format text/xml/json/yaml
log_verbose 是否输出额外细节
log_settings 是否记录影响 planning 的设置
log_triggers trigger 统计

log_analyze 的隐藏成本

官方文档特别警告:启用 log_analyze 后,即使最终语句没有达到 log_min_duration 而不写日志,也可能需要为所有语句做 per-plan-node timing。

如果同时:

log_analyze=on
log_timing=on
sample_rate=1

开销可能非常高,尤其是大量短 node 的 workload。

log_timing=off 可以保留 rows 等实际统计并减少逐节点时钟调用,但仍需实测。

沙箱的真实设置

本章只读检查发现:

auto_explain.log_min_duration       1000 ms
auto_explain.log_analyze            on
auto_explain.log_timing             on
auto_explain.log_nested_statements  on
auto_explain.sample_rate            1
auto_explain.log_parameter_max_length -1

这不是推荐模板。它是一个需要单独 overhead 与泄露评审的真实基线:

  • sample_rate=1 没有限制 eligible statement;
  • log_analyze + log_timing 有全局计时成本;
  • nested 可能显著放大日志量;
  • parameter length -1 允许完整参数;
  • 阈值 1 秒只限制最终写 plan,不一定消除 instrumentation cost。

本章不 reload、不改参数,只把风险记录下来。

BUFFERSWAL 依赖 ANALYZE

自动计划中的实际 buffer/WAL 信息要求 analyze path。不能关闭 analyze 后仍假装 获得运行时资源。

设计取舍:

plan shape only
  lower runtime detail, lower cost

analyze without timing
  actual rows/buffer possibility, less per-node clock cost

analyze with timing
  richer node time, potentially high overhead

必须在代表性 workload 上量化。

nested statement

function、trigger、procedure 内可能包含真正慢的 SQL:

top-level CALL
  cheap wrapper
  nested SQL expensive

log_nested_statements=on 能看见,但会:

  • 增加 volume;
  • 重复上下文;
  • 暴露更多 query/parameter;
  • 让一个 top-level 请求产生多份 plan。

同时使用 pg_stat_statements.track=alltoplevel 关联。

采样测试应覆盖 tail

开启前在隔离 workload 回答:

baseline TPS / p50 / p99
CPU and system time
log bytes per second
collector lag
plan count
short statement overhead
long statement capture probability
nested amplification
parameter redaction
rollback path

不要只跑一条慢查询说“开销不大”。

不要在事故中临时全开

事故中:

log_min_duration=0
log_analyze=on
log_timing=on
sample_rate=1

可能:

  • 进一步降低吞吐;
  • 加剧 disk/log pipeline;
  • 泄露参数;
  • 改变被观察 workload;
  • 制造新的故障。

更安全顺序:

  1. 用现有 SLI、activity、wait、queryid 缩小范围;
  2. 使用已有日志与 plan;
  3. 评估只对 session/role/database 的受控方法;
  4. 明确 timeout、采样、持续时间和 rollback;
  5. 由变更政策批准;
  6. 观察 overhead;
  7. 到期自动撤销并验证。

auto_explain 不是 plan history 系统

计划只在满足政策时进入日志;sampling、rotation、retention、parse 都会造成 缺口。若要做 plan regression:

  • 在测试/发布流程保存 EXPLAIN (FORMAT JSON)
  • 记录 schema/statistics/version/settings;
  • 与 queryid/plan fingerprint 关联;
  • 不要把生产日志当完整 plan catalog;
  • 不要在没有参数与数据分布语义时做机械 diff。

25.3.4 日志不得泄漏密码、令牌和敏感参数

SQL observability 是高敏感数据面

可能出现:

password in connection/DDL statement
API token in INSERT/UPDATE
tenant/customer/order identity
email/phone/address
medical/payment attributes
session variable
RLS predicate context
error detail containing row values
dynamic SQL comment
connection URI

PostgreSQL 文档明确警告,statement logging 可能暴露敏感数据,甚至明文密码。

参数记录的两套限制

log_parameter_max_length
  非错误 statement 的参数记录限制

log_parameter_max_length_on_error
  错误发生时的参数记录限制

一般语义:

0    disable
-1   no length limit
N    truncate to N bytes

auto_explain.log_parameter_max_length 又是独立设置。

沙箱:

log_parameter_max_length           -1
log_parameter_max_length_on_error   0
auto_explain.log_parameter_max_length -1

这表示错误路径参数被禁用,但普通慢 statement/auto_explain 仍可能完整记录参数。 是否安全取决于 protocol、query 和应用,不能因为“一项为 0”宣布无泄露。

log_statement 的风险

log_statement=all 会记录每条 statement;DDL 中尤其可能出现:

CREATE ROLE app LOGIN PASSWORD '...';
ALTER ROLE app PASSWORD '...';

即使参数化 DML 避免 literal,DDL、utility、comment 和 dynamic SQL 仍可能带 秘密。生产不得把“排障方便”当默认充分理由。

文件模式:0600 不是唯一安全答案

PostgreSQL log_file_mode 控制 collector 创建文件的 mode。

0600
  PostgreSQL OS owner only

0640
  owner read/write + restricted collector group read

Pigsty 使用 Vector 收集日志时,受控 group read 可能是合理实现。安全要求是:

not world-readable
collector group explicitly declared
membership minimal
no interactive user by default
directory traversal restricted
rotation preserves mode
central storage ACL reviewed

沙箱为 0640。本章没有证明 collector group membership 已完成生产审批,因此 将其作为待评审事实,而不是自动判定安全或不安全。

传输到 VictoriaLogs 扩大了边界

原本只有 database host 上的日志,集中后可能被:

  • Grafana data source;
  • log query API;
  • incident automation;
  • backup/export;
  • 多个 operator

访问。

必须重新定义:

ingestion TLS/auth
tenant isolation
query role
retention
deletion
export
redaction
audit
backup

“源文件权限正确”不能证明集中日志安全。

metric label、alert annotation、ticket 是二次泄露面

最常见事故不是直接开放 log file,而是自动化复制:

log body
  -> alert annotation
      -> chat webhook
          -> ticket
              -> email
                  -> postmortem

本章诊断包只导出:

  • error class/count;
  • queryid;
  • database/role class;
  • time window;
  • release/change id;
  • source link without credentials。

不导出:

  • statement;
  • bind value;
  • log body;
  • client address;
  • tenant/order/customer。

redaction 要在尽量靠近 source 的位置

优先级:

  1. 应用不把 secret 放进 SQL/comment;
  2. 参数化查询;
  3. PostgreSQL logging policy 限制;
  4. collector parse/redact;
  5. storage ACL/retention;
  6. alert/evidence allowlist。

最后一步 regex 不是万能防线:

  • 数据格式会变化;
  • 编码/截断会绕过;
  • 新字段未被匹配;
  • secret 可能已进入上游缓存/备份。

query text 的受控交互查看

真正排障有时需要 SQL text。正确做法不是绝对禁止人看,而是区分:

machine evidence
  no query text

interactive diagnosis
  time-bounded pg_read_all_stats or narrower role
  approved operator
  restricted UI/session
  no casual copy
  audit and expiry

public/reference summary
  queryid and aggregates only

这既保留诊断能力,也控制扩散。

prepared statement 不自动消除泄露

参数化可以让 statement text 不含 literal,但:

  • bind parameter logging 仍可能记录值;
  • 错误 detail/context 可能包含;
  • 应用 comment/baggage 可能包含;
  • object/table/schema 名仍可能敏感;
  • auto_explain parameter logging 是独立开关。

所以要检查完整链路,不只看应用 ORM。

慢日志与审计日志不是一回事

慢日志回答 performance sample;审计回答谁在何时对什么对象做了什么。两者:

  • 目的不同;
  • 保留不同;
  • 访问不同;
  • 完整性要求不同;
  • 敏感性不同。

不要让 log_statement=all 同时冒充性能、审计、合规和安全检测。需要审计时, 使用明确的审计政策、对象范围和审阅流程。

安全基线查询

SELECT name, setting, source
FROM pg_settings
WHERE name IN (
  'logging_collector',
  'log_destination',
  'log_directory',
  'log_filename',
  'log_file_mode',
  'log_statement',
  'log_min_duration_statement',
  'log_min_duration_sample',
  'log_statement_sample_rate',
  'log_parameter_max_length',
  'log_parameter_max_length_on_error'
)
OR name LIKE 'auto_explain.%'
ORDER BY name;

这只是配置事实,还要复核:

  • actual file mode/owner/group;
  • directory mode;
  • Vector identity/config;
  • VictoriaLogs access/retention;
  • sample log 是否被正确 parse/redact;
  • alert/template 是否复制 body;
  • backup 是否包含日志。

本章 L0 只记录配置,不读取日志正文。

SQL 可观测基线清单

extension
  preload / version / schema / query id

population
  track / utility / nested / max

time
  global reset / per-row stats_since / minmax_stats_since

cost
  planning / timing / auto_explain / logging volume

security
  query visibility / parameters / file group / central log ACL

diagnosis
  calls / exec / rows / blocks / temp / WAL

correlation
  service / operation / release / database / role / queryid

boundary
  no query text in automatic evidence

本节验收

你应当能够解释:

  1. 为什么 extension schema 不能假设是 public
  2. 一行 pg_stat_statements 的四个核心聚合维度;
  3. normalization 为什么不是脱敏保证;
  4. queryid 为什么不能做永久或安全身份;
  5. calls、total、mean、max 各说明什么;
  6. dealloc、global reset、stats_sinceminmax_stats_since 的差异;
  7. track_planning=off 时 plan time 为零意味着什么;
  8. 慢语句、采样慢语句、lock wait 和 temp file 日志各何时产生;
  9. auto_explain.log_analyze + log_timing 为什么可能给所有语句带来成本;
  10. nested statement 为什么同时增加诊断价值与泄露/volume;
  11. 0640 如何在受控 collector group 下成立;
  12. 为什么自动 evidence 只保留 queryid 与聚合,而交互查看另行授权。

上一节:PostgreSQL 核心运行信号 · 返回本章目录 · 下一节:把观察契约变成告警 · 查看全书目录 · 查看索引中心

25.4 把观察契约变成告警

规则语法正确,只证明 parser 接受了它。

一条生产告警还必须同时通过:

semantic
  expression 真正测量合同定义的事件吗?

temporal
  window / scrape / eval delay / for / recovery 正确吗?

governance
  page、ticket 还是 diagnostic?谁接受了这个政策?

actionability
  owner 能执行首个安全动作吗?

routing
  grouping / inhibition / receiver 会制造风暴或静默吗?

exercise
  正常、异常、缺失、错误和恢复都被测试了吗?

本章生成的文件:

它们是教学候选,不是在线配置。

25.4.1 SLO 燃烧率与用户影响

从 error ratio 到 burn rate

若 SLO target 为:

S=99.9%=0.999 S = 99.9\% = 0.999

允许的 bad ratio:

B=1S=0.001 B = 1 - S = 0.001

某窗口实际 bad ratio 为 $E$,燃烧率:

burn rate=EB \text{burn rate} = \frac{E}{B}

例如:

actual bad ratio  1.44%
budget ratio      0.1%
burn rate         14.4x

若持续 28 天,会花掉 14.4 倍预算;若只持续 1 小时,约花掉:

14.4×1h28d2.14% \frac{14.4 \times 1h}{28d} \approx 2.14\%

这解释了为什么 14.4x/1h 常被用作“快速消耗约 2% 月度预算”的起点。它是 政策起点,不是自然常数。

为什么使用两个窗口

只用长窗口:

stable
but slow to detect a sudden severe outage

只用短窗口:

fast
but noisy and easy to flap

组合:

bad_ratio_long > threshold
and on (service, operation_class, environment)
bad_ratio_short > threshold

长窗确认预算风险,短窗确认问题仍在发生。

本章可用性 fast burn

记录规则预先计算五个窗口:

5m / 30m / 1h / 6h / 3d

fast:

- alert: PG36ShopAvailabilityFastBurn
  expr: |
    pg36_shop:sli_availability_bad_ratio:rate1h > (14.4 * 0.001)
    and on (service, operation_class, environment)
    pg36_shop:sli_availability_bad_ratio:rate5m > (14.4 * 0.001)
  for: 2m

标签固定:

class: symptom
route: page
severity: SEV-1
objective_id: SLO-AVAILABILITY
owner_function: service
runbook_id: RB-USER-SYMPTOM
governance_status: accepted-ch24

fast、slow 与 ticket 是一套政策

规则 长/短窗 burn for 路由
fast 1h / 5m 14.4x 2m SEV-1 page
slow 6h / 30m 6x 5m SEV-2 page
budget 3d / 6h 1x 15m ticket

它们不是三个独立“数据库报警”:

  • fast 处理快速用户损害;
  • slow 处理持续预算风险;
  • ticket 处理应进入 backlog 的可靠性债务。

当 fast firing 时,同一 service/environment/objective 的 slow 和 ticket 可以 被抑制,避免一个事件三次通知;原始 alert state 和证据仍保留。

burn threshold 必须跟 target 绑定

延迟目标是 99%:

budget ratio 0.01
14.4x threshold 0.144

可用性目标是 99.9%:

budget ratio 0.001
14.4x threshold 0.0144

把同一个绝对 error ratio 复制过去会错一个数量级。

规则生成器若使用 policy object,应从:

objective target
window
burn policy

计算,而不是在 YAML 中散落魔法数。

event ratio,不是 instance ratio

错误写法:

count(pg_up == 0) / count(pg_up)

它算的是实例比例,不是用户 event ratio。三节点中一个 down:

  • 服务可能完全正常;
  • 某些 read path 可能降级;
  • failover 可能正在进行;
  • 用户失败比例不一定是 1/3。

正确 availability SLI 来自 application outcome:

sum by (service, operation_class, environment) (
  rate(pg36_shop_request_outcomes_total{
    eligible="true",
    outcome!="good"
  }[5m])
)
/
sum by (service, operation_class, environment) (
  rate(pg36_shop_request_outcomes_total{
    eligible="true"
  }[5m])
)

instrumentation 要预初始化 bounded outcome series,否则“没有 bad outcome series”与“零 bad events”会混淆。

latency 规则暴露了治理缺口

第 24 章定义 SLO-LATENCY,但 accepted alert list 没有 latency candidate。 本章没有悄悄将它变成生产 page,而是:

alert: PG36ShopLatencyFastBurn
route: test
severity: candidate
governance_status: proposed-not-accepted

规则和测试都存在,生产门禁仍拒绝。这体现:

technical readiness
  != policy acceptance

owner 还要决定:

  • latency threshold 是否 250 ms;
  • eligible event 与 availability 是否相同;
  • success 但慢、failure 且慢如何计数;
  • 低流量如何处理;
  • page severity;
  • 首个安全动作;
  • 与 availability page 如何去重。

freshness 必须使用 commit-correlated event

规则:

pg36_shop:sli_freshness_bad_ratio:rate1h > (14.4 * 0.01)
and on (service, read_path, environment)
pg36_shop:sli_freshness_bad_ratio:rate5m > (14.4 * 0.01)

分母是携带已知 commit token 的 probe,bad 是五秒内不可见。

不要替换成:

pg_lag > 5

因为 pg_lag 的语义、空闲行为和用户 path 都不同。

正确性不燃烧

- alert: PG36ShopCorrectnessMismatch
  expr: pg36_shop_reconciliation_mismatches > 0
  for: 0m

它没有:

burn rate
error budget
planned exclusion
slow ticket before page

一个未解释 mismatch 就进入完整性响应。缺失或 stale reconciliation 是另一个 control failure,不是零 mismatch。

恢复就绪也是 control

候选:

pg36_shop_restore_evidence_age_seconds
  > 90 * 24 * 60 * 60

它应:

  • block 高风险 release 或建立 ticket;
  • 引导查看不可变 restore manifest;
  • 安排隔离 restore drill;
  • 不把 backup success 当替代。

当前第 24 章已定义 control objective,但告警候选仍待接受,所以走 test sink。

容量预测先要求 reviewed

pg36_shop_capacity_horizon_days < 14
and on (service, environment)
pg36_shop_capacity_forecast_reviewed == 1

为什么第二个条件重要?

short history
step change
seasonality
retention change
one-time load
broken collector

都可能产生虚假线性 forecast。未经评审的模型不应 page。即使 reviewed,也默认 ticket,因为当前没有用户损害。

用户影响必须写进规则

同一个 threshold 若不能说明 user impact,就不能决定 severity:

CPU > 90%
  user impact unknown

availability fast burn
  eligible order attempts failing/unreconciled

correctness mismatch
  acknowledged state may be invalid or cross-tenant

monitoring path broken
  service health unknown

这也是为什么 PG36HostCpuHigh 保持 diagnostic。

25.4.2 阈值、持续时间、去抖、抑制与分组

threshold 是政策,不是颜色

阈值来源可以是:

  • SLO/error budget;
  • control objective;
  • 容量 horizon;
  • 安全/完整性不变量;
  • 组件规格;
  • 统计/历史基线;
  • 供应商硬限制。

每种来源对应不同动作。不要在 Grafana 中选一个“看起来红”的值,然后反向写 解释。

for 的状态机

典型:

inactive
  expression false/no matching series

pending
  expression true, for not elapsed

firing
  expression remains true for duration

inactive again
  expression false or series disappears

for 不是简单 sleep:

  • evaluation interval 决定检查粒度;
  • rule error 与 no-data 的行为不同;
  • restart/state persistence 要配置;
  • data delay 可能让最新点不可见;
  • label set 改变会创建新的 alert identity;
  • 短暂 false 可重置 pending。

VMAlert 文档说明:no data 会重置 pending;evaluation error 则不会按普通 false 处理,而会保留此前状态。必须监控 rule error,不能只看业务 alert。

for 不能修复错误语义

CPU > 90% for 10m

仍然不知道用户是否受损。

pg_archiver_failed_count > 0 for 5m

仍然会把历史失败永久当当前失败。

先修 expression,再用 for 处理持续性。

scrape、evaluation 与 delay

假设:

scrape interval       30s
storage visibility    up to 30s
evaluation interval   1m
for                   2m

“2m 后 page”不代表现实事件开始后精确两分钟:

event -> next scrape -> ingest -> visible eval -> pending -> later eval -> firing

可能更久。规则验收应测 end-to-end detection time,而不是只读 YAML。

VMAlert 支持 group eval_delay/query latency offset 等。取值要根据真实 ingestion 延迟,并在 rule test 与 dashboard query 中使用一致时间对齐。

recording 与 alert group 分离

VMAlert 一个 group 内顺序执行 rule,但 recording result 异步写回 remote storage。若同组后续 alert 立即读取前一个 recording result,可能看不到本轮 结果。

不要:

groups:
  - name: bad-chain
    rules:
      - record: A
        expr: ...
      - alert: B
        expr: A > ...

本章分成:

pg36-shop-sli-recording
pg36-postgresql-diagnostic-recording
pg36-observation-meta-recording

pg36-shop-slo-alerts
pg36-shop-control-alerts
pg36-postgresql-proposed-alerts
pg36-observation-path-alerts

测试用 group_eval_order 先运行 recording groups,再运行 alert groups。

rule result limit

错误 label 可能让一条 rule 产生数十万结果。VMAlert group 支持 result limit; 生产应按预计 cardinality 设置并监控 exceeded error。

限制不是修复:

  • 先保证 labels 有界;
  • 再估算 series;
  • 限制作为故障保护;
  • 超限时 health unknown,不能默默丢结果;
  • 监控 rule error。

Alertmanager group_by

本章:

group_by:
  - service
  - environment
  - alertname

没有把 ins 放进 user symptom grouping。原因:

one user symptom
  may correlate with many instances

group by instance
  can create one page per component

组件信息在诊断包和 dashboard,下游 page 围绕服务事件。

group timing

group_wait: 30s
group_interval: 5m
repeat_interval: 4h

含义大致是:

  • group_wait:新组第一次通知前等待相关 alert 聚合;
  • group_interval:组变化后再次发送的最小间隔;
  • repeat_interval:持续 alert 重复提醒间隔。

取值要与 severity 匹配。SEV-1 等 30 秒可能可以,也可能太慢;真实策略需 notification SLO 和业务 owner 接受。

dedup identity 来自 labels

改变 label 会创建新 alert:

release changes
instance changes
error_message changes
queryid changes

因此不要把高变化字段放 alert label。它们会:

  • 重置 for
  • 打破 dedup;
  • 增加通知;
  • 让 silence 失效。

inhibition 只去重,不删除事实

本章第一条:

source
  availability fast burn, SEV-1

target
  availability slow burn or budget ticket

equal
  service / environment / objective_id

它不会抑制:

  • 不同 service;
  • 不同 environment;
  • freshness;
  • correctness;
  • monitoring path。

第二条:

monitoring path broken
  inhibits only derived-missing alerts
  with observation_dependency=true
  for same service/environment

它不抑制独立 blackbox 看到的 user symptom。

正确性永不被普通抑制

本章对抗测试会拒绝任何 target matcher 包含:

PG36ShopCorrectnessMismatch
class=integrity

原因:

monitoring broken
  does not make known corruption less important

availability outage
  does not excuse cross-tenant mismatch

可以在 incident tooling 中关联同一事件,但不能静默正确性 page。

silence 与 inhibition 不同

inhibition 是配置中的关系规则;silence 是带 matcher 和期限的操作。

silence 必须:

  • owner;
  • reason;
  • start/end;
  • exact matcher;
  • replacement observation;
  • incident/change reference;
  • review/expiry。

不要为了维护直接 silence 整个 cluster 的所有 alert。planned maintenance 也 不应从 SLO event 中事后删除。

recovery 与 flapping

恢复条件可以:

  • expression 直接 false;
  • 使用 hysteresis;
  • 使用 keep_firing_for(若平台与政策采用);
  • 由 Alertmanager repeat/group timing 控制通知;
  • 要求独立 verification。

不要只让 threshold 出现巨大滞后,导致真实恢复长时间仍 page。

测试至少覆盖:

brief spike does not fire
sustained condition fires
condition recovers
one window recovers but other does not
series disappears
rule query errors
label identity changes

25.4.3 每条告警绑定所有者、证据和首个安全动作

page 是打断权

page 会打断一个人的当前工作或睡眠。因此最低合同:

current user/integrity/durability/observation-path impact
accountable owner function
tested route
runbook
first safe action
verification
dashboard/source
silence policy
expiry/review

没有这些字段,报警不是“先上线再补文档”的半成品,而是潜在事故放大器。

route class

route 何时使用 例子
page 需要立即有人介入 fast burn、correctness、monitoring path
ticket 当前可排期但有 deadline/owner capacity、slow reliability debt
diagnostic 解释 symptom,不独立打断 replica distance、CPU、pool queue
test 尚未治理接受或只做演练 latency candidate、archive candidate

severity 不应与 component 数值机械绑定。

owner_function,不是个人名字

owner_function: service

真实 Alertmanager route 再映射到当前值班表。好处:

  • 人员轮换不改 rule;
  • 服务责任与平台执行分开;
  • 可以审查 role 是否可达;
  • 可以测试 fallback escalation。

本章空 receiver 只是验证标签分支,未证明真实 on-call 可达。

首个安全动作要降低不确定性或损害

fast availability:

freeze latest risky release
reconcile unknown outcomes before retrying writes

不是:

restart PostgreSQL
kill top query
fail over
increase pool

这些数据库动作可能扩大未知写结果。

freshness:

route affected read-after-write journey to primary
preserve commit tokens

正确性:

freeze affected writes
preserve reconciliation boundary

monitoring path:

establish independent blackbox view
before changing monitored database

首个动作不是完整修复,而是事故中最不容易后悔的下一步。

source/dashboard URL:

dashboard://pg36-shop-slo
runbook id
rule source id
time window

不要:

https://user:password@monitor/...
signed URL with long-lived token
raw log query containing tenant/order

receiver 中的 secret 也不能进入 Git。

annotation 要短而确定

通知首屏包含:

what
user impact
scope
starts at
first safe action
runbook
dashboard

不要塞:

  • 全部诊断 SQL;
  • 数百行日志;
  • 未经验证的 root cause;
  • 变化的 error text;
  • 密码/token;
  • “请检查”这种无动作文本。

runbook 需要停止线

告警 runbook 不是:

1. 登录数据库
2. 看看
3. 重启

它至少包括:

  • entry condition;
  • identity/target;
  • authority;
  • safe read-only queries;
  • hypotheses;
  • first action;
  • stop condition;
  • escalation;
  • mutation approval;
  • verification;
  • evidence。

一个 page 若 runbook 第一条就是 destructive action,应退回评审。

dashboard 不负责结论

dashboard URL 负责定位:

  • SLI windows;
  • event count;
  • release marker;
  • entry/DB/host correlation;
  • metamonitoring。

runbook 负责:

  • 如何解释;
  • 如何复核;
  • 什么能做;
  • 什么不能做。

Grafana panel 不能表达完整权限和停止线。

alert review expiry

第 24 章 candidate 带 expires_for_review。规则应定期检查:

  • SLO target 是否变;
  • metric/version 是否变;
  • query cost;
  • false positive/negative;
  • page actionability;
  • route ownership;
  • runbook 是否可执行;
  • last drill;
  • label cardinality;
  • notification receipt。

“从未触发”可能是服务稳定,也可能是规则永远不匹配。VMAlert 有 never-firing 检测思路,但仍要用合成测试证明。

actionless instance-down 为什么被拒绝

候选:

PostgresInstanceDownWithoutAction
route page

缺少:

  • user impact;
  • owner;
  • runbook;
  • first safe action。

一个 replica 可能计划下线且服务正常。替代:

topology dashboard retains instance state
endpoint/user symptom drives page
imminent durability/HA loss may have separately governed alert

这不是“不监控实例”,而是不把所有 component state 都升级为打断。

25.4.4 用演练验证告警,而不是等生产事故

规则需要单元测试

测试文件指定:

rule_files:
  - recording-rules.yml
  - alert-rules.yml

evaluation_interval: 1m
group_eval_order:
  - recording groups...
  - alert groups...

然后注入:

input_series:
  - series: '...rate1h{service="pg36_shop",...}'
    values: "0.02x3 0x5"

检查:

1m   pending, no firing alert
3m   firing with exact labels/annotations
7m   recovered, no alert

为什么不直接在在线 VMAlert 测

向在线 datasource/Alertmanager 发合成数据会:

  • 污染 production-like time series;
  • 触发真实 route;
  • 创建 silence/incident confusion;
  • 和现有规则相互作用;
  • 难以证明完全清理。

本章使用 vmalert-tool

start isolated VictoriaMetrics
ingest synthetic series
evaluate rules
compare expected samples/alerts
exit and remove temp workspace

这是 L1 ephemeral,不是 live deployment。

测记录规则的算术

输入:

good counter  +100/min
bad counter   +2/min

期望:

2100+2=0.019607843... \frac{2}{100+2} = 0.019607843...

工具实际查询 recording result,防止:

  • selector 写反;
  • 分母只算 good;
  • label join 丢失;
  • window 名与内容不一致;
  • expression 语法只“看起来对”。

测 alert labels 和 annotations

测试不仅看 alertname,还看:

service
operation_class/read_path
environment
class
route
severity
objective_id
owner_function
runbook_id
governance_status
observation_dependency
first_safe_action
verification
dashboard

这样 candidate 被误改成 page,或 accepted rule 被误改成 proposed,会使测试或 validator 失败。

测 missing

输入:

expected_service_traffic = 1
no pg36_sli_sample_fresh series

期望:

PG36ShopSLIMissing
route=test
class=derived-missing
observation_dependency=true

不要用零 bad ratio 代替。

还应分别测试:

  • expected traffic 为零;
  • SLI fresh;
  • exporter down;
  • rule query error;
  • independent probe fails;
  • label rename。

测 route,不发送通知

Alertmanager sandbox receiver 只有名字:

receivers:
  - name: pg36-shop-page-sink
  - name: pg36-shop-ticket-sink
  - name: pg36-platform-page-sink
  - name: pg36-platform-ticket-sink
  - name: pg36-proposed-rule-sink
  - name: pg36-null-sink
  - name: pg36-default-sink

没有:

webhook_configs
email_configs
slack_configs
pagerduty_configs
credentials

amtool config routes test 根据标签离线解析 receiver。八个用例覆盖:

availability page
correctness page
service ticket
platform ticket
metamonitoring page
diagnostic null
proposed test
unknown default

测 inhibition 的正反例

五个用例:

fast inhibits same-objective slow       true
meta inhibits derived missing           true
meta inhibits independent user symptom  false
meta inhibits correctness               false
fast crosses service boundary           false

只测“应该抑制”不够;错误的 broad matcher 往往会吞掉最重要的告警。

测规则引擎本身

在线当前快照:

groups             17
alert rules         50
recording rules     698
group errors        0
rule errors         0
alert states        50 inactive
current alerts      0

这是已有 Pigsty 规则,不包括本章 31 条候选规则;本章从未部署它们。

metamonitoring 还应观察:

  • evaluation duration vs interval;
  • missed iteration;
  • datasource error;
  • remote write error;
  • notifier error;
  • state restore;
  • no-series/never-firing;
  • data delay;
  • result limit。

“当前无告警”不是通知测试

current alerts=0notification failures=0 不能证明:

  • route matcher 正确;
  • receiver secret 有效;
  • 外部服务接收;
  • 值班人员可达;
  • 升级路径工作。

真实 notification canary 要穿过:

probe
  -> ingestion
      -> rule
          -> VMAlert notifier
              -> Alertmanager grouping/route
                  -> external receiver
                      -> receipt acknowledgment

本章没有权限触达真实 receiver,覆盖矩阵保持 real_receipt_canary=false

变更后的最小测试矩阵

改动 必测
expression normal/threshold/below/above/missing
window onset、pending、firing、recovery
labels grouping、dedup、cardinality
route expected sink、default、no real integration
inhibition positive + must-not-inhibit
annotation owner/action/runbook/dashboard
recording exact arithmetic and labels
engine dry run、unit、error metrics
deployment canary、rollback、receipt;本章未执行

本节验收

你应当能说明:

  1. burn rate 如何由 objective target 计算;
  2. 14.4x 为什么只是 policy starting point;
  3. 两个窗口分别防什么问题;
  4. instance ratio 为什么不是 event ratio;
  5. latency rule 为什么只能走 proposed test;
  6. correctness 为什么没有 error budget;
  7. for 为什么不能修复错误 expression;
  8. recording 与 dependent alert 为什么要分 group;
  9. group_by 为什么不默认带 instance;
  10. fast/slow/ticket 应如何抑制;
  11. metamonitoring 只能抑制哪些派生告警;
  12. page 最低要绑定哪些动作字段;
  13. synthetic rule test 与 offline route test 各证明什么;
  14. 为什么它们仍不能证明真实 pager delivery。

上一节:SQL 可观测基线 · 返回本章目录 · 下一节:Pigsty 可观测体系 · 查看全书目录 · 查看索引中心

25.5 Pigsty 可观测体系

Pigsty 提供的是一套 PostgreSQL 可观测参考实现,而不是另一套数据库语义。

PostgreSQL system views
  -> pg_exporter metrics
      -> VictoriaMetrics time series
          -> recording/alert rules
              -> Grafana dashboard
                  -> Alertmanager route

PostgreSQL / PgBouncer / Patroni / pgBackRest / host logs
  -> Vector
      -> VictoriaLogs
          -> Grafana exploration

application traces, when instrumented
  -> VictoriaTraces
      -> Grafana exploration

每一层都可能:

  • 正常工作;
  • 延迟;
  • 丢数据;
  • 标签漂移;
  • 权限不足;
  • 版本升级;
  • 将一个原生值重新聚合;
  • 把缺失误写成零。

所以“Pigsty 面板显示”仍要回到 PostgreSQL 和应用合同复核。

当前官方入口:

25.5.1 采集、存储、规则、面板与通知链

metrics 采集

Pigsty PostgreSQL 监控主要汇集:

PostgreSQL   pg_exporter, usually 9630
PgBouncer    exporter, usually 9631
Patroni      REST/metrics, usually 8008 or HTTPS target
Node         node_exporter, usually 9100
HAProxy      exporter/stats
other infra  etcd, Vector, Grafana, Alertmanager, storage self metrics

实际端口、TLS 和访问控制以 inventory/rendered config 为准,不能把教学默认值 当网络策略。

沙箱 pg-test-1 的只读探测:

pg_exporter PostgreSQL   9630
pg_exporter PgBouncer    9631
Patroni API              8008
node_exporter            9100

探测 endpoint 可证明 process/HTTP 返回,不能证明:

  • 所有 SQL collector 成功;
  • 所有 database 被发现;
  • 权限足够;
  • series 新鲜;
  • rule 查询正确;
  • 用户服务健康。

target registration

Pigsty 文档中的 PostgreSQL target 文件沿用:

/etc/prometheus/targets/pgsql/

命名不意味着当前存储一定是旧版 Prometheus;Pigsty v4 的当前栈使用 VictoriaMetrics。target 记录:

labels:
  cls: pg-test
  ins: pg-test-1
  ip: 10.10.10.11
targets:
  - 10.10.10.11:9630
  - 10.10.10.11:9631
  - 10.10.10.11:8008

这个 external identity 会与 raw exporter metric-specific labels 合并。

raw exporter 与存储后的 label 不同

直接访问 :9630/metrics

pg_activity_count{datname="test",state="idle"} 1

写入 VictoriaMetrics 后还会带:

cls="pg-test"
ins="pg-test-1"
ip="10.10.10.11"
instance="10.10.10.11:9630"
job="pgsql"

因此调试 label 漂移要分别看:

  1. exporter raw output;
  2. target/relabel config;
  3. storage series;
  4. recording result。

只看一层可能误判 producer。

pg_exporter 是 SQL 到 metric 的编译层

它的配置定义:

query
minimum PostgreSQL version
timeout
cache
tag columns
metric columns
type
description
scale

同一个 view 可以变成多条 metric。升级 PostgreSQL/pg_exporter/Pigsty 后:

  • 列可能增加;
  • metric 名可能变化;
  • tag 可能变化;
  • 旧 dashboard/rule 可能失配;
  • 权限 schema 可能变化。

生产需要 versioned config 和 compatibility test。

exporter 内建最小信号

pg_exporter 即使不加载大量自定义 collector,也有基本自描述/连通信号,例如:

pg_up
pg_version
pg_in_recovery
pg_exporter_build_info

它们适合判断:

  • exporter process/target;
  • database connectivity;
  • server version;
  • recovery role。

pg_up=1 不证明所有高成本 collector、扩展 view 或 application database 可读。

management endpoint 要保护

pg_exporter 的管理/配置能力不应无条件暴露给业务网络。即使 /metrics 可被 监控系统读取,也要区分:

scrape read
health read
configuration reload
profiling/debug

使用:

  • 网络 ACL;
  • reverse proxy auth/TLS;
  • bind address;
  • least-privilege service;
  • 管理面关闭或隔离;
  • access log/audit。

不要把“exporter 没有业务写权限”等同于“管理 endpoint 无风险”。

metrics storage

Pigsty v4 使用 VictoriaMetrics 保存时序。它负责:

  • ingest;
  • time-series index;
  • MetricsQL/PromQL-compatible query;
  • retention;
  • recording result;
  • rule state相关读写;
  • API。

本章快照:

VictoriaMetrics version          1.148.0
total series                     44,842
total label-value pairs          387,724
distinct metric names            3,078

这些不是“监控容量还剩多少”的答案。还要看:

  • ingest rate;
  • data size;
  • retention;
  • series churn;
  • query latency;
  • cache;
  • disk;
  • backup;
  • self errors;
  • cardinality trend。

logs storage

Vector 收集:

/pg/log/postgres
/pg/log/pgbouncer
/pg/log/patroni
/pg/log/pgbackrest
host/service logs

发送到 VictoriaLogs。沙箱 health/version 证明:

VictoriaLogs v1.52.0 endpoint healthy

本章没有读取任何日志 body,因此没有证明:

  • 所有 source 被采集;
  • parser 正确;
  • redaction 正确;
  • retention 正确;
  • query role 正确;
  • incident window 有完整日志。

endpoint health 只是第一层。

traces storage

沙箱有:

VictoriaTraces v0.9.4 endpoint healthy

但没有声称 pg36_shop 发出 application span。三件事要分开:

trace backend exists
collector receives data
specific service has complete/useful instrumentation

没有第三项,不能在架构图上把 trace 当成已覆盖。

rule evaluation

VMAlert:

  • 读取规则;
  • 向 datasource 查询;
  • 执行 recording/alert;
  • remote-write recording/state;
  • 把 alert 发给 Alertmanager;
  • 暴露自监控 API/metric。

快照:

VMAlert version         1.148.0
groups                  17
alert rules             50
recording rules         698
group/rule errors       0
current firing alerts   0

这些是 Pigsty 已有规则。第 25 章的:

18 recording + 13 alert

只经过隔离工具测试,未放进在线 VMAlert。

dashboard

Grafana 将三类 data source 组织为:

PGSQL   PostgreSQL metrics
PGCAT   PostgreSQL catalog/direct datasource
PGLOG   PostgreSQL-related logs

当前 Pigsty 文档列出约 26 个 PostgreSQL dashboard,按 overview、cluster、 instance、database 等层级组织。

dashboard 的优势:

  • 导航与变量;
  • 版本化 panel;
  • 多层关联;
  • time range;
  • release annotation;
  • top-N;
  • drill-down。

它的边界:

  • panel query 可能与告警不同;
  • 变量默认值可能聚合错 scope;
  • time range/step 改变结果;
  • downsampling 隐藏 spike;
  • 颜色阈值不等于 SLO;
  • Grafana cache/query error;
  • 直接 PG datasource 可能与 metric snapshot 不同。

Alertmanager

Alertmanager 负责:

  • group;
  • dedup;
  • route;
  • inhibition;
  • silence;
  • receiver integration;
  • repeat。

它不负责决定 expression 是否有业务意义。VMAlert 能成功发送给 Alertmanager, 也不等于外部 receiver 已收到。

快照:

Alertmanager 0.33.1
current alerts 0
notification failure counters nonzero series 0

没有 receipt canary,真实 delivery 仍是盲区。

端到端链路的健康层

证据
producer PostgreSQL view/metric raw output
scrape target up、scrape duration/error
ingest newest sample time、storage health
query exact expression result、error
rule evaluation state/error/duration
notifier VMAlert notifier success/error
route Alertmanager matched receiver
integration external API accepted
receipt human/system acknowledgment

“面板有数据”通常证明到 query;“Alertmanager 无失败”最多证明部分 notifier/ integration 路径;receipt 需要独立 canary。

Pigsty 三种监控模式

当前文档:

模式 场景 可见能力
RDS / Basic 只有可连接 PGURL PG metrics,缺 host/pool/LB/log
Managed 现有数据库且可 SSH/sudo PG + node,其他可选
Full / Standard Pigsty 创建并管理 PG/pool/LB/node/log 完整参考栈

这直接影响诊断:

RDS mode has no node metrics
  -> cannot conclude host normal from absent panel

RDS mode has no local PG logs
  -> PGLOG absence is expected capability gap

Managed optional pool
  -> check inventory before diagnosing queue

dashboard 应根据 capability 隐藏/标记 unavailable,而不是显示绿色零。

平台版本是合同的一部分

本章快照:

Pigsty           v4.5.0
PostgreSQL       18.6
pg_exporter      v1.4.0
VictoriaMetrics  v1.148.0
VictoriaLogs     v1.52.0
VictoriaTraces   v0.9.4
Alertmanager     0.33.1

旧教程若仍写 Prometheus、Loki 等历史组件,不能直接套用 v4。规则语法具有兼容 目标,但 storage、API、自监控 metric 和运行特性必须按实际版本核对。

25.5.2 以指标语义定位集群、实例、数据库和查询

三个稳定基础身份

Pigsty 使用:

cls   cluster
ins   instance/member
ip    node address

同一个:

cls=pg-test
ins=pg-test-1
ip=10.10.10.11

可关联 PostgreSQL、PgBouncer、Patroni、node、HAProxy 与 logs。

它们解决的是平台身份,不自动解决业务身份:

service
environment
operation_class
objective_id

需要应用层单独提供。

为什么同时保留 clsinsip

label 用途 变化风险
cls 服务集群聚合 cluster rename/migration
ins 稳定成员角色 rebuild/replacement
ip node/网络关联 IP 重用/迁移

IP 不是唯一永久身份;instance 也可能重建。诊断包同时保存 topology event 和 captured_at。

instance 与 exporter endpoint

storage series 还有:

instance="10.10.10.11:9630"
job="pgsql"

这个 instance 是 scrape target endpoint,不一定等于 Pigsty ins。写 query 时明确:

sum by (cls, ins, ip) (...)

不要误用 Prometheus convention 的 instance 代替 Pigsty member identity。

cluster 级

常见问题:

  • 集群是否有 primary;
  • 多少 member exporter 可达;
  • transaction/WAL 总 workload;
  • aggregate capacity;
  • replication topology;
  • entrypoint health。

查询示意:

min by (cls) (pg_up)
sum by (cls) (rate(pg_db_xact_total[5m]))
max by (cls) (pg_lag)

第一条 min(pg_up) 只是“是否每个目标 up”,不是 service availability。

instance 级

常见:

role
activity/wait
checkpointer
I/O
WAL/archive
replication sender/receiver
autovacuum
host resource

本章现场:

pg_in_recovery{cls="pg-test"}

可区分:

pg-test-1 0 primary
pg-test-2 1 replica
pg-test-3 1 replica

但角色应与 Patroni、direct SQL 和 topology event 交叉,特别是在切换窗口。

database 级

raw exporter:

pg_activity_count{datname="test",state="idle"}
pg_db_deadlocks{datname="test"}
pg_db_temp_bytes{datname="test"}

storage 加平台身份后,可以:

sum by (cls, datname, state) (
  pg_activity_count
)

database label 是 logical database 名;多个 cluster 可能都有 postgrestest,不能丢 cls

query 级

现场 pg_query_calls

pg_query_calls{
  datname="postgres",
  query="-1567903771303523871"
} 214

这里 query 的值是 numeric queryid 字符串,不是 SQL text。语义:

metric label name  query
metric label value queryid
cardinality bound  pg_stat_statements.max per tracking dimensions

查询时:

topk(
  20,
  sum by (cls, datname, query) (
    rate(pg_query_exec_time[5m])
  )
)

具体 metric 名与单位由当前 pg_exporter.yml 定义,使用前查看 HELP 和 dashboard query;不要凭记忆假设 _exec_time 是 counter 还是 seconds。

query label 仍有 cardinality 成本

即使不是 raw text:

  • pg_stat_statements.max=10000
  • 多个 database/user/toplevel;
  • 多个 instance;
  • 旧 series retention;
  • query churn;

仍可产生大量 series。current snapshot 的 top label cardinality 中 recording 有 698 个值,也说明 rule identity 自身会形成规模。

需要:

  • 只保留需要的 query metric;
  • 记录 dealloc;
  • 控制 dynamic SQL shape;
  • top-N dashboard;
  • retention;
  • 不要把 queryid 复制到 page grouping。

exact metric names 来自当前 exporter

沙箱 pg_exporter v1.4.0 实际暴露:

pg_activity_count
pg_activity_max_conn_duration
pg_activity_max_duration
pg_activity_max_tx_duration

pg_archiver_failed_count / failed_time
pg_archiver_finish_count / finish_time

pg_checkpointer_timed / req / done
pg_checkpointer_write_time / sync_time / buffers_written

pg_db_numbackends / deadlocks / temp_bytes / xact_*

pg_io_read_bytes / write_bytes / extend_bytes
pg_io_read_time / write_time / fsyncs ...

pg_lag
pg_repl_*
pg_query_calls / exec_time / io_time / rows / blocks / wal_bytes
pg_table_age / n_dead_tup / size / bloat estimates

这是版本化现场证据,不是永久 API。升级时用:

curl --fail --silent http://TARGET:9630/metrics

仅在受控网络检查 # HELP# TYPE 与 label,不要把 endpoint 公网暴露。

pg_lagpg_repl_replay_diff

现场:

pg_lag                   all observed 0
pg_repl_replay_diff      two downstream series 0

前者可能是 time-like convenience metric,后者是 replay distance;具体实现要 查 exporter SQL。两者都不能代替 commit-correlated SLI。

archiver metric 命名的实现差异

native view:

archived_count
last_archived_time
failed_count
last_failed_time

exporter 观察名:

finish_count / finish_time
failed_count / failed_time

规则作者要确认:

  • finish_time 是 Unix seconds;
  • counter type;
  • primary/replica exposure;
  • reset;
  • last success after failure;
  • missing on replica。

不能把 native column 名直接猜成 metric 名。

label join 的显式性

组合两个指标:

A
and on (service, operation_class, environment)
B

必须显式声明 join key。默认所有共同 label 会参与匹配;一侧多一个 insjob,结果可能空。

调试步骤:

  1. 分别查询 A/B;
  2. 列 label sets; 3.确定语义上应该一对一、多对一还是聚合; 4.先 aggregate; 5.用 on/ignoring; 6.检查重复结果。

不要通过删除 identity 修复 join

错误:

sum(A) / sum(B)

它可能跨 cluster/environment 聚合,虽然“有数了”,语义已丢失。

正确做法先确定 service scope,再用相同 bounded dimensions。

scrape freshness

本章正式采集使用:

timestamp(pg_up)
timestamp(pg_exporter_up)
timestamp(vmalert_iteration_total)
timestamp(alertmanager_notifications_failed_total)

并计算 newest sample age,要求不超过 180 秒。up=1 但最后样本很旧,不应视为 健康;storage 里旧值可能仍可查询。

current app SLI absence

查询:

count({__name__=~"pg36_shop_.*"}) by (__name__)

结果 series 为 0。结论:

application SLI not implemented in live sandbox

不是:

zero errors
zero latency
100% availability

这一区分贯穿全章。

25.5.3 面板结论回到 SQL、日志与主机事实复核

dashboard 是索引,不是裁判

一个 panel 应能回答:

query expression
data source
scope variables
unit
legend identity
window/step
missing behavior
link to native evidence

看不到 query 的 panel 不适合支撑高风险动作。

先检查 dashboard scope

事故前先读变量:

cluster
instance
database
query
time range
timezone
refresh interval

常见错误:

  • 选了 pg-meta 而非 pg-test
  • instance 仍是旧 primary;
  • database 选 postgres 而用户在 test
  • time range 不包含 onset;
  • browser timezone 与事件 UTC 不同;
  • panel 聚合 all instance;
  • Grafana repeat panel 隐藏一个 member。

本章 SSH 还遇到一个真实访问路径陷阱:工作站对多个逻辑 IP 的 SSH port forward 落到同一元节点。只有进入元节点再连接真实沙箱网络,并验证 hostname + Patroni scope + cluster_name,才证明查询了 pg-test-1

从 availability panel 回到 event

panel:

availability bad ratio 1h / 5m

复核:

  1. source metric 是否存在;
  2. eligible/good selector;
  3. event count;
  4. low traffic;
  5. release/route;
  6. missing;
  7. reconciliation; 8.独立 probe。

如果 app metric series 根本不存在,panel 不应该显示绿色 0。

从 connection panel 回到 activity

panel 显示连接增加:

SELECT
  backend_type,
  datname,
  state,
  wait_event_type,
  count(*)
FROM pg_stat_activity
GROUP BY backend_type, datname, state, wait_event_type
ORDER BY backend_type, datname, state, wait_event_type;

再看:

  • PgBouncer client/server/wait;
  • HAProxy queue/session;
  • application pool config event;
  • connection churn log;
  • transaction age;
  • max connection headroom。

不要直接提高 max_connections

从 lock panel 回到 blocker graph

SELECT
  a.pid AS waiting_pid,
  a.datname,
  a.application_name,
  a.wait_event,
  clock_timestamp() - a.query_start AS wait_age,
  pg_blocking_pids(a.pid) AS blockers
FROM pg_stat_activity AS a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0
ORDER BY a.query_start;

然后在受限会话按 queryid/application 找 owner。panel 的 lock count 不足以 授权 terminate。

从 I/O panel 回到两个系统

PostgreSQL:

SELECT backend_type, object, context,
       sum(read_bytes), sum(read_time),
       sum(write_bytes), sum(write_time)
FROM pg_stat_io
GROUP BY backend_type, object, context;

主机:

device latency/queue/throughput
filesystem capacity
memory/page cache pressure
other process activity

只有两层同窗,才能区分 PG workload 与 host path。

从 WAL/replication panel 回到原生位置

SELECT
  application_name,
  state,
  sync_state,
  pg_wal_lsn_diff(sent_lsn, replay_lsn) AS gap_bytes,
  write_lag,
  flush_lag,
  replay_lag
FROM pg_stat_replication;

再检查:

  • Patroni role/timeline;
  • receiver;
  • slot;
  • WAL generation rate;
  • read routing;
  • commit token probe。

不要仅凭 panel 的“lag 0”关闭 freshness incident。

从 archive panel 回到时间顺序

SELECT *
FROM pg_stat_archiver;

问:

new failure or historical?
last success after failure?
WAL currently generated?
archive queue progressing?
pgBackRest check?
restore evidence age?

沙箱就是 failed_count=21 但后来成功。panel 若只把 total failed 画红,会永久 误报。

从 slow query panel 回到 reset

先看:

SELECT *
FROM monitor.pg_stat_statements_info;

再看 queryid 聚合:

SELECT
  dbid, userid, queryid, toplevel,
  calls, total_exec_time, mean_exec_time,
  shared_blks_read, temp_blks_written, wal_bytes,
  stats_since, minmax_stats_since
FROM monitor.pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 50;

检查:

  • reset;
  • dealloc;
  • member/failover;
  • calls vs mean;
  • track planning/timing;
  • dashboard delta 算法;
  • query text 权限。

从 vacuum panel 回到对象与 blocker

SELECT
  schemaname,
  relname,
  n_live_tup,
  n_dead_tup,
  n_mod_since_analyze,
  last_autovacuum,
  last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 50;

再查:

  • progress;
  • old transaction/xmin;
  • slot/feedback;
  • relation size;
  • freeze age;
  • autovacuum config;
  • host I/O。

不要因为 bloat panel 红就执行 rewrite。

从 log panel 回到 pipeline

先验证:

source file exists and advances
Vector source healthy
parse errors
VictoriaLogs ingest/query
time zone
retention
redaction

然后限定:

cluster / instance / database
severity or SQLSTATE
time window
row limit

本章不导出 body;如果必须查看,在受限界面完成。

面板与规则必须共享 contract

如果 alert:

bad_ratio 1h + 5m

dashboard 却画:

5m p99

值班无法复核 alert。至少提供:

  • exact long/short expression;
  • event count;
  • threshold;
  • pending/firing start;
  • missing/freshness;
  • release marker;
  • rule evaluation error。

用 source link,不复制 payload

alert 携带:

dashboard id
query template id
time range
service/environment

而不是把 metric dump、SQL text、log body 全塞进通知。诊断包按权限拉取。

三次复核法

高风险动作前至少三类独立证据:

service symptom
  app SLI or independent probe

PostgreSQL fact
  native SQL / log / topology

platform/host fact
  pool / host / rule engine / change event

不是机械凑三条,而是让每条能 falsify 竞争解释。

Pigsty dashboard 的正确使用路径

overview
  locate service/cluster and onset

cluster
  topology, workload, replication, capacity

instance
  role, activity, I/O, WAL, maintenance

database
  transactions, tables, query workload

query/catalog/log
  focused evidence

native SQL and host
  verify before mutation

下钻过程中始终保留 time range 和 identity。

平台升级验收

Pigsty/pg_exporter/PG major 升级后:

  1. inventory/target identity;
  2. raw exporter HELP/TYPE/labels;
  3. required metric presence;
  4. series cardinality;
  5. recording rule dry run/unit test;
  6. dashboard no-data/error;
  7. alert route test;
  8. native SQL parity;
  9. log parse/redaction;
  10. production canary/rollback。

不要以“Grafana 首页能打开”验收监控升级。

本节验收

你应当能解释:

  1. Pigsty v4 metrics、logs、traces、rules、dashboards、routes 各由谁负责;
  2. raw exporter label 与 storage external label 为什么不同;
  3. clsinsip 与 scrape instance 的区别;
  4. RDS/Managed/Full 三种模式会缺哪些信号;
  5. pg_query_*query label 为什么是 queryid 而非 text;
  6. exporter endpoint up 为什么不等于所有 collector 正常;
  7. VictoriaMetrics series count 为什么需要趋势而非单次上限;
  8. VMAlert 无 error 为什么不等于本章规则已部署;
  9. Alertmanager 无失败为什么不等于 receiver 已收到;
  10. trace backend healthy 为什么不等于应用有 span;
  11. 从 connection、I/O、replication、archive、query、vacuum panel 分别回到什么原生证据;
  12. 平台升级为什么必须做 metric/rule/dashboard/native parity。

上一节:把观察契约变成告警 · 返回本章目录 · 下一节:从告警到诊断包 · 查看全书目录 · 查看索引中心

25.6 从告警到诊断包

告警只告诉你一个合同条件成立。它不是事故报告,也不是根因分析。

值班最先丢失的往往不是 metric,而是上下文:

onset 前有哪些变更?
当时谁是 primary?
哪些入口受影响?
长窗和短窗的 event count 是多少?
锁图在三分钟后消失前是什么样?
统计是否刚 reset?
采集链有没有延迟?
日志保留窗口还在吗?
值班执行的第一条查询是否改变了状态?

诊断包的任务,是在不增加事故的前提下,自动保存一个有界、脱敏、带身份与 时间语义的起点:

alert
  -> manifest
      -> symptom windows
          -> topology and changes
              -> PostgreSQL aggregates
                  -> platform/host correlation
                      -> hypotheses and falsifiers

它不能承诺:

captured everything
proved root cause
safe to mutate
compliance achieved

机器可读合同: diagnostic-pack-contract.json

25.6.1 自动保存时间窗、拓扑、变更与关键查询

诊断包从 manifest 开始

任何包先记录:

{
  "run_id": "...",
  "captured_at": "...Z",
  "trigger_alert": "...",
  "service": "pg36_shop",
  "environment": "l2-sandbox",
  "targets": ["pg-test-1"],
  "tool_versions": {},
  "source_hashes": {},
  "risk": "L0",
  "mutation": "none"
}

没有 manifest 的截图无法回答:

  • 哪次事件;
  • 哪个环境;
  • 哪一版 query;
  • 谁采集;
  • 何时采集;
  • 是否做了 mutation;
  • 是否后来被改。

target 要通过多源确认

本章正式采集没有相信工作站 SSH alias,而是:

ProxyJump via pg-meta-1
  -> actual sandbox network 10.10.10.11
      -> hostname pg-test-1
      -> Patroni scope pg-test, name pg-test-1
      -> PostgreSQL cluster_name pg-test
      -> pg_is_in_recovery false
      -> two pg_stat_replication rows

如果其中冲突:

stop
classify identity mismatch
do not continue to mutation

连接成功不是身份验证。

时间窗要覆盖 onset 前后

本章合同默认:

before alert  30m
after alert   15m
clock         UTC

这不是 universal window。选择应考虑:

  • 最长 burn window;
  • scrape/evaluation/group delay;
  • release duration;
  • query/log retention;
  • incident severity;
  • 存储成本。

至少保存:

alert starts_at
rule evaluation time
collector time
database clock
change event time

如果 after window 尚未完成,可以:

  1. 先保存 initial pack;
  2. 15 分钟后补一个 immutable supplement;
  3. 不覆盖 initial;
  4. manifest 建立 parent/child。

保存 event count,不只保存 ratio

同样 10% bad ratio:

1 bad / 10 total
10,000 bad / 100,000 total

动作和置信度不同。SLO section 保存:

  • good/bad/eligible count 或 rate;
  • long/short window;
  • threshold;
  • burn;
  • low-traffic flag;
  • missing/freshness;
  • independent probe;
  • objective version。

不要只截图红线。

topology 是事件时刻拓扑

保存:

service entrypoints
HAProxy/PgBouncer path
cluster/member role
Patroni timeline
replication state
declared dependencies
read/write routing

不自动保存:

  • client address;
  • password;
  • connection URI;
  • inventory secret。

IP 是否允许进入私密包取决于政策;本章公开摘要只保留教学网络身份。

变更时间线

收集:

application release
schema migration
PostgreSQL parameter/config
pool size/mode
HAProxy route
failover/switchover
node restart
backup/restore
monitoring rule/dashboard
credential/security policy
capacity/retention

每个 event:

event id
actor role
target
requested/approved/executed times
result
rollback/roll-forward
evidence link

不要直接收集个人聊天全文作为唯一 change log。

PostgreSQL section

本章自动保存:

activity aggregate by client state/wait type
lock aggregate
replication identity/state/gap without client address
pg_stat_wal
pg_stat_checkpointer
pg_stat_archiver
nonzero pg_stat_io aggregate
database aggregate
maintenance aggregate
pg_stat_statements aggregate/reset, no query text

为什么 activity 只自动聚合:

  • 完整 query text 敏感;
  • PID 列表可能很大;
  • 一次 current sample 很快过时;
  • 自动证据的目标是安全起点。

若 incident 需要 blocker graph,runbook 再用受限角色采集二级包。

SQL query controls

采集连接先执行:

SET statement_timeout = '5s';
SET lock_timeout = '500ms';
SET default_transaction_read_only = on;
BEGIN READ ONLY;

并禁止:

EXPLAIN ANALYZE
pg_stat_reset*
pg_stat_statements_reset
VACUUM / ANALYZE
CHECKPOINT
cancel/terminate
DDL/DML
load generation
failover
restore
reload

READ ONLY 不是绝对安全证明:

  • SELECT 仍可很贵;
  • function 可能具有副作用,具体取决于实现与权限;
  • 系统视图查询可拿锁;
  • external extension 可能访问资源。

因此同时使用 allowlisted query、timeout 和 least privilege。

queryid-level top list

自动保存:

dbid
userid or role class
queryid
toplevel
calls
total/mean execution
rows
blocks/temp/WAL
stats_since/minmax_stats_since

限制:

top 50
no query text
no bind
no client address

top 50 不是完整 workload。manifest 要写:

sort key
limit
window/reset
source member

平台 section

保存:

  • exporter/target freshness;
  • VictoriaMetrics health/series;
  • VMAlert groups/rules/error/missed iteration;
  • Alertmanager failure counter;
  • node resource trend;
  • 日志 query count,不含 body;
  • component versions。

本章公开摘要包括:

VMAlert 17 groups
50 alert rules
698 recording rules
0 rule errors

它是采集时刻 baseline,不能事后覆盖。

文件布局

建议:

incident-<id>/
├── manifest.json
├── symptom.json
├── topology.json
├── changes.json
├── postgresql.json
├── platform.json
├── hypotheses.json
├── validation.json
└── hashes.json

正文和 body 类材料如确需保存,放在更严格的 evidence tier,不与常规诊断包 混合。

分区大小上限

本章每 section:

manifest        64 KiB
symptom         256 KiB
topology        256 KiB
changes         256 KiB
postgresql      1 MiB
platform        1 MiB
hypotheses      256 KiB

超限时:

  • 不应无限截断而不标记;
  • 记录 truncated=true
  • 保存 total count;
  • 使用 top-N;
  • 提供 restricted source link;
  • 按需发起二级采集。

初始包与后续包

T0 automatic
  cheap, bounded, no text

T1 operator
  focused blocker/query/log under incident authority

T2 mutation evidence
  before/after, approval, command, verification

T3 postmortem
  decisions, cause, counterfactual, actions

不要让 T0 自动化拥有 T2 权限。

source hash 与可重放

保存:

  • collector version;
  • query template hash;
  • rule/config hash;
  • upstream governance run id;
  • output hash;
  • capture parameters。

这样以后能回答:

same data, same query?
same rule version?
same target?

hash 证明 byte identity,不证明内容真实或政策正确;仍需身份和 source trust。

25.6.2 区分首发症状、伴随现象与根因证据

先写 timeline,不先写故事

事实表:

时间 来源 事实 置信度
10:01:30 release event version B 完成 high
10:03:00 user SLI latency short window 超阈值 high
10:04:00 PgBouncer metric wait queue 增长 medium/high
10:04:20 trace sample pool wait 占主要时间 sampled
10:05:00 PG activity active/wait 未显著变化 point sample

先不要写:

version B caused database slowdown

事实支持的初步说法:

latency symptom began after version B;
pool wait correlated;
current PostgreSQL execution evidence did not show the same growth.

四种状态标签

symptom
  contract violation directly observed

correlate
  same window changed, causal role unknown

hypothesis
  proposed mechanism with predicted evidence

cause
  mechanism corroborated, alternatives tested, repair verified

诊断包中的每条观察带 role,避免相关线自动升级成 root cause。

首发症状不是第一个 dashboard spike

“first observed” 受:

  • scrape interval;
  • evaluation interval;
  • threshold;
  • 日志延迟;
  • 时钟;
  • 采样;
  • dashboard refresh

影响。可以说:

first reliable observation available to this system

不要说“绝对第一个事件”,除非证据支持。

假设要写预测与反证

模板:

hypothesis: pool configuration reduced reusable server connections
mechanism: requests queue before PostgreSQL
predicts:
  - PgBouncer wait rises
  - edge latency rises
  - PostgreSQL active executions do not rise equally
  - host CPU/I/O remains stable
falsified_by:
  - no pool wait
  - server execution time explains latency
  - rollback does not change symptom
next_safe_query:
  - inspect pool wait and config event
mutation_required: false

这比“看起来像连接池”更可执行。

反例一:CPU 同时升高

availability fast burn
CPU high

可能机制:

  • 应用 retry storm 让 CPU 高;
  • slow query 让 CPU 高并导致错误;
  • batch 与 outage 巧合;
  • monitoring query 自身;
  • 另一 process。

需要:

queryid active work
wait state
request/transaction rate
process CPU
release/batch event
repair response

CPU 是 correlate,不是自动 cause。

反例二:replica gap 与 stale read

freshness probe bad
replica WAL gap high

强候选,但仍检查:

  • probe 确实走 replica;
  • token commit 已确认;
  • clock;
  • route;
  • cache;
  • transaction snapshot;
  • replica state/timeline。

如果切 primary path 后新 token 立即满足,而 gap 回落后 replica path 恢复, 机制更强。

反例三:归档失败计数

failed_count=21

时间线:

last failure 18:57
last success 22:27
current capture 22:48

结论:

historical failures occurred
archive later succeeded
no active failure is established by this counter alone

这是 falsification:反驳“非零 counter = 当前故障”。

反例四:锁已经消失

用户 latency 曾受 lock 影响,但初始包晚到:

current pg_locks no blocker
deadlock/lock-wait log in window
application timeout in window

不能因为 current view 空就否认历史;也不能因为日志有等待就证明当前仍阻塞。 timeline 必须保留各自时间语义。

修复验证要回到症状

如果动作是 cancel blocker:

blocker disappears

只是组件验证。还要:

  • user burn short/long window;
  • event outcome;
  • unknown writes reconciliation;
  • correctness;
  • pool/entry;
  • 副作用;
  • recurrence。

动作可能同时让 query 消失和用户失败增加。

recovery 不是瞬时绿

fast rule 的两个窗口恢复速度不同:

5m clears first
1h remains elevated

关闭事件政策可要求:

  • 短窗恢复;
  • 长窗下降趋势/低于阈值;
  • independent probe;
  • correctness check;
  • no new unknown outcome;
  • repair stable for observation period。

不要等所有长窗完全归零,也不要一个样本就关。

counterfactual

root cause review 要问:

如果没有这次变更,症状是否仍会发生?
如果只改变这个机制,症状是否恢复?
为什么监控提前没有发现?
哪个 guardrail 能阻止复发?

不能真实重放生产时,可以:

  • staging reproduction;
  • synthetic fixture;
  • plan/config diff;
  • canary;
  • independent domain evidence。

明确证据强度。

诊断包不是 postmortem

诊断包:

raw-ish bounded facts
initial hypotheses
capture boundary

postmortem:

impact
timeline
cause/contributing factors
detection/response
decisions
counterfactual
actions/owners/dates

不要在自动包里预填 root_cause=database

25.6.3 证据采集本身的负载和权限边界

observer effect

采集会:

  • 建立连接;
  • AccessShareLock
  • 读取 shared stats;
  • 执行 sort/aggregate;
  • 查询 storage;
  • 扫描日志;
  • 占网络与磁盘;
  • 出现在 pg_stat_activity/pg_stat_statements
  • 影响被观察系统。

目标是有界,不是声称零影响。

风险分级

采集 风险 默认
health/version/small metric L0 自动
bounded system-view aggregate L0 自动
top queryid without text L0 自动,有 timeout
blocker graph/PID detail L0/L1 data-sensitive incident role
bounded log body sensitive 二级授权
raw SQL/bind export high data risk 禁止自动
EXPLAIN low/moderate 评估 function/lock
EXPLAIN ANALYZE executes statement 禁止自动
statistics reset destructive observation 禁止
load/fault injection mutation 隔离演练

EXPLAIN ANALYZE 会执行

对 DML:

EXPLAIN ANALYZE DELETE ...

真的执行 DELETE,除非在可回滚事务中且没有外部副作用;即便 rollback,也会:

  • 运行 trigger/function;
  • 拿锁;
  • 产生 WAL/side effect;
  • 消耗资源;
  • 影响 sequence/external service 等。

诊断自动化不得把它当只读。

function 与 extension

SELECT 可以调用:

  • volatile function;
  • security definer;
  • external API;
  • advisory lock;
  • file/extension;
  • sleep。

allowlist system catalog/view query,不接受 incident 参数拼任意 SQL。

query timeout

本章:

statement_timeout  5s
lock_timeout       500ms
parallel capture   max 4
top query          50
log rows           1000

timeout 后:

  • 记录 section incomplete;
  • 保存 error class;
  • 不在 tight loop 重试;
  • 不自动提高 timeout;
  • 交给 operator 决定替代 source。

并发限制

对 100 个 database 同时执行 catalog query,单条很轻也会形成 load spike。

策略:

priority:
  affected service/database first

concurrency:
  fixed small pool

jitter:
  avoid synchronized collectors

deadline:
  stop when incident value decays

cache:
  reuse inventory/version

log query cost

无界:

all clusters
30 days
regex on full message
no limit

会拖慢 VictoriaLogs,也可能返回大量敏感正文。

有界:

exact cls/ins/database
45m window
severity/SQLSTATE structured filter
limit 1000
count/group first
body only under secondary authorization

metric query cost

危险模式:

  • regex 匹配所有 metric;
  • 高基数 query label 长窗口;
  • subquery 小 step;
  • topk 前未聚合;
  • cross join;
  • dashboard 多 panel 同时 refresh;
  • incident automation 重复相同查询。

记录:

  • expression hash;
  • start/end/step;
  • series count;
  • duration;
  • result size;
  • timeout/error。

权限

数据库:

machine exporter
  dedicated monitoring role, stable views

automatic diagnostic
  aggregate-only allowlist

interactive incident
  time-bounded pg_read_all_stats or narrower

mutation operator
  separate role/approval

监控平台:

metric read
log metadata read
log body read
rule edit
silence create
receiver secret
admin

不应是一个万能账号。

evidence 文件权限

本章 private bundle:

directory 0700
files     0600

reviewer 检查所有文件 mode。公共仓库只放 allowlist summary:

  • 版本;
  • 计数;
  • pass/fail;
  • declared gaps;
  • run id;
  • production gate。

evidence secret scan

自动扫描:

  • SCRAM verifier;
  • private key;
  • credential-bearing URI;
  • authorization header;
  • clear password JSON;
  • query/raw_sql/sql_text 字段。

扫描通过不证明绝对无秘密:

  • 未知格式;
  • encoded value;
  • 业务 PII;
  • hash 可重识别;
  • file name。

仍要 data classification 与人工 review。

retention

本章 private evidence 教学默认 30 天;生产需独立政策。决定:

  • 事件/合规需求;
  • 敏感程度;
  • 调查周期;
  • 删除;
  • legal hold;
  • backup;
  • 访问 audit;
  • hash/manifest。

不要因为是“监控证据”永久保存。

完整性与可更新性

原始 initial pack 不覆盖。修正错误时:

new supplement
references original run_id/hash
states correction reason
preserves original

公开摘要可以更新展示,但应保留 underlying immutable run reference。

failure-safe behavior

若 capture 失败:

do not report empty as healthy
mark section incomplete
record error without secret
continue independent low-cost sections
page metamonitoring if critical path
do not mutate database to make capture pass

本章 validator 对 application SLI absence 就是:

declared expected gap

而不是失败或绿色。

自动化权限不能随事故升级

事故 severity 变高,不代表 collector 自动获得:

  • superuser;
  • SSH root;
  • raw log body;
  • query text export;
  • failover;
  • terminate;
  • restore。

需要新 authority 的动作必须停下来进入 SOP/change plan。

诊断入口输出

给第 31 章的输入:

manifest identity/time/version
user symptom and windows
entrypoint/path
PostgreSQL state/counters/reset
top queryid aggregates
topology/change timeline
host/platform correlation
known gaps
hypotheses and falsifiers
permissions/risk

第 31 章再按症状分支:

slow query
lock/deadlock
connection/pool
replication/freshness
disk/capacity
vacuum/freeze

本节验收

你应当能说明:

  1. 诊断包为什么先有 manifest;
  2. target 为什么要多源确认;
  3. 为什么同时保存 alert、collector 和 database clock;
  4. ratio 为什么必须带 event count;
  5. topology 与 change event 为什么要保存事件时刻版本;
  6. automatic pack 为什么只保存 queryid 聚合;
  7. T0/T1/T2/T3 四层 evidence 权限如何分开;
  8. symptom/correlate/hypothesis/cause 的措辞差异;
  9. 假设为什么必须带预测与反证;
  10. repair 为什么要回到 user/control signal 验证;
  11. EXPLAIN ANALYZE 为什么不能自动运行;
  12. timeout/limit/concurrency 如何让采集 fail-safe;
  13. 0700/0600 与 public allowlist 各解决什么;
  14. secret scan 为什么仍不能替代数据分类;
  15. 采集失败为什么必须是 incomplete/unknown 而不是 healthy。

上一节:Pigsty 可观测体系 · 返回本章目录 · 下一节:实战:实现并演练观察契约 · 查看全书目录 · 查看索引中心

25.7 实战:实现并演练观察契约

本节把全章压成一条可重放的证据链:

bind ch24 observation contract
  -> validate signal and diagnostic-pack semantics
      -> compile 18 recording rules
          -> compile 13 alert rules
              -> preserve 7 accepted policies
                  -> quarantine 6 proposed policies
                      -> read live Pigsty/PostgreSQL baseline
                          -> verify target identity
                              -> run isolated VMAlert tests
                                  -> run offline Alertmanager routes
                                      -> test inhibition positive/negative
                                          -> reject 25 counterexamples
                                              -> publish 14-row coverage matrix
                                                  -> keep production gate pending

它刻意把两种动作分开:

online baseline
  L0, read-only, no mutation

isolated exercise
  L1-ephemeral, temporary loopback processes and files
  no online rule, no online alert, no real receiver

实验合同:

25.7.1 为延迟、错误、复制、备份和资源建立规则

文件布局

static/labs/ch25/
├── requirements.json
├── signal-contract.json
├── coverage-matrix.json
├── diagnostic-pack-contract.json
├── recording-rules.yml
├── alert-rules.yml
├── rule-tests.yml
├── alertmanager-sandbox.yml
├── route-tests.json
├── negative-cases.json
├── topology.mmd
├── lab-contract.md
├── capture.py
├── exercise.py
├── validate.py
├── review.py
├── task.sh
└── observability-run.json

职责:

文件 证明什么
requirements target、risk、上游、hard rejection、gate
signal contract source/type/label/reset/missing/cost
recording rules SLI window 与诊断聚合
alert rules accepted/proposed policy
rule tests synthetic arithmetic、pending、firing、recovery
AM config empty sink route 和 inhibition
route tests 八个 route、五个 inhibition 正反例
diagnostic pack 自动证据字段、limit、权限
coverage 已有信号、预期缺口、fallback
negative cases validator 不是只会通过
capture live L0 baseline
exercise remote /tmp isolated test
review file mode、secret、claim、count
public summary allowlist 结果,不含 private evidence

上游 binding

第 24 章四份输入按 hash 绑定:

observation-contract.json
alert-candidates.json
slo-policy.json
governance-run.json

并要求:

run_id              34909737-527a-460c-927c-d9d71c93aa13
production_ch24_gate pending

如果上游改了 target、alert policy 或 missing semantics,本章 capture 不能继续 假装基于旧合同。

记录规则:可用性

五个窗口:

rate5m
rate30m
rate1h
rate6h
rate3d

表达式:

sum by (service, operation_class, environment) (
  rate(pg36_shop_request_outcomes_total{
    eligible="true",
    outcome!="good"
  }[5m])
)
/
sum by (service, operation_class, environment) (
  rate(pg36_shop_request_outcomes_total{
    eligible="true"
  }[5m])
)

注意三个设计:

  1. 分子是 bad eligible event,不是 instance;
  2. 分母没有任意 clamp_min(...,1) 改变低流量比率;
  3. 缺失由独立 freshness/metamonitoring 处理,不补健康零。

instrumentation 必须预初始化 outcome category,或者用能区分“零 bad”与“bad series 未产生”的设计。

记录规则:延迟

1 -
sum by (service, operation_class, environment) (
  rate(pg36_shop_request_duration_seconds_bucket{
    eligible="true",
    le="0.25"
  }[5m])
)
/
sum by (service, operation_class, environment) (
  rate(pg36_shop_request_duration_seconds_count{
    eligible="true"
  }[5m])
)

产生 5m/1h bad ratio。

规则可计算,但 alert governance 尚未接受:

route: test
severity: candidate
governance_status: proposed-not-accepted

记录规则:新鲜度

sum by (service, read_path, environment) (
  rate(pg36_shop_commit_visibility_probes_total{
    eligible="true",
    within_bound="false"
  }[5m])
)
/
sum by (service, read_path, environment) (
  rate(pg36_shop_commit_visibility_probes_total{
    eligible="true"
  }[5m])
)

它不读取 pg_lag,因为用户 SLI 必须携带 commit token。

记录规则:PostgreSQL 诊断

pg36:replica_replay_distance_bytes:max
pg36:archive_failures:increase15m
pg36:longest_transaction_seconds:max
pg36:table_freeze_age:max
pg36:dead_tuples:sum
pg36:exporter_unavailable:max

这些是原因/容量输入,不自动 page。

记录规则:metamonitoring

pg36:vmalert_rule_errors:sum
pg36:vmalert_missed_iterations:increase15m
pg36:notification_failures:increase15m

一个重要限制:notification failure 为零仍不能证明 receiver receipt。还需要 外部 canary。

accepted 七条

validator 逐字段与第 24 章比对:

alert route for
availability fast page 2m
availability slow page 5m
availability budget ticket 15m
freshness fast page 2m
correctness mismatch page 0m
capacity horizon ticket 1h
monitoring path broken page 5m

比较:

  • route;
  • severity;
  • objective;
  • owner;
  • runbook;
  • first safe action;
  • governance status;
  • for
  • multiwindow token。

任意漂移使 lint 失败。

proposed 六条

alert 用途 为什么不直接上线
latency fast burn 用户延迟 ch24 尚未接受 page policy
restore evidence stale recovery control route/severity 待接受
archive failure active recovery risk threshold/pgBackRest 联动待评审
transaction age maintenance/capacity workload-specific policy
freeze age wraparound horizon 应从配置/rate 生成
SLI missing observation path expected traffic/fallback 待实现

统一:

route: test
severity: candidate
governance_status: proposed-not-accepted

validator 会拒绝 candidate 进入 page/ticket sink。

archive candidate 不比较历史总数

pg36:archive_failures:increase15m > 0
and on (cls, ins, ip)
(time() - pg_archiver_finish_time) > 900

同时要求:

  • 15 分钟出现新失败;
  • 成功推进已经停滞。

仍需 runbook 复核 pgBackRest 和 restore evidence。

transaction candidate

pg36:longest_transaction_seconds:max > 900

只走 test,因为:

  • batch 可能合法;
  • idle in transaction 与 active 不同;
  • cancel 权限与 unknown outcome;
  • 阈值依赖 workload;
  • user impact 可能不存在。

first action 是找 owner/queryid 与安全性,不是 terminate。

freeze candidate

pg36:table_freeze_age:max > 1000000000

固定值仅用于 synthetic test。生产应使用:

current age
current autovacuum_freeze_max_age
table override
consumption rate
vacuum throughput
blocker
safety margin

capacity accepted 规则仍需 reviewed input

pg36_shop_capacity_horizon_days < 14
and on (service, environment)
pg36_shop_capacity_forecast_reviewed == 1

accepted 是告警合同,未表示 forecast exporter 已实现。coverage 仍标记应用层 缺口。

rules 与 groups

三组 recording 和四组 alert 分开。原因是 VMAlert 的 recording result remote write 是异步的;同 group 依赖上一条结果可能读不到本轮。

25 个反例之一会把 alert 塞回 recording group,确认 validator 拒绝:

recording group contains an alert and creates rule chaining

当前 live baseline

capture.py 只读:

VictoriaMetrics   health/API/query
VictoriaLogs      health/version
VictoriaTraces    health/version
VMAlert           rules/alerts/self metrics
Alertmanager      health/alert count/self metrics
pg_exporter       raw HELP/name/label inventory
PgBouncer exporter response
Patroni           selected identity/state
PostgreSQL        bounded read-only SQL

不会读取 Alertmanager config,因为可能有 receiver secret;只保存 count 与 configuration_exported=false

PostgreSQL target identity

工作站配置:

10.10.10.10 -> local port 2222
10.10.10.11 -> local port 2200
...

现场发现多个转发落到元节点。正式路径改为:

ssh ProxyJump meta
  target HostName=10.10.10.11 inside sandbox

并验证:

hostname            pg-test-1
cluster_name        pg-test
Patroni             pg-test/pg-test-1
pg_is_in_recovery   false
replication rows    2

这一检查会拒绝“查询成功但目标错误”。

实际 PG observation settings

正式快照记录:

track_activities              on
track_counts                  on
track_io_timing               on
track_wal_io_timing           off
stats_fetch_consistency       cache

pg_stat_statements.track      all
track_planning                off
track_utility                 off

logging_collector             on
log_destination               csvlog
log_file_mode                 0640

auto_explain.log_min_duration 1000ms
auto_explain.log_analyze      on
auto_explain.log_timing       on
auto_explain.sample_rate      1

两个待评审:

auto_explain overhead reviewed  false
log group access reviewed       false

实验不修改配置。

25.7.2 注入可控症状,验证触发、路由、抑制和恢复

只做静态 lint

static/labs/ch25/task.sh lint

输出:

recording_rules=18
alert_rules=13
accepted_alerts=7
proposed_alerts=6
counterexamples=25-rejected
live_deployment=false
production_ch25_gate=pending

不访问远端。

正式完整运行

先创建一个新的私密路径;task.sh 也会拒绝覆盖非空目录:

export PG36_EVIDENCE_DIR=/absolute/private/new-empty/ch25-run
static/labs/ch25/task.sh all

动作:

capture
  L0 live read-only

exercise
  L1 remote temp / loopback only

verify
  positive + negative + source hash + live evidence

review
  private mode + secret scan + claim boundary

capture 的 SQL 安全边界

statement_timeout=5s
lock_timeout=500ms
default_transaction_read_only=on
BEGIN READ ONLY

query allowlist 不含:

query text
bind
client address
EXPLAIN ANALYZE
reset
vacuum/checkpoint
cancel/terminate
DDL/DML

即便如此,pg_locks 会看到采集查询自身的 relation lock;这是 observer effect,证据不会假装完全无影响。

isolated workspace

exercise.py

  1. 在元节点执行 mktemp -d /tmp/pg36-ch25.XXXXXXXX
  2. 验证路径符合严格 regex;
  3. 只上传四个无 secret 文件;
  4. 执行测试;
  5. rm -rf 只允许匹配该 prefix;
  6. 断言目录已经不存在;
  7. 输出 remote_cleanup=ok

如果 temp path 不匹配,宁可失败也不清理未知目录。

规则语法

vmalert -rule=recording-rules.yml
        -rule=alert-rules.yml
        -dryRun

证明 parser/规则结构可接受,不执行 live query。

合成时序

vmalert-tool

isolated VictoriaMetrics
simulated periodic ingestion
recording/alert evaluation
expected sample/alert comparison

availability recording 输入:

good +100/min
bad  +2/min

验证结果:

2102=0.019607843... \frac{2}{102}=0.019607843...

fast pending/firing/recovery

输入:

1h bad ratio  0.02 for 4 samples, then 0
5m bad ratio  0.02 for 4 samples, then 0
evaluation    1m
for           2m

期望:

1m  no firing alert
3m  exact alert labels/annotations
7m  no alert

这验证 expression、for、label identity 和 recovery。

其他合成用例

availability slow burn
availability budget ticket
freshness fast burn
correctness immediate
reviewed capacity ticket
monitoring path page
latency proposed route
expected traffic but SLI missing

correctness for=0m,capacity 需要 reviewed gauge,missing 不补零。

Alertmanager 配置检查

amtool check-config

发现:

global config
route
2 inhibition rules
7 receivers
0 templates

所有 receiver 只有 name,没有 integration。

八个 route

用例 期望 sink
availability page pg36-shop-page-sink
correctness page pg36-shop-page-sink
service ticket pg36-shop-ticket-sink
platform ticket pg36-platform-ticket-sink
metamonitoring page pg36-platform-page-sink
diagnostic pg36-null-sink
proposed pg36-proposed-rule-sink
unknown pg36-default-sink

使用:

amtool config routes test --config.file=...
  --verify.receivers=<expected>
  <labels...>

离线解析,不连接 live Alertmanager。

五个 inhibition

source → target 结果
fast → same service/objective slow inhibit
meta → derived missing inhibit
meta → independent user symptom do not
meta → correctness do not
fast → other service slow do not

validator 同时解析 config 和测试表;添加 broad correctness inhibition 会被 反例拒绝。

25 个对抗性变体

类别:

scope
  production data/approval promoted

online mutation
  live deploy/notification/Alertmanager

semantics
  missing healthy, timing disabled = zero

privacy
  tenant label, query text, public evidence

claims
  app metric/rules/pager claimed live

query safety
  EXPLAIN ANALYZE

alert governance
  accepted route/for drift, candidate page

time
  slow rule loses second window

component semantics
  archive historical counter

engine
  alert chained in recording group

routing
  real webhook, correctness inhibition, real receiver name

validator 不是检查“文件存在”,而是逐个 mutation 后要求出现预期 rejection。

正式运行结果

run_id      b876731e-5741-40e3-a03e-17cf7d20881b
captured    2026-07-29T22:48:21Z
target      pg36-l2-vagrant/pg-test
live risk   L0
exercise    L1-ephemeral

组件:

Pigsty            v4.5.0
PostgreSQL        18.6
pg_exporter       v1.4.0
VictoriaMetrics   v1.148.0
VictoriaLogs      v1.52.0
VictoriaTraces    v0.9.4
Alertmanager      0.33.1

在线:

VM series                  44,842
label-value pairs          387,724
VMAlert groups             17
live alert/recording       50 / 698
live rule errors           0
live current alerts        0
application SLI series     0

PostgreSQL:

primary                 pg-test-1
async replicas          2
observed WAL gap        0 / 0 bytes
pg_stat_statements rows 194
pgss calls              122,303
query text exported     false
archive failed_count    21
last success after fail true

演练:

18 recording rules       pass
13 alert rules           pass
8 routes                 pass
5 inhibition cases       pass
25 counterexamples       rejected
remote cleanup           pass
secret scan              pass

结论:

live chapter deployment  false
real receiver            false
production_ch25_gate     pending

evidence

private bundle:

observability-evidence.json
isolated-exercise.txt
validation-report.json
negative-report.json
review.txt

全部:

directory 0700
files     0600

公共摘要只包含 allowlist,不包含 source body、SQL text、log body 或 receiver config。

重验

export PG36_EVIDENCE_DIR=/absolute/private/existing/ch25-run
static/labs/ch25/task.sh verify
static/labs/ch25/task.sh review

如果 source file 在 capture 后改变,hash 校验失败。必须新跑,而不是手工改 report。

没有 reset action

本章:

  • 在线没有 mutation;
  • remote temp 自动清理;
  • isolated process 退出;
  • 没有 database fixture;
  • 没有 live rule。

所以没有数据库 reset。不要添加一个“为了形式完整”的 destructive reset。

25.7.3 输出告警覆盖表、盲区与 ch31 的诊断入口

14 行覆盖矩阵

coverage-matrix.json 包含:

5 user/control signals
6 PostgreSQL/platform observation areas
logs
traces
routing

每行:

question
source
live expected/status
rule status
fallback
owner
blind spot

五个应用缺口

coverage live fallback
availability event absent independent place-order probe
latency histogram absent end-to-end duration probe
commit freshness absent unique-token probe
correctness reconciliation absent signed reconciliation
restore evidence age absent immutable restore manifest

它们是 expected-gap,因为教学沙箱没有对应应用。

不得由:

pg_up
HAProxy up
pg_lag
backup success
zero DB errors

代填。

已有 PostgreSQL/platform 覆盖

exporter reachability
activity/transaction/wait/lock
I/O/WAL/checkpoint/archive/replication
maintenance/freeze/object
pg_stat_statements aggregate/reset
VMAlert/Alertmanager self monitoring
VictoriaLogs endpoint
VictoriaTraces endpoint

“已有”仍带盲区:

  • one sample misses transient wait;
  • PG I/O 不区分 OS cache/device;
  • dead tuple 不是 exact bloat;
  • queryid collision;
  • endpoint 不证明 body/parse;
  • backend 不证明 application spans;
  • notification failure 0 不证明 receipt。

配置观察到的待评审项

auto_explain

log_analyze   on
log_timing    on
sample_rate   1
threshold     1s
nested        on
parameter max -1

生产前:

  • 代表性 load test;
  • short statement overhead;
  • log volume;
  • parameter leakage;
  • log_timing=off 方案;
  • sampling;
  • rollback;
  • owner approval。

本章不直接说“必须关闭”,也不说“当前安全”。

log 0640

可能为 Vector collector group 提供 read。生产前:

  • actual owner/group;
  • group membership;
  • directory mode;
  • rotation;
  • central storage ACL;
  • retention;
  • redaction;
  • access audit。

alert coverage 不等于 implementation

七条 accepted alert 有规则,但 app source 不存在:

policy coverage   yes
synthetic test    yes
live data         no
live deploy       no
real route        no

因此不能说“availability monitoring complete”。

proposed queue

需要治理拍板:

  1. latency page route/severity;
  2. restore evidence stale 的 change gate;
  3. archive risk 的 threshold 与 pgBackRest 证据;
  4. long transaction workload class;
  5. freeze horizon based on config/rate;
  6. expected traffic/missing source;
  7. real receipt canary。

每项完成后:

  • 更新 ch24 governance;
  • hash binding 失效;
  • 重跑 ch25;
  • canary deploy;
  • 验证 route/receipt;
  • 保留 rollback。

生产上线前的门禁

service instrumentation
  event schema/version/cardinality

source validation
  producer/zero/missing/reset

rule validation
  synthetic + shadow/live query

governance
  owner/runbook/first action/severity

routing
  real receiver secret outside Git

delivery
  receipt canary

cost
  exporter/query/storage/log overhead

privacy
  labels/body/parameters/retention/access

deployment
  staged canary, rollback, change event

operations
  drill and evidence

完成之前:

production_ch25_gate=pending

ch31 诊断入口

本章输出按 symptom 路由给第 31 章:

慢查询

user latency windows
entry/pool wait
activity wait
pg_stat_statements reset + top queryid
I/O/temp/WAL
release/plan event
host evidence

锁与死锁

user outcome
current blocker graph
lock wait/deadlock log aggregate
transaction age/xmin
application owner
safe cancellation boundary

连接与池

edge queue
HAProxy/PgBouncer client/server/wait
PG active/idle/idle-in-xact
connection config change
max connection headroom

复制与新鲜度

commit token probe
read path
Patroni role/timeline
sender/receiver position
slot/WAL retention
route change

磁盘与容量

filesystem horizon
PG I/O bytes/time
device latency/queue
WAL/archive
object growth
rewrite/backup headroom

vacuum 与冻结

old xact/xmin
table estimates
progress
freeze age/config/rate
slot/feedback
host I/O

诊断入口的统一字段

run id
captured at
service/environment
target identity
symptom windows/count
topology
changes
PostgreSQL reset and aggregates
platform freshness
known gaps
hypotheses/falsifiers
risk/authority

第 31 章不需要从“打开所有 dashboard”重新开始。

完成标准

本章不是以“31 条规则存在”完成,而是:

semantics are explicit
accepted and proposed are separated
live and isolated are separated
missing is not healthy
rules are tested
routes are tested without delivery
inhibition has negative tests
native PG evidence is bounded
privacy and cost are explicit
blind spots are published
production gate remains honest

本节验收

你应当能复述:

  1. 18 条 recording rule 分哪三组;
  2. 13 条 alert 中哪七条 accepted、哪六条 proposed;
  3. archive rule 为什么同时看增量与 last success;
  4. target identity 为什么需要 ProxyJump 后三源校验;
  5. capture 与 exercise 的风险/权限边界;
  6. synthetic time series 如何验证 pending/firing/recovery;
  7. route test 为什么使用空 receiver;
  8. inhibition 为什么必须有 must-not-inhibit;
  9. 25 个反例覆盖哪些失败模式;
  10. private evidence 与 public summary 如何分离;
  11. 五个 application gap 为什么不能用 PG component metric 替代;
  12. auto_explain0640 为什么是待评审而非自动修复;
  13. 生产门禁还缺哪些步骤;
  14. 第 31 章如何使用本章诊断包。

上一节:从告警到诊断包 · 返回本章目录 · 下一章:胸有成竹:容量规划与压测基线 · 查看全书目录 · 查看索引中心

26 胸有成竹:容量规划与压测基线

一张写着“12 万 TPS”的截图,没有回答容量问题。

它可能来自:

select-only
  + 全部命中缓存
  + fsync 关闭
  + 与 server 同机的 load generator
  + 一次 10 秒运行
  + 没有约束、索引维护、WAL、复制、备份和故障余量

也可能来自一个完全严谨的实验。数字本身无法告诉你是哪一种。

容量规划真正需要的是一条可审计推理链:

业务到达过程
  -> operation mix 与数据形状
      -> 延迟、错误和正确性目标
          -> 可复现实验
              -> throughput / latency / failure distribution
                  -> CPU / memory / I/O / WAL / lock evidence
                      -> 单位业务资源需求
                          -> 增长、保留、维护和故障模型
                              -> 扩容触发线与提前期

本章把 PostgreSQL 的原生证据与 Pigsty 的观察面放到同一条链上。目标不是教你 “跑一个 pgbench 命令”,而是让你能判断:

  • workload 是否代表业务;
  • 实验是否控制了足够多的变量;
  • load generator、连接路径或缓存是否在替 server 背锅;
  • throughput 上升是否以 tail latency、错误或排队为代价;
  • 一次测量能支持什么结论,不能支持什么结论;
  • 怎样把测量变成 CPU、WAL、存储和提前期模型;
  • 为什么生产容量仍需要故障、维护、备份和 open-loop 场景。

本章实验先给出一个反直觉结论

第 26 章参考运行不是“性能榜单”,而是一组教学用、有界的证据链:

  • Pigsty v4.5.0 教学沙箱;
  • PostgreSQL 18.6
  • 独立 pg-meta-1 load generator:2 vCPU、约 3.8 GiB RAM;
  • pg-test-1 primary:1 vCPU、约 1.9 GiB RAM;
  • shared_buffers:512,753,664 bytes;
  • 50% product read、30% order read、20% place-order;
  • S/M/L 三档数据,1/8 两档 client;
  • 每个 cell 五次重复,共 30 次 measured run;
  • 511,709 笔事务,失败、skipped、deadlock 和超过 250 ms 的事务均为零;
  • raw transaction、OS、SQL snapshot 与 wait evidence 全部留在私密 evidence;
  • 专用 database、role 和远端临时目录全部清理。

聚合结果:

cell schema size clients median TPS pooled p95 server work client work
S-c1 28.3 MiB 1 1,508.5 2.152 ms 48.9% 17.7%
S-c8 28.3 MiB 8 2,920.5 9.448 ms 84.2% 29.5%
M-c1 224.3 MiB 1 1,555.8 2.123 ms 50.1% 17.8%
M-c8 224.3 MiB 8 2,911.5 9.398 ms 84.0% 29.1%
L-c1 898.6 MiB 1 1,423.6 2.259 ms 46.3% 16.2%
L-c8 898.6 MiB 8 2,774.3 10.087 ms 84.2% 27.8%

从 c1 到 c8:

throughput gain       1.87x ~ 1.95x
pooled p95 multiplier 4.39x ~ 4.47x

这说明八个 client 获得了更多吞吐,却付出了约四倍多的 p95。它没有说明:

  • knee 就在八个 client;
  • 2,774 TPS 是 L 档生产容量;
  • 65% CPU 对应的线性投影可以直接采购;
  • 0 deadlock 代表业务没有锁风险;
  • Pigsty 全窗口 median 就是某个 cell 的资源消耗;
  • 虚拟磁盘代表生产存储。

两点只能 bracket,不能定位 knee;closed-loop 只能观察固定 client population, 不能重现上游 offered load;8 秒运行也远短于生产基线所需的时间。PostgreSQL 官方 pgbench good practices 明确警告,不应相信只跑几秒的测试,可靠数字可能需要几分钟、几次乃至数小时。 因此本章参考 run 用来证明实验管线与推理方法,不批准生产数字。

公共 allowlist 结果见 capacity-run.json,完整边界见 lab-contract.md

本章学习成果

完成本章后,你应该能独立完成:

  1. 把业务 forecast 转成 operation-class arrival model,而不是只写“读写比 8:2”;
  2. 区分 request、SQL statement、database transaction、connection 与并发;
  3. 用 Little’s Law 检查到达率、响应时间和在途量是否自洽;
  4. 选择内置 pgbench 作为 engine calibration,或写业务自定义脚本;
  5. 声明 key distribution、mix、think time、arrival model、protocol 和连接路径;
  6. 固定版本、配置、数据、随机 seed、预热、顺序和重复;
  7. 正确处理 pooled percentile、run-level estimate、置信区间与异常样本;
  8. pg_stat_databasepg_stat_iopg_stat_wal、wait event 和 OS 证据解释曲线;
  9. 判断 load generator 是否成为瓶颈;
  10. 把 CPU seconds/transaction、WAL bytes/transaction 和 bytes/order 写进容量模型;
  11. 计算增长、保留、maintenance workspace、failure headroom 与 lead time;
  12. 明确保留 unknown,并拒绝把 sandbox 结果升级为生产承诺。

本章目录

26.1 从需求建立容量模型

26.2 设计代表性工作负载

26.3 建立可信实验

26.4 找到饱和点与瓶颈

26.5 从测量推导容量与成本

26.6 实战:pg36_shop 容量基线

阅读与实践路线

如果你负责应用:

26.1 -> 26.2 -> 26.3 -> 26.6

重点是 operation contract、arrival model、idempotency/retry、connection path 和 load generator。

如果你负责平台:

26.1 -> 26.3 -> 26.4 -> 26.5 -> 26.6

重点是实验控制、native counters、Pigsty time series、headroom、failure model 和 provisioning lead time。

两条路线最终必须合流。平台无法从数据库指标猜出业务 mix;应用也不能从一张 TPS 表判断 WAL、backup、replica 和 maintenance 是否还有余量。

实验文件

static/labs/ch26/
├── requirements.json
├── workload-contract.json
├── experiment-matrix.json
├── capacity-model.json
├── negative-cases.json
├── topology.mmd
├── lab-contract.md
├── setup.sql
├── reset-cell.sql
├── read-product.sql
├── read-order.sql
├── place-order.sql
├── stat-snapshot.sql
├── wait-sampler.sql
├── system_sampler.py
├── capture.py
├── exercise.py
├── remote_benchmark.py
├── validate.py
├── review.py
├── task.sh
└── capacity-run.json

它们分别固定:

文件 责任
requirements target、风险、支持/不支持的 claim、gate
workload contract mix、分布、arrival、protocol、seed、cache policy
matrix 三规模、两并发、五重复与 counterbalanced order
model 业务输入、单位需求、空间与 lead-time 方程
SQL / pgbench scripts synthetic schema、reset 与三类事务
samplers OS measured window、PostgreSQL wait 与 counter snapshot
capture L0 目标、上游与 clean-start gate
exercise 远端隔离、完整矩阵与精确清理
validate / review 正向合同、26 个反例、hash、mode、secret 与 claim
public run 聚合 allowlist;不含 raw evidence

参考资料


上一章:望闻问切:监控体系与可观测诊断 · 返回下卷导读 · 下一章:精益求精:参数调优与资源治理 · 查看全书目录 · 查看索引中心

26.1 从需求建立容量模型

容量规划的第一份输入不是 CPU 核数,而是业务在什么时间、以什么方式要求系统 完成什么工作。

一句“峰值 5,000 QPS,读写比 8:2”至少缺少:

  • QPS 是 HTTP request、SQL statement 还是 transaction;
  • 读是 point lookup、范围扫描、聚合还是 cache miss 后回源;
  • 写是单行更新、订单事务、批量导入还是索引构建;
  • 一次 request 打开几个 transaction、执行几条 SQL;
  • key 是均匀分布还是集中在少数 tenant/product;
  • peak 持续 10 秒、10 分钟还是 10 小时;
  • 失败后谁重试、最多几次、是否产生重复写;
  • batch、backup、vacuum、DDL 和 failover 是否同时发生;
  • 延迟、正确性、恢复和成本目标是什么。

所以容量模型从一份 workload contract 开始:

operation
  arrival process
  transaction boundary
  SQL and data shape
  consistency and durability
  retry and timeout
  latency/error/correctness objective
  growth and retention
  overlap and failure state

第 24 章把服务承诺写成 SLO;第 25 章把承诺落实为观察合同;本节把同一套语义 变成容量输入。三章必须使用同一个 operation name 和 eligible event 定义。

26.1.1 事务类型、读写比、并发和数据增长

先分清五个容易混用的量

符号 例子 不能替代
到达率 $\lambda$ 2,000 eligible order request/s database TPS
在途量 $L$ 300 个尚未完成的 request connection count
响应时间 $W$ p95 120 ms SQL execution time
database transaction rate $X$ 2,400 commit/s request rate
statement rate $Q$ 18,000 statement/s transaction rate

一次 place-order 可能:

HTTP request
  -> auth/cache reads
  -> BEGIN
      SELECT product
      UPDATE inventory
      INSERT order
      INSERT order_item
     COMMIT
  -> publish outbox later

于是:

1 request
!= 1 SQL
may equal 1 or more database transactions
may cause background transactions after response

如果把 5,000 HTTP RPS 直接填成 pgbench --rate=5000,脚本却只执行一条 SELECT 1,得到的不是业务容量,只是另一种 workload 的数字。

用 operation class,而不是“读/写”两个桶

一个最小 operation catalog:

operation request/s DB tx/request statements/tx data shape consistency
read-product 4,000 1.0 1–2 Zipf point read current primary
read-order 1,500 1.0 1 customer recent-N 5 s freshness
place-order 800 1.0 4–8 hot inventory + append durable commit
reconcile 20 1.0 range + aggregate time window correctness control
expire-order 100 1.0 scan + update batch eventual

同为“读”:

indexed point lookup
index-only recent-N
wide range scan
hash aggregate spilling to temp
JSON path evaluation
vector nearest-neighbor

CPU、buffer、I/O、work_mem、parallel worker 和 lock footprint 完全不同。

同为“写”:

HOT-eligible update
indexed-column update
append-only insert
upsert on a hot unique key
multi-table transaction with foreign keys
bulk COPY

WAL、index maintenance、dead tuple、checkpoint、replication 和 vacuum 成本也不 一样。

给每种 operation 建资源需求向量

对 operation $i$,定义:

$$ \mathbf{D_i}

(D_{cpu}, D_{read}, D_{write}, D_{wal}, D_{lock}, D_{temp}, D_{net}) $$

混合 workload 的单位需求不是简单“平均 SQL”:

$$ \mathbf{D_{mix}}

\sum_i w_i \mathbf{D_i} $$

其中 $w_i$ 是 transaction mix 权重。若流量增长只发生在 place-order,不能继续使用旧的全局 $D_{mix}$:

$$ \text{resource rate}

\sum_i \lambda_i \mathbf{D_i} $$

因此容量报告至少同时保留:

  • operation-class rate;
  • mix;
  • 每类 service demand;
  • mixed demand;
  • 预测期内 mix 是否变化。

读写比为什么经常误导

“80% 读、20% 写”没有说明计数单位:

by request?
by transaction?
by statement?
by tuple?
by byte?
by CPU second?
by WAL byte?

参考实验按 pgbench transaction 选择脚本:

read-product 50
read-order   30
place-order  20

这是 transaction selection weight。它不保证:

  • 80% CPU 消耗来自读;
  • 20% statement 是写;
  • 20% WAL-producing operation;
  • 20% wall time 在写事务;
  • 每次运行精确出现 50/30/20。

raw transaction log 必须核对实际 script count。

用 Little’s Law 做第一轮自洽检查

稳定系统中,平均在途量满足:

L=λW L = \lambda W

若:

arrival rate   2,000 request/s
mean latency   80 ms = 0.08 s

则平均在途 request:

L=2000×0.08=160 L = 2000 \times 0.08 = 160

这不是说“必须给 PostgreSQL 160 个连接”。应用中的在途量可能分布在:

edge queue
application worker
pool wait
database execution
external call
response serialization

如果 transaction 只占 15 ms:

Ldb=2000×0.015=30 L_{db} = 2000 \times 0.015 = 30

一个 30 左右的 active DB concurrency 可能足够;160 个 backend 反而增加上下文 切换和内存风险。

Little’s Law 是平均量关系,不描述 tail,也不自动证明系统稳定。若 offered load 超过 service capacity,queue 持续增长,就不存在可长期使用的稳态平均。

并发不是连接数

把连接状态拆开:

状态 是否占 client connection 是否占 server backend 是否消耗执行资源
application queue 否/可能 应用资源
PgBouncer waiting client pool queue
server connection idle backend memory,少量管理成本
active on CPU CPU
active waiting lock/I/O queue + held resources
idle in transaction snapshot/lock/vacuum horizon 风险

容量模型关心 active concurrency、queue、transaction duration 和 backend footprint,而不是只看 max_connections

数据增长必须进入 workload

数据量改变:

  • B-tree 高度和 cache working set;
  • statistics 与 selectivity;
  • index/table correlation;
  • vacuum 和 analyze 时间;
  • checkpoint、base backup、restore、upgrade 时间;
  • retained WAL、replication catch-up 和 archive volume;
  • partition 数量、catalog 开销与 planning time;
  • maintenance workspace。

至少分开:

logical rows/day
logical bytes/day
table bytes/day
index bytes/day
TOAST bytes/day
WAL bytes/day
archive bytes/day
backup repository growth/day

参考实验三档 shopbench schema:

scale history rows schema bytes
S 100,000 29,704,192
M 800,000 235,175,936
L 3,200,000 942,268,416

L 已大于 512,753,664-byte shared_buffers,但小于 server RAM。它能观察 PostgreSQL buffer miss,却不能区分 page 是否真正来自物理磁盘还是 OS page cache。PostgreSQL 的 pg_stat_io 文档 也明确指出,数据库 I/O 统计无法区分内核调用最终命中 page cache 还是访问 storage,必须结合 OS 证据。

本目产物:业务容量输入表

forecast_window: 12 months
operations:
  place-order:
    eligible_peak_tps: 800
    peak_duration: 20m
    db_transactions_per_request: 1
    retry_amplification_p95: 1.04
    data_growth:
      orders_per_success: 1
      line_items_per_order_p95: 6
    objective:
      p95_ms: 250
      error_ratio: 0.001
      correctness: no unexplained duplicate

不要在这个阶段填“8 vCPU”。先把问题写完整。

26.1.2 平均值、峰值、突发与批处理叠加

日平均掩盖容量风险

一天 86,400 秒。日订单 8,640,000:

daily average=100/s \text{daily average} = 100/s

但若 40% 发生在两小时促销窗:

$$ \text{promotion average}

\frac{8{,}640{,}000 \times 0.4}{7200} = 480/s $$

再叠加一分钟抢购峰值、支付回调、重试和 batch,瞬时 offered load 可能超过 1,000/s。用 100/s 采购必然低估。

至少保留四种时间尺度

时间尺度 回答问题 常见误用
1–10 s 突发、queue、admission scrape 太慢完全看不见
1–5 min 用户影响、autoscaling 响应 被小时均值摊平
1 h 班次、batch、业务时段 当成 peak
day/week/season 容量增长、节日、结算 无法解释短时 saturation

一个 forecast 需要:

baseline
peak factor
burst factor and duration
seasonality
growth trend
event calendar
retry amplification
batch overlap
maintenance overlap

区分 arrival burst 与 backlog drain

两种看起来都像“TPS 突然上升”:

new user demand
  arrival rises

backlog drain
  arrival may have fallen
  workers consume queued work faster

后者常在依赖恢复后发生:

payment provider recovers
  -> retries released
  -> queue drain
  -> database write burst
  -> replicas/archive/backup lag

如果模型只用前台 request,遗漏 backlog,恢复本身就可能触发第二次事故。

重试是负载放大器

每个原始 operation 平均尝试次数:

$$ A

1 + r_1 + r_2 + \cdots $$

更实用地:

$$ \lambda_{\text{database}}

\lambda_{\text{eligible}} \times \text{attempts per eligible operation} $$

例如:

eligible arrival          1,000/s
5% requests retry once
1% requests retry twice

平均尝试:

1+0.05+2×0.01=1.07 1 + 0.05 + 2 \times 0.01 = 1.07

数据库看到约 1,070 attempt/s。若超时发生在 commit outcome unknown 区域,盲目 重试还可能制造 duplicate 与 reconciliation workload;不能只算 TPS,不算正确性。

batch 不能用“夜间”一笔带过

列出每个 batch:

job schedule duration read/write temp WAL lock retry
settlement 00:05 18 min heavy read/update possible high row yes
expire-order every 5 min 40 s scan/update low medium row yes
analytics extract 01:00 45 min range read spill risk low AccessShare restart
backup 02:00 60 min storage/network n/a archive coupling n/a resume

“平均业务低谷”不代表有余量。batch、autovacuum、checkpoint、archive、 replication catch-up 和 backup 可能恰好在低谷争用 I/O。

把 overlap 写成场景

N0 normal peak
  interactive peak + routine background

N1 campaign peak
  interactive 2.5x + retries 1.1x

M1 maintenance overlap
  normal peak + vacuum/index build/backup

F1 one replica unavailable
  normal peak + catch-up/rebuild or read traffic reroute

F2 primary failover
  reconnect storm + retry burst + cold-ish cache + reduced topology

容量批准必须说明要满足哪个场景。只在 N0 通过,不等于生产通过。

平均、quantile 与最大值各有用途

mean
  resource accounting, Little's Law, long-run throughput

p50
  typical transaction

p95/p99
  user tail and queue onset

max
  evidence lead, but sample-size sensitive

不能把每分钟 p99 再做平均并称为“全天 p99”。若需要跨 window 或 instance 聚合,保留原始 transaction sample 或可聚合 histogram。Prometheus 的 histogram 指南 解释了为什么直接平均预计算 quantile 在统计上没有意义。

26.1.3 延迟目标、错误预算与安全余量

容量是带约束的可行域

把 capacity 定义为:

maxλsubject to{P(WT)Serror ratioEcorrectness invariant holdsresource and recovery limits hold \max \lambda \quad \text{subject to} \quad \begin{cases} P(W \le T) \ge S \\ \text{error ratio} \le E \\ \text{correctness invariant holds} \\ \text{resource and recovery limits hold} \end{cases}

例如:

place-order p95 <= 250 ms
availability >= 99.9%
no unexplained duplicate order
replica/archive remain within declared recovery bounds
primary disk < 70%
one declared failure still has headroom

没有约束的“最大 TPS”只是最大努力点。

延迟目标要定义 measurement boundary

这些延迟不相等:

client schedule -> response
application admission -> response
pool checkout -> release
transaction BEGIN -> COMMIT response
one SQL execute
server execution excluding network

参考实验的 latency:

origin     pgbench client
boundary   one chosen script
protocol   prepared
connection persistent
path       pg-meta-1 -> pg-test-1:5432

它不包含 application edge、HAProxy、PgBouncer 和 WAN,所以不能与第 24 章 place-order 用户 SLO 直接画等号。

错误不能从分母消失

报告至少包含:

attempted
processed successful
failed
retried transactions
total retries
late
skipped
client aborted
server disconnect

若只用:

$$ \text{TPS}

\frac{\text{successful}}{\text{elapsed}} $$

系统可以通过拒绝慢请求让成功样本看起来更快。failed、skipped 和 admission rejection 必须与 latency 并列。

参考实验固定 --max-tries=1,不让 pgbench 把 serialization/deadlock retry 隐藏到成功事务里;250 ms 只计 late。正式运行:

failed       0
skipped      0
late         0
deadlock     0

这只适用于 511,709 个合成事务。零次观察不是“真实概率为零”的证明。

错误预算不是容量余量

SLO 允许 0.1% bad event,不代表正常运行可以把资源推到 99.9%:

resource headroom
  absorbs burst, forecast error, maintenance and failure

error budget
  governs reliability trade-offs and release policy

若平时已经在 knee 右侧,任何轻微 burst 都会使 queue 与 tail 非线性上升,错误 预算会被快速消耗。

拆分安全余量

不要只写“预留 30%”。说明它覆盖什么:

headroom 覆盖
statistical workload 与测量波动
forecast 增长和 mix 误差
burst 短时 arrival
maintenance vacuum、backup、DDL、reindex
failure 节点/副本/路径损失
operational 扩容、验证、回滚提前期

这些余量不能总是简单相加,也不能互相冒充。failure headroom 可能要求一整台 node,而不是 10% CPU。

target utilization 是政策,不是自然常数

参考模型使用 65% CPU 作教学投影点

λ650.65Dcpu \lambda_{65} \approx \frac{0.65}{D_{cpu}}

得到 S/M/L 约 2,237 / 2,223 / 2,117 TPS。但这是假设:

  • mix 不变;
  • CPU demand 近似线性;
  • cache、I/O、lock、client 和 background 不先成为瓶颈;
  • 仍是同一个 sandbox。

所以公共结果将字段命名为 tps_at_65_percent_cpu_if_linear,并把 production_sustainable_tps 保持为 null

生产 target utilization 应由:

failure model
burst duration
autoscaling/provisioning time
workload convexity
cost objective
operational experience

共同决定,而不是复制 65%。

本节验收:一页容量问题陈述

在开始压测前,评审以下问题:

  • operation catalog 有稳定名称与 owner;
  • request/transaction/statement 的换算明确;
  • arrival、peak、burst、seasonality 和 retry 已量化;
  • data size、growth、retention 与 access skew 已量化;
  • batch、maintenance 和 failure overlap 已列场景;
  • latency measurement boundary 与 percentile 明确;
  • error、late、skip、retry 和 correctness 都在验收条件;
  • headroom 分解,而不是一个来历不明的百分比;
  • production gate 的未知项仍然可见。

如果这页写不出来,更多 pgbench client 只会更快地产生无意义数字。


返回本章目录 · 下一节:设计代表性工作负载 · 查看全书目录 · 查看索引中心

26.2 设计代表性工作负载

benchmark 首先是一个模型,然后才是一条命令。

业务现实
  -> workload model
      -> executable scripts
          -> measurements
              -> claims

模型遗漏 hot key,脚本跑得再稳定也只能稳定地回答错误问题;连接路径从 PgBouncer 换成 direct primary,数字仍然精确,但已经是另一个实验。

本节用 pgbench 作为 workload driver。它的优点是与 PostgreSQL 同源、部署 简单、支持自定义事务、权重、随机分布、rate、transaction log 和失败统计。 它不是应用模拟器,也不会自动知道你的业务语义。

26.2.1 内置 pgbench 与业务自定义脚本

内置场景适合 calibration,不是业务证明

PostgreSQL 18 提供:

pgbench --builtin=list
tpcb-like
simple-update
select-only

用途:

built-in 适合回答 不适合直接回答
select-only 简单 PK read 与 client/path sanity 业务 read latency
simple-update 一种 update/WAL/commit calibration 订单写容量
tpcb-like 固定 schema 的混合 engine baseline TPC-B 认证或通用 TPS

名字明确写着 TPC-B (sort of)。它不是经过 TPC 审计的 TPC-B 结果。

内置 tpcb-like 有少量 branch/teller 热行。若 scale 小于 client 数,结果会 主要测量这些行的 contention。PostgreSQL pgbench good practices 明确要求默认场景的 scale 至少不小于最大 client,并提醒 dead tuple、vacuum 时机和 client bottleneck 会改变结果。

所以内置场景的正确位置是:

hardware/config calibration
version regression comparison
toolchain smoke test
rough bottleneck reproduction

不是:

we ran tpcb-like
therefore pg36_shop supports N orders/s

自定义脚本先定义 transaction boundary

一个 pgbench script 被调度一次,pgbench 就把它计作一个“transaction”,但 脚本未必真的包含一个 SQL transaction:

-- one pgbench transaction, one autocommit statement
SELECT ...;
-- one pgbench transaction, one explicit DB transaction
BEGIN;
UPDATE ...;
INSERT ...;
COMMIT;
-- dangerous semantic mismatch:
-- one pgbench transaction contains two committed DB transactions
BEGIN;
INSERT ...;
COMMIT;
BEGIN;
UPDATE ...;
COMMIT;

最后一种会让 retry 与业务原子性变得难以解释。PostgreSQL 文档也提醒,若脚本 包含多个 transaction,serialization/deadlock retry 会重放整个脚本,已经成功 提交的 transaction 可能再次执行。

本章 place-order.sql 保持“一次 script = 一个显式 DB transaction”:

\set customer_id random(1, :customer_count)
\set product_id random_zipfian(1, :product_count, 1.10)
\set quantity random(1, 3)

BEGIN;

SELECT price_cents
FROM shopbench.product
WHERE product_id = :product_id
\gset

UPDATE shopbench.inventory
SET quantity = quantity - :quantity,
    updated_at = clock_timestamp()
WHERE product_id = :product_id
  AND quantity >= :quantity;

-- append one order with a unique synthetic request_ref
INSERT ...;

COMMIT;

它保留:

  • product read;
  • hot-ish inventory row lock;
  • durable heap/index insert;
  • foreign key 与 unique index;
  • commit、WAL、replication 与 archive 成本。

它省略:

  • application authorization;
  • network calls;
  • payment provider;
  • outbox consumer;
  • 多 line item;
  • idempotency reconciliation;
  • HAProxy/PgBouncer;
  • real production data distribution。

这些 omission 必须进入报告,不能藏在脚本外面。

用权重组合 operation

pgbench 支持:

--file=read-product.sql@50
--file=read-order.sql@30
--file=place-order.sql@20

每次选择 script 的概率按相对整数权重决定。权重不必和为 100,但和为 100 更 容易审查。

正式报告仍应读取 transaction log:

script_no -> operation
0         -> read-product
1         -> read-order
2         -> place-order

检查实际 count。随机选择不会保证每个短 run 精确等于 50/30/20。

prepared、extended 与 simple 不是可随意切换的“优化”

pgbench -M

mode 行为 适合
simple simple query protocol 模拟文本批次或 baseline
extended parse/bind/execute 参数协议
prepared 第二次起复用 parse prepared workload

参考 run 固定:

protocol = prepared

它与应用是否使用 prepared statement 必须一致。改变 mode 会改变:

  • parse/plan CPU;
  • network round trip;
  • parameter typing;
  • generic/custom plan 行为;
  • PgBouncer transaction pooling 兼容边界;
  • pg_stat_statements shape。

参考脚本曾在真实预热中暴露:

operator is not unique: unknown * unknown

原因是 prepared placeholder 的两个 operand 都是 unknown。修复不是切回 simple, 而是明确:

(:price_cents)::integer * (:quantity)::integer

这类问题说明 smoke/warm-up 必须使用正式 protocol。

内置与自定义可以组成两层基线

推荐:

Layer A engine calibration
  same PostgreSQL version/config/hardware
  built-in select/update

Layer B service workload
  custom schema, constraints, mix and distribution

Layer A 漂移、Layer B 也漂移:

可能是 engine/hardware/config change

Layer A 稳定、Layer B 漂移:

优先检查 schema/data/mix/plan/application path

这比只有一条“总 TPS”更容易定位回归。

初始化也属于合同

内置 pgbench -i 会创建并可能销毁标准表。官方文档明确警告,初始化会删除 同名表,应使用独立数据库。业务脚本也必须同样谨慎。

本章:

database pg36_capacity
role     dbuser_pg36bench
marker   exact shared-object comments
schema   shopbench
data     synthetic

existing database/role 一律拒绝覆盖;完整 evidence 后只删除 marker 精确匹配且 无其他 session 的 fixture。

26.2.2 参数分布、事务混合和数据倾斜

uniform 往往是最不真实的默认

均匀分布:

P(X=k)=1N P(X=k)=\frac{1}{N}

意味着每个 customer/product 被访问的概率相同。真实业务常见:

few popular products
large tenants
new orders read more often
recent time windows
one campaign SKU
one settlement account

它们改变 cache locality 和 contention。

pgbench 提供:

random(lb, ub)
random_exponential(lb, ub, parameter)
random_gaussian(lb, ub, parameter)
random_zipfian(lb, ub, parameter)

参考合同:

operation key distribution
read-product product Zipf 1.15
read-order customer Zipf 1.08
place-order customer uniform
place-order product Zipf 1.10

这让少数 inventory row 更热,但不是从 production trace 拟合的分布。参数是教学 假设。

skew 同时可能更快和更慢

更多 hot key:

cache hit rises
  -> reads may become faster

same rows updated
  -> lock queue and cache-line contention rise

所以“Zipf 比 uniform 更真实”仍然不完整。要同时验证:

  • read locality;
  • update collision;
  • tenant fairness;
  • hot partition/page;
  • index leaf split;
  • per-key rate cap。

保留变量之间的相关性

独立随机:

customer=random(...)
product=random(...)
region=random(...)

会生成现实中不存在的组合。真实 workload 可能:

tenant -> region
region -> product catalog
customer -> order history
campaign -> product set
time -> status distribution

相关性会影响:

  • multi-column statistics;
  • join cardinality;
  • partition pruning;
  • index selectivity;
  • row-level security;
  • cache sharing。

高质量 fixture 应从脱敏 trace/分布参数生成,而不是把每列独立 random()

数据形状不仅是 row count

相同 1 亿行:

形状 影响
narrow fixed-width cache density 高
wide JSON/TOAST decompression、I/O、CPU
many NULL index/tuple size 与 selectivity
monotonically increasing key rightmost index page 热点
random UUID locality 与 page split
high update churn dead tuple、vacuum、bloat
many partitions planning/catalog

记录:

row width distribution
TOAST ratio
index count and width
key correlation
live/dead tuple
bloat state
statistics target and analyze time

只写 scale factor 不够。

transaction mix 应来自同一测量边界

错误组合:

reads from HTTP logs
writes from pg_stat_database
batch from scheduler estimates

分母不同,无法相加。

更可靠:

application operation counter
  -> eligible attempt
  -> mapped DB transaction class
  -> trace/queryid corroboration

第 25 章 signal contract 尚未提供真实 pg36_shop application SLI,所以第 26 章的 50/30/20 是显式 synthetic assumption,而不是从在线业务观测得出的事实。

写操作要保留写放大

一个 place-order 不只增加一行:

heap tuple
primary-key index
customer recent-order index
unique request_ref index
foreign-key lookup
inventory heap update
inventory index/heap visibility effects
WAL and possible full-page image
replica replay
archive and backup repository
future vacuum

若 benchmark 去掉约束与索引,TPS 更高,但它测的是另一个数据模型。

failed branch 也要模拟

业务中存在:

insufficient inventory
duplicate idempotency key
invalid transition
serialization failure
lock timeout
statement timeout

参考脚本为保证 30-run pipeline 稳定,把 inventory 初始化为很大的值,未覆盖 sold-out branch。报告把它列为 unknown。生产 workload 应显式给每个 outcome 权重,并决定:

  • 是否计入 eligible 分母;
  • 是否 rollback;
  • 是否 retry;
  • 响应 latency;
  • 是否产生 WAL/log。

seed 只固定随机序列

参考公式:

$$ \text{seed}

2026072900 +100 \times scale +10 \times clients +repetition $$

固定 seed 能帮助重放 script selection 和 key sequence,但不能固定:

  • process scheduling;
  • checkpoint/autovacuum;
  • page cache;
  • network timing;
  • replica/archive activity;
  • virtual-machine neighbor;
  • query plan 受 statistics 漂移;
  • concurrent transaction interleaving。

“同 seed”不是 bit-for-bit performance reproducibility。

26.2.3 think time、连接方式和客户端瓶颈

closed-loop 与 open-loop 回答不同问题

默认 pgbench:

client starts transaction
  -> waits for completion
      -> immediately starts next

这是 closed-loop。对 $C$ 个 client:

XCR+Z X \approx \frac{C}{R+Z}

其中 $R$ 是 response time,$Z$ 是 think time。参考 run:

Z = 0

当 server 变慢,client 发得也慢;offered load 自动下降。这会隐藏真实系统中仍在 到达并排队、超时或放弃的 request。

open-loop:

pgbench --rate=2000 --latency-limit=250 ...

PostgreSQL 18 的 --rate 按 Poisson timeline 调度 transaction;报告的 latency 从 scheduled start 计算,包括 schedule lag。若已经来不及满足 latency limit, transaction 会被记为 skipped。详见 pgbench --rate

生产 SLO envelope 更需要:

offered rate
achieved rate
schedule lag
queue time
execution time
late
skipped
failed

参考实验只做 closed-loop,所以 production_sustainable_tps=null

think time 是 workload,不是装饰

真实用户:

read page
think
click
wait

worker queue:

fetch job
process externally
write result
sleep/poll

如果要模拟 closed user population,加入 \sleep 或外部 pacing;如果要模拟 arrival rate,优先使用 rate schedule。不要一边设 think time,一边把结果称为 “数据库最大 TPS”。

连接模式要单独测试

持久连接:

pgbench -c 8 ...

每个 client 保留 connection。

每事务重连:

pgbench -C ...

测量:

  • TCP/TLS;
  • authentication;
  • backend fork/init;
  • session GUC;
  • extension hooks;
  • connection storm。

它不是正常 pool 模式的替代。

连接路径至少分:

engine baseline
  direct primary

pool baseline
  PgBouncer service

service baseline
  HAProxy + PgBouncer

user baseline
  application edge + service path

将它们混在一条曲线里,回归后无法知道变化发生在哪里。

PgBouncer 不会增加 PostgreSQL CPU

pool 的价值主要是:

limit active server sessions
absorb idle client connections
queue admission
reuse authentication/session setup
reduce reconnect storm

它不能凭空创造 CPU、I/O 或 WAL capacity。若 server 已在资源 knee,扩大 pool 只会让更多 request 同时争抢。

参考 run 刻意绕过 PgBouncer/HAProxy;第 22 章已经定义了连接和路由合同。本章 后续生产 baseline 必须增加 service-path cell,而不是把 direct 数字当服务数字。

load generator 必须有自己的 telemetry

PostgreSQL 官方建议在高并发时把 pgbench 放到另一台机器,必要时使用多个 client host,因为 pgbench 自己可能成为瓶颈。

观察:

client CPU
client run queue
network throughput/retransmit
pgbench jobs
file/logging I/O
schedule lag
multiple generator agreement

参考 run:

server  pg-test-1  1 vCPU
client  pg-meta-1  2 vCPU

c8 cell 的 client work median:

scale client work server work
S 29.5% 84.2%
M 29.1% 84.0%
L 27.8% 84.2%

因此没有证据表明 load generator CPU 是本次 ceiling。仍不能排除:

  • network round trip;
  • pgbench single process coordination;
  • two jobs 的调度;
  • meta host 同时承载监控;
  • virtual hypervisor sharing。

ClientRead 不等于“客户端瓶颈”

PostgreSQL backend 在 ClientRead 时等待 client 发下一条 protocol message。 prepared multi-statement transaction 中,短暂 ClientRead 很常见。它可能表示:

  • client think/pacing;
  • network;
  • client CPU;
  • 正常 statement boundary;
  • application 在 transaction 内做外部工作。

必须结合:

backend state
wait duration
client CPU
network
transaction age
protocol

不能看到 Client wait 占多数就宣布“数据库没问题”。

workload contract 最小模板

id: shop-mix-v1
arrival:
  model: closed-loop
  clients: [1, 8]
  think_time_ms: 0
connection:
  path: direct-primary
  lifetime: persistent
  protocol: prepared
transactions:
  read-product:
    weight: 50
    product_distribution: zipf-1.15
  read-order:
    weight: 30
    customer_distribution: zipf-1.08
  place-order:
    weight: 20
    product_distribution: zipf-1.10
    customer_distribution: uniform
retry:
  max_tries: 1
latency:
  origin: pgbench-client
  limit_ms: 250
omissions:
  - application
  - HAProxy
  - PgBouncer
  - WAN
  - payment

没有这份合同,数字不能离开终端。


上一节:从需求建立容量模型 · 返回本章目录 · 下一节:建立可信实验 · 查看全书目录 · 查看索引中心

26.3 建立可信实验

可信实验不等于“环境完全没有噪声”。更现实的标准是:

问题明确
因素声明
控制可验证
响应可重算
噪声被观察
顺序偏差被限制
失败没有消失
结论不越过证据

性能实验有两类误差:

random error
  run-to-run fluctuation
  -> repetition / interval may reveal

systematic error
  wrong workload, wrong path, client bottleneck, warm-only cache
  -> more repetitions do not repair

跑 1,000 次错误 workload,只会非常精确地回答错误问题。

26.3.1 固定硬件、版本、配置、数据与随机种子

把实验写成一个不可缺字段的 tuple

result =
  f(
    hardware,
    virtualization,
    kernel,
    filesystem/storage,
    PostgreSQL build/version,
    extensions,
    configuration,
    schema,
    data,
    statistics,
    connection path,
    client,
    workload,
    time/background state
  )

少一个字段,结果就多一种解释。

硬件不只记录“8C32G”

至少记录:

CPU model / architecture / socket / core / SMT
frequency policy / steal time / power state
NUMA topology
memory total / bandwidth / swap
storage device / controller / filesystem / mount
IOPS / latency / throughput / queue assumptions
network path / RTT / bandwidth
virtualization / cloud instance / noisy-neighbor boundary

参考 run:

architecture  aarch64
client        pg-meta-1, 2 vCPU, 4,089,262,080 bytes RAM
server        pg-test-1, 1 vCPU, 2,048,679,936 bytes RAM
storage       Vagrant virtual disk on shared laptop hypervisor

所以它不能支持 production IOPS、endurance 或 failure-domain claim。

PostgreSQL provenance

版本至少包括完整 build:

SELECT version();
SHOW server_version_num;

还要:

extension versions
compiler/architecture
block size / WAL segment size
checksums
locale / encoding
huge pages / io_method

参考:

PostgreSQL 18.6 Ubuntu build
server_version_num 180006
block size         8192
data checksums     on

升级 minor version 也应重新跑 regression baseline。minor release 可能修复 planner、 executor、WAL、I/O 或 correctness 问题;“major 没变”不是等价环境。

配置要保存 value、source 与 pending restart

只保存 postgresql.conf 不够:

SELECT
    name,
    setting,
    unit,
    source,
    sourcefile,
    sourceline,
    pending_restart
FROM pg_settings
ORDER BY name;

容量关键项:

shared_buffers
work_mem / hash_mem_multiplier
maintenance_work_mem / autovacuum_work_mem
max_connections
max_worker_processes / parallel workers
effective_cache_size
random_page_cost / effective_io_concurrency
io_method / io_workers
checkpoint_timeout / max_wal_size
wal_compression / full_page_writes
synchronous_commit / fsync
track_io_timing / track_wal_io_timing

effective_cache_size 是 planner estimate,不是分配的 cache。work_mem 是每个 operation 可能使用的基础限制,不是整台 server 的总内存。PostgreSQL resource consumption 提醒,一个复杂 query 可能有多个 sort/hash,多 session 和 parallel worker 会把 总内存放大许多倍。

数据版本必须可证明

记录:

schema migration/version
DDL hash
generator/seed
row counts
relation and index sizes
distribution parameters
statistics/analyze timestamp
live/dead tuple and bloat state
partition set
sequence position

参考 fixture 每个 scale 都重建 immutable table:

customer
product
order_history

每次 measured run 前只重置:

inventory
order_live

这样五次 run 有相同 initial mutable state,同时保留自然 cache 与 background 演化。它不代表 production bloat/churn。

随机 seed 与 run order 都要固定

seed 固定:

  • script choice;
  • random key;
  • Zipf sample;
  • rate schedule(若使用)。

run order 也会影响结果。若总是:

c1 -> c8

c8 总是得到更暖 cache,也总是承受 c1 留下的 dirty page。

参考采用 counterbalanced order:

r1 c1 -> c8
r2 c8 -> c1
r3 c1 -> c8
r4 c8 -> c1
r5 c1 -> c8

scale 仍按 S -> M -> L,因为重建大数据代价高。这是保留的 order confound, 报告必须写明。

source hash 绑定执行实现

正式 evidence 保存 21 个 source file SHA-256:

contracts
SQL
pgbench scripts
samplers
runner
validator
reviewer
task

若脚本改了一个 cast、weight 或采样窗口,旧 evidence 不再声称由当前 source 产生。参考 run 在 CPU sampler 口径修正后完整重跑,而不是把旧 transaction 结果与新 CPU 算法拼在一起。

manifest 最小字段

{
  "run_id": "...",
  "captured_at": "...",
  "target": "...",
  "versions": {},
  "settings": {},
  "source_hashes": {},
  "dataset": {},
  "workload_id": "...",
  "run_order": [],
  "raw_files": {},
  "cleanup": {},
  "claims_not_made": []
}

raw file 要有 size/hash;只保存总结无法重算 quantile。

26.3.2 预热、重复、置信区间与异常值

预热要说明预热什么

可能需要预热:

client process and connections
authentication/TLS
prepared statements
PostgreSQL shared buffers
OS page cache
JIT compilation
relation extension
filesystem allocation
storage cache
statistics/exporter discovery

“跑 5 秒预热”只是动作,不是证明。

参考 run 对每个 scale/concurrency 做 5 秒自然预热,随后 reset mutable table。 它能:

  • 验证正式 protocol 与 script;
  • 建立连接;
  • 触碰部分 immutable working set;
  • 暴露 parameter typing。

它不能保证 899 MiB L dataset 全部 warm,也不能制造 cold-cache baseline。

正式时长取决于最慢周期

观察窗口要覆盖:

checkpoint cycle
autovacuum cycle
backup overlap
storage cache behavior
CPU thermal/power changes
application burst length
replica/archive catch-up

PostgreSQL 官方建议至少几分钟,可能需要数小时。

本章每 run 8 秒,是为了在 disposable laptop sandbox 中:

  • 演示完整 30-run evidence pipeline;
  • 观察短时 curve 与测量偏差;
  • 保持负载有界;
  • 不批准生产数字。

它不是 production baseline 的推荐时长。

重复的实验单位必须独立定义

参考每次 run:

reset order_live and inventory
snapshot native counters
start wait and OS samplers
run 8 s pgbench
stop at exact measured window
wait for stats flush
snapshot counters

immutable data 与 cache 不重建,所以 run 并非完全独立;它是“同一 scale 阶段内的 重复”。报告不能把五次当五台独立机器。

生产比较建议:

  • 多个 run;
  • 多个时间段;
  • 至少一次 fresh environment;
  • 若硬件采购,多个同型 node;
  • candidate/control 交错,而不是先跑完 A 再跑 B。

不要用一个平均数吞掉分布

每个 run 保存 transaction latency sample:

{x1,x2,,xn} \{x_1,x_2,\dots,x_n\}

cell pooled p95:

Q0.95(r=15Xr) Q_{0.95}\left( \bigcup_{r=1}^{5} X_r \right)

它回答“该 cell 所有保存 transaction sample 的 p95”。

run-level p95:

qr=Q0.95(Xr) q_r=Q_{0.95}(X_r)

再报告 $q_r$ 的 median/interval,回答“一个典型 run 的 p95 如何波动”。

这两者不能混名。

参考 public summary 保存:

pooled p50/p95/p99/max
run TPS median
run TPS deterministic bootstrap 95 interval

bootstrap interval 的边界

对五个 TPS:

resample five runs with replacement
compute median
repeat 10,000 times with fixed bootstrap seed
take 2.5% and 97.5%

这给出教学用 interval,但:

  • n=5 很小;
  • run 不完全独立;
  • systematic bias 不会进入 interval;
  • interval 不是“真实 TPS 有 95% 概率在里面”;
  • performance distribution 可能非平稳。

它主要让读者看见“不确定性不是零”。

异常值不能看结果再删除

L-c8 五次 TPS:

2216.087
2739.827
2774.267
2824.359
2798.272

第一轮显著低,p95 也更高。可能原因:

  • L 初始化尾部 dirty write;
  • working set 尚未稳定;
  • background checkpoint/archive/replication;
  • virtual neighbor;
  • random interleaving。

本章保留它,因此:

median TPS         2774.267
bootstrap interval [2216.087, 2824.359]

正确流程:

  1. 在实验前声明 outlier rule;
  2. 保存原始样本;
  3. 检查 concurrent evidence;
  4. 报告含/不含敏感性分析;
  5. 只有明确测量故障才排除;
  6. 排除要留 audit record。

“看起来不正常”不是删除依据。

candidate 与 baseline 的比较

对每个 matched run:

$$ \Delta_r

\frac{candidate_r-baseline_r}{baseline_r} $$

优先比较 paired distribution,而不是两个独立平均。验收可以是:

throughput regression < 3%
p95 regression < 5%
p99 no material tail expansion
error/late/skipped unchanged
resource demand does not worsen beyond budget

阈值来自业务风险与实验噪声,不是固定模板。

26.3.3 冷热缓存、后台任务与邻居噪声

PostgreSQL 有至少两层 cache

PostgreSQL shared buffers
  -> OS page cache
      -> device/controller cache
          -> physical/virtual storage

blks_hit

found in PostgreSQL shared buffers

blks_read

PostgreSQL requested a block read

不代表 physical disk read。OS 可能立即从 page cache 返回。

参考:

S/M cells  blks_read = 0 during measured runs
L-c1       2,500
L-c8       14,491

可以说 L 触发了 PostgreSQL-level reads,不能说发生了同样数量的 physical I/O。 因此 runner 同时采 /proc disk counters,并用 Pigsty node metrics 做较粗的 corroboration。

真 cold cache 是破坏性实验

常见做法:

restart PostgreSQL
drop OS page cache
reboot host
recreate VM

它们会影响同机其他 workload,不能在共享或生产节点随便执行。若要测 cold start:

  • dedicated environment;
  • 明确授权;
  • 固定动作与复位;
  • 同时记录 storage cache;
  • 区分 crash recovery、clean restart 和 first read;
  • 多次重建,不把一次 boot 当分布。

本章禁止 drop cache、restart 和 checkpoint on demand,只声明 warm-natural。

autovacuum 是 workload 的一部分

PostgreSQL 官方 pgbench 指南提醒,dead row/space 与 autovacuum 会改变结果。

两种实验都合理:

controlled engine microbenchmark
  deliberately isolate maintenance

production representative benchmark
  keep realistic autovacuum and churn

错误做法是关闭 autovacuum 获得数字,却不在 claim 里写。

若保留 autovacuum,记录:

SELECT
    relname,
    n_live_tup,
    n_dead_tup,
    autovacuum_count,
    autoanalyze_count,
    last_autovacuum,
    last_autoanalyze
FROM pg_stat_user_tables;

并观察 progress、I/O、WAL 和 latency。

checkpoint 与 full-page image

checkpoint 后第一次修改某 page,full_page_writes=on 时可能产生 full-page image。于是相同 transaction mix 的 WAL/tx 会随 checkpoint phase 改变。

观察:

pg_stat_checkpointer
pg_stat_wal.wal_fpi
pg_stat_wal.wal_bytes
Pigsty PGSQL Persist

不要为了“稳定”关闭 full-page writes;那会改变 durability。

PostgreSQL WAL 配置 说明这些参数的恢复语义。本章保持 fsync=onfull_page_writes=onsynchronous_commit=on

backup、archive 与 replica 会消耗真实资源

写 workload 同时驱动:

primary WAL generation
WAL sender
replica receive/write/replay
archive command
backup repository

参考全实验 Pigsty window:

WAL rate max            46.2 MB/s
replica replay gap max   3.47 MB
replay gap median        0

该 window 包含 bulk initialization、warm-up 和 measured run。max 不能归因给 某个 cell;median 0 也不证明 read-your-writes。它只是“复制路径在实验期间有过 推进和短时距离”的 corroboration。

virtual neighbor 让“同一台机器”也不相同

shared hypervisor 可能同时运行:

  • load generator;
  • database VMs;
  • monitoring;
  • local build;
  • host desktop workload。

guest 中看到的 CPU busy 不包含所有 host contention 细节。需要:

steal time
host load
storage latency
run-to-run spread
dedicated-host rerun

本章把 shared-hypervisor 写入 why_null,而不是用五次重复假装消除。

background inventory

每次 run 保存:

pg_stat_database delta
pg_stat_wal delta
pg_stat_io delta
pg_stat_checkpointer delta
pg_stat_bgwriter delta
pg_stat_statements queryid-only delta
pg_stat_activity wait sample
client/server /proc sample
Pigsty full-window range

统计不 reset。PostgreSQL cumulative statistics 默认有更新延迟,并可能在 transaction 内缓存 snapshot;官方 viewing statistics 解释了 PGSTAT_MIN_INTERVALstats_fetch_consistencypg_stat_clear_snapshot()。runner 在 run 后等待 flush,并比较 reset timestamp。

26.3.4 绝对性能结论只适用于记录过的环境

一条绝对数字的完整名称

不是:

PostgreSQL = 2920 TPS

而是:

pg36_shop shop-mix-v1
S dataset, 8 closed-loop clients, zero think time
prepared persistent sessions
direct pg-meta-1 -> pg-test-1 primary
PostgreSQL 18.6, Pigsty v4.5.0 sandbox
five 8-second teaching runs
median 2920.5 TPS
pooled p95 9.448 ms
zero observed failures/late
server work median 84.2%

标题很长,因为 claim 的边界本来就长。

absolute capacity 与 regression baseline 分开

短、噪声较大的实验仍可做 CI regression signal:

same controlled environment
same workload
same run order
candidate vs baseline interleaved

它不能自动成为生产采购依据。

用途 证据要求
script smoke 能运行、事务语义正确
CI regression 相对、matched、噪声已知
sandbox demand estimate OS/PG evidence + 明确边界
production capacity representative environment + SLO load
procurement production candidate hardware + failure/maintenance

环境变化触发 rebaseline

至少在以下变化后重建:

  • PostgreSQL/Pigsty/kernel/extension 升级;
  • CPU、RAM、storage、filesystem、VM type;
  • shared_buffers、WAL、checkpoint、I/O、pool 配置;
  • schema/index/partition/constraint;
  • query/plan/protocol;
  • operation mix、key skew、data size/bloat;
  • backup/replica/topology;
  • application retry/timeout/admission;
  • SLO 或 failure model。

不能把三年前、旧 instance type 的 TPS 线性乘 CPU 核数。

外推必须带假设

参考 CPU 投影:

$$ \lambda_{65}

\frac{0.65}{D_{cpu}} $$

这是 local linear model。它没有证明:

$$ D_{cpu}(\lambda)

\text{constant} $$

靠近 contention/knee 后,service demand 可能随负载增加。外推距离越大,越需要 新测量点。

生产 gate 的证据矩阵

维度 sandbox reference production gate
hardware shared laptop VMs target compute/storage/network
run length 8 s × 5 minutes/hours covering cycles
arrival closed-loop c1/c8 open-loop rate sweep
path direct primary app + HAProxy + PgBouncer
data synthetic S/M/L representative shape/skew/bloat
failure none declared node/path loss
maintenance observed incidental scheduled overlap
backup incidental backup/restore window
cache warm-natural declared warm/cold scenarios
approval sandbox passed independent review

任何一行空缺,production claim 就保持 pending。

可信实验检查单

  • target 与 production boundary 已验证;
  • load generator 和 server 身份独立;
  • source、版本、配置、schema、data 有 hash/manifest;
  • seed 与 run order 固定;
  • warm-up 的对象与限制明确;
  • 正式时长覆盖所需周期;
  • 每个 cell 有足够重复;
  • raw transaction sample 可重算 quantile;
  • failed/retry/late/skipped 没有消失;
  • client/server/PG/Pigsty 证据时间对齐;
  • statistics reset timestamp 未变化;
  • cache/background/neighbor 被观察;
  • outlier rule 预先声明;
  • absolute、relative、production claim 分级;
  • unknown 与 cleanup evidence 同时发布。

上一节:设计代表性工作负载 · 返回本章目录 · 下一节:找到饱和点与瓶颈 · 查看全书目录 · 查看索引中心

26.4 找到饱和点与瓶颈

资源到 100% 不是 saturation 的唯一定义。

系统可能先在:

latency SLO
error/timeout
lock queue
pool wait
WAL/replica/archive lag
memory pressure
storage latency

上失效,而 dashboard 中 CPU 还没满。

容量曲线必须同时画:

offered load
achieved throughput
latency distribution
failure/late/skipped
queue/wait
resource utilization

瓶颈不是“最高的那条曲线”,而是当前最先限制目标的 service center。

26.4.1 吞吐—延迟曲线与排队拐点

典型曲线有三个区域

throughput
  ^
  |                  __________ maximum / collapse region
  |              ___/
  |          ___/
  |      ___/
  |  ___/
  +--------------------------------> offered load / concurrency
       linear      knee       saturated

对应 latency:

latency
  ^
  |                         /
  |                      __/
  |                   __/
  |__________________/
  +--------------------------------> offered load / concurrency

区域:

区域 throughput latency queue
linear 近似随 load 增长 稳定 很小
knee 增益变小 tail 开始快速上升 累积
saturated 持平或下降 非线性恶化 持续/失败

capacity 通常选在 knee 左侧,并留 failure/maintenance headroom,而不是取最高 TPS 点。

两个并发点只能 bracket

参考只有 c1/c8:

scale c1 TPS c8 TPS gain c1 p95 c8 p95 p95 multiplier
S 1,508.5 2,920.5 1.936x 2.152 9.448 4.390x
M 1,555.8 2,911.5 1.871x 2.123 9.398 4.427x
L 1,423.6 2,774.3 1.949x 2.259 10.087 4.465x

c8 throughput 仍比 c1 高约 1.9 倍,不能说 knee 已经位于 c1–c8;p95 却放大 约 4.4 倍,说明排队/并行争用已明显增加。

公共结果因此写:

{
  "interpretation": "knee-not-bracketed-by-one-and-eight-clients",
  "exact_knee_known": false
}

下一轮应补:

c2 c4 c8 c12 c16 c24 c32

或以 open-loop rate 做更密的 SLO sweep。只有两点画出的“曲线”是连线,不是 找到拐点。

closed-loop 曲线的横轴不是 offered arrival

零 think time、$C$ 个 client:

XCR X \approx \frac{C}{R}

当 latency 上升,throughput 自动降低。这会形成自我节流:

server slows
  -> each client waits longer
      -> fewer new transactions arrive

真实应用 request 可能继续到达并在 pool/queue 中堆积。因此 closed-loop c8 回答:

eight persistent clients can complete how much work

不回答:

system can admit N external requests/s while meeting p95 and error SLO

open-loop 要同时看 offered 与 achieved

--rate

pgbench \
  --rate=2200 \
  --latency-limit=250 \
  --time=300 \
  ...

记录:

$$ \text{completion ratio}

\frac{\text{successful}}{\text{scheduled}} $$

以及:

schedule lag
late
skipped
failed
queue depth

若 achieved TPS 看起来稳定,但 skipped 快速增加,系统不是健康保持吞吐,而是 丢弃工作。

用 Little’s Law 检查 queue

对 database active/queued population:

L=λW L=\lambda W

c8、2,920 TPS、mean latency 粗略约:

W829202.74ms W \approx \frac{8}{2920} \approx 2.74 ms

这个量接近 mixed mean,不是 p95。若观测到平均 active+wait session 与它严重 不一致,检查:

  • transaction log boundary;
  • pool queue 未计;
  • client think/network;
  • sampler bias;
  • multiple transactions per script;
  • offered/achieved 混用。

knee 是多目标决策

可能的 acceptance:

p95 <= 250 ms
p99 <= 500 ms
failed <= 0.1%
skipped = 0
CPU target <= 65%
disk latency within budget
replica/archive catch up within 5 min
pool wait p95 <= 20 ms

最先违反的条件定义当前可用边界。它可能远早于最大 TPS。

throughput collapse

超过 knee 后可能:

more clients
  -> more context switches
  -> more lock queue
  -> more cache churn
  -> more memory/IO pressure
  -> longer transactions
  -> locks held longer
  -> even more queue

形成正反馈,throughput 反而下降。压测 runner 应有 stop condition:

  • failure/late 超阈值;
  • latency 超 hard limit;
  • replica/archive gap 无界增长;
  • disk/memory safety floor;
  • server health/HA 状态变化;
  • client lost;
  • cleanup 不再可保证。

26.4.2 CPU、内存、I/O、WAL 与锁的证据

每个候选瓶颈需要至少两层证据

假设 PostgreSQL OS/Pigsty 反证
CPU active no wait、query exec time CPU busy/run queue client/lock wait
data I/O pg_stat_io, blks_read/time disk bytes/latency/queue OS cache
WAL pg_stat_wal, WAL IO disk write, archive/replay data write
lock wait event, pg_locks blocker low CPU possible client wait
memory temp bytes, backend count available/PSI/OOM cache reclaim
connection activity/pool states client queue active execution

一个指标不是 root cause。

CPU:utilization、run queue 与 service demand

参考 measured-window median:

cell server work client work
S-c1 48.9% 17.7%
S-c8 84.2% 29.5%
M-c1 50.1% 17.8%
M-c8 84.0% 29.1%
L-c1 46.3% 16.2%
L-c8 84.2% 27.8%

c8 的 server work 显著更高,client 仍有余量。可以说:

server-side work is a stronger limiting candidate than load-generator CPU

不能说:

CPU is the sole root cause

因为约 16% 时间没有被 work 计入,可能是 idle、virtual scheduling、commit path、network 或其他等待;c8 也存在 lock/LWLock/IO samples。

单位 CPU demand:

$$ D_{cpu}

\frac{U_{cpu}\times T}{N} $$

参考 c8 median run:

scale CPU s / mixed tx
S 0.0002906
M 0.0002924
L 0.0003070

它包含 server 上所有 CPU work,不只 SQL executor;这是容量视角需要的总需求, 但 shared VM background 会引入噪声。

memory:不要用 available 一张图下结论

PostgreSQL memory:

shared memory
+ backend/session memory
+ per-node work_mem/hash memory
+ parallel workers
+ maintenance/autovacuum
+ extension/JIT
+ OS page cache

风险模型:

Mpeak≉shared_buffers+max_connections×work_mem M_{\text{peak}} \not\approx shared\_buffers + max\_connections \times work\_mem

work_mem 是每个 sort/hash operation 的基础限制,一条 query 可能多个,一台 server 可能多个 query/worker。

观察:

  • MemAvailable minimum;
  • swap/OOM/PSI;
  • backend count 与 active count;
  • temp file/bytes;
  • hash/sort spill;
  • parallel workers;
  • cgroup/VM limit。

参考 cells:

temp_bytes 0
swap       0

只说明这个短 workload 未观测到 database temp spill;它不是 memory capacity 证明。

I/O:先分 data、WAL 与 background

pg_stat_io 按:

backend_type
object
context
reads/writes/extends/fsyncs
bytes/time

聚合。track_io_timing=on 才有部分 timing;参考 track_wal_io_timing=off,所以不能声称有 WAL write/fsync timing。

cell database block evidence:

cell block reads block hits
S-c1 0 675,976
S-c8 0 1,323,054
M-c1 0 733,198
M-c8 0 1,326,143
L-c1 2,500 629,335
L-c8 14,491 1,344,632

L 出现 PostgreSQL-level reads,与 dataset 大于 shared buffers 一致;仍不能把 每个 read 当 physical disk。

Pigsty 全实验 window:

node disk read max   13.99 MB/s
node disk write max  81.22 MB/s

包含 3.2M row 初始化,不用于 cell arithmetic。per-run /proc 与 native delta 才用于解释 measured cell。

WAL:按 transaction 和 operation 分解

mixed WAL/tx:

cell WAL bytes / mixed tx
S-c1 160.3
S-c8 159.5
M-c1 176.0
M-c8 161.9
L-c1 157.6
L-c8 196.2

不要用六个值平均成“PostgreSQL 每事务 168 bytes”。它只适用于当前 80% read、 20% write mix,还受 full-page image、background 和短窗口影响。

更好的生产模型:

WAL/read-product
WAL/read-order
WAL/place-order
WAL/background

通过 operation/queryid-scoped delta 或隔离 workload 校准,再按预测 mix 加权。

WAL rate 还决定:

  • replica network/write/replay;
  • archive bandwidth;
  • retained WAL;
  • PITR repository;
  • recovery/catch-up time。

lock:等待样本不是完整 lock history

c8 cell 观察到少量:

Lock
LWLock
IO
Client
CPU

所有 cell deadlock=0。解释边界:

sampled wait
  catches only state at 250 ms sample points

deadlock counter
  counts detected deadlocks

neither proves no short lock waits

要解释 lock:

  • blocker/waiter graph;
  • lock mode/object;
  • transaction age;
  • wait duration;
  • hot key;
  • statement/queryid;
  • timeout/deadlock log。

Client wait 多也不能自动反证 lock;backend 可能在 statement boundary 等 client,另外 client 可能在等另一个 backend。

checkpoint、archive 与 replication

Pigsty PGSQL Persist dashboard 把 WAL、XID、checkpoint、archive 和 I/O 放在同一观察面。

实验期间:

replica replay gap median 0
replica replay gap max    3,471,848 bytes

这表示 exporter 的 replay-distance metric 曾观测到短时差距。不能推出:

  • commit token 已在特定 replica 可见;
  • RPO=0;
  • failure 时一定无数据丢失;
  • catch-up time 在生产 storage 上相同。

第 20 章的 HA contract 与第 25 章的 freshness signal 仍然生效。

用 hypothesis table 组织诊断

hypothesis supporting contradicting next test
client CPU ceiling none strong client <30%, server ~84% distributed client
PostgreSQL CPU knee server rises to ~84% throughput still gains ~1.9x c12/c16/c24
data I/O for L block reads appear c8 iowait median low larger-than-RAM/open-loop
hot-row lock Lock/LWLock samples no deadlock, gain still large change Zipf/uniform
WAL limit L-c8 WAL/tx higher no lag growth proven write-heavy mix

结论不是“选一行”,而是设计下一个最能区分假设的实验。

26.4.3 连接数增加为何可能降低吞吐

max_connections 是上限,不是目标

max_connections=500 只说明 PostgreSQL 接受的 backend 数上限。它不证明 500 active query 是健康并发。

每个 backend 带来:

  • process 与 private memory;
  • transaction/snapshot;
  • lock table entries;
  • plan/executor state;
  • network socket;
  • statistics/logging;
  • scheduling/cache footprint。

active concurrency 超过 bottleneck 并行度后,额外 backend 主要排队。

CPU 调度与 cache locality

1 vCPU 上 32 个 CPU-bound backend:

no more execution capacity
more runnable processes
more context switching
instruction/data cache disruption
longer transaction duration

长 transaction 又让 lock 持有更久,形成二阶影响。

观察:

CPU utilization
run queue/load
context switches
active sessions
per-query service demand

lock queue

若多个 place-order 更新同一 hot inventory row:

one holder
many waiters

增加 client 不增加该 key 的 service rate,只增加 queue:

Wq,Xconstant W_q \uparrow,\quad X \approx constant

若 transaction 在拿锁前还做很多工作,rollback/retry 成本更高。

措施不是盲目加连接:

  • partition/shard hot key;
  • shorten transaction;
  • fixed lock order;
  • admission per key/tenant;
  • move external calls outside transaction;
  • optimistic/versioned update;
  • reduce retry storm。

memory amplification

更多 active query:

more sort/hash
more parallel workers
more temp spill
less OS cache
more I/O

然后 latency 上升,连接占用更久,再增加在途数。

storage queue

存储有可用并行深度,但不是无限:

low concurrency
  underutilized device

optimal concurrency
  latency acceptable, throughput high

excess queue
  throughput flat, latency rises

同一 device 上 data、WAL、checkpoint、archive staging 和 backup 可能相互影响。

parallel query 与 client concurrency 相乘

若一个 query 最多 4 workers:

8 active queries
may request up to 8 leaders + 32 workers

PostgreSQL resource 文档提醒 parallel worker 是独立 process,CPU/memory impact 类似额外 session,work_mem 也按 worker/operation 作用。

所以容量合同要同时固定:

client concurrency
max_parallel_workers
max_parallel_workers_per_gather
query plan

pool 的正确目标是限制 server concurrency

例如:

10,000 idle application connections
  -> PgBouncer
      -> 32 server connections
          -> 8 active budget

pool queue 是可控 admission,前提是:

  • queue latency 可观测;
  • client timeout 大于/匹配 policy;
  • cancellation 正确;
  • transaction pooling 兼容 session feature;
  • retry 不放大;
  • queue 有上限。

没有上限的 pool 只是把 outage memory 从 PostgreSQL 移到中间层。

找到健康并发的实验

对每个 data/mix:

c1 c2 c4 c8 c12 c16 c24 c32

每点:

  • 固定 offered model;
  • 重复;
  • 画 throughput、p95/p99、failed、late、queue;
  • 同时画 server/client CPU、I/O、WAL、lock;
  • 记录 pool/direct path。

选择:

first point before material tail acceleration
and within headroom/failure policy

不是:

highest observed TPS

本节结论

参考证据支持:

  • c8 比 c1 吞吐高约 1.9x;
  • c8 pooled p95 高约 4.4x;
  • client CPU 不是明显 ceiling;
  • server work、lock/LWLock/IO 与 L block-read 都值得下一轮区分;
  • 两点没有定位 exact knee。

它不支持“最佳连接数=8”。下一轮实验而不是措辞强度,才能减少未知项。


上一节:建立可信实验 · 返回本章目录 · 下一节:从测量推导容量与成本 · 查看全书目录 · 查看索引中心

26.5 从测量推导容量与成本

一次压测给出的 TPS 不是容量答案。它只是某个 workload、数据规模、配置和时间窗口下 的一组观测。容量规划要把观测转换为三个可以行动的模型:

resource model
  one business operation consumes how much CPU / IO / WAL / storage

growth model
  data, WAL, backup and maintenance workspace grow how fast

decision model
  at what threshold, considering lead time and failure, should we change capacity

转换过程中最危险的捷径是:

benchmark maximum TPS × server count = future capacity

它忽略了 workload mix、尾延迟、后台工作、故障冗余、增长、复制、备份以及扩容 提前期。可信的容量模型必须保留条件、区间和未知项。

26.5.1 单位业务量的资源消耗

先定义业务单位

“每事务成本”只有在 transaction 的业务语义稳定时才有意义。第 26 章实验的 transaction mix 是:

read-product  50%
read-order    30%
place-order   20%

其中一次 place-order 会写入订单、订单行并扣减库存;一次 read operation 则主要 读取。若把三者统称为“请求”,mixed average 会随权重变化:

$$ D_{\text{mix}}

\sum_{i=1}^{n} w_iD_i $$

其中:

  • $w_i$ 是第 $i$ 类 operation 的比例;
  • $D_i$ 是该 operation 的资源需求;
  • $\sum w_i=1$。

今天写比例 20%,明天促销时写比例 45%,即使每一种 operation 的实现都没变, mixed resource/transaction 也会变化。因此模型应优先保存:

CPU seconds / read-product
CPU seconds / read-order
CPU seconds / place-order
WAL bytes / place-order
durable data bytes / place-order

而不是只保存一个 blended TPS。

CPU service demand

假设测量窗口内:

N_cpu       logical CPU count
U_cpu       non-idle CPU fraction
X           completed transactions / second

粗略的 mixed CPU demand:

Dcpu,mixNcpuUcpuXCPU-seconds / transaction D_{\text{cpu,mix}} \approx \frac{N_{\text{cpu}}U_{\text{cpu}}}{X} \quad \text{CPU-seconds / transaction}

例如单核 server 上,L-c8 的 server work ratio 约 0.842、吞吐中位数约 2,774 TPS:

Dcpu,mix1×0.84227740.000304 CPU-s/tx D_{\text{cpu,mix}} \approx \frac{1\times0.842}{2774} \approx 0.000304 \text{ CPU-s/tx}

即约 0.304 CPU-ms/tx。这个值只能用于同一 workload mix 的粗略推演。它包含 shared overhead,未把 checkpointer、WAL writer、autovacuum 等后台成本可靠地 分摊给 operation,也没有证明 CPU 是唯一限制。

要得到 per-operation demand,应设计独立或正交实验。设三种 operation 的未知 CPU demand 为 $D_1,D_2,D_3$,运行至少三组不同且可识别的 mix,得到:

$$ \begin{bmatrix} w_{11} & w_{12} & w_{13} \ w_{21} & w_{22} & w_{23} \ w_{31} & w_{32} & w_{33} \end{bmatrix} \begin{bmatrix} D_1\D_2\D_3 \end{bmatrix}

\begin{bmatrix} D_{\text{mix},1}\D_{\text{mix},2}\D_{\text{mix},3} \end{bmatrix} $$

更简单的做法是分别运行 read-only、write-only 与代表性 mixed workload,然后用 mixed run 验证模型,而不是直接拿三次结果线性拼接。

从 service demand 估算目标 CPU

若 forecast 中每类业务到达率为 $\lambda_i$:

$$ C_{\text{cpu required}}

\frac{\sum_i \lambda_iD_{\text{cpu},i}} {U_{\text{target}}} $$

目标利用率 $U_{\text{target}}$ 不是物理极限。它还要容纳:

forecast error
traffic burst
autovacuum / checkpoint / backup
failover and degraded topology
software regression
host and storage variance

参考 run 为便于教学,计算了一个 sandbox-only、线性、65% CPU 投影:

scale 65% CPU 条件投影 TPS
S 2,236.79
M 2,222.96
L 2,116.98

这只是:

X65%Xc8×0.65Uc8 X_{65\%} \approx X_{\text{c8}} \times \frac{0.65}{U_{\text{c8}}}

它不代表 production sustainable TPS,因为:

  • c8 已高于 65%,这是回算而非直接在 65% 稳态测量;
  • 两个并发点没有找到 knee;
  • workload 是 closed-loop、warm-cache、短窗口;
  • 后台维护、故障、恢复、备份与真实网络未被充分施压;
  • 只有一个虚拟化小节点。

公共结果把 production sustainable TPS 保持为 null,这比填一个看似精确的数字 更诚实。

WAL 与 durable growth 必须按写操作归一

参考实验给出了:

cell mixed WAL bytes/tx durable bytes/place-order
S-c1 160.33 415.21
S-c8 159.53 409.12
M-c1 175.95 412.81
M-c8 161.88 405.97
L-c1 157.56 411.50
L-c8 196.18 407.88

WAL bytes/tx 的分母包含 80% read transaction,因此不能直接说“一笔订单产生 约 160–196 字节 WAL”。若读操作完全不写,按 20% place-order 做第一阶换算, 写操作的 WAL 可约为 mixed value 的五倍;但真实系统还有:

  • hint bit 与 full-page image;
  • checkpoint 后首次页修改;
  • index 数量与 page split;
  • autovacuum、freeze 与 catalog activity;
  • logical decoding;
  • sequence 与 extension 行为。

正确做法是用 pg_stat_wal.wal_bytes 的窗口 delta,除以该窗口实际成功的 write operation 数:

$$ \text{WAL bytes/write op}

\frac{\Delta\text{wal_bytes}} {\text{successful write operations}} $$

同理:

$$ \text{durable growth/write op}

\frac{\Delta\text{relation bytes after stabilization}} {\text{successful write operations}} $$

这里的 “after stabilization” 很重要。heap 与 index 的 allocated size 不是每 写一行都线性增加;页内 free space 会先被消耗,扩页是离散事件。短窗口的 relation size delta / order 容易受页边界影响。应在足够长窗口、多个数据规模 和 vacuum 周期上重复测量。

PostgreSQL 原生统计可以分别回答:

SELECT wal_bytes, wal_records, wal_fpi
FROM pg_stat_wal;

SELECT
    datname,
    xact_commit,
    xact_rollback,
    blks_read,
    blks_hit,
    temp_bytes,
    deadlocks
FROM pg_stat_database
WHERE datname = current_database();

统计视图是累计计数器。必须保存 start/end snapshot、server restart/reset 时间, 用 delta 计算;不能把 dashboard 当前值当作本次 run 的成本。

I/O 成本不能用 cache hit ratio 代替

一个 operation 的 I/O 模型至少分:

logical buffer access
physical data read
data write / writeback
WAL write / sync
temp read/write
backup/archive/network bytes

参考 run 的 S、M cell 没有记录到 PostgreSQL block read,L-c1/L-c8 分别出现 2,500/14,491 次 block read。这只说明 L 数据规模在该 warm-cache 窗口触碰了更多 未驻留 block;它不证明小规模生产 workload “不需要磁盘”。

PostgreSQL 18 的 pg_stat_io 可按 backend type、object 与 context 提供 read/write/extend/fsync 等累计信息。 它仍需和 OS/Pigsty 的 device latency、queue、throughput 对齐,因为:

  • PostgreSQL read 可能由 OS page cache 满足;
  • write() 完成不等于设备持久化完成;
  • shared storage 与 hypervisor 可隐藏或放大延迟;
  • data I/O、WAL I/O 和 backup I/O 可能落在不同设备。

latency 是容量成本的一部分

若只追求最低 unit CPU cost,常会把 server 推到更高 batch/concurrency;throughput 可能提高,但 queueing 令 p95/p99 恶化。容量成本函数应含 SLO penalty:

$$ \text{effective cost}

\text{infrastructure cost} + \text{failure risk} + \text{latency penalty} + \text{operational complexity} $$

因此一份 unit economics 表至少包含:

指标 分母 条件
CPU-s operation type mix、并发、数据规模
logical/physical I/O operation type cache state、plan
WAL bytes successful write op checkpoint/FPI 状态
durable bytes business entity schema/index/vacuum 周期
network bytes API or transaction TLS、pool、result set
p50/p95/p99 completed op offered/achieved load
failure/late scheduled op timeout/retry policy

只给 $ / TPS 而没有这些条件,不是成本模型,只是报价除法。

26.5.2 增长、保留、备份与维护空间

数据库大小不是一条线

磁盘容量要同时容纳:

live heap
indexes
TOAST
free space and bloat
temporary files
WAL working set
archive staging / backlog
base backups and incrementals
restore and verification workspace
maintenance rewrite workspace
logs, packages and operating system
failure/rebuild headroom

先用原生 size function 建立对象层 inventory:

SELECT
    pg_size_pretty(pg_database_size(current_database())) AS database_size;

SELECT
    n.nspname,
    c.relname,
    pg_relation_size(c.oid) AS main_fork_bytes,
    pg_indexes_size(c.oid) AS index_bytes,
    pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(c.oid) DESC;

这些函数的语义与可选 fork 见官方 Database Object Size Functions

注意:

pg_relation_size(table)
  main fork only by default

pg_table_size(table)
  table forks + TOAST, excluding indexes

pg_total_relation_size(table)
  table + TOAST + indexes

pg_database_size(database)
  database total

不要把其中两项相加造成重复计数。

从业务增长率推导 live data

对业务实体 $i$:

$$ G_{\text{live/day}}

\sum_i N_{i,\text{day}} \times B_{i,\text{durable}}

G_{\text{purged/day}} $$

若保留 $R$ 天,且每日量以 $g$ 增长:

$$ N_R

N_0 \sum_{d=0}^{R-1}(1+g)^d $$

不要先把“当前日增量 × 保留天数”写死,再在运营增长时惊讶。还要单独模拟:

  • 热数据、温数据、冷数据保留;
  • update/delete churn;
  • 索引增长;
  • partition attach/detach;
  • 法规保留与 legal hold;
  • tenant/region/skew;
  • schema 变更增加的列和索引。

容量表应按周或月保存 actual vs forecast:

date
live rows
heap bytes
index bytes
TOAST bytes
dead tuples
WAL bytes/day
backup bytes
archive backlog
forecast P50/P90

forecast 不是只更新未来;它也要用实际误差校准。

bloat 与 free space 不是同义词

PostgreSQL MVCC update/delete 会产生 dead tuple。普通 VACUUM 通常把空间留在 relation 内供复用,并不把大部分文件空间还给文件系统。于是:

relation allocated bytes
  != live tuple bytes
  != irrecoverable waste

容量规划需要同时问:

  • 这部分 free space 能否被相同 relation 的后续写入复用?
  • workload 的 update pattern 会不会跨页,使复用率低?
  • index 是否发生结构性膨胀?
  • autovacuum 能否在增长速度下追上?
  • freeze horizon 和 long transaction 是否阻止清理?
  • 真正回收文件系统空间需要哪种 rewrite,以及多大 workspace?

不要看到 30% “bloat estimate” 就自动采购 30% 磁盘,也不要因为 vacuum 后文件没缩小 就认定 vacuum 无效。

WAL 是速率、窗口与失效状态的乘积

WAL 容量不是固定 “max_wal_size”。要考虑:

steady generation rate
checkpoint behavior
archive throughput and outage
replication slots
slow/offline replicas
backup and restore windows
wal_keep_size / sender retention
logical decoding
promotion and timeline history

若 WAL 生成率为 $W$ bytes/s,允许 archive 中断 $T_a$ 秒,安全因子为 $h$:

Sarchive backlogW×Ta×h S_{\text{archive backlog}} \ge W \times T_a \times h

若 replication slot 的 consumer 可离线 $T_s$:

Sslot retentionW×Ts×h S_{\text{slot retention}} \ge W \times T_s \times h

但 slot retention 可能无界,除非使用并验证 max_slot_wal_keep_size 等保护。 PostgreSQL 的 WAL configurationlog-shipping standby 说明了 checkpoint、归档、streaming 与 retention 之间的关系。

容量规划至少演练:

archive destination unavailable
replica offline
logical subscriber stalled
backup exceeds expected duration
write burst crosses checkpoint

正常状态的 WAL 目录大小不能代表这些失效状态。

备份容量用 retention graph,而不是压缩率愿望

备份 inventory 应包括:

full/base backup
incremental/differential data
WAL needed for PITR
retention generations
off-site copies
temporary upload/download
restore verification copy
metadata/catalog
encryption/compression overhead

备份压缩率随 data type、page utilization、encryption 与 compression method 变化。 应直接测量:

$$ \text{compression ratio}

\frac{\text{stored backup bytes}} {\text{logical source bytes}} $$

并分别保存 full 与 WAL 的 ratio。生产采购不能假设日志、JSON、already-compressed payload 与 encrypted data 都能获得同一压缩率。

retention 示例:

7 daily + 4 weekly + 12 monthly

不能简单乘以 23,因为增量链可能共享 block,full schedule 也不同;应从备份系统 catalog 汇总真实 object graph,并验证删除一代不会破坏仍保留的恢复链。

维护和迁移需要临时的第二份数据

以下动作可能需要额外 workspace:

动作 可能的额外空间
CREATE INDEX / REINDEX 新 index + sort/temp + WAL
table rewrite 新 heap + indexes + WAL
VACUUM FULL / CLUSTER relation rewrite + indexes + WAL
major upgrade in-place/link/copy 策略不同
restore rehearsal 一份可启动的数据 + WAL + logs
replica rebuild base backup staging + receive WAL
logical migration source 与 target 重叠期

ALTER TABLE 并不总是 rewrite,pg_upgrade --link 也不等于没有风险;模型要按实际 变更计划估算,不应用一个永久的 “2x” 代替 runbook。

一个实用的 local filesystem 约束是:

Sfreemax(Sroutine headroom,Slargest planned maintenance,Sfailure backlog) S_{\text{free}} \ge \max( S_{\text{routine headroom}}, S_{\text{largest planned maintenance}}, S_{\text{failure backlog}} )

若多个动作可重叠,应改为相应的和,而不是最大值。

用 time-to-limit 驱动行动

设当前使用量 $S_0$、目标上限 $S_{\text{limit}}$、净增长速率 $G$:

$$ T_{\text{limit}}

\frac{S_{\text{limit}}-S_0}{G} $$

但 $G$ 不应只有一条平均线。至少计算:

P50 trend
P90/P95 growth or burst scenario
retention policy change
failure backlog scenario
largest scheduled maintenance

如果增长非线性,就用时间序列/业务 forecast,不能继续用直线外推。

26.5.3 扩容触发线、提前期与失效假设

trigger 必须早于 capacity limit

一次扩容从发现到获得容量通常经历:

signal sustained
  -> diagnosis and forecast review
  -> decision / budget / approval
  -> procurement or quota
  -> provision
  -> data copy / rebalance / catch-up
  -> validation
  -> traffic shift
  -> rollback observation window

总提前期:

$$ T_{\text{lead}}

\sum_j T_j $$

若保守预测到达上限的时间为 $T_{\text{limit,P90}}$,行动条件应是:

Tlimit,P90Tlead+Tvalidation+Tsafety T_{\text{limit,P90}} \le T_{\text{lead}} + T_{\text{validation}} + T_{\text{safety}}

“磁盘 90% 再扩”不是通用策略。一个需要四周采购、两周复制的集群,90% 可能已经 太晚;一个可在数分钟无状态扩出的 read pool,则可使用不同 trigger。

同时定义 utilization、SLO 与 growth trigger

容量触发器不应只看 CPU:

类别 示例信号 触发语义
demand request/write rate forecast 业务增长越过已验证 envelope
SLO p95/p99、timeout、pool wait 在当前 load 下目标开始失守
CPU busy/run queue/service demand 持续值与 burst/failure headroom
memory working set、temp、swap/PSI reclaim 或 spill 开始恶化
storage latency/queue/throughput 设备服务时间接近边界
space time-to-limit 小于 lead time + safety
WAL generation/archive/slot backlog consumer 无法在允许窗口追上
maintenance autovacuum/checkpoint/backup duration 后台工作超过可用窗口
HA N+1/degraded capacity 故障后 SLO 无法维持

trigger 应具有:

threshold
duration
forecast horizon
scope
owner
runbook
rollback / stop condition

瞬时 CPU 65% 不应自动购买主机;持续两周的需求趋势、在 N+1 状态下预测将超过已验证 SLO envelope,才是可行动的 signal。

以失效状态写容量预算

先定义状态,而不是笼统说 “预留 30%”:

N0  normal: all primary/replica/pool nodes healthy
N1  one data node unavailable or rebuilding
M1  backup + autovacuum + checkpoint overlap
F1  archive destination unavailable for allowed window
F2  one replica/slot consumer stalled for allowed window

每个状态分别计算:

available CPU / storage / IOPS / connection slots
traffic redistribution
WAL accumulation
replication/rebuild load
latency and error SLO
maximum tolerable duration

例如三台 read replica 各承载 30% 峰值,不能据此宣称 N+1;失去一台后其余两台会 各承载 45%,还要叠加 cache coldness、reconnect 与 recovery traffic。N+1 必须在 failure drill 中测。

扩容不等于只加 CPU

观测到瓶颈后,选择与根因匹配的动作:

限制 候选动作 必须验证
CPU execution vertical scale、query/index、JIT/parallel policy plan、tail、failure
read throughput replica/read cache、query optimization freshness、一致性、routing
write/WAL batch/schema/index、faster WAL device durability、replica/archive
lock/contention shorten tx、partition key space、admission correctness、公平性
connection PgBouncer、pool sizing、admission session semantics、queue SLO
storage latency storage class/layout、reduce random I/O fsync tail、failure behavior
data volume retention/partition/archive/shard restore、query、operability

max_connections 变大通常不是 capacity expansion。它可能把受控 queue 从 pool 移入 database,并增加 backend memory、context switch 和 lock competition。

Pigsty 把 topology、HAProxy/PgBouncer、PostgreSQL、监控和运维入口组织在一起, 方便执行变更;它不会替代业务 workload 与 SLO 的容量判断。扩容后仍要在实际 connection path 上重跑 baseline,并用 Pigsty PGSQL dashboards 与 PostgreSQL 原生统计双重验证。

把容量结论写成有期限的决策记录

一条可审计的容量结论应包含:

decision: "当前 topology 是否足以覆盖下一预测窗口"
environment: "硬件、版本、配置、connection path"
workload: "operation catalog、mix、arrival model、data scale"
slo: "latency/error/freshness/durability"
evidence: "run ids、raw artifacts、dashboards、queries"
validated_envelope: "哪些点通过,哪些点失败"
failure_state: "N0/N1/M1/F1/F2 中验证了哪些"
forecast: "P50/P90 demand and growth"
trigger: "threshold + duration + horizon"
lead_time: "provision + copy + validation + shift"
unknowns: "尚未测量的条件"
expires_at: "何时必须复审"
owner: "谁监控、谁决策、谁执行"

任何一个重大条件变化都使 baseline 进入复审:

PostgreSQL/Pigsty or kernel/storage upgrade
schema/index/query change
workload mix or SLO change
data crosses tested scale
topology/routing/pool change
hardware or cloud instance change
backup/replication/durability policy change
material performance incident

容量模型的价值不是预测一个永远正确的 TPS,而是让团队在还有选择的时候,看见:

what is known
under which conditions
how fast the boundary is approaching
how long the response takes
which failure would invalidate the plan

上一节:找到饱和点与瓶颈 · 返回本章目录 · 下一节:实战:pg36_shop 容量基线 · 查看全书目录 · 查看索引中心

26.6 实战:`pg36_shop` 容量基线

本节把前五节收敛成一份可执行实验。目标不是发布一个炫目的 TPS,而是完整回答:

在记录过的 Pigsty 教学沙箱上,pg36_shop 的固定读写混合,面对三档合成 数据规模和一、八两个并发档位时,吞吐、延迟、CPU、I/O、WAL 与空间怎样变化?

实验边界刻意很窄:

environment   Pigsty disposable teaching sandbox
server        pg-test-1, primary, 1 vCPU, about 2 GiB
client        pg-meta-1, separate host, 2 vCPU, about 4 GiB
PostgreSQL    18.6
path          direct primary :5432
workload      synthetic shop-mix-v1
arrival       closed-loop, zero think time
protocol      prepared, persistent connections
scale         S / M / L
clients       1 / 8
repetitions   5 per cell

它不测 HAProxy、PgBouncer、应用、WAN,也不审批生产容量。完整合同在 lab-contract.md,公共参考结果在 capacity-run.json

26.6.1 运行三个规模和两个并发档位

先辨认风险等级

本章 runner 有六个动作:

动作 等级 行为
lint L0 本地校验合同和反例
capture L0 只读采集目标身份与环境
exercise L2 bounded 创建、装载、压测、清理专用 fixture
verify L0 验证已有证据
review L0 独立复算与审阅已有证据
all L2 bounded 顺序执行完整流程

exercise/all 会真实消耗 CPU、I/O、WAL、复制与归档资源。只允许在 disposable teaching sandbox 运行,绝不能把“只创建测试库”误判为 L0。

runner 只接受:

cluster       pg-test
primary       pg-test-1 / 10.10.10.11
database      pg36_capacity
role          dbuser_pg36bench
environment   pg36-l2-vagrant disposable teaching sandbox

若目标身份、primary、marker、预存对象或 session 状态不符合合同,它会 fail closed,而不是“尽量继续”。

fixture 与权限边界

实验创建两个一次性对象:

database  pg36_capacity
login     dbuser_pg36bench, non-superuser

库和角色都带本次 run 的 shared-object comment marker。清理前必须重新读回 marker, 并确认没有非本章会话;清理不用:

DROP DATABASE ... WITH (FORCE)

也不终止其他 session。

Pigsty 当前内网 HBA 通过 +dbrole_readonly 角色组识别业务连接。runner 不修改 HBA; 它把临时 login 加入 dbrole_readwrite,后者已间接属于 readonly 组,同时把临时 membership 设置为:

INHERIT FALSE
SET FALSE

这个 membership 只参与 HBA member 判断,不允许 benchmark login 继承或切换成平台 角色。实验结束后角色与 membership 一并删除。

这条细节揭示了压测中常见的错误:为了“让测试先跑起来”修改 HBA、使用超级用户, 最终测到的 connection/auth path 与生产不同,还留下永久权限。

数据规模

setup.sql 用固定 seed 生成三档 synthetic data:

scale factor customers products historical orders
S 1 10,000 2,000 100,000
M 8 80,000 16,000 800,000
L 32 320,000 64,000 3,200,000

参考 run 实际初始化结果:

scale schema bytes database bytes initialization
S 29,704,192 37,869,247 2.17 s
M 235,175,936 243,594,943 17.09 s
L 942,268,416 950,982,335 84.82 s

三档不是简单复制行数。每次切换 scale 都重新生成、ANALYZE 并捕获 row count、 relation size 与统计 provenance。压测报告必须保存 observed size,而不是只写 scale=32

事务合同

operation weight key distribution transaction
read-product 50% product Zipf 1.15 商品与库存点查
read-order 30% customer Zipf 1.08 最近五个历史订单
place-order 20% customer uniform、product Zipf 1.10 行锁、扣库存、追加订单

三个 pgbench script 分开保存:

每个 script 自己定义 transaction 边界。place-order 的锁和库存检查是 workload 语义的一部分,不应为了提高 TPS 删除。

实验矩阵

每个 scale 分别运行 c1、c8,每个 cell 五次:

3 scales × 2 client counts × 5 repetitions = 30 measured runs

每次:

5 s natural warm-up
8 s measured window
prepared protocol
persistent connection
zero think time
250 ms latency limit
max tries = 1

并发顺序做 counterbalance,seed 由公开公式从 scale、clients、repetition 推导。 这不能消除所有 background noise,但能避免总是先跑 c1、后跑 c8 的固定时间偏差。

runner 明确禁止:

statistics reset
OS cache drop
forced checkpoint
PostgreSQL restart
failover
autovacuum pause
archive/replica/backup pause
fsync or synchronous_commit disable
unlogged production-equivalent tables

如果 baseline 只有在关掉 durability 后才“通过”,它没有测量目标系统。

运行方法

先做不连接目标的本地检查:

static/labs/ch26/task.sh lint

完整实验要使用一个不存在或为空的 私密绝对路径

export PG36_EVIDENCE_DIR=/absolute/private/new-empty/ch26-run
static/labs/ch26/task.sh all

也可分步:

export PG36_EVIDENCE_DIR=/absolute/private/new-empty/ch26-run
static/labs/ch26/task.sh capture
static/labs/ch26/task.sh exercise
static/labs/ch26/task.sh verify
static/labs/ch26/task.sh review

capture/all 拒绝覆盖非空 evidence directory。verifyreview 可以反复读取 已有 evidence,不重跑 workload。

不要把私密目录放进 web root 或 Git。它包含 raw transaction log、system sample、 SQL snapshot 和监控响应;公共仓库只提交经过 allowlist 的摘要。

参考 run 的结果

正式参考 run:

run id          6c44ebdb-2206-48c3-8089-d90fdff45204
transactions    511,709
measured runs   30
failures        0
late            0
deadlocks       0
temp bytes      0

每个 cell 的 TPS 是五次 repetition 的中位数;括号是 repetition bootstrap 95% 区间。latency quantile 从该 cell 的 raw transaction sample 合并后重算,不是 五个 p95 再取平均。

cell TPS median (bootstrap 95%) p50 p95 p99 max
S-c1 1,508.5 (1,446.5–1,546.3) 0.334 2.152 2.595 20.434
S-c8 2,920.5 (2,869.3–3,005.1) 1.334 9.448 12.927 56.481
M-c1 1,555.8 (1,462.0–1,570.1) 0.330 2.123 2.598 10.774
M-c8 2,911.5 (2,755.4–2,930.3) 1.370 9.398 12.571 41.174
L-c1 1,423.6 (1,226.1–1,431.4) 0.360 2.259 2.847 30.307
L-c8 2,774.3 (2,216.1–2,824.4) 1.447 10.087 14.494 94.330

latency 单位均为 ms。L-c8 的第一次 repetition 约 2,216 TPS,后续约 2,740–2,824 TPS;实验没有把它删成“异常值”。它可能包含初始化/cache/background 效应,宽区间本身就是证据,下一轮应延长 duration 并增加 repetition。

PostgreSQL 官方 pgbench good practices 明确提醒,数秒测试不能得到可信的平均值,且 client machine 也可能成为瓶颈。 所以这组 8 s × 5 结果用于教学如何建立证据管道和发现下一问题,不是 production characterization。

26.6.2 用 Pigsty 与原生视图解释饱和点

四层证据各回答一个问题

每个 measured run 同时采集:

pgbench transaction log
  -> client observed completion and latency distribution

PostgreSQL start/end snapshots
  -> committed work, blocks, temp, deadlock, WAL delta

PostgreSQL wait samples
  -> active session currently executing or waiting for what

client/server OS samples
  -> CPU work/iowait and load-generator saturation

Pigsty time series
  -> full exercise context and independent corroboration

不能用其中一层替代所有层。例如 p95 增加并不能单独证明 CPU 饱和;CPU 84% 也不能 单独证明 p95 来自 CPU queue。

sampler 必须严格裁剪 measured window

原始 sampler 从 warm-up 前启动、pgbench 后结束是正常的,但 cell arithmetic 必须 只使用:

measured pgbench start <= sample timestamp <= measured pgbench end

参考实现保存 start/end monotonic timestamp,并对 client/server sample 严格裁剪。 如果把 pgbench 结束后两秒 idle tail 算入 CPU median,单位事务 CPU 会被系统性低估。 增加 repetition 无法修复这种 systematic error。

Pigsty query 的 full exercise window 则故意保留:

initialization
warm-up
measured runs
between-run gaps
cleanup

因此:

native per-run evidence
  authoritative for cell arithmetic

Pigsty full-window evidence
  authoritative for context and corroboration

两个窗口不能直接做逐项相等断言。

server 已进入高 CPU 区域,client 没有饱和

参考结果:

cell server work median server iowait median client work median
S-c1 48.94% 7.66% 17.74%
S-c8 84.19% 0.21% 29.50%
M-c1 50.10% 7.52% 17.79%
M-c8 83.96% 0.20% 29.07%
L-c1 46.31% 8.20% 16.17%
L-c8 84.22% 0.31% 27.81%

client 是独立 2-vCPU 主机,c8 work ratio 仍低于 30%;当前证据反对“load generator 已先饱和”这一解释。server 只有 1 vCPU,c8 约 84% work,CPU pressure 是合理的 候选瓶颈。

但不能立即下结论:

root cause = CPU

还要检查 active/wait、lock、WAL sync、I/O、context switch 与 plan。单核上的 84% aggregate work 也不等于每一毫秒都有可运行 SQL。

throughput 增长约 1.9 倍,p95 放大约 4.4 倍

scale c8/c1 TPS c8/c1 p95
S 1.936x 4.390x
M 1.871x 4.427x
L 1.949x 4.465x

解释:

c1 -> c8
  throughput still rises materially
  tail latency rises much faster
  server CPU moves toward high utilization

这说明 queue/competition 已增加,却仍不能定位精确 knee。只有两个并发点:

exact_knee_known = false
interpretation = knee-not-bracketed-by-one-and-eight-clients

下一轮至少补:

c2 c4 c8 c12 c16 c24 c32

并用更长 duration。若目标是 arrival SLO,再做 open-loop rate sweep,记录 offered、 achieved、late、skipped、failure 和 schedule lag。

数据规模开始改变 I/O 行为

cell block reads block hits
S-c1 0 675,976
S-c8 0 1,323,054
M-c1 0 733,198
M-c8 0 1,326,143
L-c1 2,500 629,335
L-c8 14,491 1,344,632

S/M 的 measured window 没记录到 PostgreSQL block read,L 则开始读 block。这里的 边界是:

  • 自然 warm cache,不代表 cold start;
  • PostgreSQL block read 可能由 OS page cache 服务;
  • 短窗口未覆盖完整 working set;
  • aggregate hit ratio 会掩盖特定 table/index;
  • 虚拟磁盘不能代表 production storage。

下一轮可以选择:

保持自然状态,延长到跨越多个 working-set cycle
增加 XL 数据量,使 working set 显著超过 RAM
分别测试 steady warm 和 restart-recovery 场景
从 pg_stat_io 分解 relation/temp/WAL context

不要为了“可重复 cold cache”在共享主机随意 drop_caches。那是高影响系统动作, 而且同时改变整台主机。

WAL 与持久化证据

cell WAL bytes/mixed tx durable bytes/place-order
S-c1 160.33 415.21
S-c8 159.53 409.12
M-c1 175.95 412.81
M-c8 161.88 405.97
L-c1 157.56 411.50
L-c8 196.18 407.88

这些值是窗口 delta 除以相应分母,不是 PostgreSQL 配置常量。L-c8 的 WAL/tx 较高 可能与 page state、full-page images 或背景活动有关;仅凭 aggregate delta 不能 定位。下一轮应同时比较:

wal_records
wal_fpi
wal_bytes
checkpoint timing
per-operation successful count

实验没有关闭 fsyncfull_page_writessynchronous_commit,也没有强制 checkpoint。若要研究 checkpoint phase,应把它作为显式 factor,而不是偷偷在每次 run 前 checkpoint。

lock、failure 与 temp 的反证

30 个 measured run 中:

failed transactions  0
late transactions    0
deadlocks            0
temp bytes           0

这反对“当前点因 deadlock/temp spill 失效”,但不能证明:

there is no lock wait
there can never be a deadlock
all queries are memory-safe
250 ms SLO will hold at higher offered load

deadlock counter 只计被 detector 处理的 deadlock;普通 row-lock queue 不是 deadlock。temp_bytes=0 也只针对本窗口与当前 plan/data。

在 Pigsty 中做独立旁证

Pigsty 的 PGSQL dashboards 把 Overview、Activity、Persist、Database、Table、Query 等视角连接起来。实验时 至少对齐:

PGSQL Activity
  active/wait/backend state

PGSQL Persist
  WAL/checkpoint/archive/replication

PGSQL Database
  commit/rollback/cache/temp/deadlock

PGSQL Table / Query
  relation workload and statement behavior

NODE / disk dashboards
  client and server CPU, disk, network

参考 run 的 Pigsty full-window 摘要:

signal median maximum
server CPU busy 64.37% 100.00%
server CPU iowait 2.77% 5.10%
client CPU busy 23.00% 28.97%
server disk read 0.41 MiB/s 13.34 MiB/s
server disk write 4.69 MiB/s 77.45 MiB/s
WAL generation 44.04 MiB/s
database commit 2,459.53/s
replica replay byte gap 0 median 3,471,848 max

full-window 含初始化和间隔,所以 commit max 不应等于 cell TPS,disk write max 也不应 除以 measured transaction。它们用于确认“什么时候发生了什么”和检查观测层是否 互相矛盾。

replica replay byte gap 最大约 3.47 MiB,不能直接转译为:

replica stale for N milliseconds

byte gap、replay time lag、应用 freshness 与 synchronous durability 是不同语义。 容量实验只记录这一旁证,没有声称 replica correctness。

建立 hypothesis table

这次实验后的调查表:

假设 支持证据 反证/缺口 下一实验
c8 有 CPU pressure server work ~84%、client <30% 无 run queue/更密曲线 c2–c32 sweep
client 先饱和 client work <30% 增大 clients 并监控 client queue
L 进入数据读路径 block reads >0 可能命中 OS cache XL、pg_stat_io、更长运行
lock 是主瓶颈 写事务会行锁 无 deadlock,wait 需更细分 提高热点/写比例
WAL 限制 throughput L-c8 WAL/tx 增加 iowait 低,缺 WAL fsync tail write-only + checkpoint factor
exact knee 已知 仅 c1/c8 密集 closed/open-loop sweep

这张表比“CPU 是瓶颈”更有用,因为它告诉下一笔实验预算该花在哪里。

26.6.3 输出可复现实验报告、容量模型和未知项

私密 evidence bundle

完整运行产生:

preflight-evidence.json
remote-benchmark.log
remote/
  capacity-evidence.json
  initialize-{S,M,L}.txt
  runs/<cell-run>/
    pgbench.stdout
    pgbench.stderr
    transactions.*
    stats-before.json
    stats-after.json
    client-system.jsonl
    server-system.jsonl
    database-waits.jsonl
remote-cleanup.json
validation-report.json
negative-report.json
public-summary.json
review.txt

证据包目录权限为 0700,文件为 0600。review 会检查 mode,避免 raw evidence 因默认 umask 变成多用户可读。

raw transaction log 对 latency 重算必不可少,却可能含时间、client/session 与 workload 行为;SQL snapshot、queryid 明细和监控 payload 也不应直接公开。发布流程 是:

private raw evidence
  -> schema and invariant validation
  -> independent recomputation
  -> explicit allowlist
  -> public capacity-run.json

不是“从 raw JSON 删除几个看起来敏感的键”。

报告中的 provenance

一份可复现实验报告至少固定:

run id and timestamps
target identity and topology
client/server hardware
PostgreSQL/Pigsty/OS version
settings with source
schema and source hashes
data generator and seeds
connection path and protocol
workload scripts and weights
arrival model and retry policy
warm-up/duration/repetition/order
measured-window boundaries
raw artifact inventory
cleanup evidence
known unknowns and claim gate

参考 run 验证了 255 个 raw artifact,并对约 21.8 MB evidence 做 secret scan。这个 数字不是质量本身;它的意义在于 manifest 能证明报告引用的 sample 没有悄悄缺失。

对抗性验证

negative-cases.json 定义了 26 个变体,validator 必须逐一拒绝。类别包括:

wrong target / primary / environment
pre-existing or marker-mismatched fixture
client and server accidentally same host
missing runs or unbalanced matrix
wrong script weight / seed / protocol
statistics reset
sampler outside measured window
client saturation hidden
quantiles averaged instead of recomputed
failed/late/skipped hidden
cleanup unproven
raw/query/secret fields leaked into public summary
exact knee or production TPS fabricated

“positive fixture 能通过”只证明 happy path;错误 evidence 也能通过的 validator 不能保护结论。

参考 run 的验收:

positive validation       passed
counterexamples rejected  26 / 26
database cleanup          verified
role cleanup              verified
remote temp cleanup       verified
nonchapter sessions killed 0
production gate           pending

容量模型只发布条件结论

公共报告给出的 65% CPU 投影:

scale CPU-s/mixed tx conditional TPS at 65% CPU
S 0.000291 2,236.79
M 0.000292 2,222.96
L 0.000307 2,116.98

它依赖四个显式假设:

50/30/20 mix unchanged
CPU demand near observation scales linearly
cache/IO/lock/client/background work does not become tighter
same recorded sandbox

所以报告写:

{
  "production_sustainable_tps": null,
  "exact_knee_known": false
}

null 不是实验失败,而是 claim 与 evidence 对齐。

未知项就是下一轮 backlog

本轮没有回答:

  1. c8 左右的精确 knee 在哪里;
  2. open-loop offered rate 下的 completion、late、skipped 与 queue;
  3. 真实应用经 HAProxy/PgBouncer/TLS 的端到端成本;
  4. realistic think time、connection churn、retry storm;
  5. 真实 query 和 tenant/key skew;
  6. cold/restart、working set 超 RAM 与 production storage;
  7. checkpoint、autovacuum、backup overlap;
  8. replica loss、failover、rebuild、archive outage;
  9. 长稳态下 bloat、WAL、slot 与空间增长;
  10. production hardware 的 N+1 SLO envelope。

将它们排成下一轮最小实验:

Experiment A
  same sandbox, M scale
  c2/c4/c8/c12/c16/c24/c32
  5 min × repetitions
  purpose: bracket knee

Experiment B
  rates around accepted SLO point
  open-loop, realistic timeout/retry
  purpose: offered-load envelope

Experiment C
  direct / PgBouncer / HAProxy / application path
  same workload and target
  purpose: attribute service-path cost

Experiment D
  accepted load + maintenance/failure scenarios
  purpose: validate headroom

Experiment E
  production-like hardware and data
  N0 and N1
  purpose: production capacity decision

一次只改变少量 factor,保留 control;否则结果发生变化时无法归因。

独立练习

练习一:改变 mix。

把写比例从 20% 提到 40%,保持总权重 100%。运行 lint,解释哪些合同 hash、WAL 模型 和 capacity claim 必须失效。不要直接修改并覆盖正式参考结果。

练习二:增加并发点。

复制 experiment contract 到个人分支,加入 c2/c4/c12/c16,设计 counterbalanced order。 先写出“怎样才算 bracket knee”的 machine-checkable rule,再运行。

练习三:检查窗口偏差。

从一个私密 evidence bundle 取某次 run,分别计算:

all sampler records CPU median
strict measured-window CPU median

解释 idle tail 对 CPU-s/transaction 和 65% 投影的方向性影响。

练习四:设计 production gate。

为自己的应用补齐:

SLO
operation catalog
arrival forecast
N+1 topology
maintenance overlap
lead time
pass/fail rule
stop condition

如果仍写不出 production sustainable TPS,保留 null,并列出最小缺失证据。

完成检查表

  • 能区分 business request、database transaction 与 SQL statement。
  • workload mix、arrival model、key distribution 和 connection path 已固定。
  • client 与 server 分离,且两侧都监控。
  • warm-up、duration、repetition、seed 与 run order 可复现。
  • per-run sample 严格裁剪 measured window。
  • quantile 从 raw sample 重算,没有平均 p99。
  • PostgreSQL counter 以 snapshot delta 计算,没有 reset shared stats。
  • Pigsty full-window 指标没有冒充 cell arithmetic。
  • failure、late、skipped、deadlock、temp 与 cleanup 均显式报告。
  • unit resource cost 的分母是明确 operation。
  • exact knee 不由两个并发点伪造。
  • production TPS 在生产证据不足时保持 null
  • 未知项已变成带目的和 pass/fail rule 的下一实验。

做到这些,容量压测才从“跑过一张 TPS 截图”变成可重复、可反驳、可用于决策的工程 过程。


上一节:从测量推导容量与成本 · 返回本章目录 · 下一章:精益求精:参数调优与资源治理 · 查看全书目录 · 查看索引中心

27 精益求精:参数调优与资源治理

调优不是把一份“最佳参数”复制到 postgresql.conf

同一个参数变化可能:

让一条查询更快
  但让十条并发查询触发 OOM

减少 WAL
  但增加 primary CPU 和 replica replay CPU

提高单条分析查询速度
  但占满 parallel worker,让 OLTP tail latency 变差

增加连接槽
  但把 pool 中可控的队列搬进数据库

在 benchmark 中提高 TPS
  但降低 durability、HA headroom 或 recovery predictability

本章用一条严格的推理链替代参数清单:

service objective
  -> observed bottleneck or resource risk
      -> parameter mechanism
          -> scope and precedence
              -> one-factor experiment
                  -> benefit + non-regression + failure behavior
                      -> rollback
                          -> ADR

如果中间缺了一环,默认动作不是“先改了看看”,而是补证据。

参数不是独立旋钮

PostgreSQL 参数形成资源系统:

shared_buffers
  <-> OS page cache
  <-> checkpoint dirty-page work
  <-> max_wal_size

work_mem
  × active operations
  × sessions
  × parallel processes
  × hash_mem_multiplier

max_connections
  <-> backend/shared memory
  <-> pool queue
  <-> lock/context-switch pressure

parallel workers
  <-> CPU
  <-> work_mem
  <-> worker pool
  <-> other queries and maintenance

checkpoint / WAL
  <-> write smoothing
  <-> WAL volume
  <-> archive/replica
  <-> crash recovery time

所以本章不按字母解释 GUC,而按资源预算和失效机制组织。

本章实验故意得到“拒绝改参”

第 26 章参考 run 提供了几个事实:

  • c8 时 server work ratio 约 84%,client 未先饱和;
  • workload 使用 prepared protocol;
  • 六个 cell 的 temp bytes 全为零;
  • L 档有 block read,但 server iowait 很低;
  • 只有 c1/c8,精确 knee 未知;
  • production sustainable TPS 仍为 null

这些事实不支持

increase work_mem
increase shared_buffers
increase max_connections
disable synchronous_commit

它们只使一个假设值得被证伪:

prepared OLTP 在 plan_cache_mode=auto 下是否仍承担了足够的 custom planning 成本,使 force_generic_plan 获得至少 2% 的稳定收益?

正式实验只改变这个参数,而且只用 PGOPTIONS 改 benchmark session:

baseline   plan_cache_mode=auto
candidate  plan_cache_mode=force_generic_plan

固定 M 数据、8 clients、prepared、50/30/20 mix;五组 paired repetition 使用相同 seed,A/B 顺序交错。共 10 次 12 秒 measured run、357,685 笔事务:

arm median TPS pooled p50 pooled p95 pooled p99 failure/late/skipped
auto 2,981.72 1.279 ms 9.217 ms 13.127 ms 0
force_generic_plan 3,051.72 1.285 ms 9.135 ms 12.933 ms 0

只看两个 median,candidate 好像快 2.35%。但正确的配对分析是:

paired TPS ratio median       1.00735
bootstrap 95% interval        [0.98082, 1.02775]
candidate / baseline p95      0.99110
required bootstrap lower      >= 1.02

candidate 没有证明至少 2% 的稳定收益。plan probe 还发现:

auto:
  first 5 custom + next 5 generic

force_generic_plan:
  10 generic + 0 custom

PostgreSQL 的 auto 已经为这两类稳定 plan 自动转向 generic。强制 generic 只省掉 少量早期 planning,却可能伤害参数敏感查询。最终 ADR:

decision                    reject-persistent-change
persistent change applied   false
production gate             pending

这不是“没有调优成果”。它避免了一项没有稳定收益、却扩大 plan risk 的持久变更。

公共结果见 tuning-run.json,完整安全边界见 lab-contract.md

本章学习成果

完成本章后,你应该能:

  1. 从 SLO、bottleneck 和 non-regression 指标写调优假设;
  2. 区分参数、SQL/schema、workload 与 topology 问题;
  3. 为 shared memory、per-operation memory、maintenance 与 OS 留出完整预算;
  4. 解释 work_mem × 节点 × 并发 × parallel process 的放大;
  5. 用 WAL、checkpointer、I/O 与 recovery evidence 调整 checkpoint;
  6. 区分 effective_cache_size estimate 与真实 cache allocation;
  7. 校准 cost parameter,而不是用 enable_seqscan=off 长期逼 planner;
  8. 计算 parallel worker 的 cluster budget 和降级行为;
  9. 把 connection limit 与 pool/admission、reserved slot 和 emergency access 联动;
  10. pg_settings.context/source/pending_restart 判断变更方式;
  11. pg_file_settings 找出 syntax error 与被后项覆盖的配置;
  12. 理解 system、database、role、role-in-database、session、transaction 的覆盖关系;
  13. 用 Pigsty template、pg_parameters 与 IaC 保持 desired state;
  14. 在 reload/restart/rolling change 前写 failure、rollback 与 validation;
  15. 接受“拒绝修改”也是合格 ADR。

本章目录

27.1 调优是一套实验方法

27.2 内存预算

27.3 WAL、检查点与写入平滑

27.4 规划器、并行与连接参数

27.5 参数作用域与变更方式

27.6 模板参数与集群变更

27.7 实战:只调一个已证实的瓶颈

阅读路线

应用开发者:

27.1 -> 27.2.2 -> 27.4 -> 27.5.2 -> 27.7

重点是 per-query memory、plan、timeout、role/database scope 和 A/B。

平台工程师:

27.1 -> 27.2 -> 27.3 -> 27.5 -> 27.6 -> 27.7

重点是 cluster resource budget、WAL/recovery、配置来源、IaC 与 rolling risk。

两条路线必须合流:应用知道 transaction 与 query shape,平台知道 failure domain 与 global resource envelope;任何一方单独调参都容易优化局部、破坏整体。

实验文件

static/labs/ch27/
├── requirements.json
├── parameter-candidates.json
├── change-contract.json
├── negative-cases.json
├── topology.mmd
├── lab-contract.md
├── setup.sql
├── reset-run.sql
├── read-product.sql
├── read-order.sql
├── place-order.sql
├── plan-probe-counts.sql
├── plan-probe-product.sql
├── plan-probe-order.sql
├── remote_experiment.py
├── capture.py
├── exercise.py
├── validate.py
├── review.py
├── task.sh
└── tuning-run.json

实验:

  • 只测试一个参数;
  • 只在 benchmark session 生效;
  • 不使用 ALTER SYSTEM、DCS edit、reload/restart;
  • 保留 raw transaction log 与 plan-shape evidence;
  • 用 28 个对抗变体验证 target、scope、配对、quantile、decision 和 cleanup;
  • 专用 database、role 与远端临时目录全部清理;
  • 不管 candidate 接受或拒绝,production gate 都保持 pending

参考资料


上一章:胸有成竹:容量规划与压测基线 · 返回下卷导读 · 下一章:除旧布新:VACUUM、冻结与膨胀治理 · 查看全书目录 · 查看索引中心

27.1 调优是一套实验方法

参数调优回答的不是:

这个参数设多大最好?

而是:

在哪一个 workload、SLO、硬件、版本和失效状态下,改变哪个机制,能以可接受的 副作用改善哪一个目标?

这句话里每一项都不能省。

一个参数没有脱离上下文的最优值:

work_mem=256MB

对一条串行分析查询可能合理;对 300 个并发 backend、每条 plan 有多个 hash/sort、 每条还启动 4 个 parallel worker 的系统,可能是 OOM 设计。

调优因此和第 26 章容量实验使用同一套科学方法,只把 factor 从 workload/load 换成 GUC:

question
  -> mechanism hypothesis
      -> baseline
          -> one controlled change
              -> repeated measurement
                  -> effect + uncertainty
                      -> non-regression
                          -> accept / reject / inconclusive

27.1.1 先定义目标、瓶颈和不可退化指标

先写 objective function

“数据库变快”不可验收。把目标写成有 scope 的 metric:

workload: checkout-v7
traffic: 1800 offered requests/s
dataset: 1.2 TiB, tenant skew P95
objective:
  checkout_p95_ms: "<= 120"
  completion_ratio: ">= 99.95%"
constraints:
  replica_freshness_p95_s: "<= 2"
  archive_backlog_recovery_min: "<= 15"
  node_memory_available_gib: ">= 8"
  cpu_busy_p95: "<= 65%"
  durability: "local WAL flush before success"
failure_state:
  - normal
  - one_read_replica_lost

没有 constraints 的“优化”会把成本转移到别处。

metric 分四类

类别 例子 作用
primary objective p95、throughput、batch duration 想改善什么
correctness rows、checksum、serialization result 不能算错
safety OOM、disk、WAL、replica/archive gap 不能失控
operability restart、rollback、recovery、observability 不能不可运维

性能 A/B 必须同时校验结果正确。一次 hash join 因错误 filter 少处理 30% 行而“快了” 不是调优。

找 service center,不找最高指标

候选瓶颈:

CPU execution
planning/JIT
data read
WAL write/sync
lock and transaction queue
pool/admission queue
memory spill/reclaim
checkpoint/writeback
replica/archive backpressure
network/client

每个假设至少需要:

symptom
mechanism evidence
corroborating resource evidence
plausible counter-evidence

例如:

假设 symptom PostgreSQL OS/Pigsty 反证
sort spill p95 随数据量跳升 EXPLAIN disk、temp blocks disk temp I/O 无 Sort/Hash 节点
WAL sync commit tail WAL I/O wait WAL device fsync tail CPU saturated
CPU throughput knee active/no wait、exec time busy/run queue client/pool/lock wait
lock long tail blockers/wait event CPU 可低 无 lock queue

“CPU 84%”本身不是参数假设。它只说明 CPU 是候选 service center。还要问:

CPU 花在 executor、planner、JIT、compression、TLS、context switch,
还是 client/hypervisor 计费误差?

把 evidence 映射到 parameter mechanism

observed spill
  -> work_mem/hash_mem_multiplier candidate

requested checkpoints too frequent
  -> max_wal_size/checkpoint_timeout candidate

full-page-image WAL dominates
  -> checkpoint interval/wal_compression candidate

planner systematically misprices random I/O
  -> cost parameter candidate

worker launch starvation
  -> worker-pool budget candidate

反例:

slow query
  -> work_mem?

high CPU
  -> shared_buffers?

high connection count
  -> max_connections?

问号前缺少机制,不应进入变更。

不可退化指标要在实验前写

如果跑完才决定什么算 regression,人会自然挑对 candidate 有利的解释。预先写:

correctness equality
failure/timeout/skipped
p50/p95/p99/max
CPU and memory
temp and WAL bytes
lock waits/deadlocks
replica/archive lag
backup/maintenance duration
recovery behavior

某些指标是 hard gate:

wrong result             reject
durability changed       reject or separate product decision
OOM / restart            reject
unbounded WAL retention  reject
rollback unproven        reject

某些是 tradeoff:

batch 20% faster
OLTP p95 2% slower
CPU 8% higher

是否接受取决于预先声明的 objective 和 budget。

baseline 必须仍然存在

“改前数据是上个月 dashboard,改后是今天”不是 A/B。至少固定:

source/version
hardware/topology
data snapshot/generator
statistics
workload/arrival
connection path
background policy
measurement window

对于 restart 参数,不能同时:

升级 minor version
修改 kernel
换 storage
ANALYZE 全库
改参数

然后把差异归给参数。

27.1.2 一次改变一个机制并准备回退

one factor 不一定等于 one GUC

有些机制需要一组一致变化:

parallel worker budget:
  max_worker_processes
  max_parallel_workers
  max_parallel_workers_per_gather

它们可以作为一个 factor,但要明确:

  • 为什么必须一起变化;
  • 每项如何约束同一机制;
  • 哪项是 hard upper bound;
  • 如何整体回退。

相反,同时改:

work_mem
random_page_cost
max_connections
checkpoint_timeout

是四个机制,不能从结果归因。

优先用最小作用域证伪

PostgreSQL 的 parameter context 决定最小 scope。若参数允许 user context,先尝试:

BEGIN;
SET LOCAL work_mem = '32MB';

EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, FORMAT JSON)
SELECT ...;

ROLLBACK;

或为一次客户端进程:

env PGOPTIONS="-c plan_cache_mode=force_generic_plan" \
  pgbench ...

PostgreSQL 官方 Setting Parameters 说明,session SET 和 libpq PGOPTIONS 不影响其他 session。这使证伪成本和 blast radius 最小。

但“session 能设”不代表生产应由每个请求随便设。通过实验后仍要决定:

query/transaction
role-in-database
role
database
instance
cluster

哪个 scope 与 ownership 一致。

paired experiment

当环境噪声随时间变化,A/B 配对比“先跑十次 A,再跑十次 B”更可靠:

pair 1  A -> B, same seed/data
pair 2  B -> A, same seed/data
pair 3  A -> B
pair 4  B -> A

每对 effect:

$$ r_i

\frac{X_{B,i}}{X_{A,i}} $$

报告:

median(r_i)
bootstrap interval of median(r_i)

不是:

median(B)median(A) \frac{\text{median}(B)} {\text{median}(A)}

二者在配对噪声下可能不同。

最小显著收益

统计上可分辨不等于工程上值得。预声明:

minimum material gain  2%
measurement noise      assessed from repetition
tail regression budget <= 5%

若 candidate 提高 0.3%,却增加 configuration exception、plan risk 和 review burden, 通常不值得持久化。

第 27 章正式 run 的规则:

paired TPS ratio bootstrap 95% lower >= 1.02
candidate/baseline pooled p95 <= 1.05
plan shapes equal
failure/late/skipped = 0
global settings unchanged

结果 lower bound 0.9808,所以拒绝。

rollback 不是“把值改回去”

回退合同包含:

previous value and source
previous desired-state commit
change scope
whether reload/restart is needed
session/pool lifecycle
HA member order
data/WAL produced under candidate
stop condition
rollback validation

例子:把 synchronous_commit 改回 on,不会重新赋予此前已向客户端确认但尚未 flush 的 transaction durability。参数值能回退,历史语义不能。

增加 max_connections 后又减回去,若当前 session 已超过新上限,现有 session 不会 神奇消失;还要协调 pool 与 reconnect。

变更状态机

proposed
  -> static reviewed
      -> sandbox falsified
          -> canary
              -> observation window
                  -> accepted
                  -> rolled back
                  -> inconclusive

每一步有 evidence 和 owner。不要把“SQL 执行成功”当作 accepted。

stop condition

实验中发现以下任何一项立即停止:

  • correctness mismatch;
  • failed/late/skipped 越界;
  • memory safety floor;
  • replica/archive gap 无界增长;
  • HA role/topology 变化;
  • client saturation;
  • config source/pending restart 异常;
  • cleanup 不再可证明。

stop 后保留失败 evidence,不重新跑到“漂亮”为止。

27.1.3 参数变化不修复错误 SQL 和错误模型

参数是系统 policy,不是 query patch

慢查询的优先顺序通常是:

correctness and transaction semantics
  -> query shape
      -> schema/index/statistics
          -> workload/admission
              -> parameter policy
                  -> hardware/topology

不是所有问题都严格按此顺序解决,但越靠前的错误越不应由全局参数掩盖。

cardinality 错误

planner 估 10 行,实际 10,000,000 行,可能选择 nested loop。把:

enable_nestloop = off

设为全局只是在压制一个 plan type。更好的证据路径:

ANALYZE freshness
per-column statistics target
extended statistics
correlated predicates
parameter-sensitive plan
expression/cast mismatch

PostgreSQL Query Planning 也把 enable_* 描述为粗粒度临时影响手段,并优先建议统计与 cost calibration。

缺索引或错误索引

random_page_cost 调低不会创建缺失的 access path。它可能让 planner 更偏爱现有 索引,但:

  • index key/order 不支持 predicate/order;
  • expression/collation/type 不匹配;
  • partial predicate 无法证明;
  • selectivity 太低;
  • index-only scan visibility 不足;

仍然存在。

无界查询

没有 pagination/limit、一次返回数百万行:

work_mem ↑
parallel workers ↑

可能让数据库更快地向网络和客户端制造巨大结果集,但没有修复 API contract。

长事务

idle_in_transaction_session_timeout 可以限制 idle transaction 伤害,却不应代替:

  • 应用 transaction boundary;
  • retry/timeout handling;
  • connection return;
  • cursor lifecycle;
  • batch chunking。

timeout 是最后防线,触发时 transaction 被中断;它不是无副作用的清理器。

N+1 与 chatty workload

max_connections 从 100 提到 1,000,不会把 1,000 次 serial round trip 变成一条 set-based SQL。它只允许更多 N+1 同时进入。

数据模型不匹配

把大 JSON 文档塞进一行,再频繁修改一个字段,可能带来:

TOAST rewrite
WAL amplification
index expression cost
MVCC churn

wal_compression 或 checkpoint 参数只能改变代价的一部分,不能修复 update model。

durability 不是性能参数

以下变化会改变业务承诺:

fsync=off
full_page_writes=off
synchronous_commit=off
unlogged table

它们不能与普通性能参数放在同一 A/B 后,仅因 TPS 更高就接受。

synchronous_commit=off 在某些明确允许“数据库 crash 时丢失最近成功确认事务”的 业务中可以成为产品设计,但需要:

business owner
loss window
idempotent recovery
observability
failure drill

而不是 DBA 的秘密加速。

参数模板是起点

Pigsty 的 tinyoltpolapcrit profile 根据硬件和 workload 类别生成合理 起点;官方 parameter optimization policy 也按场景区分模板。模板不知道:

  • 你的 query;
  • tenant skew;
  • SLO;
  • batch overlap;
  • failure budget;
  • hardware/storage tail;
  • 应用 pool 与 retry。

所以正确流程:

template baseline
  -> measure
      -> hypothesis
          -> scoped experiment
              -> ADR

不是:

internet parameter list
  -> production

一张问题分类表

现象 先查 参数候选出现的条件
单条 query 慢 plan/rows/buffers/I/O 机制已指向 memory/cost/parallel
全局 p95 上升 load/queue/wait/resource service center 已定位
temp 暴增 Sort/Hash、并发 spill 与 memory budget 同时量化
WAL 暴增 write mix/FPI/checkpoint WAL composition 已分解
connection 拒绝 pool/leak/admission demand 与 reserved slot 已设计
replica lag WAL/replay/query conflict source/replay bottleneck 已区分
OOM active plan/node/worker 最坏并发模型已还原

本章后续所有参数都遵循这个约束:先解释机制和预算,再讲值。


返回本章目录 · 下一节:内存预算 · 查看全书目录 · 查看索引中心

27.2 内存预算

PostgreSQL 没有一个叫 total_memory_limit 的总开关。

内存来自多个 scope:

postmaster shared memory
backend private memory contexts
sort/hash/memoize operations
parallel workers
maintenance/autovacuum
logical decoding
temporary table buffers
extension/foreign data wrapper
kernel page cache
monitor/backup/agent

有的是启动时固定,有的是按需分配,有的是 per operation,有的是 per worker。内存 调优首先是预算模型,其次才是参数值。

27.2.1 shared buffers、操作系统页缓存与双重缓存

PostgreSQL 不是绕过 OS 的单一 cache

普通 buffered I/O 路径近似:

query
  -> PostgreSQL shared_buffers
      -> read()/write()
          -> kernel page cache
              -> filesystem/device

同一个 relation block 可能同时存在 shared buffers 和 OS page cache。它不是简单的 “浪费两份”:

  • shared buffers 提供 PostgreSQL page、pin、lock、dirty 与 WAL 协调;
  • OS cache 服务 filesystem read/write、readahead 和其他进程;
  • checkpoint/background writer 把 dirty shared buffer 写给 kernel;
  • kernel 决定何时 writeback 到设备;
  • WAL 有自己的 buffer 与 flush 路径。

所以:

RAM - shared_buffers = OS cache

只是预算近似,不是内核保证。

shared_buffers 是启动参数

SELECT
    name,
    setting,
    unit,
    context,
    source,
    pending_restart
FROM pg_settings
WHERE name = 'shared_buffers';

参考沙箱:

setting          62592
unit             8kB
bytes            512,753,664
server RAM       2,048,679,936
ratio            about 25%
context          postmaster
source           configuration file

context=postmaster 表示需要 server restart 才能改变 effective value。reload 只能让 pending_restart=true,不能扩大正在运行的 shared memory。

PostgreSQL 官方 Resource Consumption 把 dedicated server 的约 25% 视为常见起点,并指出通常超过 RAM 40% 不太可能优于 较小值,因为系统仍依赖 OS cache。这是 starting heuristic,不是 capacity theorem。

为什么不是越大越好

增加 shared buffers 可能:

  • 提高某些 working set 的 PostgreSQL cache residency;
  • 减少 shared buffer miss;
  • 增加启动 shared memory 与 page table;
  • 压缩 kernel cache、backend private memory 和 agent headroom;
  • 增加 checkpoint 要管理的 dirty buffer;
  • 改变 writeback burst;
  • 延长重启/预热行为;
  • 在容器/cgroup limit 下增加 OOM 风险。

更大的 shared buffers 通常还要重新评估 max_wal_size 与 checkpoint write smoothing。只改 cache、不看 WAL/checkpoint,不是一个完整 factor。

cache hit 不等于不读磁盘

pg_stat_database.blks_hit 只表示 requested block 已在 PostgreSQL shared buffers; blks_read 表示 PostgreSQL 发起读取,不代表物理设备一定读了,因为 OS cache 可能 命中。

PostgreSQL hit
  -> no filesystem read for that access

PostgreSQL read
  -> may be OS-cache hit or device read

要组合:

SELECT *
FROM pg_stat_io
ORDER BY backend_type, object, context;

以及:

OS read bytes
device latency/queue
filesystem/cache state
query BUFFERS + I/O timing

aggregate hit ratio 99.9% 也可能掩盖一条关键 query 每次读取 10 GiB。

effective_cache_size 不分配内存

参考沙箱:

effective_cache_size = 187520 * 8KiB
                     = 1,536,163,840 bytes
                     ≈ 1.43 GiB

它是 planner 对“单条 query 可利用的 shared + kernel cache”的估计,不是:

  • shared memory allocation;
  • OS cache reservation;
  • memory limit;
  • 当前 cache occupancy。

官方 Query Planning 明确说明它只影响 estimate。把它从 4GB 改到 64GB 不会获得 60GB RAM,只可能让 planner 更相信 index access 的 cache 命中。

huge page 与 transparent huge page

PostgreSQL 主 shared memory 可以用 explicit huge pages:

SHOW huge_pages;
SHOW huge_pages_status;
SHOW huge_page_size;

huge_pages=try 会尝试,失败时回退;on 失败会阻止 server 启动。它是 restart parameter,需要同时验证 OS reservation、启动和 failover member。

Linux Transparent Huge Pages 与 explicit huge pages 不同。PostgreSQL 官方当前仍 提示 THP 曾对一些版本/环境造成性能退化,不应把“huge page 有益”推导为“THP 永远 开启”。

shared memory inventory

SELECT
    name,
    pg_size_pretty(allocated_size) AS allocated,
    pg_size_pretty(coalesce(free_size, 0)) AS free
FROM pg_shmem_allocations
ORDER BY allocated_size DESC;

它帮助解释 main shared memory 的用途,但不是 host 总内存视图。还要记录:

MemTotal/MemAvailable
cgroup memory.max/current/events
swap
process RSS/PSS
kernel slab/page tables
backup/exporter/sidecar

27.2.2 work_mem 按节点、并发和并行放大

work_mem 是 operation base limit

不是:

per query
per transaction
per session
preallocated fixed reservation

而是 sort/hash 等 query operation 在 spill 前可用的基础限额。官方说明一条复杂 query 可以同时有多个 operation,多 session 又会同时执行,因此总使用量可以是 work_mem 的许多倍。

涉及:

Sort: ORDER BY / DISTINCT / Merge Join
Hash: Hash Join / Hash Aggregate / IN processing
Memoize
parallel worker copies

对 hash operation:

$$ M_{\text{hash limit}}

\text{work_mem} \times \text{hash_mem_multiplier} $$

默认 hash_mem_multiplier=2.0 时,64MB work_mem 对一个 hash operation 的 limit 可能是 128MB。

放大公式

第一阶 upper-bound:

Mquery workoconcurrent operationsLo×Po M_{\text{query work}} \approx \sum_{o \in \text{concurrent operations}} L_o \times P_o

其中:

  • $L_o$ 是 sort 的 work_mem 或 hash 的放大 limit;
  • $P_o$ 是 leader + parallel workers 中实际拥有该 operation 的 process 数。

workload envelope:

MworkloadqAq×Oq×Pq×Lq M_{\text{workload}} \approx \sum_{q} A_q \times O_q \times P_q \times L_q
  • $A_q$:同时 active 的该类 query;
  • $O_q$:可并存的 memory-intensive nodes;
  • $P_q$:leader/worker 放大;
  • $L_q$:相应 node limit。

它是 safety estimate,不代表每次都用满;但用:

max_connections × work_mem

也不够,因为忽略多个 nodes、hash multiplier 与 parallel workers。

参考沙箱的反例

参考事实:

RAM              ~1.91 GiB
shared_buffers   ~489 MiB
work_mem          64 MiB
max_connections  500

天真的:

$$ 500\times64\text{MiB}

31.25\text{GiB} $$

已经远超 RAM。若每个 active query 有两个 hash node,hash_mem_multiplier=2

$$ 500\times2\times128\text{MiB}

125\text{GiB} $$

这不表示 PostgreSQL 启动就分配 125GiB,也不表示 500 个 connection 会同时跑满 两个 hash;它说明:

max_connections=500work_mem=64MB 不能同时作为“安全可用上限”理解。

必须用 pool/admission 保证 active envelope,或按 role/query 缩小 work_mem

plan nodes 不一定同时达到 peak

把 plan 中所有 Sort/Hash 的 limit 相加可能过度保守,因为父子 node 生命周期可能 错开;也可能低估,因为:

  • sibling/subplan 同时存在;
  • executor context 在 node 结束后仍保留一部分;
  • parallel process 独立;
  • multiple portals/cursors;
  • function/extension 额外 memory;
  • query 同时存在于多个 session。

方法:

  1. EXPLAIN (ANALYZE, BUFFERS, SETTINGS) 找真实 node、batch、disk;
  2. 用 concurrency trace 找同时 active 的 query class;
  3. 在 staging 观测 process/container memory;
  4. 注入 worst credible concurrency;
  5. 留 allocator、kernel 和 forecast headroom。

看 sort 与 hash 是否真的 spill

EXPLAIN (
    ANALYZE,
    BUFFERS,
    WAL,
    SETTINGS,
    FORMAT TEXT
)
SELECT ...;

关注:

Sort Method: quicksort  Memory: ...
Sort Method: external merge  Disk: ...
Hash: Buckets / Batches / Memory Usage
Buffers: temp read/written

累计旁证:

SELECT
    datname,
    temp_files,
    pg_size_pretty(temp_bytes) AS temp_bytes
FROM pg_stat_database
ORDER BY temp_bytes DESC;

以及 pg_stat_statements.temp_blks_read/written。注意累计 delta 与 reset/start time。

temp_bytes=0 的正确结论

第 26 章六个 cell 的 temp bytes 都为零。它支持:

do not raise work_mem to fix observed spill

不支持:

64MB is globally optimal
all application queries never spill
work_mem can safely be lowered globally

压测 mix 没覆盖分析、报表、DDL 和 tenant worst case。

用 scope 区分 OLTP 与分析

不要因为一条月报需要 512MB,把全局 default 改成 512MB:

BEGIN;
SET LOCAL work_mem = '512MB';
SET LOCAL statement_timeout = '15min';

SELECT ... monthly report ...;

COMMIT;

或 dedicated role/database:

ALTER ROLE dbuser_report
IN DATABASE analytics
SET work_mem = '256MB';

新 session 才获得 role/database default。connection pool 中已有 server connection 不会立即更新;transaction pooling 还要验证 pool 对 session state 的处理。

temp_file_limit 是 guard,不是内存预算

temp_file_limit 限制一个 process 可使用的 temp file 空间。它能阻止失控 query 写满磁盘,但:

  • 超限会 cancel query;
  • parallel worker/process 语义需测试;
  • 不能防 OOM;
  • 不能代替 statement/admission limit;
  • 太小会杀死合法维护或分析。

把它作为 per-role safety policy,并演练错误处理。

27.2.3 maintenance、autovacuum 与后台进程内存

maintenance 不是一个 session

maintenance_work_mem 服务:

VACUUM
CREATE INDEX
ALTER TABLE ADD FOREIGN KEY
some maintenance operations

单个 session 通常一次只有一个相关 operation,因此它常可高于 work_mem;但 cluster 可能同时有:

manual CREATE INDEX
autovacuum workers
restore-created indexes
reindex jobs
multiple databases/tenants

预算要按并发 maintenance job。

autovacuum 放大

若:

autovacuum_work_mem = -1

每个 autovacuum worker 使用 maintenance_work_mem 作为上限。第一阶预算:

Mautovacuumautovacuum_max_workers×effective autovacuum work mem M_{\text{autovacuum}} \le \text{autovacuum\_max\_workers} \times \text{effective autovacuum work mem}

还没包含 worker base RSS、shared buffer 与 extension。

参考沙箱:

maintenance_work_mem  125952 kB ≈ 123 MiB
autovacuum_work_mem   -1

如果 3 个 worker 同时接近上限,仅这一项约 369MiB。不能因为“一次 VACUUM 很安全” 就忽略多 worker。

parallel maintenance 的语义不同

并行 query 通常按 process 应用 work_mem,而 parallel utility command 的 maintenance_work_mem 一般作用于整个 command,不简单按 worker 倍增;但 worker 仍消耗 CPU/I/O 与其他私有内存。PostgreSQL 官方明确区分这两种策略。

因此不要把:

maintenance_work_mem × max_parallel_maintenance_workers

机械当作精确值,也不要假设 parallel maintenance 没有额外资源。

其他后台预算

组件 参数/上限 风险
logical decoding logical_decoding_work_mem × decoder spill/WAL retention
temp table temp_buffers per session, on demand many sessions
WAL buffers wal_buffers shared start-time/shared
worker process max_worker_processes extensions + parallel
prepared xact max_prepared_transactions shared structures lock/WAL lifetime
replication sender/receiver/plugin queue/buffer/plugin
backup pgBackRest process/buffer/compression DB 外 host memory
monitoring exporter/query DB 外或 backend memory

PostgreSQL 参数表只覆盖 server 进程,不覆盖:

Patroni
HAProxy
PgBouncer
pgBackRest
Prometheus exporters
node agents
kernel cache
SSH/Ansible

Pigsty 节点的 host memory budget 必须把整个平台加进去。

backend memory context

当前 backend:

SELECT
    name,
    type,
    path,
    total_bytes,
    free_bytes,
    used_bytes
FROM pg_backend_memory_contexts
ORDER BY total_bytes DESC
LIMIT 30;

它是瞬时、当前 session 视图。不要从一个 idle psql 推导所有 backend。

对另一 backend,可在授权与日志边界下使用 memory context logging function,但输出 进入 server log,可能很大,生产诊断要有采样和数据处理计划。

27.2.4 OOM 风险必须用最坏并发估算

总预算

一个实用模型:

MhostMshared+Mactive backends+Mquery work+Mmaintenance+Mreplication/extensions+Mplatform+Mkernel+H M_{\text{host}} \ge M_{\text{shared}} + M_{\text{active backends}} + M_{\text{query work}} + M_{\text{maintenance}} + M_{\text{replication/extensions}} + M_{\text{platform}} + M_{\text{kernel}} + H

$H$ 是 safety headroom。

active backend:

Mactive backendsqAq×(Bq+Wq) M_{\text{active backends}} \approx \sum_q A_q \times (B_q+W_q)
  • $A_q$ 是 active concurrency,不是 connection count;
  • $B_q$ 是 backend/query base;
  • $W_q$ 是 node/worker working memory。

idle backend 也有成本,但不能假设都占满 work_mem

credible worst case

不是所有理论 maximum 同时出现,但应至少模拟:

peak OLTP
+ report overlap
+ all autovacuum workers active
+ backup compression
+ one replica rebuilding/catching up
+ exporter/agent normal load
+ traffic retry after failover

把不可能重叠的场景排除时,要有 scheduler/admission 证据。例如:

report queue concurrency = 2
DDL window blocks reports
backup compression jobs = 1
pool active cap = 48

没有 enforcement 的“我们通常不会同时跑”不算边界。

OOM 可能杀谁

Linux OOM/cgroup 可能:

  • kill 某个 backend;
  • kill postmaster/Patroni;
  • kill backup/exporter;
  • 使 node reclaim/swap 抖动,p99 先失守;
  • 导致 HA 将压力转移到 replica;
  • 引发 reconnect storm。

所以 acceptance 不只是“benchmark 没被 kill”。要观察:

MemAvailable
cgroup memory.current/events
PSI memory
swap in/out
major faults
process RSS/PSS
OOM/kernel logs
HA state and reconnect

swap 不是免费 headroom

完全禁用或保留少量 swap 取决于平台 policy,但不能把可 swap 空间计作 database working set。发生 swap 前后,应以 latency/SLO 定义安全线;大量 backend memory 被换出后, 系统可能还活着却不可用。

pool 是 memory governor

PgBouncer/client pool 的关键作用:

many logical requests
  -> bounded active server connections
      -> bounded active query memory

pool size 应从 active workload/resource envelope 推导,而不是等于 max_connections

还要保留:

reserved_connections
superuser_reserved_connections
monitor/replication/maintenance slots
emergency access

参数变更前的内存表

项目 当前 candidate credible concurrency upper estimate evidence
shared memory 1 pg_shmem_allocations
OLTP work plans + active
report work spill test
parallel workers launched
autovacuum workers
DDL/restore runbook
platform/kernel 1 host OS/Pigsty
headroom failure model

表填不完整时,不要把 work_memmax_connections 翻倍。

何时接受

内存 candidate 至少通过:

  • 目标 spill/batch/latency 明确改善;
  • representative concurrency 没有 memory pressure;
  • parallel 与 maintenance overlap 已测;
  • pool/admission enforcement 可证;
  • temp disk 与 OOM guard 合理;
  • N+1/restart/failover 下仍有 headroom;
  • session/role/global scope 正确;
  • rollback 不依赖 OOM 后恢复。

内存参数的目标不是“尽可能不 spill”。spill 有成本,OOM 是失效;工程要在二者之间 找到可控边界。


上一节:调优是一套实验方法 · 返回本章目录 · 下一节:WAL、检查点与写入平滑 · 查看全书目录 · 查看索引中心

27.3 WAL、检查点与写入平滑

写事务的磁盘路径不是“把 table row 写到文件后提交”。

近似链路:

change shared buffer
  -> generate WAL record
      -> insert into WAL buffer
          -> write/flush WAL according to commit policy
              -> acknowledge commit
                  -> data page later written by backend/bgwriter/checkpoint

WAL 让 data page 可以延后写,却把 commit latency、checkpoint、archive、replication 和 crash recovery 连接成一个系统。调其中一项,必须看整条链。

27.3.1 WAL 生成、刷盘与提交延迟

write、flush 与 acknowledge

需要区分:

WAL generated
WAL copied/written to kernel
WAL flushed to durable storage
WAL sent to standby
WAL written/flushed/replayed on standby
client receives success

不同 synchronous_commit level 改变 acknowledge 等待的阶段。它不是“开/关性能”:

  • on:本地 durable,并遵守 synchronous standby 配置;
  • remote_apply:还等待 synchronous standby replay;
  • remote_write:等待 standby 写入 OS;
  • local:等待本地 flush,不等待 synchronous standby;
  • off:允许本地 WAL 尚未 flush 就向 client 确认。

具体 durability 还受 synchronous_standby_namessynchronous_standby_slots、 standby availability 和 application route 影响。不要看到 on 就自动推导 multi-node zero-loss,也不要看到 off 就说“数据会损坏”;它改变的是 crash 时最近 成功确认 transaction 的 loss window。

fsyncfull_page_writes 不是普通 candidate

fsync=off
full_page_writes=off

可能显著提高 benchmark TPS,但会改变 crash safety。PostgreSQL 官方 WAL settings 明确说明,在可能发生 OS/硬件 crash 的系统中关闭相关保护可能导致不可恢复或静默 损坏。

教学 benchmark 若关闭它们,测的是另一个产品合同。

group commit

多个 backend 可以共享一次 flush:

backend A commits
backend B commits nearby
  -> one WAL flush may make both durable

因此:

  • 单 client commit latency 不代表多 client;
  • TPS 与 fsync count 不一定 1:1;
  • storage fsync tail 比平均 throughput 更重要;
  • batch/transaction boundary 改变 group opportunity。

commit_delay/commit_siblings 可以人为等待以扩大 group,但等待本身增加 latency, 只有在高并发、flush 昂贵且实验支持时才考虑。默认不是越大越好。

测 WAL composition

累计层:

SELECT
    wal_records,
    wal_fpi,
    wal_bytes,
    wal_buffers_full,
    stats_reset
FROM pg_stat_wal;

PostgreSQL 18 把 WAL I/O bytes/timing 纳入 pg_stat_io;不要照抄旧版本 pg_stat_wal.wal_write_time/wal_sync_time 查询:

SELECT
    backend_type,
    object,
    context,
    reads,
    read_bytes,
    writes,
    write_bytes,
    fsyncs,
    write_time,
    fsync_time
FROM pg_stat_io
WHERE object = 'wal'
ORDER BY backend_type, context;

列与可用 timing 取决于 PostgreSQL major version 与 track_wal_io_timing。升级时先查 system view schema。

statement 层:

SELECT
    queryid,
    calls,
    wal_records,
    wal_fpi,
    wal_bytes
FROM pg_stat_statements
ORDER BY wal_bytes DESC
LIMIT 20;

不要公开 query text;用 queryid 对回内部 catalog。

分母要有业务语义

WAL bytes / mixed transaction
WAL bytes / write transaction
WAL bytes / order
WAL bytes / changed row

不是同一个指标。

第 26 章 mixed workload 只有 20% place-order,约 158–196 WAL bytes/mixed tx。 不能把它写成“每个订单 196 bytes”。需要按成功 write operation 归一,并把 background activity 与 full-page image phase 作为误差。

WAL buffer full

wal_buffers_full 增加表示 WAL buffer 空间不足时 backend 被迫写 WAL,但不能只看到 counter 就把 wal_buffers 手工调大:

  • 默认 -1 会基于 shared buffers 自动选择;
  • upper bound 通常一个 WAL segment;
  • 写入/flush device 可能才是限制;
  • checkpoint/FPI 或大 transaction 可能改变 burst;
  • counter 是累计 delta。

先对齐发生时间与 WAL generation/write/fsync。

commit latency 的证据链

client transaction latency
  -> server wait event
      -> WAL I/O timing/count
          -> device fsync latency/queue
              -> synchronous standby wait
                  -> network/replay

如果 backend 等 SyncRep,加快本地 WAL device 未必改善;如果 server CPU 已饱和, 启用更重 WAL compression 可能反而恶化。

27.3.2 检查点频率、写突发与恢复时间

checkpoint 做什么

checkpoint 建立 recovery 起点,并把需要的 dirty buffer 写出,使 crash recovery 不必从无限久之前 replay WAL。它不是“定期 fsync 一次”这么简单。

触发:

time: checkpoint_timeout
WAL volume: max_wal_size soft threshold
manual/requested: CHECKPOINT or operational action
shutdown/recovery transitions

PostgreSQL 18 的证据:

SELECT *
FROM pg_stat_checkpointer;

SELECT *
FROM pg_stat_bgwriter;

checkpointer 与 bgwriter 已分开统计。关注:

num_timed / num_requested / num_done
write_time / sync_time
buffers_written
backend writes/fsync
checkpoint warnings/log duration

不要用旧版本列名硬编码跨版本 dashboard。

max_wal_size 是 soft limit

它不是:

pg_wal hard cap
archive backlog cap
slot retention cap
disk safety guarantee

在 archive failure、replication slot、wal_keep_size、重负载等条件下,WAL 可超过 max_wal_size。把 disk provision 写成 max_wal_size + 10% 是错误模型。

太小的症状

requested/WAL-driven checkpoints frequent
checkpoint_warning
WAL FPI rate high
write/sync burst
backend writes increase
throughput/latency periodic sawtooth

因为每次 checkpoint 后,某页第一次修改需要 full-page image(在保护开启时),过于 频繁会增加 WAL。

太大的代价

增加 max_wal_size/checkpoint_timeout 可能:

  • 减少 checkpoint 频率;
  • 降低 checkpoint-induced FPI;
  • 让 dirty work 有更长时间平滑;
  • 增加 crash recovery 要 replay 的 WAL;
  • 增加 pg_wal working space;
  • 延后 dirty page write,扩大 failure/burst 状态;
  • 改变 archive/replica catch-up。

优化点不是“checkpoint 越少越好”,而是满足:

foreground SLO
write smoothness
crash RTO
WAL disk headroom
archive/replica behavior

checkpoint_completion_target

它控制 checkpoint 在 checkpoint interval 中用于完成的目标比例。默认较高是为了把 I/O 分散到大部分 interval。降低会让 checkpoint 更快完成:

higher instantaneous write rate
  -> then longer quiet period

通常不是想要的平滑。参考 Pigsty 沙箱为 0.95;改变它前要看 checkpoint progress、 device latency 与 SLO,而不是复制旧时代 0.7。

写入平滑不是平均值

画 time series:

WAL bytes/s
checkpoint begin/end
data write bytes/s
fsync latency
dirty buffers
backend writes
archive rate/gap
replica receive/replay gap
OLTP p95/p99

平均 20MB/s 可能是:

持续 20MB/s

或:

每 10 秒 200MB/s,其余为 0

设备和 tail latency 面对的是后者。

恢复时间要实测

粗略:

TrecoveryWAL to replayeffective replay throughput+startup/finalization T_{\text{recovery}} \approx \frac{\text{WAL to replay}} {\text{effective replay throughput}} + \text{startup/finalization}

replay throughput 受:

WAL record mix
CPU
data/WAL storage
full-page images
compression/decompression
extension
prefetch
recovery conflicts

影响。不能用 WAL device sequential bandwidth 代替。

checkpoint 调整 acceptance 应含 crash/restart 或 replica rebuild drill,而不是只跑 steady primary。

不要为 benchmark 强制 checkpoint

每次 run 前:

CHECKPOINT;

会人为同步 phase:

  • 一开始 full-page image 更多;
  • dirty state 被清空;
  • I/O burst 与 production natural phase 不同。

如果研究 checkpoint phase,应把 “immediately after checkpoint / mid-cycle” 作为显式 factor;否则让背景系统自然运行并记录 checkpoint。

27.3.3 压缩、全页写与归档代价

full-page image 的正确性作用

full_page_writes=on,checkpoint 后某页第一次被修改时,WAL 通常记录整页 image, 以防 torn page 让 recovery 无法还原。于是:

frequent checkpoint
  -> more first modifications
      -> more FPI
          -> more WAL

但关系受 working set 与 write locality 影响。修改同一小批 hot page 与随机修改巨大 dataset 的 FPI 比例不同。

wal_compression

PostgreSQL 18 支持的 method 取决于 build:

off
pglz
lz4
zstd

它主要压缩 full-page image,不是压缩所有 WAL record。tradeoff:

less WAL bytes
  vs
more primary compression CPU
more replay decompression CPU
different archive/network/storage load

参考沙箱:

wal_compression = lz4
source          = configuration file
context         = superuser

第 26 章 c8 已有 CPU pressure,却没有证明 WAL I/O 是 throughput limit。因此“开启更强 compression 减少 WAL”不符合当前 service center evidence。

compression 实验矩阵

至少比较:

维度 指标
primary TPS、p95、CPU、wal_bytes、wal_fpi
WAL device write/fsync bytes/latency
network/archive bytes/s、backlog、catch-up
replica replay CPU、lag、recovery
backup/PITR archive compatibility、restore time
failure crash replay、promotion

只看 wal_bytes 降低不能决定接受。

archive 与 slot 可能让 WAL 无界

WAL generated
  -> pg_wal recycle/remove eligibility
      depends on checkpoint
      + archive completion
      + replication consumers/slots
      + wal_keep_size
      + backup/recovery needs

若 archive throughput $A$ 小于 WAL generation $W$:

$$ \text{backlog growth}

W-A $$

增加 max_wal_size 只推迟 disk full,不修复 consumer。

若 slot inactive:

SELECT
    slot_name,
    slot_type,
    active,
    restart_lsn,
    confirmed_flush_lsn,
    wal_status,
    safe_wal_size
FROM pg_replication_slots;

列随版本变化,先查当前 view。还要配置/验证 max_slot_wal_keep_size 等 guard,但 guard 触发可能使 consumer 无法继续,属于 recovery/runbook 决策。

min_wal_size 与 recycle

min_wal_size 让一定量旧 WAL segment 被 recycle 供未来使用,减少 burst 时反复创建/ 删除。它不是最小 WAL generation,也不是 retention policy。

filesystem 类型可能影响:

wal_init_zero
wal_recycle

尤其 CoW filesystem。但这些是 storage-specific candidate,应以文件系统、allocation 行为和 crash test 证明,不能把云盘经验搬到本地 SSD。

归档压缩与 WAL compression 是两层

wal_compression
  compresses full-page images inside WAL records

archive/backup compression
  compresses WAL files/backup objects during transport/storage

二者可叠加,CPU 消耗发生在不同进程/节点/阶段。容量模型要测最终 archive object bytes 和两侧 CPU,不要把 primary wal_bytes ratio 当作备份压缩率。

参数 ADR

WAL/checkpoint 变更应写:

symptom:
  requested_checkpoints_per_hour: ...
  wal_fpi_ratio: ...
hypothesis:
  parameter: max_wal_size
  mechanism: reduce WAL-driven checkpoint frequency
baseline:
  checkpoint_interval: ...
  wal_bytes_s: ...
  crash_recovery_p95: ...
candidate:
  value: ...
benefit_gate:
  foreground_p95: ...
non_regression:
  pg_wal_peak: ...
  archive_gap: ...
  replica_replay: ...
  crash_recovery: ...
rollback:
  value/source: ...
  restart_or_reload: ...

没有 recovery/retention 证据的 checkpoint 优化,只完成了一半。


上一节:内存预算 · 返回本章目录 · 下一节:规划器、并行与连接参数 · 查看全书目录 · 查看索引中心

27.4 规划器、并行与连接参数

规划器参数决定 PostgreSQL 如何比较候选 plan;并行参数决定 plan 可请求多少 worker; 连接参数决定多少 backend 能同时竞争资源。

三者形成一条链:

cost/statistics
  -> chosen plan and requested workers
      -> active backend/worker population
          -> CPU/memory/I/O/lock demand
              -> pool queue and SLO

把它们分开调,容易得到一个单 query 很快、cluster 却更慢的系统。

27.4.1 成本参数只能用硬件和计划证据校准

cost 是相对单位

常见:

seq_page_cost
random_page_cost
cpu_tuple_cost
cpu_index_tuple_cost
cpu_operator_cost
parallel_setup_cost
parallel_tuple_cost

它们不是毫秒,默认以 sequential page cost 为相对基准。把所有 cost 同乘 10,plan 通常不变;重要的是相对值。

random_page_cost

降低它会让 random/index access 相对便宜,可能使 planner 更偏向 index scan。正确 证据:

actual storage random vs sequential latency
cache residency
concurrent workload
tablespace/storage difference
representative plans and actual time/buffers

PostgreSQL 官方指出,默认 4.0 已隐含一部分 random access 会命中 cache;完全 cached 时较低值可合理,random 物理 I/O 昂贵时则可能需要较高值。

不能这样校准:

query uses seq scan
  -> random_page_cost = 1.0

seq scan 可能就是正确 plan,或根因是 statistics/index/predicate。

tablespace 可有不同 cost

若 hot index 在 NVMe、archive table 在 HDD,可在 tablespace scope 设置 page cost, 不必用一个 cluster-global average 强迫两种 storage:

ALTER TABLESPACE fast_ssd
SET (
    random_page_cost = 1.1,
    seq_page_cost = 1.0
);

仍需把 filesystem/cache 与 production I/O 测量纳入。

effective_io_concurrency

它表达 PostgreSQL 可以向 storage 发起的并发 I/O 提示能力;合理值取决于 device/ filesystem/RAID/cloud volume 和 PostgreSQL I/O implementation。大值不是免费吞吐:

  • device queue 可能更深;
  • tail latency 可能变差;
  • shared storage 邻居受影响;
  • query/scan type 可能不使用;
  • OS/backend 的实现随 major version 演进。

参考沙箱为 200、source 是 configuration file;这不能直接复制到 HDD 或网络块存储。

effective_cache_size

只影响 planner estimate,不分配 memory。校准时考虑:

shared_buffers
+ PostgreSQL data 可实际使用的 kernel cache
- concurrent queries sharing cache
- same blocks duplicated in two caches

不要写成 host RAM 总量,也不要当作 cache guarantee。

statistics 优先于 cost hack

如果 estimated rows 错数个数量级:

EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT ...;

先查:

ANALYZE recency/sample
default_statistics_target
ALTER COLUMN SET STATISTICS
extended statistics: dependencies/ndistinct/mcv
expression statistics
partition statistics
parameter/cast/collation

成本模型在错误 cardinality 上做得再精细,也会比较错误规模的 plan。

enable_* 是诊断探针

SET LOCAL enable_hashjoin = off;
SET LOCAL enable_nestloop = off;
SET LOCAL enable_seqscan = off;

可以回答:

planner 若被迫使用另一路径,实际是否更好?

不应默认成为永久 global 配置。某种 plan type 对当前一条 query 不好,不代表对全库 无用。

plan cache

prepared statement 有:

custom plan
  parameter value known
  planning repeated
  can adapt to skew

generic plan
  reusable
  lower planning cost
  cannot adapt to specific value

plan_cache_mode=auto 通常先生成若干 custom plan,再比较 generic cost。可以用:

SELECT
    name,
    generic_plans,
    custom_plans,
    parameter_types
FROM pg_prepared_statements;

第 27 章实验中,两个 probe 在 auto 下均为:

custom_plans  5
generic_plans 5

说明自动策略已经切换。强制 generic 的长期 TPS 益处没有达到 2% 置信下界。

对于 tenant skew:

tenant small -> index/nested loop
tenant huge  -> bitmap/hash/seq alternative

强制 generic 可能让其中一类严重退化。probe 必须覆盖高低/MCV/边界,而不是只用 id=1。

JIT

JIT 有 compilation/setup cost,也可能加快长 CPU-intensive execution。阈值由 plan estimated cost 决定:

jit_above_cost
jit_inline_above_cost
jit_optimize_above_cost

OLTP point query 常低于阈值;把 jit=off 后看到无差异不证明 JIT 没用,只说明当前 query 没触发或收益不足。分析 query 要把:

planning/JIT generation time
execution time
repetition/cache
CPU

分开。

calibration report

query class estimated rows actual rows plan buffers/I/O execution alternative result
point
range
tenant-small
tenant-large
analytic

只从两条 query 推导 cluster cost constants 很危险。官方文档也指出,没有定义良好的 “理想 cost”求法,应把它视为 workload average。

27.4.2 并行 worker 的全局预算与退化条件

三层上限

max_worker_processes
  hard pool for background workers

max_parallel_workers
  cluster parallel operation subset

max_parallel_workers_per_gather
  one Gather/Gather Merge request

还包括:

max_parallel_maintenance_workers
extension background workers
logical replication workers

提高 per-gather 而不提高上层 pool,可能没有效果;提高上层 pool 又会增加 cluster CPU/ memory contention。

plan request 不等于实际 worker

Workers Planned: 4
Workers Launched: 2

worker unavailable 时,query 通常以更少 worker 运行;某些 parallel plan 在 worker 不足时效率很差。要记录:

planned
launched
launch wait/starvation
leader participation
other concurrent parallel queries

PostgreSQL 18 的 database statistics 还提供 parallel worker launch 相关累计信息; system view 随版本变化,先查当前列。

leader 也可能工作

parallel_leader_participation=on 时 leader 可以执行 parallel plan,也要负责读取 worker tuple。若 worker 输出大量 tuple,leader 可能主要消耗在汇总/传输;parallel speedup 不会等于 worker 数。

Amdahl:

$$ S(N)

\frac{1} {(1-P)+P/N+\text{parallel overhead}} $$

serial fraction、launch、tuple transfer 与 skew 限制 speedup。

memory 按 process 放大

PostgreSQL 官方给出的关键边界:parallel query 的 resource limit 通常按 worker process 应用。4 个 worker 加 leader,某些 node 的内存/CPU/I/O footprint 可接近串行的 5 倍。

因此:

max_parallel_workers_per_gather=4
work_mem=256MB

不能解释成“一条 query 最多 256MB”。

全局 CPU budget

若 host 有 $C$ cores、需要为 OLTP 保留 $R$ cores:

Cparallel budgetCRbackground/failure headroom C_{\text{parallel budget}} \le C-R-\text{background/failure headroom}

并行分析的 admission:

qactiveq×(1+workersq)process/CPU budget \sum_q \text{active}_q \times (1+\text{workers}_q) \le \text{process/CPU budget}

不是“每条报表允许 8 worker,所以十条报表都允许 8”。

什么时候 parallel 反而慢

  • query 太短,launch/setup 占比高;
  • output 太大,leader/tuple transfer 成为瓶颈;
  • worker skew,一人做绝大多数工作;
  • storage 已饱和;
  • memory spill 按 worker 放大;
  • concurrent OLTP 被抢 CPU;
  • worker pool 不足;
  • parallel-unsafe/restricted function;
  • serialization/ordering overhead。

接受时比较:

single-query duration
cluster throughput
OLTP tail
CPU/I/O/memory
worker availability
N+1 state

维护 worker

parallel CREATE INDEX/VACUUM 与 parallel query 的 memory limit 语义不完全相同, 但仍争抢 CPU/I/O/worker。DDL window 中提高 maintenance worker,可能缩短单项任务, 却把 replica/archive 与 OLTP 推过 SLO。

角色级治理

分析角色:

ALTER ROLE dbuser_analytics
SET max_parallel_workers_per_gather = 4;

OLTP 角色:

ALTER ROLE dbuser_app
SET max_parallel_workers_per_gather = 0;

只是示意,不是默认建议。要验证新连接、pool lifecycle 和 query class。role scope 比 global 更贴近 ownership,但一个 role 内也可能混合 workload。

27.4.3 连接上限、超时和锁等待边界

connection slot 不是并发目标

max_connections 是 server 能接纳的 backend 上限。提高它:

  • 预留更多 shared structures;
  • 允许更多 private backend;
  • 扩大 active query/memory/lock population;
  • 增加 context switch;
  • 可能降低每条 query cache locality;
  • 使 overload 更深。

它不增加 CPU、memory bandwidth、IOPS 或 lock throughput。

参考沙箱:

max_connections = 500
context         = postmaster
source          = command line
RAM             ~1.91 GiB

这是 platform/template fact,不表示 500 个 64MB-work-mem query 可同时 active。

连接预算

max connectionsapp server connections+replication+maintenance+monitoring+reserved/emergency+migration overlap \text{max connections} \ge \text{app server connections} + \text{replication} + \text{maintenance} + \text{monitoring} + \text{reserved/emergency} + \text{migration overlap}

同时:

active app connectionsresource/SLO envelope \text{active app connections} \le \text{resource/SLO envelope}

两条都要满足。通常:

logical client population
  > pool client connections
  > active server connections

reserved slots

PostgreSQL 18:

reserved_connections
superuser_reserved_connections

前者供拥有 pg_use_reserved_connections 的角色,后者是 superuser 最后保留。设计:

  • emergency role 最小权限;
  • pool 不耗尽 reserved;
  • monitoring/replication 配额;
  • incident 时实际演练能连接;
  • standby 的 max_connections 不低于 primary。

把 emergency slot 留给日常应用,相当于没有 reserve。

pool queue 比 database overload 更可控

在 pool:

queue depth
wait time
timeout
admission/fairness

可以观察和限制。把 server connection 上限提高,会让更多 request 同时进入 executor/ lock/memory,queue 仍然存在,只是搬到了更危险的位置。

pool sizing 要配合第 22 章 transaction/session semantics:

  • session state;
  • prepared statement;
  • temp table;
  • advisory lock;
  • LISTEN/NOTIFY;
  • transaction pooling reset。

statement_timeout

从 command 到达 server 开始计,extended protocol 对 Parse/Bind/Execute/Sync 有具体 边界。它终止 statement,不等于 HTTP request deadline。

不要设置一个过于激进的 global value杀掉:

DDL
backup catalog
maintenance
replica diagnostic
legitimate report

优先按 role/database:

ALTER ROLE dbuser_app
IN DATABASE app
SET statement_timeout = '2s';

新 session 生效。

lock_timeout

只在等待 lock 时计时,并且每次 lock acquisition 单独应用。若它等于或大于 statement_timeout,往往 statement timeout 先触发。

常见关系:

lock_timeout < statement_timeout <= request deadline

但 transaction 内多条 statement、client network 和 retry 仍需 budget。

DDL migration 常用短 lock_timeout 来避免排队阻塞业务:

BEGIN;
SET LOCAL lock_timeout = '500ms';
SET LOCAL statement_timeout = '5min';
ALTER TABLE ...;
COMMIT;

失败要退出/重试,不应无限 loop。

idle 与 transaction timeout

idle_in_transaction_session_timeout
  session 在 open transaction 中 idle
  防止长期持锁/阻碍 vacuum

idle_session_timeout
  无 transaction 的 idle session
  pool 中要谨慎,middleware 未必处理意外关闭

transaction_timeout
  整个 transaction 存活时间
  prepared transaction 不受其约束

PostgreSQL 18 官方指出,不建议把某些 timeout 粗暴写成影响所有 session 的 postgresql.conf default。scope 应跟 workload。

timeout 不是 cancel 后就结束

应用必须:

observe SQLSTATE
rollback failed transaction
release/replace connection
respect outer deadline
limit retries
preserve idempotency

否则 database cancel 后,client 立即重试可能制造 retry storm。

lock diagnosis

不要为了更快报 deadlock,把 deadlock_timeout 全局调到 1ms。deadlock check 有成本, 普通 lock wait 不是 deadlock。

调查:

SELECT
    pid,
    pg_blocking_pids(pid) AS blockers,
    wait_event_type,
    wait_event,
    xact_start,
    query_start
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';

配合 log_lock_waits 与适当 deadlock_timeout,在诊断窗口使用。最终修复通常是:

  • transaction order;
  • shorten critical section;
  • index/access path;
  • hot-key design;
  • admission;
  • application retry;

而不是无限提高 timeout。

一份连接/超时合同

application_deadline: 2500ms
pool_wait_timeout: 200ms
connect_timeout: 300ms
lock_timeout: 400ms
statement_timeout: 1800ms
transaction_timeout: 2200ms
idle_in_transaction_timeout: 30s
retry:
  max_attempts: 2
  budget_included_in_deadline: true
server_connections:
  active_cap: 48
  reserved_platform: 12
  emergency: 5

数值只是示意。关键是所有 timeout 和 slot 在同一个 end-to-end budget 中,不互相 矛盾。


上一节:WAL、检查点与写入平滑 · 返回本章目录 · 下一节:参数作用域与变更方式 · 查看全书目录 · 查看索引中心

27.5 参数作用域与变更方式

一个参数变更有三个不同状态:

desired
  inventory/template/DCS/database-role policy 想要什么

configured
  file/catalog/command line 中写了什么

effective
  当前 server/session 实际用了什么

它们可以不同。

最常见事故不是参数值本身,而是:

  • 改错 scope;
  • 被更高优先级覆盖;
  • reload 了一个必须 restart 的参数;
  • 只改 primary,failover 后消失;
  • 手工 ALTER SYSTEM 被下一次 IaC 覆盖;
  • 改了 role default,却继续复用旧 pool session;
  • pending_restart 长期无人处理。

27.5.1 编译、初始化、启动、reload 与会话级

先问“这个属性什么时候还能改变”

从最早到最晚:

build/compile
  -> initdb
      -> postmaster startup
          -> SIGHUP reload
              -> backend startup
                  -> superuser/session
                      -> transaction

越靠左,变更成本、兼容性和回退风险通常越大。

build-time

有些物理属性来自 build:

block size
some segment/page layout options
compiled features/libraries
architecture/compiler

查询:

SHOW block_size;
SELECT version();

这些不是普通 GUC。改变 block size 通常意味着不同 binary/cluster physical format, 不能用 reload/restart 改现有集群。

initdb-time

cluster 创建时固定或高度绑定:

encoding
locale/ICU provider and version choices
data checksums
WAL segment size
system identifier

例如:

SHOW data_checksums;
SHOW wal_segment_size;
SELECT datname, encoding, datcollate, datctype
FROM pg_database;

某些能力可能有离线工具/特定版本转换路径,但不能把它当作普通 GUC rollout。 参数 ADR 要标注:

new cluster / migration / offline conversion

而不是写“restart”。

pg_settings.context

SELECT DISTINCT context
FROM pg_settings
ORDER BY context;

典型语义:

context 最早/最小变化边界
internal 不能由用户改变,来自 build/init/internal
postmaster server start
sighup config reload
superuser-backend backend start,需 superuser/SET privilege
backend backend start
superuser session 可改,但权限受限
user ordinary session 可改

context=user 只表示权限/生命周期允许,不表示业务上可随意改。

restart parameter

shared_buffers
max_connections
max_worker_processes
shared_preload_libraries
huge_pages

写进 config 后 reload:

SELECT name, setting, pending_restart
FROM pg_settings
WHERE pending_restart;

pending_restart=true 表示 file 中的新值尚未成为 effective value。不能把 config diff 当作运行事实。

reload parameter

SIGHUP:

SELECT pg_reload_conf();

或平台命令。reload:

  • 重新读取配置;
  • 不停止 server;
  • 不保证每个参数/每个 backend 立即按你想象生效;
  • 不证明配置无 syntax/semantic error;
  • 不处理 postmaster parameter。

先查 pg_file_settings.error,再 reload,随后查 effective/source。

backend-start parameter

一些设置只在新 backend 建立时取得。reload 后:

new sessions see candidate
old sessions retain previous

connection pool 可让“旧 session”存活很久。变更计划要包括:

  • pool recycle/drain;
  • prepared/session state;
  • transaction 不中断;
  • 新旧 session 混合窗口;
  • verification 分别取样。

session 与 transaction

SHOW work_mem;

SET work_mem = '32MB';
-- 当前 session 后续 statement 使用

BEGIN;
SET LOCAL work_mem = '128MB';
-- 仅当前 transaction
COMMIT;
-- 回到 session value

RESET work_mem;
-- 回到 session default

SET LOCAL 在 transaction 外没有你想要的持久语义。transaction rollback 也影响配置 变化;用 connection pool 时必须测试 reset 行为。

查单位与规范化值

pg_settings.setting 常是 base unit:

SELECT
    name,
    setting,
    unit,
    vartype,
    min_val,
    max_val,
    enumvals
FROM pg_settings
WHERE name IN ('work_mem', 'shared_buffers', 'checkpoint_timeout');

不要把:

shared_buffers setting=62592

读成 bytes;unit 是 8kB

27.5.2 系统、数据库、角色与事务覆盖层

global 配置层

来源可能包括:

compiled boot value
postgresql.conf + includes
postgresql.auto.conf / ALTER SYSTEM
postmaster command-line -c
environment/client startup

postgresql.auto.conf 在普通 config 后读取;server command-line setting 又可覆盖 file。 参考沙箱:

max_connections = 500
source          = command line

所以在 postgresql.conf 写 200、reload/restart 后,若 Patroni/postmaster 仍用 -c max_connections=500,effective 仍可能是 500。

database/role defaults

ALTER DATABASE app
SET statement_timeout = '5s';

ALTER ROLE dbuser_app
SET work_mem = '16MB';

ALTER ROLE dbuser_app
IN DATABASE app
SET statement_timeout = '2s';

新 login 的优先关系可概括为:

global
  < database-specific
  < role-specific
  < role-in-database-specific
  < session SET / startup option
  < transaction SET LOCAL

database 与 role 的精确冲突规则:role-in-database 最具体;role-specific 覆盖 database-specific。

官方 Setting Parameters 强调 ALTER DATABASE/ALTER ROLE 只在新 session建立时应用。SET ROLE 不会重新 加载目标 role 的配置 default。

catalog 事实

这些 default 存在:

SELECT
    setdatabase::regdatabase,
    setrole::regrole,
    setconfig
FROM pg_db_role_setting
ORDER BY setdatabase, setrole;

需要处理 OID=0 的 all database/all role 显示;不要直接把系统 catalog 结果发给不该 看到 role policy 的用户。

current session 的 source

SELECT
    name,
    setting,
    unit,
    source,
    sourcefile,
    sourceline,
    reset_val,
    boot_val
FROM pg_settings
WHERE name = 'statement_timeout';

sourcefile 只对有 pg_read_all_settings 等权限的用户可见。公共报告不应发布主机 绝对路径。

注意:

source

在当前 session 被 SET 后会显示 session source;要验证 cluster default,需要新建 干净 session 或查 catalog/file,不要在已被测试脚本修改的 session 中判断。

ALTER SYSTEM

ALTER SYSTEM SET work_mem = '64MB';
SELECT pg_reload_conf();

写入 postgresql.auto.conf。它适合某些 standalone 管理模式,但在 IaC/Patroni/Pigsty 中会产生第二个 desired-state writer。

Pigsty 官方 parameter scopes 指出,受管集群的 postgresql.auto.conf 可由 pg_parameters 管理,手工 ALTER SYSTEM 可能被下一次 playbook 覆盖。生产持久变更应回到 inventory/desired state,除非有明确 break-glass 流程和回写。

ALTER SYSTEM RESET ALL 很危险

它不是“恢复 PostgreSQL 默认”,而是清空 postgresql.auto.conf 中 ALTER SYSTEM 设置;文件可能还有平台管理内容。不要为了撤一项变更执行 RESET ALL。

精确回退:

ALTER SYSTEM RESET work_mem;

仍要确认 lower-priority value 是预期值。

startup packet / PGOPTIONS

libpq:

env PGOPTIONS="-c statement_timeout=2s -c plan_cache_mode=auto" \
  psql ...

只影响连接 session。第 27 章实验用它确保 candidate 不落盘。

风险:

  • application 可覆盖平台 default;
  • pool 连接建立时固定;
  • 不允许的 GUC 会导致连接失败;
  • connection string/log/env 可能泄露;
  • startup setting provenance 易被忽略。

应用允许的 startup option 应纳入 policy。

自定义 GUC 与 extension

extension 可能增加:

shared_preload_libraries
extension.parameter
custom namespace

参数在 extension 未加载/版本变化时可能无效或阻止启动。升级前检查:

available extension version
preload library presence
pg_file_settings errors
standby binary parity
rollback binary compatibility

27.5.3 配置漂移、审计、回退和滚动风险

一条事实查询

SELECT
    name,
    setting,
    unit,
    context,
    source,
    sourcefile,
    sourceline,
    pending_restart
FROM pg_settings
ORDER BY name;

它回答 effective/session fact。配置文件事实:

SELECT
    sourcefile,
    sourceline,
    seqno,
    name,
    setting,
    applied,
    error
FROM pg_file_settings
ORDER BY seqno;

官方 pg_file_settings 指出:

  • 每条 file entry 一行;
  • invalid/syntax error 出现在 error
  • 被后续同名项覆盖时 applied=false,不一定是 error;
  • 它反映当前文件内容,不是 last-applied runtime。

两张 view 要一起看。

duplicate setting

postgresql.conf:100  work_mem=4MB
included/app.conf:20 work_mem=16MB
postgresql.auto.conf work_mem=64MB
command line         none

只 grep 第一处会误判。用 seqno/applied/source 还原 precedence。

drift 类型

drift desired configured effective
未应用 new new old
手工热改 old manual new manual new
command override desired desired command
session override desired desired session
member mismatch same differs by node differs
pool stale new new old/new sessions
invalid file new error old

每种 remediation 不同。

配置 snapshot 不要泄密

pg_settings 里可能有:

  • file path;
  • library/path;
  • connection-like extension setting;
  • topology;
  • logging destination。

私密 evidence 保存完整;公共报告使用 allowlist:

name
normalized setting
unit
context
coarse source
pending_restart

不发布 sourcefile absolute path、secret 或 raw extension config。

变更前检查

target cluster/member/role
current desired commit
current effective values on every member
file errors
pending_restart
HA health/lag
backup/recovery health
resource headroom
active DDL/maintenance
pool/session lifecycle
rollback value and command

参数名相同不代表 primary/replica 应完全相同,例如 delayed replica;但差异必须是 desired,而不是 drift。

reload 风险

reload 低于 restart,不等于零风险:

  • logging 参数可制造 I/O storm;
  • autovacuum 参数可启动更多工作;
  • timeout 可中断新 workload;
  • HBA/SSL/config error 可影响连接;
  • query cost 可在新 planning 时改变 plan;
  • backend-start setting 造成混合。

reload 后观察:

config log
pg_settings effective/source
new and old session sample
query/latency/resource
HA/replica/archive

rolling restart 风险

restart parameter 在 HA cluster 中通常逐 member:

replica 1
  -> restart
  -> recover/catch up/validate
replica 2
  -> ...
planned switchover if needed
old primary

但是否安全取决于 parameter:

  • standby max_connections 不应低于 primary,否则 recovery query 限制;
  • max_worker_processes standby 需要与 primary 相容;
  • shared_preload_libraries 的 extension/binary 每台都要存在;
  • protocol/physical compatibility;
  • restart 期间 N+1 capacity;
  • failover 在 mixed-version/mixed-config 窗口的行为。

不能一概写“滚动无中断”。

rollback 也可能 restart

若 candidate 是 postmaster:

apply candidate -> rolling restart
regression -> restore desired -> another rolling restart

这段时间风险是两倍操作,不是一条 git revert。change window 必须预留 rollback 时长和 N+1 capacity。

failover 中的 source of truth

Patroni 管理的参数可能来自 DCS/postmaster command line。只编辑 local postgresql.conf

  • Patroni 可能重写;
  • failover 后 candidate 消失;
  • replica effective 不同;
  • automation reconcile 回旧值。

变更前先确定:

who owns this parameter?
template, inventory, DCS, auto.conf, role catalog, or application?

一个参数只能有一个持续 desired-state owner。

configuration ADR

parameter: ...
owner: ...
current:
  desired: ...
  configured: ...
  effective: ...
  source: ...
context: postmaster|sighup|user|...
scope: cluster|instance|database|role|session
hypothesis: ...
members:
  - name: ...
    before: ...
apply:
  method: ...
  order: ...
  observation: ...
rollback:
  method: ...
  order: ...
  maximum_time: ...
failure:
  mixed_state_behavior: ...
  failover_behavior: ...
validation:
  native_sql: ...
  file_fact: ...
  Pigsty: ...

参数值只是 ADR 中一行;scope、owner、effective evidence 与 mixed-state behavior 同样重要。


上一节:规划器、并行与连接参数 · 返回本章目录 · 下一节:模板参数与集群变更 · 查看全书目录 · 查看索引中心

27.6 模板参数与集群变更

Pigsty 把 PostgreSQL 参数放进:

hardware/workload template
  + cluster inventory
      + instance override
          + Patroni dynamic configuration
              + database/role defaults

这解决的是:

如何从同一份 desired state 可重复生成、分发、验证集群配置?

它不自动回答:

这个值是否适合我的 workload?

模板负责起点,实验负责偏离模板的理由。

27.6.1 从模板生成实例配置

四类起点

当前 Pigsty 官方模板:

pg_conf 目标
tiny.yml 小节点、开发/演示、受限资源
oltp.yml 延迟敏感交易
olap.yml 扫描、分析、较低并发与较高并行
crit.yml 更保守的关键业务策略

示例:

pg-test:
  hosts:
    10.10.10.11: { pg_seq: 1, pg_role: primary }
    10.10.10.12: { pg_seq: 2, pg_role: replica }
  vars:
    pg_cluster: pg-test
    pg_conf: oltp.yml
    node_tune: oltp

模板会根据 CPU、memory、disk/workload profile 计算多项参数。Pigsty optimization policy 当前以 25% memory 作为 shared buffer 默认起点,并按 profile 处理 connection、 parallel、vacuum、WAL 与 timeout。

profile 不是标签

选择 olap 不只是:

work_mem larger

还可能改变:

connections
parallel workers
maintenance
vacuum
timeout
I/O settings
logging

因此把 existing production cluster 从 oltp.yml 换到 olap.yml 是 multi-factor change。不要用一次切换来“测试 OLAP 参数”;先生成 diff,拆成可归因变更。

hardware 与 profile 必须匹配

Pigsty 官方把 tiny 用于小型/受限节点;oltp/olap 文档面向更大的常规实例。 如果 1C2G 节点使用 aggressive profile:

  • template 仍可能生成 syntactically valid 配置;
  • server 仍可能启动;
  • 但 worst-concurrency memory/worker budget 可能不安全。

参考沙箱就是一个值得审阅的事实:

server RAM       ~1.91 GiB
max_connections  500
work_mem          64 MiB
shared_buffers   ~489 MiB

这不代表当前 idle/8-client workload 已 OOM;它表示 platform limit 不能被应用当作 “500 条复杂 query 的安全并发”。

先预览 recommendation

当前 pig CLI 提供 tuning output:

pig pg tune
pig pg tune -p tiny
pig pg tune -p olap
pig pg tune -c 8 -m 32768 -d 500 -o yaml

这些命令用于生成/查看 recommendation;先确认本机安装版本的 pig pg tune --help。 输出不是自动批准的生产变更。保存:

Pigsty version
PostgreSQL major
detected/overridden CPU memory disk
profile
generated output hash

同一 profile 在不同 Pigsty release 可能演进,升级后要 diff。

pg_parameters 显式覆盖

pg-test:
  hosts:
    10.10.10.11:
      pg_seq: 1
      pg_role: primary
    10.10.10.12:
      pg_seq: 2
      pg_role: replica
      pg_parameters:
        recovery_min_apply_delay: '5min'
  vars:
    pg_cluster: pg-test
    pg_conf: oltp.yml
    pg_parameters:
      log_min_duration_statement: 250
      track_io_timing: on

用途:

template baseline
  + reviewed cluster exception
      + reviewed instance exception

不是把所有 template 输出再复制一遍。重复复制会失去 template upgrade 能力。

inventory precedence

Pigsty 的 inventory 可以在 global、cluster、host 定义 pg_parameters,越具体的 inventory 变量覆盖越通用。还要叠加 PostgreSQL 自己的 file/DCS/catalog/session precedence。

因此两层问题:

Ansible variable resolution
  -> rendered configuration
      -> PostgreSQL/Patroni precedence
          -> effective session value

只看 YAML 不能证明最后生效。

list 参数的 YAML quoting

pg_parameters:
  shared_preload_libraries: 'timescaledb, pg_stat_statements, auto_explain'
  search_path: '"$user", public, app'

list-like GUC 必须作为一个 string 正确引用,避免 YAML 误解析或引号层级错误。render 后还要用 pg_file_settings 检查。

database 与 role 参数

某个 workload 特有的:

statement_timeout
work_mem
max_parallel_workers_per_gather
search_path
default_transaction_read_only

优先落到 Pigsty business object 的 database/user parameter,而不是 cluster global。 它们最终进入 pg_db_role_setting,新 session 生效。

scope 要匹配:

all workload on cluster     cluster/instance
one database                database
one application identity    role-in-database
one job                     transaction/session

参数 exception 的元数据

YAML 本身不保存“为什么”。在 ADR/注释/变更系统记录:

parameter: work_mem
scope: role dbuser_report in database analytics
value: 256MB
reason: report-v4 spill experiment
evidence_run: ...
owner: data-platform
expires/review: 2026-10-01
rollback: 32MB

没有 expiry 的 exception 会永久累积。

27.6.2 区分 reload、restart 与滚动执行

先从 context 生成动作

SELECT
    name,
    context,
    setting,
    pending_restart
FROM pg_settings
WHERE name = ANY (ARRAY[
    'work_mem',
    'checkpoint_timeout',
    'shared_buffers',
    'max_connections',
    'shared_preload_libraries'
])
ORDER BY name;

动作矩阵:

context/scope 持久层 应用
role/database catalog/IaC 新 session
user/superuser global default config reload + session lifecycle
sighup config/DCS reload
backend config reload + reconnect
postmaster config/DCS restart
init/build cluster/binary migration/rebuild

应用 pg_parameters

Pigsty 官方当前给出的 instance 参数应用入口:

./pgsql.yml -l pg-test -t pg_param

它会把 pg_parameters 渲染到受管配置。执行前:

确认 inventory 与 limit
查看 playbook version/help
做 diff/preview
确认 target 是 cluster 而非全环境
确认 secrets 不进命令行/log

执行后仍需 reload/restart 语义验证;“Ansible changed=1”不是 effective。

Patroni dynamic configuration

Patroni 的 cluster dynamic config 存在 DCS,由所有 member 消费;local config 又可能 覆盖 DCS。改变 Patroni 管理的 PostgreSQL 参数时要识别 owner。

Pigsty/Patroni 文档说明:

  • dynamic config 会传播到成员;
  • 非 restart 参数随后 reload;
  • postmaster 参数会标 pending_restart/restart_pending
  • local Patroni config 可能优先于 dynamic;
  • bootstrap DCS config 只用于初始建群,之后应改 dynamic config。

不要只编辑最初 inventory 里的 bootstrap fragment,期待现有 DCS 自动变化。

reload

集群:

pg reload pg-test

本机 PostgreSQL:

pig pg reload

命令面与版本有关,执行前看 --help。两者 scope 不同:一个通过 Patroni 面向 cluster/member,一个是本机 service 操作。

reload 后:

SELECT pg_reload_conf();

返回 true 只表示 signal 发出,不等于每项应用成功。查:

SELECT * FROM pg_file_settings WHERE error IS NOT NULL;
SELECT name, setting, source, pending_restart
FROM pg_settings
WHERE name IN (...);

restart

restart 会断开本 member 的 session;HA cluster 中可能由 replica 承载重启,但:

  • primary restart 仍需 switchover/connection behavior;
  • replica 重启时丧失一份冗余;
  • catch-up 产生 I/O/WAL load;
  • sync quorum 可能变化;
  • pool/client 会 reconnect;
  • session state/prepared statement 消失。

执行前必须有:

healthy replica count
lag within gate
backup/archive healthy
N+1 capacity
restart duration/RTO
client retry budget
rollback restart time

immediate restart 会触发 crash recovery,不是普通快速捷径。

rolling 顺序

典型而非万能:

1. one replica
2. wait streaming/caught-up + effective validation
3. next replica
4. controlled switchover if primary must change
5. former primary
6. end-to-end service validation

每一步 gate:

Patroni role/state
timeline/LSN
replication slots
archive
service route
pending_restart
SLO/resource

若 candidate 导致 member 起不来,不应继续下一个。

mixed-config window

滚动期间:

member A candidate
member B baseline

要回答:

  • replication compatible?
  • failover 到 A/B 各如何?
  • read route 结果/性能不同?
  • logical worker/preload plugin compatible?
  • monitoring/alert 能区分?
  • rollback 是否仍可启动?

若不能容忍 mixed state,就不能称为 rolling change,需要 maintenance/migration design。

canary member 的局限

在 replica 测 work_mem/planner 参数:

  • read-only workload 可以;
  • primary write/WAL/commit 行为不能;
  • cache、data freshness、route 不同;
  • replica conflict/recovery 干扰;
  • promote 后 workload 变化。

canary 必须代表目标 mechanism。

27.6.3 用 SQL 和文件事实验证最终生效值

desired inventory

保存:

git commit
inventory path
cluster/member limit
profile and explicit overrides
render/playbook version
review/approval

不要在 public artifact 中发布 secret inventory。

configured file

SELECT
    sourcefile,
    sourceline,
    seqno,
    name,
    setting,
    applied,
    error
FROM pg_file_settings
WHERE name IN (...) OR error IS NOT NULL
ORDER BY seqno;

它能发现:

syntax error
unknown parameter
invalid value
duplicate overridden entry
restart-required entry not applied to runtime

但 view 反映 file 当前内容,不是 last applied。

effective server/session

SELECT
    inet_server_addr() AS server,
    current_setting('cluster_name') AS cluster,
    pg_is_in_recovery() AS in_recovery,
    name,
    setting,
    unit,
    context,
    source,
    pending_restart
FROM pg_settings
WHERE name = ANY (ARRAY[
    'shared_buffers',
    'work_mem',
    'max_connections',
    'checkpoint_timeout',
    'max_wal_size',
    'plan_cache_mode'
])
ORDER BY name;

每个 member、每种 service path、新旧 session 都要取样。

normalized units

比较时用 canonical bytes/ms:

SELECT
    name,
    current_setting(name) AS display,
    setting,
    unit
FROM pg_settings
WHERE name IN ('shared_buffers', 'work_mem', 'checkpoint_timeout');

setting=62592, unit=8kB489MB 可能同值。字符串 diff 会制造假 drift。

Patroni/HA fact

cluster config in DCS
member pending_restart
member role/state/timeline/lag
PostgreSQL effective setting

四层要对齐。DCS 有 candidate 但 member 仍 pending restart,不算完成;PostgreSQL effective candidate 但 inventory 仍 baseline,也不算完成。

workload fact

变更生效不等于 hypothesis 成立。继续验证:

objective
non-regression
resource
failure/recovery
observation window

第 27 章实验同时保存:

  • global settings before/after;
  • session requested/effective plan_cache_mode
  • prepared custom/generic count;
  • representative plan-shape hash;
  • raw transaction latency;
  • cleanup。

所以能证明:

candidate was actually tested
global config was not changed
auto already selected generic after early custom plans
material gain rule did not pass

验收矩阵

证据 pass
desired inventory commit/diff exact candidate
rendered file/DCS no unexpected entries
syntax pg_file_settings no error
effective pg_settings every member value/source/context
lifecycle pending restart/new session complete
HA Patroni/replication healthy
behavior plans/SLO/resources gates pass
rollback restored desired/effective tested

少任意一层,都只能标记 partially appliedpending

配置变更的最终原则

Pigsty template gives a reviewed starting point
IaC gives repeatability
Patroni gives HA-aware distribution
PostgreSQL views give runtime truth
experiment gives causal confidence
ADR gives memory and accountability

任何单层都不能替代其余层。


上一节:参数作用域与变更方式 · 返回本章目录 · 下一节:实战:只调一个已证实的瓶颈 · 查看全书目录 · 查看索引中心

27.7 实战:只调一个已证实的瓶颈

本节从第 26 章继续,不新造一个“参数一改、TPS 翻倍”的玩具例子。

第 26 章已经证明:

c8 server work ratio   about 84%
c8 client work ratio   below 30%
throughput vs c1       about 1.9x
p95 vs c1              about 4.4x
temp bytes             0
failures/late          0
exact knee             unknown
production TPS         null

可确认的是:

  • c8 进入较高 server CPU/queue 区域;
  • load generator 没有先饱和;
  • 并发提高的 tail 代价很大。

不可确认的是:

  • CPU 花在 planning 还是 execution;
  • I/O、WAL 或 lock 是否已经限制 throughput;
  • 哪个 GUC 能改善;
  • c8 是否是 knee。

严格说,第 26 章只证实了 service-center pressure,没有证实 parameter-specific root cause。所以本节的正确目标不是“必须调成”,而是只允许 一个可逆参数假设进入 A/B,并接受拒绝。

27.7.1 从 ch26 证据提出参数假设

候选矩阵

candidate ch26 支持 缺口 决定
raise work_mem 六个 cell temp=0 不测试
raise shared_buffers L 有 block read iowait 低、OS/device 未归因 不测试
raise max_connections 只测 8 clients,knee 未知 拒绝
synchronous_commit=off 写产生 WAL WAL flush 未证实 拒绝 durability change
change wal_compression 有 WAL/tx WAL I/O 未证实、CPU 已高 不测试
force generic plan prepared + CPU pressure planning time未测,auto 可能已 generic 允许证伪

这张表的关键不是挑中了 plan_cache_mode,而是拒绝了五项不能归因的变化。

hypothesis

question: >
  Does forcing generic plans materially improve the prepared OLTP mix
  without changing representative plan shapes or tail latency?
parameter: plan_cache_mode
baseline: auto
candidate: force_generic_plan
mechanism: avoid repeated custom planning
scope: benchmark session only
apply: PGOPTIONS
rollback: session end

预测若成立:

auto repeatedly custom-plans
  -> visible planning CPU
force generic
  -> same plan shapes
  -> lower CPU/service demand
  -> stable TPS gain
  -> no tail regression

可证伪点:

auto already changes to generic
planning is too small
generic shape differs by key
TPS gain is noise
tail latency regresses

为什么不用 pg_stat_statements total planning time 直接判

它可以作为证据,但:

  • track_planning policy/overhead;
  • shared cumulative state;
  • prepared custom/generic lifecycle;
  • queryid aggregation;
  • workload 重叠;
  • planning 减少不一定转成 end-to-end SLO。

所以本实验直接测 client outcome,并用 prepared counters/plan probe 解释机制。

作用域

PGOPTIONS="-c plan_cache_mode=auto" pgbench ...
PGOPTIONS="-c plan_cache_mode=force_generic_plan" pgbench ...

不使用:

ALTER SYSTEM
pg_parameters
Patroni DCS
reload/restart
ALTER ROLE

因为本轮只回答 hypothesis,不创建持久 desired state。

safety target

environment  pg36-l2-vagrant teaching sandbox
client       pg-meta-1
server       pg-test-1 primary
database     pg36_tuning
role         dbuser_pg36tune
data         synthetic only
risk         L2 bounded

exercise 会真实制造 CPU/I/O/WAL/replication load,不能在 production 运行。

一次性连接边界

临时 database 创建为:

ALLOW_CONNECTIONS false
  -> REVOKE CONNECT FROM PUBLIC
      -> GRANT CONNECT only to disposable benchmark role
          -> ALLOW_CONNECTIONS true

为什么多这一步?

Pigsty exporter 可自动发现 database 并建立观测连接。若先开放 PUBLIC,再跑数分钟, exporter 的 idle connection 会让普通 DROP DATABASE 安全拒绝。runner 不应:

DROP DATABASE ... WITH (FORCE)
terminate unrelated monitoring sessions

先收紧 connect privilege,从源头避免 observer race。

database 与 role 都写:

pg36-ch27-disposable-tuning-fixture-v1:<run-id>

清理精确比对 marker;只允许等待 autovacuum 自然退出,不强杀。

数据与 workload

M scale:

customers          80,000
products           16,000
historical orders  800,000

mix:

operation weight distribution
product read 50% product Zipf 1.15
order read 30% customer Zipf 1.08
place order 20% customer uniform、product Zipf 1.10

每个 arm:

8 clients / 2 jobs
prepared protocol
closed-loop / zero think time
5-second natural warm-up
12-second measured run
250ms latency limit
max-tries=1

这组短 run 仍是教学证据,不是 production capacity characterization。

配对和 counterbalance

r1 auto  -> force, same seed
r2 force -> auto,  same seed
r3 auto  -> force
r4 force -> auto
r5 auto  -> force

每次 measured run 前:

  • truncate live orders;
  • reset inventory data;
  • 不 reset shared statistics;
  • 不 drop cache;
  • 不 force checkpoint;
  • 不 restart;
  • 不暂停 autovacuum/replication/archive。

每对相同 seed 降低 workload sampling noise;顺序交错降低时间趋势偏差。

predeclared rule

candidate 进入更大 canary 的必要条件:

failure/late/skipped = 0
representative plan shapes equal
global settings unchanged
candidate pooled p95 / auto pooled p95 <= 1.05
paired TPS ratio bootstrap 95% lower >= 1.02

最后一条同时表达:

gain is material
gain is supported under measured repetition uncertainty

通过也只代表 “candidate-worthy-for-larger-canary”,不是 production apply。

27.7.2 比较收益、副作用和故障恢复

运行

静态检查:

static/labs/ch27/task.sh lint

完整实验:

export PG36_EVIDENCE_DIR=/absolute/private/new-empty/ch27-run
static/labs/ch27/task.sh all

PG36_EVIDENCE_DIR 必须是不存在或为空的私密绝对目录。all

capture L0 preflight
  -> exercise L2
      -> verify L0
          -> review L0

正式 run identity

run id         efb7efec-beb2-4237-8535-6864de2a2e4e
preflight      d06c94b2-efb0-4688-86bf-b4e6ca92b90f
measured runs  10
transactions   357,685
raw tx logs    20

所有 run:

failures    0
late        0
skipped     0
deadlocks   0
temp bytes  0

arm 聚合

arm runs tx median TPS pooled p50 pooled p95 pooled p99 max
auto 5 178,823 2,981.72 1.279 9.217 13.127 73.540
force_generic_plan 5 178,862 3,051.72 1.285 9.135 12.933 77.943

latency 单位 ms。p50/p95/p99 从每个 arm 的 raw transaction sample 合并后重算。

为什么不能用 median TPS 除法

直接:

3051.722981.721.0235 \frac{3051.72}{2981.72} \approx 1.0235

看起来正好超过 2%。但运行是配对设计,正确 effect:

$$ r_i

\frac{TPS_{\text{force},i}} {TPS_{\text{auto},i}} $$

五个 ratio 的:

median               1.0073527
bootstrap 95%        [0.9808162, 1.0277460]
required lower       1.02

区间既包含负收益,又没有达到预设下界。candidate pooled p95 ratio:

9.1359.2170.9911 \frac{9.135}{9.217} \approx 0.9911

tail gate 通过,但 material-gain gate 失败。

plan probe

对 product/order 两个 read prepared statement,各执行 10 次:

mode query custom generic
auto product 5 5
auto order 5 5
force generic product 0 10
force generic order 0 10

auto 不是“每次都 custom”;它在前五次收集 custom cost 后,已经为这两条稳定 point/ range lookup 选 generic。

对每个 query 测:

low key
high key

plan shape 只保留:

Node Type
Relation/Index
Join Type/Strategy
child topology

再计算 SHA-256。auto/candidate 四个 probe shape 相同。

这支持:

candidate did not alter these representative plan shapes

不支持:

generic is safe for every tenant/key/query

机制解释

auto:
  pays five early custom plans per prepared statement/session
  then reuses generic

force:
  avoids those early custom plans
  but sustained run mostly compares generic vs generic

在 12 秒、高 transaction count 的 run 中,早期差异被摊薄,因此很难产生稳定 2% 收益。这与实验结果一致。

副作用

强制 generic 的风险不是本轮四个 shape,而是 parameter-sensitive workload:

small tenant  -> selective index plan
large tenant  -> broad scan/hash plan
MCV key       -> different selectivity
rare key      -> different selectivity

global/role 强制会禁止 planner 对具体 parameter 自适应。收益证据弱,潜在 plan blast radius 大,所以即便 p95 没退化也不应接受。

global settings 无变化

实验前后逐项比较:

setting
unit
context
source
sourcefile (private only)
pending_restart
file error set

结果完全一致。参考 baseline:

parameter value context source
plan_cache_mode auto user default
work_mem 65536 kB user config file
shared_buffers 62592 × 8kB postmaster config file
max_connections 500 postmaster command line
synchronous_commit on user default
wal_compression lz4 superuser config file

raw sourcefile path 只在私密 evidence,不进入公共 JSON。

故障与恢复边界

因为 candidate 只在 session:

session ends
  -> candidate gone

server restarts/fails over
  -> no persistent candidate to carry

benchmark aborts
  -> ephemeral connection closes

这使 hypothesis test 的 rollback 简单,但不代表 persistent rollout 也简单。

如果实验通过并要配置 role:

ALTER ROLE ... SET plan_cache_mode = force_generic_plan;

还需:

  • 新 session/pool recycle;
  • workload-wide parameter skew probes;
  • failover 后 catalog replication;
  • canary identity;
  • revert;
  • observation window。

本轮没有进入这一步。

cleanup

正式 run 证明:

database absent
role absent
marker matched
unrelated sessions terminated 0
DROP ... WITH FORCE used       false
remote /tmp absent

清理证据是实验的一部分。不能只因 fixture “名字看起来像测试库”就 drop。

27.7.3 输出参数 ADR、回退条件与拒绝修改项

ADR

id: ch27-plan-cache-mode-falsification
status: rejected
date: 2026-07-30

context:
  upstream_run: 6c44ebdb-2206-48c3-8089-d90fdff45204
  observation:
    c8_server_work: about 84%
    prepared_protocol: true
    exact_knee_known: false
    production_tps: null

hypothesis:
  parameter: plan_cache_mode
  baseline: auto
  candidate: force_generic_plan
  mechanism: reduce custom planning CPU

experiment:
  scope: benchmark session
  paired_repetitions: 5
  transactions: 357685
  plan_probe: low/high keys

result:
  paired_tps_ratio_median: 1.00735
  bootstrap_95: [0.98082, 1.02775]
  candidate_p95_ratio: 0.99110
  plan_shapes_equal: true

decision:
  persistent_change: rejected
  reason: material-gain lower bound 1.02 did not pass
  production_gate: pending

rollback:
  needed: false
  reason: candidate was session-local and no persistent change was applied

拒绝项

work_mem increase

rejected because:
  all ch26 temp_bytes = 0
  no spill mechanism
  current 64MB already has concurrency risk

如果未来 report spill,按 report role/query 单独实验。

shared_buffers increase

rejected because:
  L block reads observed
  but iowait low
  OS cache/device attribution missing
  restart + memory budget required

下一证据是 longer/XL working set 与 pg_stat_io/device latency,不是立即 restart。

max_connections increase

rejected because:
  eight clients only
  exact knee unknown
  slots do not add CPU
  500 already exceeds credible active memory envelope

下一动作是 pool/admission 与 c2–c32 sweep。

synchronous_commit=off

rejected because:
  WAL flush not established as bottleneck
  changes durability semantics

它需要产品/RPO 决策,不是本章 performance shortcut。

change wal_compression

rejected because:
  WAL/tx measured
  WAL I/O not established limiting
  CPU already pressured
  replay cost untested

unknown backlog

  1. planning 与 execution CPU 的直接分解;
  2. c2/c4/c12/c16… 的 precise knee;
  3. open-loop offered-load SLO;
  4. realistic application/PgBouncer path;
  5. parameter-sensitive tenant skew;
  6. long-duration background overlap;
  7. production hardware;
  8. N+1/failover envelope。

这些 unknown 不阻止本次“拒绝”,因为 candidate 必须证明自己;没有足够收益证据时 baseline 胜出。

28 个反例

validator 必须拒绝:

wrong/recovery target
pre-existing fixture
upstream run/claim changed
two parameters
ALTER SYSTEM / Patroni edit / restart
durability change
statistics reset / cache drop
unpaired seed / fixed order
missing raw log / averaged p95
hidden failure / plan change / global drift
weak benefit or tail regression accepted
marker mismatch / force drop
secret/query leaked
production approval fabricated

positive evidence 通过而 negative evidence 也通过的 validator 没有保护力。

evidence bundle

preflight-evidence.json
remote-experiment.log
remote/
  tuning-evidence.json
  runs/<run-id>/
    pgbench.stdout
    pgbench.stderr
    transactions.*
    stats-before.json
    stats-after.json
remote-cleanup.json
validation-report.json
negative-report.json
public-summary.json
review.txt

正式 review:

source hashes                 matched
measured runs                 10
raw files verified            60
raw transaction logs          20
private bytes scanned         11,307,205
counterexamples               28 rejected
fixture/remote cleanup        verified
public raw/secret/query        absent

公共 allowlist:

tuning-run.json

独立练习

练习一:把结论做错。

只用两个 arm 的 median TPS,写出“提升 2.35%,接受”。再用 paired ratio 重算,解释 为什么结论翻转。

练习二:设计 skew probe。

构造一个 tenant-size 极不均匀的 prepared query,比较:

auto
force_custom_plan
force_generic_plan

先写 correctness/plan/tail/memory gate,不要先预设 generic 更好。

练习三:为 work_mem 写拒绝 ADR。

只允许使用 ch26 evidence。说明为什么 temp_bytes=0 足以拒绝 increase,却不足以批准 global decrease。

练习四:把 session candidate 变成 role canary。

只写 runbook,不执行。包括:

new canary role
pool route
new session proof
plan skew catalog
rollback
failover
observation window

完成检查表

  • objective 与 non-regression 在实验前声明。
  • observed service center 与 parameter mechanism 有证据连接。
  • 一次只测试一个机制。
  • 使用最小可逆 scope。
  • A/B 配对、seed 和顺序可复现。
  • p95 从 raw sample 重算。
  • effect 报区间,不只报两个 median。
  • correctness、plan、failure 与 resource 同时验证。
  • desired/configured/effective 没有漂移。
  • cleanup 不使用 force 或误杀。
  • 未测试参数明确拒绝。
  • candidate 不通过时没有“为了有成果”落盘。
  • production gate 在 production evidence 不足时保持 pending。

当最终结果是“不改”,而团队能清楚说明为什么,这套调优流程才真正成熟。


上一节:模板参数与集群变更 · 返回本章目录 · 下一章:除旧布新:VACUUM、冻结与膨胀治理 · 查看全书目录 · 查看索引中心

28 除旧布新:VACUUM、冻结与膨胀治理

PostgreSQL 的写入并不在提交时把旧世界擦掉。

UPDATE 写出新行版本,DELETE 标记旧版本失效;旧版本必须继续存在,直到所有可能看见 它的快照都离开。这个选择换来了读写并发,也把空间、统计、事务年龄和索引维护变成一套 持续运行的生命周期:

write
  -> create obsolete row versions
      -> wait until no snapshot can see them
          -> prune / VACUUM
              -> make page space reusable
                  -> update FSM / VM / statistics
                      -> freeze old XID / MXID

这条链上任一环被阻断,表现都可能是“膨胀”,但处理方法完全不同:

autovacuum 尚未触发         -> 触发参数与变化速率
worker 已触发但排队         -> worker / I/O / cost budget
VACUUM 扫过却没清掉         -> 长快照、槽、预备事务
页内空间已经可重用          -> 不一定需要缩小文件
数据稳定但 XID 很老         -> aggressive vacuum / freeze
heap 正常但 index 膨胀      -> index diagnosis / reindex
过期数据天然按时间成片      -> detach partition,不做海量 DELETE

所以本章不把 VACUUM 当作一个“清垃圾命令”,而把它放回四条相互关联的控制回路:

控制回路 主要问题 关键证据
MVCC 回收 哪些旧版本已经无人可见 backend_xminpgstattuple、dead tuple
空间与访问 空间能否复用,VM/FSM 是否更新 relation size、FSM、VM、HOT
事务年龄 离回卷保护线还有多远 relfrozenxiddatfrozenxidrelminmxid
结构与生命周期 是否需要重建或整片退役 amcheckpg_index、partition manifest

三个必须分开的结论

“不可见”不等于“已经移除”

一条旧版本对当前事务不可见,不代表它对所有事务不可见。决定是否可回收的是全局清理 边界,而不是当前查询的快照。

“已经回收”不等于“文件已经缩小”

普通 VACUUM 主要把空间登记为关系内部可复用。它在特定条件下可能截断关系尾部,但 不承诺把散落在文件中间的空洞交还操作系统。VACUUM FULL 会重写表并取得 ACCESS EXCLUSIVE 锁,不能作为日常扫尾。

“dead tuple 很多”不等于“表已经异常膨胀”

n_dead_tup 是累计统计系统的估计;一次写入尖峰、健康的稳态 churn、被长事务阻断、 真正的 heap bloat,可能给出相似的瞬时数字。至少要联合:

change rate
last vacuum / autovacuum
dead tuple estimate and physical sample
relation growth
free space
oldest xmin holders
workload reuse behavior

再决定“等下一轮、手工 VACUUM、解除保留者、在线重建,还是安排离线重写”。

本章实验:让旧快照亲自阻断回收

正式实验在已确认的 Pigsty pg-test 沙箱创建一次性数据库:

database   pg36_maintenance
role       dbuser_pg36maint
PostgreSQL 18.6
data       synthetic only
risk       L2 bounded disposable fixture

夹具初始有 60,000 行。实验先开启一个 REPEATABLE READ 事务并确认它持有非空 backend_xmin,然后:

UPDATE 40,000 rows
DELETE 10,000 rows
VACUUM while old snapshot remains

结果:

时点 当前行 pgstattuple dead tuple heap bytes FSM 可用空间
初始 60,000 0 61,440,000 19,680,000
churn 后 50,000 50,000 87,040,000 27,877,376
旧快照仍在,普通 VACUUM 后 50,000 50,000 87,040,000 17,480,000
精确释放旧快照并 VACUUM FREEZE 50,000 0 87,040,000 51,920,000

这个结果同时证明两件事:

  1. 扫过并不等于能清;旧快照仍需要那些版本时,普通 VACUUM 必须保留它们;
  2. 回收并不等于缩文件;最后 dead tuple 为零、FSM 可用约 51.9 MB,heap 文件仍是 87.04 MB。

最后一次维护还得到:

all-visible pages   10,625
all-frozen pages    10,625
relfrozenxid age    14 -> 2

注意:这不是在声称普通 VACUUM 永远不会截断文件。本夹具只观察到“文件未缩、空间 可复用”;生产结论必须保留普通 vacuum 有条件截断尾部的例外。

同一条证据链中的索引与分区

实验没有在 heap 回收后停止。

完整性与重建

bt_index_check(... heapallindexed=true, checkunique=true)      pass
bt_index_parent_check(... rootdescend=true, ...)               pass
REINDEX INDEX CONCURRENTLY                                     pass
invalid index after                                            0
_ccnew / _ccold artifact after                                 0

被重建的二级索引从 3,227,648 bytes 降到 1,589,248 bytes;relfilenode 改变, 索引仍只有一个、indisready/indisvalid/indislive 全为真。这个数据只说明夹具中的 重建完成,不能外推生产窗口的耗时、锁等待或空间余量。

分区退役

10,000 行过期分区采用:

logical manifest
  -> DETACH PARTITION CONCURRENTLY
      -> CSV export + SHA-256
          -> independent restore table
              -> row count / range / sum / digest equality
                  -> drop detached partition

导出文件为 547,894 bytes。只有在回灌后的 10,000 行逻辑摘要完全一致后,实验才删除 独立分区;父表保留 5,000 行当前数据。

公开结果见 maintenance-run.json,安全边界见 lab-contract.md

本章学习成果

完成本章后,你应该能:

  1. 从 MVCC 快照解释 UPDATEDELETE 为什么留下旧版本;
  2. 区分 tuple visibility、deadness、removability 与 reusable space;
  3. 解释 page pruning、HOT、regular vacuum、aggressive vacuum 的分工;
  4. 用 FSM、VM、relation size 和 physical tuple evidence 分别回答不同问题;
  5. 正确计算 PostgreSQL 18 的 autovacuum update/delete 与 insert 触发阈值;
  6. 用表级 storage parameter 做定点治理,同时避免把关闭 autovacuum 当调优;
  7. 从 worker、cost delay、memory、I/O 和 workload 联合判断维护竞争;
  8. 读取 pg_stat_progress_vacuum,但不把块比例冒充 ETA;
  9. backend_xmin、复制槽 xmin/catalog_xmin、两阶段事务找出保留者;
  10. 监控 relfrozenxid/datfrozenxidrelminmxid,避免只看数据库总年龄;
  11. 在回卷紧急态按安装版本的官方流程解除保留者并让普通 VACUUM 完成;
  12. 区分 stable-state bloat、transient churn、index bloat 和 statistics error;
  13. 评估 VACUUM FULL、在线重写和并发索引重建的锁、空间、WAL 与失败残留;
  14. DETACH ... CONCURRENTLY、归档清单和回灌验证完成分区退役;
  15. amcheck 建立分层完整性检查,而不把它当页校验或恢复演练的替代品;
  16. 把 Pigsty 历史指标、日志和 dashboard 与 PostgreSQL 原生视图交叉验证;
  17. 输出日常、每周、每月和事件驱动的维护清单;
  18. 在过载与疑似损坏时分别安全路由到第 34、35 章。

本章目录

28.1 死元组与可见性

28.2 autovacuum 的触发与资源

28.3 冻结、XID 与保留者

28.4 膨胀与重建

28.5 分区生命周期

28.6 amcheck 与例行完整性检查

28.7 实战:建立维护节奏

阅读路线

应用开发者:

28.1 -> 28.2.1 -> 28.3.2 -> 28.5 -> 28.7

重点是事务生命周期、长事务边界、表级写入特征和分区保留策略。

平台工程师:

28.1 -> 28.2 -> 28.3 -> 28.4 -> 28.6 -> 28.7

重点是触发、资源、回卷安全、重建窗口和完整性检查。

两条路线必须合流:应用定义事务与数据生命周期,平台维护全局清理边界和资源预算。 应用留下无限事务,平台无法“调快 VACUUM”;平台盲目重写,应用也无法获得可预测服务。

版本与证据权威

本章命令以 PostgreSQL 18 为基线,正式 run 使用 18.6;平台示例以 Pigsty 4.5 为 参考实现。维护与紧急恢复语义应按实际安装 major/minor 的 PostgreSQL 官方文档 执行,不把旧版本博客或平台二次说明覆盖到新版本。

特别是事务 ID 即将耗尽的处置,PostgreSQL 18 官方流程要求先处理 prepared xact、 长事务和旧复制槽,再运行普通 VACUUM;它明确不建议在该状态使用 VACUUM FULLVACUUM FREEZE,一般也不需要 single-user mode。本章 28.3.4 按这个版本事实展开。

核心资料:

实验文件

static/labs/ch28/
├── requirements.json
├── maintenance-contract.json
├── negative-cases.json
├── topology.mmd
├── lab-contract.md
├── capture.py
├── remote_experiment.py
├── exercise.py
├── validate.py
├── review.py
├── task.sh
└── maintenance-run.json

执行:

static/labs/ch28/task.sh lint

export PG36_EVIDENCE_DIR=/private/path/to/new-empty-dir
static/labs/ch28/task.sh capture
static/labs/ch28/task.sh exercise
static/labs/ch28/task.sh verify
static/labs/ch28/task.sh review

all 会按同一顺序执行。exercise 会产生真实 I/O、WAL、锁和短暂维护负载,只能在 已确认的一次性开发/测试环境运行。

本章验收

只有当你能交付以下证据,才算掌握本章:

trigger calculation
holder inventory
progress and blocker evidence
before/after physical + cumulative statistics
XID and MXID headroom
lock / space / WAL budget
maintenance command and rollback
index validity after rebuild
partition archive and restore manifest
exact cleanup or production change record

“我跑了 VACUUM,没有报错”不是验收。


上一章:精益求精:参数调优与资源治理 · 返回下卷导读 · 下一章:移花接木:逻辑复制、迁移与异构同步 · 查看全书目录 · 查看索引中心

28.1 死元组与可见性

理解 VACUUM 的第一步,是放弃“表里只有当前行”的直觉。

PostgreSQL heap 存放的是行版本。一个逻辑主键在不同时间可能对应多条物理 tuple; 每个查询再用自己的 snapshot 判断哪一条可见。空间回收不能问:

这条旧版本对我还可见吗?

而要问:

集群清理边界之前,是否还存在任何合法快照可能看见它?

这两个问题之间的时间差,就是 MVCC 的空间债。

28.1.1 UPDATE/DELETE 如何产生旧版本

UPDATE 不是原地覆盖

概念上,一次更新经历:

old tuple
  xmax <- updating transaction
  t_ctid -> new tuple location

new tuple
  xmin <- updating transaction
  values <- new values

事务提交后:

  • 新快照通常看新版本;
  • 更新前已经建立的旧快照仍可能看旧版本;
  • rollback 则让更新产生的新版本不可见;
  • vacuum 不能在旧快照离开前移除它仍可能访问的版本。

DELETE 不需要创建“空的新行”,而是在旧版本上记录删除事务;它同样要等到删除前的 快照离开,才可物理回收。

这就是 PostgreSQL 18 官方维护文档强调的边界:UPDATE/DELETE 不立即移除旧行, 因为它可能仍对并发事务可见。参见 Routine Vacuuming

四个不同状态

不要把以下词混成一个 dead

状态 含义 能否立即物理移除
对当前 snapshot 不可见 本查询不应返回 未必
对所有可能 snapshot 都不可见 已跨过清理边界 通常可成为回收候选
已由 vacuum/prune 处理 tuple/line pointer 已清理或重定向 页内空间可复用
文件系统已收回 关系文件缩小或重写完成 是另一项操作结果

例如:

T1 BEGIN ISOLATION LEVEL REPEATABLE READ
T1 SELECT row                 -- snapshot S1

T2 UPDATE row
T2 COMMIT

T3 SELECT row                 -- sees new version
T1 SELECT row                 -- still sees old version

在 T1 结束前,T3 看不见旧版本不等于旧版本可删。

xminxmaxctid 是诊断入口,不是业务 API

在教学夹具可以观察:

SELECT
  ctid,
  xmin::text,
  xmax::text,
  id,
  revision
FROM maint.churn
WHERE id = 42;

但要保留三项边界:

  1. 普通 SQL 只返回当前 snapshot 可见的版本,不会自动展示完整版本链;
  2. ctid 会随 UPDATE、表重写和行移动变化,不能当持久业务键;
  3. xmin/xmax 是内部事务标识,存在冻结、回卷和 multixact 语义,不能当无限增长的 业务版本号。

若要检查页面内部,需要 pageinspect 等更侵入的诊断工具;它们适合受控故障分析, 不适合高频全库扫描。

一个容易忽略的命令级快照

数据修改 CTE 的兄弟子语句共享同一个命令快照:

WITH updated AS (
  UPDATE t SET payload = 'new'
  WHERE id <= 100
  RETURNING id
), deleted AS (
  DELETE FROM t
  WHERE id <= 100
  RETURNING id
)
SELECT ...;

不要依赖 deleted 再处理已经被 updated 修改的同一行,也不要用该命令末尾对原表的 count(*) 证明提交后状态。第 28 章实验最初正是在这里被验收器拒绝:

UPDATE count       40,000
DELETE count       10,000
same-command count 60,000
next-command count 50,000

删除确实发生了;同命令读仍使用旧 command snapshot。正确证据是把修改计数和提交后 状态拆成两个 SQL 命令。这一例子也说明:没有明确 snapshot,所谓“当前行数”并不完整。

n_dead_tup 是估计,不是验尸报告

常用视图:

SELECT
  schemaname,
  relname,
  n_live_tup,
  n_dead_tup,
  n_tup_ins,
  n_tup_upd,
  n_tup_del,
  n_tup_hot_upd,
  n_tup_newpage_upd,
  last_vacuum,
  last_autovacuum,
  vacuum_count,
  autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

n_dead_tup 来自累计统计系统,更新是最终一致的,且本来就是估计。它适合:

  • 找趋势;
  • 排优先级;
  • 关联写入速率和维护时间;
  • 发现“长期只增不降”的异常。

它不适合单独证明:

  • 精确有多少物理旧版本;
  • 多少版本已经可由 vacuum 移除;
  • 表文件浪费了多少字节;
  • 是否应该 VACUUM FULL

受控诊断可补:

CREATE EXTENSION pgstattuple;

SELECT *
FROM pgstattuple('app.orders'::regclass);

pgstattuple 会扫描关系,能给更直接的 tuple/free-space 证据,但它也消耗 I/O;大型 生产表要先评估窗口,可考虑 pgstattuple_approx 或抽样型 bloat estimate。所谓 “更精确”不是“零成本”。

谁决定“仍可能可见”

清理边界受多类对象影响:

running transaction snapshot
backend_xmin
idle in transaction
logical replication slot xmin/catalog_xmin
standby feedback
prepared transaction

因此 VACUUM 没清掉时,先找保留者,而不是先提高 vacuum worker。第 28.3 节会把 每一类对象拆开。

28.1.2 vacuum、prune、HOT 与可见性图

VACUUM 不是唯一清理旧版本的地方,也不是所有清理都做同一件事。

page pruning:局部、机会式

访问 heap page 时,如果页面上有可安全裁剪的版本链,PostgreSQL 可以做 page pruning:

remove no-longer-needed intermediate tuple data
convert root line pointer to redirect
compact page free space
preserve chain reachability for indexes

它的作用域是当前页,不会:

  • 扫全表;
  • 清所有索引死条目;
  • 更新全关系统计;
  • 推进整个表的 relfrozenxid
  • 替代周期性 vacuum。

因此出现:

n_dead_tup decreased before autovacuum

并不神秘,可能是热点页被访问时发生了 pruning。

HOT:避免不必要的索引版本

PostgreSQL 18 的 HOT 条件是:

  1. 更新没有修改任何被普通索引引用的列;核心中的 summarizing index 例外是 BRIN;
  2. 原 tuple 所在页面有足够空间放新版本。

满足时:

  • 新版本不需要给普通索引添加新 index tuple;
  • 中间版本可由 page pruning 更便宜地移除;
  • 索引仍通过原始 line pointer 沿 HOT chain 找到可见版本。

参见 Heap-Only Tuples

HOT 不是 UPDATE 的固定属性。下面这些都会降低它:

update indexed column
update expression-index referenced column
page has no room
wide row grows
fillfactor leaves too little reserve
write pattern moves working set to packed pages

监控:

SELECT
  relname,
  n_tup_upd,
  n_tup_hot_upd,
  n_tup_newpage_upd,
  round(
    100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0),
    2
  ) AS hot_pct,
  round(
    100.0 * n_tup_newpage_upd / nullif(n_tup_upd, 0),
    2
  ) AS newpage_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY n_tup_upd DESC;

实验表使用 fillfactor=70,更新 40,000 行时观察到:

n_tup_hot_upd      15,000
n_tup_newpage_upd  25,000

这不是“70% fillfactor 应得到 37.5% HOT”的公式。它只是说明同样不改索引列的 update, 仍有 25,000 行因为页内空间条件转到新页。是否调整 fillfactor,要联合:

HOT gain
base table footprint
cache residency
scan cost
insert density
rewrite cost

不能只追求 100% HOT。

普通 VACUUM 的四项工作

官方文档把日常 vacuum 目的分成:

  1. 回收或复用 UPDATE/DELETE 占用的空间;
  2. 更新 planner statistics;
  3. 更新 visibility map,帮助 index-only scan;
  4. 防止 XID/MXID 回卷。

一次命令不一定对每项做相同强度。例如:

VACUUM table
VACUUM (ANALYZE) table
VACUUM (FREEZE) table
VACUUM (INDEX_CLEANUP OFF) table

语义不同。INDEX_CLEANUP OFF 在极端防回卷场景可减少工作,但若长期跳过,索引死条目 和 heap line pointer 会累积;PostgreSQL 18 还有 failsafe 机制可在危险年龄自动跳过 某些昂贵工作。不要把临时救险选项变成常规模板。

FSM:哪里还有可放新 tuple 的空间

每个 heap 和除 hash 外的 index relation 都有 Free Space Map。它按页记录可用空间的 近似信息,帮助 insert/update 找到可复用页。

CREATE EXTENSION pg_freespacemap;

SELECT
  count(*) AS pages,
  sum(avail) AS reusable_bytes,
  max(avail) AS largest_page_free_bytes
FROM pg_freespace('app.orders'::regclass);

FSM 回答的是:

关系内部哪些页有空间可供后续写入?

它不回答:

操作系统现在多了多少 free bytes?

官方结构说明见 Free Space Map

VM:哪些页可以被安全跳过

heap relation 的 Visibility Map 每页两位:

bit 含义 主要用途
all-visible 页内 tuple 对所有事务可见,没有 tuple 需要 vacuum index-only scan 可跳 heap visibility check
all-frozen 页内 tuple 已冻结 anti-wraparound vacuum 可跳过

VM 是保守结构:

bit = 1 -> 条件必须为真
bit = 0 -> 条件可能不真,也可能尚未被 vacuum 证明

修改页面会清位,只有 vacuum 置位。因此:

all_visible = 0

不能直接推出页面里一定有 dead tuple。

观察:

CREATE EXTENSION pg_visibility;

SELECT *
FROM pg_visibility_map_summary('app.orders'::regclass);

需要进一步一致性检查时:

SELECT * FROM pg_check_visible('app.orders'::regclass);
SELECT * FROM pg_check_frozen('app.orders'::regclass);

非空结果意味着 VM 与 heap 的约束可能损坏,应停止普通维护、保全证据并进入第 35 章 的数据抢救流程,而不是“清空 VM 看看”。pg_truncate_visibility_map 是修复性、 超级用户操作,会迫使后续 vacuum 重建 VM,必须有明确故障证据和变更记录。

官方说明见 Visibility Mappg_visibility

一张图看职责

UPDATE / DELETE
  |
  v
old row versions --------> snapshot horizon
  |                             |
  | page-local                  | when safe
  v                             v
prune / HOT chain          VACUUM heap scan
  |                             |
  +------ reusable page space --+--> FSM
                                |
                                +--> index cleanup
                                +--> VM all-visible/all-frozen
                                +--> relfrozenxid / relminmxid
                                +--> optional ANALYZE

28.1.3 回收可重用空间不等于归还文件系统

普通 VACUUM 的 steady-state 目标

高 churn 表最健康的状态通常不是“每晚回到最小文件”,而是:

minimum live footprint
  + space consumed between vacuum cycles
  = stable relation plateau

后续更新和插入复用 plateau 内的空闲页,文件不再无限增长。PostgreSQL 官方文档建议 用较频繁的普通 vacuum 维持稳态,避免把 VACUUM FULL 当周期任务。

普通 VACUUM 也可能截断尾部

两个绝对命题都错:

normal VACUUM always shrinks files     false
normal VACUUM never shrinks files      false

普通 vacuum 主要原地处理页面;若关系尾部形成连续空页且锁等条件允许,它可能截断尾部。 文件中间的空洞不能靠截尾交还操作系统,但仍可由关系复用。

因此正确表述是:

普通 VACUUM 不承诺按 dead tuple 数缩小文件;其主要产物是可重用空间,并可能在 条件满足时截断空闲尾部。

用三类 size,不用一个数字

SELECT
  pg_relation_size('app.orders')       AS heap_bytes,
  pg_indexes_size('app.orders')        AS index_bytes,
  pg_total_relation_size('app.orders') AS total_bytes;

再联合:

SELECT *
FROM pgstattuple('app.orders');

SELECT sum(avail)
FROM pg_freespace('app.orders');

这些值回答不同问题:

指标 回答 不回答
heap bytes 主 fork 当前文件规模 其中多少马上可移除
index bytes 全部索引文件规模 每个索引是否逻辑健康
total bytes heap + indexes + TOAST 等总体 OS 会不会马上得到空间
free_space heap 扫描看到的自由空间 未来 workload 是否会复用
FSM sum allocator 已知的页内空间 精确物理空洞
dead tuple 旧版本数量/字节 是否被长快照保留

正式 run 的反直觉结果

baseline heap                 61,440,000 bytes
after churn heap              87,040,000 bytes
after holder-blocked vacuum   87,040,000 bytes
after release + freeze        87,040,000 bytes

dead tuples:
  with old snapshot           50,000
  after release               0

FSM reusable:
  final                       51,920,000 bytes

表已经具备很大的内部复用空间,却没有缩小。这是普通 vacuum 的正常结果,不是失败。

更重要的是,第一次普通 vacuum 在旧 snapshot 存在时:

progress samples   128
phases             initializing, scanning heap
dead tuples        still 50,000

它确实工作了;只是清理边界不允许移除那些版本。第二次在释放保留者后:

progress samples   171
phases             scanning heap, vacuuming indexes, vacuuming heap
dead tuples        0
all-frozen pages   10,625

“命令成功”与“达成预期回收”必须分别验收。

什么时候才需要把空间交还 OS

先回答:

Will the table reuse the space within the retention horizon?

若会:

  • 保留稳定 plateau;
  • 让 autovacuum 跟上;
  • 调整 fillfactor/索引设计;
  • 监控增长斜率。

若不会,且空间有现实价值:

one-time purge
tenant offboarding
retention shortened
schema removed wide columns
index permanently overgrown
filesystem headroom endangered

才评估重写。

重写决策必须有预算

object: app.orders
live_bytes: ...
estimated_rewrite_bytes: ...
extra_disk_required: ...
wal_generated_estimate: ...
replica_replay_headroom: ...
archive_headroom: ...
lock_mode: ...
long_transaction_wait: ...
duration_estimate: ...
rollback: ...
backup_and_restore_proof: ...

不同方法:

方法 主要效果 主要代价
normal VACUUM 页内复用、VM/freeze 不整理中间空洞
VACUUM FULL 重写并缩 heap ACCESS EXCLUSIVE、额外空间、WAL、长窗口
CLUSTER 按索引重写排序 强锁、额外空间、后续不会自动保持
pg_repack 较在线地重建 extension、额外对象/空间、trigger/锁/失败治理
logical copy/swap 最大控制力 迁移与双写/切换复杂度
partition detach 整片退役 设计前提、DDL/依赖/归档流程

第 28.4 节展开前三类重建,第 28.5 节处理分区退役。

停止线

看到以下任一情况,不要继续“加大清理”:

oldest backend_xmin is unexplained
replication slot consumer ownership unknown
prepared transaction ownership unknown
free disk cannot hold rewrite
backup exists but restore untested
replica/archive headroom insufficient
lock queue begins to grow
suspected structural corruption

前六项先补治理证据;最后一项转入 第 35 章:数据抢救与工程取证

本节检查清单

你应能对一张表给出:

logical live rows
cumulative dead estimate
physical tuple/free-space sample
heap / index / total bytes
HOT / new-page update ratio
VM all-visible / all-frozen
oldest holder
last vacuum / autovacuum
write and growth rate
expected future reuse

只有这些信息组合起来,VACUUM 是否健康、是否被阻断、是否需要重写才是可回答的问题。

延伸阅读


返回本章目录 · 下一节:autovacuum 的触发与资源 · 查看全书目录 · 查看索引中心

28.2 autovacuum 的触发与资源

autovacuum 不是“每隔一分钟把所有表 vacuum 一遍”。

它是一套按数据库调度、按表判断资格、按 worker 执行、按 cost budget 限速的后台系统:

launcher
  -> choose database
      -> worker examines relations
          -> trigger decision
              -> VACUUM / ANALYZE / both
                  -> resource and lock interaction

排障必须分开问:

  1. 表有没有达到触发条件?
  2. 有 worker 能接活吗?
  3. worker 启动后在做什么?
  4. 为什么扫完仍留下旧版本?

“把 scale factor 调小”最多回答第一个问题的一部分。

28.2.1 阈值、比例、插入触发与表级覆盖

UPDATE/DELETE 触发公式

PostgreSQL 18 对普通 vacuum 的变化量先计算为:

Traw=Tbase+fvacuum×Ntable T_{\text{raw}} = T_{\text{base}} + f_{\text{vacuum}} \times N_{\text{table}}

对应:

T_base   autovacuum_vacuum_threshold
f        autovacuum_vacuum_scale_factor
N_table  pg_class.reltuples
T_max    autovacuum_vacuum_max_threshold

当 $T_{\max}\ge 0$ 时,最终阈值为:

Tvacuum=min(Tmax,Traw) T_{\text{vacuum}} = \min(T_{\max}, T_{\text{raw}})

autovacuum_vacuum_max_threshold=-1 时,表示禁用最大阈值,最终阈值就是 $T_{\text{raw}}$,不能把 -1 直接代入 min()

当自上次 vacuum 以来被 UPDATE/DELETE 变旧的 tuple 估计数超过这个阈值,表取得 vacuum 资格。

PostgreSQL 18 引入/使用 autovacuum_vacuum_max_threshold 作为上限:

base=500
scale=0.08
max=100,000,000
reltuples=1,000,000,000

base + scale * rows = 80,000,500
effective threshold = 80,000,500

若表为 10 billion rows:

base + scale * rows = 800,000,500
effective threshold = 100,000,000

版本低于 PostgreSQL 18 时,不要照抄这个公式中的 max 项;先查对应 major 文档和 pg_settings 是否存在。

INSERT-only 也需要 vacuum

只插不删的表没有 dead tuple,却仍需要:

  • 更新 visibility map;
  • 让 index-only scan 受益;
  • 冻结旧 XID;
  • 降低以后 aggressive vacuum 的工作。

插入触发公式为:

Tinsert=Tinsert-base+finsert×Ntable×(1relallfrozenrelpages) T_{\text{insert}} = T_{\text{insert-base}} + f_{\text{insert}} \times N_{\text{table}} \times \left(1 - \frac{\text{relallfrozen}}{\text{relpages}}\right)

对应:

autovacuum_vacuum_insert_threshold
autovacuum_vacuum_insert_scale_factor
pg_class.reltuples
unfrozen page fraction

这不是简单的:

1000 + 0.2 * rows

它还乘以“未冻结页面比例”。当表逐步 all-frozen,insert-based 触发的 scale 部分也会 变化。

ANALYZE 有自己的阈值

Tanalyze=Tanalyze-base+fanalyze×Ntable T_{\text{analyze}} = T_{\text{analyze-base}} + f_{\text{analyze}} \times N_{\text{table}}

变化量包括 insert/update/delete。vacuum 和 analyze 可能:

only vacuum
only analyze
vacuum then analyze

不要把 last_autovacuumlast_autoanalyze

freeze 资格优先于普通变化量

relfrozenxid 年龄超过 autovacuum_freeze_max_age,系统会强制 vacuum,即使:

  • 普通 autovacuum GUC 为 off;
  • 表级 autovacuum_enabled=false
  • dead tuple 没达到普通阈值。

同理,multixact 有独立的:

relminmxid
autovacuum_multixact_freeze_max_age

因此“关 autovacuum”既不安全,也不能保证后台永远不出现 worker;防回卷维护是正确性 机制,不是可选性能功能。

reltuples 和 change count 都不是精确实时值

触发器依赖:

  • pg_class.reltuples 估计;
  • cumulative statistics 的变化计数;
  • 最近 vacuum/analyze 更新;
  • stats flush 的最终一致性。

边界附近出现几秒或一轮调度差异是正常的。排障时先查看实际输入:

WITH p AS (
  SELECT
    current_setting('autovacuum_vacuum_threshold')::numeric AS base,
    current_setting('autovacuum_vacuum_scale_factor')::numeric AS scale,
    current_setting('autovacuum_vacuum_max_threshold')::numeric AS max_t
)
SELECT
  s.schemaname,
  s.relname,
  c.reltuples,
  s.n_dead_tup,
  CASE
    WHEN p.max_t < 0 THEN p.base + p.scale * c.reltuples
    ELSE least(p.max_t, p.base + p.scale * c.reltuples)
  END AS estimated_trigger,
  s.last_autovacuum
FROM pg_stat_user_tables AS s
JOIN pg_class AS c ON c.oid = s.relid
CROSS JOIN p
ORDER BY
  s.n_dead_tup
  / nullif(
      CASE
        WHEN p.max_t < 0 THEN p.base + p.scale * c.reltuples
        ELSE least(p.max_t, p.base + p.scale * c.reltuples)
      END,
      0
    )
  DESC NULLS LAST;

这段查询仍没处理表级覆盖,生产版需要把 reloptions 合并进来。

表级覆盖:治疗特殊表,不复制全局配置

高 churn 大表、append-only 表和小型 catalog-like 表,触发策略可能不同:

ALTER TABLE app.hot_orders SET (
  autovacuum_vacuum_threshold = 1000,
  autovacuum_vacuum_scale_factor = 0.01,
  autovacuum_analyze_scale_factor = 0.02
);

查看:

SELECT
  n.nspname,
  c.relname,
  c.reloptions
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.reloptions IS NOT NULL
ORDER BY 1, 2;

恢复继承全局值:

ALTER TABLE app.hot_orders RESET (
  autovacuum_vacuum_threshold,
  autovacuum_vacuum_scale_factor,
  autovacuum_analyze_scale_factor
);

表级覆盖适合:

one relation demonstrably misses its maintenance window
its write pattern differs materially from cluster norm
change rate and resource budget are measured
override is in schema/IaC and reviewed

不适合:

copy every global GUC to every table
set autovacuum_enabled=false as tuning
hide a long-transaction blocker
raise freeze age to silence alerts

第 28 章实验为了让手工 vacuum 不被后台抢跑,在一次性夹具表上临时设置 autovacuum_enabled=false;数据库清理后该设置随表消失。公开结果明确将其列为 fixture_table_autovacuum_enabled=false,不是生产建议。

计算后还要看时间

一个表达到阈值只表示“有资格”,不表示立刻开始。launcher 要轮询数据库,worker 要 可用,其他 relation 可能排在前面。

评估维护能力,应比较:

dead tuple arrival ratevsvacuum reclamation rate \text{dead tuple arrival rate} \quad \text{vs} \quad \text{vacuum reclamation rate}

若每小时产生 500 million obsolete tuples,而可用 worker 每小时只能处理 300 million, 调低触发阈值只会更早开始积压,不能解决服务率不足。

28.2.2 worker、cost delay、I/O 与业务竞争

launcher、worker slot 和 worker 上限

PostgreSQL 18 需要同时理解:

autovacuum_worker_slots
autovacuum_max_workers
autovacuum_naptime
number of databases

autovacuum_worker_slots 在启动时为 worker 预留 backend slot; autovacuum_max_workers 是可同时运行 worker 的上限。把后者设得高于前者没有效果。

launcher 尝试把工作分散到各数据库;有 $N$ 个数据库时,会试图约每 autovacuum_naptime / N 启动一个 worker。它不是每个数据库独立一套无限 worker。

查询:

SELECT name, setting, unit, context, source, pending_restart
FROM pg_settings
WHERE name IN (
  'autovacuum',
  'autovacuum_worker_slots',
  'autovacuum_max_workers',
  'autovacuum_naptime'
)
ORDER BY name;

worker 数只是并发上限

增加 worker 可能:

  • 减少多个数据库/表的排队;
  • 让更多表并发扫描;
  • 同时增加 I/O、CPU、buffer churn;
  • 放大 autovacuum_work_mem 总预算;
  • 与 foreground query、checkpoint、backup、replay 竞争。

若瓶颈是单块磁盘,三个 worker 已把设备打满,再加三个只会提高 queue depth 和业务 tail latency。

先看:

eligible tables waiting
active autovacuum workers
worker phase
disk latency / queue
CPU busy / run queue
buffer and cache effect
business p95/p99
replica and archive lag

memory 按 worker 放大

autovacuum_work_mem 控制每个 autovacuum worker 可用的 maintenance memory;设为 -1 时回退到 maintenance_work_mem。粗略预算:

MautoWactive×Mper-worker M_{\text{auto}} \le W_{\text{active}} \times M_{\text{per-worker}}

这仍是上界近似,不是每个 worker 永远一次性占满。它用于预留最坏并发,而不是预测 RSS 精确值。

内存主要影响:

  • 收集 dead item identifiers;
  • index vacuum cycle 频率;
  • maintenance 内部结构。

它不会让一个被旧 snapshot 保留的 tuple 突然可删。

PostgreSQL 18 的 pg_stat_progress_vacuum 暴露:

max_dead_tuple_bytes
dead_tuple_bytes
num_dead_item_ids
index_vacuum_count

可以判断是否因为维护内存限制而反复做 index vacuum cycle。

cost delay 是 I/O 影响控制,不是带宽保证

vacuum 给页面操作累计抽象 cost:

page hit
page miss
page dirty

达到 vacuum_cost_limit 后,sleep vacuum_cost_delay 再继续。

autovacuum 对应:

autovacuum_vacuum_cost_delay
autovacuum_vacuum_cost_limit

若 autovacuum cost limit 非 -1,PostgreSQL 会在并行 worker 之间按比例分配,使各 worker limit 合计不超过该值。这意味着:

worker 变多,不等于每个 worker 都拿到完整 limit。

另外:

  • 手工 VACUUM 的 cost delay 默认关闭,除非显式设置非零;
  • 持有关键锁的操作段不会照常 sleep;
  • failsafe 触发后会停止 cost delay,并跳过非必要工作来优先防回卷;
  • cost unit 不是 IOPS 或 MB/s,必须用 OS/Pigsty I/O 指标校准。

正式实验为了可靠抓取进度,只在 vacuum session 设置:

SET vacuum_cost_delay = '10ms';
SET vacuum_cost_limit = 20;
SET track_cost_delay_timing = on;

session 结束即回退;没有修改集群配置。10 ms 是教学限速,不是推荐生产值。官方文档 指出正常配置通常应使用很小的 delay,大延迟并不理想。

I/O 不是唯一竞争

vacuum 还会:

read heap
dirty heap / VM / FSM
read and update indexes
generate WAL for maintenance changes
use CPU to evaluate tuple visibility
acquire relation/page locks
evict useful shared/OS cache pages

所以 “iowait 不高” 不能证明 vacuum 无影响。可能:

  • 数据在 cache,竞争表现为 CPU 和 buffer churn;
  • device 很快,竞争表现为 foreground tail;
  • cloud storage queue 未映射为 host iowait;
  • cost delay 让 worker 大量 sleep;
  • checkpoint/backup 与 vacuum 交织。

维护优先级不是一刀切

可把对象分三层:

P0 correctness:
  XID/MXID danger
  suspected corruption

P1 service health:
  dead tuple backlog accelerating
  index cleanup not completing
  table growth threatens disk/SLO

P2 efficiency:
  moderate bloat
  stale statistics
  low HOT ratio

P0 不能为了降低业务 I/O 无限限速;P2 不应在业务峰值争抢资源。

28.2.3 进度、阻塞与“为什么没清掉”

先确认 worker 身份

SELECT
  pid,
  datname,
  usename,
  backend_type,
  application_name,
  state,
  wait_event_type,
  wait_event,
  xact_start,
  query_start,
  query
FROM pg_stat_activity
WHERE backend_type = 'autovacuum worker'
   OR query LIKE 'autovacuum:%'
ORDER BY query_start;

防回卷 worker 的 query 文本会带 (to prevent wraparound)。它与普通 autovacuum 的 取消策略不同:冲突锁通常可中断普通 autovacuum,但防回卷 worker 不会被自动中断。

读取原生 progress

SELECT
  p.pid,
  p.datname,
  p.relid::regclass AS relation,
  p.phase,
  p.heap_blks_total,
  p.heap_blks_scanned,
  p.heap_blks_vacuumed,
  p.index_vacuum_count,
  p.dead_tuple_bytes,
  p.num_dead_item_ids,
  p.indexes_total,
  p.indexes_processed,
  p.delay_time
FROM pg_stat_progress_vacuum AS p
ORDER BY p.pid;

PostgreSQL 18 的主要 phase:

initializing
scanning heap
vacuuming indexes
vacuuming heap
cleaning up indexes
truncating heap
performing final cleanup

解释时注意:

  • heap_blks_total 是开始扫描时的规模;
  • VM 跳过的块仍会计入 scanned 的推进;
  • heap_blks_vacuumed 可能跳跃;
  • index 可能有多个 cycle;
  • truncation、锁等待和 index cleanup 的耗时不由 heap 扫描百分比线性预测。

所以:

heap_blks_scanned / heap_blks_total

是 scan progress,不是可靠 ETA。

“没清掉”的决策树

Did VACUUM run?
├─ no
│  ├─ below threshold
│  ├─ worker unavailable
│  ├─ autovacuum/table option disabled
│  ├─ statistics not updating
│  └─ permissions/manual command skipped relation
└─ yes
   ├─ old snapshot still needs tuples
   ├─ replication slot xmin/catalog_xmin retains them
   ├─ prepared transaction retains horizon/locks
   ├─ index cleanup skipped/deferred
   ├─ pages skipped to avoid waits
   ├─ only estimate is stale
   ├─ space became reusable but file did not shrink
   └─ new churn arrived as fast as cleanup

blocker inventory

SELECT
  pid,
  usename,
  application_name,
  state,
  xact_start,
  state_change,
  backend_xid,
  backend_xmin,
  age(backend_xid)  AS xid_age,
  age(backend_xmin) AS xmin_age,
  wait_event_type,
  wait_event
FROM pg_stat_activity
WHERE backend_xid IS NOT NULL
   OR backend_xmin IS NOT NULL
   OR state LIKE 'idle in transaction%'
ORDER BY age(backend_xmin) DESC NULLS LAST;

再查:

SELECT
  slot_name,
  slot_type,
  database,
  active,
  age(xmin) AS xmin_age,
  age(catalog_xmin) AS catalog_xmin_age,
  restart_lsn,
  wal_status,
  inactive_since,
  invalidation_reason
FROM pg_replication_slots;

SELECT
  transaction,
  age(transaction) AS xid_age,
  gid,
  prepared,
  owner,
  database
FROM pg_prepared_xacts
ORDER BY age(transaction) DESC;

不要在第一条查询里直接拼 pg_terminate_backend。先确认:

owner
application
business transaction semantics
retry behavior
prepared transaction coordinator
replication consumer
HA/failover impact

然后才能决定 cancel、terminate、commit、rollback 或 drop slot。

累计结果

SELECT
  relid::regclass,
  n_live_tup,
  n_dead_tup,
  last_vacuum,
  last_autovacuum,
  vacuum_count,
  autovacuum_count,
  total_vacuum_time,
  total_autovacuum_time
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

PostgreSQL 18 增加/提供 vacuum/analyze 累计耗时列;部署跨版本查询时要先检查列存在。 累计 view 会 reset,必须联合 stats_reset 和 Pigsty 时序数据,不要把 reset 后的 “低计数”解释成改善。

Pigsty:历史趋势与原生瞬时事实互补

Pigsty 的监控栈把 PostgreSQL、PgBouncer、Patroni、主机和日志放到同一组 cls/ins/ip 标签下。对维护问题,常用:

页面 看什么
PGSQL Tables / Table dead/live、scan、vacuum、relation trend
PGCAT Table 当前 catalog、size、bloat 类诊断
PGSQL Persist XID、WAL、checkpoint、archive、持久性
PGSQL Activity / Session backend、wait、长事务
PGSQL Replication slot、replica、replay/retention
PGCAT Locks blocker/waiter
PGLOG autovacuum verbose、warning、cancel/failsafe
NODE Instance disk latency、queue、space、CPU、memory

Dashboard 回答:

when did it start?
is it accelerating?
which instance/table changed?
what else happened at the same time?

原生 SQL 回答:

which PID and phase now?
which exact xmin/slot/prepared xact retains horizon?
which reloption and effective GUC applies?

两者必须互证。Grafana panel 不是另一个数据库真相层。

处置顺序

1. classify correctness vs service vs efficiency
2. verify actual trigger inputs and table overrides
3. locate active/queued workers and progress
4. inventory holders
5. compare cleanup rate with churn rate
6. check I/O/CPU/memory/WAL/replica side effects
7. choose smallest reversible intervention
8. validate dead/reusable/age outcome
9. record desired state and rollback

跳过第 4 步直接“手工再 vacuum 一次”,通常只会重复同一失败。

本节检查清单

effective update/delete threshold
effective insert threshold
analyze threshold
freeze/MXID age
table reloptions
eligible backlog
worker slots / max workers
per-worker and total memory budget
cost limit distribution
progress phase and cycle
old snapshot/slot/2PC holders
foreground tail and device pressure
cleanup rate vs churn rate

延伸阅读


上一节:死元组与可见性 · 返回本章目录 · 下一节:冻结、XID 与保留者 · 查看全书目录 · 查看索引中心

28.3 冻结、XID 与保留者

空间膨胀会让系统越来越慢;事务 ID 回卷可能让系统为了保护数据而拒绝写入。

两者都由 VACUUM 参与治理,却不是同一个风险:

space debt:
  obsolete tuple -> reusable page space

age debt:
  old XID/MXID -> frozen/advanced horizon

一张几乎不更新的静态表可能没有 dead tuple,却必须周期性冻结;一张高 churn 表可能 每天 vacuum,仍被一个旧 snapshot 阻止回收。成熟运维要同时看“垃圾速度”和“年龄 安全线”。

28.3.1 XID 年龄、冻结与回卷保护

XID 是全局 32 位循环空间

普通内部 xid 为 32 位,约每 42.9 亿次分配回卷一次。PostgreSQL 用模 $2^{32}$ 比较:

relative to current XID:
  about 2 billion are in the past
  about 2 billion are in the future

若一个行版本保留超过约 20 亿个事务,它原本的插入 XID 会从“过去”落到“未来”的 比较区间。冻结的目的,是把已确定对所有当前与未来事务可见的老版本标成永久过去。

注意:

  • XID 在事务第一次需要写入时才分配,不一定等于 BEGIN 时间顺序;
  • txid_current() 等旧接口与 xid8/epoch 语义不同;
  • 不能用 32 位 xmin 做长期业务序列或跨回卷排序。

事务标识内部说明见 Transactions and Identifiers

冻结是 flag,不是把 xmin 改成 2

PostgreSQL 9.4 以后,冻结通常设置 tuple header flag,并保留原 xmin 供取证;不能 因为查询还看到原 xmin 就断言“没冻结”。老版本升级数据库可能仍见 FrozenTransactionId=2

正确证据是:

relfrozenxid advance
VM all-frozen pages
VACUUM VERBOSE freeze output
pg_visibility checks

而不是:

SELECT count(*) WHERE xmin <> '2';

关系、数据库和 TOAST 三层年龄

每个普通表/物化视图:

pg_class.relfrozenxid
pg_class.relminmxid

每个数据库:

pg_database.datfrozenxid = minimum relation relfrozenxid
pg_database.datminmxid   = minimum relation relminmxid

database 值用于 cluster-level 预警,relation 值用于定位。表的 TOAST relation 也可能 最老,不能漏。

数据库总览:

SELECT
  datname,
  age(datfrozenxid) AS xid_age,
  mxid_age(datminmxid) AS mxid_age,
  datallowconn
FROM pg_database
ORDER BY age(datfrozenxid) DESC;

当前数据库按关系下钻:

SELECT
  c.oid::regclass AS relation,
  c.relkind,
  age(c.relfrozenxid) AS heap_xid_age,
  age(t.relfrozenxid) AS toast_xid_age,
  greatest(
    age(c.relfrozenxid),
    coalesce(age(t.relfrozenxid), 0)
  ) AS effective_xid_age,
  mxid_age(c.relminmxid) AS heap_mxid_age,
  mxid_age(t.relminmxid) AS toast_mxid_age,
  pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class AS c
LEFT JOIN pg_class AS t ON t.oid = c.reltoastrelid
WHERE c.relkind IN ('r', 'm')
ORDER BY effective_xid_age DESC
LIMIT 50;

relfrozenxid 是最近一次成功推进该边界的 vacuum 结果,不是“最老一行的精确插入 时间”。age() 是相对于当前 XID 的事务数量,不是秒。

把年龄换成时间余量

同样 100 million age:

100 TPS XID allocation       about 11.6 days
10,000 TPS XID allocation    about 2.8 hours

因此告警要看:

headroom secondsconfigured thresholdcurrent agerecent XID allocation rate \text{headroom seconds} \approx \frac{\text{configured threshold} - \text{current age}} {\text{recent XID allocation rate}}

并给 maintenance duration、业务尖峰和失败重试留余量。仅用固定 age percentage, 无法表达突然增长的 XID burn rate。

获取近似分配速率可对固定间隔的 pg_current_xact_id()/监控计数做差,但读取函数是否 分配 XID、采样事务本身的影响要按接口语义处理。Pigsty 的 PGSQL Persist 历史曲线更 适合看持续速率,原生 catalog 负责当前边界。

regular、aggressive 和 failsafe

regular VACUUM
  scans pages likely needing work
  may skip all-visible pages

aggressive VACUUM
  visits every page that might contain unfrozen XID/MXID
  advances relfrozenxid / relminmxid when full necessary coverage achieved

failsafe
  last-resort anti-wraparound mode
  removes cost delay
  may bypass non-essential index maintenance
  prioritizes age correctness

相关门槛:

vacuum_freeze_min_age
vacuum_freeze_table_age
autovacuum_freeze_max_age
vacuum_failsafe_age

vacuum_multixact_freeze_min_age
vacuum_multixact_freeze_table_age
autovacuum_multixact_freeze_max_age
vacuum_multixact_failsafe_age

PostgreSQL 会限制部分有效值,例如 vacuum_freeze_table_age 不会有效超过 autovacuum_freeze_max_age 的 95%。不要只读配置文件;读 pg_settings.setting 确认实际值。

eager freeze

PostgreSQL 18 的普通 vacuum 可能主动扫描一部分 all-visible 但未 all-frozen 的页面, 提前冻结,减少以后 aggressive vacuum 的工作;相关行为可用 vacuum_max_eager_freeze_failure_rate 调整。

这意味着:

regular vacuum

不再等价于“绝不扫描可跳过页”,但它仍不保证每次全表 aggressive coverage。

实验中的年龄证据

第 28 章夹具不是回卷压力测试;它绝不消耗数十亿 XID。它只验证:

baseline relfrozenxid age         14
post VACUUM (FREEZE) age          2
all-frozen pages                  10,625

这些值证明 freeze 路径在一次性表上生效,不证明生产 threshold、maintenance duration 或 headroom 合理。

28.3.2 长事务、backend_xmin 与 idle in transaction

事务久不等于一定持有旧 snapshot,反之亦然

xact_start 告诉你事务开始时间;backend_xmin 告诉你该 backend 对 vacuum horizon 的贡献。它们相关,但不等价:

old xact_start + backend_xmin       likely reclamation holder
old xact_start + no backend_xmin    still examine locks/XID/state
recent xact_start + old xmin        possible imported/exported snapshot
idle outside transaction            usually no MVCC snapshot retention
idle in transaction                 dangerous candidate

查询:

SELECT
  pid,
  datname,
  usename,
  application_name,
  client_addr,
  state,
  xact_start,
  query_start,
  state_change,
  backend_xid,
  backend_xmin,
  age(backend_xid) AS xid_age,
  age(backend_xmin) AS xmin_age,
  wait_event_type,
  wait_event,
  left(query, 200) AS query
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
  AND (
    backend_xid IS NOT NULL
    OR backend_xmin IS NOT NULL
    OR state LIKE 'idle in transaction%'
  )
ORDER BY age(backend_xmin) DESC NULLS LAST, xact_start;

idle in transaction 为什么危险

应用执行:

BEGIN
SELECT ...
client pauses / forgets COMMIT

backend 不用 CPU,却可能:

  • 保留 snapshot,阻止 dead tuple 回收;
  • 持有 relation/row/advisory locks;
  • 占用 backend 与连接池槽;
  • 让 DDL、vacuum、reindex 等等待;
  • 让 table/index/WAL 间接增长。

所以“CPU 为 0”不是无害。

timeouts 要按角色与协议设计

PostgreSQL 18 提供:

idle_in_transaction_session_timeout
transaction_timeout
statement_timeout
lock_timeout
idle_session_timeout

其中:

  • idle_in_transaction_session_timeout 专门终止在开放事务中 idle 过久的 session;
  • transaction_timeout 限制整个事务跨度;
  • 普通 idle session 不持有开放事务,危害与 idle-in-xact 不同;
  • pool/middleware 可能无法优雅处理被服务端突然关闭的连接。

更稳妥的做法:

ALTER ROLE app_rw IN DATABASE appdb
  SET idle_in_transaction_session_timeout = '2min';

ALTER ROLE analyst IN DATABASE appdb
  SET transaction_timeout = '30min';

示例值不是通用推荐。应用必须:

  • 正确 rollback/retry;
  • 不在事务中等待用户输入或远程 API;
  • 流式读取时理解 cursor/snapshot 生命周期;
  • 为 migration、batch、backup 使用独立角色和窗口。

精确处置,不批量杀 idle

处置协议:

1. identify PID + role + database + application + client
2. verify backend_xmin/xid/locks and business transaction
3. contact owner or follow pre-approved runbook
4. prefer graceful commit/rollback
5. cancel statement if only statement must stop
6. terminate session only when necessary and retry-safe
7. verify holder disappeared
8. rerun/observe vacuum and business correctness

正式实验只终止:

database          pg36_maintenance
role              dbuser_pg36maint
application       pg36-ch28-old-snapshot
PID               exact observed PID
backend_xmin      non-null
matched sessions  exactly 1

结果:

terminated sessions       1
remaining holder sessions 0
unrelated terminated      0

没有使用“杀掉所有 idle in transaction”。

读副本也可能把 horizon 反馈到主库

hot_standby_feedback 可降低 standby query cancellation,但会把所需 xmin 反馈到 primary,导致 primary 保留 dead tuple。取舍是:

cancel long replica query
vs
retain primary heap versions

有 replication slot 时,还要联合 pg_replication_slots.xmin。只在 standby 查 pg_stat_activity 可能找不到 primary 膨胀的完整原因。

28.3.3 复制槽 xmin 与孤儿 pg_prepared_xacts

复制槽有两类保留线

pg_replication_slots 中:

字段 保留什么
xmin 数据库必须保留的最老事务;更晚删除的 tuple 不能被 vacuum 移除
catalog_xmin 逻辑解码所需系统目录 tuple
restart_lsn consumer 仍可能需要的最老 WAL
confirmed_flush_lsn 逻辑 consumer 已确认接收的位置
wal_status/safe_wal_size WAL 保留状态与走向 lost 的余量

空间问题要分开:

xmin/catalog_xmin -> heap/catalog bloat and vacuum horizon
restart_lsn       -> pg_wal retention

“复制槽只会撑大 WAL”是错的。

查询:

SELECT
  slot_name,
  slot_type,
  database,
  active,
  active_pid,
  age(xmin) AS xmin_age,
  age(catalog_xmin) AS catalog_xmin_age,
  restart_lsn,
  confirmed_flush_lsn,
  wal_status,
  safe_wal_size,
  inactive_since,
  conflicting,
  invalidation_reason,
  failover,
  synced
FROM pg_replication_slots
ORDER BY
  greatest(
    coalesce(age(xmin), 0),
    coalesce(age(catalog_xmin), 0)
  ) DESC,
  slot_name;

inactive 不等于 orphan

一个 inactive slot 可能是:

  • 正常短暂断线;
  • 灾备系统等待窗口;
  • CDC consumer 故障;
  • 切换后遗留;
  • 已废弃对象;
  • standby 同步 slot。

drop slot 前必须确认:

consumer owner
expected reconnect
replica/CDC resume semantics
required WAL/rows already lost?
will replica need rebuild?
failover/synced restrictions

官方文档明确提示:若删除仍会回来使用的 slot,对应 replica 可能需要重建。

因此:

SELECT pg_drop_replication_slot('unknown_slot');

不是发现 inactive 后的第一步。

prepared transaction 没有客户端也能继续持有状态

两阶段提交:

BEGIN
changes
PREPARE TRANSACTION 'gid'
-- original session may leave
COMMIT PREPARED / ROLLBACK PREPARED later

进入 prepared 后,它仍可持有:

  • 已分配 XID;
  • 行/表锁;
  • 可见性和清理边界影响;
  • 未决业务结果。

查询:

SELECT
  transaction,
  age(transaction) AS xid_age,
  gid,
  prepared,
  clock_timestamp() - prepared AS prepared_for,
  owner,
  database
FROM pg_prepared_xacts
ORDER BY age(transaction) DESC;

不要因为 pg_stat_activity 没有对应客户端,就判断“事务已消失”。

orphan 处置需要业务协调器事实

GID maps to which distributed transaction?
coordinator decision is commit or rollback?
did participants commit elsewhere?
is retry idempotent?
what locks and rows are affected?

没有这些事实时,随意 ROLLBACK PREPARED 可能破坏跨系统一致性,随意 COMMIT PREPARED 也可能提交应回滚的业务。

正确 runbook:

inventory
  -> coordinator/ledger lookup
      -> peer participant status
          -> explicit decision
              -> COMMIT/ROLLBACK PREPARED
                  -> verify age/locks/business invariants

若业务并不需要 2PC,应让 max_prepared_transactions=0 保持禁用,而不是启用后希望 “没人会忘”。

统一保留者清单

一张事故表至少包括:

source identifier age owner needed by action proof
backend PID/app xmin age team transaction commit/terminate holder gone
slot slot name xmin/catalog age CDC/DBA consumer resume/drop consumer state
prepared GID xid age coordinator distributed tx commit/rollback business ledger

只有 owner 和 action 明确,才进入执行。

28.3.4 紧急态先解除保留并让 VACUUM 完成

这一目的标题刻意没有写“立刻 VACUUM FREEZE”。

对 PostgreSQL 18,必须区分:

proactive maintenance
warning / shrinking headroom
write-refusal protection state

它们的正确动作不同。

第一阶段:预防与早期告警

正常系统应在远离危险线时:

monitor xid/mxid age and burn rate
keep autovacuum healthy
let aggressive vacuum complete
remove long-lived holders
schedule targeted VACUUM where needed
validate relfrozenxid advancement

在这个阶段,针对静态大表使用 VACUUM (FREEZE) 可以是有计划的维护动作:

VACUUM (FREEZE, VERBOSE) app.archive_2020;

前提是:

  • 已评估 I/O、WAL、锁和 replica;
  • 不是为了掩盖未知 blocker;
  • 目标表与 TOAST 均被验收;
  • 生产窗口已批准。

Pigsty 的:

pig pg freeze mydb

封装的是 freeze vacuum;适合明确的计划动作,不应脱离 PostgreSQL 版本语义当作所有 XID 事故的万能按钮。

第二阶段:系统已接近或进入拒绝新 XID

PostgreSQL 18 官方文档说明:

  • 临近回卷点会先产生必须 vacuum 的 warning;
  • 剩余不足约 3 million XID 时,系统拒绝分配新 XID以保护数据;
  • 已在运行的事务可继续,新的只读事务可启动;
  • 普通 VACUUM 仍可执行。

此时的优先顺序是:

1. resolve old prepared transactions
2. end long-running open transactions
3. remove only confirmed obsolete replication slots
4. run ordinary VACUUM in affected database / oldest relations
5. restore normal operation
6. repair autovacuum/root cause

官方文档对 PostgreSQL 18 还明确说:

do not use VACUUM FULL
do not use VACUUM FREEZE
single-user mode is normally unnecessary and undesirable

原因是 hard-stop 状态要做恢复正常所需的最小工作

  • VACUUM FULL 自身需要/消耗 XID、强锁且重写;
  • VACUUM FREEZE 做超过最小恢复所需的工作;
  • single-user mode 会绕开保护并引入停机风险。

这与某些旧版本文章或旧 runbook 不同。执行时以安装版本官方文档为准。

“让冻结完成”的正确含义

在日常或 early warning 阶段:

不要为了降低 I/O 不断 cancel 防回卷 vacuum;解除 blocker,让 aggressive freeze 推进并验收年龄。

在已经拒绝 XID 的 hard-stop 阶段:

不要把 FREEZE option 当口号;按 PostgreSQL 18 最小恢复流程先解除 prepared xact、长事务和旧 slot,再让普通 VACUUM 完成。

两者共同反对:

raise autovacuum_freeze_max_age to silence alert
disable autovacuum
cancel every anti-wraparound worker
restart hoping age disappears
delete pg_xact files
reset xid counters by hand

这些都没有安全地冻结旧 tuple,可能把正确性风险推向灾难。

紧急 runbook 的 fail-closed 门

incident:
  postgresql_version: 18.x
  primary_identity: verified
  xid_or_mxid: xid
  oldest_database: ...
  oldest_relations: [...]
  burn_rate_per_second: ...
  estimated_headroom: ...

holders:
  prepared: [...]
  backends: [...]
  slots: [...]
  ownership_confirmed: false

execution:
  ordinary_vacuum_only: true
  vacuum_full: forbidden
  vacuum_freeze_in_hard_stop: forbidden
  force_drop: forbidden
  single_user: not_planned

validation:
  write_assignment_restored: ...
  datfrozenxid_advanced: ...
  oldest_relation_advanced: ...
  warnings_stopped: ...
  autovacuum_root_cause: ...

ownership_confirmed=false 时,不能自动 drop slot 或 resolve prepared transaction; 需要事故指挥者和业务 owner 决策。

XID 与 MXID 要分案

MXID 用于多事务共同锁行,拥有独立:

pg_multixact storage
relminmxid / datminmxid
freeze thresholds
failsafe thresholds
exhaustion effects

XID 耗尽会阻断所有需要新 XID 的写;MXID 耗尽主要阻断需要创建新 multixact 的锁类 写入。事故名称、监控和 runbook 不能只写“wraparound”。

恢复写入不等于事故闭环

普通 vacuum 让系统重新接受写入,只是止血。根因可能仍是:

application transaction leak
stale logical slot
2PC coordinator failure
worker starvation
I/O insufficient
autovacuum misconfiguration
huge database never covered
maintenance repeatedly canceled

事故关闭条件:

headroom restored
burn rate understood
all holder ownership recorded
oldest tables and TOAST advancing
autovacuum completion observable
alerts and capacity model corrected
recurrence test passed

否则下一次只是时间问题。

本节检查清单

installed PostgreSQL major/minor
database xid/mxid age
top relation + TOAST age
configured and effective thresholds
XID/MXID burn rate and headroom time
backend_xmin holders
idle-in-transaction sessions
slot xmin/catalog_xmin/restart_lsn
prepared transaction GID/owner/decision
anti-wraparound worker phase
ordinary vs aggressive vs failsafe state
version-correct emergency procedure
post-recovery root-cause action

延伸阅读


上一节:autovacuum 的触发与资源 · 返回本章目录 · 下一节:膨胀与重建 · 查看全书目录 · 查看索引中心

28.4 膨胀与重建

“膨胀”不是一个 catalog flag。

它是一个相对于预期有效载荷和未来复用的工程判断:

allocated bytes
  - live payload
  - necessary page/index overhead
  - intentionally reserved fillfactor
  - space likely to be reused soon
  = potentially reclaimable waste

这几个减数没有一个能由 n_dead_tup 单独给出。重建又会产生锁、额外空间、WAL、 replica replay 和失败残留;误判膨胀,常常比接受一个稳定 plateau 更贵。

28.4.1 表膨胀、索引膨胀与统计误判

先分 heap、TOAST 和 index

SELECT
  c.oid::regclass AS relation,
  pg_relation_size(c.oid) AS main_bytes,
  pg_table_size(c.oid) AS table_bytes,
  pg_indexes_size(c.oid) AS index_bytes,
  pg_total_relation_size(c.oid) AS total_bytes,
  c.reltuples::bigint AS planner_rows,
  c.relpages
FROM pg_class AS c
WHERE c.oid = 'app.orders'::regclass;

这些层次不同:

main fork
FSM / VM / init forks
TOAST table and TOAST indexes
user indexes
partition children

“表 2 TB”必须说清是 heap、table size 还是 total size。

表膨胀的四个常见来源

1. obsolete tuple not yet vacuumable
2. vacuumable tuple not yet processed
3. processed free space not reused by current workload
4. deliberately reserved page space / unavoidable overhead

对应动作:

来源 动作
old snapshot/slot/2PC retains 解除精确保留者
vacuum service rate insufficient 修触发/worker/I/O
retention permanently shrank 评估 rewrite/partition
fillfactor/design overhead 评估写放大与 scan tradeoff

只有第三类天然指向“交还 OS”。

pgstattuple 更直接,但不是瞬时原子快照

CREATE EXTENSION pgstattuple;

SELECT *
FROM pgstattuple('app.orders'::regclass);

返回:

table_len
tuple_count / tuple_len / tuple_percent
dead_tuple_count / dead_tuple_len / dead_tuple_percent
free_space / free_percent

它只拿 read lock 并逐页累计;并发写可在扫描期间发生,所以结果不是整张表同一时点的 原子 snapshot。大型表还会产生显著读取负载。

较轻量的候选:

SELECT *
FROM pgstattuple_approx('app.orders'::regclass);

它利用 VM 跳过部分 all-visible pages,换取估计。到底用哪一个要在 ticket 中声明 accuracy/cost。

index bloat 不是 heap dead ratio

B-tree 页面分裂、删除和 key distribution 会造成:

  • 半空 leaf page;
  • deleted/empty page;
  • logical adjacency 与 physical layout 分离;
  • 低 leaf density;
  • 大量仍需维护但查询很少使用的 index tuple。

观察:

SELECT *
FROM pgstatindex('app.orders_created_at_idx'::regclass);

重点:

index_size
tree_level
leaf_pages
empty_pages
deleted_pages
avg_leaf_density
leaf_fragmentation

但仍不能写:

avg_leaf_density < 70% -> must reindex

原因:

  • index fillfactor 本来允许余量;
  • 刚经历 page split;
  • key 是随机、递增或时间窗口;
  • 并发扫描期间数据在变化;
  • 不同 access method 的空间模型不同;
  • 低密度空间可能马上被写入复用。

还要看:

SELECT
  schemaname,
  relname,
  indexrelname,
  idx_scan,
  idx_tup_read,
  idx_tup_fetch,
  pg_relation_size(indexrelid) AS bytes
FROM pg_stat_user_indexes
WHERE relid = 'app.orders'::regclass
ORDER BY bytes DESC;

统计 reset 会影响 idx_scan;不能因为 reset 后为零就立即 drop index。

statistics error 会伪装成 bloat

常见误判:

reltuples stale
  -> rows-per-page estimate wrong
  -> SQL bloat formula says 80%

n_dead_tup delayed
  -> dashboard shows vacuum did nothing

partition parent never ANALYZE
  -> planner row estimate wrong
  -> blamed on physical bloat

stats reset
  -> usage looks zero

先确认:

SELECT
  stats_reset
FROM pg_stat_database
WHERE datname = current_database();

SELECT
  relname,
  n_live_tup,
  n_dead_tup,
  last_analyze,
  last_autoanalyze,
  analyze_count,
  autoanalyze_count
FROM pg_stat_user_tables
WHERE relname = 'orders';

必要时:

ANALYZE (VERBOSE) app.orders;

然后再重算 estimate。

“大”与“膨胀”分开

一个 4 TB 表可能:

  • live data 就是 4 TB;
  • page density 合理;
  • 维护跟上;
  • query 通过 partition pruning;
  • 无需重写。

一个 20 GB 表可能:

  • live data 只有 1 GB;
  • retention 永久下降;
  • 文件系统只剩 5 GB;
  • 重写却需要超过当前 free space;
  • 已成为事故风险。

大小决定操作成本,浪费比例决定收益,headroom 决定可执行性。三者缺一不可。

诊断报告模板

object:
  table: app.orders
  access_method: heap
  partition: false

size:
  heap_bytes: ...
  toast_bytes: ...
  index_bytes: ...
  total_bytes: ...
  growth_7d: ...

contents:
  planner_rows: ...
  cumulative_live: ...
  cumulative_dead: ...
  physical_dead_bytes: ...
  physical_free_bytes: ...

behavior:
  updates_per_hour: ...
  hot_ratio: ...
  expected_reuse_days: ...
  retention_change: ...

holders:
  backend_xmin: ...
  slots: ...
  prepared: ...

confidence:
  stats_reset: ...
  physical_scan_scope: ...
  captured_at: ...

报告结论应是:

healthy steady state
transient churn
vacuum blocked
vacuum underprovisioned
heap rewrite candidate
index-only rebuild candidate
inconclusive

而不是一个没有依据的 bloat_pct

28.4.2 VACUUM FULL、在线重建与额外空间

VACUUM FULL 做了什么

PostgreSQL 18:

write a new compact copy
keep old copy until operation completes
swap relation storage
return old storage after success

因此它:

  • 需要 ACCESS EXCLUSIVE
  • 比普通 vacuum 慢;
  • 需要额外磁盘容纳新副本;
  • 重写 heap,并重建相关索引;
  • 产生大量 I/O/WAL;
  • 可推高 replica replay 和 archive backlog;
  • 使缓存重新变冷;
  • 改变 tuple ctid 等物理标识。

命令:

VACUUM (FULL, VERBOSE, ANALYZE) app.orders;

语法简单,变更本身不简单。

为什么不能“磁盘快满时就 FULL”

VACUUM FULL 在完成前不释放旧副本;磁盘已经接近满时,它可能最缺执行所需空间。

预算至少覆盖:

new heap copy
new indexes
temporary sort/build space
WAL
archive spool
replica retention/replay
filesystem reserve
failure residue/headroom

不要用:

reclaimable bytes = operation free-space requirement

推算。可回收 500 GB 不表示先有 500 GB 可用。

锁窗口不仅是命令运行时间

wait for ACCESS EXCLUSIVE
  -> command execution
      -> dependent work / validation
          -> release

若先无限等待锁,队列会在它后面形成:

VACUUM FULL waiting
  blocks later queries that conflict with its queued lock

生产执行要有:

SET lock_timeout = '5s';
SET statement_timeout = '2h';

示例值必须按对象预算;关键是 fail-fast 获取锁,而不是在峰值排队。

在线重写不是免费重写

pg_repack 一类工具通常通过:

create shadow table/index
capture concurrent changes
copy base data
replay delta
short final lock/swap
cleanup

把长时间强锁缩短到最终切换,但代价仍在:

  • shadow copy 空间;
  • 索引和 WAL;
  • trigger/delta capture 开销;
  • 长事务等待;
  • extension/client/server 版本兼容;
  • unique key/对象类型限制;
  • DDL 并发限制;
  • 中断后的临时对象和恢复。

Pigsty 提供:

pig pg repack mydb --plan
pig pg repack mydb -t app.orders
pig pg repack mydb -j 2

它要求 pg_repack extension。--plan 只是先看计划,不是生产审批。执行前仍需:

pg_repack version compatibility
extension installed in target database
eligible unique key
disk/WAL/replica budget
lock and timeout
DDL freeze
backup/restore proof
cleanup procedure

Pigsty 默认扩展目录可提供 pg_repack 包与数据库 extension 映射,但实际是否启用要用 pg_extension 检查。

其他重写路径

路径 适合 主要风险
VACUUM FULL 小表/可停写窗口/一次性大清理 长强锁
CLUSTER 需要按索引重排且可停写 长强锁,物理顺序会再漂移
pg_repack 需保持大部分读写 额外空间、delta、最终锁、扩展复杂度
logical copy/swap 迁移、类型/模型同时变更 双写/增量/切换验证
partition detach 过期数据整片淘汰 需预先按生命周期分区

“在线”应写成:

which operations continue?
which lock still occurs?
for how long?
what happens under long transactions?

而不是一个布尔标签。

预执行 canary

对可复制数据:

1. clone representative database/table
2. reproduce bloat and statistics
3. run exact tool/version/options
4. measure peak extra disk, WAL, CPU, I/O
5. replay concurrent workload
6. inject interruption before final swap
7. verify cleanup and retry
8. verify backup/restore after rewrite

canary 仍不能完全预测生产锁队列,但能排除明显容量和兼容性错误。

验收不只是“size 下降”

logical row count/digest
constraints and triggers
indexes valid/ready
grants/owner/comments
replica caught up
archive backlog recovered
plans and latency
autovacuum reloptions
backup chain and restore
temporary objects absent

若 rewrite 改了 statistics,执行计划可能变化;第 7 章的 plan evidence 也应进入验收。

28.4.3 REINDEX CONCURRENTLY 的版本和失败处理

先问为什么重建

合理原因:

measured index bloat with low reuse
amcheck or incident evidence indicates index inconsistency
collation/operator-class change requires rebuild
index storage parameter must fully take effect
invalid index recovery

不充分:

index is large
idx_scan is zero since yesterday's stats reset
query is slow
calendar says monthly reindex

索引疑似损坏时,先保存证据、确认 heap 和 backup,再决定重建;盲目重建可能覆盖关键 取证线索。

版本边界

REINDEX CONCURRENTLY 在 PostgreSQL 12 引入。本章以 PostgreSQL 18 语义为准:

SHOW server_version;
SHOW server_version_num;

跨版本自动化不能只看语法存在,还要检查:

  • object kinds;
  • partitioned relation 支持;
  • progress view columns;
  • exclusion/system catalog 限制;
  • invalid-index recovery;
  • minor release bug fixes。

普通与并发模式

普通:

REINDEX INDEX app.orders_created_at_idx;

会阻止 parent table 写入,并对索引本身拿强锁;planner 尝试锁表的各索引,所以读也可能 受到广泛影响。

并发:

REINDEX INDEX CONCURRENTLY app.orders_created_at_idx;

允许正常 insert/update/delete 继续,但需要:

new transient index
first table scan
second catch-up scan
wait for old snapshots/readers
catalog validity swap
drop old index

它做更多总工作、花更久、需要额外空间和 CPU/memory/I/O,并不“无锁”。

不能放在 transaction block

BEGIN;
REINDEX INDEX CONCURRENTLY app.orders_created_at_idx;
-- ERROR

同一张表一次只能有一个 concurrent index build;并发 DDL 也受限制。自动化器要把 每个对象的状态作为可恢复 step,而不是把全库命令包成一个 transaction。

观察进度和等待

SELECT
  pid,
  datname,
  relid::regclass AS table_name,
  index_relid::regclass AS index_name,
  command,
  phase,
  lockers_total,
  lockers_done,
  current_locker_pid,
  blocks_total,
  blocks_done,
  tuples_total,
  tuples_done,
  partitions_total,
  partitions_done
FROM pg_stat_progress_create_index;

关键 phase 包括:

building index
waiting for writers before build
waiting for writers before validation
index validation: scanning index
index validation: sorting tuples
index validation: scanning table
waiting for old snapshots
waiting for readers before marking dead
waiting for readers before dropping

如果卡在 old snapshots,回到 28.3.2,不是再启动第二个 reindex。

失败后会留下 INVALID

官方文档明确:

  • _ccnew:新 transient index 未成功,应检查后 drop,再重试;
  • _ccold:旧 index 在成功重建后未能 drop,通常应 drop 这个 old artifact;
  • 后缀可能带数字;
  • INVALID index 不供查询,但仍可能带来 update overhead。

检查:

SELECT
  n.nspname,
  c.relname,
  c.oid,
  i.indisready,
  i.indisvalid,
  i.indislive,
  pg_relation_size(c.oid) AS bytes
FROM pg_index AS i
JOIN pg_class AS c ON c.oid = i.indexrelid
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE i.indrelid = 'app.orders'::regclass
ORDER BY c.relname;

不要用:

DROP INDEX app.orders_created_at_idx_ccnew;

作为通用 cleanup。先确认它的 indisready/valid/live、parent、constraint dependency 和本次 run marker;名称相似不是所有权证据。

约束索引需要额外验证

unique/primary/exclusion index 参与约束。并发重建会更新 constraint reference,但:

  • exclusion constraint index 不能并发 reindex;
  • unique violation 可让 build 失败;
  • 不能只验 index name;
  • 必须验 constraint 仍绑定正确有效 index。
SELECT
  conname,
  contype,
  conindid::regclass,
  convalidated
FROM pg_constraint
WHERE conrelid = 'app.orders'::regclass
  AND conindid <> 0;

正式实验

夹具的 churn_status_idx

状态 bytes valid/ready/live invalid artifact
before 3,227,648 true/true/true 0
after 1,589,248 true/true/true 0

同时:

REINDEX INDEX CONCURRENTLY return code  0
relfilenode changed                      true
same final name count                    1
_ccnew/_ccold artifacts                 0
elapsed on isolated fixture             0.089 s

0.089 秒只对这一张小型、无并发业务的夹具成立。正式 run 明确不外推生产 duration。

reindex 也能影响 vacuum

并发 reindex 自身有多事务和 snapshot 等待。官方文档提醒:像任何长事务一样,它可能 影响其他表的 concurrent vacuum 清理边界。维护任务不能互相独立排程:

reindex window
vacuum/freeze window
backup window
bulk load
schema migration

要在同一个资源与事务日历里编排。

生产完成条件

before:
  reason: measured_bloat
  heap_integrity: verified
  index_integrity: ...
  free_disk: ...
  wal_replica_archive_headroom: ...
  long_snapshot_inventory: []
  lock_timeout: ...

after:
  command_success: true
  expected_index_count: ...
  indisready_valid_live: true
  constraints_bound: true
  cc_artifacts: []
  logical_queries_equal: true
  replica_caught_up: true
  size_and_plan_reviewed: true

本节检查清单

heap / TOAST / index size separated
stats reset and estimate confidence
physical sample cost and timestamp
future reuse horizon
rewrite benefit in bytes
peak extra disk / WAL / replica budget
lock mode and fail-fast timeout
tool/extension/server compatibility
concurrent progress and snapshot blockers
invalid index cleanup plan
constraint binding
post-change logical and recovery validation

延伸阅读


上一节:冻结、XID 与保留者 · 返回本章目录 · 下一节:分区生命周期 · 查看全书目录 · 查看索引中心

28.5 分区生命周期

如果数据天然按时间或租户整片到期,最好的 vacuum 往往是不要制造那些 dead tuple

DELETE 500 million expired rows
  -> row locks / WAL / dead tuples / index cleanup / vacuum / replica replay

DETACH one expired partition
  -> catalog and lock operation
      -> standalone table
          -> archive / validate / drop

分区不是免费的性能开关;它是把数据生命周期编码进物理边界。只有 partition key、 retention unit、query pruning 和发布流程一致时,整片退役才成立。

28.5.1 新分区预建、约束和父表显式 ANALYZE

从生命周期单位反推边界

先定义:

event_time_semantics: UTC timestamptz
retention: 400 days
retirement_unit: month
late_arrival: 7 days
future_precreate: 3 months
archive_retention: 7 years

再决定:

partition key
range bounds
timezone
default partition policy
precreate horizon
detach cadence

月分区不一定最好:

单位 优点 风险
退役粒度细 partition 数、planning/catalog 开销
常见折中 大月仍可能过大
季/年 对象少 退役和维护粒度粗
tenant hash/list 隔离租户 retention 可能仍需二级时间分区

目标不是最多 partition,而是让:

query predicate
retention cut
maintenance unit

落在同一边界。

range 上界是排他的

CREATE TABLE app.events (
  event_id bigint NOT NULL,
  occurred_at timestamptz NOT NULL,
  payload jsonb NOT NULL
) PARTITION BY RANGE (occurred_at);

CREATE TABLE app.events_2026_08
  PARTITION OF app.events
  FOR VALUES FROM ('2026-08-01 00:00:00+00')
             TO   ('2026-09-01 00:00:00+00');

边界:

[2026-08-01 00:00Z, 2026-09-01 00:00Z)

共享的 2026-09-01 属于下一个 partition。若应用按本地日历月保留,必须明确 DST 和 timezone;不要让 session TimeZone 隐式决定 DDL literal。

预建,不等 insert error 报警

写入没有匹配 partition 会失败。生产应提前:

generate future partitions
validate exact non-overlapping bounds
create local indexes
apply owner/grants/comments/storage parameters
ANALYZE when populated
alert on last future boundary

例如维护表:

SELECT
  parent.relname AS parent,
  child.relname AS partition,
  pg_get_expr(child.relpartbound, child.oid) AS bound
FROM pg_inherits AS i
JOIN pg_class AS parent ON parent.oid = i.inhparent
JOIN pg_class AS child ON child.oid = i.inhrelid
WHERE parent.oid = 'app.events'::regclass
ORDER BY child.relname;

pg_partition_tree() 适合多层结构:

SELECT *
FROM pg_partition_tree('app.events');

离线装载后 ATTACH

大分区可先作为普通表准备:

CREATE TABLE app.events_2026_08_stage
  (LIKE app.events INCLUDING DEFAULTS INCLUDING CONSTRAINTS);

ALTER TABLE app.events_2026_08_stage
  ADD CONSTRAINT events_2026_08_bound
  CHECK (
    occurred_at >= TIMESTAMPTZ '2026-08-01 00:00:00+00'
    AND occurred_at < TIMESTAMPTZ '2026-09-01 00:00:00+00'
  );

-- load, cleanse, build indexes, validate

ALTER TABLE app.events
  ATTACH PARTITION app.events_2026_08_stage
  FOR VALUES FROM ('2026-08-01 00:00:00+00')
             TO   ('2026-09-01 00:00:00+00');

若已有一个有效且与 partition bound 匹配CHECK constraint,PostgreSQL 可避免 在持有 partition ACCESS EXCLUSIVE 时扫描全表验证。attach 完成后,这个重复 constraint 可在评审后删除。

若 parent 有 default partition,还应给 default 添加排除新范围的 CHECK;否则 attach 可能扫描 default,且持有其强锁。

注意:

  • expression partition key 有额外限制;
  • list partition 是否接受 NULL 影响 constraint;
  • subpartition 可能递归锁/扫到 leaf;
  • parent 是 virtual structure,实际 index 在 leaf;
  • attach 前要验证 index/constraint 与 parent 模板一致。

default partition 是缓冲区,不是垃圾桶

default 可避免未知 key 直接失败,但会带来:

silent routing of bad/future data
attach scan/lock cost
DETACH CONCURRENTLY restriction on that parent
data migration before new range attach

若使用 default:

  • 监控 row count;
  • bad key 立即告警;
  • 定期清空到正确 partition;
  • 在 attach/detach runbook 中显式处理;
  • 不把它当永久无限分区。

parent 必须显式 ANALYZE

partition leaf 的变化不会触发 parent auto-analyze;partitioned table 自身不直接存 tuple, autovacuum 不会在 parent 上运行 ANALYZE。当首次装载或分布显著变化时:

ANALYZE app.events_2026_08;
ANALYZE app.events;

parent-level statistics 会影响引用 partitioned table 的 plan。生命周期动作完成而漏掉 parent analyze,可能导致:

row estimate drift
join order change
partition-wise plan quality loss

这不是物理 bloat,却常被误归因成“分区太多”。

分区模板是 schema release

创建脚本应来自同一 desired state:

columns / generated expressions
constraints
indexes / INCLUDE / predicates
storage parameters / fillfactor
tablespace
owner / grants / RLS
publication policy
comments
autovacuum overrides

不能靠:

CREATE TABLE child (LIKE parent);

就假设复制了所有业务语义。LIKEINCLUDING ... 选项、partitioned parent 的虚拟 对象、trigger/RLS/publication 行为都要按版本验证。

28.5.2 DETACH、归档、验证后删除

detach 不是 drop

ALTER TABLE app.events
  DETACH PARTITION app.events_2024_01;

结果:

parent no longer routes/scans it
child remains as standalone table
attached child indexes detach from parent indexes
cloned triggers are removed
data remains queryable by standalone name

这是理想的 quarantine point:

online dataset
  -> detached immutable dataset
      -> archive
          -> restore validation
              -> deletion

普通与 CONCURRENTLY

普通 detach 对 parent 取得 ACCESS EXCLUSIVE

ALTER TABLE app.events
  DETACH PARTITION app.events_2024_01 CONCURRENTLY;

PostgreSQL 18 的 concurrent 形式不是“零锁”,而是内部两个 transaction:

  1. 对 parent 和 partition 取得 SHARE UPDATE EXCLUSIVE,标记 pending detach 并提交;
  2. 等所有使用 partitioned table 的旧 transaction 离开;
  3. 再对 parent 取 SHARE UPDATE EXCLUSIVE、对 partition 取 ACCESS EXCLUSIVE
  4. 完成 detach,并给 standalone table 添加等价 CHECK constraint。

限制:

cannot run inside transaction block
not allowed when parent has a default partition
only one partition per parent may be pending detach
foreign-key related tables may acquire SHARE locks
old transactions can prolong the wait

中断后:

ALTER TABLE app.events
  DETACH PARTITION app.events_2024_01 FINALIZE;

用于完成先前被取消/中断的 concurrent detach。自动化不能看到命令失败就直接重跑或 drop;先查 pending state,再决定 FINALIZE

先关闭边界写入竞争

在 detach 前确认:

retention cutoff immutable
late-arrival window closed
backfill jobs stopped
application routes no new row to old range
timezone/cutoff reviewed
no open transaction still writes old partition

否则 detach 后:

  • 新写入可能失败;
  • 被路由到 default;
  • 被误写到 archive standalone table;
  • 数据清单在导出期间变化。

一种做法是先把旧 partition 业务状态标成 sealed,再等 maximum transaction duration 过去,最后 detach。真正的控制点在应用与数据产品,不只在 DDL。

归档清单至少有四层

  1. 对象清单
database/schema/table
partition bound
owner/grants
columns/types/collations
constraints/indexes
row-level security
  1. 逻辑清单
SELECT
  count(*) AS rows,
  min(event_id) AS min_id,
  max(event_id) AS max_id,
  min(occurred_at) AS min_time,
  max(occurred_at) AS max_time,
  sum(amount) AS amount_sum
FROM app.events_2024_01;
  1. 归档文件清单
format/version
compression/encryption
object URI
bytes
cryptographic hash
created_at
retention/legal hold
  1. 恢复清单
restore target
row/type/constraint verification
logical aggregates/digests
query spot checks
elapsed time
tool versions

只有 file hash 相同,不能证明文件可以被当前工具恢复;只有 row count 相同,也不能证明 金额、范围和编码正确。

COPY 与 pg_dump 的选择

COPY
  simple data stream
  schema/privileges not included
  explicit order and format needed

pg_dump table
  schema/data options
  dependency-aware archive
  restore tooling and version policy needed

base backup
  cluster physical recovery
  not a per-partition logical archive

对于大型表,不要用:

md5(string_agg(all_rows...))

在 server 端聚合整个数据集;第 28 章夹具只有 10,000 行,才用它作为教学逻辑摘要。 生产可用有序 chunk hash、COPY/Parquet manifest、业务聚合和独立 restore 合并证明。

正式实验的顺序

events_2024 rows         10,000
parent total             15,000

manifest
  rows                   10,000
  id range               1..10,000
  amount sum             499,950.00
  logical digest         recorded

DETACH CONCURRENTLY      pass
parent after detach      5,000
standalone               10,000

CSV bytes                547,894
CSV SHA-256              cd7d54e4...a9a41d0d

restore check rows       10,000
restore digest           equal

drop standalone          only after equality

清理 validator 会拒绝:

drop before restore validation
row mismatch
digest mismatch
empty archive
parent count mismatch
force cleanup

删除后还要验 backup policy

partition 从在线库删除后:

  • PITR 仍可在 retention window 内恢复历史 cluster;
  • 逻辑 archive 负责更长期访问;
  • backup retention 和 archive legal retention 可能不同;
  • GDPR/删除义务也可能要求从 archive 到期清除;
  • catalog/monitoring 应记录在线与归档位置的转换。

生命周期不是 DROP TABLE 结束,而是 ownership 从 online service 转到 archive service。

28.5.3 用分区退役替代大批量 DELETE

大 DELETE 的债

DELETE FROM app.events
WHERE occurred_at < now() - interval '400 days';

可能产生:

row locks
large transaction / long snapshot
WAL and archive volume
replica replay
dead heap tuples
dead index tuples
autovacuum backlog
relation growth before reuse
rollback/retry cost

分批 delete 可控制 transaction:

WITH victim AS (
  SELECT ctid
  FROM app.events
  WHERE occurred_at < $1
  ORDER BY occurred_at
  LIMIT 10000
  FOR UPDATE SKIP LOCKED
)
DELETE FROM app.events AS e
USING victim AS v
WHERE e.ctid = v.ctid;

但它仍逐行处理,且 ctid 只用于当次短事务。批处理适合选择性删除,不如整片 partition 退役。

detach 的收益来自事前设计

若 expired predicate 恰好覆盖完整 partition:

row-by-row physical change
  -> partition metadata change

它避免制造海量 obsolete tuples。随后 drop standalone table 删除 relation files, 也不需要 vacuum 每一行。

但不能夸大为 $O(1)$、瞬间、无 WAL、无锁:

  • catalog 要更新;
  • parent/partition/FK 有锁;
  • concurrent 形式要等旧 transaction;
  • replica 要 replay DDL;
  • drop 仍要处理 dependency 和文件;
  • archive copy 仍按数据量花费 I/O;
  • planning/catalog 对 partition 数敏感。

准确说:

对生命周期与 partition bound 对齐的数据,detach/drop 把逐行淘汰的核心成本转换成 受控 DDL 与归档成本。

何时不能替代

predicate cuts through every partition
legal hold retains arbitrary rows
tenant records mixed in same partition
foreign keys prevent independent detach
late arrivals keep changing old ranges
application directly names leaf tables
default partition contains mixed data

这时选择:

  • 更合适的 partition key/subpartition;
  • selective batch delete;
  • logical archive+copy;
  • tenant migration;
  • schema redesign。

不要为了这次清理临时创建数千 partition;partitioning 是长期模型。

FK 与全局唯一性

partitioned table 的 unique/primary key 通常必须包含 partition key,才能由每个 leaf 的 局部索引共同保证全局逻辑唯一。跨 partition FK、引用 leaf、触发器和 publication 也会影响 detach。

设计前问:

Can an event_id be unique without time?
Who references expired rows?
Should archive preserve FK graph?
Can referenced partitions retire independently?

若答案不清晰,retention 不是一个 DBA 单表任务。

DELETE 与 detach 的选择矩阵

条件 batch DELETE partition detach
任意 predicate 支持 不支持
整片时间范围 可做但昂贵 优先候选
在线逐步释放 支持 按 partition 粒度
归档后保留 standalone 需 copy 天然
dead tuple/vacuum 债 不制造逐行债
前置建模 必须
FK/依赖 行级处理 DDL 级约束

28.5.4 将 ch04/ch07/ch11/ch16/ch28 串成能力索引

分区生命周期不是本章孤立技巧,而是贯穿设计、查询、发布和运维的能力。

第 4 章:数据表达决定边界

第 4 章:数据类型、约束与可靠数据表达 负责:

timestamptz vs timestamp
UTC and business timezone
NOT NULL / CHECK
identity and uniqueness
retention metadata

若时间语义错,partition boundary 再精确也会错删。

能力交付:

partition_key:
  column: occurred_at
  type: timestamptz
  canonical_zone: UTC
  null_allowed: false
retention_cutoff_semantics: event_time

第 7 章:统计与 pruning 证明查询受益

第 7 章:执行计划与统计信息 负责:

partition pruning
parameterized plan behavior
parent/leaf statistics
join estimate
EXPLAIN evidence

能力交付:

representative predicates prune expected leaves
generic/custom plans both reviewed
parent ANALYZE after distribution change
planning time acceptable at partition count

分区多但查询不带 partition key,可能同时扫描大量 leaf;那不是 vacuum 能修的。

第 11 章:生命周期 DDL 是安全发布

第 11 章:模式变更与安全发布 负责:

lock compatibility
lock_timeout
preflight holders
expand/contract
canary and rollback
DDL queue behavior

能力交付:

change:
  attach_or_detach: ...
  lock_budget: ...
  transaction_block: false
  old_snapshot_gate: ...
  interrupted_state: ...
  finalize_or_rollback: ...

DETACH CONCURRENTLY 仍是 production DDL。

第 16 章:时序/时空 workload 定义生命周期

第 16 章:时序、空间与时空查询 负责:

time bucketing
late/out-of-order events
rollup/downsample
spatial/temporal retention
hot/warm/cold tiers

能力交付:

late arrival horizon
immutable cutoff
rollup completion watermark
archive query contract

只有 watermark 越过 partition end + late-arrival allowance,才可 seal/detach。

第 28 章:运营闭环

本章负责:

precreate
explicit analyze
seal
detach
archive manifest
restore validation
drop
monitor and audit

合起来:

type/constraint semantics          ch04
  -> plan/pruning/statistics       ch07
      -> safe DDL release          ch11
          -> time-domain policy    ch16
              -> retirement SOP    ch28

可执行能力索引

能力 输入 证据 失败路由
create future partition calendar + schema bound/tree diff ch11
attach loaded partition valid CHECK + manifest no scan/lock plan + row proof ch11
analyze hierarchy distribution change parent/leaf stats ch07
seal range watermark + late window no new writes ch16
detach dependency + lock budget topology diff ch11/ch28
archive object + logical manifest hash + restore ch28/ch35
drop approved retention archive proof + audit ch28
query cold data archive contract result/SLO test ch16

生命周期控制表

可在 control plane 维护:

CREATE TABLE ops.partition_lifecycle (
  parent_table regclass NOT NULL,
  partition_table regclass,
  range_start timestamptz NOT NULL,
  range_end timestamptz NOT NULL,
  state text NOT NULL CHECK (
    state IN (
      'planned','attached','sealed','detached',
      'archived','restore_verified','dropped'
    )
  ),
  manifest_uri text,
  manifest_sha256 text,
  approved_by text,
  changed_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  PRIMARY KEY (parent_table, range_start)
);

但不要让这张表成为未经核验的自动 drop 开关。状态转换必须读取数据库 catalog、归档 系统和审批事实;control row 只是审计协调,不是单独真相。

本节检查清单

partition key and timezone semantics
retention and late-arrival horizon
future partitions precreated
bound gaps/overlaps checked
leaf schema/index/grant consistency
attach CHECK avoids scan where possible
default partition policy
parent explicit ANALYZE
dependency/FK inventory
old snapshot and lock budget
DETACH CONCURRENTLY restrictions
interrupted FINALIZE plan
object/logical/file/restore manifest
drop only after restore proof
online/archive ownership transition

延伸阅读


上一节:膨胀与重建 · 返回本章目录 · 下一节:amcheck 与例行完整性检查 · 查看全书目录 · 查看索引中心

28.6 `amcheck` 与例行完整性检查

备份没有报错,不表示每个 B-tree 都满足搜索不变量;checksum 开启,也不表示 heap 与 index 逻辑一致。

完整性不是一个布尔值,而是多层故障面:

page bytes
  -> relation structure
      -> heap/index logical correspondence
          -> constraints and business invariants
              -> backup artifacts
                  -> actual recoverability

amcheck 覆盖其中重要的一段:heap 与若干 index access method 的结构/逻辑检查。它是 检测工具,不是修复器,更不是 backup 或 restore 的替代品。

28.6.1 bt_index_check 与更深检查的成本

bt_index_check:日常 B-tree 基线

CREATE EXTENSION amcheck;

SELECT bt_index_check(
  index => 'app.orders_pkey'::regclass,
  heapallindexed => false,
  checkunique => true
);

它检查多种 B-tree 不变量。若发现逻辑不一致,通常抛错;无输出/无错意味着:

在本次检查覆盖的范围内没有发现问题。

不意味着:

这个索引、heap、存储设备和所有备份绝对无损坏。

锁:

index  AccessShareLock
heap   AccessShareLock

与普通 SELECT 使用的 relation lock mode 相同。官方文档把它视为 live production 日常轻量检查的较好折中。

先筛合法对象

批量检查不要把 temp、invalid、非 B-tree 对象误传:

SELECT
  bt_index_check(
    index => c.oid,
    heapallindexed => i.indisunique,
    checkunique => i.indisunique
  ),
  n.nspname,
  c.relname,
  c.relpages
FROM pg_index AS i
JOIN pg_class AS c ON c.oid = i.indexrelid
JOIN pg_namespace AS n ON n.oid = c.relnamespace
JOIN pg_am AS am ON am.oid = c.relam
WHERE am.amname = 'btree'
  AND c.relkind = 'i'
  AND c.relpersistence <> 't'
  AND i.indisready
  AND i.indisvalid
ORDER BY c.relpages DESC;

对 unique index,checkunique=true 检查重复 entry 中不应有多个可见版本;这是额外 工作,但更贴合 uniqueness 语义。

bt_index_parent_check:更强锁、更深结构

SELECT bt_index_parent_check(
  index => 'app.orders_pkey'::regclass,
  heapallindexed => true,
  rootdescend => true,
  checkunique => true
);

它是 bt_index_check 的超集:

  • 检查 parent/child relationship;
  • 检查缺失 downlink;
  • rootdescend=true 为每个 leaf tuple 从 root 重新搜索;
  • 可结合 heapallindexed/checkunique。

代价:

index  ShareLock
heap   ShareLock

这会阻止 concurrent INSERT/UPDATE/DELETE,也阻止 relation 的 VACUUM 和其他 utility command。锁只在函数运行期间持有,不是整个外部 transaction 都持有;但大型 索引检查时间可能很长,所以仍需要窗口。

它不能在 hot standby 上执行;bt_index_check 可以。不要在 replica 迁移脚本里把两者 当同义函数。

rootdescend 不一定是最有价值的生产检查

rootdescend 最初也服务于 B-tree feature development。它可能显著增加资源和时间, 但对现实中某些损坏类型的额外检出价值有限。

分层策略:

routine:
  bt_index_check

selected stronger check:
  bt_index_check + heapallindexed + checkunique

maintenance window / incident:
  bt_index_parent_check
  optional rootdescend

不要因为参数叫“更彻底”就每天全库打开。

不止 B-tree

PostgreSQL 18 amcheck 还提供:

gin_index_check
verify_heapam

verify_heapam 检查 table/sequence/materialized view 的物理格式和逻辑结构,返回每个 发现问题的 block/offset/attribute/message。

SELECT *
FROM verify_heapam(
  relation => 'app.orders'::regclass,
  on_error_stop => false,
  check_toast => false,
  skip => 'none',
  startblock => NULL,
  endblock => NULL
);

边界:

  • check_toast=true 很慢;
  • TOAST 或其 index 损坏时,检查 toast value 理论上可能导致 server crash,很多情况 会只报错,但不能当无风险;
  • skip=all-visible/all-frozen 降低成本,也降低覆盖;
  • startblock/endblock 可做 chunk;
  • 依赖的内部设施本身损坏时,函数可能无法继续。

对疑似 heap corruption,先按事故窗口、备份和证据保全运行,不要把全表 verify_heapam(check_toast=true) 当 cron。

调试日志

交互诊断可:

SET client_min_messages = DEBUG1;
SELECT bt_index_check('app.orders_pkey', true, true);

会显示更多检查上下文。生产自动化默认不应把 DEBUG 细节写入公开日志;错误信息可能 泄漏数据结构或可推断内容。

权限不是“能执行即可”

amcheck function 可授权给非超级用户,但官方文档提示安全与隐私风险。独立维护角色应:

no application writes
no broad role membership
function execute only where needed
secure log/evidence destination
audited schedule
no public error payload

Pigsty managed cluster 可用专门运维角色和私有日志采集;不要让普通应用角色在任意表上 调用结构取证函数。

28.6.2 heapallindexed、锁与业务窗口

heapallindexed 回答更强的问题

普通 B-tree structure check 主要从 index 看 index。heapallindexed=true 增加:

每一个应该有 index entry 的 heap tuple,是否都能在目标 index 的摘要结构中找到?

内部类似一次“dummy CREATE INDEX CONCURRENTLY”:

scan target index
  -> build in-memory fingerprint summary
      -> scan heap as hypothetical index input
          -> verify expected entries are represented

这能发现 heap/index 不一致,而这种 cross-check 不会在普通 index scan 中自动完成。

它是概率摘要

摘要受 maintenance_work_mem 限制。PostgreSQL 官方说明,为使每个应被索引的 heap tuple 漏检不一致的概率不超过约 2%,近似需要每 tuple 2 bytes memory;更少内存时, 漏检概率缓慢上升。

因此:

heapallindexed passed once

仍不是数学上的 100% 证明。例行重复检查会给单个缺失/畸形 tuple 新的发现机会。

计划 memory:

Mfingerprint2×Ntuples M_{\text{fingerprint}} \approx 2 \times N_{\text{tuples}}

只是质量量级,不是固定 allocation。对于 1 billion tuples,2 GB 量级已超过许多默认 maintenance_work_mem;不能以为 boolean 参数零成本。

heapallindexed 不改变 relation lock mode

对同一个函数:

bt_index_check(heapallindexed=false/true)
  -> AccessShareLock remains

bt_index_parent_check(heapallindexed=false/true)
  -> ShareLock remains

但运行时间和 I/O 通常增加数倍,锁持有时长随之增加。锁 mode 没变,不等于业务 影响没变。

业务窗口要看四个预算

scope:
  relations: [...]
  total_bytes: ...
  total_tuples: ...

locks:
  function: bt_index_check
  mode: AccessShareLock
  lock_timeout: ...

resources:
  maintenance_work_mem: ...
  jobs: ...
  io_budget: ...
  cpu_budget: ...

service:
  p95_budget: ...
  replica_lag_budget: ...
  abort_threshold: ...

若使用 parent check,再明确:

write blocking expected
maximum check duration
application retry behavior
DDL/vacuum exclusion

pg_amcheck 批量编排

PostgreSQL client utility:

pg_amcheck \
  --database=appdb \
  --schema=app \
  --heapallindexed \
  --checkunique \
  --progress \
  --jobs=2

更深:

pg_amcheck \
  --database=appdb \
  --table=app.critical_orders \
  --parent-check \
  --heapallindexed \
  --checkunique \
  --jobs=1

注意:

  • --parent-check/--rootdescend 会用更强 relation locks;
  • --rootdescend 隐式选择 parent check;
  • --jobs 是并发连接,直接放大 server I/O/CPU/lock impact;
  • 默认选 table 时也检查 dependent B-tree indexes 和 TOAST;
  • --install-missing 会改变数据库,应纳入 extension change,而不是巡检时隐式执行;
  • PostgreSQL 18 的 pg_amcheck 文档说明该工具面向 PostgreSQL 14+ server;
  • client/server 版本和 option 集必须在 automation 中记录。

不要一次扫全库最大并发

较稳妥:

catalog + small critical indexes
  -> largest/highest-risk index batches
      -> rotating coverage
          -> periodic deep window

对象优先级:

constraint/primary indexes
high write volume
recent crash/storage incident
collation version change
replica divergence suspicion
large/old/rarely read objects
previous invalid/reindex artifact

同一时段避免叠加:

backup full scan
vacuum freeze
reindex
scrub
bulk load
major analytical scan

结果模型

每个对象至少记录:

{
  "database": "appdb",
  "relation": "app.orders_pkey",
  "function": "bt_index_check",
  "heapallindexed": true,
  "checkunique": true,
  "parent_check": false,
  "rootdescend": false,
  "started_at": "...",
  "ended_at": "...",
  "server_version": "18.x",
  "maintenance_work_mem": "...",
  "result": "passed",
  "error_sqlstate": null
}

“cron exit 0”没有对象级 coverage,无法证明哪些 relation 被跳过。

正式实验

一次性 churn_pkey

bt_index_check(
  heapallindexed=true,
  checkunique=true
)                                      pass, 0.0597 s

bt_index_parent_check(
  heapallindexed=true,
  rootdescend=true,
  checkunique=true
)                                      pass, 0.0789 s

表仅 50,000 live rows、无 concurrent business writes,时间只用于证明流程,不能作为 生产吞吐基准。实验在检查后还执行 concurrent reindex,并验证:

one final named index
indisready=true
indisvalid=true
indislive=true
invalid fixture indexes=0
cc artifacts=0

完整性检查与重建后 catalog 状态形成闭环。

28.6.3 amcheck 不替代数据页 checksum 和备份恢复

四种证明覆盖不同问题

机制 主要回答 不能回答
data checksum 读到的数据页 bytes 是否匹配上次写入 checksum B-tree 排序/heap-index 逻辑、可恢复性
amcheck relation/access-method 结构与部分逻辑对应是否一致 所有磁盘 bytes、所有业务语义、备份可用
backup manifest verification 备份文件/size/checksum/所需 WAL 是否匹配 manifest server 一定能恢复、业务结果正确
restore drill 能否启动、replay、打开并验证数据/SLO 所有未来故障都可恢复

这四层是相加,不是互相替代。

data checksum 的边界

PostgreSQL 18 默认启用 page checksum,但可被 cluster-level 禁用。确认:

SHOW data_checksums;

启用时:

  • data page 写入时更新 checksum;
  • 每次读取 page 时验证;
  • 只保护 data pages;
  • 不覆盖内部数据结构和 temporary files;
  • cluster 级启停,不是 per-table。

page 已在 shared buffer 时,amcheck 可能检查的是 buffer 中版本,不一定在该时刻重新读 filesystem;它若触发磁盘读且 checksum 失败,也可能报 checksum error。

第 28 章 run 记录:

data_checksums = on

但这仍不意味着 storage、RAM 或所有 page 被本次实验读过。

amcheck 能发现 checksum 看不到的东西

例如:

operator class violates ordering rules
OS collation behavior changes
primary/standby collation environment differs
heap tuple missing corresponding index entry
access method implementation bug
logical structure valid bytes but wrong links/order

checksum 只知道 bytes 是否与写入时一致;错误逻辑也可以被“正确”地写入并拥有有效 checksum。

备份验证也不是恢复

pg_verifybackup 可:

  • 读 backup manifest;
  • 检查 system identifier/manifest checksum;
  • 对比缺失、额外、size 不同文件;
  • 比较 file checksum;
  • 对 plain backup 解析恢复所需 WAL。

官方文档同样明确:

即使 verify 通过,也应做 test restore,并验证数据库可运行、数据正确。

原因:

manifest cannot prove every server recovery check
valid WAL checksum can still encode nonsensical action due to bug
archive access/KMS/network may fail
recovery config/timeline/target may be wrong
application schema/semantic checks may fail
RTO may exceed objective

Pigsty 的 pgBackRest backup/PITR 流程应同时产出 repository check、restore drill 和业务 验收;第 31 章会完整展开恢复证明。

amcheck 通过不等于“备份健康”

可能:

live primary amcheck pass
backup missing WAL
restore impossible

也可能:

backup files verify pass
live index logically inconsistent

还可能:

primary index inconsistent
standby has different corruption state

需要按 failure domain 分别检查。

发现 corruption 后不要立即“修”

第一反应不应是:

REINDEX everything
ignore_checksum_failure=on
zero damaged page
delete relation file
fail over blindly

先:

1. preserve error, SQLSTATE, block/index identity and logs
2. identify primary/replica/timeline/version/storage
3. stop avoidable writes to affected scope
4. check whether error reproduces on another copy
5. verify checksums, storage/kernel events and recent changes
6. validate backup and recovery points
7. classify heap vs index vs VM vs catalog vs hardware
8. choose repair/rebuild/restore with evidence

如果确认只有可重建 secondary index 损坏,reindex 可能合理;如果 heap、TOAST、system catalog 或 multiple copies 损坏,简单 reindex 可能失败或掩盖证据。

转入:

第 35 章:数据抢救与工程取证

不同结果的安全路由

结果 动作
check pass 记录 coverage/time/version;继续其他层
lock timeout 本轮 inconclusive;换窗口,不算 pass
query canceled/resource gate inconclusive;缩 scope/jobs
checksum failure corruption incident;保全证据
amcheck invariant error corruption incident;定位 heap/index
server crash during deep check highest severity;停止重复触发
invalid index only 依赖/heap 验证后评估 concurrent reindex
backup verify pass 仍做 restore drill

例行计划

continuous:
  checksum/log/storage alerts

daily:
  metadata inventory, invalid indexes, recent error correlation

weekly rotating:
  bt_index_check on critical/high-change B-trees

monthly/window:
  heapallindexed selected objects
  backup manifest verification

quarterly / after incident:
  parent-check/deep checks where justified
  full restore drill + business validation

after upgrade/collation/storage event:
  targeted expanded coverage

频率应按数据变化量和 failure risk,不按“每月一号”机械套用。

完整性 SLO

coverage:
  critical_btree_days: 7
  all_btree_days: 30
  deep_selected_days: 90

backup:
  manifest_verify_each_backup: true
  restore_drill_days: 30

response:
  checksum_or_amcheck_error_page_minutes: 5
  preserve_evidence: true
  automatic_repair: false

这样“例行检查”才是可审计服务,不是散落脚本。

本节检查清单

object type / validity / persistence
bt_index_check vs parent-check selection
heapallindexed/checkunique/rootdescend flags
relation lock mode and duration
maintenance_work_mem and jobs
I/O/CPU/service budget
object-level coverage record
private error evidence
data_checksums state
backup manifest verification
restore drill recency
corruption response route
no automatic destructive repair

延伸阅读


上一节:分区生命周期 · 返回本章目录 · 下一节:实战:建立维护节奏 · 查看全书目录 · 查看索引中心

28.7 实战:建立维护节奏

前六节分别讨论了旧版本、autovacuum、冻结、膨胀、分区生命周期和物理完整性。 这些知识如果只停在若干 SQL 和参数上,仍然很容易变成“告警来了就跑一次 VACUUM”的被动运维。本节把它们收束成一条可重复的维护闭环:

发现信号
  -> 确认对象与保留者
      -> 判断正确性、服务和效率优先级
          -> 选择最小动作并预算锁 / 空间 / WAL
              -> 执行、旁路观测、验收
                  -> 留档、复盘、进入下一周期

本节同时提供一套可复现实验。它不是把固定阈值塞给读者,而是让读者亲眼验证四件事:

  1. 旧快照怎样改变普通 VACUUM 的清理结果;
  2. “空间已经可复用”和“文件已经缩小”为什么是两个结论;
  3. 索引检查、并发重建与分区退役怎样分别验收;
  4. 哪些异常仍属于日常维护,哪些必须立即转入资源事故或数据救援。

28.7.1 制造膨胀、长事务与分区到期

先读实验合同

本章实验只允许在已经确认的 Pigsty pg-test 开发沙箱执行,正式参考环境为:

target        pg36-l2-vagrant/pg-test
server        PostgreSQL 18.6
database      pg36_maintenance
role          dbuser_pg36maint
data          synthetic only
capture       L0 read-only
exercise      L2 bounded disposable fixture
production    forbidden

完整合同在 static/labs/ch28/lab-contract.md,机器可校验的要求与动作白名单 分别在 requirements.jsonmaintenance-contract.json

实验只创建带随机 run_id 注释的一次性数据库和角色。为避免 exporter 在建库与授权之间 抢先连接,runner 依次执行:

CREATE DATABASE ... ALLOW_CONNECTIONS false
  -> REVOKE CONNECT FROM PUBLIC
      -> GRANT CONNECT TO exact fixture role
          -> ALTER DATABASE ... ALLOW_CONNECTIONS true

所有表都位于夹具数据库。只有 maint.churn 的表级 autovacuum 被临时关闭,以便让手工 VACUUM 的因果关系可复现;这不是生产建议。实验明确禁止:

ALTER SYSTEM
修改 Pigsty inventory 或 Patroni DCS
reload / restart PostgreSQL
全局关闭 autovacuum
VACUUM FULL
DROP DATABASE ... WITH (FORCE)
终止不相关会话
未验证归档就删除分区
接触生产数据或流量

换言之,这里制造的是可丢弃夹具上的现象,不是在真实库中“先破坏再学习”。

先锁定输入与上游证据

capture 在任何写入前验证:

  • 第 19 章部署证据仍指向同一个非生产沙箱;
  • 第 25 章确认目标为主库,并保留观测基线;
  • 第 27 章没有把试验参数持久化;
  • 数据库与角色在起点均不存在;
  • amcheckpg_freespacemappg_visibilitypgstattuple 可安装;
  • PostgreSQL 设置、复制槽、prepared transaction、文件系统空间和校验和状态可读;
  • 11 个实验源文件的 SHA-256 与随后执行的版本一致。

这一步解决一个经常被忽略的问题:如果运行期间脚本、目标或前置状态发生变化,最终数字 即使“看起来正确”,也不能归到当前实验设计上。runner 因此在 capture 之后再次计算源文件 散列,不一致就失败关闭。

创建三组现象

夹具包含两类表。

第一类是一个 fillfactor = 70 的 heap,共写入 60,000 行,并建立主键和 (status, id) B-tree:

CREATE TABLE maint.churn (
  id      bigint PRIMARY KEY,
  status  text NOT NULL,
  payload text NOT NULL
) WITH (
  fillfactor = 70,
  autovacuum_enabled = false
);

INSERT INTO maint.churn (id, status, payload)
SELECT i, 'new', repeat(md5(i::text), 20)
FROM generate_series(1, 60000) AS g(i);

CREATE INDEX churn_status_idx
  ON maint.churn (status, id);

VACUUM (FREEZE, ANALYZE) maint.churn;

第二类是按日期范围分区的事件表:

events_2024    10,000 rows   expired
events_2025     5,000 rows   retained
parent total   15,000 rows

数据内容、行数和边界都是确定的,因此归档前后可以比较:

row_count
min(id) / max(id)
min(event_date) / max(event_date)
sum(amount)
ordered row digest

仅比较文件大小或只执行一次 count(*) 都不够:前者不能证明逻辑内容,后者无法发现 同样行数下的值篡改。

建立一个可识别的旧快照

实验另开一个连接:

BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT count(*) FROM maint.churn;

连接的 application_name 固定为 pg36-ch28-old-snapshot。runner 必须从 pg_stat_activity 同时看到:

datname          = pg36_maintenance
usename          = dbuser_pg36maint
application_name = pg36-ch28-old-snapshot
pid              = recorded holder pid
backend_xmin     IS NOT NULL

只有这五个条件全匹配,后续才允许释放该会话。脚本不使用模糊的 query 文本、不按用户名 批量杀连接,也不把所有 idle in transaction 一锅端。

接下来在另一个事务中制造 churn:

WITH updated AS (
  UPDATE maint.churn
     SET status = 'changed',
         payload = payload || '-u'
   WHERE id <= 40000
   RETURNING 1
),
deleted AS (
  DELETE FROM maint.churn
   WHERE id > 40000
     AND id <= 50000
   RETURNING 1
)
SELECT
  (SELECT count(*) FROM updated) AS updated_rows,
  (SELECT count(*) FROM deleted) AS deleted_rows;

结果应为:

updated_rows    40,000
deleted_rows    10,000
remaining_rows  50,000

remaining_rows 必须在下一条 SQL 命令中读取。PostgreSQL 的 data-modifying CTE 共享同一个命令级快照;若在同一条语句里再次 count(*),读到的仍可能是修改前的 60,000 行。这不是数据库“少提交了一次”,而是命令快照语义。类似地,不能让 UPDATEDELETE 命中同一批行,再假设两个子语句会按书写顺序串行处理。

为什么这组夹具有教学价值

这组实验同时保留了四条互不替代的证据线:

现象 主要证据 回答的问题
旧快照 backend_xmin、精确 holder identity 谁还需要旧版本
heap churn pgstattuple、FSM、VM、关系大小 旧版本是否清掉、空间去哪里
索引维护 amcheck、catalog、relfilenode 结构是否通过检查、重建是否完成
分区到期 分区拓扑、CSV、manifest、回灌 数据是否先可恢复、再退出热表

任何一列都不能替代其他列。n_dead_tup 是估计值;文件大小不是可见性;amcheck 不是备份;CSV 存在也不等于可恢复。

28.7.2 从指标与原生视图判定维护优先级

先按风险排序,不按表大小排序

维护队列应该先回答“拖延会造成什么”,而不是“哪个数字最大”。一个实用的三层优先级是:

P0 correctness
  XID / MXID headroom、校验和或 amcheck 错误、无法推进的冻结

P1 service continuity
  replication slot / prepared xact / 长事务保留、磁盘逼近红线、
  autovacuum backlog、锁等待、持续延迟与 WAL 压力

P2 efficiency
  可复用空间不足、索引低效、统计陈旧、计划可控的重组与分区退役

因此,一个 relfrozenxid 年龄危险但只有 2 GB 的表,可能比一个 2 TB、膨胀 20%、 仍有充足空间且正常被 vacuum 的表更紧急。维护分数可以帮助排序,但不能把 P0 平均进 一个漂亮的加权总分:

priority=lexicographic(correctness, service, efficiency) priority = \operatorname{lexicographic} \left( correctness,\ service,\ efficiency \right)

同层内再用 headroom、增长速率、业务关键度、预计锁时间和维护成本排序。

第一屏:全库安全边界

先看数据库年龄、活动快照、prepared transaction 和复制槽:

SELECT datname,
       age(datfrozenxid)        AS xid_age,
       mxid_age(datminmxid)     AS mxid_age
FROM pg_database
WHERE datallowconn
ORDER BY age(datfrozenxid) DESC;

SELECT pid, datname, usename, application_name,
       state, xact_start, backend_xid, backend_xmin,
       wait_event_type, wait_event
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
   OR state = 'idle in transaction'
ORDER BY xact_start NULLS LAST;

SELECT transaction, gid, prepared, owner, database
FROM pg_prepared_xacts
ORDER BY prepared;

SELECT slot_name, slot_type, active,
       xmin, catalog_xmin,
       restart_lsn, confirmed_flush_lsn,
       wal_status, safe_wal_size
FROM pg_replication_slots
ORDER BY slot_name;

不要把“最老连接”自动等同于“清理阻塞者”。真正相关的是它是否持有旧 backend_xmin、prepared XID 或 slot xmin/catalog_xmin,以及时间线是否与问题吻合。 也不要看到 age() 大就立即运行一条万能命令;先按 28.3 的版本化流程判断处于常规态、 迫近 failsafe,还是已经进入事务 ID 硬停机状态。

第二屏:对象触发与进度

候选表至少需要这些原生信息:

SELECT relid::regclass AS relation,
       n_live_tup,
       n_dead_tup,
       n_tup_ins,
       n_tup_upd,
       n_tup_hot_upd,
       n_tup_del,
       last_autovacuum,
       autovacuum_count,
       last_autoanalyze,
       autoanalyze_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

SELECT c.oid::regclass AS relation,
       c.reltuples::bigint,
       c.relpages,
       age(c.relfrozenxid)    AS xid_age,
       mxid_age(c.relminmxid) AS mxid_age,
       pg_relation_size(c.oid)       AS heap_bytes,
       pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY age(c.relfrozenxid) DESC;

SELECT pid, datname, relid::regclass AS relation,
       phase,
       heap_blks_total,
       heap_blks_scanned,
       heap_blks_vacuumed,
       index_vacuum_count,
       max_dead_tuple_bytes,
       dead_tuple_bytes,
       num_dead_item_ids,
       indexes_total,
       indexes_processed
FROM pg_stat_progress_vacuum;

其中:

  • pg_stat_user_tables 是累计统计和估计,适合发现趋势,不是物理真值;
  • pg_class 给出 catalog 估计、年龄和大小,仍不能直接证明 dead tuple 百分比;
  • pg_stat_progress_vacuum 只能描述正在运行的进度,phase 切换不是线性 ETA;
  • 需要高成本物理确认时,再在已选对象上使用 pgstattuple,不要全库高频扫描;
  • pg_visibility_map_summarypg_freespace 分别观察 VM 与 FSM,但不要把它们 解释成业务行正确性。

例如对单个已获批候选:

SELECT * FROM pgstattuple('app.orders'::regclass);

SELECT *
FROM pg_visibility_map_summary('app.orders'::regclass);

SELECT sum(avail) AS fsm_available_bytes
FROM pg_freespace('app.orders'::regclass);

将 Pigsty 看板与 SQL 对齐

Pigsty 的 PostgreSQL 监控提供数据库、实例、表、查询、复制、WAL、磁盘和 autovacuum 等多层视图。看板负责快速发现关联:

dead tuple ratio rises
  + autovacuum duration / backlog rises
  + disk free falls
  + latency or I/O pressure rises

原生视图负责复核对象、持有者、年龄、命令 phase 和 catalog 状态。正确的工作方式是:

dashboard finds pattern
  -> SQL proves identity and mechanism
      -> maintenance ticket freezes target and budget
          -> dashboard + SQL watch the operation

不要从一张图直接跳到 VACUUM FULLREINDEX 或终止会话。图表的采样、标签聚合和 保留周期都可能掩盖瞬态事实;反过来,单次 SQL 也无法替代时间序列。

本次实验如何判定

正式 run 的基线与 churn 后快照同时记录:

logical row count
pgstattuple live/dead tuple counts
pg_stat_user_tables counters
heap and total relation bytes
FSM available bytes
VM all-visible / all-frozen page counts
relfrozenxid age
holder backend_xmin
VACUUM progress samples and phases

优先级判断如下:

  1. 没有 XID/MXID 或完整性 P0 信号;
  2. 人工旧快照明确保留 50,000 个物理 dead tuples,先解除精确保留者;
  3. 普通 vacuum 之后确认空间回收语义,再决定是否需要文件重写;
  4. 索引检查、并发重建和分区到期作为独立、可验收的维护动作执行。

这里的重要判断不是“dead tuple 多,所以 vacuum”,而是“先证明谁让 vacuum 不能完成, 再移除那个精确原因”。

28.7.3 执行清理、检查和分区退役并验证副作用

运行完整闭环

先做不接触数据库的合同检查:

static/labs/ch28/task.sh lint

然后为每次正式实验使用一个不存在或为空的私密绝对目录:

export PG36_EVIDENCE_DIR=/absolute/private/new-empty/ch28-run
static/labs/ch28/task.sh all

all 的顺序固定为:

capture L0 read-only
  -> exercise L2 bounded fixture
      -> verify L0
          -> review L0

也可以逐步执行:

static/labs/ch28/task.sh capture
static/labs/ch28/task.sh exercise
static/labs/ch28/task.sh verify
static/labs/ch28/task.sh review

captureall 拒绝覆盖非空证据目录。私有目录包含原始 SQL 输出、进度采样、 归档 CSV、清理记录和 source hashes;公开仓库只保存字段白名单后的 maintenance-run.json,不保存口令、连接串、SSH 材料或原始业务数据。

第一步:旧快照存在时只运行普通 VACUUM

在 holder 仍有非空 backend_xmin 时,runner 对夹具表运行普通 VACUUM,并以较低的 session-local cost 设置放慢它,以便旁路采样:

old snapshot remains
  -> VACUUM maint.churn
      -> sample pg_stat_progress_vacuum
          -> wait for completion
              -> take physical/statistical snapshot

正式 run 结果:

项目 结果
初始行数 60,000
更新行数 40,000
删除行数 10,000
当前行数 50,000
观察到 holder backend_xmin
vacuum 后物理 dead tuples 50,000
进度采样 128
观察到 phase initializingscanning heap

这不是“VACUUM 失效”。它遵守 MVCC,不能移除那个旧快照仍可能读取的版本。若此时反复 提高 cost limit、增加 worker 或改用 VACUUM FULL,都没有解决保留边界,反而会把 问题扩大成资源或锁事故。

第二步:只释放精确 holder,再完成冻结与统计

runner 只允许终止同时匹配数据库、角色、application、记录 PID 和非空 backend_xmin 的那一个夹具会话。任一属性改变都拒绝动作。释放后执行:

VACUUM (FREEZE, ANALYZE) maint.churn;

这里的 FREEZE 是为了在可控夹具上展示冻结与 VM 变化,不是声称所有日常 vacuum 都 必须加 FREEZE,也不是 PostgreSQL 18 已进入 XID 硬停机后的万能命令。

正式结果:

证据 基线 / 旧快照阶段 释放后
物理 dead tuples 50,000(旧快照下 vacuum 后) 0
heap bytes 61,440,000(初始) 87,040,000
FSM 可用字节 51,920,000
all-visible pages 10,625
all-frozen pages 10,625
age(relfrozenxid) 14 2
进度采样 128 171
释放后 phase scan heap、vacuum indexes、vacuum heap

结论需要精确措辞:

dead tuples became removable
free space became reusable inside the relation
visibility/freeze metadata advanced
heap file did not shrink

初始 heap 为 61,440,000 字节,churn 后最终仍为 87,040,000 字节。普通 VACUUM 成功清理,并不承诺把中间空洞归还操作系统。它在适当条件下可能截断文件末尾的空页, 但这次证据不能推导出“普通 vacuum 永不缩文件”,也不能推导出“文件没缩,所以 vacuum 没用”。

第三步:检查索引,再验证并发重建

runner 先对主键执行两层 amcheck

SELECT bt_index_check(
  'maint.churn_pkey'::regclass,
  heapallindexed => true,
  checkunique    => true
);

SELECT bt_index_parent_check(
  'maint.churn_pkey'::regclass,
  heapallindexed => true,
  rootdescend    => true,
  checkunique    => true
);

正式 run:

bt_index_check             pass   0.0597 s
bt_index_parent_check      pass   0.0789 s

第二种检查更强,但会取得 ShareLock,阻塞并发 DML 与 VACUUM;不能因为本次只需 0.08 秒就假设大表也能随时运行。生产中应根据表大小、缓存状态、业务窗口和副本角色 安排,必要时先用较轻的 bt_index_checkpg_amcheck 分批巡检。

然后对次级索引执行:

REINDEX INDEX CONCURRENTLY maint.churn_status_idx;

验收不止是“命令返回 0”:

条件 正式结果
relfilenode 改变
原大小 3,227,648 bytes
新大小 1,589,248 bytes
同名有效索引 1
indisvalid / indisready / indislive 全部为真
无效夹具索引 0
_ccnew / _ccold 残留 0

尺寸变小是这次数据分布的结果,不是并发重建的固定收益。真正的最低验收是新索引有效、 唯一目标明确、没有异常残留,并且写放大、临时空间、WAL、复制延迟和锁等待仍在预算内。

第四步:先分离、归档与回灌,再删除

过期分区执行:

ALTER TABLE maint.events
  DETACH PARTITION maint.events_2024 CONCURRENTLY;

DETACH ... CONCURRENTLY 不能放在显式事务块中;它分两次事务完成,存在 default partition 时也不能直接使用。若中途留下 pending detach,应该检查 catalog 与作业状态, 按实际版本使用 FINALIZE,而不是盲目再次执行或直接 drop。

正式 run 的顺序和结果:

parent before             15,000 rows
expired partition         10,000 rows
DETACH CONCURRENTLY       passed
parent after               5,000 rows
standalone relation       10,000 rows
CSV bytes                 547,894
CSV SHA-256               cd7d54e4781db3e18deb3fd6d49d5e23...
independent restore       matched
detached partition drop   after validation only

SHA-256 完整值在公开结果文件中为:

cd7d54e4781db3e18deb3fd6d49d5e23daf54496da14e41e5daed009a9a41d0d

独立回灌表的行数、日期范围、金额合计和有序 digest 必须与 detach 前 manifest 一致。 只有 round_trip_validated = true 后,runner 才允许删除原分区。这条门槛把“对象已经 离开热路径”和“数据已经可以销毁”明确分开。

第五步:证明副作用已经收束

正式 run 最终证明:

fixture database absent         true
fixture role absent             true
ordinary drop                   true
DROP ... FORCE used             false
unrelated sessions terminated   0
remote temporary path absent    true
persistent config changed       false
production data/traffic touched false

验证器拒绝 28 个预声明反例,并对 14 个现场证据 mutant 验证失败关闭。review 校验私有 证据文件、归档、源散列和公开摘要。这样的负向验证很重要:只证明“正确文件能通过” 无法证明校验器真的会拦住数据库未清理、索引无效、digest 不一致或误用 VACUUM FULL 等失败状态。

一次沙箱成功只说明合同内流程可执行。它没有证明:

  • 同样动作在生产表上的锁时间和 I/O 成本;
  • 当前备份真的可恢复;
  • amcheck 能替代 checksums、pg_verifybackup 或恢复演练;
  • 生产窗口已经获批;
  • 任何固定 dead tuple 比例适合所有表。

28.7.4 输出维护清单及 ch34/ch35 的安全路由

把“维护”拆成不同频率

健康维护不是每月一次“大扫除”,而是多种频率的闭环。

频率 观察与动作 必须留下的证据
连续 XID/MXID headroom、磁盘、autovacuum backlog、复制槽、长事务、错误日志 告警身份、开始时间、当前 headroom
每日 top dead/churn 表、长事务、slot/prepared xact、失败 vacuum、分区边界 排序快照、owner、处置状态
每周 autovacuum 覆盖、统计陈旧、HOT 比例、FSM/VM 抽查、索引增长 趋势、候选对象、是否需要实验
每月 表级 override 审计、冻结策略、空间与 WAL 预算、归档回灌抽检 参数来源、容量预算、恢复证据
每季度 checksums/pg_amcheck 策略、备份 manifest、整库恢复演练 完整性报告、恢复 RTO/RPO
事件驱动 大批量导入/更新/删除、版本升级、分区切换、schema change 后 前后统计、对象状态、回退结果

频率不是硬编码。写入速率、表大小、SLO、冻结 headroom 和恢复要求决定实际周期。 关键是每项都有 owner、阈值来源、验收和升级路线。

一张可执行的生产工单

任何主动维护动作至少填写:

identity:
  cluster: ...
  instance_role: primary
  database: ...
  relation: schema.table
  relation_oid: ...
  owner: ...

reason:
  signal: ...
  first_seen: ...
  trend_window: ...
  mechanism_evidence: ...
  priority: P0 | P1 | P2

action:
  exact_command: ...
  expected_effect: ...
  alternatives_rejected: ...
  version_authority: PostgreSQL <major.minor> official docs

budget:
  lock_timeout: ...
  statement_timeout: ...
  estimated_runtime: ...
  free_space_required: ...
  wal_budget: ...
  replica_lag_budget: ...
  io_cpu_budget: ...

observation:
  dashboard: ...
  sql_views: ...
  sample_interval: ...
  success_conditions: ...
  abort_conditions: ...

recovery:
  cancel_or_rollback: ...
  invalid_index_cleanup: ...
  pending_detach_finalize: ...
  backup_restore_evidence: ...

approval:
  application_owner: ...
  database_owner: ...
  platform_owner: ...
  window: ...

没有 target OID、精确命令、预算和停手条件的工单,不应进入生产。

Pigsty 命令是入口,不是审批

在目标节点上,Pigsty 提供本地 PostgreSQL 维护封装:

pig pg vacuum  mydb -t app.orders
pig pg analyze mydb -t app.orders
pig pg freeze  mydb -t app.orders
pig pg repack  mydb --plan

它们分别封装 vacuumdbpg_repack,降低日常操作摩擦,但不改变 PostgreSQL 本身的锁、WAL、磁盘、MVCC 和版本语义。特别注意:

  • 不要把 pig pg vacuum mydb --full 当作常规清理;VACUUM FULL 需要 ACCESS EXCLUSIVE 并重写表;
  • pig pg freeze 适用于明确的冻结任务,不是 PostgreSQL 18 事务 ID 硬停机状态下 可以无条件执行的急救口诀;
  • pig pg repack --plan 先列出计划对象,但正式执行仍要预算额外空间、写放大、 WAL、复制延迟和最终锁;
  • pig pg kill 默认 dry-run 是好习惯;即使加 -x,也必须先用 PID、数据库、角色、 application、事务时间和保留证据锁定目标。

平台让命令一致,证据链才决定命令是否应该执行。

明确停手条件

以下任何条件出现,都应停止扩大动作,保留现场并重新判断:

target OID / role / primary identity changed
lock wait exceeds budget
free space falls below action + rollback reserve
WAL rate or replica lag exceeds budget
autovacuum / user workload begins mutual starvation
dead tuple or XID result contradicts hypothesis
REINDEX leaves invalid or _ccnew/_ccold artifacts
DETACH remains pending or archive manifest mismatches
checksum / amcheck / page read reports structural error
unrelated sessions would need termination
only force-drop could clean the fixture

“命令还在跑”不是继续等待的充分理由;“已经跑了很久”也不是取消的充分理由。应根据 预先声明的预算、progress phase、阻塞图和副作用趋势决定。

分流到第 34 章:资源与过载事故

如果数据结构没有明确损坏,但维护动作或 backlog 正在威胁服务,应转入 第 34 章:过载保护与资源故障判型

disk free / inode rapidly falling
I/O queue and latency rise together
VACUUM workers or maintenance I/O crowd out foreground traffic
WAL burst causes archive or replica lag
lock queue expands
connection / worker / memory pressure
multiple maintenance jobs overlap without budget

第 34 章回答的是“如何止血、保护前台、恢复资源 headroom,并在容量与并发边界内重排 维护”,不是在压力中继续加大 vacuum 或 reindex 并发。

分流到第 35 章:数据救援与取证

出现以下证据时,应停止把问题称为“普通膨胀”,转入 第 35 章:数据抢救与工程取证

checksum failure
amcheck reports structural inconsistency
heap / index / VM relation produces impossible invariant
page read or WAL replay exposes corruption
REINDEX cannot establish a trustworthy valid index
backup manifest or restore validation fails
primary and replica disagree in ways normal MVCC cannot explain

进入救援路线后,优先保护证据和可恢复性:

freeze the incident timeline
identify exact relation / fork / block
preserve logs, checksums, WAL and backup manifests
avoid destructive repair on the only copy
restore or clone into an isolated environment
compare independent evidence
then decide rebuild, logical extraction or failover

不要在唯一生产副本上反复尝试 zero_damaged_pages、手工删文件或未经验证的 catalog 修改。这些动作可能把可调查的局部损坏变成不可逆的数据丢失。

本章最终验收清单

完成第 28 章后,读者应该能逐项回答:

  • 我能用触发公式、表级 override 与版本参数解释 autovacuum 为什么启动;
  • 我能区分统计估计、物理抽查、VM、FSM 和关系大小各自证明什么;
  • 我能找到实际保留旧版本的事务、prepared xact 或 replication slot;
  • 我能同时检查 XID 与 MXID headroom,而不是只背一个 wraparound 数字;
  • 我知道普通 VACUUMVACUUM FULLREINDEX CONCURRENTLYpg_repack 的锁、空间和 WAL 边界;
  • 我能把分区 detach、归档、回灌验证和最终删除拆成独立门槛;
  • 我能为 amcheck 选择检查层级与窗口,并知道它不能替代备份恢复;
  • 我能写出含目标、假设、预算、停手、验收和恢复的生产维护工单;
  • 我能判断事件应留在日常维护,还是升级到第 34 或第 35 章;
  • 我不会把一次沙箱成功描述成生产安全证明。

本章参考实验的最终判定为:

maintenance loop demonstrated in sandbox
exact cleanup verified
production_ch28_gate = pending

这三个结论缺一不可:流程已经跑通,副作用已经收束,但生产变更仍未获授权。

进一步阅读:


上一节:amcheck 与例行完整性检查 · 返回本章目录 · 下一章:移花接木:逻辑复制、迁移与异构同步 · 查看全书目录 · 查看索引中心

29 移花接木:逻辑复制、迁移与异构同步

逻辑复制能让数据流动,但“数据正在流动”离“迁移已经成功”还很远。

publication 不复制 DDL,sequence 不随表中 identity 值推进,大对象不在复制范围内; subscription 停止后,slot 仍可能继续保留 WAL 与 catalog rows;本地写入 subscriber 既可能造成会报错的 apply conflict,也可能留下完全不报错的静默漂移;在切换窗口里, 源端是否真的停止写、目标序列是否安全、目标新增写如何反向带回,决定了回退是不是一句 空话。

本章把迁移建模为一个有证据、有门禁、可失败关闭的状态机:

inventory and semantic preflight
  -> target schema
      -> initial snapshot
          -> incremental stream
              -> catch-up fence
                  -> source write fence
                      -> sequence / external state sync
                          -> shadow verification
                              -> route switch
                                  -> observation
                                      -> forward or rollback decision

其中每一条箭头都有前置条件、观测、超时、停手方式与恢复路径。不能用最终行数相同跳过 中间状态,也不能把“DNS 已改”当成目标端数据、权限和业务语义已经可用。

本章目标

理解逻辑复制、CDC 和数据搬迁的状态机,用校验与可回退切换完成迁移,而不是把“数据能流动”误当成迁移成功。

读完本章,读者应该能够:

  1. 从 WAL、output plugin、publication、slot、subscription 和 apply worker 解释原生 逻辑复制的数据路径;
  2. 区分 initial table synchronization、持续 DML 与独立的 schema/sequence 迁移;
  3. 为表选择 primary key、REPLICA IDENTITY USING INDEX 或受约束的 FULL
  4. pg_replication_slotspg_stat_subscriptionpg_stat_subscription_statspg_subscription_rel 定位停滞和冲突;
  5. 解释 slot 为什么提供可恢复位点,却不能天然提供 exactly-once 的外部副作用;
  6. 为 CDC sink 设计 event identity、幂等提交、位点原子性与 replay 策略;
  7. 设计 COPY/dump/restore 的并行装载顺序,并保留 rejected rows;
  8. 用行数、摘要、分桶、不变量和抽样分别验证结构与数据;
  9. 把在线迁移拆成 preflight、全量、增量、追平、写围栏、切流、观察和退出;
  10. 区分 rollback、forward repair 与已经跨过不可逆点的前滚;
  11. 为异构系统显式记录类型、精度、时区、排序、约束、顺序和 delete 语义损失;
  12. 用 Pigsty 生成迁移上下文、观察两端集群,但不把生成脚本误认为自动切流授权;
  13. 建立源、目标、验证和管理端点的独立凭据与网络边界;
  14. 在源端主库切换时评审 logical slot failover,而不是默认 subscription 会自动跟随;
  15. 完成一份含原始证据、公开摘要、回退记录和双端精确清理的迁移证据包。

一张图看清复制与迁移

publisher database
  table DML
    -> WAL
      -> logical decoding
        -> pgoutput
          -> publication filter
            -> logical slot
              -> streaming protocol
                -> subscriber apply worker
                  -> same qualified table name

separate migration tracks
  DDL / ownership / privileges
  sequences
  large objects
  extensions and settings
  application routes and credentials
  validation and rollback state

publication 是某一个数据库中的变更集合;subscription 定义下游连接、publication 集合和 apply 行为;一个活动 subscription 通常对应源端一个持久 logical slot,初始 复制还会短暂创建 table synchronization slots。slot 的名称在整个 PostgreSQL cluster 中唯一,但 logical slot 只属于一个 database。

初始复制完成后,pg_subscription_rel.srsubstate = 'r' 只说明表同步状态 ready; 它不证明 sequence、DDL 或业务不变量一致。迁移验收必须把 PostgreSQL 内建状态与 独立 reconciliation 同时纳入。

本章正式实验

本章在两个具有不同 PostgreSQL system identifier 的 Pigsty 沙箱集群之间执行:

source       pg-test / pg36_shop_src
target       pg-meta / pg36_shop_dst
PostgreSQL   18.6 on both sides
wal_level    logical
data         synthetic only
route        private evidence simulation only
production   not touched

正式 run:

run id       7d95ca65-12a7-46c2-8e6c-ad8cbb5336c5
preflight    568c4034-5b7c-4be1-a5e0-48fa189bb782
source sysid 7668025967696967004
target sysid 7668025945980641675

初始复制:

对象 行数 table sync state logical manifest
shop.customers 5,000 r 相等
shop.orders 20,000 r 相等

随后增量执行:

INSERT 500
UPDATE 200
DELETE 100
source marker acknowledged
logical manifests equal

暂停 subscription 后写入 3,000 行:

证据 暂停前 暂停后
slot active false false
confirmed_flush_lsn 不变 不变
retained bytes 227,008 2,867,128

恢复 subscription 后,marker 被确认且双端重新相等。

冲突与静默漂移:

order_id 900000
  target local row + source incoming row
  -> confl_insert_exists: 0 -> 1
  -> apply_error_count:    0 -> 1
  -> remove exact target conflict
  -> replay and converge

order_id 1
  target-only value change
  -> no apply error required
  -> bucket 1 digest mismatch
  -> source-authoritative repair
  -> zero mismatched buckets

切换与回退:

source runtime write attempt     SQLSTATE 42501
target sequence before           1
target sequence synchronized     900000
first target canary              900001
private route history            source -> target -> source
real platform route changed      false
target-only rows reconciled      1
source retained through rollback true
final customers                  5000
final orders                     23402
final manifests equal            true
orphan / negative / invalid      0 / 0 / 0

最后普通删除两端数据库、五个角色、subscription 与 slot;未使用 force drop,未终止 无关会话。29 个预声明反例和 19 个现场证据 mutant 全部被拒绝。公开结果见 migration-run.json

本章目录

29.1 逻辑复制原语

先建立 PostgreSQL 原生模型:哪些对象定义变更集,哪些对象保存位点,初始快照如何追上 主 apply 流,以及 UPDATE/DELETE 怎样定位目标行。最后明确没有进入这条流的内容。

29.2 CDC 与复制槽治理

从内建 subscription 扩展到通用 CDC:区分产生事件、传输、处理、提交外部副作用与推进 位点,解释重放和重复为什么不可避免,并把 inactive slot 转成可预算的 source risk。

29.3 批量装载与数据校验

迁移常常先以批量方式搬运基线。本节讨论 server-side COPY、client-side \copy、 并行与约束顺序,重点是如何把坏行、批次、源文件散列和最终 reconciliation 留下来。

29.4 在线迁移状态机

把工具动作提升为迁移项目:每一 phase 有 entry condition、exit evidence、owner、timeout 与 abort path。读路径和写路径分别验证,切换不是单一时刻,而是逐步关闭不确定性的过程。

29.5 异构同步的语义损失

跨引擎 CDC 不能只看连接器“green”。本节用可声明的 semantic contract 记录类型映射、 精度、排序、事务边界、tombstone、约束和重新处理行为,再用代表性查询验证目标用途。

29.6 多集群迁移环境

用 Pigsty 承担集群、端点、监控和迁移上下文的参考实现。明确 pgsql-migration.yml 生成的是操作手册与脚本,真实写围栏、路由和退出仍需按应用接入 方式设计、审批与执行。

29.7 实战:迁移 pg36_shop

用本章 runner 重演完整双集群实验,阅读私有证据与公开白名单,练习如何从一个失败阶段 安全恢复,而不是只观察成功路径。

推荐学习路线

应用开发者:

29.1 -> 29.3 -> 29.4 -> 29.5 -> 29.7

重点是 schema/sequence 边界、数据校验、应用兼容、读写切换与异构语义。

平台工程师:

29.1 -> 29.2 -> 29.4 -> 29.6 -> 29.7

重点是 slot/WAL、权限、进度、故障恢复、源端主库切换和迁移证据。

两条路线最终必须合流:平台可以保证 stream 可用,无法替应用决定订单状态是否等价; 应用可以定义不变量,无法独自保证 slot、磁盘、网络和切换窗口。

版本与证据权威

本章 PostgreSQL 语义以 18 为基线,正式 run 使用 18.6。逻辑复制能力跨版本变化明显: row filter、column list、binary、streaming、two-phase、conflict statistics、failover slot、generated columns 与 subscription 权限都必须以源、目标实际 major/minor 的官方文档为准。

Pigsty 示例以 4.4 为参考。当前 pgsql-migration.yml 生成迁移上下文、操作手册和脚本, 不会替操作者直接完成真实路由。生成物需要进入变更评审,secret 也不能因为出现在模板里 就进入仓库或公开证据。

核心资料:

实验文件

static/labs/ch29/
├── requirements.json
├── migration-contract.json
├── negative-cases.json
├── topology.mmd
├── lab-contract.md
├── capture.py
├── remote_experiment.py
├── exercise.py
├── validate.py
├── review.py
├── task.sh
└── migration-run.json

执行:

static/labs/ch29/task.sh lint

export PG36_EVIDENCE_DIR=/absolute/private/new-empty/ch29-run
static/labs/ch29/task.sh capture
static/labs/ch29/task.sh exercise
static/labs/ch29/task.sh verify
static/labs/ch29/task.sh review

all 会按相同顺序执行。exercise 会在两个沙箱集群上真实创建 subscription/slot、 生成 WAL 并注入冲突,只能在明确的一次性开发/测试环境运行。

本章验收

只有当迁移证据包能回答以下问题,才算完成:

exact source and target identity
schema / extension / collation / setting diff
publication table and replica identity inventory
initial table synchronization states
source marker LSN and subscriber acknowledgement
slot restart / confirmed LSN, wal_status and safe_wal_size
row count + digest + bucket + business invariant
DDL / sequence / large object / external object plan
apply conflict statistics and repair record
source write-fence proof
route change, owner and rollback point
destination-only write reconciliation
source retention and exit criteria
subscription / slot / credential cleanup
production approval state

“两端 count(*) 相等”不是迁移验收。


上一章:除旧布新:VACUUM、冻结与膨胀治理 · 返回下卷导读 · 下一章:推陈出新:版本升级与回滚策略 · 查看全书目录 · 查看索引中心

29.1 逻辑复制原语

逻辑复制不是“把 WAL 发到另一台机器”。物理复制重放块级变化,逻辑复制则在源端把 WAL 解码为表和 tuple 的变化,经 publication 过滤后交给下游 apply。正因为中间已经 进入逻辑层,源和目标可以是不同 major、不同平台,甚至有不完全相同的物理布局;也正 因为只传逻辑 DML,schema、sequence 和许多数据库对象不会自动跟随。

先把原语分清,后面的迁移状态机才不会建立在错误假设上。

29.1.1 publication、subscription 与 replication slot

三个对象,三种职责

对象 所在端 持久状态 主要职责
publication publisher database table/column/row/operation 集合 定义发送哪些逻辑变化
logical slot publisher cluster + 单一 database restart/confirmed LSN、xmin/catalog_xmin 保存一个消费流的保留与确认边界
subscription subscriber database conninfo、publication、slot、apply 选项 拉取、初始同步并应用到本地表

publication 不是消息队列,也不会因为创建就开始发送。它只是某一个 database 中的 变更集合:

CREATE PUBLICATION pg36_shop_pub
FOR TABLE shop.customers, shop.orders;

一个表可以属于多个 publication,一个 publication 可以有多个 subscriber。它可以限定:

CREATE PUBLICATION paid_orders
FOR TABLE shop.orders (
  order_id, customer_id, status, amount, updated_at
)
WHERE (status = 'paid')
WITH (
  publish = 'insert, update, delete',
  publish_generated_columns = none
);

但需要记住几个条件:

  • publication 名称只需在当前 database 内唯一;
  • FOR ALL TABLESFOR TABLES IN SCHEMA 会自动纳入未来对象,权限和变更半径更大;
  • column list 必须覆盖 replica identity,才能发布 UPDATE/DELETE
  • publish 控制持续 DML,不控制初始数据复制;
  • row filter 对 TRUNCATE 无效;
  • temporary、unlogged、foreign table、view 和 materialized view 不能加入 publication;
  • CREATE PUBLICATION 不创建 slot,也不产生网络连接。

subscription 位于目标 database:

CREATE SUBSCRIPTION pg36_shop_sub
CONNECTION
  'host=source.example port=5432 dbname=shop
   user=logical_reader password=REDACTED
   application_name=pg36_shop_sub
   options=-crow_security=off'
PUBLICATION pg36_shop_pub
WITH (
  copy_data  = true,
  create_slot = true,
  enabled    = true,
  slot_name  = 'pg36_shop_slot',
  streaming  = parallel,
  binary     = false,
  run_as_owner = false
);

正常情况下,这条命令同时:

  1. 在目标 catalog 创建 subscription;
  2. 连接源端;
  3. 在源端创建持久 logical slot;
  4. 启动 apply worker;
  5. 为待同步表启动受资源上限约束的 table sync workers。

创建远端 slot 时,CREATE SUBSCRIPTION 不能放进显式事务块。若源和目标只是同一个 PostgreSQL cluster 内的不同 database,同一命令一边等待 slot 创建、一边等待自身事务 提交,可能挂住;官方做法是先单独创建 pgoutput slot,再用 create_slot = false 绑定。跨集群也可以预建 slot,但 subscription 的 slot_namefailover 属性和源端实际 slot 必须一致。

slot 保存的不是一份数据副本

logical slot 是“从哪个位置继续生成一个 database 的变化流”的持久状态:

SELECT slot_name,
       plugin,
       slot_type,
       database,
       active,
       active_pid,
       xmin,
       catalog_xmin,
       restart_lsn,
       confirmed_flush_lsn,
       wal_status,
       safe_wal_size,
       invalidation_reason,
       failover,
       synced
FROM pg_replication_slots
WHERE slot_name = 'pg36_shop_slot';

关键字段:

  • restart_lsn:仍可能需要的最老 WAL 边界;
  • confirmed_flush_lsn:logical consumer 已确认接收的位置;
  • xmin / catalog_xmin:仍需保留的普通行版本和系统目录版本;
  • active / active_pid:当前是否有一个消费者;
  • wal_statusreservedextendedunreservedlost
  • safe_wal_size:在配置有 slot WAL 上限时,距离可能 lost 尚可写多少 WAL;
  • invalidation_reasonwal_removedrows_removedwal_level_insufficientidle_timeout 等失效原因;
  • failover / synced:是否为可同步到 standby 的 failover slot、是否由上游同步而来。

slot 名称在整个 cluster 中唯一,但 logical slot 只关联一个 database。不同 CDC consumer 通常需要不同 slot;两个消费者轮流使用同一 slot,不会各自得到完整历史。 同一时刻也只能有一个 receiver 消费它。

生命周期必须成对

常规生命周期:

CREATE SUBSCRIPTION
  -> remote slot created
  -> initial sync slots created and dropped
  -> main slot continuously advances
  -> DROP SUBSCRIPTION
  -> remote main slot dropped

若目标端必须删除 subscription,而源端暂时不可达,不能假设远端 slot 也消失了。 PostgreSQL 允许先解除关联:

ALTER SUBSCRIPTION pg36_shop_sub DISABLE;
ALTER SUBSCRIPTION pg36_shop_sub SET (slot_name = NONE);
DROP SUBSCRIPTION pg36_shop_sub;

随后必须在源端按名称、database、plugin、active 状态精确检查并删除孤儿 slot:

SELECT pg_drop_replication_slot('pg36_shop_slot');

这不是通用“清理所有 inactive slot”脚本。一个 inactive slot 可能只是计划内停机的 消费者;只有迁移 owner、保留策略和恢复点都确认不再需要时才能删除。

最小权限不是一个万能复制账号

源端连接角色至少需要:

LOGIN
REPLICATION
pg_hba.conf / network allow
CONNECT on publisher database
USAGE on published schemas
SELECT on published tables for initial copy

若该角色不是 superuser 或 BYPASSRLS,publisher RLS policy 可能参与 initial copy 和 row filter。对不信任所有 table owner 的复制域,conninfo 中 options=-crow_security=off 会在后来出现 RLS 时停止,而不是悄悄按 policy 过滤。

目标端创建 subscription 的角色需要数据库 CREATEpg_create_subscription 权限。 默认 run_as_owner = false 时,apply 对每张表切换为目标 table owner;subscription owner 需要能 SET ROLErun_as_owner = true 看似省事,却让目标 table owner 有机会 通过 trigger 等对象以 subscription owner 权限执行代码,除非安全边界极其明确,不应 把它当默认解法。

本章实验还遇到一个很实际的 Pigsty 边界:当前 HBA 用 +dbrole_readonly 分类业务 连接。临时账号被授予:

GRANT dbrole_readwrite TO dbuser_pg36source
  WITH INHERIT FALSE, SET FALSE;

GRANT dbrole_readonly TO dbuser_pg36repl
  WITH INHERIT FALSE, SET FALSE;

这只让 HBA 的角色成员测试匹配,不让登录角色继承或 SET ROLE 到平台角色。把网络 分类与对象权限拆开,既能连接,也不把“能通过 HBA”误写成“自动拥有业务表权限”。

29.1.2 初始同步、流式变更与复制身份

初始快照与主 apply 流怎样汇合

逻辑复制的主路径是:

publisher backend writes WAL
  -> walsender runs logical decoding
      -> pgoutput emits protocol messages
          -> subscriber apply worker maps qualified table/columns
              -> target transaction commits

已有数据不能从未来的 WAL 中凭空恢复,所以每张表还经历:

consistent publisher snapshot
  -> table synchronization worker COPY
      -> per-table temporary synchronization slot
          -> replay changes committed during COPY
              -> hand control to main apply worker

状态在 pg_subscription_rel

code 含义
i initialize
d copying data
f table copy finished
s synchronized
r ready,进入正常复制

查询:

SELECT s.subname,
       r.srrelid::regclass AS relation,
       r.srsubstate,
       r.srsublsn
FROM pg_subscription_rel AS r
JOIN pg_subscription AS s ON s.oid = r.srsubid
ORDER BY s.subname, relation;

本章正式 run 的第一次采样看到两张表分别处于 sd,约 0.55 秒后才都进入 r。如果只看 subscription 已存在或 apply worker 有 PID,会过早宣布 initial copy 完成。

r 也只证明 PostgreSQL 的 table sync 状态。正式验收还比较:

customers rows + ordered digest
orders rows + ordered digest + amount sum
status distribution
orphan orders
negative amounts
invalid statuses

结果为 5,000 customers、20,000 orders,双端 logical manifest 相等。

publication 动作与 initial copy 是两套选择

假设:

CREATE PUBLICATION insert_only
FOR TABLE shop.orders
WITH (publish = 'insert');

publish = 'insert' 只限制后续 DML。默认 copy_data = true 时,已有行仍会被初始复制。 因此不能用 publication operation list 推断目标基线只含某类事件。

row filter 与 column list 有各自的版本规则;跨版本迁移必须按源、目标最低版本检查。 多个 publication 若以不同 column list 重叠发布同一张表,并不是一个可随意叠加的投影 系统。变更 publication 后,还需要:

ALTER SUBSCRIPTION pg36_shop_sub REFRESH PUBLICATION;

新表才进入 subscription catalog;是否 copy_data 要显式决定。REFRESH 不是 DDL 迁移,也不会为目标创建缺失表。

REPLICA IDENTITY 回答“改哪一行”

INSERT 只需把新值写入目标;UPDATEDELETE 必须携带足以在目标定位旧行的身份。 默认身份是 primary key:

SELECT n.nspname,
       c.relname,
       c.relreplident,
       i.indexrelid::regclass AS identity_index
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
LEFT JOIN pg_index AS i
  ON i.indrelid = c.oid
 AND i.indisreplident
WHERE c.oid IN (
  'shop.customers'::regclass,
  'shop.orders'::regclass
);

选择顺序通常是:

stable primary key
  > suitable unique index
      > carefully-reviewed REPLICA IDENTITY FULL
          > no UPDATE/DELETE publication

使用另一个索引:

ALTER TABLE shop.orders
  REPLICA IDENTITY USING INDEX orders_external_id_key;

显式 identity index 仍必须满足 unique、immediate、非 partial、列非空等约束。另一条 容易混淆的 PostgreSQL 18 能力是:源端使用 FULL 时,目标端可用符合条件的 B-tree 或 hash 候选索引辅助查找;候选不能是 partial,左侧首字段必须是表列而非表达式。 源端 identity 不是 FULL 时,目标端也必须有由相同或更少列组成的可用 identity。

没有合适 key 时:

ALTER TABLE legacy_events REPLICA IDENTITY FULL;

这会发送整个旧行。PostgreSQL 18 可以在目标使用满足条件的索引寻找行,但若没有, 每个 update/delete 都可能退化为昂贵查找;某些没有默认 B-tree/hash operator class 的 类型也会限制 apply。FULL 是兼容手段,不是免设计主键的奖励。

若 publication 包含 update/delete,而表仍是 NOTHING 或默认身份但没有主键,错误会 发生在 publisher 写入路径,而不是等迁移结束才发现。

transaction order 的保证边界

同一个 subscription 内,subscriber 按 publisher 的提交顺序应用,保持该 stream 的 transactional consistency。这个保证不等于:

  • 多个独立 subscription 之间存在全局顺序;
  • 外部 CDC sink 的 HTTP、文件或消息副作用与 PostgreSQL commit 原子;
  • 目标本地写与源端写自动合并;
  • 源数据库之间的事务能组合为一个全局事务;
  • 网络恢复后永远不会重发近期消息。

若为了吞吐把相关表拆进多个 subscription,原先同事务的外键或业务原子性也可能被拆开。 publication/subscription 拓扑本身就是数据模型的一部分,应进入设计评审。

监控正在发生什么

目标端:

SELECT subid, subname, worker_type, pid, leader_pid, relid::regclass,
       received_lsn, last_msg_send_time, last_msg_receipt_time,
       latest_end_lsn, latest_end_time
FROM pg_stat_subscription
WHERE subname = 'pg36_shop_sub';

SELECT *
FROM pg_stat_subscription_stats
WHERE subname = 'pg36_shop_sub';

源端:

SELECT application_name,
       state,
       sent_lsn,
       write_lsn,
       flush_lsn,
       replay_lsn,
       reply_time
FROM pg_stat_replication
WHERE application_name = 'pg36_shop_sub';

目标显示 apply/sync worker 和接收时间,源端显示 walsender 看到的反馈;两端再用 slot 确认保留边界。不要用 now() - last_msg_receipt_time 一项充当“业务复制延迟”:源端 空闲时没有新消息,时间会变大但数据并未落后。更可靠的门禁是产生一个已提交 marker LSN,等待 confirmed_flush_lsn 到达或越过它,并同时验证目标数据。

29.1.3 DDL、序列、大对象与冲突边界

DDL 不复制,目标 schema 必须先存在

原生逻辑复制按 fully-qualified table name 匹配目标:

source shop.orders
  -> target shop.orders

不会自动创建 schema、table、type、extension、function、constraint、index、owner、 privilege 或 RLS policy。初始 schema 可用受版本控制的 migration 或:

pg_dump --schema-only --no-owner --no-privileges \
  --dbname="$SOURCE_URL" |
psql --set=ON_ERROR_STOP=1 --dbname="$TARGET_URL"

但不能把这条管道当成无需审查的最终方案。pg_dump 输出中可能包含 extension、 owner、tablespace、security label、event trigger 依赖;源目标 major 不同时,还需要用 目标版本工具与官方兼容路径评审。

持续 DDL 应按兼容顺序编排。例如增加一个源端马上会写入的新列:

1. target add compatible nullable/defaulted column
2. verify target apply still healthy
3. source add column
4. publication/column list refresh if needed
5. deploy application writes
6. backfill / validate / tighten constraints

如果先改源,新的 tuple 已进入 stream,而目标 schema 还无法接收,apply 会报错并停止; DDL 后来补齐通常能恢复,但期间 slot 继续保留 WAL。

目标 schema 不必字节级相同:

  • column 按名称匹配,顺序可不同;
  • 目标可有额外列,缺失输入时使用 default;
  • 文本模式下,类型只要源文本表示可被目标输入函数接受即可;
  • binary 模式要求更严格,跨架构、跨 major 和类型 send/receive 兼容性必须单独验证。

“允许不同”不表示“任意不同都安全”。目标额外 default、trigger、constraint 或 generated expression 可能改变语义或让 apply 失败。

sequence 不随 identity 值推进

表行中的 identity/serial 数值会被复制,sequence 对象的 last_value 不会。本章正式 run 在目标已有 900,000 的最大 order_id 时,目标 sequence 仍为:

last_value = 1
is_called  = false

如果此刻允许目标接收默认 identity 写入,第一笔就可能生成已经存在的 key。正确顺序是:

source write fence proven
  -> final stream marker acknowledged
      -> source/target manifests equal
          -> synchronize every sequence
              -> verify next value exceeds data high-water mark
                  -> enable target writes

示意:

SELECT setval(
  'shop.orders_order_id_seq',
  greatest(
    (SELECT max(order_id) FROM shop.orders),
    :source_sequence_last_value
  ),
  true
);

本章先把目标 sequence 从 1 推进到 900,000,随后目标 canary 得到 900,001。真实系统还 要考虑:

  • sequence cache 中已发出但尚未落表的值;
  • 多个 sequence 与非标准 ownership;
  • shard/tenant 分段号;
  • cycling sequence;
  • 应用自行生成 ID;
  • 回退后源端是否也需要吸收目标 high-water mark。

因此 Pigsty 迁移脚本支持同步 sequence 并可加 offset,但 offset 是冲突缓冲,不是 替代写围栏。

large object 和非表对象不复制

PostgreSQL large object(pg_largeobject/OID API)不在逻辑复制范围。普通表中的 bytea 是表列,可以复制;两者不要混淆。还应逐项盘点:

large objects
materialized views and refresh state
views / functions / procedures
extensions and extension versions
FDW server / user mapping
event triggers
roles / memberships / default privileges
RLS policies
database / role settings
tablespaces
collations and ICU/libc versions
scheduled jobs
LISTEN/NOTIFY consumers
external files and object storage references

这些对象需要独立迁移和验收,不能因为两张业务表在复制就默认“整个 database 已搬完”。

subscriber 不是自动只读副本

subscriber 是普通 PostgreSQL database。应用若能在复制目标表本地写入,会产生两类 后果。

第一类会让 apply 报错:

insert_exists
update_exists
multiple_unique_conflicts
permission / RLS / other constraint errors

错误型 conflict 会停止复制,必须修复目标数据/权限,或在理解数据损失后显式跳过远端 事务。跳过不是“重试”,而是声明这笔源端事务不再应用到目标。

第二类可能不停止:

update_missing   -> incoming update skipped
delete_missing   -> incoming delete skipped
local target update later overwritten
target-only change remains forever if source never touches that row

PostgreSQL 18 在 pg_stat_subscription_stats 记录多类 conflict;部分 origin-differs 信息依赖 subscriber 的 track_commit_timestamp。但没有报错或 counter 为零,仍不 能证明双端相等。

本章正式注入:

target order_id=900000 note=target-conflict
source order_id=900000 note=source-authority

结果:

confl_insert_exists  0 -> 1
apply_error_count    0 -> 1
apply worker         stopped/retried

删除精确的目标冲突行后,worker 重放同一源事务并收敛。随后又只改目标 order_id=1,这一次没有等待 apply 报错,而是 16 桶摘要中的 bucket 1 不一致。 按源端权威行修复后,mismatch 才归零。

这说明迁移需要两条独立告警线:

native apply errors / conflict statistics
AND
continuous semantic reconciliation

只看其中一条,都会漏掉真实故障。

trigger 与 apply 语义

持续 apply worker 的 session_replication_rolereplica,普通 origin trigger/rule 默认不执行;可显式启用 replica/always trigger。初始同步更像 COPY,会触发行级和 语句级 INSERT trigger。于是同一张目标表在 initial copy 与 steady state 可能经过不同 trigger 路径。

迁移前应盘点:

SELECT n.nspname,
       c.relname,
       t.tgname,
       t.tgenabled
FROM pg_trigger AS t
JOIN pg_class AS c ON c.oid = t.tgrelid
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE NOT t.tgisinternal
ORDER BY 1, 2, 3;

不要靠 trigger 在目标重新制造本应从源复制的副作用,也不要让 initial copy 重发邮件、 支付请求或 webhook。外部副作用必须有专门的 replay/idempotency 合同。

进一步阅读:


返回本章目录 · 下一节:CDC 与复制槽治理 · 查看全书目录 · 查看索引中心

29.2 CDC 与复制槽治理

内建 logical replication 的目标是 PostgreSQL table,CDC 的目标可能是 Kafka、 对象存储、搜索引擎、缓存、数据仓库或另一个业务服务。两者都从 logical decoding 出发,却在“谁保存位点、何时确认、如何处理重复、怎样表达 schema”上走向不同系统。

本节不绑定某一个 connector,而是建立任何 CDC 实现都绕不开的合同。

29.2.1 逻辑解码、插件与消费位点

从物理 WAL 到业务事件

PostgreSQL WAL 记录的是恢复数据库所需的物理/逻辑底层信息,并不是一个稳定的 JSON 业务接口。logical decoding 负责把一个 database 的持久表变化重组为按事务提交边界 可解释的 change stream:

WAL records
  -> decode relation and tuple changes
      -> reorder by transaction
          -> output plugin
              -> logical replication protocol or SQL decoding API
                  -> consumer

output plugin 决定编码,不决定下游业务语义:

plugin / path 典型用途 重要边界
pgoutput 内建 publication/subscription 二进制 replication protocol,不是给人直接读的文本
test_decoding 测试和理解 logical decoding 测试插件,不是生产业务协议
第三方 JSON/connector plugin 通用 CDC 版本、扩展、schema envelope、升级兼容需独立负责

不要把“输出是 JSON”误认为 schema compatibility 已解决。事件仍需定义:

source identity
database / schema / table
transaction and commit position
operation
replica identity before image
new column values
schema version
event id
producer version

缺少 before image 时,DELETE 怎样定位?TOAST 列未变化时,connector 如何表示?列 rename 是新字段还是同一字段?这些都不是 JSON 格式本身能回答的。

SQL 解码 API 与 streaming protocol

教学或诊断可用:

SELECT *
FROM pg_create_logical_replication_slot(
  'demo_slot',
  'test_decoding'
);

SELECT *
FROM pg_logical_slot_peek_changes(
  'demo_slot',
  NULL,
  100
);

SELECT *
FROM pg_logical_slot_get_changes(
  'demo_slot',
  NULL,
  100
);

peek 不消费,适合观察;get 会推进消费位置。对 pgoutput 应使用 binary API 或 replication protocol,而不是强行调用文本函数。

生产 connector 通常通过 replication protocol:

START_REPLICATION SLOT ...
  -> server sends changes
      -> client sends standby status updates
          -> slot confirmed_flush_lsn advances

“客户端已经读到”“写进本地 buffer”“写到外部系统”“外部事务提交”“向 PostgreSQL 确认”是五个不同瞬间。connector 必须声明哪个瞬间触发 acknowledgement。

snapshot 与 stream 必须无缝衔接

新建 logical slot 时可导出一个 snapshot。这个 snapshot 表示:

snapshot sees database state at boundary B
slot emits all committed changes after boundary B

于是通用 bootstrap 可以:

  1. 创建 slot 并取得 exported snapshot;
  2. 在另一个事务 SET TRANSACTION SNAPSHOT
  3. 全量导出 snapshot 中的基线;
  4. 从 slot 持续消费 B 之后的变化;
  5. 在下游把全量与增量合并。

内建 subscription 将这套协调封装在 table sync workers 中。自行开发 CDC 时,如果先 普通 SELECT 全表、很久以后才创建 slot,会在两者间漏变化;若先创建 slot却不消费也 不预算磁盘,会让 WAL 无界增长。bootstrap 顺序必须成为协议,不是运气。

四个位置不要混为一个“offset”

一个成熟 CDC 管道至少有:

restart_lsn
  publisher still needs WAL from here

confirmed_flush_lsn
  source slot believes consumer confirmed through here

consumer durable checkpoint
  connector can restart from here

sink commit / business watermark
  downstream effects are durable through here

它们应满足与具体协议一致的单调关系。最危险的情况是 connector 先确认 source,再把 buffer 异步写 sink:

confirmed_flush_lsn advances
  -> connector crashes before sink commit
      -> source is allowed to recycle old changes
          -> permanent downstream gap

反过来,sink 已提交而 checkpoint 尚未推进会造成 replay;这通常可以用幂等处理,永久 缺口却无法凭空修复。因此多数 CDC 更愿意接受 at-least-once,而不是冒险提前确认。

plugin、slot 与版本都要进 inventory

建议为每个 consumer 记录:

consumer_id: search-orders-v3
source_cluster: pg-prod
database: shop
slot: cdc_search_orders_v3
plugin: pgoutput
publication: cdc_search_orders
owner: search-platform
schema_contract: order-event-v7
checkpoint_store: kafka-connect-offsets
ack_after: sink_transaction_commit
max_replay_window: 15m
max_slot_retained_wal: 80GiB
rebootstrap_method: snapshot-plus-stream
retirement_ticket: null

slot 本身不知道 consumer owner、SLO 或 sink 状态。没有外部 inventory,inactive slot 只能告诉你“现在没人连”,不能告诉你“可以删”。

29.2.2 至少一次、重复事件与下游幂等

PostgreSQL 已经明确允许 replay

logical slot 是 crash-safe 的,但它的当前位置只在 checkpoint 时持久化。服务器崩溃后, slot 可能回到较早 LSN,于是最近变化再次发送。网络断开、consumer 在 sink commit 后但 ack 前崩溃,也会产生相同结果。

因此正确假设是:

event may be delivered more than once
transaction order is meaningful within a stream
acknowledged history can no longer be requested from that slot

“测试十次没重复”不能升级为 exactly-once 保证。

exactly-once 是端到端属性

若 sink 也是 PostgreSQL,可以在同一个目标事务里同时写业务状态与 dedup ledger:

BEGIN;

INSERT INTO cdc_applied(event_id, source_lsn, applied_at)
VALUES (:event_id, :source_lsn, clock_timestamp())
ON CONFLICT (event_id) DO NOTHING;

-- 只有上一条确实插入时,才应用业务变化。
UPDATE search_projection
SET ...
WHERE ...
  AND :new_event_was_inserted;

COMMIT;

随后才向 source 确认。若事务已提交但 ack 丢失,replay 会命中唯一键,不重复副作用。

但如果副作用跨多个系统:

write database
send email
charge payment
publish Kafka
advance offset

没有一个 PostgreSQL transaction 能原子覆盖全部。应使用:

  • transactional outbox;
  • sink 自身的幂等 key / conditional write;
  • 去重 ledger;
  • 可重建投影;
  • saga/补偿;
  • 对不可重复副作用的业务级 request identity。

不要用“处理成功后更新 offset”一句话掩盖多个 commit 之间的崩溃窗口。

事件身份怎么设计

只用 txid 不够:32-bit XID 会回卷,跨 cluster/database 也会重复。只用表主键也不够: 同一行可以变化很多次。一个实用 envelope 可包含:

source system identifier
database identity
slot / publication contract
commit LSN
transaction identity
change ordinal within transaction
schema contract version

具体 connector 能提供哪些字段取决于协议。核心要求是:

same logical change -> same event id on replay
different logical changes -> different event ids

不要把消费时间戳或随机 UUID 当 event id;replay 时它们会变。

upsert 不自动等于幂等

下面的 sink 写法:

INSERT ... ON CONFLICT (id) DO UPDATE ...

只有在“最后写入覆盖即可”且顺序严格时才近似幂等。它处理不了:

  • 较旧事件晚到,覆盖较新状态;
  • amount = amount + delta 被重复执行;
  • DELETE/tombstone 后旧 UPDATE 复活;
  • 多个 source 同写一个 key;
  • 事件 schema 变化导致部分字段保留旧值;
  • 外部副作用已经发生。

更安全的投影常带 source version:

INSERT INTO order_projection (
  order_id, status, source_commit_lsn, payload
)
VALUES (...)
ON CONFLICT (order_id) DO UPDATE
SET status = excluded.status,
    source_commit_lsn = excluded.source_commit_lsn,
    payload = excluded.payload
WHERE order_projection.source_commit_lsn
    < excluded.source_commit_lsn;

LSN 只在同一 source timeline/合同内有序;跨 source 合并仍需业务 version/vector 或冲突 规则。

transaction boundary 不能随意打散

源事务:

debit account A
credit account B
append ledger

若 connector 把三条 row event 分别确认并让下游实时可见,中途失败可能暴露不平衡状态。 CDC envelope 应保留 begin/commit 或 transaction grouping;sink 要么原子应用整个事务, 要么明确只提供最终一致读模型并隐藏未完成 batch。

大事务还会带来另一组选择:

  • streaming = off:源端完整解码后发送,内存/延迟风险;
  • streaming = on:未提交变化先写 subscriber 临时文件,commit 后应用;
  • streaming = parallel:有 worker 时直接并行 apply,否则回退临时文件。

吞吐设置不能改变“只有源端 commit 后才把结果视为提交”的语义。

poison event 需要隔离,不是静默跳过

遇到无法解析或不满足 sink constraint 的事件:

stop entire stream forever
skip and lose data silently
retry at full speed forever

都不是完整策略。应记录:

event_id: ...
source_position: ...
schema_version: ...
error_class: conversion | constraint | permission | code
first_seen: ...
attempts: ...
raw_payload_ref: encrypted-private-location
owner: ...
decision: repair-and-replay | compensate | approved-skip

dead-letter queue 只是隔离区,不是数据正确性的垃圾桶。任何 approved skip 都要进入 reconciliation,并记录业务影响。

29.2.3 槽停滞、WAL 保留与磁盘风险

inactive 不等于无害

slot 与连接生命周期独立。consumer 下线后:

active = false
confirmed_flush_lsn stops
source continues writing WAL
restart_lsn remains old
pg_wal retained bytes grow
catalog_xmin may also hold catalog tuples

基础查询:

SELECT slot_name,
       database,
       active,
       active_pid,
       inactive_since,
       xmin,
       catalog_xmin,
       restart_lsn,
       confirmed_flush_lsn,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
       ) AS retained,
       wal_status,
       pg_size_pretty(safe_wal_size) AS safe_wal,
       invalidation_reason
FROM pg_replication_slots
WHERE slot_type = 'logical'
ORDER BY restart_lsn NULLS FIRST;

pg_wal_lsn_diff(current, restart_lsn) 是按当前时刻估算该 slot 的 WAL 保留距离,不等于 磁盘上所有 WAL 文件恰好这么大;checkpoint、archive、其他 slot 与 segment 粒度都会 影响实际 pg_wal

把风险换成时间

若近期 WAL 产生率为 rr bytes/s,slot 当前保留 LL,可用于增长的安全空间为 FF,则最粗略的时间预算:

TdiskFr T_{\text{disk}} \approx \frac{F}{r}

若配置 max_slot_wal_keep_size = M,距离 slot 可能在 checkpoint 后失去所需 WAL 的 预算:

TslotMLr T_{\text{slot}} \approx \frac{M - L}{r}

实际告警应使用变化率和低水位:

retained bytes
safe_wal_size
wal_status / invalidation_reason
pg_wal filesystem free
archive health
consumer lag and error rate
catalog_xmin / XID age

只告警 active = false 会在计划维护时产生噪声;只告警磁盘使用率会在 WAL 已快速增长时 太晚。

上限保护的是 source,不保证 consumer 可恢复

max_slot_wal_keep_size 为非负值时,checkpoint 可以允许回收超出上限的 WAL,slot 可能进入 unreserved 乃至 lost。这避免一个遗忘 consumer 无限填满源盘,但代价是 consumer 必须 rebootstrap。

PostgreSQL 18 还提供 idle_replication_slot_timeout:slot inactive 超过时限后可在 checkpoint 被 invalidated。它同样不是“暂停后自动保存到对象存储”。启用前必须明确:

  • 哪类 slot 允许因空闲失效;
  • 谁收到即将失效告警;
  • snapshot/rebootstrap 需要多久;
  • 同步到 standby 的 slot 等豁免/特殊语义;
  • checkpoint 周期带来的执行延迟。

不要为了保护磁盘把上限设得小于正常故障恢复窗口,然后把频繁 lost 当 consumer 问题。

本章停滞实验

正式实验先让 20,000 初始订单和 500/200/100 增量收敛,再执行:

ALTER SUBSCRIPTION pg36_shop_sub DISABLE;

确认目标 subenabled = false、worker 为零、源端 slot active = false 后,在源端插入 3,000 行有界 payload。

结果:

字段 写入前 写入后
active false false
confirmed_flush_lsn 1/9FDBBF30 1/9FDBBF30
retained bytes 227,008 2,867,128
wal_status reserved reserved

这里能确认因 consumer 停滞,确认位点没有推进且保留距离增长。不能用这 2.87 MB 推导 生产每 3,000 行的固定 WAL 成本:其他 database、full-page write、索引、payload、 checkpoint 和并发都会改变 WAL。

恢复:

ALTER SUBSCRIPTION pg36_shop_sub ENABLE;

验收同时要求:

confirmed_flush_lsn >= source marker LSN
both relation states = r
source logical manifest = target logical manifest

“slot active 又变 true”仍不够。

停滞处置顺序

1. identify exact slot and owner
2. confirm source database / plugin / consumer contract
3. capture restart, confirmed, xmin, wal_status, safe_wal_size
4. inspect consumer, network, auth, schema and apply conflicts
5. calculate disk and slot-loss deadlines
6. choose resume, repair, rebootstrap or retire
7. verify acknowledgement plus semantic convergence
8. only then close or drop the slot

优先恢复 consumer,而不是先 drop slot。若 slot 已 lost,继续重试同一位点没有意义; 冻结下游写入,按 snapshot + new slot 的协议重建。若 consumer 已永久退役,保留审批与 最后消费位置后精确 drop。

与 Pigsty 观测对齐

Pigsty 的 PGSQL Replication / Persist / Instance / Alert 看板可把:

slot retention
WAL production
archive
disk free
physical replica lag
logical pub/sub
host I/O

放在同一时间轴。原生 SQL 则确认 slot、subscription、table state 与 conflict identity。 平台看板用于发现趋势,不能替代 consumer owner 和业务 reconciliation。

进一步阅读:


上一节:逻辑复制原语 · 返回本章目录 · 下一节:批量装载与数据校验 · 查看全书目录 · 查看索引中心

29.3 批量装载与数据校验

迁移最容易制造一种虚假的成功感:目标端已经有很多行,增量也在流动,于是团队宣布 “数据迁完了”。但全量装载只回答“怎样把字节搬过去”,数据校验才回答“搬过去的是否 还是同一份业务事实”。

本节把两者作为一个不可拆分的阶段:装载方案必须预先定义验证方法,验证失败必须能 定位到批次、分桶乃至具体主键,而不是在切流前夜才比较两个 count(*)

29.3.1 COPY、并行、约束和索引顺序

先区分三条全量路径

路径 一致性边界 适用场景 主要代价
subscription initial copy 由 table sync worker 与 slot 协调 PG 到 PG,目标表已准备好 并行度和变换能力受逻辑复制模型约束
pg_dump / pg_restore dump snapshot 完整或选择性对象迁移 需要自行衔接 dump 后的增量
COPY / \copy 管道 由导出事务和位点协议定义 大表、异构转换、分批装载 快照、分片、错误账本和增量汇合都要自己负责

COPY 很快,但它不自动提供迁移一致性。若导出事务没有与 logical slot 的 exported snapshot 对齐,逐表 COPY 得到的可能是不同时间点;若完成全量后才创建 slot,全量与 增量之间还会留下永久缺口。第 29.2 节的“snapshot 加 stream”协议因此同样适用于手工 批量装载。

PostgreSQL 中有两个经常混淆的文件边界:

COPY shop.orders (order_id, customer_id, status, amount, updated_at)
TO '/server/path/orders.csv'
WITH (FORMAT csv, HEADER true, ENCODING 'UTF8');

COPY 的文件由数据库服务器进程读取或写入,需要服务器文件权限;psql\copy 则让客户端读写文件,通过 SQL 连接传输数据。迁移工作站通常使用 \copy, 避免给数据库角色服务器文件权限。无论选哪一种,都应:

  • 显式列出列名,不依赖物理列顺序;
  • 固定编码、日期格式、时区和 NULL 表示;
  • 记录导出查询、snapshot、源系统标识、行数、文件大小与文件摘要;
  • 把原始文件或不可变对象版本作为可追溯输入;
  • pg_stat_progress_copy 观察正在执行的 COPY,而不是从文件大小猜完成度。

binary COPY 省去文本转换,在完全同构、版本和类型实现已验证时可能更快;它不是通用 交换格式。跨 major、跨架构或有类型映射时,文本/CSV 加显式规范通常更可审计。

并行单位要可重放

一条 COPY 不能通过加一个参数变成并行任务。常见并行单位是:

不同表
同一分区表的不同叶子分区
按稳定主键范围切片
预先生成且有 manifest 的多个文件
pg_restore 的独立对象任务

切片必须互斥、完备并可复算。例如按整数主键范围切分时,记录 [lower, upper),不要用随数据变化的 LIMIT/OFFSET。按 hash 分桶时,固定 hash 算法、编码和桶数。并发量同时受源端顺序读、网络、目标 WAL、磁盘、索引维护、 autovacuum、standby 重放与连接数约束;“有 32 核就开 32 个 COPY”不是容量模型。

可先用一小段代表性数据测量:

source export MB/s
network MB/s and retransmission
target heap MB/s
WAL bytes / loaded byte
standby replay lag
checkpoint pressure
CPU spent on conversion and indexes

再逐级增加 worker,找到吞吐开始变平、延迟或 WAL 开始恶化之前的并发点。

约束、触发器和索引的顺序是风险选择

COPY FROM 会执行 check constraint 和 trigger,但不会执行 rewrite rule;外键检查、 二级索引维护和触发器都可能成为装载成本。不能因此笼统地把它们全部关闭:

做法 收益 风险与前提
保留 PK/UNIQUE/CHECK 立即拒绝重复或非法行 装载时持续维护索引
装完再建二级索引 批量排序建索引通常更快 装载期间查询能力弱,建索引需额外空间
按父表再子表装载 可保留 FK 检查 并行度下降
装入 staging 再转换 错误隔离、类型转换可审计 多一份空间与一次写入
暂缓 FK 后再 VALIDATE 加快大批量导入 切流前必须完成验证,且不能让非法数据外泄

对 online migration,目标表通常已经服务 logical apply。随意禁用 trigger、 session_replication_role 或删除 replica identity,可能同时改变增量应用语义。正确 顺序应在演练中固化,例如:

创建 schema 与必要主键
  -> 创建不参与装载路径的必要类型/扩展
  -> 全量装载或启动 initial copy
  -> 建立可延后的二级索引
  -> 验证/启用约束
  -> ANALYZE
  -> 等待增量追平
  -> 运行数据与业务校验

装载后立即 ANALYZE。否则数据虽然完整,优化器仍可能按空表或旧统计量选择计划, 把“迁移正确”误判成“新库性能不行”。

29.3.2 行数、摘要、分桶与业务不变量

校验是一架逐层缩小范围的梯子

单独的 count(*) 很弱:删掉一行再插入一行,行数完全不变。反过来,直接对十亿行做 一个全表摘要虽然更强,一旦不一致却只会得到“某处不同”。实用校验从便宜到昂贵逐层 推进:

  1. 对象 manifest:schema、表、列、类型、默认值、identity、约束、索引、分区、 publication membership;
  2. 精确行数:不能拿 pg_class.reltuples 这类估算值做最终验收;
  3. 列统计min/max/sum/null count/distinct count、状态分布;
  4. 稳定有序摘要:对规范化后的逻辑行计算 digest;
  5. 分桶摘要:发现差异后只重扫异常桶;
  6. 业务不变量:外键孤儿、金额边界、状态机、账务守恒;
  7. 代表性业务查询:从应用可见结果验证语义与性能。

本章实验把一张表的 logical manifest 表示为:

row_count
ordered row digest
numeric sum where applicable
status histogram where applicable
invariant violations

源端和目标端都用同一组显式列与规范化规则生成它,而不是比较 heap 文件或物理 WAL。 初始复制的正式证据为:

customers = 5,000
orders    = 20,000
两张表 pg_subscription_rel 状态均为 r
源、目标 logical manifest 完全相同

后续又同步 500 个 insert、200 个 update 和 100 个 delete,等 marker 被目标确认后再次 比较,manifest 仍完全相同。

摘要必须先定义规范化

下面这种拼接并不可靠:

md5(string_agg(a || '|' || b, '' ORDER BY id))

因为 NULL、分隔符转义、浮点格式、timestamp 时区、JSON key 顺序、collation 和编码 都可能制造歧义。更安全的合同至少明确:

columns: [order_id, customer_id, status, amount, updated_at]
order_by: [order_id]
null_token: "\\N"
text_encoding: UTF-8
numeric_scale: 2
timestamp_zone: UTC
timestamp_precision: microseconds
json_canonicalization: sorted-keys
row_framing: length-prefixed
digest: sha256

摘要算法不是安全认证;它是高概率发现迁移差异的工程手段。关键业务金额还应比较精确 聚合和业务不变量,不能只依赖 hash。

分桶让差异可定位

以不可变主键把行分成固定数量的桶:

bucket = stable_hash(primary_key) mod 16

每个桶分别记录行数和摘要。正式实验在目标端只改动 order_id = 1,16 个桶中只有 bucket 1 不一致;从源权威行修复后,不一致桶集合回到空。这比发现全表摘要不同后重新 传输整张表更适合持续 reconciliation。

生产中可递归细分:

table mismatch
  -> bucket mismatch
      -> primary-key range mismatch
          -> row-level diff
              -> approved repair

修复操作也要写 ledger:源权威端、主键、修复前后摘要、执行者、ticket、commit time 和复核结果。不要让“校验工具”直接静默覆盖目标。

校验也会与写入竞态

如果源端仍在写,先扫源、再扫目标,结果可能来自不同逻辑时点。可选方案包括:

  • 在同一个 exported snapshot 上导出基线;
  • 记录源端 marker LSN,等待目标确认后再比较;
  • 对持续校验连续运行两轮,只升级稳定重复的差异;
  • 按业务 updated_at 水位排除仍在变化的尾部;
  • 在冻结窗口内做最终强校验。

“这次比较相等”必须附带比较边界。否则它只能证明两个扫描偶然读到了相同结果。

29.3.3 装载速度不能牺牲可追溯错误

PostgreSQL 18 的 COPY FROM 可以对文本或 CSV 输入使用:

COPY migration_stage.orders_raw
FROM STDIN
WITH (
  FORMAT csv,
  HEADER true,
  ON_ERROR ignore,
  REJECT_LIMIT 100,
  LOG_VERBOSITY verbose
);

这给“少量脏行继续装载”提供了原生工具,但边界很窄:

  • ON_ERROR ignore 只忽略把输入字段转换为目标类型时的错误;
  • constraint、trigger、I/O 等错误不会因此都被吞掉;
  • REJECT_LIMIT 应是显式且很小的错误预算,超过立即失败;
  • verbose 日志可能包含输入值,只能进入受控证据目录;
  • 被忽略的行必须进入后续补录与复核流程,不能只在日志中存在。

一个可追溯 reject 账本至少保存:

run_id: 2026-07-29-shop-orders-01
source_object: s3://migration/orders/part-017.csv
source_sha256: ...
record_locator: line-18342
primary_key_if_known: 923812
error_class: invalid_numeric
raw_record_ref: encrypted://...
decision: pending
repair_version: null
replay_run_id: null

原始敏感行不必进入普通日志;可以保存不可逆摘要和受控对象引用。重要的是能够回答: 这行来自哪里、为什么被拒绝、是否修复、在哪个 run 重放、最终是否进入目标。

staging 比在正式表里猜错更便宜

异构或质量未知的数据优先装入 staging:

raw text columns
  -> parse and classify
      -> quarantine rejects
          -> cast into typed staging
              -> validate business rules
                  -> merge into target

这样 conversion error、业务 error 和目标冲突可以分别统计。正式表上的 transaction 仍应保持全成或全败;批次间可独立提交,但每个批次必须有不可变输入和 idempotent 重放方法。

一次大 COPY 在中途失败并回滚后,已经插入的 tuple 会成为不可见 dead tuples,占用 空间,之后可能需要 VACUUM 回收。把重试理解为“失败就再跑一次”会在有限窗口里放大 I/O 和磁盘压力。应在演练中测量失败批次的空间后果,合理拆批,并为 vacuum 留预算。

本阶段的停止线

满足以下条件,才能从“全量装载”进入“增量追平”或最终校验:

  • 每个输入文件/切片都有 manifest、行数与摘要;
  • 成功行数加拒绝行数与输入记录数守恒;
  • reject 未超过预算,且每一行都有处置状态;
  • 目标对象、精确行数、分桶摘要和业务不变量已输出;
  • deferred index 已创建,constraint 已验证,统计信息已更新;
  • 任一失败批次都能无副作用重放;
  • 校验采用的 snapshot/marker 边界已记录。

吞吐是迁移的约束,不是迁移的正确性定义。一个快到无法解释丢了哪些行的装载流程, 不具备上线资格。


上一节:CDC 与复制槽治理 · 返回本章目录 · 下一节:在线迁移状态机 · 查看全书目录 · 查看索引中心

29.4 在线迁移状态机

在线迁移不是一条命令,而是一个有进入条件、退出证据和失败转移的状态机。把 runbook 写成“先全量,再增量,最后切流”,现场仍会争论:什么叫追平、何时禁止旧库写入、目标 写过以后还能否回退。

本节把这些模糊动词改成可观测状态。每次转移都要求证据;证据不足就留在原状态,不用 截止时间替代正确性判断。

29.4.1 预检查、全量、增量、追平与冻结窗口

先定义状态,再填命令

状态 进入条件 退出证据 失败时动作
PREFLIGHT 迁移合同、owner、窗口已批准 兼容性、容量、权限、网络和回退演练通过 修合同,不创建长寿命 slot
BASELINE snapshot/slot 边界已建立 全量 manifest、reject ledger、对象验证通过 停装载,保留输入与证据后重建目标
STREAMING 基线与增量无缝衔接 subscription/consumer 稳定,目标持续跟随 修 apply/connector,不切流
CATCHUP 进入切换前观察 marker 已在目标可见,lag 和 retained WAL 入预算 降写入、扩容或推迟窗口
FROZEN 写围栏已生效 旧端写测试失败,最终 marker 和强校验通过 解除围栏或转前滚修复
CUTOVER 路由变更已批准 新连接身份正确,目标写 canary 成功 按回退矩阵决策
OBSERVE 目标承接生产流量 SLO、数据、任务、slot、日志持续合格 回退或前滚
EXITED 观察窗口和退出条件满足 迁移签收、资产和凭据收尾完成 不再把旧源当即时回退方案

状态不允许跳转,例如没有 FROZEN -> final validation,不能从仍在双写的 STREAMING 直接宣布 CUTOVER

preflight 要检查迁移语义,不只检查端口

预检查至少覆盖:

source/target system identifier, version, encoding, locale, timezone
schema, types, extensions, functions, collations, generated/identity columns
table size, churn, large transactions, partition topology
primary key and replica identity
RLS, owners, grants, triggers, rules
DDL change policy
sequences and identity allocation
large objects and external object references
publication filters and subscription options
WAL generation, slot retention budget, disk headroom
network throughput, latency, TLS/HBA and credential lifetime
application compatibility, connection pool and prepared statements
backup, restore, rollback and reconciliation procedure

“目标能连通”只证明网络路径存在。比如目标缺少 collation、sequence 没有同步、源表无 replica identity,都可能在增量或切流阶段才暴露。

Pigsty 的迁移任务可以生成环境检查、schema、publication/subscription、进度、差异和 sequence 操作的上下文与脚本;它不能替业务确认 trigger 语义,也不知道应用路由和 外部副作用。生成脚本应进入评审和版本控制,不能把“生成成功”当作“迁移完成”。

“追平”要有业务可见 marker

单看:

SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)
FROM pg_replication_slots
WHERE slot_name = 'pg36_shop_slot';

只能看到 publisher 与 consumer acknowledgement 的位置关系。它不必然证明目标业务 查询已经看见某一笔事务,也不覆盖下游索引、缓存和异步任务。

更可靠的追平协议是:

  1. 在源端业务表或专用控制表提交唯一 migration_marker
  2. 记录该事务的业务 ID、提交时间和附近 LSN;
  3. 等目标端通过普通应用路径读到 marker;
  4. 同时确认 subscription worker、table state、slot、错误统计和 retained WAL 正常;
  5. 在一段稳定窗口中重复,而不是只采一个瞬时零延迟。

正式实验同步完 500 个 insert、200 个 update、100 个 delete 后,等待 marker 在目标端 可见,再比较两端 manifest。这个证据比“延迟图降到 0”更接近切换要求。

冻结窗口要证明旧写入真的失败

冻结不等于在群里发一句“请勿写库”。写围栏可以来自:

  • 撤销专用 runtime role 的 DML 权限;
  • 将旧端业务入口切换为只读;
  • 应用 feature flag 阻止写请求;
  • 停止 scheduler、ETL、CDC 回写和运维脚本;
  • 对无法配合的 writer 建立数据库级拒绝规则。

然后用旧应用凭据执行负向 canary,确认 INSERT/UPDATE/DELETE 失败,同时需要的 SELECT 仍可用于核对。表 owner、superuser、SECURITY DEFINER 函数和绕过业务入口 的后台任务必须单独盘点;仅 revoke 普通角色无法约束这些路径。

29.4.2 影子读、双读、切流和观察

影子读与双读解决的是“应用是否认同”

数据库摘要相同,不代表应用行为相同。目标端可能因为 collation、timezone、扩展版本、 查询计划或 session 参数给出不同结果。切流前可以逐级放量:

方法 主结果来自 目标端副作用 适合发现
离线 replay 源端录制流量 禁止 SQL/类型/性能不兼容
影子读 源端 严格禁止 结果、错误码、延迟差异
采样双读 源端,后台比较目标 只读 长尾和真实参数差异
小比例真实读 目标端 应用正常读副作用需评估 连接池、缓存、SLO

“读”也可能有副作用:SELECT nextval(...)、advisory lock、临时表、审计函数、缓存 填充、SELECT ... FOR UPDATE 都不适合直接镜像。影子层必须有 SQL allowlist、超时、 并发限制和结果脱敏;不能为验证目标把源端峰值流量翻倍。

结果比较应按业务语义规范化:

unordered result -> sort by declared key
timestamp -> normalize to UTC and agreed precision
numeric -> agreed scale and rounding
JSON -> canonical form
expected nondeterminism -> exclude or compare distribution

同时比较错误类别、行数、关键字段、P50/P95/P99 与资源消耗。只比较 HTTP 200 会漏掉 返回空集、排序变化和悄悄截断。

切流要拆开连接地址与数据权威

应用可能经过:

DNS
  -> VIP / load balancer
      -> HAProxy service
          -> PgBouncer
              -> PostgreSQL primary

迁移 runbook 必须指出实际控制点及缓存时间。改变 DNS 不会自动清掉旧 PgBouncer 连接;改变 HAProxy backend 也不会让应用已持有的 session 消失。切换步骤通常包括:

  1. 记录旧 route generation、目标 endpoint 和回退 endpoint;
  2. 降低或确认 TTL,准备 health check;
  3. 在源端建立写围栏并做最终 marker/校验;
  4. 刷新 sequence,保证目标下一个值高于已迁移最大值;
  5. 修改唯一的权威路由控制点;
  6. drain/重建旧池,拒绝新连接进入源端写服务;
  7. 新连接查询 system_identifier、database、server address 与只读状态;
  8. 执行目标 canary 并从应用层回读;
  9. 进入限时观察,不立即拆源。

本章实验刻意不修改真实 Pigsty 路由,只在私有证据中模拟 source -> target -> source。这证明状态机与回退逻辑,不声称实验真的改过平台入口。 生产切流必须由应用或网络 owner 执行并给出实际路由证据。

观察窗口看四层信号

关键证据
应用 成功率、错误分类、业务转化、队列积压、关键任务
连接/路由 新旧连接数、endpoint 身份、池等待、事务/会话模式
PostgreSQL TPS、延迟、锁、WAL、checkpoint、autovacuum、复制/订阅错误
数据 canary、分桶摘要、业务不变量、target-only writes、reconciliation

应预先写出阈值和观察时间,例如“连续 60 分钟错误率不高于基线 + 0.1%,关键不变量 为零,异常桶为零”。现场再决定“看起来还行”无法形成一致决策。

29.4.3 回退点、前滚点与不可逆动作

回退不是把连接串改回去

切流前,源是唯一 writer,回退通常只是解除源围栏并放弃目标。目标开始承接写入后, 状态发生根本变化:

source last state S
target receives new writes T1..Tn
route points back to source

若没有 reverse CDC 或显式 reconciliation,源端不知道 T1..Tn,直接回路由会造成 已成功请求消失。应在切换前确定矩阵:

所在阶段 目标端是否有独占写 默认策略
baseline / streaming / frozen 安全取消,恢复源写
cutover,canary 尚未产生业务事实 快速回退
cutover,只有可识别 canary 是,可枚举 对账并回放 canary 后回退
observe,已有普通业务写 reverse sync/reconciliation,或前滚修复
已执行破坏性 schema/源退役 是且难逆 按灾难恢复或专门回迁方案处理

正式实验在目标写入唯一 canary,得到 sequence 值 900001。模拟回退前,证据包先识别 并把这条目标独占数据对账到源,再在源写入 rollback canary,最终两端 manifest 相同。 这个演示只覆盖“可枚举的一条目标写”,不能推导出任意生产写流都可这样回退。

sequence 是典型的切换缝隙

逻辑复制不复制 sequence 当前值。正式实验切流前看到目标 sequence 当前值仍为 1, 而源端最大 order_id 已到 900000。流程显式执行 setval,目标 canary 才得到 900001

sequence 处理要考虑:

  • is_called 语义;
  • identity column 背后的实际 sequence;
  • 多 writer 是否预留不重叠区间;
  • cached values 与仍存活的旧连接;
  • 目标独占值回退后会否冲突;
  • gap 是否被业务错误地当成连续性失败。

标出不可逆动作

以下动作不应与普通切流混在一个“一键脚本”:

删除 source cluster/database
删除 backup 或缩短 WAL 保留
drop publication/slot before exit criteria
执行 target-only destructive DDL
重新使用旧 sequence 区间
轮换后销毁唯一可用的旧凭据
让外部系统产生不可补偿副作用

对 schema 使用 expand/contract:先部署双方都理解的扩展形态,完成迁移与观察,再在独立 变更中收缩旧字段。这样问题出现时可以前滚修复,而不是在数据迁移、应用发布、破坏性 DDL 三者同时发生时赌一个总回退按钮。

每次决策至少记录:

state: OBSERVE
route_generation: 42
source_fence: active
target_only_write_boundary: marker-20260729-17
rollback_possible: conditional
required_reconciliation: target-writes-after-marker
decision_owner: migration-commander
evidence_bundle: /secure/migrations/run-...
next_decision_deadline: ...

回退能力是一项需要实验证明的属性。未演练、未定义数据合并边界的“随时可回退”,只是 一句安慰。


上一节:批量装载与数据校验 · 返回本章目录 · 下一节:异构同步的语义损失 · 查看全书目录 · 查看索引中心

29.5 异构同步的语义损失

异构同步可以让目标端“有数据”,却无法自动保证两边表达的是同一件事。connector 显示 running、offset 持续推进、目标查询也返回 200,只说明管道在工作;类型舍入、 排序规则、事务边界和删除语义仍可能已经变化。

本节给出一套语义合同。它不仅适用于 PostgreSQL 到 MySQL、Kafka、Elasticsearch 或 数据仓库,也适用于两个配置、扩展与 locale 不同的 PostgreSQL 环境。

29.5.1 类型、精度、排序规则与时区

类型映射必须是一张可测试的合同

不能只写:

numeric -> decimal
timestamp -> timestamp
jsonb -> json

至少要写清:

源语义 目标映射需要回答
numeric(p,s) 最大精度、scale、舍入模式、溢出是失败还是截断
bigint / unsigned integer 目标上下界,超界行如何隔离
real / double precision NaN、正负无穷、负零、比较语义
char(n) / text 尾随空格、Unicode normalization、空串与 NULL
timestamp without time zone 它代表本地墙上时间还是业务约定 UTC
timestamp with time zone 输出 zone、精度、DST 重叠/缺口
jsonb key 顺序、重复 key、numeric 精度、缺失与 JSON null
UUID / enum 原生类型还是 text,非法值和新增 enum label
array / range / multirange 展开、序列化还是目标原生类型
bytea 编码、大小上限、二进制是否被误当字符串

应为每一种映射准备 boundary corpus,而不是只测正常样本:

min/max
刚好超界
0 / -0
小数临界舍入
NULL / empty
非 ASCII 与组合字符
DST 切换前后
闰日
超长值
NaN / Infinity where supported

迁移前后都用同一个 canonical encoder 输出,比较规范化值和预期错误类别。若业务决定 允许损失,例如金额从 4 位小数舍入到 2 位,必须记录舍入规则、受影响行数、总误差和 批准人;不能让驱动默认转换替团队做决定。

时区问题常被样本掩盖

PostgreSQL 的 timestamptz 保存一个绝对时间点,显示受 session TimeZone 影响; timestamp 不含时区。把前者格式化为本地字符串再写进后者,会永久丢掉 offset。

合同应明确:

source_type: timestamptz
wire_form: RFC3339 with numeric offset
canonical_zone: UTC
precision: microseconds
target_type: timestamp(6) with time zone
ambiguous_local_time_policy: reject

还要验证 connector、JDBC/driver 与 sink session 的时区,而不只比较 database 参数。 夏令时地区的 02:30 可能不存在,01:30 可能出现两次;用七月的一条 UTC 样本无法 覆盖这些边界。

collation 会改变“同样查询”的结果

字符值逐字节相同,也可能因 libc/ICU/provider/version 不同而产生:

  • ORDER BY 顺序变化;
  • case/accent insensitive 比较变化;
  • UNIQUE index 对“相等”的判断不同;
  • prefix/range query 命中集合不同;
  • 分页边界漂移。

迁移 inventory 应记录数据库和列级 collation/provider/version,并在目标查询 pg_collation 与实际索引定义。若应用依赖稳定顺序,应在 SQL 中给出完整 tie-breaker, 例如 ORDER BY display_name COLLATE ..., customer_id;只靠隐含排序,本来就没有 跨环境保证。

本章正式实验使用 PostgreSQL 18.6 到 PostgreSQL 18.6,且两端都由同一 Pigsty 实验环境管理。它能证明同构 PG 逻辑复制与校验流程,不能证明上述异构类型和 collation 合同。异构结论必须在真实 source/sink 组合上另做边界语料实验。

29.5.2 约束、事务顺序与删除语义

源端约束不会自动变成下游约束

源端可以依赖:

PRIMARY KEY / UNIQUE
FOREIGN KEY
CHECK
EXCLUDE
domain constraint
trigger-maintained invariant
transaction isolation
deferred constraint

消息流通常只携带行变化,不携带这些证明。目标是搜索索引或对象存储时,甚至没有对应的 约束机制。于是“source 每次提交都合法”不能推出“sink 任意时刻都合法”。

例如源事务先创建 customer 再创建 order。若 connector 按 table 分 topic,下游并行 消费,order 可能先可见。解决方式不是祈祷消费者够快,而是明确:

  • 是否保留 source transaction ID 和 commit boundary;
  • 跨表事件是否要求原子可见;
  • 不要求原子时,查询层如何隐藏未完成 batch;
  • parent 缺失是重试、暂存、告警还是丢弃;
  • checkpoint 在整个事务之后还是每条事件之后推进。

事务内 row order 也不能随意打散。账户扣款、入账和 ledger 三条事件若被三个 worker 独立提交,中间态会破坏守恒。高吞吐设计必须说明它牺牲了什么可见性,以及如何恢复。

upsert 需要版本,delete 需要墓碑

一个简单的:

INSERT ... ON CONFLICT DO UPDATE

只保证当前语句不因 key 冲突失败,不保证旧事件不会覆盖新状态。目标记录通常需要 source version/commit position,并采用条件更新。

删除则至少有四种不同语义:

源动作 下游可能需要
physical DELETE key tombstone,删除投影
soft delete 保留记录并同步 deleted_at
FK cascade 每个子变化或可重建的级联合同
TRUNCATE 清空整个 collection,或明示不支持并触发重建

若 sink 先收到 DELETE,随后重放一条旧 UPDATE,没有 version/tombstone ledger 就会把 已删除对象复活。tombstone 的保留时间必须长于最大 replay/backfill 窗口;过早压缩会 重新暴露复活风险。

PostgreSQL publication 可以发布 TRUNCATE,但 row filter 不会过滤它。外部 CDC connector 是否把它转成一个控制事件、逐行 delete 还是直接不支持,要在上线前实测。

backfill 与实时流必须共享所有权规则

backfill 可能比实时事件更晚到:

snapshot contains version 7
stream has already applied version 9
backfill blindly upserts version 7

目标就回到了旧状态。每个写入路径都必须服从同一条条件:

apply only if incoming source version is newer
or if this batch is the declared authoritative rebuild

重建期间可以使用新的目标 namespace/index/table,完成校验后原子交换;不要让不带 version 的历史 backfill 与实时流争写同一记录。

29.5.3 目标端可查询不等于语义等价

绿灯只能证明它声明的那一层

绿灯 能证明 不能证明
connector running 进程存活并执行主循环 没有跳过 poison event
offset advancing 一些事件被确认 sink 副作用完整、顺序正确
target row count 相等 总行数一致 行内容、关联和删除一致
target query 成功 语法和服务可用 排序、精度、完整性等价
lag 接近零 消费接近 source head 历史基线正确

异构验收应沿一条更强的梯子:

transport alive
  -> no unaccounted rejects
      -> schema/type contract passes boundary corpus
          -> row and bucket manifests agree
              -> business invariants agree
                  -> representative queries agree
                      -> workload SLO agrees
                          -> reconciliation remains stable over time

代表性查询不是随机挑十条 SELECT *,而应从业务清单中覆盖:

  • equality、range、prefix、全文与排序;
  • NULL、缺失字段、数组/JSON 嵌套;
  • pagination 和 tie-breaker;
  • 聚合、去重、金额与时区窗口;
  • 删除、恢复、乱序和重复事件;
  • 最大对象、热点 key 与大事务;
  • 权限过滤和租户隔离。

每条都定义允许差异。例如搜索结果可能允许排名小幅变化,但不能跨租户;报表金额必须 精确相同;分析仓库允许 10 分钟最终一致,但 reconciliation 不允许永久缺口。

建立“允许损失登记表”

异构系统很少完全同构,现实做法不是假装零损失,而是让损失显式:

field: customer.display_name
difference: ICU collation produces different tie order
affected_queries: customer-search
business_impact: none when customer_id is secondary key
mitigation: append customer_id to ORDER BY
validation: query-corpus/collation-03
owner: customer-platform
approved_until: permanent

没有登记的差异一律视为 defect;登记项也要有 owner、验证和复审条件。这个机制防止 “已知差异”在口头交接中无限扩张。

权威源和修复方向必须唯一

持续 reconciliation 发现不一致时,先回答:

在当前阶段谁是 source of truth?
差异来自漏事件、重复、乱序、手工写还是映射改变?
修复目标会不会被下一条旧事件再次覆盖?
修复需要 rewind、rebootstrap 还是单 key replay?
该修复怎样留下 provenance?

切流前通常以源端为权威;切流后目标已承接新写,不能继续无条件“用源覆盖目标”。 权威边界随迁移状态改变,必须随状态机一同记录。

本章的目标端 drift 实验很能说明这一点:目标端手工修改 order_id = 1 后,subscription 仍是 running,目标查询也正常,但只有 bucket 1 的摘要暴露了差异。因为当时仍处于 切流前阶段,流程才能用源端权威行修复。若那是一笔切流后的合法目标写,相同动作反而 会销毁正确数据。

所以,数据“到了”是传输结论;业务“等价”是由类型合同、事务合同、校验语料和持续 对账共同支持的结论。两者不能用同一个绿色图标代替。


上一节:在线迁移状态机 · 返回本章目录 · 下一节:多集群迁移环境 · 查看全书目录 · 查看索引中心

29.6 多集群迁移环境

Pigsty 能把两个 PostgreSQL 集群、服务发现、连接池、监控和配置组织在同一个管理面里, 但迁移的数据面仍跨越两个独立系统。最危险的自动化错误,往往不是 SQL 写错,而是把 命令发到了正确环境中的错误 cluster、database 或 service。

本节把端点身份、凭据、观测和退出窗口做成多集群迁移的硬边界。

29.6.1 源、目标、验证端点与隔离凭据

给每类动作一个明确端点

端点 连接对象 允许动作 不应承担
source migration 源 primary/direct-write service 建 publication/slot、读快照、写 marker 普通应用写
source runtime 源业务 service 迁移前业务读写,冻结后负向 canary 管理 slot
target migration 目标 primary/direct-write service 建 schema/subscription、装载、sequence 源端管理
target runtime 目标业务 service 影子读、切流后业务读写 超级用户迁移 DDL
verification 两端只读连接 manifest、目录、业务不变量 修复或路由变更
monitoring exporter/API/dashboard 采集两端与代理指标 作为业务正确性的唯一证据

源 logical replication 连接必须到能够创建 slot、持续发送 WAL 的 primary。目标 subscription 在目标 database 中创建,apply 写目标表。不要把“read service”名称当作 复制端点,也不要把 HA service 的自动切换能力误认为 slot 已具备 failover 语义。

每次 run 先打印并保存非秘密身份:

SELECT current_database(),
       current_user,
       inet_server_addr(),
       inet_server_port(),
       current_setting('server_version'),
       pg_is_in_recovery();

SELECT system_identifier
FROM pg_control_system();

并断言 source 与 target system_identifier 不同。database 名称相同不等于同一个 数据源,IP 不同也不保证不是同一 cluster 的两个实例。

凭据按职责和环境隔离

至少拆分:

source runtime
target runtime
replication/login
migration DDL
verification read-only
monitoring

replication 角色需要源端 LOGIN REPLICATION,initial copy 还需要 published table 的 SELECT;它不需要目标端 DDL。目标 runtime 不应能回写源。验证角色不应因为要比较 数据而获得修复权限。

连接合同还包括:

  • TLS mode、CA 与 hostname verification;
  • 精确 HBA source CIDR 和 role classification;
  • CONNECT、schema USAGE、table privilege;
  • secret manager 引用、有效期和轮换 owner;
  • conninfo 禁止出现在公开证据、进程参数或 shell trace;
  • 迁移完成后的 revoke/rotate 清单。

本章第一次候选实验在插入 fixture 之前就被 Pigsty HBA 拒绝:临时登录角色没有加入 HBA 所使用的平台分类角色。实验没有放宽成全网 trust,而是把 runtime 与 replication 账号分别加入 dbrole_readwrite / dbrole_readonly,并使用 INHERIT FALSE, SET FALSE,只满足成员分类,不继承平台对象权限。失败候选随后按 marker 精确清理,确认两端 database、role、slot 与 subscription 都不存在。

这条失败很有价值:HBA 的“能否建立连接”和 SQL grant 的“连上后能做什么”是两道 独立门。

Pigsty 迁移上下文是编排模板,不是控制平面替身

Pigsty 的 pgsql-migration.yml 可以围绕 source/target inventory 生成并组织:

check-user / check-db / check-hba / check-repl / check-misc
copy-schema
create-pub / create-sub
copy-progress / copy-diff
copy-seq

以及需要 operator 落实的 source disable 和 re-routing 步骤。使用时应:

  1. 固定 Pigsty/模板版本;
  2. 审阅生成的变量、端点和 SQL;
  3. 把生成物与迁移 ticket 绑定;
  4. 在 disposable database 完整演练;
  5. 由实际应用/网络 owner 实施写围栏与切流;
  6. 用 PostgreSQL catalog 和应用身份反向复核。

本章正式实验在本地开发环境以 pg-test 为源、pg-meta 为目标,各自创建一次性 database 和角色。这只是为了在有限实验环境里证明真正的双 cluster 边界。生产中不应 因为教程这样做,就把承担 Pigsty 管理面的 pg-meta 当成默认业务迁移目标。

29.6.2 观察槽、WAL、延迟与切换流量

先用原生视图定义信号

源端:

SELECT slot_name,
       database,
       active,
       active_pid,
       restart_lsn,
       confirmed_flush_lsn,
       pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_bytes,
       wal_status,
       safe_wal_size,
       inactive_since,
       invalidation_reason,
       failover,
       synced
FROM pg_replication_slots
WHERE slot_name = 'pg36_shop_slot';

目标端:

SELECT subname,
       worker_type,
       pid,
       received_lsn,
       latest_end_lsn,
       last_msg_send_time,
       last_msg_receipt_time,
       latest_end_time
FROM pg_stat_subscription
WHERE subname = 'pg36_shop_sub';

SELECT *
FROM pg_stat_subscription_stats
WHERE subname = 'pg36_shop_sub';

再加上 pg_subscription_rel table state、PostgreSQL log 和业务 marker。不同版本的 视图列会变化,自动化应先断言 server major,而不是对未知列 SELECT * 后按位置解析。

关键关系是:

consumer stopped
  -> confirmed position stops
      -> restart_lsn cannot advance
          -> retained WAL grows
              -> disk pressure or slot invalidation

正式实验禁用 subscription 后确认 slot inactive,生成 3,000 条变化:

retained WAL: 227,008 -> 2,867,128 bytes
confirmed_flush_lsn: unchanged during stall

重新启用后才追平。这个小规模实验不能给生产容量一个固定阈值,却证明“subscriber 不工作”会转化为 publisher 的磁盘风险。

max_slot_wal_keep_size 可以限制 checkpoint 时 slot 允许保留的 WAL;超过后 slot 可能失去继续消费所需的 WAL,而不是自动帮 consumer 修好。PostgreSQL 18 的 idle_replication_slot_timeout 可以在 checkpoint 时使长期闲置 slot 失效,synced slot 有例外。二者都是保险丝,不能代替 owner、告警、rebootstrap 方案和容量预算。

Pigsty 视图负责聚合,catalog 负责定案

Pigsty 的 PGSQL、Replication、Persist、Service 与 Proxy 等 dashboard 可以统一观察:

source WAL and replication
target TPS/latency/locks
disk and checkpoint
service health and connection distribution
pool/proxy sessions

dashboard 适合关联趋势和告警;切流门禁仍应把关键 catalog query、时间范围和截图/导出 写入证据包。监控抓取间隔可能错过瞬时状态,面板也不会知道 marker 是否已被应用查询 读到。

logical slot failover 需要一整组前提

PostgreSQL 18 支持 failover logical slot,但不能只设置 failover = true 就宣称源端 primary 可无缝切换。完整设计还涉及:

  • primary 上 slot 标记为 failover;
  • standby 启用 sync_replication_slots
  • standby 配置 primary_slot_name 和可用的 physical replication slot;
  • primary_conninfo 包含可连接到正确 database 的配置;
  • physical standby 向上游提供所需 feedback;
  • 对需要的同步边界使用 synchronized_standby_slots
  • 切换前确认目标 standby 上对应 slot 已 synced 且位置安全;
  • connector/subscriber 重新连接后的 endpoint 与 timeline 测试。

这些参数影响可用性、WAL 保留和复制反馈,必须在与生产同构的故障切换演练中验证。 未做这套设计时,源 primary failover 可能要求重新 bootstrap;迁移窗口要把它列为明确 故障分支。

路由观测与数据复制是两条链

publication/subscription 不知道业务连接走 DNS、HAProxy、PgBouncer 还是配置中心。 切流证据至少包括:

route control plane generation
resolved endpoint before/after
new connection system identifier
old/new pool active sessions
source runtime DML denial
target runtime canary
rollback endpoint and drain status

监控图中的目标 TPS 上升只是旁证。最强证据是使用真实 runtime 凭据建立一个新连接, 验证目标系统身份并完成可回读 canary。

29.6.3 保留源环境直到退出观察窗口

保留不是继续双主写入

切流后的源环境应进入受保护状态:

runtime writes fenced
normal reads limited to verification
backup and required WAL retained
schema changes frozen
credentials and network path controlled
monitoring remains active
no scheduled job silently resumes writes

这样它既能支持调查和条件回退,又不会继续产生与目标分叉的新事实。若业务需要 reverse replication,应把它作为独立的数据合同:方向、冲突、sequence、DDL、RPO 和停止点都要 明确,不能把两个方向各建一个 subscription 就叫 active-active。

退出窗口用条件,不只用日期

观察窗口可以有最短时长,但退出还应同时满足:

  • 所有应用、worker、ETL、BI 和管理入口已迁到目标;
  • 旧 service/pool 不再产生业务连接;
  • 目标 SLO 经历了至少一个代表性高峰和关键批任务;
  • manifest、分桶和业务不变量连续通过;
  • 目标独占写边界与备份恢复已验证;
  • 告警、值班、容量和灾备 runbook 已移交;
  • 迁移临时 slot/subscription/reject 已有保留或清理决策;
  • source 回退 SLA 已到期,并有 owner 签收;
  • 资产、CMDB、DNS、secret 与文档已更新。

如果月末关账是系统最关键路径,只观察一个低峰小时即使所有图都绿色,也没有覆盖真实 业务周期。

清理按依赖方向进行

正常 subscription 与远端 slot 仍关联且源端可达时,DROP SUBSCRIPTION 会尝试删除 远端 slot。若先断网络或删除源 database,目标端 drop 可能失败,源端还可能留下孤儿 slot。退出流程应:

  1. 记录 subscription、publication、slot、owner 和最终位置;
  2. 停止/确认 consumer 不再需要;
  3. 在两端都可达时正常删除 subscription;
  4. 在源端确认 main slot 与 table-sync slot 均不存在;
  5. 再删除 publication 和迁移临时角色/权限;
  6. 轮换生产凭据;
  7. 将 source decommission 作为独立、可审计且明确不可逆的变更。

不要使用“删除所有 inactive slot”“终止所有连接”或 DROP DATABASE ... WITH (FORCE) 作为通用收尾。正式实验的清理只匹配本 run 创建的 database、role、subscription 和 slot;没有终止无关 session,普通 database drop 即成功。

保留窗口结束后,旧源也不应无限期成为影子生产系统。无限保留会继续消耗备份、补丁、 监控与安全治理成本,还让团队误以为随时能回到一个早已过期的数据副本。退出签收就是 把“临时可回退”正式转化为“目标为唯一权威,恢复走新体系”。


上一节:异构同步的语义损失 · 返回本章目录 · 下一节:实战:迁移 pg36_shop · 查看全书目录 · 查看索引中心

29.7 实战:迁移 `pg36_shop`

前六节建立了 logical replication、CDC、全量校验、迁移状态机、异构语义与多集群边界。 本节把它们压缩到一个可重复的 Pigsty 双集群实验:

pg-test / pg36_shop_src
  -> publication + logical slot
      -> pg-meta / pg36_shop_dst
          -> subscription

实验不是“看见几行复制过去”就结束,而要主动制造 consumer 停滞、显式 apply conflict 和不会报错的 silent drift,再完成 sequence 校准、写围栏、模拟切流、条件回退与精确 清理。

29.7.1 完成全量加增量同步

先读边界,再运行脚本

本章只允许在已确认的 Pigsty 四节点开发沙箱执行:

source       pg-test / PostgreSQL 18
target       pg-meta / PostgreSQL 18
data         deterministic synthetic fixture
capture      L0 read-only preflight
exercise     L2 bounded two-cluster disposable fixture
production   forbidden
real route   never changed

完整合同在 lab-contract.md,机器可读的环境、对象、数量和验收条件在 requirements.json,迁移状态与允许动作在 migration-contract.json,拓扑图在 topology.mmd

实验只创建以下固定名称对象,并用随机 run_id marker 证明所有权:

source database       pg36_shop_src
target database       pg36_shop_dst
publication           pg36_shop_pub
slot                  pg36_shop_slot
subscription          pg36_shop_sub
five exact fixture roles

它明确禁止读取现有业务表、修改 Pigsty inventory、修改 Patroni/持久参数、改变真实 HAProxy/PgBouncer/DNS/VIP、终止无关连接和 force-drop。marker、对象名或连接范围有一项 不匹配,runner 都失败关闭。

先做纯静态合同检查:

static/labs/ch29/task.sh lint

完整实验会创建和删除两个一次性数据库,必须在已确认沙箱中指定一个新的私有证据目录:

export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch29.XXXXXX"
)"

static/labs/ch29/task.sh all

若希望分步审阅:

static/labs/ch29/task.sh capture
static/labs/ch29/task.sh exercise
static/labs/ch29/task.sh verify
static/labs/ch29/task.sh review

capture 在任何写入前检查:

  • 两端 service、cluster、PostgreSQL major、primary 身份;
  • 两个不同的 system identifier;
  • source wal_level = logical
  • 目标 database、role、slot 和 subscription 起点不存在;
  • 第 19、23、25、28 章上游证据存在且环境边界一致;
  • 实验源文件散列与随后执行的版本一致;
  • 远端临时目录和证据目录满足私有权限。

创建 schema 与逻辑复制对象

夹具包含:

shop.customers     5,000 rows
shop.orders       20,000 rows

两表都有稳定主键,orders.customer_id 引用 customer;状态、金额和更新时间具有固定 业务约束。runner 在源端创建 publication 和 logical slot,在目标端创建相同 schema 与 subscription,然后等待 pg_subscription_rel 中两张表都从同步状态进入 r

正式参考 run 证明:

source system id  7668025967696967004
target system id  7668025945980641675
customers         5,000
orders            20,000
tables ready      2
logical manifest  equal

system identifier 是本次沙箱证据,不是读者环境中的预期常量。验收的是“两端不同且分别 绑定已声明 cluster”,不是数字本身。

让初始快照与持续变更汇合

初始复制完成后,源端执行:

INSERT    500 orders
UPDATE    200 orders
DELETE    100 orders
COMMIT    one unique migration marker

流程等待目标读取 marker,再比较两端精确行数、有序摘要、金额合计、状态分布和业务 不变量。参考 run 的结果为:

inserted / updated / deleted  500 / 200 / 100
marker acknowledged           true
logical manifest equal        true

这同时证明了 initial copy 与增量可以汇合,以及验证是在声明的 marker 边界之后执行。 它不证明生产大表所需时间,也不覆盖迁移期间的 DDL;后者仍需独立编排。

连接失败也是 preflight 结果

开发中的第一次候选 run 在 fixture 写入前被 HBA 拒绝,因为临时角色未匹配 Pigsty 的 group-role 分类。流程没有临时放宽认证,而是:

  1. 停止实验;
  2. 按 marker 清理两个 database、五个角色、slot/subscription;
  3. 证明所有临时对象不存在;
  4. INHERIT FALSE, SET FALSE 的成员关系满足 HBA 分类;
  5. 重新从新的 run 和空证据目录开始。

生产演练也应如此:preflight 失败说明合同不成立,不能在原 run 上一边改权限一边继续, 否则最终证据无法说明实际执行了哪套安全边界。

29.7.2 注入消费者停滞与数据差异

反例一:consumer 停了,风险留在 source

runner 精确禁用 pg36_shop_sub,确认源端 pg36_shop_slot inactive,然后在源夹具中 生成固定 3,000 行变化。参考结果:

confirmed_flush_lsn unchanged      true
retained WAL before                227,008 bytes
retained WAL after               2,867,128 bytes
retained WAL grew                  true
caught up after re-enable          true

禁用动作只匹配本 run 的 subscription;负载行数固定,不改变 max_slot_wal_keep_size,也不制造无限 WAL。数字取决于 tuple、full-page image、 checkpoint 和版本,教学结论是方向:

consumer 不确认 -> slot 不能推进 -> source retained WAL 增长

重新启用并追平后必须再次校验 manifest,不能因 worker 恢复 running 就进入下一阶段。

反例二:显式冲突会停 apply

实验先在目标端插入:

order_id = 900000, target payload

再在源端提交同一 key、不同值。目标 apply 命中唯一键,PostgreSQL 18 的 subscription 统计出现 insert_exists

confl_insert_exists  0 -> 1
apply_error_count    0 -> 1

此时 slot 仍可能存在,subscription 也仍是一个 catalog 对象,但 apply 已无法越过 冲突事务。修复流程必须先证明:

冲突表与主键
源端权威值
目标端冲突值
错误计数和日志时间
允许采取的修复方向

实验删除精确的目标冲突 fixture 行,让源事务重放,再等待 marker 与 manifest 收敛。 生产环境不能把“删除目标所有冲突行后重试”写成通用脚本;不同冲突可能代表合法的 target-only write。

反例三:静默漂移不会停 apply

runner 在目标端直接修改 order_id = 1。该行之后没有新的源变化,因此:

subscription remains healthy
no apply conflict is raised
target query succeeds

但 16 个稳定 hash bucket 中,只有 bucket 1 的行数/摘要不一致。实验处于切流前, 合同指定 source 为权威,因而按主键读取源行、记录修复前后摘要、修复目标并重跑所有 分桶,结果:

mismatched buckets before  [1]
mismatched buckets after   []

这组反例区分了两种故障:

故障 apply 是否报错 主要发现方式
唯一键/缺行等显式 conflict 通常会 worker/log/pg_stat_subscription_stats
目标手工写、错误 backfill 等 silent drift 不一定 持续 manifest、分桶和业务不变量

运行状态与数据等价必须分别验收。

29.7.3 验证、切流、回退并输出迁移证据包

切流前修正 sequence 并建立写围栏

逻辑复制已经把 order_id = 900000 复制到目标,但 sequence 本身没有随 DML 推进:

target sequence before  1
source max order_id      900000

runner 按源端最大 ID 校准目标 sequence。随后目标 runtime canary 得到 900001,证明 不会立刻与已迁移主键碰撞。

源端则撤销 runtime 的 DML 能力,用同一凭据实际发起 INSERT

SQLSTATE       42501
INSERT         denied
UPDATE         denied
DELETE         denied
SELECT         retained

“执行过 revoke”不是证据,旧凭据的负向操作才是。生产还要盘点 owner、 SECURITY DEFINER、scheduler 和其他 writer。

只模拟路由,不碰真实平台

私有 route-history.json 记录:

source -> target -> source

它只是一台状态机的模拟输入。runner 会检查:

Pigsty inventory unchanged
Patroni configuration unchanged
real HAProxy/PgBouncer/DNS/VIP unchanged
actual_platform_route_changed = false

在模拟 target 阶段写入一条可识别 canary。回退前把这 1 条目标独占数据显式对账回源, 再切回 source 并写 rollback canary。最终参考结果:

customers                      5,000
orders                        23,402
logical manifest equal          true
orphan orders                      0
negative amounts                   0
invalid statuses                   0
source retained through rollback   true

这里证明的是“在目标独占写可枚举时,条件回退协议可执行”。真实业务流量已经写入目标后, 是否回退仍取决于 reverse sync、对账能力和不可补偿副作用。

证据包必须能反驳伪成功

私有证据目录包含:

preflight-evidence.json
remote/migration-evidence.json
remote/route-history.json
remote-cleanup.json
negative-report.json
validation-report.json
public-summary.json
review.txt
source file hashes

review 会检查证据权限、schema、交叉字段、源文件 hash、私密信息与 public/private 边界。validator 不只验证成功样本,还要求:

29 declared counterexamples rejected
19 live evidence mutants rejected
11 source files hash-bound

也就是说,篡改 system identifier、初始行数、marker、retained WAL、conflict counter、 bucket repair、sequence、写围栏、路由边界或清理结论,都不能继续得到 pass。公开参考 摘要在 migration-run.json;它不含密码、conninfo、主机密钥 或原始行。

完成实验后可以对同一私有证据包重复审计:

static/labs/ch29/task.sh verify
static/labs/ch29/task.sh review

但不能把另一个 run 的证据目录与当前源文件拼接使用。

清理也是验收阶段

正常删除目标 subscription 时,PostgreSQL 同时删除远端 main slot。runner 随后分别在 两端确认:

source database absent
target database absent
all fixture roles absent
source slot absent
target subscription absent
ordinary DROP used
force drop used = false
unrelated sessions terminated = 0
remote temp absent

任何一项不成立,实验都不算完成。尤其不能为了让 CI 变绿而终止所有连接或删除所有 inactive slot。

从沙箱证据到生产迁移票据

本实验的最终决策仍是:

production_ch29_gate = pending

正式票据至少还要补:

  • 真实 schema/type/DDL/sequence/large-object inventory;
  • 数据量、写入峰值、全量时长与 slot WAL 容量压测;
  • source primary failover 和 consumer restart 演练;
  • TLS、HBA、secret rotation 与权限评审;
  • 应用影子读、连接池 drain、真实路由 owner 和变更窗口;
  • 业务不变量、允许语义损失与 reconciliation owner;
  • target-only write 边界、回退/前滚矩阵、不可逆动作;
  • 备份恢复、RPO/RTO、观察窗口和退出条件。

读者完成本节后,应能提交的不是一句“逻辑复制已同步”,而是一份可以回答同步了什么、 在哪个边界相等、故障怎样暴露、切流由谁执行、何时还能回退、如何证明已清理的迁移 证据包。


上一节:多集群迁移环境 · 返回本章目录 · 下一章:推陈出新:版本升级与回滚策略 · 查看全书目录 · 查看索引中心

30 推陈出新:版本升级与回滚策略

第 29 章已经把逻辑复制、全量校验、切流和回退组织成迁移状态机。版本升级是在这条主线 上再增加五个同时变化的维度:

PostgreSQL server
  + data/catalog format
      + SQL behavior and defaults
          + extensions and native libraries
              + clients, pools, backups, exporters and automation

因此,升级不是“换个 RPM/DEB 再重启”,也不是 pg_upgrade 返回成功就结束。它是一项 有兼容性清单、隔离彩排、业务基线、发布门、写入分界和退出窗口的迁移项目。

学习完成标准

完成本章后,读者应能:

  1. 区分 minor、安全修复和 major upgrade,并从 release notes 提取行为变化;
  2. pg_upgrade、逻辑复制和 dump/restore 之间按停机、空间、回退与重建目标选型;
  3. 盘点扩展的包、动态库、SQL 对象、preload 和 update path;
  4. 发现 collation version 漂移,并坚持先重建依赖对象、再刷新版本;
  5. 用 catalog、checksum、amcheck、恢复证据、查询结果和业务不变量建立升级基线;
  6. 在隔离环境复现完整升级,并把软件准备时间与业务不可写时间分开;
  7. 在目标第一笔独占写入前证明回退,在其后切换为对账或前滚策略;
  8. 输出含停止线、观察窗口、owner 与证据包的生产升级 runbook。

一张图看懂升级决策

识别变化
  -> inventory 数据 / 配置 / 扩展 / 客户端 / collation
      -> 选择 pg_upgrade / logical / dump-restore
          -> 克隆与彩排
              -> compatibility gate
                  -> stop old writer
                      -> upgrade and rebuild
                          -> application-visible validation
                              -> rollback proof before target writes
                                  -> release and observe

任一门禁失败,都回到前一个可解释状态;不能用“先上线再看看”跨过 checksum、 collation、扩展或业务结果不一致。

本章目录

30.1 先识别变化类型

30.2 三类大版本升级路径

30.3 扩展与依赖升级

30.4 locale、collation 与索引风险

30.5 升级前检查与业务验证

30.6 用隔离环境完成升级彩排

30.7 实战:前滚、回退与发布决策

写作与验收提示

本章提供一个真实、隔离的 PostgreSQL 17.10→18.6 参考实验。它在 Pigsty pg-meta 主机的随机 /tmp 目录中创建两套 Unix-socket-only 临时集群,不接触 Pigsty 管理的数据目录与服务。正式证据证明:

fixture rows before / after       10,000 / 10,000
ordered digest equal              true
stale ICU collation gate          blocked
REINDEX then REFRESH VERSION      passed
checksum-incompatible target      rejected
pg_upgrade method                 copy
amcheck and staged ANALYZE        passed
old PG17 restart before new write passed
forward canary                    order_id 10001
remote and fixture cleanup        verified

验证器拒绝了 30 个声明反例和 20 个现场证据变异。公开摘要位于 upgrade-run.json,完整实验合同位于 lab-contract.md

这次小型沙箱成功不预测生产停机时长,也不证明第三方扩展、驱动、备份体系或真实应用 已经兼容。production_ch30_gate 始终保持 pending;生产授权必须来自真实数据克隆、 业务彩排、备份恢复与变更审批。

参考资料


上一章:移花接木:逻辑复制、迁移与异构同步 · 返回下卷导读 · 下一章:事件分级、现场保护与应急决策——枕戈待旦 · 查看全书目录 · 查看索引中心

30.1 先识别变化类型

升级风险首先取决于“什么发生了变化”。同一个“版本升级”工单,可能只是在同一 major 里换修复版,也可能跨越 data directory、系统目录、SQL 行为和扩展 ABI。若不先分类, 团队很容易把 minor upgrade 做成一次不必要的数据迁移,或把 major upgrade 当成滚动 重启。

30.1.1 小版本、安全修复与大版本

版本号先按 PostgreSQL 规则读

从 PostgreSQL 10 开始:

17 -> major
17.10 -> major 17 的第 10 个 minor release
17 -> 18 -> major upgrade
17.9 -> 17.10 -> minor upgrade

9.6 及更早版本使用前两段表示 major,例如 9.5→9.6 是 major upgrade, 9.6.23→9.6.24 才是 minor upgrade。不要用通用 SemVer 的 “major.minor.patch”直觉解释 PostgreSQL。

社区的版本策略给出两个硬边界:

  • 每个 major 通常支持五年,超过 EOL 不再获得正常修复;
  • 同一 major 的 minor release 不改变内部存储格式,官方建议运行当前 minor;
  • major 会改变不向后兼容的 data directory,需要 dump/restore、pg_upgrade 或逻辑 迁移;
  • 可以跨多个 major 升级,但必须阅读所有跨越版本的 release notes。

分类表:

类型 数据目录 典型动作 仍需验证
minor 修复 同 major 兼容 换二进制并重启 release note、扩展包、HA 逐节点顺序
紧急安全修复 通常仍是 minor 缩短审批与暴露时间 CVE 触发面、临时缓解、升级后攻击面
major 不直接兼容 三类迁移路径之一 全部数据、行为、扩展、客户端和回退合同
OS/libc/ICU 更新 PG 版本可不变 系统维护 + 对象评估 collation、TLS、动态库、驱动
扩展更新 PG 版本可不变 包 + ALTER EXTENSION update path、对象重建、不可降级

“安全修复”不是第四种存储格式;它是变更优先级。风险高的漏洞可能要求更快上线,但不能 免除备份、逐节点、兼容和回退检查。反过来,社区明确认为长期停留在旧 minor 往往比及时 升级风险更大。

Pigsty 中的 minor rolling 仍有顺序

同一 major 的 HA 集群通常可以:

准备并锁定同一套 minor/extension 包
  -> 升级 replicas
      -> 分别重启并等待重新追平
          -> switchover
              -> 升级原 primary
                  -> 重启、追平、验收

Pigsty 提供包仓、Ansible、Patroni 与 pg 管理命令来执行这条链,但“rolling”不等于 所有节点同时更新。每一步都应确认:

SELECT version(), pg_is_in_recovery();

并观察 replica replay、slot、客户端错误与业务 SLO。扩展 .so 必须与每个节点正在 运行的 server binary 匹配;只升级 primary 的扩展包,下一次 failover 可能把故障推给 replica。

major 不能靠“replica 先装 18、primary 仍跑 17”完成物理滚动升级。物理流复制要求同一 major;跨 major 需要逻辑复制或重建后的拓扑。

30.1.2 SQL 行为、系统目录、参数和默认值

release notes 要转成可执行差异

不要只读“新特性亮点”。major release notes 的 migration/incompatibilities 部分才是 升级清单的起点。以 17→18 为例,变化包括但不限于:

  • initdb 默认启用 data checksums;
  • pg_upgrade 开始保留大部分优化器统计,但 extended/custom/cumulative statistics 仍不完整;
  • generated column 默认形态、时区缩写解析、VACUUM/ANALYZE 对继承子表的行为变化;
  • psql 对 PostgreSQL 18 的某些 CSV \copy 边界可能不兼容;
  • 某些系统视图列或含义发生变化;
  • 默认 collation provider 相关行为会影响全文检索与 pg_trgm 索引。

本章实验正是利用第一条真实边界:源 PG17 启用了 checksums,而一个用 --no-data-checksums 初始化的 PG18 目标被 pg_upgrade --check 拒绝:

old cluster uses data checksums but the new one does not

这说明“新版本的默认值更安全”不能替代显式匹配。目标必须按源集群和新架构的合同 初始化。

四张差异表

升级 ADR 至少维护:

典型内容 证据
removed/changed behavior SQL、类型、权限、触发器、排序、错误码 release notes + query corpus
parameter diff 删除、重命名、默认值、单位、context pg_settings、新旧配置渲染
catalog diff 列、view、enum/code、权限 exporter/脚本 SQL 的兼容测试
reserved words/API parser、函数签名、客户端协议 schema restore + driver test

在旧环境导出当前设置时,不要只保存一个手工编辑过的 postgresql.conf

SELECT name,
       setting,
       unit,
       source,
       sourcefile,
       sourceline,
       pending_restart
FROM pg_settings
ORDER BY name;

SELECT sourcefile,
       sourceline,
       seqno,
       name,
       setting,
       applied,
       error
FROM pg_file_settings
ORDER BY seqno;

第一张是最终有效值,第二张是配置文件解析结果。两者配合才能发现:

同名参数被后续 include 覆盖
新版本已不认识旧参数
配置行语法错误
值已写入但需要 restart
环境变量 / ALTER SYSTEM / 命令行改变来源

同样地,HBA 要用 pg_hba_file_rules 解析,不能只做文本 diff。

系统目录不是跨 major 的稳定应用 API

监控、迁移脚本和内部工具经常直接读取 pg_stat_*pg_catalog。这些是 PostgreSQL 公开能力,但列集合和含义可以随 major 演进。避免:

SELECT * 后按列位置解码
假设枚举状态永远只有旧值
把 OID 当跨集群稳定标识
在 SQL 中硬编码 server 版本分支却不测试

应显式列名,并以 server_version_num 选择经过测试的查询:

SELECT current_setting('server_version_num')::int;

升级前在新版本空集群上先跑完整 exporter、备份、健康检查和自动化 SQL。目标不是 “SQL 不报错”而是输出字段、单位和告警语义仍正确。

30.1.3 驱动、连接池、备份与观察组件兼容

兼容矩阵的单位是“实际组合”

不要写“JDBC 支持 PostgreSQL”。应记录:

application: checkout-v4
runtime: Java 21
driver: pgjdbc x.y.z
pool: HikariCP x.y.z
proxy: PgBouncer x.y
old_server: PostgreSQL 17
new_server: PostgreSQL 18
auth: scram-sha-256 + TLS verify-full
session_features:
  - prepared statements
  - binary transfer
  - application_name
  - statement_timeout
tested_queries: checkout-corpus-v8

驱动测试至少覆盖:

  • 认证、TLS、channel binding、证书和密码轮换;
  • 参数类型推断、binary/text 编解码、timestamp、numeric、array、JSON、large object;
  • server-side prepare、statement cache、batch、COPY;
  • error SQLSTATE、generated keys、cancel、timeout;
  • failover、连接重建、transaction status 和 read-only 标志。

PgBouncer/连接池还要覆盖 session state。升级切换时保留的旧连接不会自动变成新版本 连接;prepared statement、临时表、SET、advisory lock 和 transaction pooling 的语义 需要按实际模式测试。发布证据必须包含“新建连接看到目标 system identifier”,而不只 看 pool 端口可达。

备份工具要同时验证生产与恢复端

逻辑迁移通常应使用目标版本pg_dump 读取旧 server。官方说明:新版 pg_dump 可以读取受支持的旧 server,且输出面向相同或更高 server;旧版 pg_dump 会拒绝读取更高 major,dump 输出也不保证能恢复到更低 major。

这带来几条门禁:

new pg_dump -> old source   must pass
new pg_restore -> new target must pass
backup agent -> old during window must pass
backup agent -> new after cutover must pass
restore tooling -> isolated new cluster must pass

物理备份则与 server major、WAL、control file 和工具版本绑定。不能把 PG17 的物理备份 直接恢复成 PG18;它应先恢复为 PG17,再走获批的升级路径。Pigsty 中 pgBackRest 配置、 repository、stanza、retention 和恢复脚本都要在新拓扑重验,而不是只确认最新 backup 状态是 completed。

“监控没报警”可能是 exporter 已失明

升级后指标归零有两种解释:

系统没有负载
采集 SQL 已失败或字段含义改变

因此观察组件要做三向核对:

组件 新版本检查 原生对照
exporter scrape success、query errors、字段数 pg_stat_* 原始 SQL
Grafana/Pigsty 面板 时间序列连续、label 未漂移 server identity 与真实 workload
log pipeline 新错误格式、severity、采集延迟 PostgreSQL 本地 log
alert rule 正例能触发、恢复能关闭 人工受控 canary

升级测试应主动产生一条查询、一个可识别错误和一小段 WAL,确认应用、日志、指标和告警 四条链都看到同一时间线。只有全部组件对新 major 给出有意义的数据,才叫“观察能力 兼容”。


返回本章目录 · 下一节:三类大版本升级路径 · 查看全书目录 · 查看索引中心

30.2 三类大版本升级路径

大版本升级没有统一最优解。路径选择本质上是在四种成本之间交换:

业务不可写时间
额外计算/存储资源
变更系统数量与复杂度
回退与数据对账难度

先写约束,再选工具;不能因为团队最熟 pg_upgrade,就把所有数据库都压进一个停机 窗口。

30.2.1 pg_upgrade 与停机窗口

它升级 cluster,不是逐条重写业务数据

pg_upgrade 用新版本创建系统目录,再迁移旧 cluster 的 catalog 与用户 relation 文件, 因此通常比逻辑 dump/restore 快得多。它要求:

  • old/new server binaries 与 data/config directories 都可用;
  • compile-time 与 control-data 条件兼容;
  • 新 major 对应的扩展 shared libraries 已安装;
  • 两个 cluster 在正式 upgrade 时都停止;
  • 操作系统用户/数据库 install user、认证和 socket 可连接;
  • tablespace、standby、slot、统计与生成的 rebuild scripts 都有计划。

永远运行新版本pg_upgrade

/usr/lib/postgresql/18/bin/pg_upgrade \
  --old-bindir /usr/lib/postgresql/17/bin \
  --new-bindir /usr/lib/postgresql/18/bin \
  --old-datadir /pg/data/17 \
  --new-datadir /pg/data/18 \
  --username postgres \
  --socketdir /secure/short/socket \
  --check

--check 应在彩排和正式窗口前重复执行。若计划使用特定传输模式,检查时也要带相同 模式,才能覆盖文件系统约束。

文件传输模式决定速度和回退形态

模式 空间/速度 old cluster 可回退边界
default --copy 需要完整副本,较慢 old relation files 独立,新端启动后仍可启动旧端
--copy-file-range 可能利用高效内核复制 取决于实际文件系统行为,仍需实测
--clone CoW 文件系统上快且省空间 新旧逻辑独立,但要求文件系统支持
--link 硬链接,最快且省空间 新端一旦启动写共享文件,旧端不再安全
--swap 可能更快,直接交换目录内容 传输进入破坏阶段后旧端不再安全

不能把 --link 的分钟级结果与 --copy 的回退承诺同时写进 runbook。选择哪个模式, 就必须演练哪个模式、同一种文件系统、同样 tablespace 布局与数据规模。

本章为了验证“目标零写入时旧端可启动”,明确使用 --copy,禁止 link、clone、swap 和 --no-sync。升级完成后新 PG18 已启动并校验,随后停止新端、启动旧 PG17, 10,000 行 manifest 仍相等。这个结论只属于 copy 模式和本次 fixture。

停机窗口不只是一条命令的秒数

业务不可写窗口通常包含:

drain writers
  -> final checkpoint / backup evidence
      -> stop old topology
          -> pg_upgrade checks and transfer
              -> rebuild scripts / extension updates / collation work
                  -> staged ANALYZE
                      -> application validation
                          -> routing and pool drain

pg_upgrade 输出的 Upgrade Complete 只是中点。它会提示需运行的 rebuild/reindex 脚本;被这些脚本引用的表在完成前可能返回错误结果或性能极差。PostgreSQL 18 会迁移 大部分优化器统计,但 extended、扩展自定义和累计统计并不完整,仍建议:

vacuumdb --all --analyze-in-stages --missing-stats-only
vacuumdb --all --analyze-only

彩排要分别计时每一阶段,以生产数据克隆的 P95/P99 结果安排窗口,不能拿一个 10,000 行实验的 pg_upgrade 时间乘比例。

30.2.2 逻辑复制与渐进切换

逻辑复制把新 major 建成独立 cluster:

old source remains writable
  -> schema prepared on new target
      -> initial copy
          -> incremental apply
              -> shadow read and reconciliation
                  -> freeze, final marker, sequence sync
                      -> cutover

它的优点:

  • 业务不可写时间主要集中在最终冻结与切流;
  • 新旧环境可同时运行,便于真实应用影子验证;
  • 可以跨平台、改变物理布局、重配参数和扩展;
  • 目标可逐步扩容与预热。

代价已经在第 29 章验证:

  • DDL、sequence、large object 与许多对象不自动复制;
  • publication/subscription、replica identity、slot WAL 必须治理;
  • 目标写入会制造冲突或 silent drift;
  • 全量、增量、业务不变量和切流路由都需独立证据;
  • 回退在目标承接写入后需要 reverse sync 或 reconciliation。

跨 major 还要检查 row/column filter、generated columns、binary transfer 与协议选项在 两个版本交集中的语义。一般先升级 subscriber/target 能扩大兼容余量,但具体拓扑 需按官方 logical replication upgrade 章节执行。

PostgreSQL 17 起,pg_upgrade 可以在满足前提时迁移 logical slot/subscription 依赖;这不意味着任意逻辑复制拓扑能自动升级。publisher upgrade 前需要停用对应 subscription,循环、多节点和 two-phase 拓扑有非事务步骤,必须有备份和逐节点状态机。

Pigsty 推荐生产 major upgrade 优先考虑新建集群 + 逻辑迁移,因为它把应用验证和资源 准备移到切换前。这里的“推荐”仍需服从数据对象是否可逻辑复制、写入率、slot 容量、 DDL 频率和团队能否可靠完成对账。

30.2.3 dump/restore 与重建机会

dump/restore 是最“逻辑化”的升级:在新 cluster 按新版本规则重新创建对象和写入行。 它成本高,却也是一次摆脱历史物理包袱的机会:

  • 重排 tablespace、partition 与对象 owner;
  • 只迁移仍需保留的数据;
  • 统一 encoding/locale 的新建策略;
  • 重建全部 index,消除旧物理布局;
  • 审阅 schema、extension、privilege 和废弃对象;
  • 用 directory archive 并行 dump/restore。

常见路径:

# 用目标版本工具读取旧 server
pg_dump -Fd -j 8 -d app -f app.dump
pg_dumpall --globals-only > globals.sql

# 在新 server 恢复并审阅错误
psql -X -d postgres -f globals.sql
pg_restore -j 8 -d app_new app.dump

选择性恢复不是“完整 cluster 迁移”的同义词。需要另外处理:

roles and memberships
database-level settings
tablespaces
extensions and shared libraries
large objects
publications/subscriptions
security labels
replication slots
external files and FDW credentials

使用 target major 的 pg_dump,并读取 stderr 全部 warning。directory 格式是唯一支持 parallel dump 的 archive;custom/directory 都支持选择与重排 restore。并行增加 jobs + 1 个连接和源端负载,还可能因 DDL lock 排队而失败,必须在生产形态彩排。

dump/restore 的回退与逻辑迁移相似:旧端可继续保留,但目标开始承接独占写入后,改回 连接串仍会丢新事实。它不是因为“重建了一份”就天然可逆。

30.2.4 没有一种路径天然“滚动无感”

用约束选型

约束 pg_upgrade logical replication dump/restore
最小写停机 较弱 较弱
额外硬件 可同机 通常需要完整新集群 通常需要完整新集群
升级速度 通常最快 取决于全量 + 追平 通常最慢
真实影子验证 有限 最强 恢复完成后可做
改物理布局 有限 最强
DDL/sequence 编排 最复杂 restore 负责大部分
旧端物理回退 取决于 copy/link/swap 切流前强 切流前强
数据对账 必需 最重 必需

一个常见组合不是三选一,而是:

production: logical replication
  + disaster rehearsal: dump/restore
  + small internal clusters: pg_upgrade

甚至同一项目会先用 dump/restore 生成测试克隆,再用 pg_upgrade 彩排正式路径。

“无感”要拆成多个 SLO

所谓无感至少包含:

connection establishment
read availability
write availability
transaction in flight
latency and plan stability
background jobs
CDC and replica continuity
error and retry semantics
data freshness

逻辑切换只有几秒写冻结,不代表旧池里的长事务、prepared statement、sequence、 缓存、ETL 与外部副作用无感。minor rolling 也会发生连接断开和 failover。pg_upgrade 即使文件迁移很快,post-upgrade reindex/ANALYZE 仍可能控制上线时间。

所以 runbook 不写“无感升级”,而写:

read_unavailable_budget: 5s
write_unavailable_budget: 30s
inflight_policy: drain-then-retry-idempotently
replication_rpo: 0
latency_gate: p99 <= baseline * 1.20
rollback_boundary: before first target-only commit

可测量的预算才是发布决策;“滚动”“在线”“秒级”只是实现特征。


上一节:先识别变化类型 · 返回本章目录 · 下一节:扩展与依赖升级 · 查看全书目录 · 查看索引中心

30.3 扩展与依赖升级

PostgreSQL 本体升级成功,扩展仍可能让新 cluster 无法启动、schema restore 失败,或更 隐蔽地在旧类型/索引上执行新二进制代码。原因是扩展不是一个对象,而是一条跨越软件 仓库、文件系统、配置、系统目录和业务数据的依赖链。

30.3.1 二进制包、数据库对象和预加载顺序

一项扩展至少有五层

示例 升级问题
OS/package postgresql-18-postgis-*.so 是否有目标 major/架构/OS 包
control/SQL files .control--1.0--1.1.sql default version 与 update path
preload/config shared_preload_libraries、GUC 新 server 能否启动、是否需 restart
database catalog pg_extension.extversion、members 每个 database 当前装了什么
stored data/index extension type、operator class、index 新代码能否解释旧物理表示

pg_extension 只回答第四层:

SELECT e.extname,
       e.extversion,
       n.nspname AS schema,
       r.rolname AS owner,
       e.extrelocatable
FROM pg_extension AS e
JOIN pg_namespace AS n ON n.oid = e.extnamespace
JOIN pg_roles AS r ON r.oid = e.extowner
ORDER BY e.extname;

还要查询目标 server 实际能提供的版本:

SELECT name,
       default_version,
       installed_version,
       comment
FROM pg_available_extensions
ORDER BY name;

SELECT name, version, installed, superuser, trusted
FROM pg_available_extension_versions
ORDER BY name, version;

“源端安装了 3.4,目标仓库有 3.5”并不能证明可直接升级;中间 SQL update scripts 可能缺失,C ABI 也可能不支持目标 major。

pg_upgrade 前先装文件,不要重建 SQL 对象

官方 pg_upgrade 流程要求先在新版本安装匹配的 extension shared libraries 与支持 文件。不要在空的新 cluster 预先执行:

CREATE EXTENSION postgis;

旧 cluster 的 extension catalog 与对象会随升级迁入,提前 CREATE 会重复。正确顺序 通常是:

锁定 old extension inventory
  -> 准备 target-major packages on every future node
      -> 检查 control / SQL / shared library
          -> 配置必要 preload,但暂不让错误配置影响 managed cluster
              -> pg_upgrade --check
                  -> pg_upgrade
                      -> 按生成脚本和扩展文档 ALTER EXTENSION UPDATE

pg_upgrade 会检查它能发现的 required libraries,也会为可用 extension update 生成 脚本;但官方明确指出外部模块的所有 binary compatibility 无法由它完全检查。包含自定义 background worker、WAL resource manager、shared memory 或特殊 table access method 的扩展,必须按扩展自己的 major-upgrade 文档测试。

preload 是启动依赖

盘点:

SELECT name, setting, source, pending_restart
FROM pg_settings
WHERE name IN (
  'shared_preload_libraries',
  'session_preload_libraries',
  'local_preload_libraries'
);

shared_preload_libraries 中任一 .so 缺失或与新 server ABI 不匹配,目标可能根本 起不来。先在隔离 cluster 加载:

postgres -D /isolated/new -C shared_preload_libraries

再实际启动并检查 log。不要在正式窗口里通过“先把所有 preload 清空”绕过;这样启动的 server 可能缺少审计、监控、时序、列存或业务所依赖的语义。

HA 集群中每个候选 primary/replica 都必须有同一套兼容库。只在当前 primary 安装, failover 才会暴露缺包。

30.3.2 扩展升级脚本与不可降级路径

先证明存在完整 update path

SELECT source, target, path
FROM pg_extension_update_paths('postgis')
WHERE source = (
  SELECT extversion
  FROM pg_extension
  WHERE extname = 'postgis'
)
ORDER BY target;

目标是看到从当前 installed version 到批准 target version 的路径。然后显式指定:

ALTER EXTENSION postgis UPDATE TO '3.x.y';

不带 TO 会使用 control file 的 default version;软件仓库下一次更新 default 后, 同一 runbook 可能得到不同结果。版本和包摘要都应固定。

ALTER EXTENSION UPDATE 执行扩展提供的 SQL scripts。脚本可能:

  • 修改 type/function/operator 签名;
  • 重写 extension-owned table;
  • 替换 operator class 或索引支持函数;
  • 迁移内部元数据;
  • 删除旧对象;
  • 要求先后执行专用 pre/post-upgrade procedure。

即使 SQL transaction 回滚成功,已更换的 .so、preload、外部文件和其他 database 状态也不一定一起回滚。更重要的是,许多扩展根本不提供 downgrade script。

降级能力要逐版本证明

对每个扩展写矩阵:

必答
package downgrade 仓库是否保留 old major + old extension package
SQL downgrade path pg_extension_update_paths 是否存在反向 path
on-disk format 新版本写入后旧 binary 是否还能读
index rebuild 哪些 index 必须重建,能否 concurrent
logical export extension type 如何导出为 portable form
fallback 回旧 cluster、restore,还是只能前滚

如果没有反向 path,发布决策应写:

before ALTER EXTENSION: old cluster / backup can still be rollback source
after extension writes new format: rollback requires restore or logical transform

不要尝试把 pg_extension.extversion 手工改回旧字符串;它只改 catalog 声明,不会逆转 SQL objects、内部表或存储格式。

扩展脚本也是受信任代码

升级脚本常以 extension owner 或 superuser 权限执行,binary 还能在 server 进程内运行。 包来源、签名/摘要、供应链和发布说明应进入变更评审。对 source superuser 不可信的 cluster,pg_upgrade/restore 还可能在目标执行源端预先布置的代码;隔离环境与代码 审阅不是可选项。

30.3.3 从 ch14 ADR 获取退出与兼容信息

第 14 章要求扩展准入时就记录 extension ADR。升级时不要重新从零猜用途,而应把 ADR 转成当前 inventory:

extension: postgis
business_owner: geo-platform
technical_owner: dba
installed_databases: [maps, routing]
installed_version: 3.x
target_version: 3.y
postgresql_majors: [17, 18]
os_arch: ubuntu24-arm64
packages:
  old: ...
  new: ...
preload: false
stored_types: [geometry, geography]
indexes: [gist, spgist]
upgrade_path_evidence: ...
downgrade_path: none
portable_export: EWKB/EWKT
exit_cost: high

将 ADR 与现场 catalog 对账:

ADR 有、catalog 无 -> 是否已退役但配置/包残留
catalog 有、ADR 无 -> 未治理依赖,升级门禁失败
version 不同 -> 漂移,先解释
preload 不同 -> 启动风险
package 无 target major -> 路径不可行

还要找出 extension members 与业务依赖:

SELECT e.extname,
       pg_describe_object(d.classid, d.objid, d.objsubid) AS member
FROM pg_extension AS e
JOIN pg_depend AS d
  ON d.refclassid = 'pg_extension'::regclass
 AND d.refobjid = e.oid
 AND d.deptype = 'e'
ORDER BY e.extname, member;

业务对象依赖 extension type/function/operator 的方向还需从 pg_depend 反查。只有列出 stored types、indexes、views、generated expressions 和 functions,才能知道升级失败 影响哪些对象。

退出策略在升级时兑现

ADR 若声明“可移除”,彩排应实际证明:

导出为 core PostgreSQL / portable representation
  -> 在无该 extension 的目标恢复
      -> 比较业务结果
          -> 重建目标索引
              -> 回放增量或冻结切换

若做不到,就把 exit cost 和供应商/社区生命周期写进升级风险。一个已停止支持 PG18 的 关键扩展,可能决定整个数据库只能停留在 PG17,或必须先做一次应用层去依赖迁移。

本章正式实验只迁移内建 plpgsql,升级后才安装 amcheck 验证两个 B-tree。它故意 不声称覆盖任何第三方扩展;生产票据必须用第 14 章 ADR 和真实包矩阵补齐这块证据。


上一节:三类大版本升级路径 · 返回本章目录 · 下一节:locale、collation 与索引风险 · 查看全书目录 · 查看索引中心

30.4 locale、collation 与索引风险

collation 决定字符串怎样比较和排序。B-tree 在创建时把这种比较结果固化成页面内顺序; UNIQUE constraint 还依赖它判断两个字符串是否相等。操作系统的 libc、ICU 或 PostgreSQL builtin provider 发生变化后,heap 里的文本没有改变,旧索引却可能已经不再符合新比较 规则。

这是一个典型的“服务能启动、查询也能执行,但可能返回错结果”的升级风险。

30.4.1 libc、ICU 与排序规则版本

locale 是环境选择,collation 是数据库对象

需要分别记录:

database encoding
database default locale provider and locale
database recorded/actual collation version
column/expression-level COLLATE
user-defined pg_collation objects
libc / ICU / builtin provider and version
operating-system image and architecture

在每个 database 中:

SELECT c.oid::regcollation AS collation,
       c.collprovider,
       c.collisdeterministic,
       c.collcollate,
       c.collctype,
       c.colllocale,
       c.collversion AS recorded_version,
       pg_collation_actual_version(c.oid) AS actual_version
FROM pg_collation AS c
WHERE c.collversion IS DISTINCT FROM
      pg_collation_actual_version(c.oid)
ORDER BY 1;

对数据库默认 collation,还要检查 pg_database.datcollversionpg_database_collation_actual_version(oid)。一个 cluster 有多个 database,逐库检查 不能省略。

provider 特征:

provider 版本来源与边界
libc 依赖 OS C library/locale data;版本号有时只是近似代理
ICU ICU 提供版本,跨平台能力强,但升级 ICU 仍可改变规则
builtin PostgreSQL 内建规则,减少 OS 漂移,但只覆盖其支持的 locale
default 继承 database 默认 provider

版本字符串相同也不是数学证明。发行版可能 backport locale 数据而没有按预期改变外层 版本;因此还应保留一组关键字符串排序与相等性 corpus。

warning 是门禁,不是自动修复

PostgreSQL 使用 collation 时会比较 recorded 与 actual version。若不一致,会提示:

rebuild affected objects
then REFRESH VERSION

它不会自动知道所有外部语义,也不会替你安排锁和空间。OS patch、容器基础镜像、 跨发行版迁移与 pg_upgrade 都可能触发这条门禁,所以 collation 检查不应只在 PostgreSQL major upgrade 执行。

30.4.2 排序变化对唯一性和索引顺序的影响

B-tree 的正确性依赖比较函数稳定

假设旧规则认为:

a < ä < b

新规则变成:

a < b < ä

旧 index page 仍按第一种顺序排列。binary search 使用新 comparator 在旧顺序上导航, 可能漏行、返回错误 range,ORDER BY 也可能错误地相信 index 已有正确顺序。amcheck 文档明确把“索引 tuple 是否按 collation 逻辑顺序”列为 B-tree invariant。

UNIQUE 更危险。若旧规则认为两个值不同,新规则认为相等:

旧索引允许两行
  -> 新规则下 REINDEX UNIQUE
      -> duplicate key,重建失败

此时不能先刷新版本并继续。需要业务 owner 决定:

  • 规范化并合并重复值;
  • 改为 deterministic collation;
  • 改唯一键设计,加入稳定业务 ID;
  • 保留旧 provider/locale,推迟环境变化。

若新规则只改变顺序、不改变相等性,仍需重建所有依赖排序的结构。

受影响的不只普通文本索引

盘点:

B-tree index and UNIQUE/PRIMARY constraints with collatable keys
expression index using collation-sensitive functions/operators
partition bounds and partitioned indexes
materialized views with ordered/normalized derived data
full-text and pg_trgm indexes called out by release notes
application pagination/bookmark built on locale ordering
cached sorted projections outside PostgreSQL

hash index 不保存排序,但 collation-sensitive equality 和业务去重仍需按实际 operator 语义验证。不能把“只重建所有 text B-tree”当成对任意扩展 operator class 的完整规则。

query corpus 要覆盖等价类

建议保存:

SELECT value
FROM upgrade_collation_corpus
ORDER BY value COLLATE app.customer_name, corpus_id;

SELECT left_value,
       right_value,
       left_value = right_value COLLATE app.customer_name AS equal,
       left_value < right_value COLLATE app.customer_name AS less
FROM upgrade_collation_pairs
ORDER BY pair_id;

语料包含 case、accent、组合字符、数字字符串、标点、emoji、各业务语言和空白。比较 旧/新结果时,先区分“批准的排序变化”与“会破坏唯一性/分页的变化”。

30.4.3 识别受影响对象并规划重建

先列依赖,再排动作

官方给出的通用依赖查询:

SELECT pg_describe_object(
         d.refclassid, d.refobjid, d.refobjsubid
       ) AS collation,
       pg_describe_object(
         d.classid, d.objid, d.objsubid
       ) AS dependent_object
FROM pg_depend AS d
JOIN pg_collation AS c
  ON d.refclassid = 'pg_collation'::regclass
 AND d.refobjid = c.oid
WHERE c.collversion <>
      pg_collation_actual_version(c.oid)
ORDER BY 1, 2;

版本字段可能为 NULL,因此生产查询通常同时使用 IS DISTINCT FROM,再根据 provider 判断哪些 NULL 是预期。对 index 可进一步从 pg_index.indcollation 精确列出:

SELECT i.indexrelid::regclass AS index_name,
       i.indisunique,
       pg_relation_size(i.indexrelid) AS bytes
FROM pg_index AS i
JOIN pg_collation AS c
  ON c.oid = ANY(i.indcollation)
WHERE c.oid = 'app.en_numeric'::regcollation
ORDER BY bytes DESC;

计划每个对象的:

rebuild command and whether CONCURRENTLY is supported
lock and blocking behavior
temporary and final disk headroom
WAL volume and replica lag
unique collision handling
estimated duration from clone rehearsal
validation query and owner

顺序不能颠倒

正确顺序:

identify all affected objects
  -> validate new equality/sort semantics
      -> resolve unique conflicts
          -> rebuild every affected object
              -> run amcheck / query corpus
                  -> ALTER COLLATION ... REFRESH VERSION
                      -> verify mismatch set is empty

REFRESH VERSION 只把 catalog 中的 recorded version 更新为当前 provider version; 官方明确说明它不会检查对象是否已正确重建。若先 refresh,warning 消失了,旧索引 却还在,反而销毁了最显眼的故障信号。

数据库默认 collation 使用:

ALTER DATABASE app REFRESH COLLATION VERSION;

也同样必须在所有依赖对象重建后执行。

正式实验的反例

本章 runner 在一次性 PG17 cluster 中精确把:

app.en_numeric.collversion = pg36-injected-stale
actual ICU version         = 153.121
affected index             = app.orders_order_code_key

门禁立即变成 blocked。runner 只允许:

REINDEX INDEX app.orders_order_code_key;
ALTER COLLATION app.en_numeric REFRESH VERSION;

完成后 recorded/actual 都为 153.121,mismatch 回到 false,且 10,000 行 logical manifest 未改变。catalog update 是为了在可丢弃 fixture 中制造现象,生产中严禁手工 伪造 collversion;真实 mismatch 来自 provider/OS 变化。


上一节:扩展与依赖升级 · 返回本章目录 · 下一节:升级前检查与业务验证 · 查看全书目录 · 查看索引中心

30.5 升级前检查与业务验证

升级会复制或重新解释现有状态。源端已经存在的 invalid index、prepared transaction、 catalog 漂移或数据损坏,不会因为换了 major 自动痊愈;它们只会让故障因果更加难分。

升级前检查的目标不是证明数据库“完美”,而是建立一个已知、可恢复、可比较的起点。

30.5.1 系统目录、无效对象与长事务

第一组:cluster 与 database inventory

SELECT datname,
       pg_encoding_to_char(encoding) AS encoding,
       datlocprovider,
       datcollate,
       datctype,
       datlocale,
       datcollversion,
       datallowconn
FROM pg_database
ORDER BY datname;

SELECT spcname, pg_tablespace_location(oid)
FROM pg_tablespace
ORDER BY spcname;

SELECT rolname, rolsuper, rolreplication, rolbypassrls
FROM pg_roles
ORDER BY rolname;

随后逐 database 采集 extension、collation、schema、owner、privilege、large object、 publication/subscription、FDW/server/user mapping、database/role settings。pg_upgrade 处理的是整个 cluster,漏掉一个平时不连接的 database,也可能在 schema dump/restore 阶段让全局升级失败。

第二组:不完整与待完成状态

SELECT i.indexrelid::regclass AS index_name,
       i.indisready,
       i.indisvalid,
       i.indislive
FROM pg_index AS i
WHERE NOT i.indisready
   OR NOT i.indisvalid
   OR NOT i.indislive
ORDER BY 1;

SELECT conrelid::regclass AS relation,
       conname,
       contype,
       convalidated
FROM pg_constraint
WHERE NOT convalidated
ORDER BY 1, 2;

SELECT transaction, gid, prepared, owner, database
FROM pg_prepared_xacts
ORDER BY prepared;

invalid index 可能来自失败的 CREATE INDEX CONCURRENTLY;它是否删除、重建或保留, 要在升级前决定。NOT VALID constraint 可以是有意的渐进变更,但必须进入 inventory。 prepared transaction 是未完成的分布式业务事实,不能在停机时当普通长连接粗暴清掉。

还要检查 logical replication:

SELECT slot_name, slot_type, database, active,
       restart_lsn, confirmed_flush_lsn,
       wal_status, invalidation_reason
FROM pg_replication_slots
ORDER BY slot_name;

SELECT subname, subenabled, subslotname
FROM pg_subscription
ORDER BY subname;

PG17+ 的某些 logical slot/subscription 状态可由 pg_upgrade 迁移,但必须满足官方升级 前提;老版本、无效 slot 和复杂拓扑不能靠默认推断。

第三组:活动与变更冻结

SELECT pid, datname, usename, application_name,
       state, xact_start, backend_xid, backend_xmin,
       wait_event_type, wait_event
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

正式停机前应:

冻结 DDL 与 extension 变更
停止 scheduler / ETL / schema migrator
drain application writers and long transactions
resolve prepared transactions by their coordinator
ensure replicas caught up
record final configuration and object manifest
stop old primary cleanly

pg_upgrade --check 还会检查 version、control data、prepared transaction、部分不支持 类型、required libraries、logical slot/subscription 等条件。它是必要门禁,但不覆盖 应用查询与业务不变量。

一个容易遗漏的原生限制是:pg_upgrade 不支持用户列使用若干保存 OID 的 reg* 类型,如 regcollationregconfigregprocregprocedureregclassregroleregtype 可升级。preflight 应按目标版本文档扫描,而不是等正式窗口报错。

30.5.2 amcheck、checksum 状态与备份恢复证据

在线先做结构检查

对已选重要 B-tree:

CREATE EXTENSION IF NOT EXISTS amcheck;

SELECT bt_index_check(
         index => i.indexrelid,
         heapallindexed => i.indisunique,
         checkunique => i.indisunique
       )
FROM pg_index AS i
WHERE i.indexrelid IN (
  'app.orders_pkey'::regclass,
  'app.orders_order_code_key'::regclass
);

bt_index_check 用与实际 index scan 相同的 operator class/comparison 逻辑验证 B-tree 结构与顺序;启用 heapallindexed 还会检查 heap tuple 是否有对应 index tuple。更强的 bt_index_parent_check 检查 parent/child invariant,但锁与成本更高。

大库不能在窗口前临时对所有 index 做最重检查。按:

系统目录和关键唯一索引
近期报错/存储异常对象
最大与最热对象
抽样普通对象

分层,并在生产克隆中测量耗时。失败时先转入第 35 章的数据抢救流程,不要把损坏对象 直接送进升级。

停库后再做全 cluster checksum 检查

pg_checksums --check --progress -D /pg/data/17

pg_checksums 要求 server clean shutdown;check 会扫描 cluster 文件,发现至少一个 checksum failure 时返回非零。启用/禁用 checksum 更是会修改数据块或 control file, HA 拓扑必须所有节点一致处理,不能把它顺手塞进 major upgrade。

PostgreSQL 18 initdb 默认开启 checksums,而 pg_upgrade 要求 old/new 设置匹配。 本章实验显式构造:

old PG17 checksums on
new PG18 checksums off

--check 返回 1;runner 删除这个不兼容的目标,重新以 checksums on 初始化,才允许 继续。

“有备份”必须变成一次可恢复证据

升级票据至少绑定:

backup_id: ...
source_system_identifier: ...
start_lsn: ...
stop_lsn: ...
timeline: ...
manifest_verified: true
required_wal_present: true
restore_target: isolated-pg17
restore_completed_at: ...
logical_manifest_equal: true
rto_measured: ...

pg_verifybackup 可以按 backup manifest 检查文件、摘要和所需 WAL,但官方明确说明它 不能覆盖启动 server 时的全部检查,仍需 test restore。Pigsty/pgBackRest 的 check、backup info 和 repository 健康也不能替代实际恢复。

回退到旧 cluster 依赖旧版本的可恢复性,所以升级前的 restore 应首先恢复为 old major。 升级后还要建立 new major 的新备份基线,并再次恢复;不能无限依赖切换前那份旧版本 备份。

30.5.3 查询结果、计划、性能和业务不变量基线

先比较结果,再讨论计划

一个新 major 选择了不同 plan,不一定是回归;选择了同一 plan,也不证明结果正确。 验证顺序:

  1. error/SQLSTATE 与行结果;
  2. 行数、排序、NULL、类型与精度;
  3. 业务不变量;
  4. plan shape、估算与实际行数;
  5. latency、吞吐、CPU、I/O、WAL 与锁。

pg_stat_statements、APM 和业务清单建立代表性 query corpus:

top total time
top mean/p99 latency
top calls
largest temp/WAL/I/O
关键交易与结算
低频但高风险管理语句
DDL / migration / backup / exporter queries

参数分布很重要。只用一个平均 customer_id 做 EXPLAIN,无法发现 skew、NULL、热点和 极端时间范围。

保存可比的证据格式

EXPLAIN (
  ANALYZE,
  BUFFERS,
  WAL,
  SETTINGS,
  VERBOSE,
  FORMAT JSON
)
SELECT ...;

对写 SQL 应在可回滚、隔离的克隆上执行。记录:

server version and system identifier
schema/data snapshot identity
session GUC
parameter values or distribution class
cold/warm cache condition
concurrency
plan JSON
result digest
latency distribution and resource counters

不要逐字比较 cost;新版本 cost model、节点和统计可能合理变化。设置门禁:

result: exact
business_invariants: zero violations
p95_latency: <= old * 1.15
p99_latency: <= old * 1.25
error_rate: no regression
temp_bytes: <= budget
wal_bytes_for_write_corpus: <= budget
plan_regression: reviewed, not byte-identical

本章 fixture 查询 order_code = 'order-5000' 在 PG17 和 PG18 都返回同一行,两个版本 都使用 orders_order_code_key Index Scan。实验记录完整 plan JSON,但 validator 只强制 业务结果相等和 plan 存在,不把“必须同一个节点”误写成普遍升级合同。

业务不变量是最终解释层

通用数据摘要之外,还应验证:

账务借贷平衡
订单状态合法且转移可达
租户间无交叉
外键孤儿为零
sequence next value 安全
任务水位和队列 offset 一致
权限矩阵不扩大
collation-sensitive pagination 稳定

这些规则应在 old snapshot 和 upgraded snapshot 上运行相同版本,并保存异常主键范围。 否则应用测试只能告诉你几个请求成功,不能证明整批数据仍满足业务语义。

30.5.4 amcheck 与 checksum 检测对象不同

证据 主要检测 不能证明
page checksum page 从写入后是否发生可检测的物理变化 SQL 逻辑、所有内存/写入前错误、索引业务一致性
pg_checksums --check clean-shutdown cluster 中 checksum page 扫描 未启用 checksum 的历史、server 可恢复、业务正确
amcheck B-tree page/link/order、可选 heap/index coverage 与 uniqueness 所有 access method、业务行值、底层每个文件摘要
verify_heapam heap 结构异常并尽量继续报告 所有 index、备份可恢复
backup manifest verify 备份文件、摘要、所需 WAL 的一部分完整性 server 启动和业务恢复
test restore 备份能在声明环境恢复并启动 之后每笔新写、全部应用语义
logical manifest 行级规范化结果和业务聚合 物理 page/index 结构

它们不是相互替代关系:

checksums pass + amcheck fails
  -> page 未被随机篡改,但 index 逻辑结构可能不满足 invariant

amcheck passes + checksum fails elsewhere
  -> 已检对象逻辑可读,但其他 page 物理完整性有问题

both pass + restore fails
  -> backup/WAL/config/secret/extension 链仍可能不完整

all pass + business invariant fails
  -> 数据在物理和结构层可读,业务事实仍然错误

升级发布至少需要这四条独立证据线:

physical integrity
structural integrity
recoverability
logical/business equivalence

把它们压成一个“数据库健康检查已通过”复选框,会丢掉每项证据真正覆盖的边界。


上一节:locale、collation 与索引风险 · 返回本章目录 · 下一节:用隔离环境完成升级彩排 · 查看全书目录 · 查看索引中心

30.6 用隔离环境完成升级彩排

第一次在真实数据、真实扩展、真实配置和真实运维入口上组合升级,不应发生在生产窗口。 隔离彩排的价值不是练熟命令,而是发现:

哪些前置条件不成立
哪一步真正控制停机时间
哪些结果必须由业务解释
回退边界何时消失
平台自动化覆盖了什么、没有覆盖什么

30.6.1 克隆数据与版本化配置

克隆必须回答“像生产的哪一部分”

克隆方式 保真度 主要用途 风险
schema + synthetic data 对象高,分布低 快速 pg_upgrade --check、脚本开发 无法预测真实时长/plan
logical dump/restore 逻辑对象与行高 新 major 兼容、清理物理布局 大库慢,需脱敏
physical restore page/WAL/物理布局高 pg_upgrade 时长、完整性、恢复 敏感数据与存储成本
storage snapshot clone 速度快、物理高 重复彩排 一致性、tablespace/WAL 同步边界
production-sized generated corpus 分布可控 性能回归与极端边界 难覆盖真实历史异常

生产升级至少需要一次真实规模的物理/恢复克隆;日常 CI 可以使用 schema + deterministic fixture。无论哪种,记录:

source_system_identifier: ...
source_backup_or_snapshot: ...
source_lsn_and_timeline: ...
captured_at: ...
data_scope: full | subset | synthetic
anonymization_version: ...
excluded_objects: ...
size_manifest: ...

脱敏不能破坏 join、skew、长度、locale 与唯一性分布,否则兼容测试会得到过于乐观的 结果。隔离网络、独立 secret、禁止邮件/支付/webhook/CDC 等外部副作用同样重要。

配置不是复制旧文件

Pigsty 中应把新 cluster 作为独立 inventory 对象,显式声明:

pg_cluster: pg-new
pg_version: 18
pg_packages:
  - pgsql-main
  - pgsql-common
pg_extensions:
  - ...
pg_conf: oltp.yml

实际变量名和包 alias 以当前 Pigsty 版本为准。关键原则是:

old rendered config -> inventory of intent
release-note parameter diff -> migration decisions
new-version template -> new rendered config
catalog/native views -> effective-value verification

不要把旧 postgresql.confpostgresql.auto.confpg_hba.conf 原样盖到新版本上。 这样会带入已删除参数、错误 include、旧路径与旧认证边界,还会绕过 Pigsty/Patroni 的 配置 ownership。

需要版本化的输入包括:

  • Pigsty inventory commit 与模板/角色版本;
  • PostgreSQL/extension/OS package lock 与摘要;
  • effective pg_settingspg_file_settings、HBA 解析;
  • locale/ICU/libc/architecture;
  • Patroni、pgBackRest、PgBouncer、HAProxy、exporter 版本与配置;
  • schema/extension ADR、query corpus 和业务 invariant 版本。

不复制生产身份

克隆环境必须重写:

cluster_name / Patroni scope / DCS path
systemd/service identifiers
ports, VIP, DNS and HAProxy service
backup stanza/repository write target
archive_command and restore_command
replication slots/subscriptions
application secrets and external endpoints
monitoring labels

否则一次彩排可能向生产 archive 写 WAL、争夺同一 DCS leader、消费真实 slot 或被应用 误连。第 19/23 章的环境 marker 与权限边界应在此复用。

30.6.2 执行升级、扩展更新和服务接入验证

彩排按正式状态机执行

建议阶段:

阶段 主要动作 必留证据
software-ready 仓库、binary、extensions、client tools 版本、包摘要、所有节点矩阵
clone-ready 恢复/生成数据,隔离副作用 source identity、manifest、restore log
preflight catalog/config/collation/integrity/backup check 报告与阻断项
stop-old drain writer,clean shutdown 最终连接、LSN、checkpoint、时间
upgrade 指定实际模式执行 完整 stdout/stderr、每阶段时长
post-process 脚本、extension、reindex、ANALYZE 对象清单、错误与耗时
validate SQL、应用、监控、备份恢复 query corpus、invariant、SLO
rollback-proof 目标零写入时启动旧端 old identity、manifest、路由未变
release 接入服务并写 canary target identity、pool drain、write boundary

软件下载、包签名、镜像构建和克隆恢复不应计入业务不可写窗口;但它们必须计入项目 lead time。每个阶段同时记录 wall time、CPU、I/O、WAL、额外空间和锁。

Pigsty major upgrade 有两种平台映射

新集群 + 逻辑复制

用新 pg_version 新建独立 Pigsty cluster
  -> 按第 29 章迁移 schema/data/incremental
      -> Pigsty service/proxy/dashboard 先行验证
          -> 业务切流

这通常最符合生产最小停机目标,也让旧 cluster 保持完整。

隔离 pg_upgrade / in-place 项目

需要同时处理:

Patroni service lifecycle
old/new data and binary paths
primary/standby upgrade or rebuild
pgBackRest stanza and backup baseline
extension packages on every node
generated PostgreSQL config and HBA
service discovery / proxy / exporter
rollback mode

不要绕开 Patroni,在正在受管的 production data directory 上直接照抄一条裸 pg_ctl/pg_upgrade 命令。本章实验之所以能使用它们,是因为所有 data directory 都在 随机 /tmp 下,从未被 Pigsty 管理。

服务接入验证使用新连接

升级后的原生身份:

SELECT current_database(),
       current_user,
       current_setting('server_version'),
       current_setting('cluster_name'),
       pg_is_in_recovery(),
       inet_server_addr(),
       inet_server_port();

SELECT system_identifier FROM pg_control_system();

再从每种入口重复:

direct primary
read service
primary service
PgBouncer transaction/session pools
HAProxy/VIP/DNS
actual application runtime
backup/exporter accounts

连接成功后运行 query corpus、写/读 canary、权限负例和 failover/failback。旧池必须 drain;否则你验证的可能仍是旧 server session。

30.6.3 比较 Pigsty 面板与原生证据的前后差异

面板比较不是截图找不同

升级会重置/改变部分累计统计,system identifier、timeline、instance label 也可能变化。 因此按信号类型比较:

类型 比较方式
配置/容量 前后绝对值与 source
rate/latency 相同 workload、相同窗口的分布
cumulative counter 以启动/重置点为边界,不直接比总数
catalog objects 精确 manifest
plan 结果先相等,再评估 plan/resource
HA/replication 角色、timeline、lag、slot 与 failover 实验

Pigsty dashboard 负责关联:

service availability and pool
TPS / query latency / errors
CPU / memory / I/O / disk
WAL / checkpoint / replication
autovacuum and table/index
backup and exporter health

每个 release gate 同时保留原生证据。例如面板显示 index latency 正常,还要保存:

SELECT relid, indexrelid, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE indexrelid = 'app.orders_order_code_key'::regclass;

以及对应 EXPLAINamcheck 与 query result。面板没有报警不能证明 exporter SQL 没有 因 catalog 变化而停止采集。

建立差异解释账本

signal: buffer_cache_hit_ratio
old: 99.4%
new_first_10m: 71.2%
new_after_warmup: 99.1%
classification: expected-cold-cache
action: none
owner: dba
evidence: dashboard-window + pg_stat_io snapshot

每个差异必须归类:

expected reset/warmup
approved new-version behavior
configuration drift
statistics/plan regression
monitoring incompatibility
unknown -> release blocked

未知差异不能因为维护窗口快结束就自动变成 expected。

本章实验的平台边界

runner 在 pg-meta 主机上读取 managed cluster 的:

cluster_name
server_version
system_identifier
primary identity

并在实验前后要求完全相同。临时 PG17/18 使用私有 Unix socket,不接入 Pigsty exporter、 HAProxy 或 PgBouncer,所以它证明的是没有误伤 managed cluster,不是“Pigsty 全套 服务已经兼容升级”。

生产彩排必须另行让新 cluster 进入 Pigsty 监控与服务体系,执行上面的面板/原生双向 验证,并从新版本建立可恢复的备份基线。


上一节:升级前检查与业务验证 · 返回本章目录 · 下一节:实战:前滚、回退与发布决策 · 查看全书目录 · 查看索引中心

30.7 实战:前滚、回退与发布决策

前六节已经把升级拆成版本决策、数据迁移、扩展、排序规则、验收和 Pigsty 平台动作。 本节再把它们收束为一条可重复的状态机:

preflight
  -> compatibility blocked
  -> compatibility repaired
  -> pg_upgrade --check
  -> pg_upgrade --copy
  -> validate
  -> rollback proof
  -> forward commit
  -> cleanup

实验会在 Pigsty 开发沙箱的 pg-meta 主机上,用 PostgreSQL 17 与 18 的二进制创建两个 Unix-socket-only 私有临时集群。它不会升级 Pigsty 管理的集群,也不会修改 inventory、 Patroni、HAProxy、PgBouncer 或真实路由。目标不是测一个漂亮的停机秒数,而是证明: 不兼容能在发布前阻断,修复顺序有证据,第一笔目标独占写之前可以回退,之后则必须先 对账。

30.7.1 注入一个扩展或排序规则兼容问题

先固定二进制与实验边界

完整边界见 lab-contract.md,主机、版本、对象与验收条件见 requirements.json,状态转移和禁止动作见 upgrade-contract.json,拓扑见 topology.mmd

runner 不联网下载,也不安装系统软件。调用者需要提前准备:

PG36_CH30_OLD_BIN    PostgreSQL 17 bin 目录
PG36_CH30_OLD_SHARE  与该 17 版二进制匹配的 share 目录
PG36_CH30_NEW_BIN    PostgreSQL 18 bin 目录

先做不连接数据库的静态合同检查:

static/labs/ch30/task.sh lint

完整实验只应在已确认的开发沙箱中运行,并使用新的私有证据目录:

export PG36_CH30_OLD_BIN=/path/to/postgresql-17/bin
export PG36_CH30_OLD_SHARE=/path/to/postgresql-17/share
export PG36_CH30_NEW_BIN=/path/to/postgresql-18/bin
export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch30.XXXXXX"
)"

static/labs/ch30/task.sh all

若要分步评审,可以依次执行:

static/labs/ch30/task.sh capture
static/labs/ch30/task.sh exercise
static/labs/ch30/task.sh verify
static/labs/ch30/task.sh review

capture 先读取执行主机身份、文件系统与二进制版本,校验确为相邻的 17→18 major 升级,并记录可执行文件 SHA-256。exercise 才会在随机 marker 约束的临时目录中 初始化集群;listen_addresses 为空,所有连接都走权限为 0700 的 Unix socket。 实验只有 pg36_upgrade.app.orders 中 10,000 行确定性合成数据。

正式参考 run 使用 PostgreSQL 17.10 和 18.6;minor 号只是那次证据的事实,并不是读者 环境要硬编码的常量。升级前基线为:

rows             10,000
ordered digest   7c1f9a24b7a7ac4aef59e9b482bb1374
data checksums   enabled
custom collation app.en_numeric
dependent index  app.orders_order_code_key

制造一个可控的排序规则失配

为了让阻断逻辑可重复,runner 只在一次性 fixture 中,把 app.en_numeric 对应 pg_collation 行的 collversion 精确改为 pg36-injected-stale。这是教学故障注入,绝不是生产修复方法;修改其他 catalog 行或在托管集群中照做都被合同禁止。

PostgreSQL 读取到的实际 ICU 版本为 153.121,门禁因而得到:

recorded version  pg36-injected-stale
actual version    153.121
mismatch          true
affected index    app.orders_order_code_key
release           blocked

修复必须遵守第 30.4 节建立的顺序:

REINDEX INDEX app.orders_order_code_key;
ALTER COLLATION app.en_numeric REFRESH VERSION;

先重建依赖对象,是让索引按当前排序语义重新物化;后刷新版本,只是承认依赖已经处理。 如果反过来执行,告警可能消失,但旧索引仍可能保留旧排序语义。参考 run 在重建后重新 验证 10,000 行 manifest 与查询结果,确认 mismatch 消失,才允许进入停库阶段。

pg_upgrade --check 拒绝真正不兼容的目标

源集群启用了 data checksums。runner 先故意以 --no-data-checksums 初始化一个 PG18 目标,再用计划采用的 PG18 pg_upgrade 二进制执行检查:

return code  1
reason       old cluster uses data checksums but the new one does not
decision     blocked

失败目标会被精确删除。随后重新初始化 checksums 一致的目标,pg_upgrade --check 通过,正式执行:

pg_upgrade --copy ...

实验固定使用默认 copy 模式,禁止 link、clone、copy-file-range、swap 与 --no-sync。 copy 会多占磁盘和复制时间,但旧集群文件保持独立,适合演示“新集群尚未写入前”的软件 回退。pg_upgrade 生成的 delete_old_cluster.sh 被记录但从不执行。

30.7.2 在业务写入恢复前验证回退路径

先证明升级结果,不急着开放写入

升级完成不等于可以切流。runner 启动 PG18 后,依次验证:

  • 数据库、schema、table、index 与 extension manifest;
  • 10,000 行的有序摘要和业务查询结果;
  • 两个 B-tree 索引的 amcheck
  • 升级后统计信息补采与 ANALYZE
  • 排序规则版本不再失配;
  • 临时实例仍只监听私有 Unix socket。

参考 run 的升级前后摘要完全相等:

source rows / digest  10,000 / 7c1f9a24b7a7ac4aef59e9b482bb1374
target rows / digest  10,000 / 7c1f9a24b7a7ac4aef59e9b482bb1374
extension manifest    equal
query result          equal
amcheck               passed
post-upgrade analyze  completed

前后查询都使用了 index scan,但 validator 没把“执行计划文本必须相同”当成通过条件。 新版本优化器可以合法地选择不同计划;真正要验收的是结果等价、业务时延与资源预算,而 不是把旧计划冻结成正确答案。

在第一笔目标独占写之前实际回退

完成上述验证后,实验仍不向 PG18 写入。它停止新集群,重新启动旧 PG17,再用旧端读取 同一 manifest:

new cluster stopped                 true
old cluster restarted               true
target-only writes before proof     0
old manifest equals baseline        true
rollback proven                     true

这不是纸面命令,也不是“旧目录还在”的推断,而是一次真实启动与查询。它成立有三个必要 前提:

  1. 使用 copy 模式,旧目录没有与新目录共享或交换文件;
  2. 新集群还没有接受任何独占写入;
  3. 应用、连接池和路由仍被写围栏挡在外面。

若用 link 模式,新集群一旦启动就可能修改旧集群共用的数据页,官方明确警告旧集群此后 不再安全;swap 更会交换文件,不能套用本节回退步骤。clone 是否可回退还取决于文件系统 和后续写入边界,也不能只凭模式名假设。

第一笔新写入改变了问题

回退证明完成后,runner 再次停止 PG17、启动 PG18,并提交一条明确 canary:

order_id                         10001
target-only canary rows              1
final target rows               10001
direct rollback remains safe    false

从这一刻开始,旧 PG17 不包含 order_id = 10001。若立即把路由切回旧集群,数据会在 用户视角消失;真实系统还可能已经发送邮件、扣款或调用外部服务。此后的“回退”不再只是 启动旧二进制,而是数据迁移和业务补偿:

停止或围住新写入
  -> 确定目标独占提交边界
  -> 反向同步或逐项对账
  -> 补偿外部副作用
  -> 再次校验
  -> 才能决定回旧,或继续前滚

因此升级 runbook 必须把两种回退写成不同状态:

状态 新版本独占写 可以采取的动作
验证窗口 0 停新、启旧、验证旧端后恢复旧路由
已恢复写入 > 0 或未知 先写围栏与对账;默认优先修复后前滚

所谓“回退窗口”不是维护开始后的固定分钟数,而是第一笔无法在旧端重现的提交之前。 时间阈值仍然有用,但它不能替代数据边界。

30.7.3 输出升级 runbook、决策门和观察窗口

证据包必须能识别“看起来成功”

私有证据目录保存:

preflight-evidence.json
remote/upgrade-evidence.json
remote/pg_upgrade-check-bad.log
remote/pg_upgrade-check-good.log
remote/pg_upgrade.log
remote/rollback-evidence.json
remote/cleanup-evidence.json
negative-report.json
validation-report.json
public-summary.json
review.txt
source file hashes

公开参考摘要在 upgrade-run.json。它保留版本、状态和验收结论, 但删除本地路径、连接信息与私有原始日志。对已完成的同一个证据包,可重复执行:

static/labs/ch30/task.sh verify
static/labs/ch30/task.sh review

validator 不只检查成功字段。正式 run 要求:

30 declared counterexamples rejected
20 live evidence mutants rejected
11 source files hash-bound

也就是说,伪造版本关系、二进制散列、checksum 拒绝原因、collation 依赖、修复顺序、 manifest、amcheck、回退前写入数、forward canary、平台边界或清理结果,都不能继续 得到 pass。

清理不是附属动作

实验结束时逐项证明:

temporary postmasters stopped       true
fixture run root absent             true
remote root absent                  true
unrelated processes terminated         0
system packages changed            false
Pigsty managed data touched        false
Pigsty inventory changed           false
Patroni configuration changed      false
external listener created          false

清理只匹配当前随机 marker 所有的临时路径;它不使用宽泛进程匹配,也不终止无关 postmaster。任一临时实例仍在运行、目录 marker 不符或 Pigsty 平台身份发生变化,整次 run 都失败。

把实验提升为可执行 runbook

生产升级票据至少要有以下字段:

类别 必填内容
变更身份 change ID、集群、数据库、版本、窗口、指挥人与每个动作 owner
软件清单 server/client、OS、驱动、连接池、扩展、动态库、collation provider
数据基线 system identifier、checksum、对象/行数/摘要、容量、备份与恢复演练
预检 release notes、pg_upgrade --check、扩展升级路径、失效对象、长事务、slot
平台动作 Pigsty 配置、服务身份、连接端点、路由、pool drain 与监控 dashboard
状态机 每次停写、停库、升级、启库、切流、恢复写入的前置条件与证据
阻断阈值 校验失败、延迟、错误率、锁等待、CPU/IO、业务不变量的 stop condition
回退协议 第一笔目标独占写边界、旧端启动命令、反向对账和不可补偿副作用
退出条件 观察窗口、交接人、旧目录保留期、何时允许执行旧集群清理

执行时不要靠“大家觉得可以了”推进,而要逐门签字:

决策门 通过证据 不通过动作
软件门 目标包、扩展库、驱动与配置已冻结并散列 不进窗口
备份门 可验证备份且完成目标版本试恢复 不停源库
兼容门 extension/collation/DDL/catalog 清单无未决项 修复或改迁移路线
检查门 以正式参数执行的 pg_upgrade --check 通过 保持停写,修复后重检
数据门 manifest、业务不变量、sequence/identity 一致 不切流
完整性门 amcheck、checksum 与备份校验各自通过 隔离并诊断
应用门 驱动、连接池、关键读写与影子流量通过 回旧或继续阻断
服务门 端点、TLS、HBA、路由和连接 drain 可观测 不开放流量
回退门 旧端已实际启动,且目标独占写为 0 不恢复写入
发布门 指挥人确认全部门禁与 owner 不切换状态

其中“备份可验证”不等于执行过一次 pg_verifybackup;它还要包含新环境中的恢复、启动 和业务读取。amcheck、data checksum 与备份恢复也分别回答逻辑结构、存储页和灾难 恢复问题,不能互相替代。

观察窗口要有阈值和退出动作

切流后建议按三层观察:

0–15 min   连接失败、认证、panic/crash、错误率、关键写入、锁与复制异常
15–60 min  p95/p99、CPU/IO、cache、autovacuum、长事务、队列和业务漏斗
1 个业务周期以上  batch、报表、备份、归档、故障转移与低频路径

具体时长必须由业务周期决定。每一项都要写基线、阈值、查询或 dashboard、owner 和超阈值 动作。例如“观察延迟”不够,至少应写成:

signal       checkout p99
baseline     previous seven comparable periods
threshold    > baseline × 1.5 for 5 consecutive minutes
owner        application on-call
action       keep write fence / forward fix / invoke reconciliation plan

参考沙箱最终得到的是:

isolated-pg17-to-pg18-state-machine-demonstrated
production_ch30_gate = pending

它证明升级合同可执行,却没有证明真实扩展 ABI、生产数据量、停机预算、HA 拓扑、应用 驱动、备份恢复和业务峰值。只有这些生产证据补齐并由 owner 批准,pending 才能转为 可发布。

第 31 章将把这种门禁和状态机带入更不友好的情形:系统已经发生故障时,怎样先止血、 再取证、恢复服务并留下可复盘的事件时间线。


上一节:用隔离环境完成升级彩排 · 返回本章目录 · 下一章:事件分级、现场保护与应急决策——枕戈待旦 · 查看全书目录 · 查看索引中心

31 事件分级、现场保护与应急决策——枕戈待旦

前十二章已经建立部署、HA、备份、安全、可观测、容量、调优、维护、迁移与升级能力。 系统真正出事时,这些能力不会自动拼成一次正确响应:告警只呈现某个观察面的症状, 自动化可能继续改变拓扑,人在压力下又容易把第一个解释当成根因。

本章是第 32~35 章的共同控制平面。它不提前穷举所有故障,而是先回答四个问题:

现在影响了谁,数据与恢复能力是否仍安全?
哪些状态仍在变化,哪些自动化需要精确暂停?
已有证据把问题路由到哪类恢复目标?
下一步动作由谁执行,何时停止,怎样证明结果?

事故早期最稀缺的通常不是命令,而是可信状态与可逆选择。好的响应不以“最快猜到 根因”为目标,而以更快降低用户影响、数据风险和不确定性,同时保留恢复路径为目标。

学习完成标准

完成本章后,读者应能:

  1. 从用户影响、数据风险、范围、变化速度和可恢复性分级事件;
  2. 把“恢复数据、恢复拓扑、释放压力、保护完整性”写成明确响应目标;
  3. 区分严重度与技术判型,不让首发告警直接授权重启、提升或删除;
  4. 有选择地控制 Patroni、调度器、路由与保留任务,而不是笼统“暂停一切”;
  5. 采集带 UTC、拓扑、版本、system identifier、timeline、LSN 与来源散列的最小证据;
  6. 按 PITR、HA、过载和完整性四条路线进入第 32~35 章;
  7. 用“事实—假设—动作—预期—停止线—回退—结果”记录每次决策;
  8. 在单人和团队两种模式下完成前十五分钟响应与可读交接;
  9. 识别必须引入业务、存储、安全、法务或厂商的升级条件;
  10. 组织一次不触碰生产的盲抽桌面演练,并区分工具通过与人员胜任。

一张图看懂事故控制面

detect symptom
  -> declare incident and start UTC timeline
      -> quantify impact / data risk / scope / trend / recoverability
          -> preserve writer identity / WAL / backup / evidence
              -> collect independent layers
                  -> choose PITR / HA / OVERLOAD / INTEGRITY
                      -> authorize one bounded action
                          -> verify actual result
                              -> communicate and hand off

若新证据推翻当前路线,应回到 triage;若动作越过了写入、timeline 或不可逆边界,应更新 回退定义。严重度可以升降,技术路线也可以改变,但每次改变都必须留下事实和时间。

本章目录

31.1 事件分级与响应目标

31.2 第一原则:保护现场与可恢复性

31.3 从症状路由而不是猜根因

31.4 决策、沟通与变更纪律

31.5 单人值守与团队响应

31.6 实战:盲抽症状的桌面演练

写作与验收提示

本章提供八个盲抽场景,每条后续技术路线各两个。正式参考 run 在 Pigsty FULL/L3 监控环境中对 pg-testL0-read-only 采集,再离线完成一份 solo 与一份 team 响应:

PostgreSQL                         18.6
Patroni topology                   1 primary + 2 replicas
timeline                           11
pgBackRest status / backups        0 / 6
drawn routes                       INTEGRITY + PITR
online mutation                    none
real incident injected             false
dangerous actions executed         0

验证器拒绝了 31 个声明反例和 18 个现场证据变异,并绑定 12 个实验源文件。公开摘要见 incident-run.json,完整边界见 lab-contract.md

这次通过只证明只读采集、盲包、响应合同和校验器可执行;参考响应由程序生成,不代表 任何真人通过了能力考核,更没有实施 failover、恢复备份或处理真实事故。 production_ch31_gate 保持 pending


上一章:推陈出新:版本升级与回滚策略 · 返回下卷导读 · 下一章:PITR 与误操作恢复——妙手回春 · 查看全书目录 · 查看索引中心

31.1 事件分级与响应目标

监控系统每天会产生很多 event,真正需要进入 incident response 的只是其中一部分。 本书把“事件”定义为:用户、数据、恢复能力或关键控制面出现现实风险,需要超出日常 工单节奏的协调、记录和处置。它可以由告警触发,也可以由用户投诉、审计差异、存储 报错或一次危险变更触发。

这个定义故意不要求先知道根因。先建立响应节奏,才有机会在状态继续变化之前保存证据 和恢复选择。NIST SP 800-61r3 也把检测、响应、恢复放进持续的风险管理活动,而不是把 响应理解成一次孤立的“修服务器”任务 (NIST SP 800-61r3)。

31.1.1 用户影响、数据风险、范围与持续时间

用五个轴描述事件

“数据库有问题”不是可以分级的事实。事件声明至少要填五个轴:

要回答的问题 可验证的例子
用户影响 谁现在不能完成什么? checkout 写失败 74%,只读目录正常
数据风险 已提交状态会丢、错、重复或泄漏吗? 12,400 行被错误更新,外部退款 318 笔
blast radius 哪些租户、库、表、节点、地域和依赖受影响? pg-test 写服务;只涉及订单库
time dynamics 稳定、扩散、振荡还是已停止? WAL 每分钟增长 3 GiB;错误 job 仍运行
recoverability 现有副本、WAL、备份和证据还能支持什么? archive 覆盖事发前 41 小时,尚未试恢复

每个结论都应带三个限定:

as_of       2026-07-30T02:14:00Z
source      dashboard / SQL result / ticket / business owner
confidence  observed / inferred / unknown

不要把 unknown 填成 zero。没有发现数据损坏,可能只是还没有做 manifest 或 checksum 验证;监控图没有错误,也可能是 exporter、规则或标签链路已经失效。第 25 章建立的 “signal missing 也是状态”在事故中尤其重要。

严重度是一套组织合同

下面是一种可用的起点,不是 PostgreSQL 的内置标准:

等级 典型触发 协调节奏
SEV1 核心服务大面积不可用;数据完整性或唯一 writer 不确定 立即指挥、持续记录、5~15 分钟更新
SEV2 重要功能显著受损;范围受限但可能扩大 值班负责人接管、15~30 分钟更新
SEV3 局部降级且有可靠绕行,数据风险低 工作时段协调并持续观察
SEV4 无当前用户影响的缺陷或近失事件 正常问题管理

真正的阈值必须写入组织策略:多少用户、多少收入、哪类数据、哪个合规义务。数据库 SLO 可以帮助衡量可用性,却不能单独覆盖错误数据、隐私泄露或恢复窗口丢失。

分级不是一次性动作。影响扩大、发现第二个 writer 或确认 archive 断档时要升级;入口 恢复而数据仍不可信时,不能因为 HTTP 200 回来了就降级。

31.1.2 恢复数据、恢复拓扑、释放流量压力、抢救完整性

先说要恢复什么

restart PostgreSQLpromote replicadrop slot 都是动作,不是目标。若没有目标, 团队无法判断动作成功后是否真的改善了局面。第六篇把主要响应目标分成四类:

目标 典型问题 第一优先保护 后续章节
恢复数据 已提交数据被误删、误改或要回到历史边界 writer、WAL/archive、恢复目标 ch32
恢复拓扑 primary、timeline、DCS 或服务路由不确定 唯一 writer、fencing、提交边界 ch33
释放压力 连接、锁、CPU、内存、I/O、磁盘成为约束 管理通道、关键流量、剩余容量 ch34
抢救完整性 page、checksum、WAL、index 或业务等价存疑 原始介质、独立副本、证据来源 ch35

一次事件可能同时有多个问题。例如 inactive slot 填满 pg_wal,既威胁可用性,也威胁 恢复能力。此时主要目标可以先定为“释放压力,但禁止直接删除 WAL”;当空间稳定后再 转入 consumer 重建与完整性验证。

响应目标必须可验收

把“尽快恢复”改写成状态断言:

用户状态       关键写接口恢复,错误率低于已声明阈值
数据状态       marker 边界后的 manifest 与业务不变量成立
拓扑状态       一个可写 primary,所有服务端点与 DCS 认知一致
恢复状态       新 WAL 持续归档,备份覆盖未被破坏
证据状态       时间线、动作与结果均有来源和散列

这些状态可以分步达到。为了保护数据,允许先进入 degraded read-only;为了避免满盘, 可以先 shed 非关键写,而不是立即恢复所有流量。关键是把“临时稳定状态”和“最终恢复 状态”分别命名,避免临时措施永久化。

最小化不可逆性

在信息不足时,优先级通常是:

围住继续扩散
  -> 保存恢复与取证输入
      -> 建立独立观察
          -> 执行最小范围的可逆动作
              -> 验证后再扩大

这不是“永远不动作”。磁盘还剩两分钟时必须行动,但动作仍应有精确目标、owner、停止线 和后续代价。例如扩容比删除 pg_wal 更可控;暂停一个错误 job 比停止整个集群更容易 验证;隔离恢复比在主库上直接覆盖更能保留选择。

31.1.3 严重度决定节奏,不替代技术判型

相同症状,可以是四条完全不同的路

“应用连不上数据库”至少可能表示:

错误 DDL/DML 后应用主动拒绝服务       -> PITR / data recovery
HAProxy health check 失效              -> HA / service topology
max_connections 或 pool queue 耗尽     -> overload
关键 catalog/page 无法读取导致启动失败  -> integrity

它们都可能是 SEV1,但安全动作相反。第二种情况下随机提升副本会把接入故障变成双主; 第三种情况下重启只会暂时清空连接,重试风暴会再次打满;第四种情况下在原介质上反复 启动可能覆盖证据。

因此,严重度只控制:

  • 谁必须加入、谁有决策权;
  • 更新频率和业务沟通范围;
  • 可以接受多大的临时降级;
  • 多快需要引入外部专家。

它不告诉你根因,也不降低高风险动作的证据门槛。

把第一个解释当作假设

首发告警应写成:

fact        write endpoint returned 503 from 02:03Z
hypothesis  primary may be unavailable
alternatives
            proxy health check / pool exhaustion / DCS partition /
            primary crash / network path
next test   compare endpoint, Patroni, SQL role and DCS leader

一次测试的价值不只在“证实”,也在排除。若直连现任 primary 成功,不能直接宣告数据库 健康;它只降低了“postmaster 已停止”的可能性,还要检查服务 backend、writer 唯一性 和提交确认。

两条状态线并行更新

事故记录中应分别维护:

impact line      SEV1 -> SEV2 -> resolved
technical route HA -> OVERLOAD -> recovery complete

入口恢复可能让影响下降,但若数据等价尚未验证,技术事件仍未关闭。相反,根因尚未知时 也可以通过限流、围栏或只读模式降低影响。把两条线分开,团队才不会为了“找到根因” 延误止血,也不会为了“服务绿了”过早结束调查。


返回本章目录 · 下一节:第一原则:保护现场与可恢复性 · 查看全书目录 · 查看索引中心

31.2 第一原则:保护现场与可恢复性

事故现场不是静止的。应用在重试,Patroni 在判断角色,WAL 在产生和回收,日志在轮转, backup retention 在清理历史,运维人员的查询本身也会留下连接和日志。所谓“保护现场” 不是把所有东西停住,而是知道谁还会改变什么,有选择地围住最危险的状态转移,并 为恢复保留必要的 WAL、备份、拓扑和业务边界。

31.2.1 暂停自动化、危险变更与证据覆盖

先列控制器,再决定是否暂停

Pigsty 集群中常见的主动控制器包括:

控制器 可能继续做什么 盲目停止的代价
Patroni + DCS 角色管理、重启、failover、配置协调 丧失自动保护;pause 也不是 fencing
HAProxy / VIP / pool 根据健康检查改变流量去向 现有连接与新连接可能走不同节点
应用重试与 job 继续 DML、DDL、回填、消费消息 错误或负载继续扩大
WAL archiver / backup 保存恢复链、执行保留策略 停 archive 可能填满 pg_wal 并缩短恢复窗口
autovacuum / maintenance 清理 tuple、冻结 XID、重建对象 全局停用会引入膨胀和 wraparound 风险
日志轮转与 telemetry retention 覆盖旧日志、聚合或丢弃高基数字段 取证窗口变短;全部开启 debug 又可能泄密或打满盘

正确的“冻结记录”应逐项写:

controller       refund-consumer / Patroni / backup retention
observed_state   running, config hash abc..., owner team-x
exact_scope      run_id=backfill-20260730 only
reason           stop new external side effects
expected_effect  queue remains retained; other consumers continue
resume_owner     business owner
resume_condition reconciliation manifest approved

优先围住制造新歧义的 writer、重试和错误 job。不要为了“干净现场”关闭 WAL 归档、删除 旧备份或把所有监控改成 debug;这些动作可能同时破坏恢复能力和磁盘余量。

Patroni pause 不是“时间停止”

Patroni 的 pause mode 适合大版本升级、损坏恢复等需要暂时脱离自动管理的特殊操作,但 其官方语义很具体:

  • member key 和 primary 的 leader lock 仍会更新;
  • Patroni 仍可能对运行中的成员做只读查询;
  • 人工 restart、failover/switchover 和 reinitialize 仍可执行;
  • 发现 parallel primaries 时只告警,并不会自动 demote 无 leader lock 的 primary;
  • PostgreSQL 停止后不会被自动拉起。

所以 patronictl pause 既不是写围栏,也不是禁止所有人工动作。使用前必须先记录成员、 leader、timeline、路由和现有 pause 状态,明确谁负责 resume,并验证旧 writer 已在 网络、存储或服务层被独立围住。单纯停止 Patroni 进程更不是维护模式。

暂停要有过期条件

每个临时控制都要有:

start UTC
owner
exact target
automatic expiry or review time
health signal while paused
resume command and verifier

没有恢复条件的临时限流会变成永久容量损失;遗忘的 Patroni pause 会让后续故障不再 自动恢复;遗忘的 archive/retention 变更会在几小时后制造第二次事故。事件结束条件中 必须包含“所有临时控制已复位或进入有 owner 的计划变更”。

31.2.2 记录时间、拓扑、版本、告警与最近变更

建立 incident envelope

第一份现场包不需要“把服务器全抄走”,但必须能回答:

incident id / collector / UTC / clock source
environment / cluster / service endpoint
PostgreSQL version / system identifier / timeline / LSN
primary and replica observations / Patroni and DCS state
HAProxy / pool / VIP route
backup stanza / latest backup / archive boundary
recent deploy / DDL / config / secret / storage / network change
active user impact and business marker
every artifact's source, collection command, hash and access class

system_identifier 区分不同 PostgreSQL 集群,timeline 识别 failover 后的历史分支,LSN 描述同一 timeline 上的位置;三者不能互相替代。连接到“一个叫 production 的端点” 并不能证明取到了预期数据副本。

运行中的 PostgreSQL 可以在短超时只读事务中取 control identity:

\set ON_ERROR_STOP on
SET statement_timeout = '5s';
SET lock_timeout = '500ms';
SET default_transaction_read_only = on;

BEGIN READ ONLY;
SELECT
  clock_timestamp() AS observed_at,
  current_setting('cluster_name') AS cluster_name,
  current_setting('server_version') AS server_version,
  pg_is_in_recovery() AS in_recovery,
  s.system_identifier,
  c.timeline_id,
  c.checkpoint_lsn,
  c.redo_lsn,
  c.checkpoint_time
FROM pg_control_system() AS s
CROSS JOIN pg_control_checkpoint() AS c;
COMMIT;

离线数据目录或只读取证副本可以使用 pg_controldata,但报告中 必须写清楚读取的是哪个路径、当时是否运行、工具版本和文件来源,不能把两个节点的 输出拼成一条时间线。

动态状态、累计统计和日志不是同一种证据

PostgreSQL pg_stat_activity 描述当前 backend;pg_stat_replication 描述当前 walsender;pg_stat_databasepg_stat_archiver 等是累计计数。官方文档指出累计统计不会瞬时更新,并且默认在同一 事务内缓存;clean shutdown 可以保存统计,而 crash、base backup 启动和 PITR 会重置 累计计数 (Cumulative Statistics System)。

因此:

  • failed_count = 21 不等于当前 archive 正在失败,要比较 reset、last failure、 last success 和当前 WAL;
  • deadlocks = 0 必须同时记录 stats_reset
  • 一张事务内的静态快照适合关联字段,连续变化率则要用多个有时间的样本;
  • 采集活动时默认按 state、wait 和 age 聚合,原始 SQL、bind value、client address 只在必要且获授权时进入受控证据。

PostgreSQL 日志还有轮转与覆盖策略;log_truncate_on_rotation 在特定文件命名下可以 周期性覆盖旧内容 (PostgreSQL Logging)。 应先复制事故时间窗内的精确文件并计算散列,而不是先改 logging 配置或执行一次会刷屏 的诊断。

在 Pigsty 中跨层对齐

Pigsty 的 FULL/L3 监控把 PostgreSQL、PgBouncer、Patroni、HAProxy、主机与日志用 clsinsip 等标签关联。事故时可以从 PGSQL Alert → Cluster → Service / Patroni / Replication / Persist → Instance / Session / Query 逐层缩小,而不是只看 一张总览 (Pigsty Dashboard)。

命令行可以用:

pig context -o json

获取主机、PostgreSQL、Patroni、pgBackRest 与扩展上下文 (pig context)。把它当采集起点而非唯一 真相:还要用 SQL role、Patroni REST、DCS 和客户端端点交叉验证。配置和 secret 文件 通常只保存版本号、权限和 SHA-256,不把内容直接放进普通事件频道。

证据清单本身也要可审计

artifact       patroni-members.json
observed_at    2026-07-30T02:05:03.441Z
collector      oncall-a
source         three declared REST endpoints
scope          state / role / version / timeline only
sha256         ...
redaction      connection_url and tags removed
access         incident-restricted

NIST SP 800-61r3 要求保留 incident data 的完整性和 provenance,同时保护响应记录的 机密性。散列只能证明“以后看到的 bytes 没变”,不能证明采集命令正确、时钟准确或源 本身可信;这些信息要由 provenance 补齐。

31.2.3 先克隆、隔离或只读,再做破坏性尝试

保存原件,实验只在工作副本

可能改写数据页、WAL、catalog 或时间线的动作,先问:

原始状态是否已有独立副本?
这个副本在什么一致性边界创建?
数据目录、tablespace 和 WAL 是否属于同一快照?
工作副本是否与生产网络和路由隔离?
若动作失败,原始副本和恢复链是否仍可用?

推荐分成三份:

副本 目的 允许动作
原始证据 保留最早可得状态 不直接启动或修复;受控访问
工作副本 执行 amcheck、WAL 分析、恢复和修复试验 可销毁、可重新生成
恢复候选 通过验证后准备回灌或接管 只执行 runbook 声明动作

“把目录复制了一份”并不自动得到有效 physical backup。运行中对 PGDATA 做普通文件复制 可能跨越不同页和 WAL 时刻;带 tablespace 的集群还可能跨多个文件系统。使用 pgBackRest、PostgreSQL backup API 或由存储平台保证一致性的原子快照,并记录其 crash-consistent / application-consistent 语义。

副本不等于历史

物理 replica 会重放主库已经提交的错误 DELETE,不能当成误操作前的时间胶囊; 共享故障域中的存储损坏也可能影响多个副本。每份候选都要独立标注:

system identifier
timeline and fork point
last replayed / checkpoint LSN
capture time and clock
checksum state
backup and archive ancestry
business manifest

只有这些边界与恢复目标匹配,副本才有资格成为数据来源。

“只读”有多个层次

  • SQL BEGIN READ ONLY 阻止普通数据库写事务,但不冻结其他会话、WAL replay 或后台 状态变化;
  • 只读业务路由只约束经过该端点的应用,不约束 owner、scheduler 或直连;
  • 文件系统只读保护 bytes,却可能使 PostgreSQL 无法完成 crash recovery,不能在 原始取证挂载上强行启动;
  • snapshot clone 通常是独立可写工作副本,但 CoW 底层和保留策略仍要记录。

因此应保存不可变原件,再从它派生可写工作副本,而不是把“只读”当作万能开关。

pg_waldump主要用于 debug 和教学, 官方还提醒在 server 运行时可能给出错误结果。对事故 WAL 的分析应固定工具版本、 timeline 和 segment 范围,优先在复制出的 WAL 上完成;不要为了让工具读取而随意改名 或移动现役 pg_wal 文件。

破坏性尝试必须留下退出线

下列动作默认只在工作副本:

zero_damaged_pages
pg_resetwal
手工 page/file copy
强制 catalog 修改
无来源约束的 REINDEX / VACUUM FULL
覆盖式 PITR
失败后继续使用同一个 pg_rewind target

即便工作副本“修好了”,也要再回答:丢了哪些 tuple、违反了哪些业务不变量、能否从 WAL/备份/其他副本补齐、修复步骤能否重复、生产切换后如何对账。能启动只是一条证据, 不是完整性结论。


上一节:事件分级与响应目标 · 返回本章目录 · 下一节:从症状路由而不是猜根因 · 查看全书目录 · 查看索引中心

31.3 从症状路由而不是猜根因

事故初期通常只能看到症状。路由的目的不是在十五分钟内宣布 root cause,而是把事件 交给一组不会立即破坏恢复条件的下一步协议。当前证据足以回答主要响应目标,就可以 先进入对应章节;新证据矛盾时再返回本节重新判型。

先填一行:

symptom       what was directly observed
impact        user/data/recovery consequence
known copies  primary / replicas / backup / archive / clone
uncertainty   writer / timeline / resource / integrity
next route    PITR / HA / OVERLOAD / INTEGRITY
stop line     action forbidden until which fact is known

31.3.1 误删误改与恢复目标 → ch32《PITR 与误操作恢复》

什么时候进入数据恢复路线

下列证据把问题指向 ch32:

  • 已提交的 DML、DDL、job 或业务事件使数据变成错误状态;
  • primary 和 physical replicas 都忠实包含同一错误;
  • 需要从某个历史时间、XID、LSN 或 restore point 重建旧状态;
  • 当前正确写入仍在发生,不能简单让全库倒退;
  • 备份与 WAL archive 是否覆盖目标边界尚需证明。

最常见的误判是“副本没延迟,所以可以提升”。物理复制正是把 WAL 中的错误 DELETE/UPDATE 重放到副本;零延迟只说明错误传播完成。

先固定三个边界

damage start       首个错误事务可能开始的边界
damage end         错误 writer 被围住或最后错误提交的边界
recovery target    期望隔离恢复停止的 time / XID / LSN / restore point

告警时间通常晚于 damage start;应用日志时间还可能受时区和时钟漂移影响。优先用事务 审计、DDL log、job run id、业务 marker 和 WAL/LSN 交叉定位。PostgreSQL 连续归档可以 按 time、XID、LSN 或 named restore point 恢复,并会创建新的 timeline (PITR)。

PITR 恢复的是一个 PostgreSQL 集群状态,不是原地“撤销一条 SQL”。生产通常需要:

保留现役提交
  + 隔离恢复到目标
      + 提取受影响对象或比较 manifest
          + 处理 damage end 之后的合法写入
              + 条件化回灌或整体切换

进入 ch32 前先保护输入

  • 围住精确的错误 job、角色或业务写路径;
  • 记录 source system identifier、timeline、当前 LSN 与目标时区;
  • 确认 base backup、archive min/max、失败记录和 retention owner;
  • 禁止清理 WAL、删除备份或覆盖现役主库;
  • 枚举 target-only 合法提交与外部副作用;
  • 指定业务数据 owner,数据库团队不能代替业务裁决旧值。

若归档覆盖未知,不要先启动一次随意恢复来“试试看”;先把现有 repo 和 WAL 保留下来, 再在独立目标验证。具体恢复目标、timeline 和回灌协议见 第 32 章

31.3.2 主节点、复制或 DCS 异常 → ch33《故障切换与集群重建》

从四个观察面证明拓扑

“primary down”至少要拆成:

观察面 关键事实
客户端与服务 DNS/VIP/HAProxy/PgBouncer 实际送到谁,旧连接还在哪
PostgreSQL pg_is_in_recovery()、system identifier、timeline、LSN、可否提交
Patroni 与 DCS member、leader lock、租约、pause/failsafe、候选状态
网络与主机 节点是否存活、分区方向、存储 lease/fencing 是否成立

四层结论不一致时,优先把它当作 writer identity 风险,而不是选择“看起来最新”的节点 提升。特别是旧 primary 与 DCS 多数派隔离时,它可能仍接受直连或遗留 VIP 流量。

PostgreSQL 核心只提供 standby、promotion 与复制机制,不负责完整的故障检测和通知; 官方 failover 文档强调旧 primary 必须有机制知道自己不再是 primary,通常称为 STONITH/fencing,以避免两个节点都认为自己可写 (PostgreSQL Failover)。 在 Pigsty 中应由 Patroni/DCS 与声明的服务机制协调,而不是绕过控制面随意 pg_ctl promote

failover 前的最低证明

candidate       system id matches; role and replay state known
data boundary   receive / flush / replay LSN and expected RPO
old writer      stopped or independently fenced
DCS             quorum and leader ownership understood
routing         exact service owner and drain behavior known
archive/slots   promotion后的归档、physical/logical slot continuity assessed
rollback        old primary rejoin requires rewind or rebuild, not直接开机

同步复制提高已确认事务的耐久承诺,但仍要根据当时 sync_state、配置和客户端收到的 commit acknowledgment 判断;“lag 面板为 0”不是 fencing 证明。

若观察到两个 timeline 都有独占提交,事件已经同时包含数据 reconciliation。先保存两边 证据,不能用 pg_rewind 把旧分支直接覆盖。pg_rewind 官方也警告失败后的 target 数据目录可能不可恢复,应改用新备份 (pg_rewind)。

完整的候选选择、切换、fencing、旧主重建与服务验收见 第 33 章

31.3.3 连接、延迟、CPU、内存、I/O 或磁盘表象 → ch34《过载保护与资源故障判型》

可用性故障不一定是 HA 故障

下列症状优先进入资源判型:

  • connection timeout,但 primary/route 身份一致;
  • pool wait、max_connections、worker 或 thread pool 达上限;
  • TPS 下降伴随 lock、client、I/O、WAL flush 等 wait;
  • CPU run queue、内存回收/OOM、I/O latency 或文件系统空间异常;
  • pg_wal、temp、日志、backup 或 telemetry retention 增长;
  • 单一查询、租户、job 或重试风暴占据大部分资源。

PostgreSQL 官方建议把累计统计与 pstopiostatvmstat 等 OS 观察结合, 因为数据库视图不能完整描述 kernel page cache、设备和调度器 (Monitoring Database Activity)。

按资源链逐层问

admission   请求量、重试、HAProxy queue、PgBouncer wait
sessions    connection slots、idle in transaction、transaction age
contention  locks、buffer pin、WAL/sync、client waits
compute     CPU、run queue、memory、swap、OOM、NUMA
storage     latency、queue depth、throughput、filesystem/inode
persistence checkpoint、WAL generation、archive、slots、replica replay

单个指标不能宣布根因。CPU 43% 时连接槽仍可耗尽;磁盘吞吐未满时 fsync tail latency 仍可拖慢 commit;ClientRead 可能表示 backend 等客户端,而不是数据库内部繁忙。 statewait_event 也是独立字段,官方提醒两者之间可能出现短暂不一致。

先保住管理面和剩余容量

优先动作通常是:

  • 在入口停止异常 deploy、批任务或非关键重试;
  • 保留管理连接,设置诊断查询超时;
  • 分批 cancel 精确 query,必要时再 terminate 已复核 session;
  • 限制新工作而不是把 max_connections 一路调大;
  • 扩容或释放已声明的应急预留,不手删数据库文件;
  • 对 slot、archive、replica 和 backup 分别确认 owner 后再改变保留。

Pigsty 故障指南把数据、pg_wal、日志、本地备份、监控数据和对象存储列为不同的空间 来源 (Pigsty Troubleshooting)。先用 df 确认文件系统,再按目录类别和增长率定位;du 很重时也要限制范围和优先级。 绝不能直接删除 pg_wal 中看似旧的文件。

过载中的负载保护、连接排空、磁盘急救、内存与 I/O 判型见 第 34 章

31.3.4 checksum、索引、排序或逻辑不一致 → ch35《数据抢救与工程取证》

先区分“不一致在哪一层”

证据 主要回答 不能单独证明
data checksum 读取到的物理 page 是否匹配页内 checksum 业务值正确、所有页都已读
amcheck B-tree/heap 结构的特定不变量 存储设备健康、业务副本等价
collation version + dependency 排序 provider 版本与依赖对象范围 索引已经重建、冲突值已裁决
seq/index result comparison 某查询路径是否出现差异 全库无其他损坏
business manifest 行数、摘要、外键、金额等业务不变量 page/WAL 物理完好
backup restore 某份备份能恢复并读取声明对象 当前 primary 正确、其他时间窗可恢复

任何一项失败,都先记录 database、relation、fork、block、timeline、LSN、查询路径和 首次时间。任何一项通过,也只缩小它所覆盖的故障面。

修复会覆盖原因

发现 index scan 漏行时立刻 REINDEX,可能恢复服务,却同时丢掉:

  • 修复前 seq/index 集合;
  • 原 relfilenode 与受影响 block;
  • collation 版本和升级动作顺序;
  • 存储错误、checksum 与其他副本对照;
  • 业务上冲突键的裁决机会。

更安全的顺序是:

围住受影响写路径
  -> 保存原始存储或一致快照
      -> 建立对象与业务范围映射
          -> 在工作副本运行分层检测
              -> 比较独立副本和备份
                  -> 才选择重建、提取、回灌或整体替换

amcheck 提供 B-tree 检查和 verify_heapam,但不同函数的锁、代价与误报/漏报边界不同, 应按版本阅读 amcheck 文档并先在克隆测量。 pg_checksums --check 要求集群 cleanly shut down,不能在现役 primary 上临时跑 (pg_checksums)。

若一个 page 的候选来自 replica 或备份,仍不能直接复制进 live data file;WAL、tuple visibility、FSM/VM、索引和业务约束可能不在同一边界。具体的隔离、抢救、证据链和 重建协议见第 35 章


上一节:第一原则:保护现场与可恢复性 · 返回本章目录 · 下一节:决策、沟通与变更纪律 · 查看全书目录 · 查看索引中心

31.4 决策、沟通与变更纪律

故障不会因为开了 incident channel 就停止变化。没有统一决策格式时,聊天记录很快会 混合事实、猜测、建议、已经执行的命令和转述结果;十分钟后,团队甚至无法回答“谁在 哪台机器上改了什么”。响应纪律的作用不是增加仪式,而是让并行思考最后汇聚成一个 可审计的系统状态转移。

31.4.1 事实、假设、动作、预期与停止条件

五类记录不能混写

类型 含义 示例
fact 有来源、时间和范围的直接观察 02:03Z 写端点 503;直连 primary 只读探针成功
hypothesis 对事实的可证伪解释 HAProxy health credential 可能失效
decision 在不确定性下选择目标或约束 在证明 writer 唯一前禁止 promote
action 获授权并实际执行的状态改变或测试 回退 exact health-check secret version
result 动作后重新观察到的事实 三个 backend 中仅 DCS leader healthy

“Patroni 有问题,已处理”不属于任何合格类型。它没有时间、来源、动作和结果,也让接班 人无法判断现在是否安全。

把每个动作写成有界实验

一次可执行动作至少包含:

minute / UTC
actor and exact target
facts and hypothesis that justify it
risk class and approver
rendered action
expected observable result
time budget
stop condition
rollback or compensation
actual result and evidence id

例如:

fact       HAProxy has zero healthy backends; all Patroni members agree
            pg-test-1 is primary on timeline 11
hypothesis health-check secret v42 is invalid
action     access owner rolls back only secret v42 -> v41
expected   pg-test-1 becomes healthy within 10 s; no role change
stop       any replica becomes write backend, or primary identity changes
rollback   restore v42 and keep write endpoint fenced
result     pending

预期结果必须是可以再观察的状态,不是“应该修好”。停止条件写在命令之前,因为执行后 人容易被 sunk cost 推着继续。没有可行 rollback 时,要明确写 irreversible after <boundary>,而不是填一个虚假的“恢复备份”。

时间线记录的是认识变化

02:03 fact       endpoint 503
02:05 hypothesis primary may be down
02:07 fact       SQL primary running; Patroni/DCS agree
02:08 hypothesis access health check failed
02:10 decision   hold failover; access rollback authorized
02:11 action     health credential v42 -> v41
02:12 result     backend healthy; canary commit succeeds
02:14 decision   impact downgraded; data validation remains open

早期假设错误并不可耻,偷偷改写历史才危险。保留被推翻的假设和证据,可以解释为什么 没有执行另一条路径,也能在复盘中发现告警或 runbook 的诱导性。

所有时间以 UTC 为主,并记录 collector clock。若应用、主机和数据库时钟有偏差,先 保存各自时间再估算 offset,不要直接修改原始日志时间。

31.4.2 单一指挥、记录员、执行者与业务接口

单一指挥不等于单一思考

团队模式至少有四个逻辑角色:

角色 负责 不负责
incident commander 目标、优先级、风险门、角色与更新节奏 亲自解释所有技术细节
operator 预检、读回目标、执行一个获批动作、报告原始结果 私自扩大范围或并行试命令
scribe UTC 时间线、证据 ID、决定、结果和待办 把聊天摘要伪装成事实
business liaison 用户影响、业务优先级、外部副作用和状态更新 替数据库团队选择技术命令

还可以增加 HA、storage、security、application 等 subject-matter expert。NIST SP 800-61r3 强调现代响应依赖领导、事件处理者、技术人员、法律、公共沟通和资产 owner 等多方参与;这些角色要由一个协调实体汇合,而不是各自行动。

IC 不必是职位最高或 PostgreSQL 最熟的人,而应能维护共享状态、拒绝未经验证的高风险 动作、召集正确 owner。技术负责人可以给出候选方案,最终由 IC 根据业务目标和授权 选择状态转移。

一次只允许一个命令 owner

可以并行:

  • 一组读取 Patroni/DCS;
  • 一组确认应用影响和最近变更;
  • 一组检查 backup/archive;
  • scribe 持续整理时间线。

不能并行:

  • 两个人同时修改同一 cluster;
  • 一边 failover,一边重启旧 primary;
  • 一边 drop slot,一边尝试恢复 consumer;
  • 一边回切路由,一边解除写围栏。

每个有副作用的系统在一个时刻只有一个 operator token。执行前读回:

incident / environment / cluster / node
current role / system id / timeline
exact command or API request
expected / timeout / stop / rollback
approver

执行者贴回 exit status、时间与证据引用,不只说“done”。讨论频道、命令频道和业务状态 频道最好分开,避免建议被误当命令、原始日志被转发给无权限人员。

对外更新只说已知边界

一条合格更新包含:

known       checkout writes degraded since 02:03Z
unknown     final cause and whether 318 requests have external side effects
impact      74% write failure; reads unaffected
doing       bad deploy fenced; recovery and topology evidence being checked
next update 02:20Z, or earlier on material change

不要为了显得确定而宣布未经验证的 root cause 或恢复时间;也不要把技术日志直接扔给 业务方。固定下一次更新时间可以减少无序追问,让 operator 保持注意力。

31.4.3 高风险动作的复核、审批与回退

风险分级针对动作,不针对职位

级别 例子 最低控制
R0 观察 短超时聚合 SQL、只读 REST、配置 hash 范围、来源、timeout
R1 可逆 containment 限制一个 job、摘掉一类非关键流量 exact target、owner、验证、恢复条件
R2 受控状态变更 terminate 精确会话、有限且可回退的路由调整、drop exact rebuildable slot、隔离 failover 演练 IC 批准、独立技术复核、回退/补偿
R3 生产敏感/潜在不可逆 生产 failover 或 authority cutover、覆盖恢复、rewind、pg_resetwal、手工页修复 保存原件、明确授权、双人 command review、停止线

熟练 DBA 执行 R3 仍然是 R3。SEV1 可以缩短等待,但不能让 system identifier、 timeline、目标路径和 writer fencing 变得不重要。

批准之前解析最终目标

审批材料不能只有模板变量:

bad:  drop slot ${SLOT}
good: cluster=pg-prod, system_id=..., primary=pg-prod-2,
      slot=cdc_legacy, active=false, retained=1.31TiB,
      consumer owner=..., rebuild approved=..., command hash=...

最终执行包应包含:

  1. 当前证据与目标状态;
  2. exact host/service/database/object/PID/path;
  3. 命令渲染结果,而不是未解析变量、glob 或 command substitution;
  4. 前置条件和权限;
  5. 预计持续时间、负载与观察查询;
  6. stop condition;
  7. rollback、compensation 或不可逆边界;
  8. operator、reviewer 与 IC 的时间戳。

审批证明组织授权,不证明命令技术上正确。reviewer 必须独立检查目标、方向和恢复前提, 不能只回一个“LGTM”。

回退、补偿和重建不是同义词

类型 含义 例子
rollback 恢复原状态且没有新独占事实 目标写入前切回 copy-mode 旧集群
compensation 原动作无法抹去,新增反向业务动作 已发退款后执行会计冲正
rebuild 丢弃一个非权威副本,从权威来源重建 failover 后重建旧 primary 为 replica

DROP replication slot 无法 rollback;只能让 consumer 从新快照重建。目标数据库已接受 独占写后,切回旧库需要反向对账,不再是路由 rollback。pg_rewind 失败后不能假设重跑 会恢复 target;官方建议此时取新备份。

高风险动作完成后必须运行独立的 verifier。执行命令的人报告成功还不够:服务 owner 验证端点,数据 owner 验证业务 manifest,HA owner 验证 writer/timeline,backup owner 验证归档与恢复链。最后再决定扩大流量或进入观察窗口。


上一节:从症状路由而不是猜根因 · 返回本章目录 · 下一节:单人值守与团队响应 · 查看全书目录 · 查看索引中心

31.5 单人值守与团队响应

同一响应协议既要能被凌晨单人值守执行,也要能在几十人加入时保持一致。差别不在技术 正确性:单人把角色按时间串行切换,团队把只读调查并行化;两种模式都要有一个状态、 一个时间线、一个命令 owner 和明确的升级条件。

31.5.1 单人时先稳定、记录,再逐级升级

前十五分钟按顺序切换角色

分钟 逻辑角色 产物
0~2 IC incident ID、UTC 起点、当前用户影响、下一次更新时间
2~5 scribe 首发事实、告警来源、最近变更、当前自动化和 writer 身份
5~9 operator L0 只读现场包;连接/HA/资源/完整性最小证据
9~12 technical lead 候选路线、被排除路线、第一安全动作与 stop line
12~15 liaison 呼叫所需 owner、发布 known/unknown/impact/next update

一个人可以戴四顶帽子,但要按顺序。先把“准备执行什么”写进时间线,再切到 shell; 执行后贴原始 exit status 和 verifier 结果,再回到记录。多开几个终端不会让一个人获得 真正的独立复核,只会增加连错环境和忘记动作的概率。

单人第一目标是保持可操作

  • 保留一个管理连接或 out-of-band 路径;
  • 给诊断 SQL、SSH 和 HTTP probe 设置短 timeout;
  • 先停止精确的错误来源,不做全局“重启看看”;
  • 把当前 cluster、node、role、system id、timeline 放在命令前;
  • 不在普通聊天粘贴连接串、原始 SQL、业务行或私钥;
  • 每五分钟停一次,问“现有动作是否仍匹配响应目标”。

若手已经在高压下连续操作,最有价值的动作往往是叫醒第二个人。独立读回一个 target 可以阻止方向反了的 failover、误删 slot 或在错误 data directory 上恢复。

预先写死升级触发器

单人不能因为“还没完全搞清楚”而无限延迟呼叫。下列任一项应立即升级:

writer uniqueness unknown
checksum / WAL / page / storage media error
backup or archive recovery window unknown
R2/R3 action required
regulated or financial data involved
credentials / intrusion / malicious change suspected
impact still growing after first containment
fifteen minutes内没有形成可证伪路线

真正存在“几分钟后满盘”的倒计时,也只授权最小保护动作,例如按声明 marker 释放 emergency reserve 或在入口 shed load;它不自动授权删除 pg_wal。按组织 break-glass 政策执行后,必须立刻补齐 owner、证据和独立复核。

防范单人认知陷阱

陷阱 自检问题
anchoring 除首发告警外,还有哪两个解释?
action bias 不做这条命令,未来五分钟会丢掉什么?
confirmation bias 哪条最便宜的证据会推翻我的路线?
sunk cost stop condition 是否已经触发?
fatigue 我能否准确复述 target、方向和不可逆边界?

如果最后一个问题答不清,应停止有副作用的操作并交接,而不是靠咖啡继续。

31.5.2 团队时避免多人同时改同一系统

两分钟内建立响应拓扑

IC                 one person
database operator  one active command token
scribe             one canonical timeline
business liaison   one outward status owner
read-only tracks   access / HA / database / host-storage / backup

人员不足时可以合并角色,人员过多时也不要复制角色。新加入者先读当前摘要和 stop line, 再领取一个问题;不要从头重复所有探针或提出第五次“要不要重启”。

并行问题,不并行状态改变

一个好的分工可能是:

track A  从客户端、HAProxy、PgBouncer 还原实际路径
track B  读取 Patroni、DCS、PostgreSQL role/timeline
track C  检查 host resource、storage 和最近 kernel error
track D  检查 backup/archive/recovery coverage
track E  由业务 owner 确认影响键和外部副作用

这些 track 默认 R0。任何人提出 R1~R3 动作,都先写成 decision packet;IC 指定唯一 operator 和 verifier。若已有动作执行中,新动作必须说明是等待、互斥还是可以安全并行。

“一个人重启 pool、另一个人切流、第三个人 terminate session”无法从结果中辨别哪个 动作有效,还可能在连接排空过程中制造未知事务。

使用 read-back 和 closed loop

IC:

Authorize action A on cluster pg-x only, node pg-x-2,
expected B within 30 seconds, stop on C, rollback D.

operator 复述 target 和条件,执行后报告:

started / completed UTC
exact command or API request hash
exit status
expected signal B actual value
stop condition C true/false
evidence id

verifier 从独立观察面确认。只有 IC 宣布状态转移后,时间线才从 authorized 变成 completed/verified。这样可以避免“某人说 done”被误读为业务已恢复。

交接要传递未决风险

轮班摘要不应只列已做事项:

current severity / impact / route
authoritative writer and timeline
active fences, pauses, silences and temporary config
last verified data/recovery boundary
actions completed and their results
hypotheses rejected / still open
next action, owner, approval and stop line
irreversible boundaries already crossed
next stakeholder update

接班 operator 对关键身份做一次 read-back。长事件要安排休息和轮换;疲劳是会改变系统 风险的运行条件,不是个人意志问题。

31.5.3 何时必须请求业务、存储、安全或厂商协助

技术 owner 不能替代语义 owner

需要谁 强制触发条件 需要对方决定什么
业务/数据 owner 错误值、target-only 写、退款/通知等副作用 权威状态、补偿规则、可接受降级
应用 owner 重试、连接池、幂等键、deploy/job 相关 围栏范围、回放安全、客户端验收
存储/基础设施 media error、snapshot、空间、fsync/latency 异常 一致快照、fencing、扩容与设备替换
网络/DCS owner leader lock、分区、VIP/route 不一致 多数派、网络围栏和路径恢复
安全/法务/隐私 凭据泄漏、恶意变更、受监管数据、证据可能用于调查 保全、通知、访问范围和 chain of custody
扩展/厂商 非核心插件、内核崩溃、未文档格式或支持合同相关 已知缺陷、受支持恢复路径、补丁

数据库团队可以证明“这 77 行在恢复点之后被合法修改”,却不能决定哪一笔退款该撤销; 存储团队可以生成 crash-consistent snapshot,却不能宣称业务事务完整;厂商可以建议 修复步骤,最终生产授权仍属于本组织。

外部求助包要小而完整

发送前先写一个明确问题:

We need to determine whether PG18.6 can safely start this copied
data directory after error X, without modifying the preserved source.

随包提供:

  • incident ID、版本、OS/架构、扩展与精确错误;
  • system identifier、timeline/LSN 和简化拓扑;
  • 已执行动作、结果和 stop line;
  • 最小可复现样本或隔离工作副本;
  • 相关日志的精确时间窗、散列与脱敏说明;
  • 业务影响和所需答复时限。

不要默认发送整个 postgresql.confpg_hba.conf、Patroni YAML、完整日志、core dump 或业务表;它们可能含凭据、连接串、SQL、参数值和个人数据。先按合同、法律和最小必要 原则确认接收渠道与访问权限。

提前定义“必须停手”

以下情况应停止本地修复尝试,转为保存、隔离和专家协作:

  • 无法确认目标 data directory 的 system identifier 或 timeline;
  • 两个 writer 都有独占提交;
  • pg_resetwal、手工 page copy、catalog hack 等成为唯一候选;
  • pg_rewind 已失败,target 状态未知;
  • 存储错误仍增长,工作副本来源不可靠;
  • 扩展数据格式或加密密钥不受当前团队理解;
  • 任何动作可能影响法定通知或证据可采性。

及时升级不是“把事故甩出去”。IC 仍要跟踪请求 ID、对方假设、建议适用版本、执行 授权和验证结果,并把外部建议纳入同一决策日志。


上一节:决策、沟通与变更纪律 · 返回本章目录 · 下一节:实战:盲抽症状的桌面演练 · 查看全书目录 · 查看索引中心

31.6 实战:盲抽症状的桌面演练

本节不对数据库制造故障。它先从 Pigsty FULL/L3 沙箱采集一份最小只读上下文,再从 八个场景中盲抽两个,把本章协议压缩进前十五分钟。这里有两个容易混淆的 “level”:

Pigsty FULL/L3   监控接入层级:PG + node + pool + HA + logs
experiment L0   风险层级:read-only,在线 mutation 为零

参考 run 的响应由程序生成,用来证明合同和 validator 自洽;真正的人员训练必须由主持人 只发盲包、按请求提供证据卡并独立评分。

31.6.1 随机症状、误导性首发告警与缺失信息

先读合同

完整边界见 lab-contract.md,环境与验收条件见 requirements.json,响应协议见 incident-contract.json,拓扑与路由见 topology.mmd,参与者填写 response-template.json

先做纯静态检查:

static/labs/ch31/task.sh lint

完整参考 run 需要连接已经确认的四节点开发沙箱,但在线阶段只有只读 SQL、Patroni REST 和 pgBackRest info

export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch31.XXXXXX"
)"
export PG36_CH31_SEED=pg36-ch31-reference-v1

static/labs/ch31/task.sh all

也可以分步执行:

static/labs/ch31/task.sh capture
static/labs/ch31/task.sh exercise
static/labs/ch31/task.sh verify
static/labs/ch31/task.sh review

capture 显式使用 BEGIN READ ONLY、短 statement/lock timeout,只保存聚合会话、复制、 slot、archive、control identity 和缩减后的 HA/backup 上下文。它不读取业务行、不保存 query text 或原始日志。exercise 完全离线,不 pause Patroni、不 failover、不重启、不 terminate connection、不改路由、不恢复 backup。

八个场景让首发症状“不够用”

场景 首发误导 正确主路线
订单数下降、复制延迟为零 把同步副本误当历史副本 PITR
数据库健康、退款突然增长 把技术健康误当语义正确 PITR
写端点 503、primary up 把接入故障误当 primary 故障 HA
DCS timeout、节点自称 primary 相信单节点自报角色 HA
connection timeout、CPU 43% 只看 CPU,忽略连接槽和 pool OVERLOAD
满盘且 replica lag 把 lag 当根因,想手删 WAL OVERLOAD
checksum mismatch、lag 为零 把同步状态当完整性证明 INTEGRITY
seq/index 结果不同 REFRESH VERSION 当重建 INTEGRITY

每个 blind packet 只包含:

  • initial signal 与已报告用户影响;
  • 可以请求的证据卡 ID、问题、层次和时间成本;
  • 十五分钟 deadline 与作答规则。

它不会包含 hidden truth、严重度答案、route、required card、safe/dangerous action 或 证据 observation。facilitator-pack.json 才保存这些内容。场景源文件本身是公开教材, 所以这种隔离是教学流程而非密码学保密;正式考核应由主持人控制文件访问或使用私有 派生场景。

“没有证据”也要进入记录

参与者请求一张暂时不可得的证据卡时,主持人应回答:

unavailable / permission denied / collector down / retention expired

而不是替换成“正常”。参与者要决定是换独立观察面、升级权限 owner、缩小动作,还是 因为关键事实缺失而保持 stop line。事故能力的一部分就是在证据不完整时拒绝不安全的 确定性。

31.6.2 分别按单人和团队模式完成前十五分钟

主持方式

  1. 主持人运行参考工具并单独保管 evidence directory;
  2. 只把一个 blind packet 与空白 response template 发给参与者;
  3. 参与者先声明五轴影响和当前 severity;
  4. 参与者按 ID 请求证据卡,主持人从 facilitator pack 逐张返回 observation;
  5. 每个有副作用的建议都要求 risk、owner、expected、stop、rollback;
  6. 第 15 分钟停止,参与者发布状态更新并完成 handoff;
  7. 最后才揭示 hidden truth、required cards、safe/dangerous actions。

参考 run 固定 seed 抽到:

solo  integrity-collation-index  -> INTEGRITY
team  data-accidental-delete     -> PITR

随机 seed 可改变题目,但 runner 会保证两题属于不同路线。不要重复抽到熟题就假装完成 盲测;应记录 seed、场景使用历史和参与者是否见过答案。

solo 与 team 使用同一张答卷

solo 模式四个 role 都由 solo-oncall 承担,但按时间串行切换;team 模式要求 IC、 operator、scribe、business liaison 是四个不同 actor。两者都必须做到:

required evidence complete minute < route decision minute <= 15
all actions belong to declared safe action set
dangerous_actions_executed = []
decision log starts at minute 0 and ends at minute 15
at least one stakeholder update before or at minute 15
production_authorized = false

团队可以让多个 R0 track 并行,但仍只有一个 operator token;单人不能用“我自己复核过” 冒充独立 R2/R3 review。

一份可复用评分表

项目 分值 失分例
五轴影响与 severity 15 把 unknown 写成无影响
现场和恢复能力保护 20 未围住 writer;建议删除 WAL
跨层证据选择 20 只看一张 dashboard
技术 route 与目标 15 severity 直接决定 failover
决策日志与 stop line 15 动作无预期、回退和结果
角色、升级与沟通 10 多人同时改;不报 unknown
机密与生产边界 5 粘贴凭据;宣称已获生产授权

发生以下任一项应直接判定需要重练,不用总分掩盖:

  • 在证明 writer 唯一前 promote;
  • 删除现役 pg_wal
  • 在原始证据上执行覆盖式修复;
  • 未经业务 owner 直接覆盖事后合法写入;
  • 把 facilitator 未提供的信息自行当成事实。

程序生成的 responses.json 只是一份 schema-complete reference,不是答题者成绩。真人 评分要保存自己的 response、主持人发卡时间和讨论录音/记录权限,并由未参与执行的人 复核。

31.6.3 在 Pigsty L3 输出时间线、决策日志、证据包与路由选择

正式参考现场

正式 run 在 pg-test 捕获:

PostgreSQL version              18.6
system identifier              bound to pgBackRest stanza identity
timeline                       11
Patroni                         1 running primary + 2 running replicas
physical replication streams   2, max sent/replay gap 0 bytes
pgBackRest status / backups     0 / 6
SQL transaction                 READ ONLY
raw query / raw log             absent / absent
online mutation                 none

现场的 pg_stat_archiver.failed_count 为 21,但 last failure 在 2026-07-29T18:57:58Z,last archived success 在 2026-07-30T01:36:16Z。这正说明累计失败数不能直接解释成“当前归档故障”。本章不因 看到 21 就 reset 统计或修改 archive;它保留 reset 与时间,交给真实趋势和 backup 证据解释。

“repo 中有 6 份 backup”也不等于恢复通过。本次没有执行 restore,公开结论只写 pgbackrest_status=0 和 backup count;第 21、32 章的恢复证据才回答可恢复性。

私有证据与公开摘要分层

证据目录包含:

preflight-evidence.json
blind-packets.json
facilitator-pack.json
responses.json
exercise-evidence.json
negative-report.json
validation-report.json
public-summary.json
review.txt

目录权限为 0700、文件为 0600。review 拒绝 credential URI、SCRAM verifier、 private key、clear password、raw SQL/log field,并检查 preflight、盲包、答案、 exercise 与 public summary 属于同一 run。

公开参考摘要见 incident-run.json。它只保留环境轮廓、路线、安全 边界与验收计数,不公开 facilitator 答案和 live 细节。

反例验证防止“格式漂亮的伪响应”

正式 validator 要求:

31 declared counterexamples rejected
18 live evidence mutants rejected
12 source files hash-bound

它会拒绝:

  • severity 被允许直接选择 route;
  • blind packet 泄露 hidden truth 或 expected route;
  • required evidence 未齐就决策;
  • solo/team role 语义不成立;
  • action 属于 dangerous set 或晚于 minute 15;
  • decision log 缺 expected/stop/rollback;
  • live capture 不是 READ ONLY、cluster/timeline/topology 不匹配;
  • backup system identifier 不同;
  • source/upstream/evidence hash 被替换;
  • 任何 production gate 被打开。

对同一份私有证据可以重复:

static/labs/ch31/task.sh verify
static/labs/ch31/task.sh review

但修改实验源文件、盲包或 response 后,不能继续复用旧 exercise hash;应开启新 run, 保留旧包作为历史。

本章交付物不是根因报告

合格的第 31 章 handoff 应包括:

severity and five-axis basis
current user/data/recovery impact
authoritative or still-unknown writer/timeline
active fences and automation state
evidence manifest and decision timeline
chosen route and alternatives rejected
first safe action / stop line / owner
next update and escalation requests

参考 run 最终结论是:

read-only-context-and-tabletop-protocol-demonstrated
human_competency_claimed = false
production_ch31_gate = pending

下一章从第一条路线开始:面对已经提交的误删误改,怎样选准恢复目标、在隔离实例完成 PITR,并把历史正确状态与事后合法写入重新合并。


上一节:单人值守与团队响应 · 返回本章目录 · 下一章:PITR 与误操作恢复——妙手回春 · 查看全书目录 · 查看索引中心

32 PITR 与误操作恢复——妙手回春

第 21 章已经证明一条基础备份与连续 WAL 可以在隔离实例中恢复;第 31 章又要求事故 响应先保护现场、明确目标,再授权动作。本章把两者接起来,处理一种尤其危险的情况:

PostgreSQL 没有宕机,复制、监控甚至备份都显示正常,但一笔已经提交的事务把业务 数据改错了。

同步副本会忠实重放错误,HA 切换不会让错误消失;物理 PITR 又会把整个 cluster 带回旧状态,连错误之后的合法写入一起拿走。因此“执行恢复命令”只占很小一部分, 真正的工作是:

界定事故
  -> 固定提交边界与事后合法写集
      -> 选择能覆盖目标的 backup + WAL + timeline
          -> 在隔离环境恢复多个候选
              -> 证明目标事务存在或不存在
                  -> 合并目标之后的合法事实
                      -> 控制外部副作用
                          -> 决定提取、修补或整库切换

本章正式实验故意恢复两个候选。第一个使用默认的 inclusive XID 语义,错误事务被重放, 所以必须否决;第二个使用 exclusive XID,安全事务存在而错误事务不存在,才可作为历史 正确状态。实验随后证明一个更重要的反例:直接切到第二个候选会丢掉目标之后 100 笔合法 写入,只有经过审计对账,最终业务状态才完整。

学习完成标准

完成本章后,读者应能:

  1. 区分误更新、误删除、误 DDL、批处理越界和外部副作用事故;
  2. 写出影响对象、错误事务、持续写入、事后合法写与业务真相来源;
  3. 在逻辑补偿、从恢复副本提取对象、整库 PITR 之间作出有条件的选择;
  4. 正确比较 time、XID、LSN、name、immediate 与 end-of-WAL 目标;
  5. 解释 recovery_target_inclusive 为什么会决定错误事务是否被保留;
  6. 理解 XID 按事务开始分配而按提交顺序恢复,不能把数字大小当提交顺序;
  7. 根据 backup lineage 与 timeline history 选择 currentlatest 或明确 timeline;
  8. 把时区、时钟偏差、日志延迟和事务时间语义写入目标误差预算;
  9. 使用 Pigsty pig pitr --plan 审阅真实恢复计划,并区分 managed restore 与 side restore;
  10. 在不覆盖原集群、不接入 Patroni、不开放 TCP、不回写 archive 的环境中恢复候选;
  11. 用 PostgreSQL 日志、recovery 配置、控制信息与 SQL 共同判断恢复进度;
  12. 以对象 manifest、关键交易和跨表不变量验证数据,而不是只看实例能启动;
  13. 用条件写入把历史正确值与目标之后的合法增量合并,并在不匹配时整批回滚;
  14. 隔离 outbox、定时任务、CDC、邮件、支付和 webhook,防止恢复副本制造第二次事故;
  15. 区分“历史候选正确”“对账完成”“服务可切换”和“生产已批准”;
  16. 输出一份可复核的 target、backup、WAL、timeline、验证、清理与决策证据包。

这不是“把时间拨回去”

物理 PITR 的输入和输出可以写成:

C(T,τ)=B(Tb)+W(TbT,τ) C(T,\tau)=B(T_b)+W(T_b \rightarrow T,\tau)

其中:

  • $B$ 是结束于目标之前的某份物理基础备份;
  • $W$ 是从该备份一致点开始的连续 WAL;
  • $T$ 是恢复目标;
  • $\tau$ 是所选 timeline;
  • $C$ 是整个 PostgreSQL cluster 在该历史分支上的一致状态。

这个公式没有说 $C$ 就是现在应交付给用户的业务状态。若错误提交点为 $T_e$,当前时刻 为 $T_n$,则还存在两类信息:

good_before   在 Te 之前且应保留的事实
bad_at        在 Te 提交的错误事务及其副作用
good_after    在 Te 之后仍然合法的提交

exclusive PITR 可以得到 good_before,但不会自动得到 good_after;外部支付、邮件或消息 也不在物理数据目录里。最终状态通常更接近:

Sfinal=Sexclusive before TeΔgood afterexternal reconciliation S_{\text{final}} = S_{\text{exclusive before }T_e} \oplus \Delta_{\text{good after}} \oplus \text{external reconciliation}

这里的 $\oplus$ 不是盲目覆盖,而是带身份、顺序、幂等键和前置状态检查的业务合并。

从事故到交付的七道门

决策门 必须回答 缺失时
事故门 哪些对象错、哪笔提交错、错误是否仍继续 只保护现场,不恢复
真相门 历史正确值与事后合法事实分别来自哪里 升级业务 owner
血缘门 哪份 backup 结束于目标前,WAL 是否连续,timeline 是哪条 阻断 restore
隔离门 是否保留原集群、关闭路由与外部副作用 不启动候选
目标门 inclusive/exclusive 的可证伪预期是什么 至少恢复两个候选
数据门 manifest、不变量与副作用对账是否通过 不切流
服务门 写围栏、路由、观察窗、回退与 owner 是否齐备 production gate=pending

严重度不能替代这些门。即便是 SEV1,也不能因为“时间紧”就跳过 timeline 或外部副作用 判断;即便只错一行,也可能因为已经触发支付而需要跨系统对账。

与第 21 章的分工

第 21 章 本章
从损失场景设计备份体系 从已发生的误操作界定恢复边界
证明 full backup + WAL 可恢复 证明目标事务能被精确包含或排除
使用预先创建的 named restore point 从 source audit 独立取得真实 damage XID
验证 keep 存在、discard 不存在 验证 safe、damage、post-target 三类提交
保留停止后的恢复目录 验证后删除两个一次性候选
不处理事后合法写入 实际对账并保留 100 笔合法写

两章共同坚持:repository status=ok 不是恢复证明,第一条查询成功也不是恢复完成, 恢复工具成功更不是业务可用。

正式实验与结论边界

参考实验运行于已确认的四节点 Pigsty 开发沙箱:

target             pg36-l2-vagrant/pg-test
source             pg-test-1, managed primary
restore host       pg-test-3, live replica remains unchanged
PostgreSQL         18.6
Pig                1.5.1
pgBackRest         2.59.0
fixture            5,000 accounts
random victims     1,000 contiguous accounts
safe-before        +100 cents to every victim
damage             set 1,000 balances to zero + 1,000 pending outbox rows
good-after         +700 cents to first 100 victims
target             damage XID from source audit
restore            exact fresh full backup, timeline=current
isolation          custom -D, no restart, Unix socket only, archive off

正式观测:

fresh full backup                    2.090 s
backup logical / repository delta    42,355,954 / 5,751,304 bytes
pgBackRest check                     0.630 s
target identification               0.183 s
inclusive plan / restore             0.219 s / 3.040 s
inclusive start -> promoted/validate 0.961 s / 0.340 s
inclusive damage present             true
inclusive accepted                   false
exclusive plan / restore             0.214 s / 2.974 s
exclusive start -> promoted/validate 0.939 s / 0.503 s
exclusive safe present               true
exclusive damage/post present        false / false
exclusive accepted                   true
raw exclusive legitimate writes lost 100
reconciled legitimate writes kept    100
reconciliation                      0.200 s
conditional rows repaired            1,000
wrong outbox rows canceled            1,000
external dispatch                         0
fixture data loss after reconcile         0
source -> candidate timeline          11 -> 12

最终还证明:

source Patroni topology unchanged    true
managed PGDATA touched               false
DCS / route changed                  false / false
TCP listener created                 false
fixture schema removed               true
side restore roots removed           true
fresh backup retained                true
backup or WAL deleted                false
declared counterexamples rejected    32
live evidence mutants rejected       32
source files hash-bound              12
production_ch32_gate                 pending

这些时间只是 42.4 MB 合成沙箱的一次观测,不能外推生产 RTO。实验没有切业务路由,没有 测试并发交错事务、缺失 WAL、加密密钥丢失、区域故障、真实支付补偿,也没有批准生产 操作。

阅读前后关系

本章目录

32.1 先界定误操作

32.2 恢复目标与时间线

32.3 隔离恢复策略

32.4 执行恢复并观察进度

32.5 数据验证与安全回切

32.6 实战:随机恢复目标演练

权威参考

PostgreSQL:

Pigsty:


上一章:事件分级、现场保护与应急决策——枕戈待旦 · 返回下卷导读 · 下一章:故障切换与集群重建——力挽狂澜 · 查看全书目录 · 查看索引中心

32.1 先界定误操作

误操作事故最容易犯的第一个错误,是把“有人说删错了”直接翻译成一个 PITR 时间。事故 描述是业务语言,恢复目标是 WAL 历史上的精确边界,中间至少还缺对象、事务、提交、 后续写入和外部副作用五层证据。

先写事故合同,再碰恢复命令:

affected truth       哪些业务事实错了
bad transaction      哪一笔或哪一组提交制造错误
first/last bad       错误开始和结束边界
continuing mutation  错误作业是否仍在运行
good after           错误之后哪些合法事实必须保留
external effects     数据库之外已经发生了什么
authority            谁能判定最终业务真相

若其中关键项仍是 unknown,正确动作通常是围住继续伤害、保存证据和并行恢复候选,而不是 在原集群上赌一个时间点。

32.1.1 错误 UPDATE、DELETE、DROP 与批处理

先按状态变化分类

同一句“数据没了”,恢复语义可能完全不同:

类型 典型例子 首要边界 常见恢复方向
单笔 DML UPDATE 漏写 WHERE 一笔 commit 前后 逻辑修补或对象提取
多笔 DML 客户端 autocommit 循环删除 第一笔与最后一笔坏提交 多候选、批量对账
事务型 DDL DROP TABLEDROP SCHEMA DDL commit 与依赖范围 从隔离 PITR 提取对象
粗粒度操作 TRUNCATE、分区 detach/drop 对象与关联表 对象提取或整库恢复
后台批处理 调度器持续重算错误价格 作业开始、每次 commit、停止时刻 先停作业,再找完整坏窗
权限/配置 错误 GRANT、参数或路由变更 数据是否真的变化 配置回退,不一定 PITR
外部副作用 outbox 已发、支付已扣 数据库 commit 与外部确认 数据恢复 + 业务补偿

PostgreSQL 的许多 DDL 具有事务性,未提交时可以 ROLLBACK;一旦已经提交,就不能回到 原会话补发 ROLLBACK。PITR 的作用不是“撤销 SQL”,而是从较早基础备份重放 WAL, 在指定提交边界之前或之后构造另一份完整历史。

这一区别会直接改变调查方式:

single transaction
  -> 找到 commit identity
  -> 验证 before / after 两个候选

autocommit batch
  -> 找到 first bad commit
  -> 找到 last bad commit
  -> 判断中间是否夹杂合法事务

external side effect
  -> 找数据库提交
  -> 找 message/payment idempotency identity
  -> 恢复数据后仍要外部对账

“执行成功”不说明影响正确

应用需要把下面这些信息当作审计事件,而不是只记录 SQL 文本:

change_id / job_id / request_id
application actor and database role
transaction id or commit-correlated identity
object and business-key range
expected rows / actual rows
old/new aggregate or manifest
started_at / committed_at in UTC
idempotency key and external event ids

UPDATE 1000000 的 row count 能提示范围异常,却不能说明哪一百万行本来应该改变; statement log 能说明服务端收到什么文本,却可能没有 bind 参数;WAL 是物理恢复事实, 不是自动生成的业务审计表。恢复能力必须在事故前就布置可关联的应用、数据库和外部系统 身份。

批处理不是“一笔大事务”的同义词

考虑一个每 1,000 行提交一次的脚本:

10:00:01  batch 1 commit  bad
10:00:03  user order      good
10:00:04  batch 2 commit  bad
10:00:06  refund          good
10:00:07  batch 3 commit  bad

恢复到 batch 1 之前会同时丢掉两笔合法写;恢复到 batch 3 之后又保留全部错误。此时没有 一个单独 PITR 点能直接得到最终正确业务状态。需要先取得历史正确对象,再按业务身份重放 或合并合法变化。

因此发现坏批次后,第一步是阻断相同 mutation source

  • 禁用精确的 scheduler job,而不是停掉所有 scheduler;
  • 吊销或冻结产生错误的 service role,而不是随意改全局 HBA;
  • 对受影响业务 key 加写围栏,而不是默认让全站停写;
  • 记录围栏生效前最后一笔提交,不能把“点击停止”当成“已经停止”。

停止动作、实际停止证据和最后坏提交是三件事。

复制不会替你保留“正确过去”

流复制和同步复制的目标是复制已经提交的 WAL。误更新只要正常提交,副本也会正确地把 它重放。以下判断都不成立:

replica lag = 0       -> 副本数据一定正确
three replicas        -> 一定存在错误之前的副本
fail over             -> 误更新会消失
backup job succeeded  -> 一定能定位到正确边界

若团队希望保留延迟副本,必须把延迟窗口、监控、promotion 防误触和 WAL 保留纳入独立 设计;它也不能替代基础备份、连续归档和恢复演练。

32.1.2 影响对象、开始时间、结束时间和持续写入

建立事故包络

把事故范围写成一个六维包络:

I=(O,K,[Tf,Tl],X,A,E) I=(O,K,[T_f,T_l],X,A,E)
  • $O$:database、schema、table、partition、sequence、large object 等对象集合;
  • $K$:受影响业务 key 或谓词;
  • $[T_f,T_l]$:第一笔到最后一笔坏提交的区间;
  • $X$:已确认或候选 transaction identity;
  • $A$:坏提交之后必须保留的合法写集合;
  • $E$:消息、支付、邮件、搜索索引、缓存等外部副作用。

不要把“发现时间”写进 $T_l$。四个时刻应分开:

t_start    错误动作开始
t_commit   某笔错误事务真正提交
t_detect   人或监控发现异常
t_fence    错误来源已被证明停止

若一个作业持续运行,则 t_fence 之前仍可能出现新坏提交,事故窗口是移动的。先建立写 围栏并确认它生效,才有稳定的恢复问题。

从多层证据收敛,而不是信一个时间戳

证据层 能回答 不能单独回答
工单/聊天 人声称何时做了什么 实际 commit 边界
应用审计 request、actor、业务 key、idempotency 数据库是否提交
PostgreSQL log session、statement、error、duration 未记录的参数与业务正确性
审计表 run、stage、XID、LSN、对象摘要 外部系统是否消费
当前表状态 现在受影响多少 历史旧值
backup/WAL catalog 可恢复血缘与覆盖范围 哪个业务状态正确
外部账本 支付、消息、邮件结果 数据库内部完整状态

pg_stat_activity 适合回答“现在什么还在运行”,不是历史审计;累计统计也不能还原某笔 事务。对关键批处理,最好在同一事务里写入最小 incident_audit

INSERT INTO ops.change_audit (
    change_id, stage, xid, observed_lsn, details
)
VALUES (
    :'change_id',
    'before-dangerous-change',
    pg_current_xact_id(),
    pg_current_wal_lsn(),
    jsonb_build_object(
        'predicate', :'reviewed_predicate',
        'expected_rows', :expected_rows
    )
);

这不是让 DBA 在事故后凭空制造 XID,而是展示可恢复性如何进入变更协议。若事前没有 审计,必须用日志、应用请求、业务 key、WAL 工具和候选恢复交叉定位,并扩大不确定区间。

把事后合法写入作为一等对象

很多恢复票只写“删除 1,000 行”,没有写错误之后发生的 100 笔合法订单。应单独建立 good-after manifest

identity         order_id / ledger_id / event_id
commit evidence  audit row / immutable ledger / application event
ordering         depends-on or sequence
payload digest   canonical fields, not secret-bearing raw request
external state   not-sent / sent / acknowledged / compensated
replay method    idempotent insert / conditional update / manual review
owner            business authority

这个集合为空也必须有证据:例如错误后立即完成全局写围栏,且所有 writer、job 与外部 ingress 都被证明停止。“没看到新行”不等于没有合法写。

用上下界表达不确定性

若只能确认错误发生在 10:00:00+0810:00:05+08 之间,不要假装存在一个精确 10:00:03。更安全的候选搜索是:

candidate A  upper bound before suspected error
candidate B  first candidate where damage appears
candidate C  immediately before independently identified commit

每个候选都运行同一组业务探针。目标选择是一个可证伪的搜索过程,而不是一次性猜值。 恢复副本越容易创建,越应该多恢复一个候选,少在原集群上做一次不可逆猜测。

事故包络的完成条件

进入 restore 前至少要能填写:

incident:
  affected_objects: [...]
  affected_business_keys: [...]
  first_bad_commit: known | bounded | unknown
  last_bad_commit: known | bounded | unknown
  mutation_source_fenced: true
  good_after_manifest: evidence-reference
  external_effects: none | bounded | unknown
  business_truth_owner: named-person-or-role
  unresolved_unknowns: [...]

mutation_source_fenced=false,先回第 31 章处理现场;若 good_after_manifest=unknown,可以并行恢复候选,但不能批准覆盖或切流。

32.1.3 逻辑补偿、对象恢复与整库 PITR 的选择

三种路线解决不同问题

PostgreSQL 连续归档的物理恢复只能恢复整个 database cluster,不是按 database 或 table 选择性恢复 (官方说明)。 “只恢复一张表”通常意味着先把整套物理副本恢复到隔离环境,再从中逻辑提取对象。

路线 适合条件 主要风险 验收重点
原地逻辑补偿 影响 key 精确、逆变换明确、当前值可条件匹配 覆盖并发合法写 affected-row preimage 与业务不变量
隔离 PITR 后对象提取 只需部分对象的历史值,原服务需继续 FK、sequence、触发器与跨表遗漏 完整依赖与合并 manifest
隔离 PITR 后整库切换 影响广、catalog 级破坏、无法可靠逐项修补 丢失目标后合法写与外部状态 写围栏、delta replay、路由与回退

选择依据不是“哪条命令最熟”,而是:

$$ \text{total risk} = \text{unknown blast radius}

  • \text{good-after loss}
  • \text{merge error}
  • \text{external inconsistency}
  • \text{cutover risk} $$

什么时候优先逻辑补偿

例如误把一组账户余额减去固定值,且满足:

  • 受影响 account ID 由不可变 change ID 精确列出;
  • 每行当前版本仍等于错误后的预期值;
  • 目标之后的合法变化有独立 ledger;
  • 没有不可逆外部副作用,或已经有补偿协议;
  • 修补能放在一笔受控事务中,并在 row count 不符时回滚。

应使用条件更新,不要盲目写回:

UPDATE app.account AS a
SET balance_cents = r.recovered_balance_cents
                  + r.audited_post_delta_cents,
    status = 'active'
FROM recovery_candidate AS r
WHERE a.account_id = r.account_id
  AND a.balance_cents = r.expected_current_balance_cents
  AND a.status = 'mispriced';

随后检查实际 row count 必须等于 manifest;少一行说明当前状态已被其他事务改变,整笔 修补应回滚并重新调查。

什么时候从恢复副本提取对象

DROP TABLE、误删一个租户或一个时间分区时,常见流程是:

physical PITR full cluster into isolation
  -> validate historical object and dependencies
      -> export selected schema/data
          -> import into quarantine schema
              -> compare current and recovered identities
                  -> merge under write fence

导出前要连同以下对象评审:

  • sequence 当前值与 identity;
  • foreign key 的父子行;
  • partition、index、constraint、trigger、policy;
  • large object 与外部文件;
  • extension 类型、collation 与函数依赖;
  • logical replication identity 和 downstream 消费位置。

单独 pg_dump -t target 能生成表数据,不代表它已经捕获业务闭包。先定义 closure,再 导出。

什么时候考虑整库切换

下面情况更可能需要整库路线:

  • 大范围 schema/catalog 破坏,受影响对象无法可靠枚举;
  • 大量相互依赖的数据都被错误转换;
  • 目标后写入已被严格围住,或能从独立日志完整重放;
  • 业务明确接受目标后的数据损失;
  • 原集群已不可信,但恢复候选通过完整引擎与业务验证。

即便如此,也先做 side restore。直接覆盖 managed PGDATA 会同时销毁现场、当前合法 写和比较基准;空间不足不是自动授权覆盖,而是需要事故指挥人明确选择保存哪些证据并 承担什么损失。

五个常见伪方案

在 replica 上查旧值
  错误 WAL 通常已经重放;lag 不是受控历史边界。

对已提交事务执行 ROLLBACK
  ROLLBACK 只能影响当前未提交事务。

从最近 full backup 直接启动
  缺少目标前 WAL 时,它只代表 backup 一致点,不代表事故边界。

恢复到“报警前一分钟”
  报警时间不是 commit 时间,时钟与采集链还有误差。

在原 PGDATA 上反复试 target
  每次都覆盖当前现场,丧失比较和回退能力。

交给下一节的恢复假设

完成本节后,应该得到而不是猜到:

route                  compensate | extract | full-cutover
bad commit             exact identity or bounded interval
inclusive expectation  damage must be present or absent
exclusive expectation  safe facts present, damage absent
good-after             manifest plus replay/merge owner
external effects       fenced and reconciled plan
production cutover     still not authorized

下一节把这些业务边界映射到 PostgreSQL 的 time、XID、LSN、name、inclusive 与 timeline 语义。


返回本章目录 · 下一节:恢复目标与时间线 · 查看全书目录 · 查看索引中心

32.2 恢复目标与时间线

恢复目标由三部分组成:

where to stop     time | xid | lsn | name | immediate | end-of-WAL
which history     timeline
what to do there  pause | promote | shutdown

少写任何一项,工具都会替你选默认值;默认值可能技术上合理,却不一定符合这次事故。 特别是 recovery_target_inclusive 默认是 onrecovery_target_timeline 默认是 latestrecovery_target_action 默认是 pause。事故 runbook 应显式记录有效值, 不能把“没有写”当成“没有语义”。

32.2.1 时间、LSN、事务 ID 与命名恢复点

PostgreSQL 每次只接受一个停止目标

PostgreSQL 18 的目标设置如下:

目标 原生设置 Pig 参数 语义与适用场景
最早一致点 recovery_target='immediate' -I 在线 backup 完成的一致点
命名点 recovery_target_name --name 事前调用 pg_create_restore_point()
时间 recovery_target_time -t/--time 有可靠 commit 时间与时区证据
XID recovery_target_xid --xid 已审计到目标事务提交身份
LSN recovery_target_lsn --lsn 已定位 WAL 边界
WAL 末尾 不设置较早目标 -d/--default 尽可能恢复到 archive 末端

原生配置最多只能设置一个 immediate/name/time/xid/lsn 目标,否则启动报错 (Recovery Target Settings)。 end-of-WAL 是“重放所有可用 WAL”,不是“回到事故前”。

选择顺序可以写成:

reviewed named point exists?
  yes -> name
  no  -> exact bad commit XID independently audited?
           yes -> XID, then prove inclusive/exclusive
           no  -> exact WAL boundary independently derived?
                    yes -> LSN, then prove nearby transaction set
                    no  -> bounded UTC commit interval?
                             yes -> time, restore multiple candidates
                             no  -> continue evidence collection

time:人最容易理解,也最容易被时钟骗

时间目标接受 timestamp with time zone 语义。生产票据应写完整偏移:

2026-07-30 03:09:12.483+00
2026-07-30 11:09:12.483+08

不要只写:

03:09
yesterday 11:09
2026-07-30 11:09:12

Pig 可以为不带时区的输入补当前本地时区,但自动补全是 CLI 解析行为,不是事故证据。 执行计划应把目标归一化成完整时间戳,再由操作者对照日志、server timezone 和原始来源 复核。

时间目标配合 recovery_target_inclusive

inclusive=on   包含 commit timestamp 恰好等于目标的事务
inclusive=off  停在它之前

真实系统中多个事务可能共享很接近的提交时间;日志时间还有格式精度和采集延迟。时间目标 更适合表达候选区间,而不是把一条聊天消息的分钟级时间强行当成精确提交点。

XID:本章实验的目标,但不是天然全序号

XID 在事务开始时顺序分配,事务可以按不同顺序结束:

XID 500 begins
XID 501 begins
XID 501 commits
XID 500 commits

官方定义是:恢复那些在目标事务之前提交的事务,并根据 inclusive 决定是否包含目标 事务;不能用 xid < target 推断“更早提交” (recovery_target_xid)。

本章实验故意串行制造三笔事务:

safe-before -> damage -> post-target

所以 XID target 可无歧义地证明 inclusive/exclusive 差别。生产中若存在并发事务,仍要 从候选数据库查询业务 manifest,明确哪些并发提交被包含;XID 只是停止条件,不是业务 正确性的替代品。

不要依赖表行的 xmin 作为通用事故审计:

  • xmin 是行版本创建者,不直接表达删除者或完整事务影响;
  • UPDATE 会产生新行版本;
  • 一个事务可影响许多表,也可能回滚;
  • XID 会 wraparound,显示和比较需要理解 epoch;
  • freeze、复制与逻辑导出语义都可能让简单推断失效。

可控变更应在同一事务里记录 pg_current_xact_id() 与业务 change ID。

LSN:精确 WAL 位置,不自动等于业务提交

LSN 是 WAL 字节位置,适合:

  • 从日志或工具精确得到 commit record 附近边界;
  • 关联 archive segment 与 replay 进度;
  • 在两个候选之间做技术二分;
  • 证明 WAL 已覆盖到目标之后。

pg_current_wal_lsn() 是观察时的当前插入位置,不是自动返回“当前事务未来的 commit record LSN”。在事务内部随手记一个 LSN,再把它命名为 commit LSN,会造成伪精确。 如需按 LSN 恢复,应说明 LSN 如何获得、对应哪条 WAL 记录,并用候选数据验证。

LSN 同样受 inclusive 控制。它可以告诉 PostgreSQL在何处停,却不能告诉业务“账户余额 是否正确”。

name:最清晰,但必须提前创建

SELECT pg_create_restore_point(
    'before_price_rebuild_change_20260730'
);

命名恢复点适合重大 DDL、批处理和发布窗口。名称应包含 change ID,创建结果与 LSN 要进 变更证据,并确认对应 WAL 已归档。它不是 bookmark 服务:只有已执行并写入 WAL 的恢复 点才能在未来被找到,也不能用新 backup 恢复到该 backup 结束之前的 named point。

recovery_target_inclusive 只适用于 time、XID 和 LSN,不用于 name。若想表达“危险事务 之前”,应在危险动作前创建 restore point,而不是事后猜 named point 的包含语义。

immediate 与 end-of-WAL

immediate 在基础备份达到一致状态时结束,通常用于验证基础备份本身;它不包含 backup 之后的 WAL 业务变化。end-of-WAL 会尽可能重放全部可用 WAL,适用于主机损失后追到 最新,而不适合排除已经归档的误操作。

目标必须晚于所选基础备份的结束点。若想恢复到 backup 进行期间的更早时刻,需要选择 更早一份基础备份;不是把 target 参数写得更早就能穿越该下界。

32.2.2 timeline 分叉与“恢复到刚刚之前”

PITR 会产生新历史

原历史:

timeline 11
  A ---- B ---- damage ---- C ---- D

恢复到 damage 之前并 promote:

timeline 11
  A ---- B ---- damage ---- C ---- D
          \
timeline 12
            B' ---- E ---- F

新 timeline 防止恢复后生成的 WAL 覆盖旧历史。PostgreSQL 会产生 timeline history 文件,记录从哪条历史、哪个位置分叉;它们会像 WAL 一样归档,且是多分支恢复选择所必需 的证据 (Timelines)。

因此 timeline ID 不是“恢复次数”或“谁更新”,而是 WAL 历史分支身份。看到 timeline 变大也不能单独断言一次未授权 failover;要读 history、DCS、日志和变更记录。

currentlatest 的参照点

PostgreSQL 的定义非常具体:

current  沿基础备份创建时所在的 timeline 恢复
latest   沿 archive 中可找到的最新 timeline 恢复;默认值
N        沿明确的十进制 timeline
0xN      沿明确的十六进制 timeline

latest 对持续追随新 primary 的 standby 很有用,但在多次事故演练后,archive 里可能 已有后来创建的实验分支。若本次目标是原事故历史,盲用 latest 可能沿错分支。本章正式 实验显式使用 current,把恢复限定在 fresh backup 所属的 timeline 11;候选 promote 后各自在隔离目录进入新 timeline,但 archive_mode=off,不把实验分支推回 source repository。

复杂 re-recovery 必须画出 lineage:

backup label
backup start / stop LSN
backup timeline
target timeline
parent timeline and switchpoint
required history files
required WAL min/max
why latest/current/explicit is chosen

“仓库里有这个 WAL 文件名”仍不够。WAL 文件名前八位是 timeline 的十六进制表示;同一 逻辑 segment 位置在不同 timeline 上可属于不同历史。

“刚刚之前”是 exclusive 语义

time、XID、LSN 的 recovery_target_inclusive 默认是 on

on   stop just after target
off  stop just before target

如果目标 XID 正是误更新事务:

候选 damage 结论
inclusive / 默认 存在 应否决
exclusive / -X 不存在 才可能接受

这不是文案差别,而是一整笔事务是否存在。本章不只检查配置里写了 exclusive,还在 恢复候选中查询 damage audit、错误状态和 outbox 行,证明错误确实不存在。

target action 决定到点后的生命周期

action 到点行为 适用 注意
pause 暂停 WAL replay 希望只读检查目标 默认;需判断是否真的 paused
promote 结束 recovery,开始接受连接 隔离候选需完整验证或导出 会创建新 timeline
shutdown 到点后停库 希望保留精确停点目录 recovery.signal 仍在,重启前要处理配置

官方还规定:设置了目标,但 archive recovery 在到达它之前已经结束,server 会 fatal shutdown。不要把这种失败解释成“恢复到了最接近的位置”;目标没有到达就是阻断。

本章使用 promote,但只在 private Unix socket 上启动。promote 的“接受连接”不等于 接受业务流量,也不等于已重新接入 Patroni。

用两个候选替代一场争论

当团队争论目标是否应 inclusive 时,最稳妥的做法通常不是在事故群里投票,而是:

same backup
same target
same source timeline
same validation probes
candidate A inclusive
candidate B exclusive

然后让数据回答。候选差异应聚焦目标事务;如果两者还有其他意外差异,说明 backup、 timeline、并发提交或验证合同仍未被理解。

32.2.3 时钟、时区和证据误差

PostgreSQL 有不止一个“现在”

在一笔长事务中:

SELECT
    current_timestamp,
    statement_timestamp(),
    clock_timestamp();
  • current_timestamp / transaction_timestamp() 固定为事务开始时刻;
  • statement_timestamp() 是当前语句开始时刻;
  • clock_timestamp() 返回实际墙钟,会在语句执行中变化。

若审计列默认使用 current_timestamp,长事务提交时记录的值可能接近事务开始,不是 commit 时刻。日志行时间、应用 request time、消息 broker time 又分别来自不同进程。 “按 audit.created_at 恢复”之前必须先确认这个列的时间语义。

建立时钟证据

恢复票据至少记录:

PostgreSQL TimeZone and log_timezone
database host UTC time and synchronization state
application host UTC time
collector/queue timestamp semantics
client-provided timestamps and trust level
log timestamp precision
known NTP offset or uncertainty
conversion rule used by CLI

使用完整 UTC 或 numeric offset,不使用歧义缩写。官方建议 recovery time 使用数值偏移 或完整 zone name;缩写是否可用取决于更早加载的 timezone_abbreviations

把误差写进搜索区间

设:

  • $t_o$ 是观察到的时间;
  • $\epsilon_c$ 是时钟偏差;
  • $\epsilon_l$ 是日志/采集延迟;
  • $\epsilon_s$ 是时间戳精度与语义误差。

则候选区间至少为:

[to(ϵc+ϵl+ϵs),to+(ϵc+ϵl+ϵs)] [t_o-(\epsilon_c+\epsilon_l+\epsilon_s), t_o+(\epsilon_c+\epsilon_l+\epsilon_s)]

这不是统计置信区间,而是操作上的保守边界。区间越宽,越应恢复多个候选或改用独立 XID/name/LSN 证据。

夏令时与本地日期是事故放大器

2026-11-01 01:30:00 在采用夏令时回拨的地区可能出现两次;2026-07-30 作为 Pig time 输入会补为本地当天 00:00:00;只写 12:00:00 还会补“今天”。这些便捷格式 适合交互,不适合冻结事故目标。

生产票据应保存:

raw source timestamp
source timezone
normalized UTC timestamp
normalization tool/version
operator-reviewed target
inclusive/exclusive
candidate validation result

目标选择验收表

检查 通过证据 失败动作
一个目标 effective config / Pig plan 只有一种 target 修正冲突参数
target 可达 backup end < target 且 archive 覆盖 选更早 backup 或修复 WAL
timeline 正确 history + backup lineage + explicit rationale 不启动
inclusive 明确 两候选或业务边界证明 默认恢复两次
action 明确 pause/promote/shutdown 与后续步骤一致 修正生命周期
时区明确 raw + UTC + offset + clock evidence 扩大候选区间
业务探针明确 damage/safe/good-after 预期 回第 32.1 节

恢复计划至此才具有可执行语义。下一节把它放进一个不会覆盖现场、不会接入真实服务、 不会发送外部消息的隔离环境。


上一节:先界定误操作 · 返回本章目录 · 下一节:隔离恢复策略 · 查看全书目录 · 查看索引中心

32.3 隔离恢复策略

隔离恢复的目标不是“找一台空机器”,而是让恢复候选同时满足:

cannot overwrite source evidence
cannot join source HA control plane
cannot receive application traffic
cannot emit unintended external effects
cannot contaminate source WAL archive
can still read the exact backup/WAL it needs
can be identified, validated, stopped and deleted exactly

这六条不是同一个开关。listen_addresses='' 能关闭 TCP 入站,不会自动阻止逻辑订阅、 HTTP 扩展或脚本向外连接;custom PGDATA 不接入 Patroni,也不会自动得到只读 repository 凭据。隔离要逐层证明。

32.3.1 不覆盖仍可取证的原集群

原集群同时是现场、差异源和回退资产

即使原数据已经错误,它仍保存:

  • 错误之后的合法提交;
  • 当前业务 key、version 与外部事件身份;
  • 未归档 WAL 和最近 commit 线索;
  • session、job、slot、subscription 与路由状态;
  • 与恢复候选进行差异比较的基准;
  • 如果目标判断错误,重新选择候选所需的证据。

因此默认拓扑应是:

live source --------------------------> preserved
    |                                      |
    +--> backup/WAL --> candidate A        +--> good-after audit
                   \--> candidate B        +--> current-state manifest

而不是:

live source PGDATA --overwrite--> guessed target

PostgreSQL 官方恢复流程也建议在空间允许时先复制整个 cluster data directory 与 tablespace;至少保存可能尚未归档的 pg_walRecovering Using a Continuous Archive Backup)。 对仍在线的 source,不应把这理解成随意复制活动 PGDATA;应通过受支持的备份、存储快照 或停机证据流程完成。

先决定 source 的运行状态

三种常见状态:

source 状态 何时选择 必须控制
继续服务 影响局部,可围住错误对象 bad writer、受影响 key、事后写审计
只读/全局写围栏 影响广,good-after 难追踪 所有入口、后台作业、长事务
停止并保全 完整性未知或仍快速恶化 WAL、内存外证据、启动权限

“source 继续服务”不能写成“什么都不做”。至少要冻结错误 job/service identity,记录围栏 时间,保存合法写 manifest。反之,也不要因为要做 PITR 就自动停全站;停写是业务可用性 决策,必须由影响范围与对账能力支撑。

保存事实,不保存秘密扩散

事故证据包可以记录:

cluster and member identity
system identifier relation or protected digest
timeline and LSN
backup label and archive range
effective recovery parameters
object/row manifests and business-key digests
UTC action timeline
source-file and evidence hashes

不应把这些内容直接放进公开工单:

SCRAM verifiers
TLS private keys
cloud/repository credentials
raw connection URIs
unredacted business rows
unbounded query text or bind values

私有证据目录需要最小权限、保留期限与访问日志;公开摘要只保留足以复核结论的环境轮廓、 计数、状态和散列。

任何覆盖动作都要单独授权

managed PGDATA restore 至少会:

  1. 停止或绕开 HA 生命周期;
  2. 替换当前物理数据;
  3. 改变 timeline 与可继续恢复的路径;
  4. 使旧节点与 DCS/其他成员产生身份冲突风险;
  5. 让回到当前状态依赖另一条恢复路径。

它是独立的高风险状态迁移,不应因为 side restore 验证通过就自动获批。本章实验从不 执行 managed restore,也不把 side candidate 注册为 Patroni member。

32.3.2 选择备份、WAL 与目标环境

备份必须早于目标并能走到目标

对候选 backup $B_i$,最低条件是:

stop(Bi)<Ttarget \operatorname{stop}(B_i) < T_{\text{target}}

并且从 backup 一致点到 target 的所有必需 WAL 与 timeline history 都可读。选择最近的 合格 backup 通常减少重放量,但不能只按 label 字符串或“最新”按钮决定:

stanza identity matches source
database system lineage matches
PostgreSQL major and block/checksum properties compatible
backup status has no error
full/diff/incr dependencies all retained
backup stop point is before target
archive min/max and history cover target timeline
tablespace mapping is available
repository key and credentials are usable

本章正式实验在 fixture base 创建后新做一份 full backup,再提交 safe、damage 与 post-target 三笔事务;这样既测试 backup 后 WAL 重放,也避免选到目标之后才完成的 backup。

“archive max 大于目标”只是必要条件

WAL segment 名的范围比较可以证明仓库至少走到某个 segment,却不能单独证明:

  • 中间每一个 segment 都存在;
  • 对象内容未损坏;
  • 所需 timeline history 存在;
  • repository key 可用;
  • restore command 权限和网络可用;
  • 目标记录确实在宣称的 segment 中。

所以先运行 repository check,再实际恢复。测试环境可以主动 pg_switch_wal() 缩短等待, 生产不能把频繁 switch 当作归档性能修复;archive_timeout 太短会生成大量未填满但仍为 完整尺寸的 segment。

固定 exact backup,而不是让工具临场猜

恢复票据应保存:

stanza             pg-test
repo               1
backup label       20260730-030910F
backup type        full
backup stop        timestamp + LSN + WAL
target             XID/time/LSN/name
inclusive          true/false
timeline           current/latest/N + rationale
target action      pause/promote/shutdown

运行 pig pitr 时使用 -b/--set 指定这份 backup。若 plan 解析出的 effective label、 target、timeline 或 data directory 与票据不一致,应在 restore 前阻断。

目标环境要匹配物理要求

物理恢复不是把 data directory 交给任意 PostgreSQL:

  • server major 必须与物理格式匹配;
  • 所需 extension shared libraries、locale/collation provider 要可用;
  • data checksums、page/block/WAL segment 属性要被理解;
  • tablespace 目标必须存在、映射正确且容量足够;
  • 恢复关键参数不能低于源端需要;
  • OS 用户、owner、mode 与文件系统语义要正确。

恢复关键参数常包括:

max_connections
max_worker_processes
max_wal_senders
max_prepared_transactions
max_locks_per_transaction

如果恢复主机当前配置比 backup 要求低,recovery 可能拒绝启动。正式实验读取 source effective settings,再显式传给隔离 postmaster;这不是把整个 source 配置无审查复制过来。

先算空间,再决定保留策略

至少预算:

$$ \text{space} \ge \text{restored PGDATA}

  • \text{tablespaces}
  • \text{temporary WAL}
  • \text{export/intermediate data}
  • \text{safety margin} $$

若同时恢复 inclusive 与 exclusive,可顺序复用主机端口和空间,但应保留各自 manifest 与日志;不要同时启动两个继承相同 identity、port 和外部配置的副本。

空间不足时,禁止以 expire 旧 backup 或删除 archive WAL 作为脚本的隐式副作用。本章 实验会留下新 full backup,因为清理 repository 不在授权范围内。

32.3.3 控制网络、凭据和外部副作用

六层隔离清单

本章正式实验 真实生产演练还要考虑
文件 随机 marker 下的 custom -D 独立卷、tablespace、快照权限
进程 手工 pg_ctl,一次只启一个 cgroup/container/systemd identity
入站 listen_addresses='',mode-0700 Unix socket host firewall/security group
身份 private HBA,仅本机 postgres peer secrets replacement、least privilege
控制面 不加入 Patroni/DCS/HAProxy/PgBouncer DNS/VIP/controller admission
出站/副作用 fixture 没有 dispatcher,archive off egress deny、subscription/job/webhook 隔离

最后一行最容易被漏掉。listen_addresses='' 只是不接受 TCP 连接,不会阻止数据库或宿主 进程主动向外连接。真正的取证环境应在网络层默认 deny egress,再为只读 backup/WAL 读取开精确 allowlist。

恢复出来的凭据仍然有效

物理 backup 包含当时的:

  • database roles 与 password verifier;
  • pg_hba.conf、证书引用与部分服务配置;
  • extension、job、subscription 和 FDW metadata;
  • application-owned secret(若错误地存进业务表)。

因此不能让恢复实例监听原端口或加载原 HBA。本章在 command line 覆盖:

listen_addresses=''
unix_socket_directories=<private-root>/socket
unix_socket_permissions=0700
hba_file=<private-root>/pg_hba.restore.conf
ssl=off
shared_preload_libraries=''
primary_conninfo=''
primary_slot_name=''
archive_mode=off

private HBA:

local all postgres peer
local all all      reject

这些设置适合本章沙箱,不是所有生产恢复的通用模板。例如需要业务验证账号时,应新建 一次性、最小权限的验证入口,而不是重新开放原应用角色。

archive_mode=off 防止污染 source 历史

隔离 candidate promote 后会产生新 timeline 和新 WAL。若它继承 archive_mode=always/on 与 source repository 写凭据,实验分支可能写入共享 archive, 增加 timeline 选择歧义,甚至与 retention 发生交互。

本章通过 pgBackRest restore 参数和 postmaster command line 双重确认 archive_mode=off。这不影响 restore_command 在 recovery 期间读取所需 WAL; 它只禁止候选成为新的 archive producer。

更严格的设计还应让恢复凭据 repository read-only。因为具备 restore 权限的 host 往往 也配置了 backup 写权限,单靠操作人员承诺不是权限隔离。

控制数据库内的自动执行器

恢复实例中可能存在:

pg_cron / pgAgent jobs
logical subscriptions
background worker extensions
LISTEN/NOTIFY consumers
outbox relay
trigger-driven HTTP calls
foreign data wrappers
application sidecars and local service units

应在启动前列出它们,并从三层阻断:

  1. 网络层:默认拒绝出站;
  2. 进程层:不启动 application/relay/agent,按评审禁用 background workers;
  3. 数据层:把 outbox/queue 置隔离状态,使用幂等键和 sandbox endpoint。

本章只证明 synthetic fixture 的 external_dispatch_count=0,没有宣称通用 egress 隔离 已经通过。读者把实验迁移到真实系统时,必须补这一门。

Pigsty 的 managed 与 side restore 边界

当前 pig pitr 有两种生命周期:

managed data directory
  may stop Patroni
  ensures PostgreSQL is stopped
  restores managed PGDATA
  may start PostgreSQL
  leaves Patroni stopped
  does not rejoin HA or switch traffic

custom -D side restore
  requires pre-created postgres-owned directory
  requires --no-restart
  does not stop Patroni
  does not manage default PostgreSQL service
  operator starts isolated postmaster manually

完整语义见 pig pitr。这里最重要的不是记选项,而是从 plan 核对实际 boundary。路径是否为 side restore 由有效 managed PGDATA 与解析后的路径决定, 不是简单看字符串里有没有 /pg/data

正式实验先执行:

pig pitr \
  -s pg-test -r 1 \
  -b "$BACKUP_LABEL" \
  --xid "$DAMAGE_XID" \
  --target-action=promote \
  --target-timeline=current \
  -D "$CANDIDATE/data" \
  --no-restart \
  --plan \
  -o json \
  -- \
  --archive-mode=off

plan 必须明确:

boundary       pitr:side-restore
data directory exact candidate path
service lifecycle not-managed
backup set     exact reviewed label
target         exact source-audited XID
timeline       current
confirmation   required

结构化执行不会交互询问;只有审批完成后才使用 --yes。不要把 --yes 写进没有 guard、 没有 exact target、会指向 managed PGDATA 的通用脚本。

隔离验收

启动候选后,同时从 OS 与 SQL 两侧检查:

OS:
  no TCP listener on candidate port
  private socket exists and mode is 0700
  postmaster.pid belongs to exact candidate
  managed Patroni process remains unchanged

SQL:
  cluster_name identifies candidate
  listen_addresses is empty
  archive_mode is off
  ssl is off for private socket-only lab
  shared_preload_libraries is empty in this fixture
  pg_is_in_recovery() eventually becomes false

停止时只允许:

pg_ctl -D <exact-marker-root>/data stop
verify PID and socket absent
delete <exact-marker-root>

宽泛 pkill postgres、按端口杀进程、删除未解析变量路径都不属于可接受清理。


上一节:恢复目标与时间线 · 返回本章目录 · 下一节:执行恢复并观察进度 · 查看全书目录 · 查看索引中心

32.4 执行恢复并观察进度

一次 PITR 至少有四个不同的“完成”:

restore copy complete
  -> PostgreSQL first accepts a connection
      -> configured recovery target is reached
          -> candidate data and business invariants pass

pig pitr --no-restart 成功只证明 pgBackRest restore 阶段完成;pg_ctl -w start 成功 可能只代表 hot standby 已可读;pg_is_in_recovery() = false 证明 recovery 已结束, 仍不证明目标业务状态正确。工具状态必须映射到这条状态机。

32.4.1 从已验证备份克隆恢复目标

冻结一份可重放参数包

执行前把变量写入私有 evidence,而不是临场从 shell history 猜:

stanza
repository number
backup label
source system-lineage relation
backup stop LSN/time/timeline
target type and exact normalized value
inclusive/exclusive
target timeline
target action
candidate root
PostgreSQL binary/version
recovery-critical settings
source file hashes
operator, reviewer and authorization

秘密只保存引用或受控位置,不复制进 JSON。参数包要能回答“同一份证据能否重放同一个 候选”,但不能变成凭据泄漏包。

先验证 repository 和 target 可达性

只读预检:

pgbackrest --stanza=pg-test --repo=1 --output=json info
pgbackrest --stanza=pg-test check

检查:

  • stanza status.code = 0
  • exact backup label 存在且 error=false
  • backup type/dependency 与预期一致;
  • backup end 在 target 之前;
  • archive 与 timeline history 覆盖 target;
  • restore host 有足够空间、正确版本和 extension library;
  • candidate root 不存在,端口与 socket path 未占用。

pgbackrest check 成功仍不替代 restore。它验证 repository/stanza 的一组条件,不会运行 本章业务探针。

为 side restore 建一次性目录

本章 runner 只接受形如:

/data/pg36-ch32-restore/run_YYYYMMDDTHHMMSSZ_random/inclusive
/data/pg36-ch32-restore/run_YYYYMMDDTHHMMSSZ_random/exclusive

的 exact path。目录必须:

owner       postgres:postgres
mode        0700
symlink     false
preexisting false
managed PGDATA relation different

Pig 要求 custom -D 预先存在、归 DBSU 所有;它不会代为创建,因为在 destructive classification、owner 检查和 plan 输出之前必须先知道具体目标路径。

先 plan,后 execute

inclusive 候选:

sudo -iu postgres pig pitr \
  -s pg-test -r 1 \
  -b "$BACKUP_LABEL" \
  --xid "$DAMAGE_XID" \
  --target-action=promote \
  --target-timeline=current \
  -D "$INCLUSIVE_ROOT/data" \
  --no-restart \
  --plan \
  -o json \
  -- \
  --archive-mode=off \
  --spool-path="$INCLUSIVE_ROOT/spool" \
  --log-path="$INCLUSIVE_ROOT/log"

审阅 plan 后,在受 guard 约束的自动化中把 --plan 换成 --yes。exclusive 候选只增加:

--exclusive

不要同时改变 backup、timeline、target action 或验证探针,否则两个候选无法归因比较。

Pig 的 first-class 参数负责 target、backup、timeline、data directory 与生命周期; -- 后的原生 pgBackRest 参数不能绕过这些边界。当前 CLI 在 structured output 下要求 显式 --yes 才执行,--plan 是无执行预览路径 (pig pitr Safety Mechanisms)。

读懂 restore 结果

本章正式 run 的 Pig 结果包括:

boundary/effective data dir  exact side path
side_restore                 true
managed data dir             /pg/data
patroni_stopped              false
postgres_restarted           false
backup_set                   exact fresh full
target_type                  xid
target_value                 source-audited XID
exclusive                    true or false
target_action                promote
target_timeline              current

patroni_active=true 在这里是好事:它描述 restore host 上原有 live replica 的 Patroni 没有被 side restore 停掉;隔离 candidate 尚未启动。不能把它误读成“候选已由 Patroni 管理”。

文件恢复失败时

若 pgBackRest restore 失败:

  1. 保留 plan、stdout/stderr、candidate marker 与 repository snapshot;
  2. 不启动部分恢复目录;
  3. 判断是 backup、WAL、key、权限、空间、tablespace 还是版本问题;
  4. 停止任何 exact candidate postmaster;
  5. 只删除当前 marker 所有的 candidate;
  6. 修复原因后开启新 run,不覆盖旧 evidence。

managed restore 失败时,Patroni 可能已停止且 PGDATA 可能只恢复了一部分;不能因失败而 直接重启 Patroni。side restore 则不应改变 managed 生命周期,这正是事故调查优先使用 它的原因。

32.4.2 监控 WAL 重放、目标达成与启动状态

手工启动时覆盖危险继承配置

正式 runner 使用 PostgreSQL 18 的 pg_ctl 启动 custom PGDATA,并通过 -o 传入:

config_file=<candidate>/data/postgresql.conf
hba_file=<candidate>/pg_hba.restore.conf
listen_addresses=''
port=55433
unix_socket_directories=<candidate>/socket
unix_socket_permissions=0700
ssl=off
archive_mode=off
primary_conninfo=''
primary_slot_name=''
shared_preload_libraries=''
logging_collector=off
cluster_name=pg36-ch32-inclusive|exclusive

以及 source 的五项 recovery-critical maxima。完整、安全转义与路径验证见 exercise.py,不要从上面的概念清单拼接未审阅 shell。

第一条连接可能发生在 recovery 中

hot_standby=on,PostgreSQL 达到一致状态后可以开放只读查询,而后继续重放 WAL。 因此:

pg_ctl -w start

-w 只等待 server ready,不保证 target_action=promote 已完成。第 21 章正式实验 已经实际观察到:

first connection  recovery=true, transaction_read_only=true
later             recovery=false, writable=true

本章 runner 继续轮询:

SELECT json_build_object(
    'in_recovery', pg_is_in_recovery(),
    'transaction_read_only',
        current_setting('transaction_read_only')::boolean,
    'last_replay_lsn', pg_last_wal_replay_lsn(),
    'replay_paused', pg_is_wal_replay_paused()
);

只有 pg_is_in_recovery() = false 才表示 promote action 已完成。随后仍先运行只读业务 探针;“可写”只是候选能力,不是允许应用写入。

分解恢复时延

用一个总秒数会掩盖瓶颈。本章分开测:

target_identification_ms
plan_ms
restore_copy_ms
start_to_first_connection_ms
start_to_promoted_ms
candidate_validation_ms
reconciliation_ms

关系:

$$ T_{\text{technical}} = T_{\text{identify}}

  • T_{\text{plan}}
  • T_{\text{copy}}
  • T_{\text{replay}}
  • T_{\text{validate}} $$

真实 RTO 还要加:

$$ T_{\text{service}} = T_{\text{technical}}

  • T_{\text{reconcile}}
  • T_{\text{cutover}}
  • T_{\text{application validate}}
  • T_{\text{approval}} $$

本章没有执行后三项,所以不会从 2.8 秒 restore 推导“生产 RTO 3 秒”。

监控哪些进度

阶段 观察 正常推进 阻断
文件 restore pgBackRest log/result files restored, exit 0 missing backup/key/space
启动 postmaster log、PID、socket startup then consistent state config/library/tablespace error
archive replay PostgreSQL log、replay LSN requested WAL advances repeated missing/corrupt WAL
target log + target-specific data stop before/after expected commit target not reached
action recovery functions paused/promoted/shutdown as declared unexpected action
validation SQL manifest expected safe/bad/post sets any invariant mismatch

没有通用的“百分比”能准确表示 archive recovery,因为最终 target 与可用 WAL、restore latency、replay workload 都会影响剩余工作。比虚构 73% 更有用的是保存已请求 WAL、 last replay LSN、目标和时间趋势。

target 未达到就是失败

PostgreSQL 明确规定:配置了 recovery target,但 archive recovery 在到达它之前结束, server 会以 fatal error 停止 (recovery_target_action)。

常见原因:

  • 所选 backup 本来就在 target 之后;
  • 中间 WAL 缺失或不可读;
  • timeline 选错;
  • target time/XID/LSN 根本不在该历史;
  • repository credential 或网络中断;
  • target 格式/时区错误。

禁止把 last replay LSN 当成“近似成功点”继续交付。应回到血缘与 target 证据。

32.4.3 用原生文件、日志和 SQL 复核工具状态

三个观察面互相校验

文件/配置面

PG_VERSION
backup_label or backup metadata
postgresql.conf / include chain
postgresql.auto.conf
recovery.signal
standby.signal
tablespace links
postmaster.pid / postmaster.opts

现代 PostgreSQL 通过 recovery.signal 进入 targeted recovery;若同时有 standby.signal,standby mode 优先。pgBackRest 会把 recovery 设置写入有效配置, 操作者应读 effective result,不能只看手写模板。

日志面

关注事件顺序,而不是只搜 ready

selected timeline / restore command
redo starts at ...
restored log file ...
consistent recovery state reached
recovery stopping before|after target transaction/time/LSN
redo done
new timeline selected/created
database system is ready to accept connections
fatal target not reached or WAL unavailable

日志时间必须与第 32.2 节的时区证据绑定。敏感业务值、凭据和原始查询不应未经筛选进入 公开 evidence。

SQL/控制面

SELECT
    pg_is_in_recovery(),
    pg_is_wal_replay_paused(),
    pg_last_wal_receive_lsn(),
    pg_last_wal_replay_lsn(),
    pg_last_xact_replay_timestamp();

SELECT * FROM pg_control_checkpoint();
SELECT * FROM pg_control_recovery();
SELECT * FROM pg_control_system();

在 promote 后,一些 recovery 函数会返回 NULL 或历史最后值,这是状态语义,不是自动 错误。原始 system identifier 应在私有证据中受保护;公开摘要通常只需要 “matches source lineage=true”。

工具结论必须回到原生事实

工具说法 原生复核
side restore effective PGDATA 与 managed PGDATA 不同;Patroni 未停
archive off SHOW archive_mode
target promote pg_is_in_recovery() = false 与日志
exact timeline backup/history/config 与 control data
no TCP SHOW listen_addresses + OS socket table
candidate stopped exact postmaster.pid/socket absent
backup retained restore 后再次读取 repository catalog

平台包装减少操作错误,但不会改变 PostgreSQL 的恢复语义。证据同时保留 Pig structured result 与原生复核,才能在 CLI 版本变化或输出解释争议时继续审计。

pg_waldump 的位置

pg_waldump 可在高级调查中识别 WAL record、事务 commit/abort 与 relation 变化,但:

  • 需要匹配 PostgreSQL major;
  • 输出是低层内部格式,不是业务审计;
  • 应分析受保护副本或 archive 文件;
  • 不应在运行中的 active pg_wal 上做会干扰现场的操作;
  • 结果仍需与日志、catalog 和业务 identity 关联。

本章正式 run 不靠 pg_waldump 猜 XID,而是从同一错误事务写入的 source audit 取目标, 再由两个候选证明包含关系。

候选状态表

恢复完成后先填:

candidate:
  backup_label: exact
  target:
    type: xid
    value_source: source-audit
    inclusive: true | false
    timeline: current
    action: promote
  runtime:
    in_recovery: false
    archive_mode: off
    tcp_listener: false
    private_socket: true
    patroni_managed: false
  data:
    safe_present: true | false
    damage_present: true | false
    post_target_present: true | false
  decision: accepted | rejected | blocked

下一节才把 accepted candidate 与当前 source、good-after manifest 和外部系统放在一起, 决定提取、修补或切换。


上一节:隔离恢复策略 · 返回本章目录 · 下一节:数据验证与安全回切 · 查看全书目录 · 查看索引中心

32.5 数据验证与安全回切

恢复候选能启动,只证明 WAL 把物理文件带到一个一致状态;错误事务不在候选里,只证明 一个必要条件;把这个候选切给用户,还要回答:

目标之前应该保留的事实是否完整?
目标之后的合法提交去了哪里?
当前 source 是否已发生新的变化?
数据库外的消息、支付、邮件和索引是什么状态?
切换后应用是否会重复执行历史工作?
如果判断错了,怎样回到切换前状态?

验证应当从“便宜、宽泛”逐渐走向“昂贵、业务特定”,每一层都能独立阻断下一层。

32.5.1 行数、摘要、关键交易与跨表不变量

五层验证金字塔

层次 问题 示例证据
引擎 cluster 能否稳定完成 recovery log、control data、pg_is_in_recovery()
对象 schema 与依赖是否齐全 catalog manifest、extension、constraint、index
数据 行与值是否在目标边界 count、分桶 aggregate、digest、sample
业务 跨表事实是否自洽 ledger=balance、order=payment、inventory conservation
服务 应用与外部系统能否安全接入 smoke、idempotency、routing、backlog、SLO

前一层通过不能推出后一层。例如:

PostgreSQL ready            != target reached
target reached              != target selected correctly
row count equal             != row contents equal
table digest equal          != payment provider agrees
application login succeeds  != background jobs are safe

先做对象 manifest

对象 manifest 不应只列 table name:

SELECT
    n.nspname,
    c.relname,
    c.relkind,
    c.relpersistence,
    c.relispartition,
    c.reltuples::bigint AS estimated_rows
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = ANY (ARRAY['app', 'ledger'])
ORDER BY 1, 2;

还要覆盖:

  • extension 名称与版本;
  • column 类型、default、identity、generated expression;
  • PK/UK/FK/CHECK/exclusion constraint 及 validated 状态;
  • index definition、valid/ready 状态与 predicate;
  • trigger、policy、publication/subscription;
  • partition bound、sequence ownership/value;
  • function/procedure language 与依赖;
  • collation provider/version。

对象缺失时,不要先补一个同名空表让检查“变绿”;这会破坏恢复证据。候选应保持可重建, 修复动作进入另一个受控副本。

行数是 smoke,不是证明

下面两个表可以行数与总金额相同,业务 key 却完全不同:

candidate A  account 1 = 100, account 2 = 200
candidate B  account 1 = 200, account 2 = 100

更有用的 manifest:

exact count
min/max business key
sum and bounded numeric aggregates
count by status/tenant/time bucket
ordered or commutative digest over canonical fields
null/duplicate/orphan counts
critical identity allow/deny probes

对大表按稳定业务 key 分桶,分别保存 count/sum/digest;失败时可以缩小范围,也避免一个 全表 string_agg 占用巨大内存。digest 输入要固定:

column order
NULL representation
timestamp timezone and precision
numeric scale
text encoding/collation
row ordering
hash algorithm/version

否则“摘要不同”可能只是序列化不同。

关键交易采用双向探针

每个候选至少要有:

must exist      target 前已提交的安全事实
must not exist  错误事务制造的事实
must not exist  target 后提交、尚未合并的事实

本章正式实验:

探针 inclusive exclusive
base audit
safe-before audit / 1,000 ledger
damage audit / mispriced rows
1,000 pending wrong outbox
post-target audit / 100 ledger

inclusive 候选因为 damage 存在而否决;exclusive 候选虽然通过历史边界,却也明确缺少 100 笔合法写,所以不能直接替换 source。

跨表不变量比单表摘要更接近业务

示例:

account.balance
  = opening balance + sum(posted ledger)

order.total
  = sum(order_line.amount) + tax - discount

inventory opening + receipts - reservations - shipments
  = inventory closing

one settled payment
  -> exactly one immutable ledger posting
  -> at most one externally acknowledged charge

把不变量写成可返回违反数量的 SQL:

SELECT count(*) AS violations
FROM app.account AS a
JOIN (
    SELECT account_id, sum(amount_cents) AS ledger_balance
    FROM ledger.entry
    WHERE state = 'posted'
    GROUP BY account_id
) AS l USING (account_id)
WHERE a.balance_cents <> l.ledger_balance;

violations=0 仍只覆盖这条不变量。正式验收要列出每条 SQL、适用范围、运行时间、快照 时刻与 expected result;不能只写“数据检查通过”。

一致快照与当前变化

在仍有写入的 source 上运行多个验证查询,结果可能来自不同时刻。可以:

  • 在短、受控的 REPEATABLE READ READ ONLY 事务中取得一致快照;
  • 按业务 change sequence 或 audit high-water mark 冻结 manifest;
  • 对高成本检查使用副本,但记录 replay LSN 与 snapshot 时间;
  • 避免长事务拖住 vacuum、xmin 与清理。

候选是静止历史,source 是移动目标。比较报告必须给双方标注 snapshot identity,不能把 跨分钟采集的数字假装成原子快照。

32.5.2 提取差异、逻辑补回或切换整个服务

先决定交付单位

交付方式 恢复候选提供 source 保留 需要的围栏
值/行提取 历史正确字段或对象 大部分当前状态 受影响 key
quarantine schema 导入 完整对象闭包 当前 cluster 与服务 合并期间对象写
整库切换 目标前整个 cluster 仅作为旧现场 全局写与流量

优先选择最小、可验证的交付单位,但不能为了“小”而漏掉依赖闭包。整库切换看似省去 merge,实际上把 target 后所有变化和服务路由都变成问题。

三集合合并

设:

  • $R$:exclusive candidate 的历史正确行;
  • $D$:当前 source 中错误后的状态;
  • $G$:错误之后的合法增量。

目标不是 source = R,而是:

source final=RG \text{source final} = R \oplus G

并且只有在当前 source 仍符合预期错误前像 $D$ 时才允许写。流程:

export R with business keys and digest
export G from independent audit/ledger
derive expected current preimage D
start scoped write fence
BEGIN
  verify exact key set and current preimage
  conditionally write R + G
  cancel/mark bad outbox rows
  assert affected row counts
  assert business invariants
COMMIT
release fence after external validation

任何 identity、preimage 或 row count 不匹配都应让事务回滚。这说明 source 在目标定位后 又变化,或 good-after manifest 不完整。

不要把 recovered row 直接当 SQL 文本

安全的数据通道应:

  • 使用 typed staging table、COPY 或受控参数;
  • 校验 schema/version 与 column mapping;
  • 对 business key 建唯一约束;
  • 保存行数与 digest;
  • 不把值拼接进动态 SQL;
  • 对敏感字段做最小化与访问控制;
  • 在 merge 前再次检查 candidate source hash。

本章 runner 将 1,000 个恢复账户解析成 typed JSON recordset,构造临时表,再进行条件 更新;实验数据是确定性整数,私有证据只公开 row count 与 SHA-256,不公开真实业务值。

sequence、identity 与冲突

对象提取后常见遗漏:

rows restored, sequence still behind
historical key now reused by a new row
unique key conflicts with post-target write
foreign key points to newer version
trigger re-emits side effect
generated column expression changed

处理 sequence 不能只 setval(max(id))

  • 其他 partition/table 是否共享 sequence;
  • cache 中是否还有已分配值;
  • post-target 是否使用更高 ID;
  • 应保留 monotonically increasing 还是允许 gap;
  • application 是否把 ID 大小误当业务顺序。

冲突必须交给业务规则决定:保留当前、保留历史、合并字段、生成新 identity 或人工审阅。 数据库工具不能替 owner 发明真相。

整库切换前先重放 good-after

若选择整库 candidate,至少:

  1. 在 source 建立并证明全局写围栏;
  2. 冻结最后 source commit/high-water mark;
  3. 把 $G$ 按依赖与幂等顺序应用到 candidate;
  4. 对数据库和外部系统重新对账;
  5. 重新运行对象、数据、业务与应用验证;
  6. 排空/重建连接池;
  7. 以明确 endpoint 切换,不依赖 DNS 猜测;
  8. 观察错误率、延迟、backlog 与不变量;
  9. 保留 source,不立即 rewind/delete;
  10. 达到观察窗和审批后才处理旧拓扑。

pig pitr 不会自动完成 Patroni rejoin、VIP、HAProxy/PgBouncer 切换或应用 smoke。它是 恢复编排工具,不是 cluster/service recovery controller。相关平台动作要结合 第 22 章第 33 章 的角色、路由和重建合同。

回切之后的回退边界

切到 candidate 并恢复写入后,会出现 candidate-only commits。此时回 source 也不再是 “切回连接串”,而需要反向合并:

before first candidate-only write
  -> technical route rollback may be possible

after first candidate-only write
  -> fence
  -> identify candidate-only commits
  -> reverse reconcile
  -> external compensation
  -> then decide route

这与第 30 章升级的“第一笔目标独占写”边界相同。把观察窗口只写成 30 分钟不够,还要 记录第一笔独占提交。

32.5.3 防止恢复环境向外重复发送消息和支付

数据库回到过去,外部世界不会

假设:

10:00 order committed
10:01 payment provider charged
10:02 email sent
10:03 search index updated
10:05 database restored to 09:59

数据库里订单消失,并不会自动退款、收回邮件或删除搜索文档。若恢复实例又重放同一 outbox,还可能再次扣款或发送。

因此恢复状态是一个分布式对账问题:

Sbusiness=SPostgreSQLSbrokerSpaymentSsearchSnotification S_{\text{business}} = S_{\text{PostgreSQL}} \Join S_{\text{broker}} \Join S_{\text{payment}} \Join S_{\text{search}} \Join S_{\text{notification}}

PITR 只直接构造第一项。

启动前列出所有副作用通道

通道 风险 隔离/验证
transactional outbox 历史 pending 再次发送 relay 不启动、event id 对账
logical subscription 恢复后主动连 publisher egress deny、禁用 subscription worker
CDC connector 从旧 LSN 重发 独立 connector identity,不接 candidate
cron/job 到点重新跑批 scheduler 不启动、job 状态清单
payment API 重复 charge/refund provider idempotency key + provider ledger
email/SMS/webhook 不可撤回或重复 sandbox endpoint、dispatch fence
cache/search 历史状态覆盖新状态 versioned event/rebuild policy
FDW/external function 查询或写远端 network deny、extension review

“应用没有连接”不能覆盖内置 background worker、宿主 sidecar 或外部 connector。控制项要 跨数据库、主机、网络和平台。

outbox 的恢复协议

理想 outbox 至少有:

event_id          globally stable
aggregate_id
aggregate_version
event_kind
payload_digest
created_at
dispatch_state
external_ack_id
idempotency_key

恢复时把事件分为:

already acknowledged externally
  -> do not resend; rebuild local state from ack

pending and still semantically valid
  -> send once under same idempotency key

created by bad transaction
  -> cancel/tombstone; never dispatch

unknown
  -> quarantine for owner review

不要直接删除坏 outbox 行。保留 canceled 状态和 incident/change ID,才能证明为什么没 有发送,并避免后续任务把“缺失”解释成尚未生成。

本章正式实验在 damage 事务内生成 1,000 条 pending fixture outbox;外部 dispatcher 根本不存在。对账事务精确把这 1,000 行变为 canceled,要求 row count 完全匹配, 最终 pending=0external_dispatch=0

支付必须以外部账本为准

支付场景禁止只根据恢复后的 payment.status 决定重扣或退款。至少比对:

merchant order id
provider transaction id
idempotency key
authorization / capture / refund state
amount and currency
provider event sequence
database immutable ledger

若数据库显示“未支付”但 provider 已 capture,正确动作可能是补回本地账,而不是再发 charge。若数据库显示“已退款”但 provider 没有退款,则要执行受审批补偿,而不是只改一 列让 dashboard 变绿。

服务切换的最终门

通过条件
candidate exact target/timeline,safe/bad/post 探针符合预期
database 对象、manifest、约束、索引与跨表不变量通过
delta good-after 身份完整,重放/合并无缺失
external payment/message/search/notification 已对账
isolation job/connector/route 在审批前仍被围住
topology writer 唯一,Patroni/DCS 计划经过评审
client pool drain、endpoint、transaction retry 与 smoke 明确
rollback 第一笔新独占写边界与反向对账方案明确
authority DBA、业务 owner、incident commander 各自签字

任一项 unknown 都不能被“数据库已经启动”覆盖。可以选择继续隔离验证、只提取部分数据、 保持 source 服务或升级决策,但不能把 unknown 自动转成 pass。

本章实验为什么不切流

正式 run 的目的,是证明:

wrong target can be rejected
right historical boundary can be recovered
post-target legitimate writes can be reconciled
fixture side effects can be canceled
source topology can remain healthy

它没有真实应用、payment provider、CDC、DNS/VIP 或 production owner,因而不具备服务 切换前提。business_cutover_performed=falseproduction_ch32_gate=pending 是实验通过条件,不是未完成项。


上一节:执行恢复并观察进度 · 返回本章目录 · 下一节:实战:随机恢复目标演练 · 查看全书目录 · 查看索引中心

32.6 实战:随机恢复目标演练

本节把前五节压成一次可重放实验。它不把 target 写死在脚本里,也不只恢复一个“预期会 成功”的候选,而是要求:

source audit independently yields damage XID
inclusive candidate must prove damage is present and be rejected
exclusive candidate must prove damage is absent and be accepted
raw exclusive candidate must expose post-target data loss
reconciliation must preserve every audited legitimate write
all cleanup and safety claims must survive adversarial mutation

实验会创建并删除一次性 synthetic schema,创建两个 side-restore 目录后精确删除,并 保留一份新 full backup。它只适用于已经确认的开发沙箱;guard 字符串不是生产授权。

32.6.1 在隐藏时间窗内注入误更新

先读四份合同

静态检查不连接远端:

static/labs/ch32/task.sh lint

只读预检可在一个独立新目录运行:

export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch32-capture.XXXXXX"
)"
static/labs/ch32/task.sh capture

它验证:

target identity and three-member Patroni topology
pg-test-1 is sole running primary
pg-test-2/3 are streaming replicas
PostgreSQL major and cluster_name
fixture schema absent
restore prefix empty and port free
repository readable
Pig pitr required flags present
source and upstream evidence hashes

capture 的在线 mutation 为零,不创建 backup,不恢复目录,也不改变 Patroni/DCS/路由。

完整演练需要新的私有 evidence directory

export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch32.XXXXXX"
)"
export PG36_CH32_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH32_NONPRODUCTION=true
export PG36_CH32_PRODUCTION_DATA=false
export PG36_CH32_PRODUCTION_TRAFFIC=false
export PG36_CH32_CONFIRM=RANDOM_XID_PITR_RECONCILE_CH32

static/labs/ch32/task.sh drill:pitr

任一 guard 缺失,runner 在 source mutation 前退出。evidence directory 已非空也会退出, 避免把新 run 混进旧证据。正式实现见:

fixture 是一份可独立计算的账

test.pg36_ch32 有 5,000 个账户:

account_id       1 .. 5000
opening balance  100000 + account_id cents
status           active

opening total:

$$ 5000 \times 100000

  • \frac{5000 \times 5001}{2} =512{,}502{,}500 $$

runner 用系统随机源选择连续 1,000 个 victim。目标账户范围、run ID 与事故时刻只在本次 私有证据中确定,恢复命令没有预置 XID 或固定时间。

四笔状态和一份 fresh backup

顺序:

base transaction
  create 5,000 accounts and run marker
  write base audit

fresh full backup

safe-before transaction
  +100 cents to all 1,000 victims
  insert 1,000 safe ledger rows
  write safe XID audit

damage transaction
  set all 1,000 victim balances to zero
  status = mispriced
  create 1,000 fixture-only pending outbox rows
  write damage XID audit in the same transaction

post-target transaction
  +700 cents to first 100 victims
  insert 100 legitimate ledger rows
  write post-target XID audit

damage 和 outbox 在同一事务中,因此 inclusive candidate 要么同时看到二者,要么实验 失败。外部 dispatcher 不存在,external_dispatch_enabled=false 写入 audit 与合同。

注意 source 在 damage 后、修补前的当前值:

first 100 victims    balance=700, status=mispriced
other 900 victims   balance=0,   status=mispriced

而正确历史值应是 opening + 100,前 100 个还要再加合法的 700。这个设计使“直接写回 recovered value”必然丢数据,逼迫操作者处理 good-after。

target 来自 source,不来自注入代码常量

runner 在三笔事务完成后,单独查询:

pg36_ch32.incident_audit
  where run_id = exact marker
    and stage = damage

取得 damage XID,再记录当前 LSN 与 required WAL segment。随后执行一次 WAL switch、 pgbackrest check,轮询 repository 直到 archive max 覆盖 required segment。若 XID 缺失、不是数字、与 damage transaction 返回值不一致或 archive 未覆盖,恢复不开始。

这不是说生产必须依赖一张同名 audit 表,而是证明目标身份必须从独立事实导出,并与 fixture/业务身份绑定。

32.6.2 独立定位目标、恢复、验证与回切

两个候选只有一个变量不同

参数 inclusive exclusive
backup 同一 exact fresh full 同一 exact fresh full
target 同一 damage XID 同一 damage XID
timeline current current
action promote promote
side directory marker/inclusive marker/exclusive
archive mode off off
差异 默认 inclusive --exclusive

两个 plan 都先被保存和校验。两个 postmaster 顺序启动在同一空闲端口,只监听各自 mode-0700 Unix socket;inclusive 停止并删除后才创建 exclusive。

inclusive 候选必须被否决

正式 run 观察:

base audit                   present
safe-before audit            present
safe ledger rows             1,000
damage audit with target XID present
mispriced victim rows        1,000
pending wrong outbox         1,000
post-target audit/ledger     absent / 0
decision                     rejected

这证明默认 inclusive XID 语义停在目标事务之后。若 validator 只检查 pig_result.success 而没有检查 damage 数据,这个错误候选会看起来完全健康。

exclusive 候选才是历史正确边界

base audit                   present
safe-before audit            present
safe ledger rows             1,000
damage audit                 absent
pending wrong outbox         0
post-target audit/ledger     absent / 0
victims active               1,000
victim balance sum           deterministic opening + 100 each
decision                     accepted

source timeline 为 11;两个隔离候选沿 current 重放并 promote 到 timeline 12。因为 archive_mode=off,两个实验分支不会进入共享 repository。

accepted 的含义是“可作为历史正确数据源”,不是“可以直接切流”。它明确缺少 100 笔 post-target ledger。

从 candidate 提取,再与 source audit 合并

exclusive candidate 导出 1,000 行:

account_id
recovered_balance_cents
status=active

私有 evidence 只保存 count 和导出集合 SHA-256。source 独立导出 100 条 post-target ledger,要求 key 集合正好等于预期 100 个账户,每条 delta 都是 700。

runner 构造 typed temporary table:

account_id
recovered_balance_cents
post_delta_cents
expected_current_balance_cents

一笔事务中先验证 1,000 行 current preimage:

first 100 expected current balance = 700
remaining 900 expected current balance = 0
all status = mispriced

完全匹配后才执行:

new balance = recovered balance + audited post delta
status = active
pending wrong outbox -> canceled

条件更新或 outbox row count 不是 1,000,事务抛错并回滚。

最终 manifest 独立可算

safe 增量:

1000×100=100,000 1000 \times 100 = 100{,}000

post-target 合法增量:

100×700=70,000 100 \times 700 = 70{,}000

最终总额:

512,502,500+100,000+70,000=512,672,500 512{,}502{,}500 + 100{,}000 + 70{,}000 =512{,}672{,}500

正式结果:

accounts                    5,000
total balance               512,672,500 cents
victims active              1,000
victims mispriced           0
safe ledger                 1,000
post-target ledger          100
pending wrong outbox        0
canceled wrong outbox       1,000
external dispatch           0

runner 最后按 exact run marker 删除 pg36_ch32,停止两个 candidate,确认 socket/PID 消失后删除 exact roots;再次验证 Patroni 仍为一主两从、source system lineage 未变、 fresh backup 仍在 repository。它不执行 application cutover。

32.6.3 测量 RPO/RTO 并记录版本迁移工时

正式时间分解

公开证据 pitr-run.json 记录:

阶段 inclusive exclusive
plan 0.219 s 0.214 s
restore copy 3.040 s 2.974 s
start → promoted 0.961 s 0.939 s
candidate validation 0.340 s 0.503 s

共同阶段:

target identification        0.183 s
fresh full backup            2.090 s
pgBackRest check             0.630 s
reconciliation               0.200 s
backup logical size          42,355,954 bytes
repository delta              5,751,304 bytes

这是本地 ARM64 四虚拟机、42.4 MB 合成数据的一次观测。磁盘缓存、已有 repository、 网络距离、压缩/加密、WAL 数量、表空间、extension 和并发都与生产不同。

一次事故有多种 RPO

口径 本次结果 含义
archive RPO target WAL covered 没有注入 WAL 缺口
raw exclusive loss 100 rows 为排除 damage,故意丢弃 target 后合法写
reconciled fixture loss 0 rows 100 条 audited delta 全部合并
external-system loss 未测试 没有真实 payment/message provider

所以不能只说“RPO=0”。更准确的结论是:

在这份 synthetic fixture、完整 audit 与无外部 dispatch 的条件下,exclusive PITR 原始缺少 100 笔合法写;经过条件对账后,已知 fixture 数据损失为 0。

若 production 没有完整 good-after audit,reconciled RPO 就是 unknown,不会因为 WAL 完整自动变成 0。

restore time 不是 RTO

本次没有测量:

incident detection
human target investigation
production approval
large-scale validation
business-owner reconciliation
application deployment/config change
pool drain and traffic switch
cache/search rebuild
observation window

因此公开 evidence 固定:

timings_are_sandbox_observations = true
production_rto_claimed = false

生产演练应测 end-to-end:

t0 incident declared
t1 mutation fenced
t2 target approved
t3 candidate engine ready
t4 candidate business-valid
t5 good-after/external reconcile complete
t6 service traffic restored
t7 observation window exited

分别报告阶段时长与 critical path,不能只取最快的 restore copy

版本恢复与版本迁移是两项工时

物理 backup 要由兼容的 PostgreSQL major 与 extension library 启动。若事故时生产已经 升级,而目标 backup 属于旧 major,runbook 可能需要:

acquire exact old server binaries
acquire matching extension libraries
recreate locale/collation runtime
restore and validate on old major
export selected data
transform/import into current major
validate cross-version semantics

这段工时要独立记录,不能用“目标主机已经装了新版 PG”抵消。不能直接拿 PG18 server 启动 PG17 physical PGDATA;跨 major 迁移应使用第 30 章的 pg_upgrade、逻辑迁移或 对象提取路径。

本次 source、backup 与 candidate 都是 PostgreSQL 18.6,没有执行 major migration。 所以本次只证明“同 major 运行时已就绪”的恢复路径,没有测得跨版本迁移工时。生产 报告应写 not required in this scenario,而不是虚构成 0 秒。

建议工时表:

工作 started ended active labor waiting owner evidence
old runtime acquire
extension compatibility
physical restore
logical extract/transform
target import
cross-version validation

32.6.4 输出恢复证据和备份体系改进项

私有证据包

preflight-evidence.json
exercise-manifest.json
source-before.json
fixture.json
backup.json
inclusive-plan.json
inclusive-recovery.json
exclusive-plan.json
exclusive-recovery.json
reconciliation.json
source-after.json
cleanup.json
negative-report.json
validation-report.json
public-summary.json
review.txt

目录为 0700,文件为 0600。review 拒绝 SCRAM verifier、private key、clear password、 credential URI 和 raw system identifier。公开摘要只保留版本、规模、计数、时间、 候选决策、安全结论和 gate。

对完成的同一证据目录,可以离线重跑:

export PG36_EVIDENCE_DIR=/private/existing/ch32-run
static/labs/ch32/task.sh verify
static/labs/ch32/task.sh review

all 也只执行 verify + review,不会再次创建 fixture、backup 或 restore。修改 12 个 实验源文件中的任何一个后,旧 preflight/source hash 会失败;应开启新 run,不应给旧 证据重新盖章。

32 个反例不是装饰

validator 将每个完整 evidence 深拷贝后实际注入变异,要求全部被拒绝:

authority:
  production data allowed
  managed PGDATA / Patroni mutation allowed
  backup expiration allowed

target:
  time replaces audited XID
  exclusive disabled
  timeline changes to latest
  archive mode enabled
  TCP listener enabled

preflight:
  target/primary/topology changed
  source or upstream hash changed

recovery:
  damage XID or backup label changed
  archive coverage false
  inclusive damage missing or candidate accepted
  exclusive damage/post present or safe missing

reconciliation:
  repaired rows = 999
  post-target write lost
  external dispatch = 1

cleanup/governance:
  route changed
  restore root remains
  production gate approved

正式 run:

declared counterexamples rejected  32 / 32
live evidence mutants rejected     32 / 32
source files hash-bound            12 / 12
secret material                    absent

从一次成功演练生成改进 backlog

不要把结论写成“PITR 已掌握”。按发现分类:

类别 本次证明 下一轮故意测试
target exact serial damage XID 并发事务与宽时间窗
WAL target 后 segment 已归档 缺一个 WAL、archive 延迟、坏 history
repository 单一 sandbox repo 可读 lost key、read-only credential、immutable/off-site
backup fresh full diff/incr dependency 与过期边界
runtime 同 PG18.6 旧 major、缺 extension、collation drift
data 5,000 确定性账户 多 TB、tablespace、large object
reconcile 100 audited deltas 冲突写、未知 delta、人工 exception
side effects fixture dispatcher absent broker/payment/search sandbox
topology source HA 未变 经授权的 managed restore/rejoin
service 未切流 application smoke、pool drain、观察窗

每项改进要有 owner、截止时间、下次 drill 场景和通过证据。只写“加强监控”无法改变下一次 恢复结果。

终局结论

本章正式证据支持:

exact audited XID targeting demonstrated
inclusive/exclusive boundary demonstrated
isolated Pig side restore demonstrated
post-target reconciliation demonstrated
exact fixture/root cleanup demonstrated
source HA preservation demonstrated

它不支持:

production cutover approved
worst-case archive RPO met
production RTO met
external side effects compensated
cross-major restore completed
regional disaster recovery completed

所以最终决策保持:

business_cutover_performed = false
production_ch32_gate = pending

下一章切换响应目标:当数据本身正确,但 writer、timeline 或节点拓扑失去唯一性时,怎样 完成主从切换、fencing、pg_rewind 与故障节点重建。


上一节:数据验证与安全回切 · 返回本章目录 · 下一章:故障切换与集群重建——力挽狂澜 · 查看全书目录 · 查看索引中心

33 故障切换与集群重建——力挽狂澜

第 20 章演练的是健康集群上的计划切换;第 31 章要求事故处理中先保护现场、分清 事实与假设;第 32 章处理“集群健康、数据却已经写错”的 PITR。本章面对另一条恢复 路线:

原主库已经不可用或不再可信,怎样在不制造两个可写主库的前提下接受新主库,并让 旧主库安全归队?

这不是一条 failover 命令的问题。操作者必须连续回答三个不同问题:

failure diagnosis
  客户端失败发生在哪一层,数据库真的失去主库了吗?

authority
  哪个节点仍有权写;旧主是否已围栏;谁有资格成为候选?

lineage repair
  旧主与新主是否已经分叉;可以 rewind,还是必须从可信源全量重建?

把三问混在一起,会产生两类相反事故:代理故障被误判成数据库故障,执行了一次多余 promotion;真正的主机分区又被当成普通进程退出,在旧主仍可能写时接受了新主。

学习完成标准

完成本章后,读者应能:

  1. 区分 PostgreSQL 进程、主机、存储、复制、DCS、代理与客户端失败;
  2. 从用户症状反向追踪服务路径,而不是把“连不上”直接翻译成“主库宕机”;
  3. 用 Patroni、SQL、操作系统、DCS 与客户端证据共同判定当前数据库角色;
  4. 解释 WAL 的 sent、write、flush、replay 位置分别代表什么;
  5. 解释 timeline history、共同祖先与历史分叉,而不是把 timeline 当版本号;
  6. 理解“数据看起来最新”只是候选条件之一,不等于可安全提升;
  7. 列出 Patroni 候选的可达性、tag、lag、timeline、同步状态与 watchdog 条件;
  8. 区分 DCS 多数派、PostgreSQL 副本数量和业务写多数派,避免混用“quorum”;
  9. 解释 failsafe_mode 为什么要求 incumbent primary 联系全部已知成员;
  10. 为旧主选择进程、watchdog、云/虚拟化、电源、存储或网络 fence,并写出证据;
  11. 区分 automatic failover、planned switchover 与 manual failover;
  12. 在自动化不能证明 authority 时停下来转人工,而不是强制选一个候选;
  13. 判断 pg_rewind 的 lineage、停机、checksums/wal_log_hints、WAL 与权限前提;
  14. 在 rewind 失败或前提不明时,转向可信 backup 或 fresh base backup;
  15. 重建后复核复制槽、连接端点、客户端未知结果、监控和备份;
  16. 分开报告检测时间、围栏时间、控制面恢复、客户端写缺口与数据损失;
  17. 用 Pigsty/Patroni 命令执行动作,再回到 PostgreSQL 原生证据验收;
  18. 输出一份包含失败域、authority、timeline、token 对账、复位与结论边界的证据包。

三个平面,四道门

一次 HA 事故至少横跨三个平面:

平面 关键事实 典型证据
数据面 谁可写、WAL 到哪里、哪条 timeline pg_is_in_recovery()、LSN、sender/receiver、control data
控制面 谁持有 leader lock、谁可竞选、自动化是否暂停 Patroni REST/CLI、DCS revision、动态配置、tags
服务面 客户实际连到哪里、池与代理何时摘挂节点 HAProxy/PgBouncer health、DNS/VIP、端到端 token

恢复路径依次通过四道门:

诊断门
  失败域与影响边界是否有多源证据?

围栏门
  旧主是持有有效 authority,还是已经被证明不能写?

候选门
  新主是否同 lineage、可达、合格且在可接受数据风险内?

交付门
  路由、未知结果、归队副本、监控、归档和备份是否重新健康?

任何一道门为 unknown,都不能用“业务很急”把 unknown 改写成 true。可以在事故指挥下 明确承担风险,但风险接受必须被记录,不能藏在 --force 后面。

本章的核心不变量

故障切换不是“副本变成主库”,而是维持下面的不变量:

accepted writable primary=1 \left|\text{accepted writable primary}\right| = 1

这里的 accepted 很重要。网络分区两侧可能各有一个 pg_is_in_recovery()=false 的 PostgreSQL;只看本机 SQL 会得到两个“主库”。要让其中一个可被系统接受,还必须有 authority 与 fence:

accepted primary
  = writable PostgreSQL
  + valid control-plane authority
  + old-primary exclusion
  + admissible lineage
  + service-route acceptance

路由摘除只能防止正常客户端访问旧主,不等于围栏。定时任务、直连、复制、维护脚本或 网络另一侧仍可能写入旧主。真正的 fence 必须使它失去写能力,或至少使它无法继续产生 会被业务接受的状态,并能由独立证据验证。

正式实验

本章在已确认的四节点 Pigsty 开发沙箱完成两段真实实验。

第一段对 managed pg-test

initial primary       pg-test-1
eligible replicas     pg-test-2, pg-test-3
fault                 guarded systemctl stop patroni on pg-test-1
candidate             not forced; selected by Patroni at runtime
client                200 ms idempotent INSERT through port 5433
rejoin                start pg-test-1 and require streaming
baseline restore      planned switchover to pg-test-1
DCS/network mutation  none
managed reinit        none

实际候选是 pg-test-3。这不是脚本预期之外的杂音,而是实验最重要的结果之一: 自动竞选必须接受“任一满足条件的副本”,runbook 不能把某个候选预写成已经发生的事实。

正式观测:

timeline                         17 -> 18 -> 19
Patroni service stop             1.582 s
action start -> process fence    1.817 s
action start -> topology stable  4.536 s
old primary start -> streaming   2.527 s
planned baseline switchover      2.832 s

client attempts                  160
acknowledged                     130
unknown                          30
acknowledged missing             0
duplicate tokens                 0
unreconciled unknown             0
maximum acknowledgement gap      6.212 s

服务路径通过 Unix socket 回源,inet_server_addr() 为 NULL,因此 runner 没有伪造 后端地址,而是用“客户端观察到的 timeline + 同期 Patroni 拓扑”归属提交:旧 timeline 确认 18 次,新 timeline 确认 112 次。

第二段在 pg-test-3 的一次性目录创建同源临时集群 A/B/C:

A -> basebackup -> B
stop A
promote B and write new-primary branch
stop B
start A alone and write old-primary-divergent branch
stop A; restart B
pg_rewind A from B -R
start A as streaming standby
fresh pg_basebackup C -R from B
start C as streaming standby
stop all and remove exact root

两个分叉 primary 从不同时运行。结果:

same system identifier           true
timeline diverged                true
pg_rewind                        0.245 s
rewound A streaming              true
B branch markers on A            present
A divergent marker after rewind  absent
fresh pg_basebackup              0.228 s
C streaming                      true
temporary root removed           true

这些是几十 MB 的本机虚拟化沙箱观测,不是生产 RTO。环境使用异步复制、单 etcd、 watchdog off;没有注入断电、存储故障、真实网络分区,也没有执行破坏性的 managed reinit。最终门禁保持 production_ch33_gate=pending

阅读前后关系

本章目录

33.1 先识别失败域

33.2 复制状态与时间线证据

33.3 自动故障转移的保护条件

33.4 DCS 故障的安全处理

33.5 旧主重加入与集群重建

33.6 切换与重建 runbook

33.7 实战:主库故障与 DCS 干扰

权威参考

PostgreSQL:

Patroni:

Pigsty:


上一章:PITR 与误操作恢复——妙手回春 · 返回下卷导读 · 下一章:过载保护与资源故障判型——李代桃僵 · 查看全书目录 · 查看索引中心

33.1 先识别失败域

“连接失败”是用户看到的症状,不是失败域。它可能来自 DNS、证书、连接池、HAProxy、 主库进程、整台主机、磁盘阻塞或 DCS;这些故障需要完全不同的动作。没有定位失败域就 执行 promotion,相当于用一次有数据风险的拓扑变更修一个尚未证明属于数据库的问题。

先画当前请求真正经过的路径:

application
  -> DNS / VIP
      -> HAProxy
          -> PgBouncer
              -> PostgreSQL socket / TCP
                  -> primary process
                      -> storage

Patroni -> DCS leader lock
Patroni REST -> HAProxy health decision
primary -> WAL sender -> receiver -> replay

每一条箭头都可以失败。事故调查应从失败请求出发逐跳验证,同时从 PostgreSQL 和 Patroni 反向确认角色,最终在同一 UTC 时间线上汇合。

33.1.1 进程失败、主机失败与存储失败

三类失败不能只用 ping

失败域 可能观察 尚不能推出 首要动作
PostgreSQL 进程 SSH/OS 正常,端口或 socket 失败 主机或磁盘必坏 看 Patroni、postmaster、日志与 I/O
Patroni 进程 PostgreSQL 可能仍运行,REST 失败 PostgreSQL 已被围栏 查 service、leader lease 与 watchdog
主机 SSH、REST、数据库同时不可达 主机已断电 从虚拟化/BMC/云控制面取证并 fence
存储 I/O error、fsync 延迟、只读文件系统 其他节点也坏 停止扩大损伤,验证数据目录与备份

进程退出是最容易围住的故障。若主机仍可控,可以明确停止 Patroni/PostgreSQL,并从 systemd、PID、REST 和 SQL 四处取得“不能写”证据。本章实验就是这种窄模型:

systemctl is-active patroni    -> inactive
managed postmaster PID         -> absent
Patroni REST                   -> unreachable
direct SQL                     -> unavailable

这证明的是 process fence,不是 hardware fence。

主机不可达更危险。ping 超时可能是 ICMP 被过滤,SSH 超时可能只是管理网故障,云 API 显示 running 也不说明内核还能调度 PostgreSQL。若无法从故障主机自身证明停机,应使用 独立控制面:

  • BMC/IPMI 或电源控制器断电;
  • 云或虚拟化平台 stop/fence,并等待实例状态与网络状态收敛;
  • 存储租约或卷 detach,保证旧主无法继续访问可写数据;
  • 硬件/软件 watchdog 在 Patroni 失去心跳时使节点重启;
  • 经审计的网络 fence,阻断所有写入路径而不只是一条客户端路由。

“我连不上它”不是 fence evidence。一个从管理网不可达、从业务网仍可服务的旧主,正是 最典型的脑裂来源。

存储故障不一定表现为进程退出

存储卡顿时 postmaster、REST 和端口都可能仍在,健康检查却因超时把节点摘除。要同时 检查:

kernel / filesystem error
device latency and queue depth
PostgreSQL wait events and checkpoint latency
Patroni loop delay
DCS lease update latency
WAL archive and replica sender progress

若进程因 I/O hang 无法及时 demote,单纯 systemctl stop 也可能卡住。此时需要 watchdog 或外部 power/storage fence。不要为了让命令“成功返回”直接 kill -9 Patroni;杀掉 控制进程并不自动杀掉仍可写的 postmaster,反而可能移除最后一层角色管理。

先写可证伪假设

hypothesis: PostgreSQL process failure on pg-test-1
supports:
  - host SSH reachable
  - patroni service inactive
  - postmaster PID absent
  - REST and direct SQL unavailable
contradicts:
  - kernel I/O errors
  - host power state unknown
not_claimed:
  - host is powered off
  - storage is healthy
stop_line:
  - any evidence that old postmaster still accepts writes

如果反证出现,就回到失败域判断,不要硬把它解释成预期剧本。

33.1.2 网络分区、客户端不可达与代理误判

“数据库可用”有多个观察位置

至少分开下面四个探针:

local SQL       node-local socket -> PostgreSQL
direct SQL      observer -> exact PostgreSQL address
service SQL     application subnet -> HAProxy/PgBouncer/VIP
business write  real auth/transaction/idempotency path

local SQL 成功、service SQL 失败,优先调查服务路径;service SQL 成功、某个应用失败, 优先调查 DNS、证书、凭据、连接池和应用网络。所有 SQL 都失败而 Patroni REST 正常, 再看 PostgreSQL 状态与健康检查条件。

代理健康检查回答的是一个谓词,不是宇宙真相。Patroni 的 /primary/read-write 只有在节点为 primary 且持有 leader lock 时返回成功,适合写服务;replica/read-only 服务需要不同 endpoint。若 HAProxy 误用了普通 /patroni,replica 也可能被当成写后端。

审阅时写出:

frontend address and port
backend health URL and expected status
check interval / rise / fall
connection-drain behavior
PgBouncer transaction/session mode
DNS/VIP convergence
application retry and target_session_attrs
direct-IP escape routes

网络分区没有单一“网络坏了”

用方向矩阵描述:

来源 → 目标 DCS primary REST replica REST SQL client
old primary ? local ? local ?
candidate ? ? local local ?
observer ? ? ? ? ?

primary -> DCS 失败、replica -> DCS 成功,与所有节点都失去 DCS 是两种场景; primary -> replica 失败、replica 仍可见 DCS,又与客户端单向不可达不同。只写 “network partition”无法决定谁应 demote。

网络恢复时还可能出现陈旧连接:

  • 应用池里已有到旧主的 session;
  • DNS 缓存仍解析旧 VIP;
  • PgBouncer server connection 尚未重建;
  • 长事务在切换前已开始,客户端只看到断线;
  • 客户端超时,但 COMMIT 实际已在新主落盘。

因此切换后必须按幂等身份对账 unknown outcome,不能把所有网络错误都当作“未执行” 再裸重试。

代理摘除不是旧主围栏

把旧主从 HAProxy backend 移除只能约束走这条代理的客户端。以下 writer 可能绕过它:

direct host:port connection
maintenance job on database host
logical replication subscriber callback
scheduler or ETL
monitoring remediation script
another region's proxy
cached DNS or stale VIP owner

服务面隔离是必要条件,却不能替代数据面的 stop/watchdog/power/storage fence。

33.1.3 主库失败、复制停滞与控制面失败

先问“谁坏了”,再问“要不要切”

当前事实 默认路线
primary 可写,replica replay 停滞 修复制、保 WAL;通常不切主
primary 不可写且已围栏,候选健康 自动或受控 failover
primary 健康,代理服务失败 修服务路径,不 promotion
DCS 不可达,数据库角色仍稳定 保护角色、查 failsafe/网络,不重置选举
多个节点自称 primary 事故升级;先停止外部写和取得 fence
system identifier 不同 不是同一复制集;禁止提升

副本坏了不需要主库故障切换。PostgreSQL 官方文档也区分 primary failure 与 standby failure:standby 可重启就继续 recovery,不可恢复再创建新 standby。为了让“拓扑看起来 整齐”而切主,会给业务增加一次没有必要的中断。

控制面状态与数据库事实要成对记录

一个最小事实包:

UTC and monotonic timestamps
Patroni members: role / state / timeline / lag / tags
dynamic config: pause / ttl / loop_wait / retry_timeout
DCS: leader key identity / revision / latency / auth result
SQL per node: pg_is_in_recovery / system_identifier / timeline / LSN
OS per node: service / PID / I/O / clock
service path: backend selected / health result
client: exact token and outcome

不要只贴 patronictl list 截图。DCS 故障时 CLI 自己可能无法读取状态;SQL 能显示本机 角色,却不知道它是否仍持有 authority;代理能显示后端健康,却不知道数据 lineage。 相互矛盾正是事故信号,不能挑一个最方便的来源当真相。

五条立即停手线

出现任一项,自动动作转人工:

  1. 旧主是否仍可写为 unknown;
  2. 观察到两个 writable PostgreSQL;
  3. 候选 system identifier 或 timeline history 不明;
  4. DCS 权威分区与数据库可达分区不一致;
  5. 无法说明客户端 unknown transaction 如何对账。

转人工不是“直接运行 manual failover”。它意味着冻结进一步自动化、保留当前事实,由 事故指挥人明确风险、候选、fence、回退和业务 owner,再授权下一动作。

本节检查表

进入候选选择前,应能完成:

failure_domain: process | host | storage | network | proxy | replication | dcs
affected_paths: [...]
incumbent_sql_role: fact-reference
incumbent_authority: valid | fenced | unknown
candidate_set: [...]
lineage_status: same-system-id-and-history | unknown
client_unknown_outcomes: count-and-reconciliation-owner
contradicting_evidence: [...]
next_action: bounded-and-reversible
stop_condition: explicit

下一节将把其中的 replication 与 timeline evidence 展开。


返回本章目录 · 下一节:复制状态与时间线证据 · 查看全书目录 · 查看索引中心

33.2 复制状态与时间线证据

故障切换前,复制证据回答两个不同问题:

currency    候选已经收到并落盘/重放到哪里?
lineage     这些 WAL 属于哪一个 system identifier 和哪条历史分支?

只比较一个“延迟 0 MB”不能回答第二问;只看 timeline 相同,也不能说明最后一笔已确认 事务已经到达候选。

33.2.1 发送、接收、重放位置与延迟

一条 WAL 有多个位置

primary 上的 pg_stat_replication 包含:

sent_lsn    sender 已发送
write_lsn   standby OS 已写入
flush_lsn   standby 已持久化
replay_lsn  standby 已重放、查询可见

standby 上的 pg_stat_wal_receiver 则从接收端描述 upstream、written/flushed 与最近结束 位置。对一个候选 $r$,可以定义:

Lsend(r)=diff(LSNprimary,sent_lsnr) L_{\text{send}}(r) = \operatorname{diff}(LSN_{\text{primary}}, sent\_lsn_r) Ldurable(r)=diff(LSNprimary,flush_lsnr) L_{\text{durable}}(r) = \operatorname{diff}(LSN_{\text{primary}}, flush\_lsn_r) Lvisible(r)=diff(LSNprimary,replay_lsnr) L_{\text{visible}}(r) = \operatorname{diff}(LSN_{\text{primary}}, replay\_lsn_r)

字节差是 WAL 距离,不是秒数。相同 1 MB 在空闲系统可能代表很久,在写入高峰可能只 代表毫秒;累积统计也有采样与刷新周期。

primary 侧只读检查:

SELECT
    application_name,
    client_addr,
    state,
    sync_state,
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn,
    pg_wal_lsn_diff(pg_current_wal_lsn(), flush_lsn)
        AS durable_gap_bytes,
    pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)
        AS replay_gap_bytes
FROM pg_stat_replication
ORDER BY application_name;

standby 侧:

SELECT
    pg_is_in_recovery(),
    pg_last_wal_receive_lsn(),
    pg_last_wal_replay_lsn(),
    pg_wal_lsn_diff(
        pg_last_wal_receive_lsn(),
        pg_last_wal_replay_lsn()
    ) AS receive_replay_gap_bytes;

SELECT
    status,
    sender_host,
    sender_port,
    written_lsn,
    flushed_lsn,
    latest_end_lsn,
    latest_end_time
FROM pg_stat_wal_receiver;

“flush 到了”与“业务可见”分开

promotion 关心 durability,读服务关心 replay visibility。一个 standby 可能已经 flush 某段 WAL,却因 replay pause、冲突、I/O 或恢复延迟尚未应用;promotion 后它会 继续完成 recovery,但切换时长和读一致性仍受影响。

同步复制也必须说明同步到哪一层:

synchronous_commit primary 等待的主要确认
on synchronous standby flush
remote_write standby OS write
remote_apply standby replay
local / off 不以 remote durability 作为提交门槛

不要仅凭 sync_state='sync' 宣称零损失,还要核对当前事务的 commit 策略、同步成员、 同步节点故障组合与 Patroni 模式。

异步 RPO 不是一个固定配置值

Patroni 的 maximum_lag_on_failover 是候选过滤条件,不是精确 RPO。官方 replication mode 文档指出,primary WAL 位置不是实时连续采样;异步最坏损失还包括最近采样之后 继续产生的 WAL。可以把风险粗略写为:

$$ RPO_{\text{bytes}} \lesssim maximum_lag_on_failover

  • WAL_{\text{after last observation}} $$

业务数据损失又不等于 WAL 字节数。最终要用业务 identity:

last acknowledged token on old timeline
first acknowledged token on new timeline
unknown tokens resolved against new primary
missing committed business identities

本章实验有 30 次客户端 unknown,但对账后全部能分类,已确认 token 缺失为 0。这只 说明该 fixture,不把异步策略改写成生产零 RPO。

33.2.2 timeline、历史文件与分叉

timeline 是历史分支

standby promotion 会创建新的 timeline。新 timeline 的 history 文件记录它从父 timeline 的哪个 WAL 位置分叉:

timeline 1  original history
                 \
timeline 2        promoted standby
                      \
timeline 3             later switchover

timeline 数字较大只说明产生过后续分支,不自动说明该节点包含最多业务提交。必须结合 history 的父子关系和 fork LSN。两个节点还必须具有相同 PostgreSQL system identifier; system identifier 不同意味着根本不是同一物理集群 lineage。

SQL 控制信息:

SELECT
    system_identifier,
    pg_control_version,
    catalog_version_no
FROM pg_control_system();

SELECT
    timeline_id,
    redo_lsn,
    checkpoint_lsn
FROM pg_control_checkpoint();

注意 pg_control_checkpoint().timeline_id 是控制文件中最近 checkpoint 的 timeline 事实,不是任意时刻的分布式 authority。将它与 Patroni REST/DCS、当前 WAL 文件名和 recovery 状态一起解释。

分叉发生在“旧主继续写”时

设共同祖先为 $F$:

old primary: F -> A1 -> A2
new primary: F -> B1 -> B2

AB 都可能是内部一致的 PostgreSQL 历史,但不能把两边物理 WAL 直接拼接成一条 历史。旧主归队要选择一个权威分支:

  • 保留新主 B;
  • 丢弃旧主 A 在分叉后的变化;
  • pg_rewind 把旧主变成 B 的 standby;
  • 若 A 中有需要抢救的业务事实,先隔离提取并由业务 owner 对账,不能让 A 重新服务。

PostgreSQL 官方 pg_rewind 正是用 target timeline history 找共同祖先,并复制分叉后 目标上需要替换的页面和文件。它不是双向 merge。

recovery_target_timeline=latest

HA standby 通常应跟随新 promotion 产生的最新 timeline。PostgreSQL warm standby 文档 建议多 standby 的 HA 场景使用 latest(也是默认),否则 standby 可能到父 timeline 终点后停住,不能沿新主继续。

但 PITR 与 HA 的“latest”语义不同:

  • HA follower 通常要跟随当前权威新主的最新分支;
  • 事故恢复可能故意选择 backup 所在的 current timeline,避免误入后来分支;
  • 明确 timeline 必须能由 history 与恢复目标证明。

这也是第 32 章为何把 target timeline 写入恢复合同。

本章正式 timeline 证据

managed 演练:

before      pg-test-1 primary, timeline 17
failover    pg-test-3 primary, timeline 18
restored    pg-test-1 primary, timeline 19
system id   unchanged across all healthy phases

disposable 实验则故意制造:

B promoted and writes on newer timeline
A writes old-primary-divergent while B is stopped
pg_rewind target A from source B
A starts in recovery and streams from B
old-primary-divergent marker absent

只有 timeline 前进而没有 marker 验证,不能证明选对分支;只有 marker 正确而没有 system identifier、receiver status 和 cleanup,也不是完整归队证据。

33.2.3 数据最新不等于可以安全提升

候选资格是条件交集

一个可接受候选更接近:

Eligible(r)=Reachable(r)SameLineage(r)TagOK(r)LagOK(r)TimelineOK(r)ModeOK(r)FenceOK Eligible(r)= Reachable(r) \land SameLineage(r) \land TagOK(r) \land LagOK(r) \land TimelineOK(r) \land ModeOK(r) \land FenceOK

Patroni 的健康候选检查包括 REST 可达、nofailover 未启用、required watchdog 可用、 lag 不超过 maximum_lag_on_failover;启用 check_timeline 时还检查 timeline。同步 模式还会考虑同步成员;manual failover 在无 leader 时可能放宽部分 lag/sync 条件, 因此它比自动 failover 更需要人工风险确认。

读取状态时至少看:

pig pt list pg-test -o json
pig pt config show -o json

不同版本的输出选项以本机 pig pt ... --help 为准。不要让脚本解析面向人的表格列宽, 也不要从显示顺序推断“第一个 replica 就会提升”。

为什么正式实验没有指定候选

演练前 pg-test-2pg-test-3 都为 streaming、同 timeline、lag 在策略内。最初若 把 pg-test-2 写成“expected candidate”,实际 Patroni 却选择 pg-test-3,验证器 会错误判定一个健康自动切换失败。

正确合同是:

eligible set        {pg-test-2, pg-test-3}
candidate forced    false
actual candidate    observed at runtime
acceptance          exactly one eligible candidate is running primary
other eligible      remains streaming replica
old primary         process-fenced, then later streaming

生产若必须固定候选,应把需求变成显式操作和策略:planned switchover 指定 candidate, 或通过 tags/拓扑策略定义资格;不能一边声称自动竞选,一边在文档里假装结果已确定。

“最新”仍可能不安全的五种情况

  1. 节点 system identifier 不同,只是碰巧有相似业务表;
  2. 节点在较旧 timeline,缺失已经被接受的新主历史;
  3. 节点被 nofailover 标记,可能承担延迟副本或特殊任务;
  4. 旧主尚未 fence,即使候选有最新 WAL,提升仍可能双写;
  5. DCS authority 不明,候选看到的“无 leader”只是分区后的局部视图。

此外,最新 standby 可能位于与旧主相同的失败域:同机架、同电源、同 SAN、同 hypervisor。 数据最新不等于失败独立性。

提升前证据卡

candidate: observed-member
system_identifier: exact-value
timeline:
  id: exact
  history: verified
wal:
  receive: exact-lsn
  flush: exact-lsn
  replay: exact-lsn
  observed_at: utc-and-monotonic
policy:
  paused: false
  nofailover: false
  lag_budget: bytes
  sync_membership: value
  check_timeline: value
fence:
  incumbent: fenced | valid-authority
  evidence: [...]
client:
  last_ack: token
  unknown_set: manifest
decision_owner: role

缺一项不一定永远不能提升,但必须把缺失变成显式 risk acceptance,不能悄悄忽略。


上一节:先识别失败域 · 返回本章目录 · 下一节:自动故障转移的保护条件 · 查看全书目录 · 查看索引中心

33.3 自动故障转移的保护条件

自动故障转移的价值,是在预先证明过的条件内缩短决策时间;它不是在信息不足时替人 猜测。一个安全状态机应当宁可暂时没有可写主库,也不接受两个相互冲突的写 authority。

简化后的 Patroni 路径:

incumbent loop
  -> renew leader lock
      -> success: remain primary
      -> failure:
           distinguish lock loss from transient DCS error as far as possible
           demote, or enter preconfigured failsafe checks

replica loop
  -> observe no valid leader
      -> verify eligibility and data state
          -> leader race through DCS
              -> winner promotes
              -> others follow the new timeline

ttlloop_waitretry_timeout 会影响探测和选举节奏,但端到端 RTO 还包括 PostgreSQL 停止/恢复、DCS 延迟、候选 replay、代理健康检查、连接重建和客户端重试。

33.3.1 候选健康、数据风险与多数判断

候选先通过资格门

候选至少需要:

条件 目的 证据
Patroni REST 可达 能参与管理与健康检查 exact endpoint/status
同 system identifier 属于同一物理 lineage pg_control_system()
WAL/timeline 可接受 不跳回旧分支 LSN、history、check_timeline
lag 在策略内 控制异步数据风险 pg_stat_replication 与 policy
nofailover=false 未被运维策略排除 Patroni tags
replay 未异常暂停 promotion 后能完成 recovery recovery/receiver state
required watchdog 可用 满足本节点 leader 前提 Patroni/watchdog status
同步模式条件满足 遵守 sync/quorum policy DCS sync state

“候选服务可连”没有覆盖这些条件。一个被刻意延迟 30 分钟的 reporting replica 可能非常 健康,却绝不能自动成为业务主库。

三种“多数”不要混为一谈

DCS quorum
  DCS 自己能否形成一致写 authority

PostgreSQL replicas
  有多少数据节点仍活着、各自有哪些 WAL

business commit quorum
  当前提交策略要求多少同步确认

三节点 PostgreSQL 加单节点 etcd,并不会因为“数据库还有 2/3”就拥有 DCS 多数; 三节点 etcd 加两节点 PostgreSQL,也不会自动使 PostgreSQL 写入成为多数派复制。DCS 负责协调 leader authority,WAL durability 由 PostgreSQL replication mode 决定。

生产设计需要分别画失败域:

etcd member placement       odd count, independent failure domains
PostgreSQL placement        required data/availability tolerance
sync policy                 commit latency and data-loss budget
client route                which authority is actually reachable
fence                       how an isolated incumbent loses write ability

异步可用性换取数据风险

默认异步模式允许 primary 在副本未确认时提交。故障后提升某个满足最低健康条件的 standby,未复制到它的事务会留在旧分支。maximum_lag_on_failover 限制候选在最近 观测时的 WAL gap,却不覆盖观测后到故障间的新 WAL。

所以报告应写:

policy lag threshold              1 MiB
observed replay gap at timestamp  exact bytes
last client acknowledgement       token
unknown outcome count             N
post-failover reconciliation      result

而不是只写 RPO < 1MB。WAL 字节不是业务订单数,unknown 也不等于丢失。

同步模式能收紧正常单故障的数据风险,但也有可用性和复合故障边界。切换报告仍应验证 业务 identity,不把配置名当作结果。

自动选择不等于随机选择

Patroni 按当前资格与状态参与 leader race;操作者不应从成员列表顺序推断赢家。本章 正式 run 的两个 replica 都合格,实际 pg-test-3 提升。正确验收是:

winner in eligible set
winner is unique running primary
other eligible replica remains streaming
old primary was fenced before acceptance

如果业务要求指定节点,使用 planned switchover 或明确 tags/拓扑策略,不能把自动选举 伪装成固定候选。

33.3.2 fencing 旧主与防止双写

fence 的目标是消除旧 authority

旧主围栏的验收谓词:

CanWrite(old)=false CanWrite(old) = false

或者在特殊设计中:

CanProduceAcceptedEffects(old)=false CanProduceAcceptedEffects(old) = false

第二种更难证明,因为必须覆盖所有客户端、job、CDC、消息和外部副作用。数据库 HA 通常优先选择第一种:让 PostgreSQL 停止、只读、失去存储或节点断电。

围栏层次

fence 能证明 主要盲点
PostgreSQL clean stop postmaster 不再写 I/O hang 时可能无法完成
Patroni demote/stop 角色管理与数据库按协议停止 控制进程自身可能失效
watchdog reset Patroni 未续喂时节点重启 设备/权限/超时配置必须真实可用
BMC/cloud power off 主机失去计算能力 控制面状态延迟、自动重启策略
storage detach/revoke 旧主失去可写数据 本地缓存、detach 完成语义
network isolation 阻断已枚举的路径 漏掉网络、job 或控制路径
proxy/backend removal 正常服务不再路由 不是数据库 fence

Patroni watchdog 是一层额外保护:若 mode 为 required 且不能启用 watchdog,节点拒绝 成为 leader;leader 正常运行时必须持续喂狗,否则 watchdog 在超时后触发 reset。它 不能只存在配置文件里,演练要验证设备、权限、超时和真实 reset。

Pigsty 的 patroni_watchdog_mode 支持 offautomaticrequired。本章沙箱为 off,所以正式 run 明确只声称 process fence,不声称硬件 watchdog。

顺序不变量

t0 fault action starts
t1 old primary fence independently verified
t2 eligible candidate becomes unique primary
t3 service health accepts new primary
t4 first new-timeline business write acknowledged

要求 $t_1 \le t_2$。如果无法观测真实 promotion 瞬间,至少在“接受候选为可服务” 之前完成 fence evidence;不要用事后日志倒推一个未经保护的时间窗不存在。

本章正式结果:

action -> process fence       1.817 s
action -> Patroni stable      4.536 s
fence before stable          true
old service active           false
old postmaster alive         false
old REST reachable           false

这是一台可控虚拟机上的 graceful systemd stop。主机断电、内核 hang 与存储 stall 需要不同 fence,不能复用这组时延。

双主之后不要急着“选数据多的”

若已经观察到两个 writable primary:

  1. 阻断外部写,记录每条路由与 writer;
  2. 保存两边 system identifier、timeline、LSN 与业务 identity;
  3. 取得至少一边的可信 fence;
  4. 由业务 authority 选择权威分支;
  5. 隔离提取另一分支独有的合法事实;
  6. 用逻辑对账合并,而不是直接让它重新加入;
  7. 从权威分支 rewind 被舍弃的一侧,或对其做全量重建。

pg_rewind 会舍弃 target 的分叉变化。未先抢救业务事实就 rewind,等于主动销毁可能 需要审计的数据。

33.3.3 自动化不确定时何时转人工

转人工的含义

转人工不是自动执行:

patronictl failover ... --force

它意味着把状态机停在安全边界,要求人补齐:

current accepted authority
old-primary fence mechanism and evidence
candidate identity and lineage
data-loss upper bound / unknown set
service route owner
rollback or rebuild path
production authorization

三种切换入口

入口 适用 leader/candidate 风险
automatic failover 已验证故障与预设策略内 由 Patroni/DCS 竞选 策略边界内自动
planned switchover 健康 leader 的维护切换 可显式指定 可预检、通常低风险
manual failover leader 不可用且自动路径不能完成 必须明确 candidate 可能放宽 lag/sync 条件并丢数据

Patroni REST 文档明确警告 manual failover 可能导致数据损失;无 leader 时,手工候选 可能不受自动 failover 的全部 lag/sync 检查。它是一项风险接受,不是“更强的修复命令”。

Pig 的入口:

pig pt list pg-test -o json
pig pt switchover --plan
pig pt failover --candidate <member> --plan
pig pt reinit <replica> --plan

--plan 只展示工具计划,不能替代 fence 与 SQL 证据。执行参数以现场 --help 为准; structured execution 通常还要求显式确认。

自动化停止线

不确定性 自动动作
old primary writable? unknown 禁止接受新主
candidate lineage unknown 禁止 promote
DCS split direction unknown 禁止删 key/重建 DCS
manual candidate lag unknown 禁止 force failover
client unknown outcomes unbounded 可以恢复服务,但不能宣称 RPO
backup/archive health unknown 可以先恢复 HA,必须保持生产 gate pending

自动化可以并行采集和收敛证据,但不能把超时本身当作安全事实。一个 60 秒 timeout 只能 证明“在观察位置没看到预期状态”,不能证明旧主已经断电。

人工决策记录

decision: accept-candidate | retain-incumbent | stop-writes | rebuild
decided_at: UTC
decision_owner: incident-commander
facts:
  - evidence-id
hypotheses:
  - statement-and-test
data_risk:
  last_ack: token
  unknown: manifest
  accepted_loss: explicit
fence:
  target: old-primary
  mechanism: exact
  independently_verified_by: role
stop_condition: exact
rollback: exact

没有这份记录的 --force,在复盘时无法区分有意识的风险接受与操作失误。


上一节:复制状态与时间线证据 · 返回本章目录 · 下一节:DCS 故障的安全处理 · 查看全书目录 · 查看索引中心

33.4 DCS 故障的安全处理

Patroni 依赖 DCS 保存 leader lock、动态配置与成员协调状态。某节点无法更新 leader lock, 可能是 DCS 整体故障,也可能是该节点落在网络分区的错误一侧。从单个节点看,两者很难 区分:

I cannot reach DCS
  != DCS has no quorum
  != nobody else can reach DCS
  != I am still the accepted primary

安全默认是按最坏分区处理:旧主不能续约 authority,就要在 lease 失效前停止写,避免 另一侧取得 lock 后出现双主。failsafe_mode 是一个有严格条件的例外,不是忽略 DCS。

33.4.1 DCS 不可达、失去多数与延迟

先区分四种现象

现象 可能原因 不能立即做
一个 Patroni 到 DCS 超时 节点网络、DNS、凭据、DCS 宣称 DCS 整体宕机
所有 Patroni 到 DCS 失败 DCS 失去 quorum、公共网络 直接 bootstrap 新 DCS
DCS 请求慢但可成功 负载、磁盘、网络、GC 只调大 TTL 掩盖
DCS 有 quorum,某分区不可见 不对称网络 在不可见一侧手工提升

调查矩阵:

each PostgreSQL member -> every configured DCS endpoint
each DCS member -> DCS peers
current primary -> every known Patroni REST endpoint
replicas -> current primary REST endpoint
observer/client -> current SQL service

同时记录 UTC、monotonic clock、请求耗时和 DCS revision。只记录一次 endpoint health=true 会漏掉尾延迟和间歇超时;只看平均值又会漏掉超过 retry_timeout/lease deadline 的长尾。

DCS 多数派属于 DCS

etcd 通常部署奇数成员。三成员容忍一成员故障,五成员容忍两成员故障;单成员没有 冗余。PostgreSQL 数据节点数量不会补足 etcd quorum。

Pigsty 生产设计应让 infra/DCS 与 PostgreSQL failure domain 相互审阅:

  • DCS 成员跨独立电源、主机或可用区;
  • latency 满足 lease 与运维目标;
  • client/peer 网络和证书可用;
  • 备份 DCS 配置与凭据恢复流程,但不把陈旧快照直接覆盖活集群;
  • 监控 leader changes、fsync、peer RTT、容量与 auth failure;
  • 演练成员故障和 quorum 丢失,不只测 systemctl status

本章沙箱只有一个 etcd,不能演示多数派。正式实验因此不停止 DCS,只用 blind decision scenario 验证 runbook。

延迟故障比“down”更隐蔽

DCS 还能响应,但延迟接近 Patroni deadline 时可能出现:

leader loop misses renewal window
members observe stale or alternating state
CLI intermittently fails
health check remains green for part of the interval
clock and log ordering become hard to compare

正确动作是保存 latency distribution、Patroni loop 时间和 lease revision,处理控制面 性能根因。盲目增大 ttl 会延长故障检测与服务恢复;盲目减小又会让正常长尾触发抖动。 参数变更必须作为容量与失败注入实验,而不是事故现场的猜测。

33.4.2 先保护当前数据库角色,不盲目重置选举状态

failsafe_mode 的严格语义

Patroni DCS failsafe mode 启用后,incumbent primary 在 DCS leader lock 更新因特定 连接类错误失败时,可以向 /failsafe全部已知成员发送 Patroni REST 请求。 只有全部成员确认它仍是当前 primary,它才可以继续作为 primary。

为什么不是多数副本确认?DCS quorum 的网络分区与 PostgreSQL 成员分布可能不同。如果 primary 只联系到某个“数据库多数”,DCS 可写分区里的少数 PostgreSQL 节点仍可能取得 leader lock。要求 ALL known members,才能让任何可能竞选的成员知道 incumbent 仍活着。

因此:

failsafe_mode = true
  does not mean "primary ignores DCS"
  does not mean "majority of replicas is enough"
  does not create WAL durability
  does not rescue an unknown /failsafe membership

若已知成员之一不可达,incumbent 应 demote;DCS 恢复前不会凭空得到新的 authority。

不要删除你还没理解的 key

危险动作:

delete leader key
delete /config or /failsafe
wipe DCS data directory
bootstrap a second independent DCS
restore a stale DCS snapshot over live quorum
pause/resume without recording current state
manually promote PostgreSQL outside Patroni

leader key 是协调事实,不是“卡住的锁文件”。删除它会触发新的 leader race,却不会 自动停止旧主;如果旧主仍写,删除 key 正好制造双主窗口。

先保护:

  1. 当前 SQL 角色和客户端写入口;
  2. leader lock、revision、member 与 failsafe 内容;
  3. 每个 Patroni 的 REST 角色与可达性;
  4. DCS member/quorum 与 auth 状态;
  5. 旧主 fence 能力;
  6. WAL/archive/backup 现场。

需要禁止写时,围住精确业务入口或数据库 authority,不要用破坏 DCS 历史的方式达到 “看起来没主库”。

六个决策场景

本章 failure-model.json 固定六种:

场景 默认决策
所有 Patroni 失去 DCS、彼此 REST 全可达 观察 failsafe incumbent,不发起新竞选
仅 primary 失去 DCS、可达全部成员 验证 failsafe handshake,replica 不竞选
primary 失去 DCS 且看不到一名已知成员 old primary 必须 demote/fence 后才接受候选
仅 replica 失去 DCS 排除该 replica,保持当前 primary
DCS latency 接近 timeout 控制面事故,停止人工 promotion,保存时间证据
代理故障伪装成数据库故障 修服务路径,不切数据库

正式 run 随机抽到“primary 同时失去 DCS 和一个 replica”。正确答案不是立即 promote, 而是先证明旧主只读/停止或由外部 fence 隔离,再确认幸存侧的 DCS authority 与候选 WAL。本次只做决策演练,没有真实注入分区。

33.4.3 恢复控制面后核对 leader lock 与数据库事实

恢复顺序

1 restore DCS quorum and stable latency
2 keep application writes fenced if authority is ambiguous
3 read leader lock, config, sync/failsafe and member records
4 query Patroni REST on every PostgreSQL member
5 query pg_is_in_recovery and lineage on every reachable database
6 resolve contradictions and fence losers
7 allow one authority to remain/become primary
8 restore replicas, service routing and client traffic
9 verify monitoring, archive and backup

不要在第 1 步完成后直接开放流量。DCS 恢复只说明控制面重新可读写,数据库可能已经有 两个分支或有成员保留陈旧角色。

四层互证表

唯一主库应看到 异常例子
DCS one valid leader lock no lock / stale identity
Patroni REST one primary with lock, replicas elsewhere two primaries / unknown
SQL accepted node recovery=false,followers=true DCS leader SQL 仍 recovery
service write health only points accepted primary old backend remains healthy

如果 DCS 指向 A、SQL 却显示 A 在 recovery、B 可写,不要为了让表格一致而手改 key。 先停止流量,保存两边日志、control data 和 timeline,查明谁何时改变角色。

推荐证据命令

pig pt list pg-test -o json
pig pt config show -o json

curl --fail --silent http://<member>:8008/patroni

SQL:

SELECT
    pg_is_in_recovery(),
    (pg_control_system()).system_identifier,
    (pg_control_checkpoint()).timeline_id,
    pg_last_wal_receive_lsn(),
    pg_last_wal_replay_lsn();

在 primary 上另查 pg_stat_replication;在 replica 上查 pg_stat_wal_receiver。所有 输出带采集位置、UTC、monotonic sequence 与 hash。REST/DCS 可能含敏感认证信息,证据 包只保留必要 projection,不导出密码、token 或完整配置。

DCS 恢复完成标准

dcs:
  quorum: healthy
  latency_budget: passed
  leader_lock: exact-member-and-revision
  config_hash: expected
  failsafe_members: reconciled
database:
  accepted_primary: exact-member
  old_primary_fenced_or_replica: true
  system_identifier_relation: one
  timeline_history: admissible
service:
  write_backend: accepted-primary-only
  stale_connections_reconciled: true
replication:
  all_expected_members_streaming: true
  slots_and_archive: healthy
production_gate: approved-by-owner | pending

production_gate=pending 时可以继续修复与验证,但不能把技术恢复自动升级成业务批准。


上一节:自动故障转移的保护条件 · 返回本章目录 · 下一节:旧主重加入与集群重建 · 查看全书目录 · 查看索引中心

33.5 旧主重加入与集群重建

新主稳定后,旧主不能直接“启动看看”。它可能仍停在共同祖先,也可能已经在旧 timeline 产生分叉写。归队的目标不是让进程起来,而是构造一个只读 follower

same system identifier
same accepted history
standby.signal / recovery configuration correct
receiver streaming from accepted primary
replay reaches verification point
old divergent facts absent
no stale client route or external side effect

若旧主在 promotion 前已经干净停止、没有产生分叉,它可能直接沿 timeline history 继续 recovery;若已经分叉,则要 rewind 或全量重建。

33.5.1 pg_rewind 的前提、失败与验证

pg_rewind 做什么

pg_rewind 将 target PGDATA 同步到 source 所在的权威分支。它根据 timeline history 找到共同祖先,从 target 读取分叉之后发生变化的数据块,并从 source 复制所需页面; 新文件、配置文件和 WAL 等按文件复制。它通常比全量 base backup 少复制很多数据。

典型关系:

target old primary: F -> A1 -> A2
source new primary: F -> B1 -> B2

pg_rewind(target=A, source=B)
  -> discard A after F
  -> copy B-required changes
  -> configure A to recover/follow B

它不会把 A1/A2B1/B2 做业务 merge。target 分叉事实若需要保留,必须在 rewind 前从隔离实例提取。

前提清单

前提 原因 验证
同 system identifier 必须来自同一 cluster ancestry pg_controldata / control function
timeline 有共同祖先 才能确定分叉点 history files
target 已停止 文件不能继续变化 service/PID/lock evidence
target 启用 checksums 或 wal_log_hints 识别修改页面所需 init/control/config
full_page_writes=on rewind 安全前提 source/target config evidence
source 一致且可信 source 是要保留的权威历史 authority decision
所需 WAL 可取得 target 启动后要从共同 checkpoint replay source/archive coverage
target 可写、空间充足 rewind 会修改整个目录 filesystem preflight
recovery config 正确 启动后必须跟随 source -R 与配置复核

PostgreSQL 18 默认 initdb 启用 data checksums,但不能把“默认”当现场事实。老集群、升级 集群或定制 initdb 可能不同。

source 与 target 方向不能写反

pg_rewind \
  --target-pgdata=/path/to/old-primary \
  --source-server='host=<new-primary> dbname=postgres user=<rewind-role>' \
  --write-recovery-conf \
  --progress

target 会被改写,source 被读取。生产命令应从 inventory/incident record 生成,并在执行 前打印 secret-free plan:

target member and PGDATA
source member and system identifier
target/source timeline
common ancestor
target clean-stop evidence
required WAL source
recovery destination
expected post-state
rollback = fresh rebuild, not "undo rewind"

不要把带密码的连接串写入工单或证据;使用受控 service/password file 或短期凭据。

clean shutdown 与失败语义

pg_rewind 要求 target cleanly shut down。默认情况下,若 target 非干净停止,工具会 尝试单用户模式完成 crash recovery;--no-ensure-shutdown 可让它直接报错。生产流程 更适合先显式处理 crash recovery、保留日志与控制信息,再决定是否允许工具自动动作。

更重要的是:PostgreSQL 官方文档警告,rewind 中途失败后 target 很可能不再处于可恢复 状态,推荐取得 fresh backup。不要:

retry start target as primary
reverse source/target and "rewind back"
rsync a few reported files
delete control/history files to force startup

保留失败日志、目录 manifest 与 source 身份,然后把 target 视为待重建。

配置与 WAL 复核

rewind 会从 source 复制配置文件,target 的本机差异可能被覆盖:

  • port、socket、listen address;
  • SSL key/certificate symlink;
  • tablespace path;
  • primary_conninfo 与 slot;
  • archive/restore command;
  • include 文件和本机路径。

使用 -R/--write-recovery-conf 会创建 standby.signal 并写 recovery connection,但仍要 检查目标端本机覆盖。缺失从共同 checkpoint 到 source 当前状态的 WAL 时,target 启动 仍会失败;应确保 source pg_wal、archive 或 --restore-target-wal 路径满足需求。

验收不是“pg_rewind done”

SELECT
    pg_is_in_recovery(),
    (pg_control_system()).system_identifier,
    (pg_control_checkpoint()).timeline_id,
    pg_last_wal_receive_lsn(),
    pg_last_wal_replay_lsn();

SELECT
    status,
    sender_host,
    sender_port,
    written_lsn,
    flushed_lsn,
    latest_end_lsn
FROM pg_stat_wal_receiver;

还要验证:

accepted new-primary marker present
known old-primary divergent marker absent
receiver status = streaming
replay reaches post-rewind verification LSN
Patroni registers member as replica, not primary
client write health does not route to it

本章一次性实验的 A 在 rewind 后只看到 basenew-primaryafter-divergence, 看不到 old-primary-divergent,并以 streaming standby 启动。

33.5.2 从备份或新基础备份重建

什么时候跳过 rewind

任一项成立时优先全量重建:

  • system identifier 或共同祖先不可信;
  • checksums/wal_log_hints 前提不满足;
  • 所需 WAL 缺失且无法恢复;
  • target 存储疑似损坏;
  • rewind 中途失败;
  • target 文件权限、tablespace 或 symlink 状态复杂且不可验证;
  • 数据规模不大,全量路径更简单、更可预测;
  • 合规要求使用已验证 backup lineage。

优化目标不是复制字节最少,而是总风险最低:

$$ Cost = copy_time

  • uncertainty
  • validation
  • rollback_risk
  • operator_complexity $$

fresh base backup

原生流程示意:

pg_basebackup \
  --host=<accepted-primary> \
  --username=<replication-role> \
  --pgdata=<empty-authorized-target> \
  --wal-method=stream \
  --write-recovery-conf \
  --checkpoint=fast

必须确认 target 是精确授权的空目录。不要对变量为空、符号链接或宽泛 glob 执行删除; managed member 的数据目录应由 Patroni/Pigsty reinit 流程管理。

Pig/Patroni 路径:

pig pt reinit <replica> --plan
pig pt reinit <replica> --wait

当前 Pig 帮助明确警告:reinit 会删除目标成员数据并从 leader 重建。它是破坏性动作, 必须核对:

target is replica, never current leader
target member identity and PGDATA
accepted source leader
backup/basebackup method and bandwidth
tablespaces and encryption keys
WAL retention during copy
failure cleanup
post-rebuild validation

本章没有执行 managed reinit,因为“编写书籍”并不等于授权删除托管副本。实验用 exact 临时目录 C 真实运行 pg_basebackup -R,证明机制后立即停止并删除。

从已有 backup 重建

大集群从对象存储/pgBackRest backup restore,可能比从 primary 传全量 base backup 更 少占生产网络,并提供明确 lineage。选择时比较:

来源 优点 代价
current primary basebackup 最新、路径直接 消耗 primary I/O/网络,长 copy 要保 WAL
replica basebackup 减少 primary 压力 source 必须合格且允许
pgBackRest backup + archive 可复用已验证备份 需要 restore + WAL catch-up
volume snapshot 一致性、加密、跨主机与 lineage 要证明

无论来源,最终都必须 streaming 到 accepted primary,并验证业务 marker,而不只看目录 复制完成。

估算恢复窗口

粗略下界:

$$ T_{\text{rebuild}} \ge \frac{bytes\ to\ transfer}{effective\ throughput}

  • WAL\ catchup
  • validation $$

若 copy 期间 primary 持续产生 WAL:

WALretainedwrite_rate×Tcopy+safety margin WAL_{\text{retained}} \ge write\_rate \times T_{\text{copy}} + safety\ margin

还要计算 replication slot 导致的磁盘增长,避免“为了重建副本”把 primary pg_wal 撑满。

33.5.3 复制槽、端点和客户端状态清理

复制槽不是自动清理垃圾

成员失联期间,physical slot 可能继续保留 WAL。重建前后检查:

SELECT
    slot_name,
    slot_type,
    active,
    active_pid,
    restart_lsn,
    wal_status,
    safe_wal_size,
    invalidation_reason
FROM pg_replication_slots
ORDER BY slot_name;

不要仅因 slot active=false 就删除。它可能正为待恢复成员、logical subscriber 或 备份流程保留 WAL。先映射:

slot -> owner/member/subscriber
required restart LSN
retained bytes
rebuild plan
drop authorization

Patroni 管理 permanent/member slots 时,还要核对 DCS 成员与 slot policy,避免手工 删除后被重建或导致 WAL gap。

服务端点要清掉陈旧状态

重建成功后:

  • Patroni REST role 为 replica;
  • HAProxy write health 不接受它;
  • read service 是否允许加入由 lag/业务策略决定;
  • PgBouncer server connection 已重建;
  • VIP/DNS owner 与 TTL 正确;
  • direct-IP 配置与运维脚本没有指向旧角色;
  • application target_session_attrs=read-write 等约束生效。

“节点回到集群”与“可以承载读流量”不是同一门。刚重建副本可能仍在 catch-up、缓存 全冷、统计未热、备份未覆盖。

客户端 unknown outcome

故障窗口内:

acknowledged  客户端收到成功;必须在新主存在一次
rejected      明确未提交;可按协议重试
unknown       连接中断,提交结果未知;必须查询幂等身份

对账:

SELECT token, count(*)
FROM app.idempotency_record
WHERE token = ANY (:unknown_tokens)
GROUP BY token;

每个 unknown 应归类为 absent 或 committed once。没有 idempotency identity 时,无法用 数据库技术准确判断“同一业务动作是否可重试”,必须升级业务 owner。

本章实验:

attempts                     160
acknowledged                 130
unknown                       30
acknowledged missing           0
duplicates                     0
unreconciled unknown           0
persisted rows               130

30 个 unknown 最终均 absent;如果其中有 committed once,也仍可正确归类。验证器关心 “全部可对账”,不要求网络故障时 unknown 必须为零。

重建完成清单

member:
  patroni_role: replica
  sql_recovery: true
  system_identifier: matches
  timeline_history: accepted
  receiver_status: streaming
  replay_at_verification_lsn: true
data:
  accepted_markers_present: true
  divergent_markers_absent: true
service:
  write_route: excluded
  read_route: policy-dependent
client:
  unknown_outcomes_unreconciled: 0
operations:
  slots: reconciled
  archive: healthy
  monitoring: healthy
  backup: scheduled-and-tested

上一节:DCS 故障的安全处理 · 返回本章目录 · 下一节:切换与重建 runbook · 查看全书目录 · 查看索引中心

33.6 切换与重建 runbook

runbook 不是命令收藏。它把每个动作绑定到触发条件、证据、风险、停止线、成功谓词与 复位路径。切换场景尤其要防止“命令执行成功”替代“唯一 authority 已建立”。

建议每一步都使用:

step: stable-id
intent: why
preconditions: [...]
command_or_action: exact
risk_class: R0 | R1 | R2 | R3
expected: [...]
evidence: [...]
stop_if: [...]
rollback_or_next_safe_state: [...]
owner: role

33.6.1 计划切换、故障切换与人工干预入口

先选路径

healthy leader + maintenance need
  -> planned switchover

leader process demonstrably failed/fenced
  + automatic policy healthy
  -> observe automatic failover

no accepted leader
  + automatic path cannot complete
  + exact candidate/fence/data risk approved
  -> manual failover

replica broken, leader healthy
  -> restart/rewind/reinit replica; do not fail over

第 20 章已经完整演练 healthy planned switchover;本章正式 run 是 controlled process fault 后的 automatic failover,最后才用 planned switchover 复原教学基线。

R0:只读预检

pig pt list pg-test -o json
pig pt config show -o json

加上 SQL:

SELECT pg_is_in_recovery(),
       (pg_control_system()).system_identifier,
       (pg_control_checkpoint()).timeline_id;

检查:

one primary / expected replicas
pause=false
member tags
ttl / loop_wait / retry_timeout
maximum_lag_on_failover
synchronous and failsafe modes
watchdog mode
sender/receiver and replay gap
DCS member/quorum
service path and client probe

R0 也要创建 evidence timestamp,不能把几分钟前的健康状态当动作瞬间事实。

R1/R2:计划切换

pig pt switchover --plan
pig pt switchover \
  --leader <current> \
  --candidate <target>

版本选项以 pig pt switchover --help 为准。执行前:

  • current leader 与 candidate 都来自 fresh structured state;
  • candidate replay 在窗口内;
  • 长事务、DDL、备份和批任务已评估;
  • client probe 已开始;
  • backout candidate 可用;
  • 路由与 connection drain owner 在线。

执行后不能立刻退出:

new timeline
old leader streaming
client writes on new authority
unknown outcome reconciled
archive follows new primary
scheduled jobs no duplicate

R2/R3:故障与手工切换

自动 failover 通常不需要操作者再发一条 failover 命令;重点是确认 fence、候选和服务 收敛。manual path:

pig pt failover --candidate <exact-member> --plan

计划应明确警告 leader 不可用时可能丢失未复制事务。执行前需要独立 reviewer:

old-primary fence
candidate lineage and lag
last ack / unknown manifest
accepted data loss
DCS authority
business owner and incident commander authorization

不要用 manual failover 修复 proxy、replica 或 DCS 延迟问题。

重建入口

pig pt reinit <replica> --plan

reinit 会删除目标 replica 的 PGDATA,属于 R3。它只应在:

  • target 已确认是 replica;
  • target 数据无需再取证;
  • accepted primary/source 明确;
  • rewind 不适用或已失败;
  • 带宽、WAL retention、tablespace 和密钥已准备;
  • post-validation 与 failure cleanup 已写好;
  • 两人复核具体 member。

本章 runner 永不执行 managed reinit。

33.6.2 Patroni、DCS、代理和 SQL 证据互证

证据矩阵

问题 Patroni DCS SQL 服务/客户端
谁是 primary REST role leader lock recovery=false write health
旧主是否排除 member state lease/failsafe unavailable/recovery no accepted route
候选是否最新 timeline/lag member/sync state LSN/receiver token boundary
切换是否完成 one leader lock updated followers streaming writes succeed
数据是否丢失 不直接回答 不直接回答 token/ledger query client ack manifest

任一行中出现冲突,都应保存而不是“修正输出”。例如:

DCS leader = A
Patroni REST A = replica
SQL A recovery = true
SQL B recovery = false

这不是运行一个 edit-config 的理由,而是 authority contradiction。先停止写、查 timeline 与动作日志。

采集顺序避免自我污染

capture before
start client probe
record action monotonic timestamp
perform one bounded action
capture fence and transition
capture after
reconcile client identities
only then perform cleanup/baseline restore
capture restored

如果先 restart/reinit 再取证,就会丢失原 PID、日志、control data 和 timeline 现场。 如果先删除 fixture 再对账,就无法证明 unknown 是否提交。

Secret-free projection

完整 Patroni、DCS 与 inventory 配置含密码、token、证书路径。证据只投影:

scope / member
DCS kind and endpoint count
config booleans and numeric policy
credential present + hash/length where necessary
REST role/state/timeline
raw log hash + selected event counts

本章 journal evidence 只保留行数、整体 SHA-256 和 pattern counts,明确 raw_log_exported=false。需要审计原日志时,在受控现场保存,不把它发布到教材。

monotonic 与 UTC 双时钟

UTC 用于跨主机关联,monotonic 用于本机阶段时长:

started_at UTC
started_monotonic_ns
finished_at UTC
finished_monotonic_ns

NTP 校时可能让 wall clock 跳变,不能用两个 UTC 字符串相减替代 monotonic duration。 跨主机 monotonic 不能直接比较,因此 fence/promotion 关键顺序最好由同一 observer 记录,另用多主机 UTC/clock offset 辅助。

证据 bundle

requirements + failure model
before phase
fault action
old-primary fence
failed phase + selected candidate
rejoin phase
client event stream + reconciliation
baseline restore action + restored phase
DCS tabletop decision
rewind/basebackup evidence
cleanup
source hashes
positive validation + adversarial mutations
public summary

公开摘要 failover-run.json 不含 inventory、密码、原始日志或 临时 service 文件。

33.6.3 集群恢复后重新建立监控与备份健康

HA 恢复不是事故关闭

新主可写后,至少检查:

database authority
replication and slots
client route and unknown outcomes
WAL archive
backup schedule and repository
monitoring/alerts
jobs, CDC, logical replication
capacity and cache
security/audit

切换会改变产生 WAL、执行 cron/job、归档、备份和 logical publisher 的节点。只验证 SELECT 1 会漏掉一半恢复工作。

复制与 slot

SELECT application_name, state, sync_state, replay_lsn
FROM pg_stat_replication;

SELECT slot_name, active, restart_lsn, wal_status,
       safe_wal_size, invalidation_reason
FROM pg_replication_slots;

验收:

  • 所有预期 replica streaming;
  • 无未知 sender;
  • slot owner 与 member/subscriber 对应;
  • pg_wal retained bytes 有界;
  • rebuild member replay 到验证 LSN;
  • logical replication origin/subscription 状态正确。

archive 与 backup

pg_stat_archiver last success/error
pgBackRest stanza status/check
new-primary archive command and credentials
latest backup lineage/timeline
repository capacity and retention
next scheduled backup owner

更稳妥的事故关闭门是:在新 topology 上完成一次新的可恢复点,并在计划窗口验证 side restore;至少不能让 backup/archiving warning 被“主库已恢复”盖掉。

服务与应用

  • HAProxy 只接受当前 write endpoint;
  • PgBouncer 不保留错误 server connection;
  • stale DNS/VIP 已收敛;
  • application pool 重建;
  • old timeline transaction 全部断开或明确结束;
  • unknown idempotency token 对账完成;
  • scheduler 单实例语义恢复;
  • outbox/CDC offset 没有重复或空洞;
  • cache/search 等派生系统按权威数据库重建。

监控重新设基线

切换会让:

timeline increase
backend PID reset
cumulative stats reset/restart
cache hit ratio 暂时下降
replica lag spike
connection errors spike
checkpoint/archive timing change

这些不是都应静默。给事故窗口加 annotation,保留告警作为证据,再针对已解释的瞬态 调整状态;不要批量关闭告警后忘记恢复。

关闭条件

authority:
  unique_primary: true
  old_primary: streaming_or_decommissioned
data:
  acknowledged_missing: 0
  unknown_unreconciled: 0
  accepted_loss: documented
replication:
  expected_members_streaming: true
  slots_reconciled: true
service:
  write_route_valid: true
  stale_connections_drained: true
recoverability:
  archive_healthy: true
  backup_healthy: true
  restore_followup_scheduled: true
operations:
  alerts_restored: true
  jobs_and_cdc_validated: true
  evidence_bundle_sealed: true
business:
  owner_acceptance: approved | pending

技术项全绿但 owner_acceptance=pending 时,事故仍不能被描述成“业务已完全恢复”。


上一节:旧主重加入与集群重建 · 返回本章目录 · 下一节:实战:主库故障与 DCS 干扰 · 查看全书目录 · 查看索引中心

33.7 实战:主库故障与 DCS 干扰

本节把前六节变成三条可重复、但风险边界不同的证据链:

managed online drill
  controlled Patroni service stop
  -> process fence
  -> automatic candidate selection
  -> client token reconciliation
  -> old member rejoin
  -> planned baseline restore

offline decision drill
  random DCS/network symptom packet
  -> evidence request
  -> stop line
  -> no live DCS/network mutation

disposable PostgreSQL lab
  real timeline divergence
  -> pg_rewind
  -> fresh pg_basebackup
  -> marker/streaming validation
  -> exact cleanup

小节标题中的“随机注入主机、网络或 DCS 症状”指从 blind scenario library 随机抽取 症状;正式 online mutation 只有可自动复位的进程 fence。单 etcd、watchdog off 的共享 沙箱不具备安全、真实地证明不对称网络分区和 DCS quorum 的条件。

33.7.1 随机注入主机、网络或 DCS 症状

先读合同

静态检查不连接远端:

static/labs/ch33/task.sh lint

它检查合同、failure model、负例集合与 15 个 hash-bound source files。只读现场快照:

export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch33-capture.XXXXXX"
)"
static/labs/ch33/task.sh capture

capture 投影:

three Patroni members
role / state / timeline / lag / tags
dynamic ttl / loop_wait / retry_timeout
pause / synchronous / failsafe / rewind / slot policy
per-node service / postmaster / REST
per-node system identifier / recovery / LSN
sender / receiver state

它不读取 inventory,不修改数据库、DCS、服务或路由。

完整演练 guard

export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch33.XXXXXX"
)"
export PG36_CH33_INVENTORY=/absolute/private/inventory.yml
export PG36_CH33_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH33_NONPRODUCTION=true
export PG36_CH33_PRODUCTION_DATA=false
export PG36_CH33_PRODUCTION_TRAFFIC=false
export PG36_CH33_CONFIRM=FENCE_FAILOVER_REJOIN_REBUILD_CH33

static/labs/ch33/task.sh drill:failover

inventory 必须为 mode 0600。private_client_service.py 从中只提取 fixture 用户密码, 生成一次性 mode-0600 libpq service file;密码不会进入 evidence,退出时 exact private directory 被删除。

任一 guard 不符,runner 在 mutation 前以 77 退出。evidence directory 已非空也拒绝, 避免把两次 run 混成一份报告。

online fault 为什么选 service stop

runner 运行:

systemctl stop patroni on pg-test-1

这是明确、可复位的 L2 动作。它不是:

kill -9 Patroni while postmaster may remain writable
power off a host
freeze storage
iptables asymmetric partition
stop the single etcd
delete a DCS key

systemctl stop 返回后还不算 fence。runner 单独检查:

service_active=false
postmaster_alive=false
patroni_rest_reachable=false

只有 fence 成立,后续候选才可被接受。

随机 DCS scenario

同一 run 用系统随机源从六个 scenario 选一个。本次抽到:

primary-isolated-from-dcs-and-one-replica

blind observation:

incumbent primary 失去 DCS,同时不能联系 failsafe set 中一名 replica。

正确决策:

require demotion or external fence of old primary
then establish DCS authority on surviving side
then validate candidate WAL/timeline
only then accept promotion

evidence 保存 decision_only=truelive_dcs_fault_injected=falselive_network_partition_injected=falseleader_key_deleted=false。教材不把桌面推理冒充在线故障结果。

33.7.2 保护旧主、选择候选、测量 RTO/RPO

fixture 与客户端

runner 在 test.pg36_ch33 创建 exact marker schema:

run_marker(run_id, external_dispatch_enabled=false)
write_probe(
    run_id,
    attempt_no,
    token UNIQUE,
    client_sent_at,
    committed_at
)

客户端每 200 ms:

token = run_id + attempt_no
INSERT ... RETURNING commit time, LSN, timeline
autocommit
target_session_attrs=read-write

网络/连接异常记录 outcome=unknown,不把错误字符串或密码写入 evidence。探针在 fault 前必须已有 acknowledgement,且在 topology stable 后还要有新 timeline acknowledgement, 否则不能测量切换窗口。

候选在运行时产生

preflight:

pg-test-1  primary  running    timeline 17
pg-test-2  replica  streaming  timeline 17
pg-test-3  replica  streaming  timeline 17
pause=false
maximum_lag_on_failover=1 MiB
synchronous_mode=false
failsafe_mode=true
use_pg_rewind/use_slots=true

合同只声明 eligible set:

{pg-test-2, pg-test-3}

没有指定 expected winner。正式结果:

selected candidate  pg-test-3
failed topology     pg-test-3 primary, pg-test-2 streaming
old pg-test-1       process-fenced
timeline            17 -> 18

开发 runner 的早期版本曾硬编码 pg-test-2,真实运行立即暴露了错误。本章把这次失败 转成负例 evidence.failed.leader=...:validator 必须接受任一 eligible winner,并拒绝 把未观察候选写成事实。

时序

正式公开证据 failover-run.json

阶段 观测
systemctl stop patroni 1.582 s
action start → process fence 1.817 s
action start → Patroni topology stable 4.536 s
old primary start → streaming 2.527 s
planned switchover back 2.832 s
maximum client acknowledgement gap 6.212 s

为什么 client gap 大于 control-plane stable:

200 ms sampling resolution
connection failure detection
PgBouncer/HAProxy health convergence
new connection establishment
PostgreSQL promotion/recovery
client retry schedule

因此 4.536 秒是 observer 看到 Patroni stable 的控制面时间;6.212 秒是 synthetic client 连续两个成功 acknowledgement 的端到端缺口。二者都不是完整业务 RTO,后者仍未包含 事故发现、人工确认和所有应用恢复。

timeline 归属而不是伪造 IP

服务从 HAProxy/PgBouncer 通过 Unix socket 回源,PostgreSQL 的 inet_server_addr() 返回 NULL。runner 没有把 NULL 强行替换成配置 IP,而是:

client returned timeline
+ same observer's Patroni phase
= backend authority attribution

结果:

old timeline acknowledged   18
new timeline acknowledged  112

这套归属只在切换窗口 timeline 唯一前进、baseline restore 在 probe 结束后才执行的合同 内成立。若窗口内多次切换,应再加入 server identity 或 commit audit。

unknown outcome 对账

attempts                       160
acknowledged                   130
unknown                         30
persisted rows                 130
acknowledged missing             0
duplicate token                  0
unreconciled unknown              0

对每个 unknown,runner 按 (run_id, attempt_no, token) 查询新主,得到 committed once 或 absent。本次 30 个都 absent。若某个 committed once,它也不是失败;只有无法分类 才是 unresolved。

怎样写 RPO

错误写法:

RPO = 0

本次正确写法:

在 160 次 synthetic idempotent INSERT、异步 pg-test 和该服务路径条件下,130 个 客户端已确认 token 全部在新历史中存在一次;30 个 unknown 全部对账,已知 fixture 数据损失为 0。没有证明任意生产事务或复合故障下的零 RPO。

若应用没有 idempotency key/ledger,客户端 acknowledgement 与数据库 WAL 位置之间就 缺少可对账身份,RPO 只能保持 unknown。

旧主归队与基线恢复

runner 启动 pg-test-1 后要求:

pg-test-3 primary running
pg-test-1 replica streaming
pg-test-2 replica streaming
pg-test-1 pg_is_in_recovery()=true

然后才执行 planned switchover:

leader     pg-test-3
candidate  pg-test-1
final      pg-test-1 primary, two streaming replicas
timeline   18 -> 19

若任一阶段失败,recovery handler 先启动由本 run 停止的服务,再读取实际唯一 leader; 只在三成员健康后从该 runtime leader 切回 pg-test-1。它不假设 winner 必为某节点。

33.7.3 重建旧主并验证时间线、端点和业务写入

为什么另做 disposable lab

managed 旧主在 controlled stop 后没有分叉,Patroni 可以直接让它跟随新 timeline;这 不能证明 pg_rewind。真实 managed reinit 又会删除副本 PGDATA,超出本章授权。

所以 runner 在 pg-test-3 创建:

/tmp/pg36-ch33-rebuild-<exact-run-id>/
  A/
  B/
  C/
  sock-A/
  sock-B/
  sock-C/
  .pg36-ch33-owned

全部 listen_addresses=''、Unix socket mode 0700,不加入 Patroni/DCS/代理/备份。

分叉状态机

initdb A --data-checksums
create base marker
pg_basebackup -R A -> B
start B streaming

stop A
promote B
write new-primary
stop B

start A alone
write old-primary-divergent
stop A

restart B alone
write after-divergence
pg_rewind target=A source=B -R
start A streaming from B

pg_basebackup -R B -> C
start C streaming from B

每次开始另一分叉写入前先停止当前 primary,因此 concurrent_divergent_primaries=false。这避免为了演示 rewind 真制造并发双主。

rewind 验收

PostgreSQL                       18.6
same system identifier          true
timeline diverged               true
pg_rewind                       245.343 ms
A pg_is_in_recovery             true
A receiver_status               streaming
A markers:
  base                          present
  new-primary                   present
  after-divergence              present
  old-primary-divergent         absent

pg_rewind 快,是因为临时数据极小且本地缓存/磁盘路径很短。它不代表 TB 级集群 rewind 时间;生产还受修改页面比例、WAL/archive、tablespace、同步与存储影响。

full rebuild 验收

fresh pg_basebackup             228.115 ms
C pg_is_in_recovery             true
C receiver_status              streaming
C accepted markers             all present
temporary instances after      none
exact root after               absent
managed PGDATA touched         false

两个时延不可拿来比较“rewind 一定比 basebackup 慢/快”:样本极小,basebackup 初始源、 checkpoint 与缓存条件不同。实验要证明的是两条机制都能构造正确 follower,以及失败时 有明确 fallback。

33 个反例

validate.py 对真实 evidence 逐个变异:

production data/traffic permission opened
managed reinit or DCS/network mutation enabled
watchdog claim opened in watchdog-off sandbox
DCS member count or synchronous mode 被伪造
failure domain / scenario / fence invariant missing
preflight two primaries / pause / split system id / excessive lag
wrong fault host / no service stop
service still active / postmaster still alive
wrong selected leader / old primary writable / timeline unchanged
old primary did not rejoin
ack missing / duplicate / unresolved unknown
rewind system id split / divergent marker remains
basebackup not streaming
temporary root remains
production gate approved

正式结果:

declared counterexamples rejected  33
live evidence mutants rejected     33
source files hash-bound            15
secret scan                        passed
production_ch33_gate               pending

这不证明代码没有 bug,但能防止“只检查工具 exit 0”“清理失败仍通过”“沙箱结果冒充生产” 等结构性错误。

复核现有 bundle

export PG36_EVIDENCE_DIR=/absolute/private/ch33-evidence
static/labs/ch33/task.sh verify
static/labs/ch33/task.sh review

# 或一次完成,不产生在线 mutation
static/labs/ch33/task.sh all

all 只重建 validation/public summary 并扫描 evidence,不停止服务、不连接 secret inventory、不触发 failover/rebuild。

最终边界

正式证据能够支持:

  • controlled process fence 先于候选接受;
  • Patroni 从 eligible set 自动选择唯一新主;
  • endpoint 上的 synthetic token 可完整对账;
  • 旧主以 streaming replica 归队;
  • planned switchover 恢复教学基线;
  • 同源分叉可用 pg_rewind 收敛;
  • rewind 不适用时 fresh base backup 能构造 follower;
  • exact fixture/root 被清理。

不能支持:

  • 主机断电、内核 hang、存储损坏的等价性;
  • 真实不对称网络分区下没有脑裂;
  • 单 etcd 代表生产 DCS quorum;
  • watchdog fencing 已验证;
  • 异步复制的生产零 RPO;
  • managed reinit 的工时与存储集成;
  • 6.212 秒是生产 RTO SLO。

因此公开证据的最终决策始终是:

controlled failover/rejoin/rebuild mechanism demonstrated
production approval = null
production_ch33_gate = pending

上一节:切换与重建 runbook · 返回本章目录 · 下一章:过载保护与资源故障判型——李代桃僵 · 查看全书目录 · 查看索引中心

34 过载保护与资源故障判型——李代桃僵

数据库“慢、满、连不上”时,最危险的动作往往不是没有动作,而是把正确手段用在了 错误根因上:

连接风暴  -> 扩大 max_connections  -> 内存与调度更快耗尽
WAL 撑盘 -> 取消慢查询              -> restart_lsn 一字节也不前进
XID 保留 -> 清理普通表空间          -> 冻结边界仍被旧 xmin 钉住
I/O 排队 -> 同时重启所有组件        -> 证据消失,恢复负载叠加

本章把资源事故分成两条首先必须分开的路径:

flow pressure
  新工作到达得比系统完成得快
  -> 排队、拒绝、超时、重试放大
  -> 目标是减少进入量、并发量或单项成本

retention pressure
  某个仍被声明为“需要”的历史边界不能前进
  -> WAL、旧版本或事务状态不能回收
  -> 目标是识别 owner、保护证据、修复消费者或恢复链

二者可以同时发生,也可能都不是。如果证据不足,正确路线不是猜一个,而是 STOP_AND_INVESTIGATE:停止破坏性清理、冻结新增变量、保留 SQL 与主机证据,并明确 尚未回答的问题。

学习完成标准

完成本章后,读者应能:

  1. 把“CPU 高、磁盘满、延迟高、连接失败”视为症状,而不是根因;
  2. 区分流量型竞争与 WAL/XID/slot/归档/长事务造成的保留型压力;
  3. 解释到达率、服务率、并发、队列和超时为什么会形成正反馈;
  4. 为应用池、PgBouncer、HAProxy 与 PostgreSQL 分配一致的连接预算;
  5. 识别健康检查、短连接和无抖动重试造成的隐藏放大;
  6. pg_stat_activity、wait event、阻塞树和执行计划识别失控工作;
  7. 区分 pg_cancel_backendpg_terminate_backend 的影响和权限边界;
  8. 在结束长事务或大事务前评估锁释放、中止清理、既有 WAL、死版本与后续 vacuum 成本;
  9. 用并发最坏值估算 work_mem、并行 worker 与连接数的内存风险;
  10. 将 PostgreSQL 的 I/O 证据与主机设备延迟、队列和文件系统余量互证;
  11. 为限流、熔断、摘流、取消和只读降级写出收益、代价、停止线与回退;
  12. backend_xmin、复制槽 xmin/catalog_xmin 和 prepared transaction 判断 XID 保留者;
  13. restart_lsnwal_status、归档与备份状态判断 WAL 保留者;
  14. 解释为什么绝不能在运行中的实例里手工删除 pg_wal 文件;
  15. 用 Pigsty 的服务端点、连接池与监控缩小影响范围,但回到 PostgreSQL/OS 证据判型;
  16. 在不知道盲测答案时选择正确路线,并拒绝 34 类越界或误判证据。

一张判型表

问题 流量型 保留型
核心状态 到达工作超过可服务能力 最老的必需历史边界不能前进
典型信号 连接拒绝、队列、锁等待、CPU/I/O 饱和 inactive slot、旧 xmin、归档失败、WAL 累积
第一目标 减少 admission、并发或单项成本 找到 owner 与恢复来源,保护 lineage
可以立即做 限流、暂停批处理、精确 cancel、降级 留证、隔离增长、恢复消费者、评估精确释放
不应盲做 临时放大连接/内存,广域 terminate cancel 普通查询、删 pg_wal、随意 drop slot
成功证据 队列下降、拒绝停止、业务探针恢复 保留边界推进、归档/消费者恢复、恢复链完整

资源余量也不是一个百分比。至少要同时表达:

Hr=LrUr H_r = L_r - U_r

其中 $L_r$ 是资源 $r$ 的安全上限,$U_r$ 是当前与已承诺使用量。对连接、内存、WAL 空间、XID age、I/O 服务能力分别计算的 $H_r$ 不能相互替代。磁盘还有 30% 并不能 证明连接有余量;CPU 只有 20% 也不能证明 WAL 保留安全。

对流量型队列,若一段持续窗口内到达率 $\lambda$ 大于完成率 $\mu$:

dQdtλμ>0 \frac{dQ}{dt} \approx \lambda-\mu > 0

队列 $Q$ 就会增长。平均值暂时正常也救不了尾延迟;重试还会反过来抬高 $\lambda$。 对保留型压力,真正需要测的是“最老仍被需要的位置”及其推进速度,而不是只看目录 当前大小。

事故时的四步闭环

1. bound
   影响哪个服务、角色、数据库、主机和时间窗?

2. classify
   flow、retention、both 还是 unknown?

3. relieve or route
   流量型做精确减压;保留型保护证据并进入专门恢复路径

4. verify
   用户探针、队列、保留边界、拓扑与临时动作是否全部复位?

每一步都要记录 UTC 时间、证据引用、操作者、预期收益、停止条件和实际结果。仅仅看到 面板曲线下降,不能说明动作正确:流量也可能因为所有客户端都超时而“下降”。

正式实验

本章在已确认的 Pigsty 开发沙箱做两层实验:

managed pg-test
  Patroni / SQL read-only capture before and after
  no connection storm, slot, cancel, service or route mutation

pg-test-3 disposable PostgreSQL 18.6
  /tmp/pg36-ch34-overload-<run-id>
  listen_addresses=''
  private Unix socket
  max_connections=24
  exact cleanup after server stop

runner 用系统随机源安排两个 blind case,classifier 只能读取共同告警 postgresql-resource-headroom-at-risk 及观测字段,不能读取 hidden truth。正式顺序为:

RETENTION -> FLOW

正式观测:

情形 关键证据 判定与动作
connection storm 30 次尝试,21 个会话,9 次拒绝,20 个锁等待 RELIEVE_FLOW_PRESSURE;精确 cancel fixture sessions
WAL retention 1 个 inactive physical slot,保留 42,611,296 bytes PRESERVE_RETENTION_EVIDENCE;先留证,再 drop exact disposable slot

两个 case 完成后:

fixture sessions                   0
disposable physical slots          0
manual pg_wal file deletion    false
OOM / filesystem fill          false
managed topology changed       false
managed system id changed      false
managed timeline changed       false
exact temporary root remains   false

验证器同时构造并拒绝 34 个真实 mutant,包括生产边界被打开、blind packet 泄露答案、 阈值被削弱、广域 cancel、slot 仍残留以及谎报清理成功。公开证据见 overload-run.json

这份实验能证明在该隔离 PG18 合同内两类证据可区分,且精确动作能复位 fixture。 它不证明生产连接上限、真实 OOM victim、文件系统填满行为、归档仓库故障或未知 replication slot 可以安全删除;最终门禁固定为 production_ch34_gate=pending

阅读前后关系

本章目录

34.1 第一动作:流量型还是保留型

34.2 连接风暴与排队失控

34.3 失控查询、锁与事务

34.4 CPU、内存、I/O 与 OOM

34.5 流量型止血动作

34.6 保留型故障的安全路由

34.7 平台级流量控制与证据

34.8 实战:同一症状、两种成因

权威参考

PostgreSQL:

Pigsty:


上一章:故障切换与集群重建——力挽狂澜 · 返回下卷导读 · 下一章:数据抢救与工程取证——起死回生 · 查看全书目录 · 查看索引中心

34.1 第一动作:流量型还是保留型

资源告警出现后,先不要问“删什么”或“重启谁”,而要问:

当前资源是被正在到达和执行的工作消耗,还是被一个不能前进的历史边界 保留?

这不是给故障贴标签,而是选择安全动作。流量型压力需要减少进入量、并发或单项成本; 保留型压力需要找到保留者及其 owner。两类证据都成立时,先处理即将触发的硬失败, 同时保留另一条根因链;两类都不成立时,保持 unknown。

34.1.1 流量增长、慢查询、锁与连接导致的竞争

流量型的共同结构

下面四种表象最后都会形成“到达大于完成”:

放大源 到达侧变化 完成侧变化
业务流量增长 请求数增加 单次成本可能不变
慢查询或坏计划 请求数不变 每次占用 CPU/I/O/连接更久
锁竞争 等待工作继续占连接 有效并行度下降
连接风暴 建连、认证、backend 创建增加 正常查询拿不到 admission

若每秒进入 $\lambda$ 个工作、完成 $\mu$ 个工作,持续满足 $\lambda>\mu$,队列就增长。 这里的工作可以是 HTTP 请求、池等待者、数据库 session、正在运行的 statement 或磁盘 I/O。只看 PostgreSQL 的 active session 会漏掉在应用池和代理前排队的请求。

先把同一 UTC 窗口的证据放在一起:

SELECT application_name,
       state,
       wait_event_type,
       wait_event,
       count(*) AS sessions,
       max(clock_timestamp() - coalesce(xact_start, query_start))
         AS oldest_age
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1, 2, 3, 4
ORDER BY sessions DESC;

再对照:

application arrival / timeout / retry
pool waiting clients / server connections
proxy accept / queue / backend health
PostgreSQL active / idle-in-transaction / Lock waits
host run queue / memory pressure / device latency

state='active' 不表示正在使用 CPU;PostgreSQL 文档明确指出 statewait_event 相互独立。active 且 wait_event_type='Lock' 的 backend 正在执行语句,但实际被阻塞。 这也是为什么“active 数很多”必须继续拆成 running、lock wait、I/O wait 与 client wait。

判定成立的最低条件

把问题判为 flow,至少应看到一条能够闭合的因果链:

arrival/retry increases
  -> admission or execution concurrency increases
  -> queue/wait/rejection grows
  -> completion rate or user success falls

仅凭 CPU 90% 不够。CPU 高也可能是 checkpoint 后的恢复工作、压缩、备份或一个与用户 延迟无关的后台任务;连接数高也可能都是长期 idle、但尚未达到瓶颈。

34.1.2 WAL、XID、复制槽、归档和长事务导致的保留

保留型不是“有人正在大量使用”

PostgreSQL 为恢复、复制和 MVCC 正确性保留历史。只要某个消费者仍声明“我可能需要 这里以前的内容”,系统就不能越过它回收:

被保留对象 常见保留者 关键边界
旧 tuple 版本 活跃快照、长事务、prepared transaction backend_xmin、prepared XID
catalog tuple logical replication slot catalog_xmin
WAL segment physical/logical slot、备库、备份 restart_lsn
待归档 WAL archive command/repository 失败 pg_stat_archiver 与归档队列
事务 ID 安全空间 未冻结表与被钉住的 xmin relation/database age

这里重要的是最老边界及其速度

SELECT pid,
       usename,
       application_name,
       state,
       xact_start,
       backend_xid,
       backend_xmin
FROM pg_stat_activity
WHERE backend_xid IS NOT NULL
   OR backend_xmin IS NOT NULL
ORDER BY xact_start NULLS LAST;

SELECT slot_name,
       slot_type,
       active,
       xmin,
       catalog_xmin,
       restart_lsn,
       wal_status,
       safe_wal_size,
       invalidation_reason
FROM pg_replication_slots
ORDER BY slot_name;

SELECT transaction, gid, prepared, owner, database
FROM pg_prepared_xacts
ORDER BY prepared;

inactive slot 不等于废弃 slot。它可能对应暂时离线的副本、迁移、CDC 消费者或恢复流程; active slot 也不等于健康,消费者可能连着却不推进。必须把 slot 映射到服务 owner、 consumer、恢复承诺和最后成功时间。

先测增长,再谈释放

两个快照比一个快照更有意义:

t0: free bytes, current LSN, restart_lsn, archive failure count
t1: same fields after a known interval

retained bytes     = current LSN - restart_lsn
growth rate        = (retained_t1 - retained_t0) / elapsed
time to hard limit = usable headroom / positive growth rate

max_slot_wal_keep_size=-1,replication slot 可以不受该参数上限地保留 WAL;即使配置 了有限值,相关状态也在 checkpoint 时才重新评估,不能把参数值误当作实时保险丝。

34.1.3 同样表现为“磁盘满”或“延迟高”,动作可以相反

症状相同,控制变量不同

症状 可能的 flow 根因 可能的 retention 根因 需要区分的证据
pg_wal 写流量突增、checkpoint 压力 slot/归档/备份钉住 WAL WAL 生成率与最老保留 LSN
表膨胀 更新/删除量增长 长快照或 slot catalog_xmin DML 率、vacuum 进度与 xmin
磁盘延迟高 并发查询、temp、checkpoint 被保留数据持续占满并触发写放大 device queue、文件分类、增长率
连接失败 到达/重试超过连接预算 磁盘满后新事务无法写 WAL pool queue、PG error、filesystem
CPU 高 执行/解析/自旋竞争 recovery/cleanup 追赶保留积压 backend type、wait、工作量变化

因此动作可以完全相反:

flow:
  stop admission -> cancel exact work -> queue falls

retention:
  preserve owner/lineage evidence -> repair consumer/archive
  -> only then release an exact, authorized boundary

把 retention 当 flow,取消再多普通查询也不会推进 restart_lsn。把 flow 当 retention, 忙着调查 slot 而不限制重试,服务可能先被连接和内存击穿。

同时发生怎么办

WAL slot 滞留期间又发生写入洪峰并不矛盾。事故记录应允许:

classification:
  flow: confirmed
  retention: confirmed
immediate_hard_failure: filesystem-full-in-18m
parallel_controls:
  - reduce noncritical write admission
  - preserve slot/archive evidence and contact owner
forbidden:
  - delete pg_wal files
  - drop unknown slot

“只能选一个根因”是复盘分类,不是在线处理原则。

34.1.4 判型不清时先停止破坏性清理

unknown 是一种有效状态

下面任一项不明,就不能执行不可逆释放:

which path is failing?
which object is growing?
who owns the oldest xmin/restart_lsn?
is there a valid backup or replica copy?
what client work will be canceled?
what data or recovery capability can be lost?

先收集最小证据包:

UTC + monotonic timestamp
user-facing probe and exact error class
pg_stat_activity grouped by app/state/wait
blocker tree and long/prepared transactions
pg_replication_slots and pg_stat_archiver
current/replay LSN and write rate
filesystem by mount/path plus inode usage
CPU/memory/pressure/device queue
recent config/deploy/failover/backup changes

然后将路线写成机器与人都能审阅的三态判定:

FLOW
  sufficient connection/queue/wait evidence
  and no contradictory retention evidence for this action

RETENTION
  exact owner boundary and retained quantity observed
  and flow relief cannot release that boundary

STOP_AND_INVESTIGATE
  两类证据同时成立,或任何一类都不足以支持动作

停止线包括:有人建议删 pg_wal、drop 未知 slot、终止未知大事务、在唯一副本上试验 危险参数、或以“磁盘快满”为由跳过 owner/backup 识别。此时应保护现场并回到第 31 章 的事故指挥框架。

本节交付物

进入具体止血前,至少写出:

symptom: exact user and resource observation
scope: service / database / node / time window
classification: FLOW | RETENTION | BOTH | UNKNOWN
supporting_evidence: [...]
contradicting_evidence: [...]
first_action: bounded and reversible
stop_condition: measurable
owner: named role

没有这些字段,“先重启看看”不是 runbook。


返回本章目录 · 下一节:连接风暴与排队失控 · 查看全书目录 · 查看索引中心

34.2 连接风暴与排队失控

PostgreSQL 采用一个 client connection 对应一个 backend process 的模型。连接不仅占一个 数字,还需要进程、内存、认证、catalog 初始化、socket、锁表与调度成本。连接池的 价值不是让数据库接受无限请求,而是把大量 client concurrency 变成有上限的 database concurrency。

34.2.1 数据库连接、代理池与应用池三层

三层都在排队

request
  -> application worker / local pool waiters
      -> PgBouncer client connections / waiters
          -> PgBouncer server connections
              -> PostgreSQL client backends

每层至少有四个量:

admitted
running
waiting
rejected/timed out

只看 PostgreSQL numbackends 会漏掉池前的队列;只看应用 pool size 又会漏掉多个 pod/进程/租户汇总后对数据库的总承诺。

一个粗略预算应满足:

iAiPi+M+BCpool \sum_i A_i P_i + M + B \le C_{\text{pool}}

其中 $A_i$ 是第 $i$ 类应用实例数,$P_i$ 是每实例可能占用的 server connection, $M$ 是迁移、运维和监控预算,$B$ 是故障切换/伸缩缓冲。对 PostgreSQL:

Cpool+Cdirect<max_connectionsCreserved C_{\text{pool}} + C_{\text{direct}} < \texttt{max\_connections} - C_{\text{reserved}}

这里不是要求把所有层的上限设成同一个数。应用 client queue 可以大于 PgBouncer server pool,但必须有长度、deadline 和拒绝策略;PgBouncer client connection 也不等于 PostgreSQL backend。

按事务语义分池

不能只按主机分池,还要按工作类型隔离:

特征 建议控制
OLTP 短事务、低尾延迟 最稳定的预算,快速失败
batch/ETL 长查询、吞吐优先 独立小池,可暂停
admin/migration 低频但高权限 保留直连/管理余量
monitoring 周期查询 有界并发,不能形成自激
read-only 可接受副本语义时 独立只读端点与 staleness 契约

若批处理与 OLTP 共用一池,批处理占满 server connections 后,所谓“主库健康”也无法 给在线请求提供 admission。Pigsty 的主写、只读、离线等服务端点可以提供路由分界,但 是否适合某事务仍由应用一致性语义决定。

事故时不要立刻放大 max_connections

扩大上限会让更多工作同时进入执行层,可能把一个有界连接拒绝变成内存、CPU、锁与 I/O 全面争用。只有在以下事实都成立时,调整才是经过评估的容量变更:

current connections are useful, not retry duplicates
per-backend and per-query memory worst case is safe
CPU/I/O still have service headroom
new reserved/admin budget remains available
pool and application limits will not simply refill the new space
rollback and restart/reload semantics are known

在线事故的默认路线是收紧 admission,而不是把硬边界向后推。

34.2.2 重试放大、健康检查和短连接

重试会把失败变成新流量

若原始到达率为 $\lambda_0$,每次失败平均触发 $r$ 次下一轮尝试,成功率没有及时恢复, 有效流量近似:

λeffective=λ0(1+r+r2+) \lambda_{\text{effective}} = \lambda_0(1+r+r^2+\dots)

当 $r\ge1$ 且没有 retry budget、deadline 或熔断时,系统进入正反馈:

latency rises
  -> client timeout
  -> synchronized retry
  -> more connections and work
  -> latency rises again

日志里的“请求量上升”可能不是用户流量,而是同一批请求的重复尝试。必须用稳定的 request/idempotency key 区分 original、retry 与 hedge。

健康检查也会成为负载

设 $N$ 个应用实例,每个实例维护 $P$ 个 worker,每 $h$ 秒建立一次检查连接,则单健康 检查一项就可能产生约 $NP/h$ 次每秒建连。以下设计尤其危险:

  • 每个业务请求先新建连接执行 SELECT 1
  • 每个 pod 同时启动并预热完整池;
  • 多级代理各自以高频新连接探测;
  • 故障时 autoscaling 新增实例,同时所有实例立刻重试;
  • liveness 把短暂数据库慢判为应用死亡,形成重启风暴。

健康检查要区分:

liveness: process itself是否需要重启
readiness: 是否接收新业务流量
dependency health: 数据库路径是否满足该业务语义

数据库慢通常应先让应用 not-ready 或熔断新请求,而不是把所有应用进程重启。

长连接也不是免疫

已有连接在代理切换、数据库重启、证书轮换或网络抖动后会同时重连。池应具备:

randomized connection lifetime
startup/prewarm rate limit
connect timeout shorter than request deadline
bounded reconnect concurrency
exponential backoff with full jitter
global retry budget

不要给每一层各自配置十次重试。应用、驱动、service mesh、代理和任务框架叠加后, 最坏尝试次数是乘法。

34.2.3 限流、队列、连接预算与指数退避

把过载变成显式 admission

好的过载控制不是“永不拒绝”,而是在系统仍能完成高价值工作时,尽早、明确地拒绝 超出预算的工作:

admit if:
  class budget available
  AND request deadline still useful
  AND downstream breaker allows probe
else:
  reject/queue with bounded cost

队列必须同时有:

  • 最大长度,防止内存成为下一瓶颈;
  • 最大等待时间,过期工作不再进入数据库;
  • 公平性或优先级,避免批处理饿死 OLTP;
  • 可观测的 admitted/waited/rejected/expired 计数;
  • drain 与 deploy 行为,避免发布时丢失或翻倍。

无限队列只是把快速失败变成更晚失败。Little’s Law 给出稳定系统中的关系:

L=λW L=\lambda W

当平均等待 $W$ 上升时,在途数量 $L$ 也上升;如果请求 deadline 已经小于排队时间, 即使最终执行成功,对用户也没有价值。

指数退避要带随机抖动

一个常见策略:

cap = min(max_backoff, base * 2^attempt)
sleep = random(0, cap)          # full jitter
stop when:
  request deadline exhausted
  retry budget exhausted
  operation is not idempotent/reconcilable

重试条件也要按错误分类:

错误 默认处理
认证/权限/语法 不重试,修配置或代码
连接拒绝/切换窗口 有预算、带 jitter 重试
statement timeout 先判是否仍在数据库执行及是否幂等
deadlock/serialization failure 整个事务按有限策略重试
unknown COMMIT outcome 先用业务 token 对账,不裸重放

连接事故的止血顺序

1. freeze autoscaling/restart/retry amplification
2. preserve admin/reserved path
3. cap low-priority application admission
4. pause batch and migration pools
5. inspect active/waiting/root blockers
6. cancel exact low-value work if necessary
7. verify queue, success rate and tail latency
8. only after stability, repair capacity/config root cause

验收不是“连接数下降”,而是:

new connection rejection stops or is intentional
pool waiting/expired work trends down
useful completion rate recovers
admin path remains available
no new memory/I/O bottleneck appears
temporary limits have an owner and expiry

上一节:第一动作:流量型还是保留型 · 返回本章目录 · 下一节:失控查询、锁与事务 · 查看全书目录 · 查看索引中心

34.3 失控查询、锁与事务

“杀慢查询”不是故障判型。一个耗时最长的 session 可能是阻塞根节点、也可能是等待者; 可能正在做有价值的恢复,也可能已经超过用户 deadline;可能可以安全 cancel,也可能 已经写入海量 WAL 和死版本,terminate 也不会把这些成本自动抹掉。动作必须绑定 query、transaction、application、owner 与业务语义。

34.3.1 识别高消耗查询和阻塞根节点

当前现场与历史重查询分开

pg_stat_activity 说明此刻有哪些 backend、状态与等待;pg_stat_statements 聚合的是 一段时间内同类语句的执行统计。前者适合回答“谁现在占着资源”,后者适合回答“哪类 语句长期贡献最多”。不能用累计榜单代替当前事故现场。

一个不导出完整 SQL 文本的当前投影:

SELECT pid,
       usename,
       application_name,
       state,
       wait_event_type,
       wait_event,
       clock_timestamp() - query_start AS query_age,
       clock_timestamp() - xact_start AS xact_age,
       backend_xid,
       backend_xmin,
       query_id
FROM pg_stat_activity
WHERE backend_type = 'client backend'
  AND pid <> pg_backend_pid()
ORDER BY xact_start NULLS LAST, query_start NULLS LAST;

对历史工作量,可按目标排序,而不是永远按 total time:

SELECT queryid,
       calls,
       total_exec_time,
       mean_exec_time,
       rows,
       shared_blks_read,
       shared_blks_written,
       temp_blks_read,
       temp_blks_written,
       wal_bytes
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

版本、扩展列和统计起点应随报告一起记录。统计 reset 或重启后的短窗口不能与一周基线 直接比较。

找根阻塞者,不要只杀等待者

WITH RECURSIVE lock_tree AS (
  SELECT a.pid,
         a.application_name,
         a.xact_start,
         pg_blocking_pids(a.pid) AS blockers,
         ARRAY[a.pid] AS path
  FROM pg_stat_activity AS a
  WHERE cardinality(pg_blocking_pids(a.pid)) > 0

  UNION ALL

  SELECT b.pid,
         b.application_name,
         b.xact_start,
         pg_blocking_pids(b.pid),
         t.path || b.pid
  FROM lock_tree AS t
  CROSS JOIN LATERAL unnest(t.blockers) AS p(pid)
  JOIN pg_stat_activity AS b ON b.pid = p.pid
  WHERE NOT b.pid = ANY(t.path)
)
SELECT * FROM lock_tree;

根 blocker 可能显示 idle in transaction,因为它已经执行完持锁语句,正在等客户端下 一条命令。仅筛选 state='active' 会漏掉它。也要排除 autovacuum、logical worker、 备份和维护工作等不同 backend_type,不要把每个 PID 都当作应用会话。

高消耗不是自动有罪

取消前回答:

is this the root blocker or a victim?
is its client deadline already expired?
is it OLTP, migration, maintenance, backup, recovery, or batch?
what locks and objects does it own?
what rows/WAL/temp/I/O has it already produced?
does it carry a business idempotency key?
who owns the decision?

对 query_id 做执行计划分析时,转到第 10、11 章的方法;在线事故中不要在主库上无界 执行 EXPLAIN ANALYZE 复现一条未知重查询。

34.3.2 cancel、terminate 与中止后成本

两个函数的边界

SELECT pg_cancel_backend(:pid);
SELECT pg_terminate_backend(:pid);

pg_cancel_backend 向目标 backend 发送取消当前 query 的请求。session 通常仍存在; 当前事务会进入错误状态,客户端需要 ROLLBACKpg_terminate_backend 终止整个 session,连接断开,未提交事务由服务器回滚。两者都需要相应权限;不要通过给应用 超级用户来获得事故处置能力。

优先级一般是:

application cooperative cancel/deadline
  -> pg_cancel_backend exact PID
      -> wait and verify
          -> pg_terminate_backend exact PID when justified

“exact”至少绑定:

pid + backend_start
database + user + application_name
query_id / transaction age
incident run id or ticket

PID 会复用。先查 PID、过几分钟再裸 terminate,可能命中完全不同的新连接。执行动作的 SQL 应在同一事务/语句里重验识别字段。

cancel 不等于立即释放全部资源

  • query 可能在到达可中断点前继续运行;
  • client 可能自动重试同一工作;
  • 事务未 rollback 前仍可能持有锁;
  • parallel workers 与 leader 的收敛需要时间;
  • remote/extension 调用的中断语义取决于组件;
  • query 已经产生的 WAL、temp 或脏页不会凭空消失。

terminate 也不是免费的“更强 cancel”,但要准确理解 PostgreSQL 的代价:普通事务 中止不会通过物理 undo 逐行撤销已经写过的 tuple。backend 仍需响应信号、执行中止清理 并释放锁和本地资源;已经产生的 WAL、脏页、复制延迟和死版本不会消失,死版本通常要 由后续 vacuum 回收。因此不能套用“按修改量做数小时物理回滚”的模型,也不能因为 session 已消失就宣称资源影响全部结束。

动作后必须复核

SELECT pid, backend_start, state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE pid = :pid;

同时观察:

root blocker disappeared?
dependent waiters made progress?
pool stopped recreating the work?
abort cleanup、vacuum 或 replica catch-up 是否仍在消耗 I/O?
user success and tail latency recovered?

如果应用立刻重建同一 session,数据库端 cancel 只是短暂擦除症状,真正控制点在 admission 和 retry。

34.3.3 长事务和大事务结束前先评估后果

“长”与“大”是两个维度

long but small
  idle transaction holds snapshot/locks for hours

short but large
  bulk UPDATE changes millions of rows in minutes

long and large
  migration/ETL both retains old state and creates rollback work

长事务主要风险是 lock、backend_xmin、vacuum 回收与连接占用;大事务还带来 WAL、 dirty buffers、replication lag、终止响应与后续清理成本。prepared transaction 即使 没有活动 session,也可长期保留锁和 XID 状态:

SELECT gid,
       prepared,
       owner,
       database,
       clock_timestamp() - prepared AS age
FROM pg_prepared_xacts
ORDER BY prepared;

不要看到 prepared transaction 就 ROLLBACK PREPARED。它属于两阶段提交协议,必须先 与 transaction manager/业务 ledger 对账,判断应 commit 还是 rollback。

结束前的后果清单

identity:
  pid_backend_start: ...
  application_owner: ...
  transaction_or_job_id: ...
business:
  partial_external_effects: ...
  idempotency_or_reconciliation: ...
database:
  locks: ...
  xmin_retention: ...
  rows_wal_temp_estimate: ...
  replicas_and_archive_effect: ...
abort_and_cleanup:
  expected_resource_cost: ...
  observation_query: ...
  escalation_timeout: ...

若事务包含数据库外部副作用,PostgreSQL rollback 只能撤销数据库内未提交状态,不能 撤回已经发出的邮件、支付或消息。此时需要业务补偿,不是更强的 terminate。

更好的预防

  • 为交互式事务设置合理的 idle_in_transaction_session_timeout
  • 为不同工作负载设置 statement/lock timeout,而不是一个全局极小值;
  • 大批处理分块提交,并让每块有可恢复 checkpoint;
  • schema change 使用受控 lock timeout 和发布门;
  • 统一 application_name、query tag 与业务 job id;
  • 为重要操作保留可对账 token。

timeout 是保护栏,不是容量。设置后还要验证应用如何处理取消、事务错误和重试。


上一节:连接风暴与排队失控 · 返回本章目录 · 下一节:CPU、内存、I/O 与 OOM · 查看全书目录 · 查看索引中心

34.4 CPU、内存、I/O 与 OOM

数据库资源彼此耦合。内存紧张会增加 reclaim 与 swap I/O;I/O 变慢会延长 query 和 transaction,占住更多连接与内存;连接排队又会触发超时重试,进一步增加 CPU。 事故中不能把每张主机图分开解释,而要寻找同一时间线上的因果方向。

34.4.1 饱和、排队、抖动与抢占

利用率不是完整答案

CPU 100% 可能仍有高吞吐且尾延迟可接受;CPU 40% 也可能因为单核热点、锁、自旋、 steal time 或 I/O 等待导致业务停滞。主机证据至少包括:

per-CPU user/system/iowait/steal
run queue and runnable tasks
context switches
memory pressure / reclaim / swap
per-device latency, queue depth, throughput and errors
cgroup/container limits and throttling
filesystem free bytes and inodes

再按 PostgreSQL backend type 与 wait event 对齐:

SELECT backend_type,
       wait_event_type,
       wait_event,
       count(*) AS processes
FROM pg_stat_activity
GROUP BY 1, 2, 3
ORDER BY processes DESC;

PostgreSQL 官方建议把统计视图与操作系统工具结合,因为数据库 I/O 统计不能区分数据 来自物理设备还是内核 page cache。数据库看到 read,也不等于磁盘实际发生同量读取。

饱和、排队和抖动

saturation
  resource has little service headroom

queueing
  work waits before resource service

jitter
  completion latency varies sharply over time

contention/preemption
  work loses CPU/lock/device service to other work

平均设备延迟 2 ms 可能掩盖 checkpoint 时 500 ms 尖峰;五分钟 CPU 平均值也会抹掉 每 30 秒同步到来的任务。保留原始采样粒度、时钟和分位数,不要只截一张平滑后的图。

先区分主机级还是数据库级

证据组合 更可能的方向
host run queue 高,PG 多数无 wait CPU runnable 竞争
PG 大量 Lock,CPU 不高 数据库锁序列化
device await/queue 高,PG 大量 IO 存储服务能力不足
cgroup throttled,宿主机空闲 容器/服务配额
swap/reclaim 高,连接与 query 同增 内存承诺或并发过大
PG 平稳,其他进程占资源 noisy neighbor/备份/扫描

不要在没确认 cgroup/虚拟化边界时用宿主机总容量推导数据库余量。

34.4.2 临时文件、并行、checkpoint 与后台维护

前台与后台会争同一设备

下面工作可能同时写盘:

sort/hash spill -> temp files
WAL writer / WAL sync
backend data writes
checkpointer flushing dirty buffers
autovacuum/vacuum
CREATE INDEX / REINDEX
base backup and archive
operating-system or storage maintenance

pg_stat_io 按 backend type、object 与 context 提供集群级 I/O 统计;pg_stat_database 的 temp 计数、日志中的 temporary file、pg_stat_checkpointerpg_stat_archiver 和 进度视图分别补充来源。统计是累计量,需要记录起点并取差值:

SELECT backend_type,
       object,
       context,
       reads,
       read_time,
       writes,
       write_time,
       extends,
       fsyncs,
       fsync_time
FROM pg_stat_io
ORDER BY backend_type, object, context;

列集合随 PostgreSQL 版本演进,生产脚本应绑定 major version 并做兼容检查。

临时文件是结果,不是单一根因

spill 可能来自:

  • work_mem 对该 sort/hash 太小;
  • 行数估计错误导致计划不合适;
  • 并发相同操作太多;
  • 查询本来就必须处理大量数据;
  • hash 操作按 hash_mem_multiplier 获得更高上限;
  • parallel workers 各自执行内存/临时工作。

直接把 work_mem 全局放大,可能把磁盘事故变成 OOM。优先修 query/统计、限制该工作 并发,必要时只对可控 role/session 做有界调整并验证。

checkpoint 峰值与追赶效应

checkpoint 需要把脏页推进到 durable storage。写流量突增、WAL 配置、恢复/重启后的 缓存重新填充和存储变慢都可能让 checkpoint 与前台 I/O 相互干扰。事故中记录:

checkpoint requested/timed
buffers written and write/sync duration
WAL generation rate
device write latency/queue
replica and archive progress

暂停 autovacuum 或 checkpoint 通常不是通用止血。autovacuum 还承担 XID freeze; 暂停后可能把短期 I/O 压力转成更危险的保留问题。只能对已识别对象、在明确时间窗与 回补计划下调整维护。

34.4.3 内存最坏并发、OOM killer 与进程重启

work_mem 不是每连接只分配一次

PostgreSQL 文档强调,work_mem 是一个 query operation(如 sort/hash)的基础上限; 一个复杂 query 可同时有多个 operation,多个 session 又可并发,parallel worker 也会 扩大总使用。粗略上界应按工作节点估算:

Mworkloadsactive sessionsoconcurrent operations(s)wparticipants(s,o)Ms,o,w M_{\text{workload}} \approx \sum_{s \in \text{active sessions}} \sum_{o \in \text{concurrent operations}(s)} \sum_{w \in \text{participants}(s,o)} M_{s,o,w}

其中 participants 包括执行该 operation 的 leader 与 parallel workers。这个表达式的 关键不是算出一个恒定值,而是把“同时活跃的 session × 同时活跃的内存节点 × 参与 进程”三层并发都纳入预算。

再加上:

shared_buffers and shared memory
backend base memory
maintenance_work_mem / autovacuum_work_mem
logical decoding and extension memory
kernel page cache
proxy, exporter, Patroni and other host processes
failure/recovery reserve

因此:

max_connections * work_mem

既不是准确实测,也不是足够保守的最坏值。它漏掉每 query 多个节点和并行,也忽略许多 非 work_mem 内存;反过来假设所有连接同时打满每个上限又可能极度悲观。容量测试要用 真实 workload envelope 和并发组合。

Linux OOM 不是数据库的流控机制

Linux overcommit 允许进程承诺超过物理内存的虚拟地址空间;真正耗尽时,OOM killer 可能选择某个进程。若 PostgreSQL child 被杀,postmaster 会把它当作异常退出,为保护 共享内存一致性,可能终止其他 server processes 并执行 crash recovery;若 postmaster 本身被杀,服务管理器行为又是另一条路径。

不要故意在共享/生产主机上“测一次 OOM”。安全实验应在有明确 cgroup/VM 边界的专用 环境里完成,并验证:

which cgroup/host reported OOM
which PID and backend_type was selected
whether postmaster remained
whether crash recovery occurred
client unknown outcomes
replica/archive/backup state after restart

本章正式实验明确不注入 OOM。

事故动作

内存压力下优先:

  1. 阻止新低价值工作和重试;
  2. 识别 exact 高内存 query/role/pool;
  3. 暂停可恢复 batch 与并行任务;
  4. 用 query cancel 逐步释放,而不是一次 terminate 全部;
  5. 保留管理连接与 OS 控制面;
  6. 观察 reclaim、swap、RSS、队列和业务成功率;
  7. 稳定后修正连接预算、query、并行与内存配置。

不要在事故中 drop OS cache:它既不能修复内存承诺,还会把后续读取推向存储,破坏 现场并制造新的 I/O 峰值。也不要把 swap 使用本身等同于故障;关键是持续 swap in/out、 memory pressure 与用户影响。

资源证据矩阵

cpu:
  utilization: ...
  run_queue: ...
  steal_or_throttle: ...
memory:
  available: ...
  pressure: ...
  swap_rate: ...
  oom_event: ...
io:
  device_latency_queue: ...
  pg_waits: ...
  checkpoint_temp_maintenance: ...
workload:
  admitted_running_waiting_rejected: ...
  root_queries_or_jobs: ...
decision:
  exact_control: ...
  stop_and_rollback: ...

单张 top 截图不够支撑数据库重启。


上一节:失控查询、锁与事务 · 返回本章目录 · 下一节:流量型止血动作 · 查看全书目录 · 查看索引中心

34.5 流量型止血动作

流量型止血的目标不是让所有请求都继续进入,而是让系统重新获得完成有价值工作的 能力。在过载区间里,少接收一些工作通常比全部接收、全部超时更可用。

34.5.1 限流、熔断、摘除非关键负载

越靠近来源,拒绝成本越低

client / edge
  -> application admission
      -> local pool
          -> proxy / PgBouncer
              -> PostgreSQL

在应用 admission 拒绝一个尚未创建事务的请求,成本通常远低于让它拿到 database connection、执行一半、生成 WAL 后再取消。因此控制顺序优先:

  1. 阻止新的低优先级到达;
  2. 冻结无抖动重试、autoscaling 和批量 worker 扩张;
  3. 缩小 batch/report/migration pool;
  4. 对依赖数据库的非关键功能打开熔断或静态降级;
  5. 最后才在数据库内取消已经进入的 exact work。

限流、熔断、摘流回答不同问题

控制 回答 典型状态
rate limit 单位时间允许多少新工作 token/leaky bucket
concurrency limit 同时允许多少在途工作 semaphore/pool
queue bound 等待多少、等多久 length + deadline
circuit breaker 下游失败时是否继续尝试 closed/open/half-open
load shedding 哪类工作先被拒绝 priority/admission class
route removal 哪个后端不再接新流量 health/maintenance state

熔断打开不等于数据库恢复;它只是停止继续伤害下游。half-open probe 必须有很小并发, 否则所有实例同时探测会形成下一轮风暴。

按业务价值而不是技术便利舍弃

一个可执行的 shedding 顺序需要产品 owner 参与:

preserve:
  - payment commit
  - authentication write
  - incident/admin probe
degrade:
  - recommendation to cached response
  - dashboard to stale snapshot
pause:
  - ETL
  - report export
  - reindex/migration
reject:
  - best-effort refresh

“所有 SELECT 都是低价值”或“写都更重要”并不成立。某些 read 是支付授权前置条件, 某些 write 只是可重算的埋点。

34.5.2 取消查询、暂停批处理与只读降级

取消只命中已证明的工作集

安全筛选示例:

WITH target AS (
  SELECT pid, backend_start
  FROM pg_stat_activity
  WHERE application_name LIKE 'report-worker:%'
    AND state = 'active'
    AND query_start < clock_timestamp() - interval '30 seconds'
    AND pid <> pg_backend_pid()
)
SELECT pid,
       backend_start,
       pg_cancel_backend(pid) AS signaled
FROM target;

生产中还应绑定数据库、role、query/job tag、变更单与 owner。LIKE 'report-worker:%' 只有在 application_name 受治理、不能被任意业务伪造时才够用。

取消后等待并验收,不要立即升级为 terminate:

target sessions disappear or become idle/aborted
root lock releases
waiters make progress
application does not recreate work
useful completion rate rises
rollback/recovery cost remains bounded

暂停 producer 比逐条 cancel 更有效

batch 系统通常有 scheduler、queue consumer 或 worker deployment。先暂停 producer, 再处理 in-flight work;否则数据库每取消一条,调度器就补一条。暂停要记录:

queue name and partition
last acknowledged item/checkpoint
in-flight ownership
resume condition
duplicate/replay semantics
maximum backlog after pause

如果任务没有 checkpoint 与幂等性,暂停本身可能产生业务不一致,需要应用 owner 决策。

只读降级有一致性前提

把读流量移到 replica 可以减少 primary 的部分 CPU/I/O,但必须回答:

can this operation tolerate replica lag?
does it require read-your-write or monotonic reads?
will long reads delay replay or create conflicts?
does replica share the same storage/CPU failure domain?
is offline/analytics capacity isolated?
what happens when no eligible replica exists?

不要把写请求改成“返回成功但不落库”,除非业务明确设计了 durable queue/ledger 和补偿。 也不要把所有查询涌向一台 replica:这可能让 replay 落后,进一步破坏读语义和 HA 候选质量。

Pigsty 的 replica/offline 服务可以表达路由意图,不能替代这些语义判断。路由后用 pg_is_in_recovery()transaction_read_only、replay lag 与业务 token 复核。

34.5.3 每个动作写清收益、代价、停止条件和回退

动作卡,而不是命令清单

action_id: FLOW-07
hypothesis:
  report batch consumes the OLTP server pool
target:
  application_name_prefix: "report-worker:"
  pool: report
expected_benefit:
  free_at_least_connections: 12
  reduce_lock_waiters_below: 3
cost:
  report_jobs_paused: true
  duplicate_risk: reconciled-by-job-id
guard:
  exclude_admin_and_oltp: true
stop_condition:
  useful_tps_not_improved_after: 120s
  post_cancel_io_exceeds_baseline_by: 2x
rollback:
  restore_pool_limit: after 15m stable window
evidence:
  before: ...
  after: ...
owner: ...

四个字段不可省:

  1. 收益:哪个指标应在多长时间内变化;
  2. 代价:谁被拒绝、延迟或需要补偿;
  3. 停止条件:什么证据说明假设错误或副作用更大;
  4. 回退:如何撤销临时配置、恢复任务并验证没有重放。

一次只改变可辨识的控制变量

同时扩容、重启、cancel、改 pool 和改路由,曲线即使恢复也无法知道哪个动作有效; 某个有害动作可能被另一个动作掩盖。事故很急时可以并行动作,但必须按独立目标分组, 记录精确时间和 owner:

T0 admission limit
T1 batch pause
T2 exact cancel
T3 service probe recovery
T4 queue below stop threshold

涉及同一变量的相反动作不能并发,例如一个人缩 pool、另一个人扩大 max_connections

止血成功的定义

至少同时满足:

user success and tail latency recover
queue/rejection trend is understood
database has resource headroom, not merely lower traffic
replicas/archive/backup remain healthy
no unknown large rollback or retention boundary remains
temporary controls have owner, expiry and rollback evidence

服务恢复后不要马上取消全部限流。先保持观察窗口,逐级放量;每一级都验证完成率、 尾延迟、资源余量与重试量。一次把 backlog 全部释放,会制造第二次尖峰。


上一节:CPU、内存、I/O 与 OOM · 返回本章目录 · 下一节:保留型故障的安全路由 · 查看全书目录 · 查看索引中心

34.6 保留型故障的安全路由

保留型事故的第一原则是:

先证明“谁还需要哪段历史”,再决定是恢复消费者、迁移恢复来源,还是释放这项需要。

一条复制槽、一个 xmin 或一批 WAL 文件本身不是垃圾。它们是恢复、复制、快照或事务 协议的状态。未经 owner 与恢复链确认的“清理”,可能把空间问题变成不可恢复的数据 问题。

34.6.1 XID:检查 backend_xmin、复制槽 xminpg_prepared_xacts

先列出所有可能的 horizon owner

活动 backend:

SELECT pid,
       datname,
       usename,
       application_name,
       state,
       xact_start,
       backend_xid,
       backend_xmin,
       wait_event_type,
       wait_event
FROM pg_stat_activity
WHERE backend_xid IS NOT NULL
   OR backend_xmin IS NOT NULL
ORDER BY xact_start NULLS LAST;

复制槽:

SELECT slot_name,
       slot_type,
       database,
       active,
       xmin,
       catalog_xmin,
       restart_lsn,
       inactive_since,
       invalidation_reason
FROM pg_replication_slots
ORDER BY slot_name;

两阶段事务:

SELECT transaction,
       gid,
       prepared,
       owner,
       database
FROM pg_prepared_xacts
ORDER BY prepared;

再看 database/relation freeze age:

SELECT datname,
       age(datfrozenxid) AS xid_age,
       mxid_age(datminmxid) AS multixact_age
FROM pg_database
ORDER BY xid_age DESC;

这些视图回答的是不同问题:

  • backend_xmin:该 backend 当前快照仍可能看到多老的版本;
  • slot xmin:消费者需要的数据行版本边界;
  • catalog_xmin:逻辑解码需要的 catalog 版本边界;
  • prepared XID:已经 PREPARE TRANSACTION、等待外部决议的事务;
  • datfrozenxid:数据库中尚未冻结事务的保守下界。

不要把最老 PID 自动当成罪魁

一个长 session 未必持有 xmin;一个短暂但 prepared 的事务可能没有 session 却长期 持锁。logical slot 的 catalog_xmin 也可能成为 catalog vacuum 的约束。先把每个边界 映射到:

owner
business or replication purpose
last successful progress
expected outage/retention window
recoverability if released
approved decision maker

处置路线:

保留者 安全路线
应用长事务 联系 owner,停止新工作,评估 cancel/terminate 与补偿
prepared transaction 与 transaction manager/ledger 对账后 commit 或 rollback
logical slot 恢复 consumer,或从新起点重建并明确数据缺口
freeze 落后 修复 blocker/资源后执行受控 vacuum/freeze
无法识别 保持证据,升级,不释放

完整的 XID、freeze 与膨胀处理见第 28 章

34.6.2 WAL 撑盘:检查归档失败、复制槽和未完成备份

pg_wal 大小不是根因

WAL 目录可以因为正常高写入暂时变大,也可以因为保留者不推进持续增长。先记录:

SELECT pg_current_wal_lsn() AS current_lsn,
       pg_wal_lsn_diff(
         pg_current_wal_lsn(),
         '0/0'::pg_lsn
       ) AS absolute_lsn_bytes;

SELECT slot_name,
       slot_type,
       active,
       restart_lsn,
       pg_wal_lsn_diff(
         pg_current_wal_lsn(),
         restart_lsn
       ) AS retained_wal_bytes,
       wal_status,
       safe_wal_size,
       invalidation_reason
FROM pg_replication_slots
ORDER BY retained_wal_bytes DESC NULLS LAST;

SELECT archived_count,
       failed_count,
       last_archived_wal,
       last_archived_time,
       last_failed_wal,
       last_failed_time
FROM pg_stat_archiver;

同时检查:

physical replica receive/replay progress
logical consumer confirmed progress
archive command/repository health
base backup / restore / WAL summarization activity
checkpoint timing
WAL generation rate by workload
filesystem free bytes and inode headroom

pg_stat_archiver.failed_count 是累计量;单次历史失败不证明当前仍坏。需要看最近成功、 最近失败和时间窗增量。slot active=true 也只说明当前有人连接,不说明 restart_lsn 正在推进。

未完成备份也是恢复链的一部分

备份过程可能需要一段 WAL 才能形成一致恢复点。事故中贸然停止备份、删除 staging 或释放 WAL,可能让本来可用的恢复副本失效。记录:

backup run id / start LSN / stop LSN
repository and archive confirmation
base backup progress
restore validation state
current RPO source
alternative healthy backup/replica

若空间即将耗尽,可以同时减少非关键写入,降低 WAL 增长率;这只是争取时间,不是 修复 retention owner。

slot 的有限上限也不是无损方案

max_slot_wal_keep_size 可以限制 checkpoint 时允许 replication slot 保留的 WAL; 超过可用范围时 slot 可能不再可继续使用,wal_status/invalidation_reason 会反映 状态。它是防止磁盘无限增长的风险取舍,不保证 consumer 无损恢复。配置前必须明确:

consumer maximum outage
WAL generation envelope
alert lead time
reseed procedure
accepted data-loss semantics

34.6.3 绝不手工删除 pg_wal;保护现场后转 ch21/ch28/ch35

为什么文件看起来“旧”也不能删

PostgreSQL 自己依据 checkpoint、recovery、归档和复制需要管理 WAL segment。文件名 顺序不能告诉操作者某段是否仍被 crash recovery、standby、backup 或 timeline history 需要。运行中用 rm 删除 pg_wal

  • 不会更新 control/catalog/slot 状态;
  • 可能让当前实例在 crash recovery 时缺日志;
  • 可能让 replica、PITR 或 backup 无法继续;
  • 会破坏最重要的事故证据;
  • 当前进程暂时继续运行,也不能证明下次 restart 可恢复。

正确做法是:

reduce noncritical WAL generation
preserve SQL + filesystem + archive evidence
identify exact retention owner
repair archive/consumer or establish a new recovery source
release only an exact, authorized object through PostgreSQL
verify recovery chain and filesystem headroom

路由到正确章节

  • XID、vacuum、freeze、膨胀:转第 28 章
  • 归档、备份、恢复链:转第 21 章
  • 已发生文件缺失、checksum/页/索引异常:保护现场,转 第 35 章
  • 错删/误写但物理集群健康、需要时间点恢复:结合 第 32 章

每次转交都带:

facts:
  system_identifier_timeline: ...
  current_and_retained_lsn: ...
  owner_consumer: ...
  archive_backup_state: ...
  growth_rate_time_to_full: ...
actions_already_taken: [...]
unknowns: [...]
forbidden_actions:
  - manual delete pg_wal
  - drop unknown slot

34.6.4 本章的一般止血动作不构成保留型修复

常见伪修复

动作 可能短期效果 为什么没修复保留边界
cancel 普通慢查询 降低 CPU/I/O 不一定命中持 xmin 的事务
限制新连接 减少 flow slot/归档边界仍不推进
增加磁盘 延后满盘 owner 与恢复链仍旧失效
重启数据库 清部分 session prepared xact/slot/归档问题仍在,且增加恢复风险
drop 所有 inactive slot 快速释放 WAL 破坏未知 consumer 的恢复能力
删除 WAL 文件 目录变小 数据库状态未修复,恢复链被破坏

增加磁盘在硬故障逼近时可以是合法的时间购买动作,但报告必须写:

temporary headroom gained
new estimated time to full
retention owner unchanged
permanent repair owner/deadline
rollback or capacity reconciliation

保留型成功标准

不是“磁盘百分比下降”,而是:

exact retention owner identified
oldest required horizon is advancing or deliberately re-established
consumer/archive/backup has a valid recovery path
released capability and accepted loss are recorded
WAL/XID growth rate returns inside envelope
replicas and PITR validation pass
temporary storage/traffic controls are reconciled

如果唯一能说的是“删完之后 PostgreSQL 还在运行”,事故尚未被安全恢复。


上一节:流量型止血动作 · 返回本章目录 · 下一节:平台级流量控制与证据 · 查看全书目录 · 查看索引中心

34.7 平台级流量控制与证据

Pigsty 把 PostgreSQL、Patroni、HAProxy、PgBouncer、监控和配置管理组合成平台。它提供 多个可以减压或隔离的控制点,但不会替操作者判断一致性语义,也不会把一条面板曲线 自动变成根因。

34.7.1 从服务端点隔离批处理和只读流量

端口背后是服务契约

Pigsty 的默认服务意图通常是:

服务 常见端口 默认目标 适用工作
primary 5433 当前主库的 PgBouncer 短 OLTP 读写
replica 5434 可读节点的 PgBouncer 可容忍副本语义的读
default 5436 当前主库 PostgreSQL 直连 管理、迁移、session-sensitive
offline 5438 offline/replica PostgreSQL 直连 受控 OLAP/ETL

这些是参考配置,不是 PostgreSQL 固有端口;必须以当前 inventory 与生成配置为准。 HAProxy 通常用 Patroni role endpoint 判后端资格,PgBouncer 再把大量 client connection 映射为受控 server connection。

正确隔离:

OLTP write -> primary pooled service
read-with-staleness-contract -> replica pooled service
long report/ETL -> offline service + separate budget
DDL/admin/session semantics -> controlled direct service

不正确隔离:

all SELECT -> replica, regardless of read-your-write
all long queries -> offline, regardless of shared storage/CPU
database slow -> send everything to every replica
primary service full -> bypass PgBouncer through direct service

direct service 是为需要 session/管理语义的受控客户端准备的路径,不是池满时的逃生 后门。若应用能无治理地从 5433 切到 5436,连接预算就失去意义。

路由变化也要验收数据语义

切到 replica/offline 后检查:

SELECT pg_is_in_recovery(),
       current_setting('transaction_read_only'),
       pg_last_wal_receive_lsn(),
       pg_last_wal_replay_lsn(),
       pg_last_xact_replay_timestamp();

还要用业务 token 验证 staleness/可见性。transaction_read_only=on 只证明 session 写保护,不证明读到了业务所需的新鲜数据。

34.7.2 用连接池、代理与应用控制点逐级减压

每层职责

application
  request priority, deadline, idempotency, retry budget

PgBouncer
  client waiting, server-pool concurrency, pool mode, reserve

HAProxy
  role-aware backend eligibility, connection limits, queue, drain

PostgreSQL
  hard backend limit, statement/lock/session guard, workload execution

越靠上越理解业务价值,越靠下越能保护数据库硬边界。完整防线要组合,而不是希望 PgBouncer 一层解决所有过载。

先采配置事实

在变更前保存:

inventory revision and rendered config hash
service frontend/backend mapping
HAProxy health predicate and backend state
PgBouncer pool mode and per-database/user pool settings
client/server/waiting counts
PostgreSQL max/reserved/current connections
application instance and local-pool counts

Pigsty 可以通过 database/user 定义映射连接池参数;修改应回到声明式 inventory 和 受控部署流程。事故中对运行时做临时操作时,必须记录 drift,并在稳定后选择:

promote the change into config-as-code
or explicitly revert runtime state

否则下次部署会“神秘地”覆盖救火配置。

减压顺序

1. application: reject/queue low priority and cap retry
2. scheduler: pause batch producers
3. pool: preserve OLTP/admin budget, constrain noisy class
4. proxy: drain ineligible or overloaded path when evidence supports it
5. database: exact cancel, timeout, role/session controls

不要同时把 proxy backend 摘除和把应用全部转 direct;前者减少一条路径,后者可能在 另一条路径绕过所有 pool 限制。

故障切换期间的额外风险

切换会让旧 server connections 断开,新主同时接受大量重连。连接池要在新主 admission 前限速,HAProxy health 收敛与 Patroni role 收敛也要分别观测。恢复后逐级 prewarm, 不要让所有 pod 同时填满 pool。第 19、20、33 章分别给出连接路径、HA 和故障切换的 完整证据模型。

34.7.3 从面板判断范围,再用 SQL 与主机证据判型

面板适合回答“哪里、何时、范围多大”

Pigsty 的 PostgreSQL 监控可以把 cluster、instance、database、query、connection、 WAL、replication、checkpoint 和 host 指标放到同一时间线。事故开场先用面板定位:

single query / database / instance / whole cluster?
primary only or replicas too?
started at deploy, backup, checkpoint, traffic event, or failover?
connections, CPU, I/O, WAL and user latency which changed first?
is the signal rising, flat, oscillating, or recovering?

面板是索引,不是最终证据。采样、聚合、label 和 exporter 失败都可能造成误解;告警 静默也可能是监控链坏了。

回到原生 SQL

流量型最小 SQL:

pg_stat_activity by app/state/wait
pg_blocking_pids root tree
pg_stat_statements workload deltas
pg_stat_database temp/deadlock/session deltas
pg_stat_io by backend/context

保留型最小 SQL:

backend_xid/backend_xmin
pg_prepared_xacts
pg_replication_slots xmin/catalog_xmin/restart_lsn/status
pg_stat_archiver
replication receive/replay LSN
database/relation freeze age

SQL 再与主机证据互证:

filesystem mount and inode
device latency/queue/errors
memory pressure/swap/OOM
CPU run queue/steal/throttle
kernel and service events

时间与身份要可关联

每份证据包含:

captured_at_utc: ...
observer: ...
cluster_instance_database: ...
query_or_command_version: ...
source_config_revision: ...
redaction: ...

SQL 快照不要导出不必要的完整 query、口令、连接 URI 或业务数据。监控截图要保存 panel 时间窗、timezone、变量和 dashboard revision;否则复盘时无法重现。

平台动作的验收

Pigsty/HAProxy/PgBouncer state
  says intended route/pool changed

PostgreSQL
  proves actual role, session population, wait and retention state

host
  proves resource pressure changed

synthetic/business probe
  proves user-facing contract recovered

四层有冲突时保留冲突,不要挑最漂亮的图作为结论。


上一节:保留型故障的安全路由 · 返回本章目录 · 下一节:实战:同一症状、两种成因 · 查看全书目录 · 查看索引中心

34.8 实战:同一症状、两种成因

本实验不给操作者“连接风暴实验”和“WAL 实验”两个有答案的按钮。runner 随机安排 两种根因、生成无语义 case ID,classifier 只能看证据:

common alert: postgresql-resource-headroom-at-risk

case opaque-A:
  connection / retention / engine / filesystem observations

case opaque-B:
  connection / retention / engine / filesystem observations

hidden answer 在分类完成后才用于验收。这样练习的是判型,不是背剧本顺序。

34.8.1 随机注入连接风暴或 WAL 保留

先读实验合同

先做不连接远端的静态检查:

static/labs/ch34/task.sh lint

只读现场采集:

export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch34-capture.XXXXXX"
)"
static/labs/ch34/task.sh capture

它读取 managed pg-test 的 Patroni 成员、primary system identifier/timeline、连接与 replication slot 投影,并确认 pg-test-3 没有残留实验 root。它不执行 SQL 写入、 cancel、slot、service、route 或 DCS 变更。

完整演练 guard

export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch34.XXXXXX"
)"
export PG36_CH34_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH34_NONPRODUCTION=true
export PG36_CH34_PRODUCTION_DATA=false
export PG36_CH34_PRODUCTION_TRAFFIC=false
export PG36_CH34_CONFIRM=BLIND_FLOW_VS_RETENTION_CH34

static/labs/ch34/task.sh drill:overload

任一 guard 不符,runner 在创建远端目录前退出。evidence directory 已包含文件也会被 拒绝,避免混合两次 run。

隔离引擎

在线 fault 不发生在 managed PostgreSQL,而是在 pg-test-3 创建:

/tmp/pg36-ch34-overload-<uuid>/
  .pg36-ch34-owned
  data/
  socket/
  postgres.log

关键限制:

PostgreSQL 18.6
listen_addresses=''
Unix socket mode 0700
port 55444 only names the private socket
max_connections=24
superuser_reserved_connections=3
max_replication_slots=4
checksums=on
not a Patroni/DCS/HAProxy/PgBouncer member

实验不注入 OOM、不填满文件系统、不 drop cache,也不执行错误动作。无论正常或失败, 只在 marker 与 exact UUID root 匹配后停止临时 postmaster 并删除整个一次性目录。

两个真实 fault

FLOW:

30 non-superuser psql clients
same advisory lock
one holder sleeps for 20 seconds
other admitted clients wait on Lock
excess clients hit the connection boundary

RETENTION:

one exact inactive physical replication slot
immediately reserved restart_lsn
bounded WAL generation
stop after retained >= 32 MiB
hard cap 128 MiB

max_slot_wal_keep_size=-1 只在这个一次性实例中用于稳定展示保留机制,绝不是生产推荐 值。

34.8.2 在不知道答案时先判型,再选择动作

classifier 唯一允许读取的字段

{
  "case_id": "opaque",
  "observed_at": "UTC",
  "connection": {
    "observed_sessions": 0,
    "connection_rejections": 0,
    "lock_waiters": 0
  },
  "retention": {
    "inactive_physical_slots": 0,
    "retained_wal_bytes": 0
  },
  "engine": {},
  "filesystem": {}
}

判定合同:

FLOW =
  observed_sessions >= 18
  AND connection_rejections >= 1
  AND lock_waiters >= 1
  AND inactive_physical_slots = 0

RETENTION =
  inactive_physical_slots = 1
  AND retained_wal_bytes >= 33554432
  AND connection_rejections = 0

both or neither =
  STOP_AND_INVESTIGATE

classifier 不读取 hidden-answers.json。validator 会比较 case identity set、字段完整性、 classifier provenance 与 hidden answer;blind packet 若带 truthexpected_route 字段反而判失败。

正式 run

公开证据: overload-run.json

run id         1ae188cd-e7f2-4724-98d8-fe166cf4e33f
random order   RETENTION -> FLOW

WAL case 先出现,但 classifier 没把“第一个”解释成 flow:

inactive physical slots     1
retained WAL       42,611,296 bytes
connection rejects          0
route        PRESERVE_RETENTION_EVIDENCE

connection case:

attempted clients           30
observed sessions           21
connection rejects           9
lock waiters                20
inactive physical slots      0
route           RELIEVE_FLOW_PRESSURE

24 个 max_connections 减去 3 个 superuser reserved slots,正好留下 21 个普通 admission; 其余 9 个被拒绝。20 个 lock waiter 加 1 个持锁/睡眠 session 又构成 21 个已进入会话。 这是该临时配置下的可解释闭环,不应外推为任何生产集群连接预算。

34.8.3 对流量型恢复服务,对保留型完成安全路由

FLOW 动作

runner 用 run-specific application prefix:

pg36-ch34-flow-<run-prefix>-<client-number>

只对匹配 prefix 的 backend 执行 pg_cancel_backend。正式结果:

cancel signals sent                    21
broad cancel used                   false
max_connections changed             false
fallback client terminate/kill        0/0
post fixture sessions                   0
post SQL probe                          1

若 backend 没在 deadline 内退出,唯一 fallback 是 runner 直接持有的 exact child process; 不会扫描或终止主机上的其他 psql,更不会触碰 managed pg-test

生产映射不是“按 prefix 杀 21 个会话”,而是:

identify admission class and owner
stop producer/retry
preserve admin headroom
cancel exact expired/low-value work
verify queue and useful completion

RETENTION 动作

runner 先保存:

slot_name/type/active
restart_lsn
retained_wal_bytes
wal_status/safe_wal_size/invalidation_reason
filesystem projection

然后只 drop 带本 run identity 的 disposable slot:

scope                     exact-owned-disposable-slot
evidence preserved        before action
manual pg_wal deletion    false
post physical slots       0
post SQL probe            1

生产上的 inactive slot 没有这份授权。对应动作是联系 owner、确认 consumer/backup/RPO, 恢复消费或建立新的恢复起点,再由明确负责人批准 exact release。

managed 边界复核

before/after 都要求:

pg-test-1 primary running
pg-test-2/3 replica streaming
system identifier unchanged
timeline 19 unchanged
chapter fixture sessions 0
matching disposable roots []

公开报告因此写的是 managed mutations=0,而不是声称在 Pigsty managed cluster 上 验证了饱和行为。

34.8.4 输出动作时间线、误判代价与容量改进项

evidence bundle

before.json
exercise/
  run-manifest.json
  source-manifest.json
  exercise-evidence.json
  blind-packets.json
  hidden-answers.json
  cleanup.json
classification.json
after.json
validation-report.json
negative-report.json
public-summary.json
review.txt

已有完整证据包可重复做只读校验:

export PG36_EVIDENCE_DIR=/absolute/evidence/ch34/run
static/labs/ch34/task.sh all

validator 将 12 个实验 source file 与 SHA-256 绑定,并实际构造 34 个 mutant。反例覆盖:

production/managed mutation guard opened
scenario or classifier contract weakened
blind packet leaks truth or misses fields
classification duplicated/unsupported/wrong
flow evidence below threshold or broad cancel
retention slot/bytes/active state falsified
manual WAL deletion or leftover slot claimed
cleanup root left behind
production gate falsely approved

34 个都必须被拒绝;声明有 34 条 JSON 并不等于做了对抗验证。

误判代价

误判 结果
flow 当 retention 不限制 admission,连接失败与队列继续
retention 当 flow cancel session 不推进 restart_lsn,WAL 继续增长
unknown 强判 flow 可能 terminate 关键/大事务,并留下既有 WAL、死版本与后续清理压力
unknown 强判 retention 可能 drop 有效 slot、破坏 consumer/RPO
手工删 pg_wal 破坏 crash recovery、复制、备份与现场证据

因此 unknown route 不是实验的“第三种错误答案”,而是证据不足时唯一正确的动作类别。

从实验回写容量控制

这次结果至少导出四个可验证改进:

  1. 为普通连接显式保留 admin/reserved budget,并让 pool 上限小于硬边界;
  2. 对 application pool 总和、retry 与启动 prewarm 做全局预算;
  3. 对每个 slot 记录 owner、active、restart/catalog xmin、retained bytes 与推进率;
  4. 告警同时包含“当前余量”和“增长率/耗尽时间”,并带 FLOW/RETENTION 判型链接。

不能从这次实验导出:

production max_connections should be 24
every inactive slot may be dropped after 32 MiB
42 MB WAL implies a disk incident
exact cancel always has zero fallback
managed Pigsty can tolerate the same storm

最终门禁保持:

production_ch34_gate=pending

上一节:平台级流量控制与证据 · 返回本章目录 · 下一章:数据抢救与工程取证——起死回生 · 查看全书目录 · 查看索引中心

35 数据抢救与工程取证——起死回生

当数据库报告 checksum failure、invalid page、索引不一致或 collation version mismatch, 目标不再是“让错误消失”,而是回答四个必须分开的问题:

detect
  哪个对象、块或不变量出现异常?检测覆盖什么、没有覆盖什么?

preserve
  哪份是原始证据,哪份是可信恢复来源,怎样证明它们没有被改写?

recover
  应重建派生对象、从健康来源恢复,还是只能分段抽取可读数据?

validate
  数据库能启动之外,业务不变量、恢复链与故障域是否重新可信?

“起死回生”不是在唯一副本上尝试越来越危险的参数。专业抢救的第一动作通常是停止 新增写入、保存原始现场、制作可重复的操作副本,并让每次尝试都从同一个证据点分叉。

学习完成标准

完成本章后,读者应能:

  1. 区分物理页/存储异常、索引/排序规则派生异常与业务语义损坏;
  2. 解释 detection、repair、extraction、rebuild 为什么是四种不同工作;
  3. 为停写、只读、storage snapshot、PostgreSQL backup 与 clone 写出一致性边界;
  4. 用 SHA-256、只读介质、操作日志和副本树维护工程级 chain of custody;
  5. 记录 system identifier、timeline、版本、extension、locale/ICU、硬件与最近变更;
  6. 解释 data checksum 保护的是 data page,不覆盖 temp file、所有内部结构或业务语义;
  7. 正确使用 SHOW data_checksums 与离线 pg_checksums --check
  8. 用日志中的 relation/block 线索和 pg_relation_filepath 定位对象,但不手改文件;
  9. 判断存储、内存、内核或文件系统仍不可信时,应先修基础设施;
  10. 区分 bt_index_checkbt_index_parent_checkheapallindexedverify_heapam
  11. 评估 amcheck 的锁、I/O、CPU、内存、隐私与 hot-standby 限制;
  12. 识别 collation provider/version drift,并按“重建依赖对象后刷新版本”处置;
  13. 解释“索引可重建”为什么不能证明 heap 与业务数据安全;
  14. 按备份、健康副本、逻辑来源、分段抽取的优先级选择恢复源;
  15. ignore_checksum_failurezero_damaged_pagesignore_invalid_pagespg_resetwal 限定为克隆现场的最后手段;
  16. 写出停止自救、升级厂商/文件系统/硬件/数据库专业支持的触发线;
  17. 区分“能启动、能查询、结构一致、业务可信、可恢复”五个验收层次;
  18. 为不可恢复范围、估计方法、法律/合规通知和客户沟通保留证据;
  19. 在盲测中区分 1-bit heap page 异常与 collation-derived mismatch;
  20. reset:host 当作需独立审批的 L3 重建风险类别,而不是普通维护命令。

三类损坏,三种恢复来源

类别 典型证据 主要恢复来源 不应默认做
物理 checksum、invalid page、I/O error、块读取失败 可信 backup、健康副本、存储 snapshot、可读抽取 原地改页、删文件、忽略错误继续写
派生 amcheck、collation version、错误索引结果 heap/源表 + 正确规则重建 把 REINDEX 当作 heap 已安全
语义 约束外不变量、ledger/事件/业务对账 PITR、审计日志、上游事实、补偿 用物理工具“修”业务写错

分类可以重叠。一次坏内存写可能同时损坏 heap 和 index;错误 ICU 版本可能让结构在 不同节点上被不同比较规则解释;应用误写也可能生成完全合法的 page/checksum。遇到 冲突证据应扩大范围,不要强选最便宜的修法。

信任阶梯

process started
  < SQL endpoint responds
  < physical/structural checks pass
  < relational and business invariants pass
  < replica/archive/backup lineage passes
  < observation window remains clean

每一层只能证明自己的命题。pg_ctl start 成功不能证明索引返回正确结果; pg_checksums 全绿不能证明 collation、约束外余额或外部副作用正确; 业务抽样正确也不能证明每个 relation block 都可读。

正式实验

本章对 managed pg-test 只做 before/after L0 read-only capture。真实 fault 全部在 pg-test-3 的一次性 PostgreSQL 18.6 中:

/tmp/pg36-ch35-forensics-<run-id>
private Unix socket
data_checksums=on
12,000-row deterministic fixture
stopped known-good snapshot
separate case and working copies

runner 随机安排两个 blind case,正式顺序为:

COLLATION_METADATA -> PHYSICAL_HEAP_PAGE

第一例只在 disposable catalog 中把 exact ICU collation stored version 从 153.121 改为 run-specific fake value:

offline bad checksums        0
amcheck structural pass   true
version mismatch          true
route       REINDEX_AND_REFRESH_COLLATION
repair order  REINDEX -> REFRESH VERSION
repair elapsed            267.719 ms
business invariants match true

它模拟的是版本元数据不一致,不冒充真实 ICU 排序语义升级。

第二例在 stopped clone 上只翻转 heap block 2 中一个 byte:

bytes changed                 1
offline bad checksums         1
online sequential scan    XX001
route       RESTORE_FROM_KNOWN_GOOD_COPY
in-place repair           false
recovered bad checksums       0
business invariants match true

两个 mutated original case 在各自恢复期间保持逐文件 digest 不变,known-good snapshot 也保持不变;最后停止所有临时 postmaster 并删除 exact root。managed PGDATA、服务、 路由、Patroni/DCS 和 reset:host 均未触碰。

验证器构造并拒绝 35 个真实 mutant,包括打开生产/managed 边界、弱化 checksum 阈值、 blind evidence 泄露答案、原地修复、REFRESH 先于 REINDEX、业务 digest 漂移和谎报 清理成功。公开证据见 rescue-run.json

本章边界

正式实验没有:

  • 注入真实磁盘、控制器、内存、内核或文件系统故障;
  • 模拟真实 ICU/libc 比较语义改变;
  • 在唯一副本或 managed PGDATA 上改一个字节;
  • 使用 ignore_checksum_failurezero_damaged_pagespg_resetwal
  • 执行 Pigsty host 移除、重装或生产恢复。

因此它证明的是机制与证据链,不是生产数据可恢复比例或 host rebuild RTO。最终门禁 保持 production_ch35_gate=pending

阅读前后关系

本章目录

35.1 现场保护与操作边界

35.2 先分类再抢救

35.3 页与 checksum 证据

35.4 索引、collation 与 amcheck

35.5 抽取、跳过与重建策略

35.6 工程取证与业务验证

35.7 实战:在克隆环境分类并恢复

权威参考

PostgreSQL:

Pigsty:


上一章:过载保护与资源故障判型——李代桃僵 · 返回下卷导读 · 下一章:事故复盘、控制固化与平台演进——举一反三 · 查看全书目录 · 查看索引中心

35.1 现场保护与操作边界

数据损坏现场与普通性能事故不同:每一次 checkpoint、vacuum、restart、reindex、文件 删除甚至查询,都可能改变证据或覆盖本来还能读取的内容。第一目标是建立一个不再 变化的证据点,不是赶紧让告警变绿。

35.1.1 停写、只读、快照、克隆与证据哈希

先冻结新增变量

按影响面与 authority 选择:

stop application writes at admission
drain write service routes
revoke/disable affected writer credentials when authorized
place exact application in maintenance/read-only mode
fence a suspect instance from accepted database authority
stop PostgreSQL cleanly when evidence and recovery design require it

“只读”要区分三层:

能证明什么 不能证明什么
应用只读 正常业务不发写请求 运维、直连、后台任务不会写
数据库 transaction read-only 该 session 不执行普通写 所有 session/后台都停止
存储 snapshot/read-only mount 捕获某个块状态 snapshot 一定应用一致、源存储健康

不要把 promotion 当作“自动停写旧主”。若故障域是共享存储或坏内存,健康副本也可能 已接收同一损坏;若旧主没有 fence,切换还会增加两个可写历史的风险。

快照有一致性等级

crash-consistent
  相当于某一瞬间掉电,必须有完整 PGDATA + tablespace + WAL,启动时 recovery

application-consistent
  数据库与外部系统的业务 checkpoint/ledger 一致

clean-stopped
  PostgreSQL clean shutdown 后复制,适合离线工具与确定性实验

文件系统/storage snapshot 只有在覆盖所有 tablespace、WAL 路径与必要元数据,且写入 顺序语义可靠时,才可作为 crash-consistent PostgreSQL 副本。只复制 base/ 不是 PGDATA snapshot。

本章正式实验使用 clean-stopped known-good snapshot。生产未必有条件 clean stop; 那时应保留 crash-consistent snapshot,并在克隆上让 PostgreSQL recovery。

原始、来源、操作副本分开

original evidence
  发现异常时的最早可保存状态;只读、封存

known-good source
  经备份/副本/快照验证的恢复来源;只读、封存

working copy A/B/C
  每项假设从同一 source/evidence 分叉,可丢弃

不要让“原始损坏副本”与“可信恢复源”共用一个标签 backup。前者用于解释发生了什么, 后者用于恢复正确状态。

哈希证明什么

对封存文件树记录:

algorithm: SHA-256
relative path
size
digest
capture UTC
source device/snapshot identity
collector/tool version

哈希相同只证明两次计算之间字节相同,不证明第一次采集时就正确,也不证明未遗漏 tablespace、WAL 或外部对象。manifest 本身也应签名/受控保存,且不能把口令、密钥、 原始业务数据无界导出到普通 evidence 目录。

35.1.2 记录硬件、内核、日志、版本和最近变更

建立同一 UTC 时间线

至少记录:

identity:
  cluster_system_identifier: ...
  timeline_and_lsn: ...
  instance_host_storage_ids: ...
software:
  postgres_server_and_binary: ...
  extensions: ...
  os_kernel_libc_icu: ...
  filesystem_storage_firmware: ...
configuration:
  data_checksums: ...
  full_page_writes: ...
  locale_collation_versions: ...
  tablespaces_and_wal_paths: ...
events:
  first_user_symptom: ...
  first_postgres_error: ...
  kernel_storage_memory_events: ...
  deploy_upgrade_failover_backup: ...
  power_or_hypervisor_event: ...

日志要保留原时区、sequence/journal cursor、rotation 边界与原始文件哈希。摘抄一行 invalid page in block 7 会丢掉前后的 I/O error、backend identity 和 relation context。

版本是故障证据

以下差异都可能改变解释:

  • PostgreSQL major/minor 与启动该 PGDATA 的 binary;
  • extension shared library 与 SQL extension version;
  • libc/ICU/tzdata;
  • filesystem/kernel/storage firmware;
  • primary 与 standby 的 OS/collation provider;
  • backup/restore 工具和 repository format。

不要用“应该都是 PG18”代替实际 server_version_num、package build 和 binary hash。 同 major 的不同操作系统镜像也可能带不同 ICU/libc。

最近变更不等于根因

变更与首个症状相邻,只是候选因果:

change:
  at: ...
  scope: ...
  expected_effect: ...
supports:
  - exact evidence
contradicts:
  - exact evidence
experiment_on_clone:
  - falsifiable check

硬盘 error 同时出现在升级后,不应因为“刚升级”就忽略设备证据;反之硬件告警也可能 是读取已存在坏页时才触发。

35.1.3 不在唯一副本上反复试错

每次尝试都会消耗选择权

start/recovery        may replay WAL and update control state
SELECT                may set hint bits or expose more damaged pages
VACUUM                may remove versions and update visibility maps
REINDEX               replaces derived evidence
pg_checksums --enable rewrites relation blocks in place
zero_damaged_pages    discards all tuples on a page in memory
pg_resetwal           invents WAL/control continuity

因此工作树应是:

evidence E0 (immutable)
  ├─ clone H1: test hardware/filesystem interpretation
  ├─ clone P1: PostgreSQL structural checks
  ├─ clone X1: best-effort logical extraction
  └─ clone R1: candidate recovery

H1 失败后重建 H2,不在 H1 上连续叠加参数。每个 clone 保存输入 hash、动作顺序、输出、 退出状态与结论。

“只有一份”时怎么办

若无法制作 snapshot/clone:

  1. 停止非必要写入和自动恢复;
  2. 记录为什么不能复制(空间、设备状态、加密、访问权);
  3. 评估块级镜像/专业数据恢复而非数据库内试错;
  4. 明确每个读取会否加剧介质故障;
  5. 升级事故级别和专业支持;
  6. 由数据 owner 明确接受任何不可逆动作。

时间压力不增加数据副本。越接近唯一来源,越应减少实验。

35.1.4 绝不手工删除 pg_wal 或原始损坏文件

不要用文件系统动作伪装修复

pg_wal、relation segment、visibility/free-space map、control file 与 tablespace symlink 共同构成 PostgreSQL 状态。手工删除看似坏掉或“旧”的文件:

  • 不更新 catalog/control/WAL recovery 语义;
  • 可能把局部损坏扩大为数据库无法启动;
  • 破坏 replica/PITR/backup 所需 lineage;
  • 覆盖或消灭故障根因证据;
  • 让后续专业工具无法比较原始字节。

即使某个损坏 index 最终可重建,也先保存其 identity、size、hash、错误与依赖关系, 再在 working copy 或受控生产变更中用 PostgreSQL DDL 重建;不要直接 rm index relation file。

自动化也会改现场

临时隔离后检查并暂停可能的自动动作:

Patroni restart/reinit/failover
systemd restart policy
Kubernetes liveness restart
filesystem repair/fsck
cloud auto-replace
backup retention/prune
logrotate and core cleanup
autovacuum/maintenance jobs
monitoring remediation

不是所有自动化都要停,而是必须知道哪些会写现场,并由事故指挥统一决定。

现场保护完成定义

accepted writer stopped or isolated
original evidence identity and hash recorded
known-good recovery candidates named
at least one working copy available, or copy blocker escalated
automatic mutators inventoried
time/version/hardware/change evidence preserved
destructive actions and owners explicitly gated

完成这些才进入下一节的分类。


返回本章目录 · 下一节:先分类再抢救 · 查看全书目录 · 查看索引中心

35.2 先分类再抢救

“corruption”不是一个恢复方案。至少要区分:存储的字节/页是否错误、从源数据派生的 结构是否错误、业务意义是否错误。检测工具、恢复来源和可接受损失都不同。

35.2.1 物理页、存储与 checksum 错误

物理异常的证据链

典型入口:

checksum mismatch
invalid page / invalid page header
could not read/write block
short read
I/O error / filesystem remount read-only
storage medium error / controller reset
unexpected relation segment size or missing file

这些信号仍需定位:

one block / one relation / one tablespace / one device / many hosts
heap / index / toast / catalog / WAL / control
primary only / replica too / backup copy too
read-time detection / write-time failure / recovery-time failure

checksum failure 说明“读出的 data page 与页内 checksum 不一致”,不自动说明是磁盘: 坏内存、DMA/controller、虚拟化、filesystem、软件 bug 或离线篡改都可能改变字节。

交叉副本不能只比较 SQL 行

在相同 system identifier/lineage 下,可比较:

same relation identity and logical object
same or corresponding block/LSN context
primary, replica, backup and storage snapshot
kernel/storage errors on each host

物理 replica 可能从 primary 接收已经损坏的逻辑变化,也可能各自发生局部介质损坏; 共享存储/镜像又可能让“多个副本都有同样错误”并不独立。恢复源要有独立故障域与实际 校验,不是名字叫 replica 就可信。

默认路线

preserve corrupt original
verify hardware/storage path
identify last known-good source
restore/reseed a new working copy
validate physical + logical + business state

只有在没有健康来源且明确接受损失时,才考虑 best-effort extraction。

35.2.2 索引、排序规则与派生结构错误

派生结构可以重建,但先证明 source

index、materialized view、search vector、rollup 与 cache 都由别的状态生成。典型信号:

amcheck raises B-tree invariant error
same predicate gives seqscan/indexscan different rows
unique index behavior conflicts with expected equality
collation stored version differs from provider actual version
OS/ICU upgrade changes comparison rules
primary and standby use different provider versions

先关闭 planner 路径做诊断时,只在 clone 或有界 read-only 查询里进行:

BEGIN READ ONLY;
SET LOCAL enable_indexscan = off;
SET LOCAL enable_indexonlyscan = off;
SET LOCAL enable_bitmapscan = off;
-- bounded invariant query
ROLLBACK;

这只能帮助比较,不是修复,也不能保证 seqscan 本身绕过 heap 损坏。

collation 是外部语义依赖

collation object 把 SQL 名称映射到 libc 或 ICU provider。text B-tree 的顺序依赖比较 规则;provider 升级后,旧 index 中 tuple 的物理顺序可能不再满足新比较规则。仅更新 collversion 会消除 mismatch 提示,却不会重新排列已有 index。

因此安全顺序是:

inventory affected collation dependencies
  -> verify on clone
      -> REINDEX affected indexes/materialized structures
          -> validate
              -> REFRESH VERSION metadata

数据库级 default collation 还会影响更多对象;要从 catalogs 枚举依赖,不能只重建 报错的第一条 index。

派生异常也可能来自 heap

heapallindexed 发现 heap tuple 没有对应 index tuple 时,原因可能是 index 本身,也 可能是 heap/visibility/transaction state 异常。REINDEX 后 amcheck 通过只是一个信号; 还要验证 heap、toast、constraints 和业务 invariants。

35.2.3 逻辑不变量、应用写错与语义损坏

checksum 全绿也可能数据完全错误

UPDATE accounts SET balance = 0;
wrong tenant predicate
duplicate external event applied twice
currency unit conversion error
missing ledger entry
referential rule enforced only in application
out-of-order CDC correction

PostgreSQL 会把这些合法写入按 WAL、checksum 和 replication 正确保存。物理工具不会 知道“余额不应为负”或“订单总额应等于明细”。

业务不变量应提前定义:

-- 结构约束能表达的尽量进入数据库
ALTER TABLE account
ADD CONSTRAINT balance_domain CHECK (balance >= 0);

跨行、跨表、跨系统不变量需要 reconciliation:

row count by partition/tenant
sum/count/min/max with known semantic
ledger debit = credit
event id uniqueness and sequence
source-system vs database token
snapshot + change-log continuity

MD5/SHA digest 适合证明同一有序投影是否相同,不证明投影本身业务正确;必须绑定 SQL、 排序、NULL/encoding 与 snapshot time。

恢复路线

  • 错误发生时间明确且需整体回退:PITR 到隔离实例,再做差异恢复;
  • 有审计/事件/ledger:按稳定业务 ID 重放或补偿;
  • 上游系统为事实源:重新同步并核对删除/版本;
  • 局部错误且可逆:受控 correction transaction;
  • 外部副作用已经发生:数据库恢复之外做业务补偿。

不要在原实例上反复 PITR。第 32 章的恢复目标、timeline 与 replay 验证仍适用。

35.2.4 三类问题的恢复来源不同

决策矩阵

已证明的异常 首选 source 典型动作 核心验收
heap data page verified backup/replica/snapshot restore/reseed new copy checksum + rows + business
index only verified heap REINDEX exact dependency amcheck + query equivalence
collation drift heap + intended provider/version reindex then refresh dependency + order + invariants
materialized/derived authoritative base tables/events rebuild/refresh source-to-derived reconciliation
app wrong write PITR/audit/event/upstream recover/diff/compensate business invariants
multiple/unknown immutable evidence + expert analysis stop and escalate missing facts resolved

分类置信度

不要把一个工具的结果写成绝对结论:

classification: physical-page
confidence: high
supports:
  - data_checksums=on
  - offline pg_checksums bad=1
  - scan raises XX001 on exact heap
contradicts:
  - none observed
not_proven:
  - root hardware component
  - other pages are clean
  - backups are independent and usable

pg_checksums 找到 1 个坏 checksum,不等于整个集群只有 1 处问题:可能还有未覆盖对象、 业务语义损坏或未来才读出的设备错误。反过来全绿也只覆盖被扫描的 data pages。

停止线

以下任一出现,路线转 STOP_AND_ESCALATE

  1. system identifier、timeline 或 binary/PGDATA 归属不明;
  2. 原始 evidence 已被多次写入,动作时间线无法复原;
  3. checksum、amcheck、业务结果相互矛盾;
  4. 所有已知 backup/replica 可能共享故障;
  5. 唯一副本无法安全克隆;
  6. 需要 zero_damaged_pagesignore_invalid_pagespg_resetwal 才能继续;
  7. 数据损失范围涉及监管、财务、隐私或安全事件。

上一节:现场保护与操作边界 · 返回本章目录 · 下一节:页与 checksum 证据 · 查看全书目录 · 查看索引中心

35.3 页与 checksum 证据

PostgreSQL data checksum 是发现部分静默字节变化的重要机制,但不是万能完整性证明。 理解其单位、触发时机和未覆盖面,才能正确解释一次成功或失败的检查。

35.3.1 checksum 是否启用及其检测边界

先确认,而不是假设

在线只读确认:

SHOW data_checksums;

SELECT data_page_checksum_version
FROM pg_control_init();

data checksum 是 cluster 级属性,不按 database/table 单独启用。启用时,每个 data page 在写入时更新 checksum,读取时校验。PostgreSQL 18 上游 initdb 默认启用 checksum, 但 --no-data-checksums 或部署工具的显式选项仍可关闭;因此生产判断必须读取实际 control state,不能从版本号反推。

离线检查:

pg_checksums --check --pgdata=/exact/clone/pgdata

官方要求 PostgreSQL clean shutdown 后运行 pg_checksums。返回码为 0 表示本次 扫描没有 checksum error,非 0 表示至少检测到一次失败。不要只解析人类输出而忽略 exit status,也不要对正在运行或有其他 writer 的 PGDATA 执行。

如需限定已知 relation filenode,可用:

pg_checksums --check \
  --filenode=16395 \
  --pgdata=/exact/clone/pgdata

这适合复验定位,不替代 full-cluster scan。 filenode 数字本身不包含 database/tablespace 身份,证据中仍要同时保存 catalog 对象 与完整相对路径,不能把这个过滤参数当作全局唯一对象标识。

覆盖与不覆盖

checksum 保护 data pages,但官方明确不覆盖:

  • internal data structures 的全部状态;
  • temporary files;
  • 业务语义与跨行不变量;
  • 是否遗漏了整个 relation/file;
  • collation/operator class 规则改变;
  • 被正确重写成错误内容的合法页面。

它也不是纠错码:能发现不一致,不能根据 checksum 自动恢复原字节。

读时检测的含义

在线读取坏页通常中止当前 transaction。若 page 仍在 shared buffer 且损坏发生在存储 副本上,何时重新从设备读会影响首次发现时间;因此“昨天没报错”不证明昨天设备上 没有坏块。离线全扫与经过业务访问的在线读覆盖不同。

ignore_checksum_failure=on 只会在报告 warning 后尝试继续;官方警告它可能导致 crash、传播或隐藏损坏。它不是高可用开关,本章实验保持 off。

35.3.2 日志、块号、关系文件与物理定位

从数据库 identity 映射到文件

对还可查询的 clone:

SELECT c.oid,
       n.nspname,
       c.relname,
       c.relkind,
       c.relfilenode,
       pg_relation_filenode(c.oid) AS current_filenode,
       pg_relation_filepath(c.oid) AS relative_path,
       pg_relation_size(c.oid) AS bytes
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.oid = 'public.target_table'::regclass;

使用 pg_relation_filenode/path,不要假设 OID 等于当前 filenode。TRUNCATE、REINDEX、 某些 ALTER/rewrites 会更换 filenode;tablespace 又改变相对路径。

PostgreSQL 默认 block size 常见为 8192,但应从实际 control data确认:

SELECT database_block_size
FROM pg_control_init();

日志块号 $b$ 对应 relation fork 内的字节范围:

[b×B, (b+1)×B) [b \times B,\ (b+1)\times B)

其中 $B$ 是实际 block size。这只是定位数学,不是授权用十六进制编辑器修改该范围。

先确定 fork 与 segment

大 relation 会分成 segment;relation 还有:

main fork
_fsm free space map
_vm visibility map
_init unlogged initialization fork
TOAST relation and indexes

错误路径、block 和 relation identity 一起保存。index 的 block 2 与 heap 的 block 2 不是同一数据;同名 relation 在不同 database/tablespace 也不同。

日志与 SQLSTATE

保留:

SQLSTATE
severity
relation/database/backend identity if available
block number and file path
statement/query_id with privacy controls
UTC and log sequence
preceding kernel/storage messages

本章正式 bit-flip case 的在线顺序扫描返回 XX001,但教材没有把 raw stderr 导出, 因为错误文本可能带 object/query 信息。生产证据可在受控位置保存完整原日志,再制作 去敏投影用于协作。

物理定位不等于根因定位

找到 base/5/16395 block 2 只说明错误出现在哪里。根因仍可能在:

memory / CPU
filesystem / volume / controller / drive
hypervisor / cloud storage
DMA / firmware
PostgreSQL or extension bug
offline operator action
backup/restore transfer

需要跨层证据,而不是仅替换这一文件。

35.3.3 存储故障先修基础设施,再谈数据库重建

不要把健康数据恢复到坏底座

如果设备仍报告 error、filesystem 不稳定、memory test 未过或 hypervisor path 不可信, 在原主机恢复 backup 可能再次损坏干净数据。安全路线:

fence suspect host/storage from authority
preserve device and filesystem evidence
provision verified clean failure domain
restore/reseed from verified source
validate before accepting traffic
keep suspect media isolated for analysis

RAID rebuild、filesystem repair、cloud volume detach/attach 都会改变现场,应由对应专业 团队纳入同一时间线。数据库团队不要在唯一卷上自行执行 fsck -y

健康底座的验收

hardware/controller/drive diagnostics
kernel error-free observation window
filesystem consistency and mount semantics
memory/CPU health
power and time synchronization
firmware/driver/package baseline
write durability assumptions
independent backup/restore test

“新 VM”也不自动独立:它可能仍使用相同宿主机、storage pool、image 或坏备份。

重建后仍要扫描

新副本需要:

clean PostgreSQL startup/recovery
pg_checksums on a clean-stopped clone or scheduled validation
amcheck by risk-ranked object set
business invariants and query equivalence
replication/archive/backup health
service role and endpoint identity
monitoring and alert observation window

若从 physical backup 恢复,损坏页可能被原样复制;“restore 成功”只说明工具完成传输。 第 21 章的 restore drill 要与本章的 integrity checks 组合。

本节结论

checksum error
  -> page evidence
  != root component
  != automatic data-loss estimate
  != permission to edit/delete

下一节处理 checksum 可能发现不了、却会让查询返回错误答案的派生结构。


上一节:先分类再抢救 · 返回本章目录 · 下一节:索引、collation 与 amcheck · 查看全书目录 · 查看索引中心

35.4 索引、collation 与 `amcheck`

data checksum 可以证明 page 字节与页内 checksum 是否一致,却无法证明 B-tree 的 tuple 顺序、parent/child link、heap-to-index coverage 或比较规则仍然一致。amcheck 针对的是 relation 的结构与逻辑不变量,两者互补。

35.4.1 bt_index_checkheapallindexed 与锁成本

选择正确强度

安装 supplied extension:

CREATE EXTENSION IF NOT EXISTS amcheck;

单个 B-tree 轻量检查:

SELECT bt_index_check(
  index => 'public.orders_created_idx'::regclass,
  heapallindexed => false,
  checkunique => false
);

检查 heap tuple 是否都有 index 表示:

SELECT bt_index_check(
  index => 'public.orders_created_idx'::regclass,
  heapallindexed => true,
  checkunique => false
);

更全面的 parent/child 与 root descent:

SELECT bt_index_parent_check(
  index => 'public.orders_created_idx'::regclass,
  heapallindexed => true,
  rootdescend => true,
  checkunique => false
);

函数返回 void;没有抛错表示本次所检查的不变量未发现异常,不是“整个数据库完全 正确”。

锁与副本限制

PostgreSQL 18 官方边界:

函数 relation lock 特点
bt_index_check index + heap AccessShareLock 较轻,可用于 hot standby
bt_index_parent_check index + heap ShareLock 阻止 DML/VACUUM,不能用于 hot standby

heapallindexed=true 不提高 relation lock mode,但会显著增加时间、I/O 与内存工作; 其摘要结构受 maintenance_work_mem 约束。checkunique=true 又增加 unique visibility 检查。不要在事故主库上对所有大 index 一次性开最强选项。

一个风险排序:

exact reported index, heapallindexed=false
  -> exact index, heapallindexed=true on clone/low-load window
      -> parent check on writable clone or maintenance window
          -> wider object set by tablespace/provider/change scope

执行前记录 relation size、锁等待、statement timeout、I/O 预算、replica lag 与停止线。

verify_heapam

SELECT *
FROM verify_heapam(
  relation => 'public.orders'::regclass,
  on_error_stop => false,
  check_toast => true,
  skip => 'none',
  startblock => 0,
  endblock => 999
);

它可按 block 范围返回 heap/tuple 结构问题,但 check_toast=true 较慢;若依赖结构本身 损坏,检查也可能 error,极端情况下存在 crash 风险。优先在 clone 执行,并对输出做 隐私审查;错误信息虽偏结构,仍可能泄露数据特征。

35.4.2 collation 版本变化与索引顺序异常

版本 mismatch

SELECT n.nspname,
       c.collname,
       c.collprovider,
       c.collversion AS stored_version,
       pg_collation_actual_version(c.oid) AS actual_version,
       c.collversion IS DISTINCT FROM
         pg_collation_actual_version(c.oid) AS mismatch
FROM pg_collation AS c
JOIN pg_namespace AS n ON n.oid = c.collnamespace
WHERE c.collversion IS NOT NULL
ORDER BY mismatch DESC, 1, 2;

数据库 default collation:

SELECT datname,
       datcollversion AS stored_version,
       pg_database_collation_actual_version(oid) AS actual_version
FROM pg_database
ORDER BY datname;

provider:

d = database default
c = libc
i = ICU
b = builtin

操作系统或 ICU 升级可能改变 text comparison。旧 index 是按旧规则构建的,新 backend 按新规则搜索时,binary search 可能走错方向并返回错误答案。primary/standby provider 版本不一致也可能让只读副本先暴露问题。

枚举 index 的 collation 依赖

SELECT i.indexrelid::regclass AS index_name,
       x.ord AS key_position,
       x.collation_oid::regcollation AS collation
FROM pg_index AS i
CROSS JOIN LATERAL
  unnest(i.indcollation) WITH ORDINALITY AS x(collation_oid, ord)
WHERE x.collation_oid <> 0
ORDER BY 3, 1, 2;

表达式 index、operator class、partition、materialized view 与 extension objects 还需结合 pg_depend、DDL 和应用查询盘点。indcollation=0 只表示该 index key 不使用 collation, 不是整个 object 无外部语义依赖。

修复顺序

在 provider 已稳定、工作副本验证后:

1. inventory every affected derived object
2. REINDEX / rebuild each object under intended comparison rules
3. run amcheck and business/order invariants
4. ALTER COLLATION ... REFRESH VERSION
   or ALTER DATABASE ... REFRESH COLLATION VERSION
5. repeat checks on every role/replica after rollout

REFRESH VERSION 更新 catalog 记录,不会自动重建所有依赖对象。先 refresh 会让 warning 消失,却可能留下按旧规则排列的 index,是典型“消除检测器而没有消除缺陷”。

本章实验只伪造 stored version,实际 ICU 规则没有变化;因此它只证明流程与元数据, 不证明真实升级后的每个 index 必然有序。

35.4.3 索引可重建不意味着堆表数据安全

先证明 source relation

REINDEX 从 heap 读取 row 并生成新派生结构。如果 heap page、TOAST、visibility/XID 或 业务数据已经错误,新 index 可能只是忠实地索引了错误 source

至少组合:

data checksum / verify_heapam on source
TOAST readability
row count and partition coverage
constraints and foreign-key validation
business aggregates/ledger/token
seqscan vs indexscan bounded equivalence
amcheck after rebuild
backup/replica cross-check

查询等价性要控制 snapshot

若比较 seqscan 与 indexscan:

same transaction snapshot
same WHERE/ORDER BY/collation
stable deterministic projection
bounded result
explicit NULL and duplicate handling
same role/GUC/RLS context

两个独立时间点的 count 不同,可能只是并发写,不是 index corruption。对生产主库最好 在 repeatable-read/read-only snapshot 或 clone 中比较。

REINDEX 的生产风险

普通 REINDEXREINDEX CONCURRENTLY 的锁、空间、WAL、失败状态和支持对象不同。 损坏场景下 concurrently 也未必是正确路线:它需要继续依赖当前系统结构和写入并发。 先在 clone 证明:

source readable
new index validates
temporary disk/WAL capacity sufficient
unique conflicts understood
cutover and rollback defined

如果坏的是 system catalog index、heap 或唯一可信 source,停止套用普通 REINDEX runbook,升级抢救流程。


上一节:页与 checksum 证据 · 返回本章目录 · 下一节:抽取、跳过与重建策略 · 查看全书目录 · 查看索引中心

35.5 抽取、跳过与重建策略

抢救策略的优先级由“可信数据来源”决定,不由某个技巧是否能让 PostgreSQL 启动决定。 完整、已验证的 backup/健康副本通常优于在坏页周围挖数据;分段抽取又优于在唯一 PGDATA 上开启会丢数据的全局参数。

35.5.1 优先从备份、健康副本或逻辑来源恢复

候选来源矩阵

来源 优点 必须验证
physical backup + WAL 保留完整 PostgreSQL 状态,可 PITR restore、checksum、lineage、损坏是否已进入 backup
healthy physical replica RPO 较新,可快速重建 独立故障域、replay、checksum、同 system id/timeline
storage snapshot 快速封存大量字节 crash consistency、覆盖 tablespace/WAL、设备独立性
logical dump/export 隔离物理布局,适合重建 全对象覆盖、snapshot、权限/DDL/large object
upstream/event/ledger 可恢复业务事实 完整性、顺序、幂等、删除与外部副作用
corrupted clone extraction 最后可取回部分数据 明确缺口、不可验证范围、重复与编码

“最近”与“最好”不同。一个 5 分钟前但含坏页的 replica,不如 30 分钟前已 restore 验证 且有完整 WAL 的 backup;是否接受 RPO 由业务 owner 决定。

并行验证,不要串行赌注

track A: preserve and diagnose original
track B: restore latest known backup to isolation
track C: validate independent replica/snapshot
track D: prepare logical reconciliation

每条 track 不写另一个 track 的来源。先得到可用候选,再按 RPO、完整性、时间和风险 选择,不要等在唯一 damaged instance 上的“修复”失败后才想起恢复 backup。

物理来源也可能复制损坏

backup 工具成功退出不等于每个 page 正确;physical replication/WAL 会忠实传播合法 page changes,也不能修复已在 source 中的逻辑错误。每个候选都跑:

startup/recovery and system identifier/timeline
offline/online physical checks
amcheck/heap checks by scope
business invariants
archive/backup continuation

35.5.2 对可读数据做分段抽取和校验

只在 clone 上建立 extraction map

先按稳定业务 key/partition 分块:

COPY (
  SELECT id, tenant_id, occurred_at, payload
  FROM public.events
  WHERE id >= :lo AND id < :hi
  ORDER BY id
) TO STDOUT WITH (FORMAT binary);

每块记录:

range: "[lo, hi)"
snapshot: ...
rows: ...
min_max: ...
ordered_digest: ...
copy_exit_status: ...
error_relation_block: ...
destination_rows_digest: ...

不要用 OFFSET/LIMIT 做可恢复分块;行在并发/错误下可能跳动,复杂度也高。ctid 可辅助 定位 physical block,但会随 UPDATE/VACUUM/rewrite 改变,不能作为业务去重身份。

二分定位只是抽取方法

若某 key range 读取失败,可以在 clone 上继续二分:

[0, 1M) failed
  [0, 500k) pass
  [500k, 1M) failed
    ...

最终报告:

verified exported ranges
failed/unreadable ranges
rows known exported
rows estimated/unknown lost
duplicates and reconciliation rule
TOAST/large object coverage
constraints not yet validated

不能把“成功导出了 99% range”说成“只丢 1% 行”:坏块上的行数、TOAST 引用和跨表 关系可能未知。

导入到新库再验证

load into quarantine schema
retain source range/run id
reject or quarantine constraint conflicts
build indexes after source import when appropriate
validate counts/digests/business ledger
deduplicate by stable identity
record every transformation

不要直接把 best-effort 数据导回生产表覆盖已有正确数据。

35.5.3 危险恢复参数只在克隆现场、明确损失下使用

参数不是普通 troubleshooting 开关

手段 可能让什么继续 代价
ignore_checksum_failure=on 尝试读 checksum 不匹配页 可能 crash、隐藏/传播损坏
zero_damaged_pages=on 跳过坏 page header 内存中把整页置零,丢失该页所有行
ignore_invalid_pages=on recovery 忽略无效 page 引用 可能 crash、丢数据、隐藏/传播损坏
pg_resetwal 在缺 WAL/control continuity 时尝试启动 数据与事务一致性可能不可证明

官方对 zero_damaged_pages 的描述非常明确:它会销毁 damaged page 上的所有行,应在 已经放弃恢复该页后才考虑。它不是“自动修页”。

最低授权合同

target: exact disposable clone hash
original_evidence: immutable and independently stored
healthy_source_search: exhausted_or_documented
expected_loss:
  object_blocks_rows: ...
  business_effect: ...
parameter:
  name_value_scope: ...
  reason: ...
extraction_only: true
no_return_to_service: true
owner_approval: ...
stop_condition: ...

危险参数只为了抽取仍可读数据,产出的 cluster 永不直接回到服务。抽取后在全新 cluster 重建、验证。

不要发布复制粘贴“魔法命令”

每个损坏现场的 PG version、system id、WAL、checkpoint、relation 与硬件证据不同。 尤其 pg_resetwal 应由熟悉 PostgreSQL 内部与业务损失的人在 clone 上分析;本书实验 不执行它,也不提供绕过 guard 的快捷命令。

35.5.4 何时停止自救并升级到专业支持

技术停手线

  • system catalog、control file、WAL 或多个关键 relation 损坏;
  • 服务器反复 crash,core/stack 尚未保存;
  • 存储/内存仍报告硬件错误;
  • primary、replica、backup 对同一事实给出冲突结果;
  • 不知道某动作会否覆盖唯一恢复来源;
  • dangerous GUC/pg_resetwal 成为下一步;
  • encryption/key、filesystem、volume snapshot 需要专项能力;
  • 估计损失跨越 financial/security/compliance 边界。

升级包

business impact and decision deadline
immutable evidence manifest and access method
PostgreSQL/system/extension/locale versions
system id/timeline/LSN/control projections
exact errors with UTC and SQLSTATE
relation/fork/block mapping
kernel/storage/hardware evidence
backup/replica/source candidates
all actions already performed in order
questions requiring expert decision

不要为了“给专家一个更干净的环境”先删除日志、重启十次或运行 repair。未经请求不要 把含 PII/凭据/密钥的完整 PGDATA 或日志发给外部支持;先走安全与法律批准、最小披露和 加密传输。

时间与选择权

专业支持不是承诺一定恢复所有数据,而是避免用不可逆试错继续缩小选择空间。若业务 deadline 更早,可并行恢复已验证 backup,同时保留 damaged original 供后续取证;恢复 服务与确定根因不必串行。


上一节:索引、collation 与 amcheck · 返回本章目录 · 下一节:工程取证与业务验证 · 查看全书目录 · 查看索引中心

35.6 工程取证与业务验证

数据库抢救有两个交付对象:一个是可以承担服务的新状态,另一个是解释“原状态发生了 什么、哪些数据仍不确定”的证据链。只交付一个能启动的 cluster,会把未知损失留给 业务和下一次事故。

35.6.1 保留原始证据、操作副本和完整时间线

证据树

incident/
  manifest.json
  original/
    storage-snapshot-id
    pgdata/tablespace/wal hashes
    logs/kernel/hardware projections
  sources/
    backup-id + restore validation
    replica-id + lineage validation
  experiments/
    E001-input-hash/
      hypothesis
      commands/tool-versions
      output-projection
      conclusion
    E002-input-hash/
  recovery/
    selected-source
    transformations
    validation
  communication/
    decisions
    impact-estimates

敏感原件放在受控 evidence store,协作仓库只放去敏 projection。不要因为教材/工单需要 “可见”就把 raw PGDATA、query、凭据或客户记录提交 Git。

append-only 时间线

每条事件:

at_utc: ...
monotonic_or_sequence: ...
actor_or_automation: ...
target_identity: ...
action_or_observation: ...
input_evidence: ...
output_hash: ...
authority_ticket: ...
interpretation_at_the_time: ...
later_correction: ...

后来的认识不要覆盖当时记录;追加 correction。这样复盘才能区分“当时可见事实”与 “事后才知道的事实”。

工具可复现性

保存:

exact PostgreSQL binary/package version
tool options and locale/timezone
extension version and schema
script source hash
exit code
stdout/stderr raw location and redacted projection
input filesystem/snapshot identity

同名 pg_checksumsamcheck 或 ICU 在不同版本上可能有不同列、规则和输出。

35.6.2 区分“数据库能启动”与“业务数据可信”

五层验收

问题 示例证据
L1 process postmaster 是否稳定运行 service/PID/log/crash loop
L2 SQL catalog/事务是否可用 connect、read/write probe、control
L3 physical page/structure 是否一致 checksum、amcheck、heap check
L4 relational schema constraints/rows 是否一致 validate constraints、counts/digests
L5 business 业务事实是否可信 ledger、token、upstream reconciliation

恢复完成还要加 L6 operational:

replication
archive and backup
monitoring and alerting
service route and roles
capacity headroom
observation window

业务不变量要有定义版本

invariant: account_ledger_balanced
definition_version: git-sha
snapshot_or_cutoff: "..."
query_or_program_hash: "..."
scope:
  tenants: ...
  partitions: ...
expected:
  debit_minus_credit: 0
actual: ...
exceptions: [...]
owner_signoff: ...

只有 row count 不够。相同行数可以包含错误值、重复/缺失互相抵消、错误 tenant 或错误 时间窗。本章实验同时比较 row count、sum ID、sum balance 和有序内容 digest,但真实 业务还要使用有意义的 ledger/token。

抽样与全量

全量验证可能超过 RTO,可分层:

release gate:
  critical tenants/ledger + physical checks + current write probe

observation window:
  wider partitions, indexes, backups, reconciliations

post-incident:
  full historical scan where feasible

必须明确哪些是全量、哪些是抽样、抽样方法与遗漏风险。不能把“抽查 100 行正确”写成 “数据已完整恢复”。

35.6.3 记录不可恢复范围与合规沟通

用区间与集合表达 unknown

confirmed_recovered:
  id_ranges: [...]
  row_count: ...
confirmed_missing:
  business_ids: [...]
  reason: ...
possibly_affected:
  time_window: ...
  tenants: ...
  relation_blocks: ...
unknown:
  - rows formerly present on unreadable page
  - external side effects without idempotency ledger
estimation_method:
  source: ...
  confidence: ...

不要把 unknown 填成 0。若坏页内容不可读,精确行数可能无法知道;可以报告下界/上界 和估计方法。

技术 RPO 与业务损失分开

WAL/RPO gap
  recovery point 与事故点之间可能缺的数据库事务

physical extraction gap
  某些 page/object 无法读取

semantic gap
  数据存在但业务意义错误

external-effect gap
  数据库与支付、消息、邮件、对象存储不同步

一个 RPO 数字不能覆盖四类损失。

合规与沟通

由法律、安全、隐私和业务 owner 判断是否触发通知。技术团队提供可审计事实:

what systems/data classes were in scope
confidentiality vs integrity vs availability impact
earliest/latest affected time
confirmed/possible/unknown records
detection and containment time
recovery source and validation
evidence retention and access
next update time

对客户不要说“数据库坏了但已经修好”这种不可证实概括。应说明已确认影响、仍在验证 范围、临时控制和下一次更新时间;不公布攻击/隐私细节前先走授权。

结案条件

service and business invariants accepted
unknown/loss register signed by owners
original evidence retained per policy
temporary credentials/routes/clones removed
backup/replication/monitoring re-established
hardware/root-cause track assigned
postmortem and control actions scheduled

第 36 章将把这份证据包转化为长期控制。


上一节:抽取、跳过与重建策略 · 返回本章目录 · 下一节:实战:在克隆环境分类并恢复 · 查看全书目录 · 查看索引中心

35.7 实战:在克隆环境分类并恢复

前六节建立了抢救原则,本节把它们压进一次可复验的盲测。演练不是“制造一个错误, 然后按预先知道的答案修掉”,而是同时维护三条相互独立的证据链:

现场链:managed before/after + original case digest + known-good digest
判断链:blind packet -> classifier predicate -> route
恢复链:selected source -> transformation -> physical/business validation

如果分类器偷看答案、恢复动作改写原始 case,或验收只看进程启动,这三条链中至少有 一条会断。实验的价值正在于让这些捷径变成机器可拒绝的反例。

35.7.1 仅在启用 checksum 的专用镜像注入可控页异常

先读合同,再允许 mutation

实验入口不是脚本参数,而是六份可评审合同:

先做不接触 PostgreSQL 的静态检查:

static/labs/ch35/task.sh lint

预期输出至少包含:

status=lint-ok
declared_counterexamples=35-schema-valid
source_files_hash_bound=13
managed_reset_host_executed=false
production_ch35_gate=pending

如需核对当前 Pigsty sandbox,capture 只通过 SSH 读取目标身份、拓扑和服务投影,不改 数据库:

evidence_dir="$(mktemp -d /tmp/pg36-ch35-capture.XXXXXX)"
PG36_EVIDENCE_DIR="$evidence_dir" \
  static/labs/ch35/task.sh capture

完整演练则是 L3:会在 pg-test-3 创建私有临时 PostgreSQL 集群、停止并复制它, 再修改操作副本。因此 runner 要求五项精确 guard,缺一项就在创建远端目录之前失败:

evidence_dir="$(mktemp -d /tmp/pg36-ch35-evidence.XXXXXX)"

PG36_EVIDENCE_DIR="$evidence_dir" \
PG36_CH35_TARGET=pg36-l2-vagrant/pg-test \
PG36_CH35_NONPRODUCTION=true \
PG36_CH35_PRODUCTION_DATA=false \
PG36_CH35_PRODUCTION_TRAFFIC=false \
PG36_CH35_CONFIRM=CLONE_CLASSIFY_RECOVER_CH35 \
  static/labs/ch35/task.sh drill:forensics

这些值不是“我愿意承担风险”的通用开关。它们共同声明:目标正是本书的无数据、 无流量 sandbox,且授权范围只到 disposable clone。换成生产主机、生产数据或生产 流量后,本命令没有任何授权意义。

隔离布局

runner 在 pg-test-3 上创建:

/tmp/pg36-ch35-forensics-<UUID>/
  source/          checksum-enabled fixture
  known-good/      clean shutdown 后冻结的可信快照
  cases/           每个场景独立的 original evidence copy
  working/         修复或恢复所使用的新副本
  sockets/         mode 0700 的私有 Unix socket

临时实例设置 listen_addresses='',不进入 Patroni、DCS、HAProxy、PgBouncer、备份仓库 或业务路由。managed pg-test 只做 before/after 只读投影。cleanup 还必须同时满足:

  1. 路径符合 exact UUID root;
  2. root 中的 marker 与本次 run identity 相符;
  3. 所有临时 postmaster 已停止;
  4. original case 和 known-good 的保留性已经验证。

只有四项都成立,runner 才删除整个 exact root;它不会根据模糊 glob 删除目录。

物理页场景

fixture 是启用 data checksum 的 PostgreSQL 18.6 集群,包含 12,000 行确定性数据和 至少八个 heap block。源实例干净停止并冻结 known-good 后,runner 才为物理场景创建 独立 clone,并在 postmaster 已停止 时对目标 heap relation 执行:

block             2
offset in block   512
operation         byte XOR 0x01
bytes changed     1

这个动作的目的不是模拟真实磁盘故障机理,而是生成一个可重复的、checksum 能检测到的 坏页。实验同时保存 relation mutation 前后的 SHA-256;运行中的 relation file、 managed PGDATA 和唯一恢复来源永远不被改写。

随后取得两类互补证据:

offline: pg_checksums --check -> bad checksum = 1, exit non-zero
online:  sequential heap scan -> SQLSTATE XX001

离线检查回答“磁盘上的 checksum 是否匹配”,在线扫描回答“服务实际读取该页时发生 什么”。两者都不是修复动作。实验没有启用 ignore_checksum_failurezero_damaged_pages,也没有运行 pg_resetwal 或删除任何 pg_wal 文件。

35.7.2 另设索引或 collation 异常,随机隐藏故障类型

第二种故障故意不破坏 page

collation 场景从同一 known-good snapshot 创建另一份 case。它只在 disposable catalog 中临时允许 allow_system_table_mods,把实验专用 ICU collation 的 stored version 改成带 run identity 的假值:

actual version   153.121
stored version   0.pg36-ch35-278fcd34

这个构造只模拟“catalog 记录的版本与当前 provider version 不同”。它没有安装另一版 ICU,也没有证明比较函数或索引顺序真的变化。相应证据是:

offline bad checksums       0
stored != actual         true
bt_index_check passes    true
relation kind            index-derived

因此,“checksum 全绿”和“B-tree 结构检查通过”不能否定 collation-derived state 已经过期;反过来,version mismatch 也不能被夸大成 heap page 已损坏。

隐藏答案,分类证据

runner 随机安排两个场景,并给每个场景生成无语义的 case_id。classifier 只能读取 blind-packets.json 中七组字段:

case_id
observed_at
checksum
relation
collation
amcheck
business

场景标签与注入细节保存在独立的 hidden-answers.json,不作为 classifier 输入。合法 route 是显式、可评审的合取谓词:

RESTORE_FROM_KNOWN_GOOD_COPY
  checksum.enabled = true
  AND checksum.offline_bad_checksums >= 1
  AND relation.kind = heap
  AND collation.version_mismatch = false

REINDEX_AND_REFRESH_COLLATION
  checksum.offline_bad_checksums = 0
  AND collation.version_mismatch = true
  AND amcheck.structural_check_passed = true
  AND relation.kind = index-derived

若两个谓词同时成立,或两个都不成立,分类器必须返回:

STOP_AND_ESCALATE

未知状态不是“选一个最像的”。正确动作是保留原件、停止写入和重复实验,列出缺少的 事实与恢复来源,并在危险参数之前升级。

正式 run 278fcd34-48af-49e0-97c9-c5e5e4161c78 的随机顺序是:

顺序 隐藏场景 关键盲证据 分类 route
1 COLLATION_METADATA checksum 0、version mismatch、amcheck pass REINDEX_AND_REFRESH_COLLATION
2 PHYSICAL_HEAP_PAGE checksum 1、heap、scan XX001 RESTORE_FROM_KNOWN_GOOD_COPY

这里公布 hidden truth 是实验完成后的教学复盘;分类发生时,答案并未进入算法输入。

35.7.3 用备份、重建或抽取恢复并验证不变量

物理异常:恢复新副本,不改现场

物理场景命中 RESTORE_FROM_KNOWN_GOOD_COPY 后,runner 不在坏页上写回正确 byte, 也不复制单个 block 覆盖现场。它从停止状态的 known-good snapshot 创建一份新的 recovery copy,再在新副本上完成:

pg_checksums --check       bad checksums = 0
amcheck                    pass
row_count                  12,000
sum_id                     72,006,000
sum_balance                588,702,000
ordered_content_digest     5c04d26539b8fa55f52e13e6a78f8bdd

四个业务不变量同时匹配才算实验恢复成功。row_count 能发现大块缺失,却发现不了值 被替换;两项求和能捕获部分数值漂移,却可能相互抵消;确定顺序的内容 digest 再覆盖 每行关键字段。真实系统还应加入账务平衡、对象存在性、外部事件和幂等副作用等业务 事实,不能照抄这四项就宣称可信。

原始 damaged case 在验证期间保持逐文件 digest 不变,直到最终统一清理。这样即使 恢复结果后来被推翻,调查者仍能从同一原件重新分叉。

collation 异常:先重建派生对象,后承认新版本

collation 场景也不直接操作 original case。runner 创建单独 working copy,并严格按 以下语义顺序执行:

REINDEX INDEX public.pg36_ch35_code_idx;
ALTER COLLATION public.pg36_ch35_icu REFRESH VERSION;

第一步让 exact dependent index 按当前 provider 规则重建,第二步才把 catalog 中 stored version 刷新为 actual version。若颠倒顺序,warning 会消失,但旧派生对象 可能仍由旧规则构造;那只是消音,不是修复。

正式结果:

repair order               REINDEX -> REFRESH VERSION
elapsed                    267.719 ms
version mismatch after     false
offline bad checksums      0
amcheck after              pass
four invariants            all match

实际事故不能仅凭 collation 名称猜依赖关系。应先枚举所有依赖对象,确认 index、 partition、materialized view、constraint 与应用排序语义,规划锁和容量,再对 exact 对象重建。若 heap、checksum 或业务证据同时异常,应回到扩大调查范围,而不是继续套用 这条单因果 route。

验证证据包,而不是相信终端输出

一次完整 run 的关键产物是:

evidence/
  before.json
  after.json
  classification.json
  negative-report.json
  public-summary.json
  validation-report.json
  review.txt
  exercise/
    blind-packets.json
    hidden-answers.json
    exercise-evidence.json
    cleanup.json
    run-manifest.json
    source-manifest.json

before.jsonafter.json 证明 managed system identifier、timeline 和 topology 没有变化;source-manifest.json 把 13 份 runner/contract 源文件绑定到 hash; run-manifest.json 绑定本次输入与输出;cleanup.json 证明 exact root 已删除且没有 临时 postmaster 遗留。raw evidence 保留在受控位置,对外只发布去敏摘要 rescue-run.json

对一个已有完整 bundle,可在不再连接或修改 PostgreSQL 的情况下复验:

PG36_EVIDENCE_DIR=/path/to/ch35-evidence \
  static/labs/ch35/task.sh all

all 只消费现有证据。正式 bundle 的 validator 不仅检查 happy path,还把合同的 35 个反例逐一变成 live mutant 并确认全部拒绝,覆盖:

  • 把生产数据或 managed PGDATA 伪装成实验目标;
  • 弱化 checksum 阈值或泄露 hidden answer;
  • 在 original case 上原地修复;
  • REFRESH VERSION、后 REINDEX
  • 业务 digest 漂移却宣称恢复成功;
  • 临时 postmaster 未停或 root 未删却宣称 cleanup 成功。

这类负向验证回答的是“哪些错误证据绝不能通过”,比再打印一次 status=completed 更能约束未来脚本回归。

35.7.4 用 reset:host 重建 Pigsty L3,不复用损坏现场

这是风险类别,不是复制粘贴命令

本书把 reset:host 用作一个内部风险标签:当操作系统、存储、package 或 PostgreSQL 基线不再可信时,从声明式 inventory 和可信数据源重建整台 L3。它不是 Pigsty 中已经 替读者填好目标的普通命令,也不授权删除任何 host。机器可读计划见 l3-rebuild-plan.json

宿主机重建前至少要具备:

  1. incident commander 明确 exact host、故障域与数据库权威所在;
  2. 原始证据和 storage snapshot 已保存在目标之外;
  3. 流量已排空,且健康节点或恢复环境拥有数据库权威;
  4. 已验证的 backup、健康 cluster source 或重建路径被明确命名;
  5. inventory、Pigsty release、package repository 与 secret source 全部固定版本;
  6. 替代容量、失败回退和生产 destructive approval 已就绪。

安全状态机是:

preserve + hash evidence
  -> fence suspect host from authority and routes
  -> provision clean host or verified clean storage
  -> apply pinned Pigsty node/PostgreSQL baseline
  -> restore trusted backup OR join as a fresh replica
  -> validate lineage/checksum/replication/route/backup/monitoring/business
  -> observe
  -> return traffic

关键字是 cleanfresh。不要把可疑 PGDATA 当作新实例恢复源,不要在未保存 唯一证据时格式化磁盘,也不要因为 service 成功启动就跳过 lineage 与业务验收。原 PGDATA 只有在证据保留期、合规义务和 incident owner 共同批准后,才进入单独的清理 流程。

本实验刻意停在生产门外

正式演练只验证了 L3 rebuild decision contract,没有执行 managed host 的移除、 重装或恢复:

managed_reset_host_executed = false
managed_pgdata_mutated      = false
managed_service_changed     = false
managed_route_changed       = false
production_ch35_gate        = pending

因此本节可以支持团队评审“何时应重建、重建应满足什么”,不能提供生产重建时长、 数据可恢复比例或变更批准。真实生产执行要重新解析 exact inventory、当前 Pigsty 版本、故障域、备份恢复演练结果和业务 RTO/RPO,并走独立破坏性门禁。

到这里,本章形成了一条完整原则:

保留原件,用证据分类;从可信来源生成新状态,用物理与业务不变量共同验收;只有在 基础设施可信度也恢复后,才让流量回归。


上一节:工程取证与业务验证 · 返回本章目录 · 下一章:事故复盘、控制固化与平台演进——举一反三 · 查看全书目录 · 查看索引中心

36 事故复盘、控制固化与平台演进——举一反三

服务恢复解决的是“现在还能不能用”,事故复盘要解决的是“为什么系统允许这条失效链 成立,以及下一次什么会不同”。如果复盘止于一份文档,系统没有发生任何变化;如果 行动止于“代码已经合并”,控制也未必真的有效。

本章把全书最后一个闭环写成:

restore user outcome
  -> stabilize correctness and headroom
  -> preserve what was known when
  -> explain trigger, amplification and failed defenses
  -> convert findings into owned controls
  -> verify effectiveness and expiry
  -> make the safer path a platform default

“无责”不是“无因”或“无责任”。它要求不把复杂系统失败压缩成人格评价,同时仍精确 记录谁以什么 role 对哪项控制、截止时间和验证证据负责。

学习完成标准

完成本章后,读者应能:

  1. 分开用户影响恢复、数据正确性、运行余量与 incident closure;
  2. 清点并回收临时降级、应急权限、路由旁路和被暂停的自动化;
  3. 为通知、观察窗口、证据保留和正式结案定义可验证条件;
  4. 用事件时间、采集时间和知识时间重建 append-only timeline;
  5. 区分 trigger、放大机制、failed defense 与潜在失效条件;
  6. 从技术、流程、组织和认知四个层面寻找 contributing factors;
  7. 写出不归罪个人、又不回避具体动作与决策缺陷的复盘;
  8. 区分当时可见事实、当时假设、后来证据与事后解释;
  9. 判断某个正确结果来自受控机制,还是仅仅来自运气;
  10. 评估日志、指标、trace、审计、时间同步和 retention 的证据质量;
  11. 把行动项写成 owner role、期限、控制类型、验证、失效与重验合同;
  12. 区分 prevent、detect、mitigate、recover 四类控制;
  13. 拒绝“加强意识”“以后注意”和“部署即关闭”等不可验收行动;
  14. 把教训回写到 SLI/SLO、runbook、恢复目标、安全假设和 ADR;
  15. 用 Pigsty inventory、模板、监控规则、safeguard 与演练承载平台控制;
  16. 将局部补丁升级为默认护栏,同时保留 exception、版本和退出路径;
  17. 从多个事故识别重复控制主题,但不把教学实验冒充生产缺陷;
  18. 产出 90 天路线,并用独立证据而不是 ticket 状态关闭控制。

三种关闭不能混为一谈

层次 关闭条件 不能替代它的信号
incident 用户影响、正确性、容量余量与运行路径稳定 endpoint 偶尔返回 200
postmortem 影响、时间线、因果、决策、未知项经相关方评审 文档创建成功
control action 指定验证通过,关闭证据被独立接受,重验时间已登记 PR merged / ticket done

行动关闭后,控制还可能因版本、流量、拓扑、人员边界或依赖变化而失效。因此控制注册表 需要 last_verified_atevidencevalid_untilrevalidation_days,而不是 永久绿色的 checkbox。

从四类演练提取控制主题

第 32~35 章分别保留了四条恢复路线:

章节 场景 主要决策 不能外推
ch32 误写与 PITR 排除错误 history,合并审计后的合法增量 sandbox timing 不是生产 RTO
ch33 主库失效 围栏、接纳新权威、对账 unknown、修复 lineage 受控停进程不是硬件/网络分区
ch34 flow 与 retention pressure 先分类,再限流或修复 owner fixture 阈值不是生产容量线
ch35 物理与派生损坏 从可信源恢复或重建 derived state 单字节/元数据注入不是真实介质故障

它们反复提示 observation contract、生产门禁、业务验收、精确作用域、unknown outcome、 未知分类停止线和 lineage/authority 七个主题。这里的措辞必须严格:

exercise exposed a control question
!=
production lacks this control

只有生产 inventory、流程、配置和演练证据完成评估后,某个主题才能被确认成实际缺口。

正式实验

本章的实验是完全离线的 postmortem compiler。它读取四份已冻结、去敏的公开摘要:

每个 observed fact 都绑定 source JSON Pointer、expected value、actual value 和 knowledge stage。编译器产出四份事故记录、七个跨事故控制主题、十二项 0~90 天参考 backlog 和覆盖 ch01~ch36 的能力评估合同。

正式 run 91c4464b-89f7-4145-9708-f07256d747ce

input evidence files hash-bound     4
incident records                    4
cross-incident themes               7
proposed control actions           12
roadmap phases                      3
capability chapters covered        36
live mutants rejected              36 / 36
production gaps confirmed           0
database / SSH connections          0 / 0
production mutations                0

36 个 live mutant 包括篡改源事实、把 sandbox 影响冒充真实用户、删除 action owner、 期限、验证或失效条件、自动批准生产动作、漏掉路线阶段,以及仅因能力地图完整就自动 认证读者。公开结果见 closure-run.json

本章边界

本章没有:

  • 分析任何真实生产事故、个人或客户数据;
  • 证明读者所在组织存在七个缺口;
  • 创建工单、发送通知、修改 Pigsty inventory 或执行 backlog;
  • 把第 32~35 章一次实验计时提升成 SLO/RTO;
  • 因为读者完成阅读而自动授予能力认证。

正式结论保持:

production_ch36_gate = pending
roadmap_status        = reference-proposal-requires-local-approval
learner_assessment    = not-assessed

阅读前后关系

本章目录

36.1 服务恢复不等于事件结束

36.2 从时间线建立因果链

36.3 证据质量与决策复盘

36.4 把行动项变成控制

36.5 回写 SLO、SOP 与架构 ADR

36.6 将控制固化到平台

36.7 实战:复盘四类事故并完成全书结业

权威参考

复盘与 SRE:

PostgreSQL:

Pigsty:


上一章:数据抢救与工程取证——起死回生 · 返回下卷导读 · 返回全书导读 · 查看全书目录 · 查看索引中心

36.1 服务恢复不等于事件结束

SELECT 1 成功、主端点重新可连或 Grafana 曲线回落,只能说明某个观察面在某一时刻 恢复。事件是否结束,还取决于用户结果、数据正确性、恢复能力、容量余量和临时控制 是否都回到可接受状态。

36.1.1 恢复用户影响、数据正确性与运行余量

用状态向量代替单点绿灯

事件恢复状态可以写成:

R=(U,D,H,P,O) R = (U, D, H, P, O)

其中:

U  user outcome:成功率、延迟、功能和影响人群
D  data:完整性、一致性、unknown outcome 与外部副作用
H  headroom:连接、CPU、内存、I/O、WAL、XID、容量余量
P  protection:HA、backup/archive、权限、围栏和回退能力
O  operations:监控、告警、自动化、值班和变更路径

只有各维都达到预先定义的 acceptance,才能从 active incident 进入观察。典型的反例:

表面恢复 尚未回答
应用成功率回升 超时请求究竟提交还是回滚
新主库可写 旧主是否已围栏、replica 是否同 lineage
磁盘空间释放 slot/XID owner 是否恢复、WAL archive 是否连续
PostgreSQL 启动 checksum、索引、业务不变量是否可信
PITR candidate 可查询 target 是否正确、合法 post-target 写是否对账

用户影响要从用户路径测量。数据库连接成功不是订单可提交,readiness probe 成功也不是 支付状态正确。至少比较 incident 前基线、影响窗口和恢复窗口:

user_journey: checkout_commit
sli_revision: checkout-v4
window:
  impact_start: ...
  mitigation_start: ...
  observation_end: ...
segments:
  region: [...]
  client_version: [...]
  operation: [...]
result:
  attempts: ...
  good: ...
  unknown: ...
  duplicate: ...
source_query_hash: ...

unknown 不能并入 success 或 failure;它需要 stable request token、业务 ledger、 outbox/inbox 和外部系统对账。

正确性恢复要写明 cutoff

“数据已经恢复”至少需要:

accepted source and system identifier/timeline
restore or reconciliation cutoff
confirmed affected object and row/event scope
versioned business invariant
external side-effect reconciliation
unrecoverable and still-unknown register
owner acceptance

验证查询本身也可能因 snapshot、时区、collation、replica lag 或遗漏 partition 而说谎。 保存 SQL/程序 hash、参数、执行角色、目标 endpoint 和 snapshot/cutoff。抽样可用于 早期判断,不能自动替代最终全量或风险加权验收。

运行余量是恢复的一部分

若服务只在当前流量下勉强稳定,下一次重试、checkpoint、autovacuum 或 backup 就可能 再次触发事故。对每个主要资源记录:

headroom=safe capacitycurrent demandsafe capacity \text{headroom} = \frac{\text{safe capacity} - \text{current demand}} {\text{safe capacity}}

safe capacity 来自压测与安全边界,不等同于理论最大值。观察:

  • active/queued connection 与 pool wait;
  • CPU run queue、memory pressure、swap/OOM 和 I/O latency;
  • WAL generation/archive/retention 与 filesystem free;
  • replica replay lag、slot restart LSN 与 backup freshness;
  • oldest xmin、freeze age、dead tuples 与 maintenance debt;
  • error budget burn、retry amplification 和降级队列积压。

事故后的补偿、缓存回暖、索引重建和备份会制造第二波负载;应纳入容量计划。

36.1.2 清理临时降级、应急权限和旁路配置

为每个临时动作建债务账本

incident commander 批准临时动作时就应同步登记回收条件:

temporary_control_id: TC-...
target_identity: ...
change:
  desired_before: ...
  emergency_value: ...
reason: ...
owner_role: ...
approved_at: ...
expected_effect: ...
stop_condition: ...
rollback_or_supersede: ...
expires_at: ...
verification_after_removal: ...

常见临时债务:

  • 只读、限流、功能开关、缩短队列或拒绝非关键工作;
  • 精确暂停 failover、backup、vacuum、发布或调度器;
  • 临时路由、旁路 endpoint、DNS/HAProxy 权重;
  • break-glass role、临时证书、放宽的网络来源;
  • 提高日志、采样或 trace 密度;
  • 临时增加资源、保留 replication slot 或 forensic clone;
  • 为抽取损坏数据而只在 clone 使用的危险参数。

不要在压力刚回落时机械执行“全部 revert”。临时限流可能仍在保护低余量系统,立即 撤掉会重启事故。先确认它是:

remove now
  原风险消失,移除不会突破余量

replace with permanent control
  临时动作有效,但实现、权限或可观测性不适合长期保留

retain with dated exception
  当前不能移除,有 owner、风险、补偿控制和到期日

从外向内、逐项回收

推荐次序随事故调整,但每次只改变一个可解释变量:

  1. 确认稳定基线与 rollback;
  2. 回收过期的 break-glass 权限、token 和会话;
  3. 恢复被暂停的监控、归档、备份、vacuum 与调度;
  4. 校正路由和服务发现,移除旁路;
  5. 分阶段撤销限流或降级,观察 user SLI 与资源余量;
  6. 恢复常规变更窗口;
  7. 验证 inventory、runtime 和 secrets source 没有漂移。

安全相关临时措施按“先建立替代保护,再移除旧保护”处理。不要为关闭 incident 而 先删证据 clone、storage snapshot、audit log 或 recovery backup;它们按证据保留 策略单独到期。

Pigsty 中比较 desired 与 observed

Pigsty inventory 描述期望集群、实例、服务、用户、数据库和参数;运行中的 PostgreSQL、 Patroni、HAProxy、PgBouncer 与监控则提供 observed state。事故后至少做三方对照:

version-controlled inventory
vs rendered configuration
vs runtime/catalog/topology observation

差异要么回写声明式配置并评审,要么从 runtime 清除。不要只修改现场,留下下一次 playbook 重跑会覆盖的“幽灵修复”;也不要未经 diff 把 inventory 全量重放到刚恢复的 系统。危险 playbook 使用 exact -l 目标、safeguard、preview 和独立批准。

36.1.3 通知、观察窗口与正式结案条件

恢复通知要说已知、未知和下一步

一次可信的恢复更新包含:

current user impact and affected segments
confirmed impact window
mitigation/recovery performed
data correctness and unknown-outcome status
temporary controls still active
what remains unverified
observation window and next update
owner/contact and escalation path

不要使用“完全恢复”“无数据丢失”这类超出证据的表述。如果结论只覆盖 fixture、区域、 时间 cutoff 或某类业务对象,就明确写出量词。安全、隐私、法律和客户通知由相应 owner 决策,工程团队提供事实、范围与置信度,不自行淡化或扩大。

观察窗口由失效周期决定

“观察 30 分钟”不是通用规则。窗口至少覆盖相关周期:

  • 高峰流量、重试和 backlog 排空;
  • checkpoint、WAL switch、archive 与 backup;
  • autovacuum/freeze 或维护任务;
  • replica catch-up、connection recycle、DNS/TTL;
  • cache warm-up、batch、settlement 或账务周期;
  • 临时控制撤销后的再暴露。

有些验证必须经过一个完整 backup + restore 或下一次业务结算,不能让 active incident 无限挂起;可以将事件关闭,同时把长期验证转成有 owner 的 control action。但交接 不能抹掉风险。

结案门

incident_closure:
  user_sli_accepted: true
  business_invariants_accepted: true
  unknown_outcomes_reconciled_or_owned: true
  capacity_headroom_accepted: true
  ha_backup_archive_monitoring_restored: true
  temporary_controls_accounted_for: true
  evidence_retention_recorded: true
  stakeholder_update_sent: true
  observation_window_passed: true
  residual_risks_owned: true
  postmortem_trigger_decided: true

postmortem_trigger_decided 不等于“复盘已经写完”。达到上面条件后,事件可以从实时 响应转入学习与控制工作;后续三种状态分别追踪:

incident: closed
postmortem: draft -> reviewed -> published
actions: proposed -> implemented -> effectiveness-verified -> expired/revalidated

这能避免为了让 dashboard 上的 incident 数量归零,而提前把未验证行动标成完成。


返回本章目录 · 下一节:从时间线建立因果链 · 查看全书目录 · 查看索引中心

36.2 从时间线建立因果链

时间线回答“先后发生了什么”,因果分析回答“哪些条件共同使结果成为可能”。二者不能 互相替代:相关事件排在前面,不代表它导致后果;一个关键条件没有出现在日志里,也 不代表它不存在。

36.2.1 触发条件、放大机制与失效防线

先建立三种时间

每条 timeline event 至少区分:

event_time       被观察对象声称事件发生的时间
observed_at      collector 或人看到它的时间
recorded_at      它进入证据库的时间

再加:

source_clock / timezone / clock_offset
sequence or monotonic marker
source identity and hash
actor or automation
action / observation / decision
knowledge available at that moment
confidence and later correction

跨 PostgreSQL、Patroni、HAProxy、应用、主机和外部依赖时,wall clock 可能偏移;事务 XID、LSN、timeline、request token、日志 sequence 和 trace span 能建立局部 happens- XID、LSN、timeline、request token、日志 sequence 和 trace span 能建立局部的先于 关系(happens-before),但它们也不是一个全局时钟。不要为了画出整齐图表而把不确定 的秒级顺序伪造成毫秒精度。

因果链的五个位置

latent condition
  平时存在但尚未造成可见影响的条件

trigger
  让系统进入失效路径的事件

amplifier
  扩大范围、持续时间或恢复难度的反馈

failed / absent defense
  本应阻断、发现或减轻路径却没有生效的控制

impact
  用户、数据、安全、恢复能力或运营负担的结果

以一个假设性 PostgreSQL 连接事故为例:

latent:  pool 没有 admission budget,应用重试无 jitter
trigger: downstream latency 突升
amplifier: 请求超时 -> 立即重试 -> session/lock queue 继续增长
failed defense: user SLI 未触发值班告警,只有 node connection alert
impact: checkout timeout;后台任务挤占交互式容量

“连接数过多”只是中间状态;“调大 max_connections”可能增强放大器。好的分析会继续 问工作为何被允许无界进入、为何重试不知 commit outcome、为何隔离和降级没有生效。

用反事实检验边

对每条 A -> B 写出可反驳命题:

evidence for temporal ordering
mechanism connecting A to B
independent observation
counterfactual: if A were absent, would B still occur?
alternative explanations
confidence

反事实不是要求在线复现生产事故。可以用 trace、query plan、WAL/lock graph、隔离 fixture、历史对照或模型验证。若证据只支持相关性,就写“contributing hypothesis”, 不要升级成 root cause。

防线为什么失效

逐层检查:

防线 应回答
prevent 为什么错误配置、无界请求或危险变更能进入系统
detect 为什么没有在用户影响前/同时发现
mitigate 为什么隔离、限流、降级或围栏没缩小影响
recover 为什么 restore、failover、rebuild 或对账不够快/不够可信

“告警触发了”不等于 detect control 有效。若它晚于客户投诉、没有 route、缺少 owner 或每次都误报,它只生成噪声。

36.2.2 技术、流程、组织与认知因素

把 contributing factors 只写成技术缺陷,常常会在另一条路径重演同类事故。四个视角 互相约束:

技术

software defect / query plan / lock behavior
capacity and queue design
replication, WAL, backup and storage
topology, routing and failure domain
configuration/default/version interaction
observability and identity

例如 Patroni 正确 promotion 仍可能遇到应用没有幂等 token;backup 完整仍可能因目标 选择或业务 delta 缺失而恢复错误 history。

流程

review and approval
change staging and rollback
incident command and handoff
runbook decision points
backup/restore/failover exercise
action verification and expiry

不要把“runbook 没写”当终点。继续问:这个判断是否适合 runbook?信息能否自动采集? 当证据冲突时是否有 stop line?runbook 是否随当前版本演练过?

组织

service and data ownership
on-call authority
dependency contract
priority and staffing
incentive and delivery pressure
cross-team escalation

如果 database team 能看到 replication lag,却不知道哪个 slot 属于谁,问题不是 pg_replication_slots 文档不足,而是 retention owner 没有进入服务目录与升级路径。

认知

mental model available at the time
ambiguous names or dashboards
confirmation bias and anchoring
alert framing
hidden automation
training and experience

认知因素不是把责任还给个人。平台要让正确 mental model 更容易形成:明确 endpoint 语义、显示 target identity、把 evidence 与 inference 分栏、对危险动作展示前置谓词, 并让未知状态进入 STOP_AND_ESCALATE

建一张因素矩阵

失效环节 技术 流程 组织 认知
进入 无 admission limit 发布门未测压力 owner 不明 把 timeout 当失败
扩大 即时重试 无降级触发线 app/DB 各自优化 把 session 当容量
检测 缺 user SLI 告警未演练 告警 owner 空缺 dashboard 名称误导
恢复 无 token 对账 runbook 缺 stop 权威不明确 先重启再取证

矩阵不要求每格都填内容;它防止团队只在自己熟悉的层面找答案。

36.2.3 避免单一根因和个人归罪

“谁执行”仍是事实,“谁粗心”不是机制

可审计表述:

At T, the release role applied revision X to target Y.
The preview did not include generated lock acquisition.
The approval UI showed cluster name but not system identifier.
Rollback criterion was not defined before execution.

归罪表述:

某某不够谨慎。
值班同学经验不足。
操作失误导致事故。

前者保留动作、上下文和控制缺口,后者用人格标签替代可改变的系统条件。真正无责的分析 假设参与者在当时信息、目标、工具和压力下有局部合理性,然后追问系统怎样让危险动作 显得合理。

accountability 与 blame

无责不取消 accountability:

  • incident commander 对响应目标与决策节奏负责;
  • service owner 接受用户影响和 residual risk;
  • action owner role 按期交付验证证据;
  • reviewer 对结论的证据强度提出异议;
  • management 为优先级、资源和到期 exception 作决定。

故意违规、欺诈、骚扰或安全事件可以进入独立的人事、法律或合规程序;不要把该程序 塞进技术复盘,也不要用“blameless”掩盖它。技术复盘仍分析系统如何预防、检测和限制 后果。

不要寻找一个可删除的“根”

复杂系统通常有多条必要/充分关系:

trigger existed
AND guard absent
AND amplification active
AND detection late
AND recovery path unverified
-> observed impact

“五个为什么”可帮助继续追问,但线性链容易忽略并发分支。用 causal graph 表示:

flowchart LR
  A["配置变更"] --> C["查询成本上升"]
  B["统计信息过期"] --> C
  C --> D["请求超时"]
  E["立即重试"] --> F["队列放大"]
  D --> F
  G["无 admission gate"] --> F
  F --> H["用户错误率上升"]
  I["缺少 user-SLI page"] --> J["检测延迟"]
  J --> H

行动优先选能切断多条边、覆盖多类事故且可验证的控制,而不是只修最后一次 trigger。 例如目标身份 + exact scope 的发布门,可能同时降低误恢复、误切换、误删 slot 和误改 PGDATA 的风险。

复盘结论的证据等级

observed       source-bound direct evidence
corroborated   two independent observations agree
inferred       mechanism fits evidence, alternatives remain
hypothesized   plausible and testable, not yet verified
unknown        material fact unavailable

写作时保留等级。评审者要能指出哪条新证据会推翻结论;无法被反驳的“根因”通常也无法 指导一个可验证控制。


上一节:服务恢复不等于事件结束 · 返回本章目录 · 下一节:证据质量与决策复盘 · 查看全书目录 · 查看索引中心

36.3 证据质量与决策复盘

复盘拥有响应时没有的时间、权限和上下文,因此最容易犯 hindsight bias:把后来才知道 的答案投射给当时的人。决策质量必须按当时可用信息评估,结果质量则按后来完整 证据评估。

36.3.1 哪些事实当时可见,哪些后来才知道

建 knowledge timeline

普通 timeline 记录系统事件;knowledge timeline 记录响应者何时获得什么信息:

knowledge_id: K-...
observed_at: ...
available_to_roles: [...]
statement: ...
kind: observation | inference | report
source:
  system: ...
  object_identity: ...
  query_or_artifact_hash: ...
quality:
  directness: direct | derived | hearsay
  completeness: ...
  clock_uncertainty_ms: ...
interpretation_at_the_time: ...
decision_ids_informed: [...]
later_evidence: ...
correction: ...

后来发现原告警是 replica、不是 primary,不要覆盖旧记录。追加:

T1 observed: endpoint E reports recovery=true
T1 inference: E is believed to be current primary
T2 correction: inventory and DCS show E was a replica

这样才能问:“在 T1,接受写流量是否合理?”而不是用 T2 的答案责怪 T1。

四栏复盘表

当时可见事实 当时假设 后来证据 当前结论
client timeout 上升 primary 过载 proxy backend errors + DB 正常 故障在 route
WAL 目录增长 WAL 生成太快 slot restart LSN 不动 retention owner 阻塞
新节点可写 failover 成功 旧主仍可接受直连 authority 尚未安全
checksum clean 数据无损坏 业务 digest 漂移 page 完整性与语义不同

要求每个结论能回指源证据,并明确 source identity。截图可帮助人理解,但通常缺查询、 时间窗、变量、完整返回和 hash;关键结论保留机器可读原始投影。

决策日志与复盘文档分离

响应期间的 decision record 应 append-only:

decision_id: D-...
at: ...
objective: ...
known_evidence: [K-...]
hypotheses_considered: [...]
chosen_action:
expected_observation:
stop_condition:
rollback:
authority:

复盘可以评价该决定,却不能重写当时输入。若事故中没有决策日志,这本身就是一个证据 缺口;不要靠会后记忆补成精确逐分钟事实。访谈内容标记为 recollection,并与日志、 审计、metric 和 trace 交叉验证。

证据保真与最小披露

raw evidence 可能包含 query text、角色、IP、token、客户标识或 payload。采用两层:

restricted original
  immutable/retained/access-audited

review projection
  redacted, source-bound, sufficient for the claim

去敏不应破坏关联键;可用 stable pseudonymous token、区间、计数与 hash。复盘仓库只放 projection 和原件位置/权限,不复制 secret 或个人数据。

36.3.2 哪些假设被验证,哪些动作靠运气

好结果不能证明好决策

四种组合都值得复盘:

决策过程 结果 判断
证据充分、边界清楚 成功 机制候选,仍需复验
证据充分、边界清楚 失败 模型或实现有缺口
猜测、无 stop/rollback 成功 near miss / luck
猜测、无 stop/rollback 失败 显性事故

“重启后好了”只证明重启与恢复同时发生。它可能清掉等待队列、终止事务、触发 failover、 刷新 cache 或碰巧等到下游恢复;没有前后证据就无法选出机制,也无法知道丢了什么。

为每个关键动作做机制审计

preconditions
  执行前必须为真的事实是否被验证?

scope
  作用到 exact object/session/node/cluster 吗?

mechanism
  为什么它应改变目标症状?

expected
  多久、在哪个观察面看到什么?

stop / rollback
  哪个信号表明应停止或撤回?

result
  预期与实际是否一致?

repeatability
  在隔离环境重演后仍成立吗?

例如取消 exact application_name 会话后 lock waiters 清零,可以支持“这些 fixture session 构成 flow pressure”;它不能证明任意高连接事故都应取消会话。又如从 known-good snapshot 恢复后业务 digest 匹配,支持该 fixture 恢复路线;它不能证明 生产 backup 覆盖同一范围。

验证假设,而不是验证故事

每个 hypothesis 写:

hypothesis: H-...
prediction:
  if_true: ...
  if_false: ...
test:
  isolation: ...
  changed_variable: ...
  independent_observations: [...]
result:
alternative_explanations:
status: supported | weakened | rejected | unresolved

如果测试同时改变配置、版本、流量和拓扑,即使问题消失也无法归因。生产不能安全复现 时,在 clone、replay、模型或历史数据上验证,并把外推边界写清。

near miss 也进入控制系统

这些情况值得和事故一样记录:

  • 错命令被 safeguard 阻止;
  • 误切换前发现 system identifier 不符;
  • dangerous parameter 在唯一副本执行前被 review 拒绝;
  • restore candidate 偶然正确,但 target 没有审计来源;
  • unknown request outcome 恰好没有重复副作用。

near miss 提供了低损失的失效路径证据。若只统计造成用户影响的事件,平台会忽略已经 穿透多层防线、仅靠最后运气没有出事的路径。

36.3.3 告警、日志和时间同步缺口

从问题反推 observation contract

不要以“多收日志”为默认行动。先列事故中无法及时回答的问题:

问题 所需信号 identity/维度 retention
谁受影响 user SLI / request outcome service、operation、segment 至少覆盖 SLO 窗口
查询为何慢 wait、plan、query ID、I/O cluster、db、role、query 覆盖发布与周期负载
谁保留 WAL slot/subscriber/archive system id、timeline、slot owner 覆盖恢复窗口
谁拥有写权威 DCS、Patroni、timeline、route cluster/member/endpoint 覆盖 failover 前后
数据何时变化 audit/WAL/business event transaction/token/object 由 RPO、合规决定
恢复是否可信 backup/restore/business manifest source/cutoff/candidate 覆盖证据保留期

每个信号写 producer、collector、query、labels、刷新周期、缺失语义、owner、retention、 权限与成本。missing value 不能默认解释成 zero/healthy。

PostgreSQL 证据的时效与局限

PostgreSQL cumulative statistics、pg_stat_activitypg_stat_replicationpg_stat_replication_slotspg_stat_walpg_stat_io 等提供不同观察面,但要记录:

  • counter 是累计值还是当前 gauge;
  • stats reset、server restart 和 failover 是否改变基线;
  • 读取 snapshot、事务和刷新延迟;
  • query text 是否截断或因权限不可见;
  • standby 与 primary 的语义差异;
  • extension/版本是否改变列和统计;
  • NULL、空行与零值分别意味着什么。

日志应使用可解析格式(如 csvlog/jsonlog)和稳定关联字段,但 log_statement=all 可能暴露敏感数据并产生高开销。优先记录必要 identity、duration、SQLSTATE、query ID、 application/client context;query 参数和 payload 按数据分类处理。

指标、日志与告警要能互相落点

一条值班告警(page)应先表达 user impact 或 error-budget threat,再链接诊断上下文:

alert
  exact service + SLI window + burn/severity
  -> dashboard
     user outcome + dependency + PostgreSQL/host
  -> runbook
     evidence requests + route predicates + stop line
  -> raw source
     reproducible query/log projection

按每个 instance 发 30 条 alert 通常不如按 service impact 聚合一次,再保留 instance 分解。告警规则也需要单元/合成 time series 测试:正常、阈值边界、缺失数据、抖动和 长期低速 burn 都要覆盖。

时间同步是证据基础设施

至少监控:

clock source and synchronization state
offset / frequency error
last successful sync
host suspend/resume or VM migration
timezone configuration
collector ingestion delay

数据库 now() 是事务开始时间;statement_timestamp()clock_timestamp() 语义不同。应用、PostgreSQL log、systemd journal、proxy 和 monitoring timestamp 还可能分别来自 event time 与 ingest time。复盘合并前先统一 UTC 展示、保留原时区,并标注误差;不能靠肉眼把相近 timestamp 当因果顺序。

缺口行动要可验收

不要写:

增加更多监控。
完善日志。
保证时间准确。

写成:

owner: observability-platform
artifact: pg36_service user-SLI rule revision 3
test: synthetic 5% failure over declared windows
pass: fast window pages; slow burn records ticket; zero/missing remain distinguishable
clock test: inject collector offset fixture and reject order claims below uncertainty
evidence: rule test output + notification trace + dashboard link
revalidate: 30 days

观察能力只有在问题发生前存在、事件中可访问、事件后可重放时,才是一项控制。


上一节:从时间线建立因果链 · 返回本章目录 · 下一节:把行动项变成控制 · 查看全书目录 · 查看索引中心

36.4 把行动项变成控制

复盘行动项的目标不是让团队“做过一些事”,而是改变下一次失效路径的概率、可见性、 影响或恢复质量。能否关闭一项 action,取决于控制效果证据,而不取决于 ticket 状态。

36.4.1 所有者、截止时间、验证方法与失效条件

合格 action contract

id: ACT-...
finding_or_theme: ...
control_objective: ...
control_type: prevent | detect | mitigate | recover
owner_role: ...
decision_owner: ...
priority: P0 | P1 | P2
due_at: ...
scope:
  services: [...]
  versions: [...]
  environments: [...]
artifact: ...
verification:
  procedure: ...
  pass_condition: ...
  evidence_to_close: ...
failure_condition: ...
rollback_or_disable: ...
dependencies: [...]
status: proposed
last_verified_at: null
revalidate_at: ...
exception: null

owner_role 保证组织结构变化后仍能路由;具体 assignee 可在工单中绑定。decision_owner 对资源、延期和 residual risk 作决定,不能把所有压力留给实现者。

关闭有四个阶段

implemented
  artifact 已创建

deployed
  control 已进入目标环境

operating
  运行数据证明它持续执行

effective
  反例或演练证明它在失效路径上产生预期效果

例如新增一个 replication lag alert:

  • rule 合并:implemented;
  • VMAlert 已加载:deployed;
  • evaluation 和 notification 正常:operating;
  • 注入受控 lag 后按 SLI/route 触发且未误触其他场景:effective。

只有最后一步及其证据满足 closure contract,才标 effectiveness-verified。若控制只在 部分 service/version 上部署,状态也只能按该 scope 关闭。

写失效条件

没有 failure condition 的控制无法监控自身:

控制 失效条件示例
backup 最近可恢复点超 RPO,或隔离 restore 失败
failover 旧 writer 未围栏,unknown outcome 无法对账
capacity gate 未知 workload 绕过 budget,queue 无上限
release gate exact target/system identifier 缺失仍可执行
checksum review 扫描未覆盖全部声明对象或结果不可追溯
runbook 当前版本/拓扑不适用,参与者只能靠 hidden answer

同时规定 control telemetry 的 owner 和 retention。否则控制悄悄失效,直到下一次事故 才被发现。

到期、例外和重验

配置、版本、工作负载和依赖持续变化。每项控制应有:

revalidation interval
events that invalidate prior evidence
exception owner and expiry
replacement or retirement condition

升级 PostgreSQL/Pigsty、改变 DCS/backup repository、迁移 region、重写事务边界、 修改 pool 或流量翻倍,都可能使旧演练证据失效。exception 不是永久豁免;到期时必须 重新接受风险、补充控制或完成修复。

36.4.2 自动检查、发布门、容量线与恢复演练

先选控制位置

越靠近错误进入点,通常越便宜:

design/static
  schema、inventory、policy、compatibility check

release
  target identity、preview、migration lock、canary、rollback gate

runtime
  admission、timeout、quota、least privilege、safeguard

detect
  user SLI、database/host evidence、integrity and backup checks

recover
  PITR、failover、rebuild、reconciliation drill

不能自动 prevent 的风险,用 detect + mitigate;无法可靠恢复的数据,必须更早 prevent。 不要为追求“全自动”让一个弱分类器直接执行不可逆动作。

自动检查同时测试反例

正向测试只证明一个正确样本通过。控制至少覆盖:

missing identity
wrong cluster / database / role
unsupported version
stale inventory
conflicting evidence
empty or partial result
timeout / collector unavailable
unsafe production flag
rollback unavailable

对 migration,不只验证 SQL syntax;还验证 lock path、rewrite、replica lag、old/new application compatibility 和 rollback。对 restore,不只验证 PostgreSQL 启动;还验证 source lineage、target cutoff、business manifest、archive/backup 恢复和 route 未误切。

发布门要有明确 deny

ALLOW
  required evidence complete
  exact target resolved
  risk/authority/rollback valid

DENY
  predicate false

STOP_AND_ESCALATE
  evidence missing, conflicting or outside classifier domain

未知状态默认 allow,会把 collector 故障变成生产变更。默认 deny 也不是全部答案: 紧急 break-glass 需要独立身份、范围、期限、记录和事后 review。

容量线是多资源 envelope

不要把压测 TPS 单值写进 gate。容量合同至少包含:

workload_revision: ...
hardware_and_topology: ...
dataset_and_cache_state: ...
concurrency_and_arrival: ...
service_sli:
  latency: ...
  error: ...
resource_limits:
  connection_and_queue: ...
  cpu_memory_io: ...
  wal_archive_replication: ...
  xid_vacuum_storage: ...
safety_margin: ...
valid_until_or_invalidation: ...

流量、数据倾斜、query mix、checkpoint、vacuum 和 backup 会改变 envelope。容量 gate 既可阻止超预算发布,也要在运行中检测 headroom 消耗;指标缺失时不能宣称有余量。

恢复演练验证整条链

周期演练不是每季度运行同一 happy-path 命令:

能力 至少验证
backup/PITR source、target、candidate、业务 delta、archive continuity
failover authority、fence、client unknown、timeline、rejoin
overload blind classification、scope、stop route、post-cleanup
integrity preserve、checksum/amcheck、source selection、business invariant
secrets/access break-glass、审计、到期回收

轮换 seed、故障点、参与者和 hidden truth;保留可比较指标,但不把单次练习排名成个人 绩效,否则参与者会优化剧本而不是暴露控制缺口。

36.4.3 不能验证的“加强意识”不是合格行动项

把愿望改写成系统行为

不合格 可验证改写
加强备份意识 每 90 天从随机 retained backup 隔离恢复;业务 manifest 全过
以后谨慎执行 target 缺 system identifier、scope 或 rollback 时 gate 必须拒绝
完善监控 synthetic user failure 在规定窗口触发 SLI 值班告警并附诊断链接
培训故障切换 blind scenario 中先证明 fence/authority,再 reconcile unknown
优化性能 固定 workload 下达到声明 SLI 且所有资源保留安全余量
更新文档 当前版本新值班者仅凭 runbook 完成演练,错误分支被 stop line 阻止

培训和文档可以是控制组件,但不能单独证明系统更安全。人会遗忘、轮岗并在压力下使用 默认路径;应同时改进工具、权限、界面、自动采集、guard 与平台默认值。

拒绝 solution-first action

从“升级版本”“增加节点”“换存储”“重写服务”开始,容易跳过控制目标。先写:

which failure edge must be cut?
how much risk reduction is expected?
what evidence would falsify the proposal?
what new failure modes does it introduce?
how will it be rolled back or retired?

升级 PostgreSQL 或 Pigsty 可能修复已知 bug,也会改变扩展、配置、监控、backup 和 playbook 语义。它需要 ADR 与验证矩阵,不能作为通用复盘结论。

排优先级看风险,不看措辞力度

一种简单排序:

priority score=P(recurrence)×impact×control coverage×confidencedelivery cost+operational cost \text{priority score} = \frac{ P(\text{recurrence}) \times \text{impact} \times \text{control coverage} \times \text{confidence} }{ \text{delivery cost} + \text{operational cost} }

数字不是客观真理,而是迫使团队公开假设。优先:

  • 能切断多条 causal edge;
  • 覆盖多个 service/incident theme;
  • 在影响前 prevent/detect;
  • 具备明确、廉价的 effectiveness test;
  • 降低 on-call 认知负担;
  • 不制造更大单点或不可逆自动化。

低成本并不自动优先。“再加一条 alert”容易交付,却可能增加 noise;一次 target- identity gate 可能需要更多工程投入,但能同时阻断多类破坏性误操作。

action review 的停止线

以下任一成立,不应进入“完成”:

owner or decision owner absent
scope/version unspecified
verification cannot fail
closure evidence unavailable
failure condition unobservable
production rollout lacks rollback/approval
action only changes wording or awareness
exception has no expiry

复盘质量最终体现在控制 registry 里有多少 action 被有效验证,而不是文档里列了 多少 bullet。


上一节:证据质量与决策复盘 · 返回本章目录 · 下一节:回写 SLO、SOP 与架构 ADR · 查看全书目录 · 查看索引中心

36.5 回写 SLO、SOP 与架构 ADR

事故揭示的新事实如果只留在 postmortem,日常开发、发布和值班仍会按旧假设运行。 复盘的下游消费者是 SLO、runbook、service catalog、inventory、ADR、测试与版本路线; 每个消费者都需要明确 revision 和 owner。

36.5.1 更新观察契约、告警规则与 runbook

observation contract 是接口

应用、PostgreSQL、Pigsty 组件和 incident process 对同一 identity 达成约定:

service: pg36_shop
revision: obs-v...
identity:
  service: ...
  environment: ...
  pg_cluster: ...
  system_identifier_projection: ...
  instance: ...
  database: ...
  role: ...
  application_name: ...
user_slis:
  availability: ...
  latency: ...
  correctness: ...
diagnostic_sources:
  postgresql_views: [...]
  logs: [...]
  patroni_dcs: [...]
  proxy_pool: [...]
  host_storage: [...]
missing_semantics: ...
retention_and_access: ...
owners: ...

新增 label 不是免费:高基数会增加监控成本,敏感 identity 会扩大数据暴露。每个维度 回答“哪项决策需要它”,没有消费者的 telemetry 不应无限保留。

从 SLI 到诊断,不从组件告警猜影响

推荐两层告警:

page
  user SLI / error-budget burn / imminent data or recovery risk

ticket or context
  component symptom, capacity trend, maintenance debt

例如 replica lag 可能威胁 RPO 或 read-only 用户路径,也可能只是一个无业务 route 的 重建节点。告警需要 topology、route 和 objective 才能分级。Pigsty 的 PostgreSQL、 Patroni、PgBouncer、HAProxy、host、backup 指标可作为诊断层,业务 good-event 仍要由 应用定义。

规则发布前测试:

normal
threshold boundary
fast burn
slow burn
missing series
one instance vs whole service
maintenance and failover
notification routing

每条值班告警链接一个能在当前版本执行的 runbook,而不是 dashboard 首页。

runbook 写判断,不堆命令

symptom_or_page: ...
objective: ...
required_identity: ...
first_evidence:
  - source: ...
    why: ...
routes:
  - predicate: ...
    action: ...
    expected: ...
    stop: ...
    rollback: ...
unknown_route:
  action: STOP_AND_ESCALATE
authority: ...
version_scope: ...
last_exercised_at: ...
evidence_example: ...

命令输出随 PostgreSQL/Pigsty 版本漂移;runbook 应说明指标和 SQL 的语义,并记录版本 适用范围。危险操作不要留一个通用变量空槽让值班者临时填目标,应从已验证 identity 生成 exact plan 并再次确认。

验证回写完成

“文档更新”需要:

  1. alert fixture 能进入对应 runbook;
  2. 新参与者在 blind tabletop 中请求到正确证据;
  3. 错误/缺失 evidence 进入 stop line;
  4. 文档链接、query 和权限在当前环境有效;
  5. postmortem action 反向链接 observation/runbook revision。

文档过期检测也可自动化:版本、owner、链接、最近演练时间和依赖对象进入 lint。

36.5.2 修正 RPO/RTO、容量和安全假设

objective、capability 与 observation 分栏

objective
  业务愿意承诺什么

designed capability
  架构和控制预计支持什么

observed result
  某次真实事件或受控演练测到什么

一次 sandbox failover 的 6,211.692334 ms acknowledgement gap 是 observation,不是 生产 RTO;一次 fixture PITR 的恢复时间也不包含审批、下载、容量申请、DNS、外部对账 和用户切换。反过来,一个写在文档里的 15 分钟 RTO 若从未演练,也不是 capability。

RPO 不是一个数据库数字

按结果类型拆分:

database transaction durability
replica / WAL archive lag
backup recovery point
business event and outbox state
external side effect
audit/reconciliation source

异步复制、archive 与 backup 各有不同 loss window。PITR 到正确 target 仍可能丢弃 target 后的合法写;数据库恢复也不会自动撤销已发出的支付、邮件或消息。因此 RPO 合同写数据类别、cutoff、source、对账方法和 exception。

RTO 分解而不是报一个 stopwatch

RTO=Tdetect+Tdecide+Tprovision+Trestore+Tvalidate+Troute+Tobserve \mathrm{RTO} = T_{\mathrm{detect}} + T_{\mathrm{decide}} + T_{\mathrm{provision}} + T_{\mathrm{restore}} + T_{\mathrm{validate}} + T_{\mathrm{route}} + T_{\mathrm{observe}}

不同路线各自分布:

  • automatic/controlled failover;
  • PITR candidate + reconciliation;
  • replica rebuild;
  • backup restore;
  • data extraction/forensic recovery;
  • host/region rebuild。

记录 p50/p95 并不代表数据足够;小样本保留 raw runs 和条件。把最慢、最不确定且可改变 的阶段转成行动,而不是只优化 PostgreSQL copy speed。

容量与安全假设也要版本化

事故后检查 ADR 中的隐含假设:

peak and retry multiplier
pool/session/work_mem concurrency
WAL/archive/slot growth rate
vacuum/freeze window
backup repository and restore bandwidth
DCS/failure-domain independence
credential and network trust boundary
break-glass availability and audit
monitoring dependency during control-plane failure

每项写 evidence、margin、owner、invalidation event。将“单节点也够用”“replica 一定 最新”“内网可信”“备份每天成功”这种自然语言替换为可测谓词。

不因事故随意降低目标

若观测证明 objective 不可实现,有三个诚实选择:

  1. 投资能力达到业务目标;
  2. 调整产品/降级设计,缩小承诺范围;
  3. 与业务共同接受并批准新目标和 residual risk。

不能为了让报表变绿而单方面降低 SLO/RPO/RTO,也不能保留不可能实现的目标让值班者 承担结构性失败。

36.5.3 将必要变更纳入服务目录与版本路线

service catalog 是事故路由表

数据库服务条目至少包含:

service_and_tier:
business_owner:
technical_owner:
oncall_and_escalation:
data_classification:
postgresql:
  cluster:
  major_and_extensions:
  databases:
  writer_and_reader_services:
pigsty:
  inventory_revision:
  release:
dependencies:
  dcs:
  backup_repository:
  object_storage:
  identity_and_secrets:
objectives:
  sli_slo:
  rpo_rto:
controls:
  backup_restore:
  ha_fencing:
  capacity:
  integrity:
  security:
last_verified:
exceptions:

这不是手工 CMDB 展示页。inventory 和 runtime 能自动投影的字段不重复录入;业务 owner、data class、objective、外部 dependency 与例外仍需显式治理。

用 ADR 记录为什么

配置告诉系统“是什么”,ADR 解释“为什么这样、在什么条件下仍正确”:

# ADR-...: ...

Status / Date / Owners
Context and incident evidence
Decision
Alternatives considered
Assumptions and version scope
Consequences and new failure modes
Migration / rollback
Verification and observability
Invalidation / review trigger
Related controls and postmortems

例如把 DCS 从单故障域扩为多成员,不能只写节点数;ADR 要讨论 failure domain、quorum、 latency、failsafe/watchdog、maintenance、network partition 和运营复杂度。

版本路线按风险依赖排序

行动可能需要:

  • PostgreSQL minor/major fix;
  • Pigsty/Patroni/pgBackRest/PgBouncer/HAProxy 版本更新;
  • extension compatibility 与 shared_preload_libraries
  • OS、kernel、filesystem、ICU/libc 或 hardware firmware;
  • dashboard/alert/runbook schema;
  • 应用 driver、retry、transaction 和 idempotency contract。

建立 compatibility matrix:

current -> candidate
known incident relevance
support/security window
configuration semantic diff
extension and backup compatibility
rollback boundary
staged evidence
production approval

不要把多个必要升级捆成一次无法归因的大爆炸。先解决阻塞链和可观测性,再 canary/ batch;每阶段都有 before/after、stop 和 rollback。

Pigsty 映射

Pigsty 的声明式 inventory 与幂等 playbook 适合承载 desired state;监控栈适合承载 control telemetry;pgBackRest、Patroni、PgBouncer 和 HAProxy 分别承载恢复、权威、 连接与路由机制。但平台不会替业务定义:

good user event
transaction and idempotency boundary
acceptable data loss
business invariant
external side-effect reconciliation

这些应用合同必须回写 service catalog,并与 Pigsty/PostgreSQL 证据在同一次演练中 共同验收。

路线变更也需要退出条件

每个 roadmap item 记录:

why now
dependency and owner
target version/revision
evidence before rollout
success and stop criteria
rollback support window
when old path is retired

只有当旧例外、旧 runbook、旧 dashboard 和旧配置都被清点,版本演进才真正关闭; 否则值班时仍可能按过时入口执行。


上一节:把行动项变成控制 · 返回本章目录 · 下一节:将控制固化到平台 · 查看全书目录 · 查看索引中心

36.6 将控制固化到平台

平台化不是把所有决定自动化,而是让安全默认、身份、证据、审批和复位在每次操作中 一致出现。自动化适合执行已解析的意图;当 target、authority 或 evidence 仍有歧义时, 平台应停下来,而不是更快执行。

36.6.1 配置模板、验证脚本与策略即代码

控制从机器可读合同开始

desired state
  inventory / parameter / role / service / backup policy

precondition
  exact identity / version / topology / authority / headroom

plan
  rendered change / affected objects / lock and restart / traffic impact

gate
  risk / approval / rollback / evidence completeness

execution
  bounded target / idempotency / audit

postcondition
  native state + platform state + business invariant

模板只消除重复,不应隐藏高风险选择。默认填入:

  • stable service/cluster naming 与 environment/data class;
  • least-privilege role 与明确 HBA 来源;
  • timeout、pool、backup、monitoring、安全和容量基线;
  • version pin 与 extension compatibility;
  • safeguard、exact target 和 destructive approval;
  • owner、SLO/RPO/RTO 与验证 revision。

把业务密码、private key 或 raw token 从 inventory Git 中分离;模板引用受控 secret source,并验证存在性/权限,不输出值。

Pigsty inventory 是 desired state,不是全部事实

Pigsty 以声明式配置表达 node、cluster、instance、service、database、user 与参数, playbook 将其物化。推荐流程:

inventory PR
  -> schema/policy lint
  -> render and semantic diff
  -> sandbox/canary
  -> exact -l scope
  -> staged rollout
  -> SQL + Patroni/DCS + proxy/pool + monitoring verification

--check --diff 可帮助预览部分 Ansible 变化,但不能模拟所有 handler、运行时决策、 数据库锁、外部 repository 或 failover。preview 是证据之一,不是执行结果。

删除、重建和 PITR 等流程必须额外读取当前 identity 与 authority。Pigsty 的 pg_safeguard 可阻止危险 PGSQL 删除路径,但不能替代 exact inventory、备份验证、 流量排空和审批。安全控制应多层、互相独立。

原生证据复核平台结论

平台声称 回到原生/组件证据
primary/replica 正常 Patroni/DCS role、PostgreSQL recovery/timeline/replication
service route 正确 HAProxy backend + endpoint 实际连接 identity
pool 正常 PgBouncer pool/client/server state + PostgreSQL sessions
backup 正常 pgBackRest info/check + 隔离 restore/business manifest
参数生效 pg_settings source/pending_restart + process/runtime
监控覆盖 collector target、query result、rule test、notification

平台 UI 的绿色状态不能成为唯一证据;否则平台控制面故障时,团队失去验证路径。

策略即代码也要可解释

规则输出:

decision: deny
policy_revision: ...
target_identity: ...
failed_predicates:
  - required backup restore evidence expired
  - production destructive approval absent
evidence_links: [...]
exception_path: ...

不可解释的 deny 会诱使人绕过控制;不可审计的 allow 会隐藏风险。policy 变更本身需要 review、测试、版本和回退,并覆盖 allow/deny/unknown 三类 fixture。

36.6.2 备份、切换、容量和维护的周期演练

建 capability calendar

按风险与变更频率决定周期,而不是所有项目“一年一次”:

能力 建议触发
backup check 连续运行;失败立即路由
isolated restore 固定周期 + backup/repository/version 大变更
planned switchover 维护周期 + topology/Patroni 变更
unplanned failover tabletop/drill 固定周期 + DCS/fencing 变更
capacity benchmark workload/hardware/major config 变化
vacuum/freeze review 持续趋势 + 数据增长/事务模式变化
integrity check 风险分层周期 + storage/ICU/major version 变化
security access review 固定周期 + owner/role/network 变化
migration/upgrade rehearsal 每次 candidate revision

周期只是上限;invalidation event 应提前触发。

调度演练而不调度事故

exercise:
  capability: ...
  environment: isolated
  source_snapshot_or_fixture: ...
  hidden_scenario_seed: ...
  allowed_mutations: [...]
  forbidden_targets: [...]
  guards: [...]
  expected_evidence: [...]
  cleanup_and_preservation: ...
  production_claims_forbidden: [...]

故障注入限定 disposable clone 或明确无数据、无流量 sandbox。production chaos 需要 另一套组织授权,不能由“周期演练”四个字自动许可。

Pigsty 能承载的周期任务

  • 通过监控栈持续观察 PostgreSQL、host、Patroni、PgBouncer、HAProxy、backup;
  • 用 pgBackRest policy 与 exporter 观察 backup/archive,再在隔离目标实际 restore;
  • 用 Patroni/Pigsty 服务模型演练计划切换和受控故障切换;
  • 从 version-controlled inventory 重建 node/cluster,并验证 drift;
  • 用 playbook tag/limit 管理作用域;
  • 把 exporter query、alert rule、dashboard 与 runbook revision 共同发布。

具体 playbook、参数和输出会随 Pigsty 版本变化。运行前以已固定 release 的官方文档和 本地 source 为准;破坏性 playbook 不从书中复制到生产。

统一 evidence envelope

不同演练使用同一外壳:

contract + source hashes
environment identity
before / during / after
decision log
raw restricted evidence + redacted projection
business manifest
cleanup / retained artifacts
negative cases
review and production-claim boundary

统一 envelope 让平台能跨演练统计:哪些 control 过期、哪些 action 没有 evidence、 哪些版本从未恢复、哪些团队只跑 happy path。

演练失败不是坏成绩

演练在不伤害生产的前提下暴露:

backup 不可读
权限不足
runbook过期
candidate选错
业务不变量缺失
cleanup不完整
值班升级路径断裂

这正是它的产出。禁止为了完成率修改 pass condition 或隐藏失败;修复后从同一合同 重新验证,并保留前次失败证据。

36.6.3 从单个补丁升级为默认护栏

从 incident-specific 修复抽象不变量

单点补丁:

给 pg-prod-7 的某个脚本加一行 if

默认护栏:

任何 destructive workflow:
  必须解析 environment + cluster + system identity
  必须有 current backup/recovery evidence
  必须声明 data/traffic/authority
  必须有 exact scope、preview、rollback、approval
  缺失或冲突 -> deny/stop

抽象层次以共同机制为准,不是越通用越好。一个巨大“万能安全框架”若无法描述 PostgreSQL timeline、replication slot、PITR target 或 collation dependency,反而 会隐藏领域事实。

safer default 的五个性质

  1. 自动采用:新 service 默认获得,不靠记忆 opt-in;
  2. 显式例外:绕过需要 owner、理由、补偿控制和到期;
  3. 可观察:知道 guard 是否执行、何时失效;
  4. 可版本化:配置、policy、runbook、dashboard 共同 revision;
  5. 可验证/可退出:有反例、回退和 retirement path。

例如:

new PostgreSQL service
  default backup/archive policy
  default service endpoints and pool limits
  default SLI + PostgreSQL/host dashboards
  default safeguard and least privilege
  default restore/failover exercise registration

业务仍要填 owner、data class、RPO/RTO、good event 与不变量;模板不能替它猜。

推广前做 blast-radius 管理

平台默认变更影响面大,按:

fixture -> sandbox -> one canary service -> cohort -> default for new
-> migrate existing -> retire exception

每阶段比较 compatibility、false positive/negative、延迟与资源成本、operator burden 和 rollback。guard 误拒绝所有紧急恢复也会制造可用性风险;保留受控 break-glass,并 监控使用频率。

建 control registry

control_id: ...
objective: ...
implementation_revision: ...
default_scope: ...
exceptions: [...]
telemetry: ...
owner_role: ...
last_verified:
  at: ...
  environment: ...
  evidence: ...
valid_until: ...
related_incidents: [...]
replacement: ...

跨 postmortem 查询重复 theme,而不是逐篇人工回忆。平台 backlog 优先合并能覆盖多个 incident/service 的控制,仍保留每个 incident 的特有 action。

平台完成的定义

一个控制成为默认护栏后,仍要证明:

new service receives it
existing intended scope converged
exception inventory complete
runtime telemetry healthy
negative fixture is blocked
positive fixture is not blocked
break-glass is audited and expires
revalidation is scheduled

“已经写进 Pigsty 模板”只完成第一步。最终效果必须在 PostgreSQL、组件、用户路径和 业务不变量上共同可见。


上一节:回写 SLO、SOP 与架构 ADR · 返回本章目录 · 下一节:实战:复盘四类事故并完成全书结业 · 查看全书目录 · 查看索引中心

36.7 实战:复盘四类事故并完成全书结业

最后一次实验不再连接数据库。它把第 32~35 章公开证据作为不可改写的输入,验证我们 能否在不夸大结论的前提下形成跨事故控制路线,并把全书能力映射为可答辩的证据。

36.7.1 汇总 ch32–ch35 的证据、决策与用户影响

实验合同

先读:

静态检查会创建临时输出,编译、验证后删除;它没有网络/SSH/数据库入口:

static/labs/ch36/task.sh lint

创建一份自己的 closure bundle:

evidence_dir="$(mktemp -d /tmp/pg36-ch36-evidence.XXXXXX)"

PG36_EVIDENCE_DIR="$evidence_dir" \
  static/labs/ch36/task.sh compile

PG36_EVIDENCE_DIR="$evidence_dir" \
  static/labs/ch36/task.sh all

compile 拒绝覆盖非空目录。all 只消费已有 bundle,重新验证 source hash、JSON Pointer、控制合同、路线和 36 个 mutant。它不会执行 backlog。

输出结构

closure-report.json
postmortem-portfolio.json
roadmap-90d.json
capability-assessment.json
input-manifest.json
source-manifest.json
validation-report.json
negative-report.json
public-summary.json
review.txt

四份输入分别绑定:

../ch32/pitr-run.json
../ch33/failover-run.json
../ch34/overload-run.json
../ch35/rescue-run.json

每条 fact 保存:

{
  "id": "F35-CHECKSUM",
  "source_pointer": "/physical_page/offline_bad_checksums",
  "expected": 1,
  "actual": 1,
  "matches_source": true,
  "knowledge_stage": "during-response"
}

编译器重新从源文件解析 actual。修改报告里的数字、pointer、source schema 或 hash 都会失败;事后解释也不能改写 knowledge_stage

四份事故记录

记录 影响证据 决策 验收/边界
ch32 误写 fixture 1,000 victims、1,000 wrong outbox、外发 0 exclusive PITR + audited delta 恢复 1,000,保留 100 条合法后写,fixture loss 0
ch33 主库失效 160 attempts、130 ack、最大 ack gap 6,211.692334 ms fence → promote → reconcile → rejoin ack missing 0、unknown unresolved 0;不是生产 RTO
ch34 资源压力 flow 30/21 admitted/9 rejected;retained WAL 42,611,296 B flow 与 retention 分开 route exact fixture 清理;managed mutations 0
ch35 完整性 1 byte、1 bad checksum、XX001;另一 case checksum 0 + version mismatch trusted copy;REINDEX 后 REFRESH checksum/amcheck/业务不变量通过,原件保留

impact.kind 只能是 simulatedobserved-sandbox。报告中没有真实用户、生产数据或 生产影响。

事实与叙事分离

catalog 为每个 incident 保留:

impact statement -> source fact IDs
decision route -> basis fact IDs
ordered timeline
control theme IDs
claims not made

这使读者可以质疑叙事而不改写证据。例如 ch33 的 observed gap 确实是 6,211.692334 ms,但 claims_not_made 明确拒绝“这就是生产 RTO”;ch35 确实翻转 一个 byte,但不声称模拟真实控制器或内存故障。

36.7.2 找出跨事故重复出现的控制缺口

先找共同问题,不先宣布生产有缺陷

四次实验可以支持:

these mechanisms repeatedly matter

不能直接支持:

your production platform lacks them

所以七个结果都标为:

status = production-assessment-required
basis  = exercise-exposed-risk-not-confirmed-production-deficiency

正式报告中 production_gaps_confirmed=0。真实采用时,要用本地 inventory、policy、 runtime、访谈和演练逐项把 status 更新为 present/effectivegapexceptionnot-applicable

七个共同主题

主题 出现章节 生产评估问题
observation contract 32/33/34/35 user impact、identity、数据库与业务证据是否可关联
production claim gate 32/33/34/35 sandbox timing/route 是否会被误当目标或批准
business validation 32/33/35 恢复是否超越 process/topology 到业务事实
reversible exact scope 32/33/34/35 mutation/cleanup 是否有目标、guard、rollback
unknown outcome 32/33 timeout/ack/commit 是否能按稳定 token 对账
classifier stop route 34/35 缺失/冲突证据是否阻止错误动作
lineage and authority 32/33/35 source、timeline、writer authority 是否可证明

出现次数不等于优先级。用 production exposure、impact、现有控制强度、验证成本和多事故 覆盖率排序。

主题必须双向一致

每个 incident 列出 theme IDs,每个 theme 又列 incident IDs。validator 检查双向 membership,并要求至少两个 incident 才能称 cross-incident。删除一个主题、把主题 缩成单事故或引用不存在的 incident 都会被拒绝。

控制不是事故类型的一一映射

同一个 target-identity gate 可以保护:

  • PITR 不恢复错 cluster;
  • failover 不接纳错 lineage;
  • overload mitigation 不取消无关会话/slot;
  • rescue 不改写 managed/unique PGDATA。

同一事故也需要多类控制:

prevent   exact target + safeguard
detect    user SLI + source-bound evidence
mitigate  admission / fence / stop route
recover   restore / rejoin / reconcile

平台 backlog 优先寻找这类多边覆盖,但仍保留专用控制,例如 collation dependency 重建或 outbox 对账。

识别“控制存在但无效”

生产评估不能只问“有没有”:

implemented?
deployed to intended scope?
operating now?
tested against negative case?
effective at cutting the causal edge?
evidence still valid?
exceptions complete?

有 backup job 但从未 restore,不算已验证 recover control;有 runbook 但 current version 无法执行,也不算;有 safeguard 但 generic token 可绕过 target identity, 只能算弱控制。

36.7.3 输出 90 天改进路线、平台 backlog 与复验计划

路线是参考 proposal,不是自动变更

十二项 action 均为:

status                        proposed
production_execution_approved false

每项包含 stable owner role、P0/P1、due day、source themes、control type、artifact、 verification procedure、pass condition、evidence to close、failure condition 与 revalidation days。采用者要把 role、scope、date 和 approval 映射到自己的组织。

Day 0~30:先让风险可见、危险动作可停

Action 控制
A36-01 版本化 incident observation/evidence schema,并让缺字段 packet 失败
A36-02 destructive workflow 强制 exact target、data/traffic/authority 与 approval
A36-03 定义 request idempotency token 与 unknown-outcome reconciliation
A36-04 分开 user-impact SLI 与 PostgreSQL/host 诊断 telemetry

这一阶段不承诺完成所有架构改造,先建立 identity、证据和 stop line。没有这些基础, 后续演练可能只是在更好地记录错误动作。

Day 31~60:证明四条恢复能力

Action 控制
A36-05 多 PITR candidate + legitimate delta + business manifest
A36-06 fence/failover/client reconciliation/rejoin
A36-07 flow/retention blind classifier + ambiguous stop
A36-08 checksum/amcheck/collation review + clone-only rescue
A36-09 Pigsty inventory、release、safeguard 与 dangerous playbook review

演练用隔离环境,生产不被当作故障注入场。失败产物被保留并转 action;不能为了按期 完成把 pass condition 改成“脚本退出 0”。

Day 61~90:把局部改进变成持续系统

Action 控制
A36-10 用生产测量/有边界演练修订 SLO、RPO、RTO、容量与安全 ADR
A36-11 每月聚合重复 theme、过期 evidence 与 exception
A36-12 blind 90-day game day + 独立 effectiveness review

第 90 天不是项目结束。control registry 根据 revalidation_days 继续检查 30/60/90/180 天周期;版本、拓扑、workload 或依赖变化会提前使证据失效。

validator 如何对抗“纸面完成”

36 个 live mutant 覆盖六组失败:

source integrity
  drop incident, change fact/pointer/schema/hash

epistemic boundary
  claim production impact, rewrite knowledge stage, remove claims-not-made

cross-incident reasoning
  single-incident theme, unknown membership, missing theme

action quality
  no owner/due/verification/failure/revalidation, vague result

governance
  mark closed, auto-approve production, incomplete roadmap

graduation
  duplicate chapter coverage, auto-certify learner

正式结果:

run_id                         91c4464b-89f7-4145-9708-f07256d747ce
input files hash-bound        4
compiler source files bound  12
live mutants rejected        36 / 36
database connections          0
SSH connections               0
external dispatch             0
production mutation           0
production_ch36_gate          pending

公开摘要 SHA-256:

c00463cfedbd4d880d60d6af2f3401d568ef7549948f4cbd062c9b500e53117b

摘要见 closure-run.json。raw formal bundle 不发布, 因为公开教材只需要去敏结论和可重跑 source。

36.7.4 回看从 SQL 到生产的能力地图

十二个可答辩能力域

capability-map.json 将 36 章恰好覆盖一次:

能力域 章节 读者应能交付
context/workflow 1–2 精确身份、对象地图和可重跑任务
model/integrity 3–4 用 schema/type/constraint 编码业务不变量
transaction/programming 5–6 解释 MVCC、事务与服务端副作用
query engineering 7–9 从 plan/stats/workload 证明优化
concurrency/release 10–12 兼容发布、锁边界、rollback 与 contract test
extension workloads 13–18 按语义、生命周期、风险和退出选择扩展
service baseline 19–22 用 Pigsty 交付 route/HA/backup/recovery
security/governance 23–25 least privilege、SLO/SOP 与 observation contract
capacity/maintenance 26–28 可复现 envelope、调优、vacuum/freeze/bloat
evolution 29–30 migration/upgrade candidate、cutover 与 rollback
incident/recovery 31–35 blind classify、保护权威与证据、正确恢复
platform learning 36 将证据转成控制并独立验证效果

地图完整只证明教材没有漏章,不证明具体读者掌握。因此正式结果必须保持:

learner_assessment.status        not-assessed
automatic_certification          false
assessment_required              true

结业不是记忆命令

对每个域,读者完成四层答辩:

explain
  用自己的话说明机制、边界与反例

execute
  在授权环境完成实验并保留机器证据

diagnose
  面对隐藏场景从 evidence 选择 route,而不是背答案

design
  把一次结果转成适合本地 service 的控制、验证和复位

只会复制 psql、playbook 或恢复命令,不构成专家能力;只会讲原理、不能从 runtime 证明状态,也不构成。PostgreSQL 原生证据、Pigsty 平台状态和业务事实要互相校验。

建个人结业 portfolio

portfolio/
  environment-and-version-contract/
  schema-and-transaction-design/
  query-and-concurrency-cases/
  release-and-service-baseline/
  backup-ha-security-observability/
  capacity-maintenance-evolution/
  incident-recovery/
  postmortem-and-control/

每份 evidence 标注:

produced_by:
environment_authority:
version_and_topology:
source_hashes:
claim:
what_it_does_not_prove:
reviewer:
verified_at:
valid_until:

真正的结业由你所在环境的 owner/reviewer 根据证据决定,而不是本书脚本替组织签字。

从哪里继续

读完全书不是把 PostgreSQL 变成“学完的知识”,而是获得一套持续更新的方法:

先确认身份与目标
再从原生证据理解 PostgreSQL
用 Pigsty 把能力组合成服务
用业务不变量决定是否真的正确
用隔离演练证明恢复
用复盘和平台默认值防止同类路径重演

下一步选择一个真实但低风险的 service,把本节 12 项参考 backlog 做本地 present/gap/not-applicable/exception 评估,只批准一项最小、可验证改进。完成它、 保存效果证据、安排重验,然后再进入下一项。这比一次性宣布“数据库平台建设完成” 更接近长期可靠性。


上一节:将控制固化到平台 · 返回本章目录 · 返回全书导读 · 查看全书目录 · 查看索引中心

附录与速查

附录用于快速定位版本、证据、症状、分区、实验安全和术语边界。它们不替代正文中的 机制与实验:遇到事故先按附录 C 找到首个安全动作,再进入目标章节完成证据分类。

附录 A:版本矩阵与差异注记

冻结 PostgreSQL 18.6、Pigsty v4.5.0、pig 1.5.1 与正式 L1/L2/L3 基线;说明哪些 结论必须按版本重验,以及勘误如何保留历史适用范围。

附录 B:对象、视图、命令与证据速查

按连接、对象、事务、锁、计划、复制、WAL、backup、vacuum 和容量定位首选证据; 每个动作同时标注风险、前置与 after 验收。

附录 C:症状与首个安全动作索引

从误操作、主库/DCS、复制、连接/锁、资源、XID、WAL 和完整性症状路由到 ch31~ch35; 明确第一步和绝不能做的捷径。

附录 D:分区能力索引

串联 ch04 决策、ch07 裁剪、ch11 在线迁移、ch16 时间语义与 ch28 生命周期。

附录 E:实验拓扑、风险与复位手册

定义 L1/L2/L3 规格,区分 R0–R3 风险,解释 reset:sqlreset:clusterreset:host 以及 snapshot/checksum/evidence 合同。

附录 F:术语与技术边界表

区分 PostgreSQL、Pigsty、Patroni、DCS、PgBouncer、HAProxy、实例、两种 cluster 与 service endpoint,并对照 RDS、自建和 Operator 的责任。


返回全书导读 · 查看全书目录 · 查看索引中心

附录 A:版本矩阵与差异注记

本附录是全书的版本控制面。正文中的原理尽量保持跨小版本稳定,但命令、默认值、组件组合和界面必须绑定实际版本。读者复现实验时,先记录事实,再判断差异是否影响结论。

A.1 PostgreSQL、Pigsty、OS、Patroni、PgBouncer、备份工具与扩展版本

本书复现基线:

层次 基线 说明
PostgreSQL 服务端 18.6 正式实验基线
PostgreSQL 兼容阅读范围 14–18 仅在结论确实成立时采用;差异必须显式说明
Pigsty v4.5.0 2026-07-10 正式发布版本
pig CLI 1.5.1 L2/L3 正式实验观察版本;与 Pigsty release 分开记录
L1 参考 OS Ubuntu 24.04.4 LTS AMD64 与 ARM64 均可;记录实际补丁版本
L2 正式环境 Ubuntu 24.04 / aarch64 四 VM pg-meta + 3×pg-test;共享 hypervisor
L3 正式环境 L2 host 上的私有 disposable PG18.6 clone exact temporary root、Unix socket、无业务路由

L2 的三台 pg-test VM 在正式 run 中只有 1 vCPU、约 1.9 GiB RAM,是明确记录的 sandbox exception;它证明实验在该下限跑通,不构成生产 sizing。精确资源、网络和 限制见附录 Ech19/requirements.json

Patroni、PgBouncer、HAProxy、pgBackRest 与扩展的小版本可能随操作系统仓库和离线包变化,因此不在正文中假定一个虚假的全平台统一值。进入实验节点后采样:

{
  printf 'captured_at=%s\n' "$(date -Is)"
  uname -a
  cat /etc/os-release
  postgres --version
  psql --version
  patronictl version
  pgbouncer --version
  haproxy -v
  pgbackrest version
} > component-versions.txt 2>&1

命令不存在或需要不同 PATH 时,保留失败输出并从软件包管理器补充,不得把“未采集”写成“未安装”。服务端 PostgreSQL 版本还要从连接内部复核:

SELECT
    current_setting('server_version') AS server_version,
    current_setting('server_version_num') AS server_version_num,
    version() AS build;

扩展分为“操作系统已提供”和“当前数据库已安装”两层。后者使用:

SELECT extname, extversion
FROM pg_catalog.pg_extension
ORDER BY extname;

不要用 pg_available_extensions 代替已安装清单,也不要假设一个数据库安装的扩展会自动出现在同实例的其他数据库中。

A.2 强版本相关行为:并发 DDL、预备语句、排序规则、升级与恢复

下列主题不得只写“PostgreSQL 支持”:

主题 必须绑定的版本或环境
并发 DDL、锁级别与快速默认值 PostgreSQL 大版本、对象状态与表规模
驱动预备语句与 PgBouncer 驱动、PgBouncer 版本和池化模式
locale、collation 与索引一致性 PostgreSQL、libc/ICU/builtin 提供者及操作系统
pg_upgrade 与逻辑迁移 源/目标大版本、扩展二进制与排序规则
备份、WAL 与 PITR PostgreSQL、pgBackRest、仓库格式与时间线
系统目录和统计视图列 PostgreSQL 大版本
Pigsty 参数、端口、Playbook 与面板 Pigsty 发布版本与所用配置模板

强版本相关实验在正文中同时给出“本书基线的已验证路径”和“迁移到其他版本时要重新验证的观察点”。不能验证的行为明确标为未决,不用相近版本输出冒充。

A.3 版本增量通过记录与勘误链接

每次升级复现基线都执行一次版本增量验证:

  1. 创建全新的 L1,记录安装制品校验值与全部版本;
  2. 从 ch01 开始运行 setup、exercise、verify 与 reset;
  3. 对比系统目录、命令输出、默认值和服务路由;
  4. 将差异分为“输出变化”“行为变化”“安全边界变化”“实验失效”;
  5. 修正文稿与脚本,并记录最小受影响版本范围;
  6. 方法、架构、事故三类代表性章节通过后,再推进全书回归。

勘误记录至少包含:

字段 含义
发现版本 问题出现在哪个 PostgreSQL、Pigsty 或组件版本
影响页面 稳定 URL 与小节编号
原结论 当时成立的版本和条件
修正结论 新版本行为与证据
读者动作 是否需要修改脚本、重建实验或采取安全措施
验证状态 未复现、已复现、已修正、已回归

版本更新不覆盖历史事实。若旧版行为在当时确实成立,应保留适用范围并补充新行为;只有事实本身错误时才作为勘误修正。

附录 B:对象、视图、命令与证据速查

本附录用于事故前后的快速定位,不替代正文中的机制、权限与风险判断。所有视图和命令 按 PostgreSQL 18 / Pigsty v4.5 基线列出;跨版本先查附录 A

B.1 连接、角色、对象、事务、锁和计划

身份与对象

问题 首选证据 注意
连到哪个 server inet_server_addr/port()version() Unix socket 时地址/端口可为 NULL
哪个 database/role current_database()current_usersession_user role 是 cluster-wide,database 不是
对象从哪解析 SHOW search_pathcurrent_schemas(true) 临时 schema 与 $user 会改变结果
是否 recovery pg_is_in_recovery() 不能单独证明 route/authority
哪个 schema/object pg_class + pg_namespaceregclass 名称需 schema-qualified
对象 owner/ACL pg_get_userbyid(relowner)\dpaclexplode owner、membership、grant 要合并判断
extension 已安装 pg_extension 不等同于 pg_available_extensions

最小 identity:

SELECT
    current_database(),
    current_user,
    session_user,
    inet_server_addr(),
    inet_server_port(),
    pg_is_in_recovery(),
    current_setting('server_version_num');

psql

命令 用途
\conninfo 当前连接摘要
\l+ / \dn+ database / schema
\dtS+ pattern / \diS+ pattern table / index
\d+ schema.object 对象定义摘要
\df+ pattern / \dx+ function / extension
\du+ / \dp role / ACL
\gdesc 只描述结果列,不执行取数
\gx expanded result

元命令适合交互探索;可审计脚本应同时保存等价 catalog query、目标 identity 和版本。

会话、事务与锁

问题 视图/函数 关键列
谁在运行/等待 pg_stat_activity pidbackend_typestatewait_event_type/eventxact_start
谁阻塞 PID pg_blocking_pids(pid) 结果是 blocker PID 数组
持有哪些锁 pg_locks locktype、对象 identity、modegranted
prepared transaction pg_prepared_xacts transactionpreparedownerdatabase
当前 backend XID/XMIN pg_stat_activity backend_xidbackend_xmin
数据库事务计数 pg_stat_database counter 受 stats reset 影响

等待链骨架:

SELECT
    a.pid,
    a.application_name,
    a.state,
    a.wait_event_type,
    a.wait_event,
    a.xact_start,
    pg_catalog.pg_blocking_pids(a.pid) AS blocking_pids
FROM pg_catalog.pg_stat_activity AS a
WHERE a.datname = current_database()
ORDER BY a.xact_start NULLS LAST, a.pid;

query text 可能含敏感数据、被截断或因权限不可见。取消/终止 backend 是有副作用动作, 先绑定 exact PID + backend start + application/user/database + expected/stop。

计划与语句

工具 能回答 不能单独回答
EXPLAIN planner 估算和选路 实际时间、cache/I/O
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS) 实际执行与资源投影 全部并发/OS/历史上下文
pg_stat_statements 聚合 workload 单次 timeline、未归一化业务语义
auto_explain 被采样语句计划 完整 workload;有 logging 开销
pg_stat_io backend/object context I/O 计数 device latency 的全部机理

ANALYZE 选项会真的执行语句;对 INSERT/UPDATE/DELETE/MERGE 或 volatile function, 先在可回滚/隔离环境设计,不要对生产写语句直接照抄。正文: ch07ch08ch09ch10

B.2 复制、备份、vacuum、WAL 与容量

复制、权威与 WAL

问题 证据 关键边界
primary 看 replicas pg_stat_replication 一行是 walsender,不自动等于业务健康
standby 看 receiver pg_stat_wal_receiver source/LSN/status
slot 保留什么 pg_replication_slots slot_typeactivexmincatalog_xminrestart_lsn
subscription 状态 pg_stat_subscription* logical replication 语义不同
archive 是否推进 pg_stat_archiver + repository counter 与实际可恢复性不同
WAL 位置差 pg_current_wal_lsn() / replay/receive LSN byte lag 不是 time/RPO
timeline/system id control data、Patroni/DCS、backup metadata 不公开 raw identifier 时保存一致性投影

不要手工删除 pg_wal。WAL 撑盘先判 archive、slot、replica、backup/restore owner;见 ch34附录 C

backup 与 restore

证据 用途
pgbackrest info backup set、timeline、size、status
pgbackrest check stanza/repository/archive 基础检查
backup/repository manifest source、hash、retention
isolated restore output 实际可读性与阶段时间
PostgreSQL control + SQL identity 恢复后的 lineage/target
business manifest 业务 cutoff 与不变量

“最近 backup success”不等于能在目标 RTO 内恢复,也不证明正确 target。恢复必须在隔离 candidate 验证;见 ch21ch32

vacuum、freeze 与膨胀

问题 证据
table maintenance pg_stat_all_tablespg_stat_progress_vacuum
relation age age(relfrozenxid)mxid_age(relminmxid)
database age age(datfrozenxid)
blockers pg_stat_activity.backend_xmin、slot xmin/catalog_xmin、prepared xacts
dead/live estimates n_dead_tupn_live_tup(估算)
relation bytes pg_relation_sizepg_total_relation_size
index validity/use pg_indexpg_stat_all_indexesamcheck

膨胀不是单一准确 counter。stats 是估算且可 reset;结合 page/sample/extension 工具时 记录版本、锁和开销。见 ch28

容量与配置

work arrival / concurrency / queue
CPU run and saturation
memory budget and OOM/swap
I/O latency, throughput and queue
connections and per-operation memory
WAL/archive/replication retention
XID/multixact age
data/index/temp/log/backup storage

PostgreSQL:

证据 说明
pg_settings value、unit、source、context、pending_restart
pg_stat_database database workload counters
pg_stat_wal / pg_stat_bgwriter / pg_stat_checkpointer WAL/checkpoint/background write
pg_stat_io backend/object/context I/O
pg_stat_activity sessions、transactions、wait
pg_stat_progress_* 部分长任务进度

同时采样 OS vmstatiostatpidstat/cgroup/host metrics。数据库 counter 不能解释 所有 kernel/device 行为。见 ch25ch27

B.3 每项命令的风险等级、适用范围和验证方式

先填 action card

target:
  environment:
  cluster/system:
  instance/database/object/session:
risk: R0 | R1 | R2 | R3
authority:
preconditions:
expected:
stop:
rollback_or_recovery:
before_evidence:
after_evidence:
风险 定义 示例类别 最低要求
R0 观察 不改变目标状态 identity、catalog/stat、plan without ANALYZE exact context、成本/隐私边界
R1 可逆变更 改对象/配置/流量,有验证过的回退 fixture DDL、reload、bounded cancel、canary owner、scope、before/after、rollback
R2 受控状态变更/演练 有非平凡状态影响,但范围隔离且恢复路径已验证 精确 cancel、一次性对象删除、隔离 PITR/failover、byte fault guard、批准、恢复源、停止线、证据
R3 生产敏感/潜在不可逆 触及真实数据/流量、authority/lineage,或恢复昂贵 生产 failover/cutover、rewind/reinit、host rebuild、pg_resetwal 原件保留、明确授权、独立复核、业务验收

风险由目标与后果决定,不由命令长短决定。同一 PITR 机制在一次性隔离 candidate 上可为 R2,切换生产 authority 或覆盖真实目标时应升为 R3。SELECT 可调用 volatile/security definer function;EXPLAIN ANALYZE 可执行写入;VACUUM FULLREINDEX、DDL 和 playbook 可能持锁、重写、重启或改变路由。

常见动作速查

动作 通常风险 前置 after
catalog/stat query R0 role、database、query cost timestamp、rows、source
ANALYZE R1 workload/lock/I/O window stats timestamp、plan
CREATE INDEX CONCURRENTLY R1 version、invalid index、disk/WAL indisvalid/indisready、plan
parameter reload R1 context/source、rendered diff pg_settings + runtime
restart-required config R1/R2 HA/traffic/rollback identity、role、availability
cancel exact query R1 PID reuse protection、owner target gone、business effect
switchover/failover R2/R3 fence/authority/candidate/client contract timeline、route、unknown
restore/PITR R2/R3 source/target/candidate/isolated destination lineage、business manifest
pg_rewind/base backup R2/R3 system id/timeline/source direction streaming lineage
checksum fault injection R2 stopped disposable clone original hash + recovery copy

pg_resetwalzero_damaged_pagesignore_checksum_failure、手改 relation/WAL 不属于 普通速查动作;仅在证据 clone、明确损失和专业升级下考虑,见 ch35

验证模板

before
  exact identity + objective + independent baseline

action
  command/source hash + parameters + exit/stdout/stderr + authority

after
  expected observation + no-regression + business invariant

restore
  temporary artifacts removed or retained by policy

boundary
  what this evidence does not prove

退出码 0、service active 和 dashboard green 都只能证明局部命题。


返回附录目录 · 症状索引 · 实验风险与复位 · 查看全书目录

附录 C:症状与首个安全动作索引

这是“先别把事故变糟”的路由表,不是自动诊断器。任何症状先确认 environment、 cluster/system identity、用户影响、数据风险和变化速度;证据缺失或冲突时进入 ch31 的 STOP_AND_ESCALATE,不要强行匹配一行。

C.1 误删误改、主节点故障、DCS 故障、复制停滞

症状 首个安全动作 首批证据 路由 禁止捷径
误删/误改 停止继续写与外部副作用,保留 audit/WAL/backup exact transaction、时间/XID/LSN、影响对象、合法后写 ch32 PITR 在原库盲目反向 SQL、删除 WAL
writer 不可达 从 user path 到 proxy/DB 分层确认,并保护单 writer authority endpoint、HAProxy、Patroni/DCS、role/timeline、client unknown ch33 failover 未围栏旧主就强制 promotion
DCS 异常 暂停扩大 authority 的动作,确认 quorum/failsafe/watchdog 与节点视图 member health、leader/term/revision、network 分区、DB role ch33 DCS 删 key、重建 DCS 后宣称 lineage 安全
replica 停滞 保护 primary 与 WAL,识别 receive/replay/network/slot/source sender/receiver、LSN、timeline、logs、disk、slot ch20 HAch33 立即 reinit,先抹掉故障证据

误操作的最小记录

who/role/application
exact database/schema/object
transaction identity and commit status
first known bad / last known good
external dispatch and caches
backup + WAL coverage
post-target legitimate writes

应用 timeout 不能证明 transaction 回滚;先用 request/idempotency token 对账。

“主库故障”的分层

user/client
  -> DNS/VIP/HAProxy
  -> PgBouncer
  -> PostgreSQL listener/session
  -> Patroni/DCS authority
  -> storage/host/network

代理错误不应触发数据库 promotion;进程停止也不等于硬件已围栏。每层用独立证据, 接受新 writer 前证明旧 writer 不能继续拥有 authority。

C.2 连接耗尽、锁等待、CPU、内存、I/O 与 OOM

症状 首个安全动作 先分辨 禁止捷径
connection exhausted 在入口阻止新放大,保留管理通道 pool wait、server session、role/app、retry 先调大 max_connections
lock wait 建 blocker/waiter graph,保护业务 owner lock queue、long xact、DDL、prepared xact 无差别 kill 全库
CPU 高 观察 run queue、query mix、plan、spin/系统进程 demand、单 query、并行、vacuum、非 DB 仅凭 load average 重启
memory/OOM 限制新工作,保存 kernel/cgroup/PostgreSQL 证据 resident/cache、per-op memory、并发、OOM victim drop cache、反复拉起
I/O 慢 降低非关键 I/O,区分 latency/queue/throughput device/fs、checkpoint、WAL、temp、backup 同时重启所有组件

flow pressure 的首要目标

reduce arrival
reduce concurrency
reduce per-item cost
protect critical lane

按 service/role/application_name/query class 精确限流、降级或取消,并定义 expected、 stop、rollback。客户端 retry 没有 backoff/jitter/idempotency 时,会把短故障放大为 持续过载。

retention pressure 不走限流捷径

WAL、XID 或磁盘满可能由仍被声明为“需要”的历史边界造成。取消慢 SQL 不一定推进 slot restart_lsn 或旧 xmin。先查 owner、恢复/复制语义,再清 exact owned consumer。

详见 ch22 连接预算ch25 可观测ch34 资源事故

C.3 XID 回卷:先查 backend_xmin、复制槽 xminpg_prepared_xacts

首个目标:找谁钉住 horizon

SELECT
    datname,
    age(datfrozenxid) AS xid_age
FROM pg_catalog.pg_database
ORDER BY xid_age DESC;

SELECT
    pid,
    datname,
    usename,
    application_name,
    backend_xmin,
    xact_start,
    state,
    wait_event_type,
    wait_event
FROM pg_catalog.pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY xact_start NULLS LAST;

SELECT
    slot_name,
    slot_type,
    active,
    xmin,
    catalog_xmin,
    restart_lsn
FROM pg_catalog.pg_replication_slots;

SELECT *
FROM pg_catalog.pg_prepared_xacts
ORDER BY prepared;

再查:

autovacuum/freeze progress and logs
table relfrozenxid/relminmxid age
long-running idle-in-transaction
logical decoder/subscriber owner
prepared transaction business owner
disk and WAL headroom

动作边界

  • 先阻止新的长事务/无界读取,保护 maintenance lane;
  • exact backend 取消/终止需要业务 owner 和 commit/rollback 影响判断;
  • slot 可能代表 DR、CDC 或恢复承诺,不能只因 inactive 删除;
  • prepared transaction 要按业务协议 commit/rollback,不能猜;
  • 提高 freeze 参数或跑更激进 vacuum 前确认 I/O、WAL、lock 与时间余量;
  • 接近 wraparound 时升级 severity 与 authority,不在压力下尝试不熟悉的 catalog 修改。

路由:ch28 VACUUM、冻结与膨胀; 资源止血见 ch34.6

C.4 WAL 撑盘:先查归档、复制槽和备份保留者,绝不手工删除 pg_wal

先建立 conservation picture

generation rate
  pg_stat_wal + workload/checkpoint

archive
  pg_stat_archiver + archive logs + repository

physical/logical consumers
  pg_stat_replication + pg_replication_slots + subscriptions

restore/backup
  pgBackRest process, spool, lock and repository

filesystem
  PGDATA/pg_wal mount, free bytes/inodes, I/O errors

常见分类:

证据 方向
archive failed_count 增长/last success 停滞 修 archive destination/auth/network
inactive slot restart LSN 不动 找 owner,保护证据,再决定 consumer/slot
replica receive/replay 停滞 分 network/storage/query/recovery
WAL 生成率暴增但消费者正常 flow/query/checkpoint/DDL/backup workload
filesystem error/只读/OOM 基础设施事故,先保护数据

首个安全动作

  1. 停止非关键的大写入、bulk/DDL 与 retry 放大;
  2. 保留管理连接和当前 slot/archive/replication evidence;
  3. 估算 time-to-full,而不是只报百分比;
  4. 确认能否安全扩容/迁移 filesystem;
  5. 修复 exact owned consumer,或在审批后清理;
  6. 验证 archive continuity、replica/slot 和 backup。

绝不手工删除 pg_wal、伪造 archive success 或随意 pg_resetwal。这些动作会破坏 crash recovery、replication 或 PITR,且可能把可恢复事故变成不可恢复损坏。

C.5 checksum、索引、collation 与逻辑不一致

症状 首个安全动作 分类证据 主要恢复源
checksum/invalid page/I/O 停写或隔离、snapshot、hash 原件 checksum、relation/block、kernel/storage backup/健康副本/snapshot
amcheck 索引异常 保留 heap 与索引证据,查同故障域 index check、heap check、checksum heap + 正确规则重建
collation version mismatch 枚举 exact dependencies,不先消 warning stored/actual version、provider、amcheck REINDEX derived objects 后 REFRESH
合法 page 但业务错误 阻止副作用,定义 affected fact/cutoff audit、ledger、不变量、external PITR/审计/upstream/补偿

不能互相替代

checksum clean
  != index order correct
  != business data correct

amcheck pass
  != heap/page/storage safe

service starts
  != recovered data trusted

抢救先保存 original evidence,再从同一 snapshot 分叉 working clone。危险恢复参数仅在 clone、明确接受损失和专业升级下使用。详见 ch35 数据抢救与取证

C.6 每一行同时标明目标章节、首个安全动作和禁止动作

总路由

入口症状 首个安全动作 目标章节 禁止动作
影响不明、证据冲突 建 identity/impact/evidence,保持可逆 ch31 根据第一个告警猜根因
误写/误删 停副作用、保存 audit/WAL/backup ch32 原库反复试回滚
primary/DCS/lineage 保护单 writer authority、先围栏 ch33 无 fence 强制切换
慢/满/连不上 分 flow 与 retention ch34 统一用 restart/扩连接
page/index/collation/语义 原件 snapshot/hash,clone 分类 ch35 改唯一副本、删 WAL
服务已恢复 清临时控制、复盘、验证 action ch36 以 ticket/PR 代替效果

首个动作卡

symptom:
target_identity:
user_impact:
data_and_recovery_risk:
changing_now:
first_safe_action:
evidence_before:
expected:
stop:
rollback:
owner_and_authority:
route_if_supported:
route_if_unknown: STOP_AND_ESCALATE

若没有权限执行首个动作,正确动作是升级 owner 并继续只读取证,而不是扩大权限范围。


返回附录目录 · 对象与证据速查 · 实验风险与复位 · 查看全书目录

附录 D:分区能力索引

分区不是一个孤立功能:是否该用、查询能否裁剪、如何在线迁移、时间边界怎样表达、 旧分区如何冻结/退役,分布在五个章节。本附录把它们串成一条生命周期。

D.1 ch04:分区决策门

ch04.6 先问:

dominant lifecycle boundary?
queries usually constrain the same key?
retention/archive needs cheap detach/drop?
maintenance can benefit from smaller independent relations?
number and creation rate of partitions remain bounded?

不要因为“表会变大”自动分区。分区会增加:

  • parent/child catalog、DDL、statistics 与 plan 开销;
  • partition creation/retention automation;
  • constraint/unique/FK 设计限制;
  • prepared/generic plan 与参数裁剪不确定性;
  • cross-partition query/index/maintenance 复杂度;
  • migration、default partition 和 late-arriving data 处理。

key 选择

策略 适合 风险
RANGE(time/id) 时间生命周期、递增范围 hot partition、时区/边界、未来 partition
LIST(tenant/region/state) 少量稳定离散域 key 增长、skew、default 膨胀
HASH(key) 均匀分布/并行维护 生命周期语义弱、重分片成本
multi-level 同时有生命周期与隔离 partition 数和运维复杂度乘积

使用 [start, end) 边界,显式时区与 catch-all/拒绝策略。parent-level PRIMARY KEY / UNIQUE 必须满足当前 PostgreSQL 对 partition key 的要求;不能假设多个本地索引自动 提供任意全局唯一性。

决策交付物

decision: partition | do-not-partition | revisit
key_and_method:
business_and_retention_boundary:
query_predicates:
partition_count_now_and_horizon:
unique_fk_constraints:
late_and_future_data:
automation_owner:
evidence:
revisit_trigger:

D.2 ch07:规划时/执行时裁剪与父表统计

ch07.4EXPLAIN 区分:

plan-time pruning
  常量/可折叠表达式在规划时排除 partition

execution-time pruning
  parameter/nested-loop value 在 executor 初始化或运行阶段排除

检查:

SHOW enable_partition_pruning;

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE)
SELECT ...
FROM partitioned_parent
WHERE partition_key >= $1
  AND partition_key <  $2;

关注:

Subplans Removed
loops = 0
实际访问的 child relation
parent/child row estimates
planning time and partition count

常见裁剪失败

  • predicate 没落在 partition key;
  • 隐式 cast、时区或函数阻止匹配;
  • wrapper/表达式与 partition bound 不同;
  • generic/custom prepared plan 行为不同;
  • join value 只能在执行阶段知道;
  • default partition 覆盖过大;
  • 误把 constraint exclusion 与 declarative pruning 混为一谈。

“查询结果快”不证明裁剪;小数据可能全扫仍快。保存 plan、参数、table definition、 statistics 和 server version。

statistics

parent/child 的 statistics、autovacuum/analyze 与增量数据分布可能不同。检查:

parent estimates vs actual
hot/current child statistics freshness
partition key and correlated columns
default partition skew
newly attached partition analyze state

不要只在一个 child ANALYZE 后推断 parent workload 已正确估算。

D.3 ch11:在线分区化

ch11.4 把“改成分区表”当迁移项目:

expand
  create partitioned parent, children, indexes, constraints

migrate
  backfill bounded ranges; capture concurrent delta

validate
  counts/digests/constraints/query plans/replica lag

cut over
  bounded lock; route old/new application versions

contract
  stop dual path; retain rollback; retire old table

PostgreSQL 不能把普通表原地无成本变成 partitioned parent。迁移策略可用新表、shadow write、logical change capture、短暂停写或 ATTACH PARTITION,但每种都要重新验证 锁、WAL、trigger/FK、sequence、replica 和 rollback。

ATTACH PARTITION

若待 attach 表已有能证明 bound 的匹配 CHECK constraint,PostgreSQL 可避免为验证 partition constraint 扫描它;具体锁与扫描行为绑定版本与对象状态。default partition 还可能需要验证它不含新 range 数据。执行前:

exact bound and no overlap
matching columns/types/collations
constraints and indexes
no rows outside bound
default partition impact
parent/child concurrent traffic
lock timeout and stop

attach 后再验证 parent query、direct child access、privilege、trigger、FK、stats 与 backup/replication。

dual write 风险

应用双写或 trigger capture 可能产生:

ordering difference
partial commit across systems
duplicate/retry
hidden trigger side effect
sequence drift
old/new schema incompatibility

优先同一 transaction 内可验证机制;仍需 source-of-truth、reconciliation 和 cutover watermark。不要以两个 row count 相等作为唯一证明。

D.4 ch16:时间分区场景

ch16.2 先定义时间:

event time       业务事件发生
ingest time      系统接收
effective time   业务事实生效
system time      数据库记录版本

partition key 必须匹配主要生命周期和查询。按 ingest time 分区容易接收 late event, 却不一定裁剪 event-time 查询;按 event time 分区需要 future/late/default 策略。

边界规则

store timestamptz for global instant
choose one canonical timezone for bounds
use half-open [from, to)
generate future partitions ahead of time
alert before current partition end
define late-arrival and backfill authority

不要用本地日期字符串猜 DST 边界。保存实际 bound:

SELECT
    inhparent::pg_catalog.regclass AS parent,
    inhrelid::pg_catalog.regclass AS child,
    pg_catalog.pg_get_expr(c.relpartbound, c.oid) AS bound
FROM pg_catalog.pg_inherits AS i
JOIN pg_catalog.pg_class AS c ON c.oid = i.inhrelid
WHERE inhparent = 'schema.parent'::pg_catalog.regclass
ORDER BY child::text;

partition 内索引

时间 range 裁剪减少 child 数,child 内仍要按谓词、排序和 join 选 B-tree/BRIN/GiST 等。 BRIN 依赖物理相关性,不是“时序表默认更快”;空间 + 时间查询还要验证两种 selectivity 如何组合。

D.5 ch28:分区生命周期、冻结与退役

ch28.5 把 partition state 作为有限状态机:

future -> writable -> sealed -> validated -> archived
       -> detached -> retained -> dropped

每次 transition 有:

partition:
bound:
state_before:
preconditions:
business_retention:
legal_hold:
backup_or_export:
freeze_and_visibility:
dependent_objects:
action:
validation:
rollback_or_re-attach:
owner:

sealed 不等于无需 vacuum

旧 partition 即使不再业务写入,仍可能需要:

  • freeze XID/multixact;
  • 清理过去更新留下的 dead tuple;
  • 更新 visibility map;
  • 完成 index/constraint validation;
  • 处理仍引用它的 snapshot/slot/prepared transaction。

观察每个 child 的 age、stats 和 size,不只看 parent aggregate。

detach/drop 与大 DELETE

按完整 partition 退役通常能避免逐行 DELETE 的大量 WAL/dead tuples,但 DDL 仍有锁、 依赖、replication、backup 与业务风险。先确认:

bound fully outside retention
no legal/audit hold
archive/export is readable
queries no longer require it
FK/view/publication/privilege dependencies understood
exact partition identity
rollback window

DETACH 后对象仍占空间;DROP 才释放 relation,且是不可逆 schema/data action。不要 把 retention policy 直接变成无人审批的自动 drop。

五章闭环检查

ch04 decision still valid?
ch07 representative queries prune?
ch11 migration/cutover evidence retained?
ch16 time semantics and late data correct?
ch28 creation/seal/archive/drop automation healthy?

任一答案未知,先修生命周期合同,不急于增加 partition 数。


返回附录目录 · 对象与证据速查 · ch04 分区决策门 · 查看全书目录

附录 E:实验拓扑、风险与复位手册

本附录定义实验“在哪里运行、能改变什么、怎样回到可信状态”。它不是生产授权书; 每个章节的 lab contract 与当前环境 authority 优先。

E.1 L1/L2/L3 资源规格、网络和成本说明

L1:单节点学习环境

安装下限 本书最低 推荐
node 1 1 1
vCPU 1 2 4
RAM 2 GiB 4 GiB 8 GiB
可用磁盘 20 GiB 40 GiB 80 GiB

用途:ch01~ch18 的对象、SQL、应用与 extension PoC。默认单节点 meta 模板包含 PostgreSQL 与可观测组件,但不提供独立故障域或生产 HA。见第 0 章

L2:四 VM 生产仿真

正式拓扑 pg36-l2-vagrant

pg-meta-1  10.10.10.10  control + single PostgreSQL service
pg-test-1  10.10.10.11  pg-test primary at baseline
pg-test-2  10.10.10.12  pg-test replica
pg-test-3  10.10.10.13  pg-test replica/offline + disposable drill host

合同:

Pigsty v4.5.0 / PostgreSQL 18
Ubuntu 24.04 / aarch64 in formal run
UTC + synchronized clocks
data_checksums=on / UTF8 / C.UTF-8 / scram-sha-256 / SSL
distinct machine identities
minimum root free 8 GiB at acceptance

三台 pg-test VM 的正式教学运行只有 1 vCPU、约 1.9 GiB RAM;这被记录为 accepted sandbox exception。推荐至少给每个 PostgreSQL VM 2 vCPU / 2 GiB,控制/压测 client 使用 2 vCPU / 4 GiB 以上,并为 WAL、backup、clone 和 fixture 预留更多磁盘。

所有 VM 共享一台 laptop/hypervisor/power/storage,因此:

3 PostgreSQL members != 3 production failure domains
1 etcd member          != production DCS quorum
local MinIO/repository != offsite disaster recovery
virtual disk result    != production IOPS/durability

ch19 正式合同

L3:从可信点分叉的事故现场

L3 不是“把 L2 破坏得更严重”,而是:

managed L2 read-only/controlled boundary
  +
stopped snapshot or deterministic source
  ->
private disposable case/working/recovery copies

第 32~35 章在 L2 host 上使用 exact UUID temporary root、private Unix socket、 非业务端口/无 TCP listener,并与 Patroni、DCS、HAProxy、PgBouncer、backup repository 和业务 route 隔离。L3 需要额外磁盘至少容纳 source + cases + working/recovery + evidence;运行前按 fixture 实测,而不是假定固定 8 GiB 足够。

网络

operator -> SSH to exact lab aliases
L2 internal 10.10.10.0/24 teaching network
service path and direct instance path both identifiable
disposable cluster listen_addresses='' where contract requires
no production route or public exposure

成本

本地成本来自 host RAM/CPU、磁盘、耗电与时间;云端另有 compute、volume/snapshot、 public IP、egress、object storage/API。价格随 region/日期变化,本书不冻结金额。每个 环境设置 owner、expiration 与 budget alert,并把保留 evidence 的费用计入。

E.2 R0–R3 风险标记

L1/L2/L3 描述实验环境;R0–R3 描述动作风险。两者不能互推:L3 中仍有 R0 查询,L1 上误删唯一数据仍是破坏性动作。

风险 定义 例子 必备
R0 观察 不改变目标状态 identity/catalog/stats、plain EXPLAIN、capture exact context、query cost、隐私
R1 可逆变更 改状态但有已验证回退 fixture DDL、bounded config、canary、精确 cancel owner、before/after、stop、rollback
R2 受控状态变更/演练 有非平凡状态影响,但范围隔离且恢复路径已验证 一次性对象删除、隔离 PITR/failover、fault injection guard、批准、恢复源、停止线、证据
R3 生产敏感/潜在不可逆 触及真实数据/流量、authority/lineage,或恢复昂贵 生产 failover/cutover、rewind/reinit、host rebuild、pg_resetwal 原件保留、明确授权、独立复核、业务验收

同一命令没有固定风险等级:在 disposable clone 上恢复一份 candidate 可以是 R2;让它 接管生产流量、覆盖真实目标或改变唯一权威时就是 R3。

风险升级因素

production data or traffic
target identity ambiguity
large or unknown scope
irreversible external effect
unique copy
weak/untested rollback
authority/lineage change
service restart or route change
long lock / resource saturation
secret or personal data exposure

任一因素都可能把看似普通命令升级。风险低不代表无成本:复杂 catalog query、 EXPLAIN ANALYZE、日志导出也可能造成负载或泄露。

guard 不是免责声明

target:
environment:
production_data:
production_traffic:
scope:
confirmation:
authority:
rollback_source:

guard 必须在 mutation 前解析并 fail closed。设置 I_KNOW_WHAT_I_AM_DOING=true 这种通用 token 不证明 target 或 authority。

各章脚本还可能使用 L0/L1/L2/L3 表示其内部 mutation level;以对应 lab-contract.md 定义为准,不与拓扑层级混用。

E.3 reset:sqlreset:clusterreset:host

三种复位不是强度旋钮

复位 目标 不应做
reset:sql 删除/重建本章 owned fixture,恢复数据库对象起点 drop 未解析 schema/database
reset:cluster 恢复服务、角色、配置、路由与本章 fixture baseline 删除 managed PGDATA 猜测重建
reset:host 从 clean OS/storage + pinned inventory 重建不可信宿主机 当作一条普通可复制命令

名称是全书实验合同类别,不保证每章存在同名脚本。

reset:sql

前置:

current database/role/system identity match
owned object manifest complete
no production data/traffic
dependent session/job stopped
only pg36 chapter namespace/object selected

执行后验证 object absent/recreated、其他 schema digest 不变、connection context 仍正确。 用明确对象列表,不使用模糊 wildcard/cascade。

reset:cluster

可能包含:

remove exact fixture sessions/roles/schema
restore parameter and pending_restart state
restore Patroni role/topology baseline
restore HAProxy/PgBouncer route
re-enable archive/backup/monitoring/automation
reconcile temporary privilege and secret

先生成 plan,逐项 before/after。计划切换后的 baseline restore 仍是 HA 变更,需要 authority 和 client validation;不是测试清理的附带步骤。

reset:host

只有当 OS、storage、package 或 PostgreSQL 基线不再可信才进入:

preserve/hash evidence outside target
fence host from writer authority and routes
prove trusted backup or healthy source
pin inventory/release/packages/secrets
obtain destructive production approval
provision clean host/storage
restore or join as fresh replica
validate lineage, backup, monitoring, business
observe before traffic

不要复用 suspect PGDATA,不删除唯一 evidence。第 35 章只生成 l3-rebuild-plan.json,没有执行 managed reset:host

reset 也需要验证器

exact target resolved
owned fixture removed
unrelated object/topology unchanged
temporary process stopped
route/automation restored
retained evidence still present
cleanup path exact

“脚本执行完”不等于 baseline 已恢复。

E.4 随机种子、快照、校验和与故障场景清单

可复现 fixture

generator_revision:
seed:
row_count:
distribution:
time_anchor:
locale_timezone:
scale:
expected:
  counts:
  sums:
  ordered_digest:

固定 seed 不足以保证相同结果;generator、PRNG、locale、timezone、dependency 与输入 排序都要固定。摘要至少组合 row count、关键 sum/range 与确定顺序 digest。

snapshot 树

known-good immutable source
  -> case-A original
     -> working-A1
     -> working-A2
  -> case-B original
     -> recovery-B

记录 snapshot ID、parent、created_at、filesystem/database consistency、system identifier/timeline projection 和 hash。实验只改 case/working,known-good 与 original 在结论完成前保持不变。

checksum 的四种含义

checksum/hash 证明
PostgreSQL data checksum data page 写入/读取校验范围内的物理一致性
file SHA-256 同一 byte stream 未变
canonical JSON/source hash 合同/证据 source 未漂移
business digest 所选字段/顺序在定义范围内一致

它们不能互换。hash 匹配不证明来源可信,business digest 不证明每个 page 可读。

场景清单

scenario_id:
hidden_truth:
public_packet:
allowed_mutations:
forbidden_targets:
required_evidence:
classifier_predicates:
unknown_route:
safe_actions:
dangerous_actions:
cleanup:
claims_not_made:

blind exercise 把 hidden truth 与 participant/classifier input 分离;场景 source 在公开 教材中不是密码学秘密,正式考核由主持人控制访问或生成私有 seed。

evidence bundle

contract + source manifest
environment identity
before / during / after
blind packet + hidden answer
decision/classification
business manifest
negative cases
cleanup
redacted public summary

raw logs、PGDATA、query payload、credentials 和个人数据留在受控 evidence store, 仓库只发布去敏 projection。


返回附录目录 · 症状索引 · 术语边界 · 查看全书目录

附录 F:术语与技术边界表

同一个词在 PostgreSQL、Pigsty、云平台和 Kubernetes 中可能指不同对象。本附录固定 全书用语;命令执行前仍要解析 exact identity,不能只靠名词。

F.1 PostgreSQL、Pigsty、Patroni、PgBouncer 与 HAProxy 术语

组件 核心职责 不负责
PostgreSQL SQL、事务/MVCC、存储、WAL、复制原语、catalog 跨节点共识、业务 SLO、外部 route
Pigsty 声明式 inventory、部署、HA/backup/pool/route/monitoring 组合 替业务定义 good event、RPO 接受与不变量
Patroni 用 DCS 协调 PostgreSQL role、leader lock、failover/rejoin 提供 DCS quorum、网络/硬件绝对围栏
etcd/DCS 保存 leader/cluster 协调状态并提供共识语义 保存业务数据、替 PostgreSQL 复制 WAL
PgBouncer 复用 client/server connection,控制 pool/queue 选择 PostgreSQL leader、保持所有 session state
HAProxy 按 health/selector 将 service port 路由到 backend 理解 transaction commit 或业务正确性
pgBackRest physical backup、WAL archive、restore 工具链 自动选择业务正确的 PITR target
monitoring stack 采集、存储、展示、评估与通知 signals 自动把 component metric 变成 user SLI

PostgreSQL

全书核心知识对象。原生证据来自 SQL/catalog/stats、server log、control/WAL/backup metadata 和 filesystem/OS。平台结论最终要能回到这些语义验证。

Pigsty

PostgreSQL 数据库服务的参考实现/发行与管理平台。它把多个独立组件通过配置、playbook、 service 和监控组合起来。pig 是相关 CLI/package/operations 工具,其版本号与 Pigsty release 不同,例如正式实验观察到 pig 1.5.1 与 Pigsty v4.5.0

Patroni 与 DCS

Patroni 不“复制数据库”;PostgreSQL streaming replication 复制 WAL。Patroni 根据 DCS leader state、成员健康和配置协调 promotion/demotion。DCS 可用不证明 PostgreSQL 数据最新,PostgreSQL 可写也不证明它仍拥有集群 authority。

PgBouncer

三种 pool mode(session/transaction/statement)改变 server connection 的租用边界。 transaction pooling 下,不应假定跨 transaction 保留 temp table、session GUC、 prepared statement 或 advisory-lock 语义;实际能力还受 PgBouncer/driver 版本与配置 影响。

HAProxy

Pigsty service port 用 health endpoint/selector 将流量送到合适 instance/PgBouncer。 client 连接 HAProxy 的 address 与 PostgreSQL inet_server_addr() 返回的 backend address 不同,是正常的两层 identity。

F.2 实例、database cluster、Pigsty cluster 与服务端点

对象层级

host/node
  -> PostgreSQL instance/server (one postmaster + PGDATA + port)
     -> PostgreSQL database cluster (all databases in that PGDATA)
        -> database
           -> schema
              -> relation/function/type/extension objects

PostgreSQL 官方术语中的 database cluster 是一个 server/PGDATA 管理的 database 集合,不等于三节点 HA cluster。

Pigsty pg_cluster

Pigsty 把共享 pg_cluster 名称的 PostgreSQL instances 组织为一个管理/HA 单元:

pg-test
  pg-test-1 primary
  pg-test-2 replica
  pg-test-3 replica/offline

每个 instance 有自己的 PGDATA,是同一 PostgreSQL system lineage 的物理副本。 pg-metapg-test 名称相近也可能拥有不同 system identifier,不能互相 restore/ rewind。

容易混淆的 identity

名词 示例 验证
environment pg36-l2-vagrant authority/inventory/host set
node/host pg-test-1 machine ID、address、OS
Pigsty cluster pg-test inventory + Patroni scope
instance/member pg-test-1 Patroni member + PostgreSQL identity
PostgreSQL database cluster instance PGDATA system identifier/control data
database pg36_shop current_database() / pg_database
schema shop pg_namespace / search_path
role app_rw current_user / pg_roles
service primary/replica/default/offline HAProxy config + actual backend

service endpoint

服务端点表达能力语义,而不是机器:

primary service  -> current writable authority, usually via pool
replica service  -> selected read-only members, may lag
default service  -> current primary direct PostgreSQL
offline service  -> offline/analytics-selected member

具体端口与 selector 以当前 Pigsty config 为准。DNS、VIP、HAProxy node 和 backend 是 不同层;连接串只显示入口,SQL identity 显示实际 backend。

system identifier 与 timeline

system identifier
  PostgreSQL lineage identity;不同初始化通常不同

timeline
  WAL history 分支;promotion 通常产生新 timeline

LSN
  某条 timeline/WAL stream 内的位置语义

同 LSN 字符串在不同 system/timeline 不能直接比较。failover、rewind、PITR、restore 必须同时证明 source direction、system identifier 和 timeline history。

F.3 [PG][平台][Pigsty] 能力映射

这些标签用于作者的能力分析,不出现在顶层导航标题中:

PostgreSQL native capability
  引擎/SQL/catalog/tool 直接提供并可原生验证

generic platform responsibility
  任何生产数据库服务都必须承担,但不一定由 PostgreSQL 自己完成

Pigsty mapping
  Pigsty 对平台责任的具体组件、inventory、playbook、service 或 dashboard 实现

示例

主题 PostgreSQL 原生 平台责任 Pigsty 映射
transaction MVCC、isolation、lock、WAL retry/idempotency、SLO dashboard/query + service baseline
HA streaming replication、timeline quorum、fence、route、client outcome Patroni + etcd + HAProxy/PgBouncer
backup backup API、WAL/recovery repository、retention、exercise、RPO pgBackRest + policy/monitoring/playbook
security role、HBA、TLS、RLS、audit hooks identity/secrets/network/review inventory + cert/access templates
observability stats/views/logs storage、dashboard、alert/notification exporters + Victoria/Grafana/Alertmanager
capacity counters/settings/execution workload model、hardware/cost/headroom host/PG monitoring + declarative baseline

使用规则

  1. 先解释 PostgreSQL 语义;
  2. 再说明生产服务缺少什么组合责任;
  3. 给出 Pigsty reference implementation;
  4. 回到 SQL、config 或 component state 复核;
  5. 标明替换平台时必须保留的责任,而不是复制 Pigsty 命令。

例如“backup green”不是 PostgreSQL 原生结论;它组合 pgBackRest、repository、monitor、 restore drill 与业务 manifest。迁移到 RDS/Operator 后工具不同,责任仍在。

F.4 托管 RDS、自建 Patroni 与 Operator 的职责对照

三种交付模型

责任 托管 PostgreSQL/RDS 自建 Patroni/Pigsty Kubernetes Operator
host/OS provider 多数承担 用户/平台团队 node/cloud + cluster platform
PostgreSQL config/version API 约束下共享 用户完整承担 CR/operator + image/package
HA control provider 实现 Patroni/DCS/route 自管 operator + DCS/lease/service
backup repository provider feature + 用户 policy pgBackRest/repository 自管 operator integration + storage
network/identity provider primitive + 用户配置 用户全栈 cloud/K8s/network policy + 用户
monitoring provider baseline + 用户 SLI 用户组合全栈 operator/exporter + platform
restore/failover validation 用户仍需验证 用户需设计/执行 用户需设计/执行
business invariant 用户 用户 用户
data classification/SLO 用户 用户 用户

“托管”转移部分实施责任,不转移业务正确性、权限配置、查询/模式、RPO/RTO 接受、 external side effect 与 vendor failure 的验证责任。

自建 Patroni/Pigsty

优点:

完整 PostgreSQL/extension/OS 控制
可审查组件与数据路径
统一声明式平台和可观测

代价:

DCS/fencing/failure domain
package/OS/security lifecycle
backup repository and restore
on-call and incident authority

Pigsty 提供强 reference baseline,但 production topology、secrets、capacity、DR 与业务 合同仍由采用者验收。

Operator

Operator 用 Kubernetes reconciliation 管理 PostgreSQL lifecycle;它不等于:

Kubernetes automatically provides database consistency
pod restart equals failover safety
PVC equals backup
Service equals correct writer authority

需要理解 operator CRD、leader/lease、pod/PVC/node/zone failure domain、backup integration、disruption/upgrade 和 platform control-plane dependency。

选择问题

required PostgreSQL/extension control
team operating skill and on-call model
failure domains and regulatory/data residency
RPO/RTO and restore evidence
version/upgrade cadence
cost and lock-in
observability/export/access
exit and migration path

不要只比较“有没有 HA/backup”勾选项;比较故障模型、验证接口、责任边界和失败时的 authority。


返回附录目录 · 对象与证据速查 · 第 1 章全局地图 · 查看全书目录

索引中心

除按章、节、目顺序阅读外,还可以按角色、任务、技术边界、事故症状或分区能力查找内容。

索引入口

按角色阅读

角色路线用于系统阅读;跨章跳读前仍应检查前置依赖。

应用开发者

DBA / SRE

架构师与平台工程师

事故处置

按任务查找

按实际任务查找入口。章节链接始终同时显示编号与功能标题,避免重排后语义丢失。

任务 首选章节 必要前置
设计可靠模式 ch03《从业务规则到关系模型》ch04《数据类型、约束与可靠数据表达》 ch01《PostgreSQL 与 Pigsty 全局地图》ch02《psql 与可复现工作流》
查慢 SQL / 设计索引 ch07《执行计划与统计信息》ch08《慢 SQL 诊断方法论》ch09《索引设计与效果验证》 ch05《查询、事务与锁的核心心智模型》
处理并发错误 ch10《并发控制与隔离异常》 ch05《查询、事务与锁的核心心智模型》
安全改表与发布 ch11《模式变更与安全发布》ch12《从数据库契约到后端服务》 ch06《开发规约与交付基线》ch07《执行计划与统计信息》ch08《慢 SQL 诊断方法论》ch09《索引设计与效果验证》ch10《并发控制与隔离异常》
选择扩展 ch14《内核分支与扩展生态》ch15《全文、模糊与向量检索》ch16《时序、空间与时空查询》ch17《分析加速与分布式选型》ch18《PostgreSQL 数据平台与替代边界》 ch07《执行计划与统计信息》ch08《慢 SQL 诊断方法论》ch09《索引设计与效果验证》ch10《并发控制与隔离异常》ch11《模式变更与安全发布》ch12《从数据库契约到后端服务》
建设高可用与备份 ch19《环境规划与部署基线》ch20《高可用拓扑与容灾目标》ch21《备份体系与恢复演练》ch22《服务接入、连接池与路由》 ch01《PostgreSQL 与 Pigsty 全局地图》ch05《查询、事务与锁的核心心智模型》
建立安全与治理 ch23《认证、授权与数据安全》ch24《SLO、SOP 与组织治理》ch25《监控体系与可观测诊断》 ch19《环境规划与部署基线》ch20《高可用拓扑与容灾目标》ch21《备份体系与恢复演练》ch22《服务接入、连接池与路由》
压测、调优与维护 ch26《容量规划与压测基线》ch27《参数调优与资源治理》ch28《VACUUM、冻结与膨胀治理》ch29《逻辑复制、迁移与异构同步》ch30《版本升级与回滚策略》 ch07《执行计划与统计信息》ch08《慢 SQL 诊断方法论》ch09《索引设计与效果验证》ch10《并发控制与隔离异常》ch11《模式变更与安全发布》ch25《监控体系与可观测诊断》
误操作恢复 ch31《事件分级、现场保护与应急决策》ch32《PITR 与误操作恢复》 ch21《备份体系与恢复演练》
主库或 DCS 故障 ch31《事件分级、现场保护与应急决策》ch33《故障切换与集群重建》 ch20《高可用拓扑与容灾目标》
连接风暴与资源耗尽 ch31《事件分级、现场保护与应急决策》ch34《过载保护与资源故障判型》 ch22《服务接入、连接池与路由》ch25《监控体系与可观测诊断》ch26《容量规划与压测基线》ch27《参数调优与资源治理》ch28《VACUUM、冻结与膨胀治理》
数据损坏与抢救 ch31《事件分级、现场保护与应急决策》ch35《数据抢救与工程取证》 ch21《备份体系与恢复演练》ch28《VACUUM、冻结与膨胀治理》ch30《版本升级与回滚策略》

技术边界索引

按二级小节的主要责任归属索引。混合小节只标主责,具体正文仍需说明边界。

[PG] PostgreSQL 原生能力

[平台] 通用平台职责

[Pigsty] Pigsty 参考实现

事故症状与首个安全动作

先按症状选择“首个安全动作”,再进入正式章节。症状相似不代表修复动作相同。

症状或场景 首个安全动作 目标章节 明确禁止
误删、误更新、错误 DDL 停止继续写入并确定影响时间窗 ch32《PITR 与误操作恢复》 不覆盖仍可取证的原集群
主节点、复制或 DCS 异常 保护旧主并核对角色、时间线与 DCS 事实 ch33《故障切换与集群重建》 不在未 fencing 时提升第二个主库
连接、延迟、CPU、内存、I/O 表象 先判流量型还是保留型 ch34《过载保护与资源故障判型》 判型前不做破坏性清理
XID 回卷风险 检查 backend_xmin、复制槽 xminpg_prepared_xacts ch28《VACUUM、冻结与膨胀治理》ch34《过载保护与资源故障判型》 不用一般摘流代替解除保留
WAL 撑满磁盘 检查归档失败、复制槽和未完成备份 ch21《备份体系与恢复演练》ch34《过载保护与资源故障判型》ch35《数据抢救与工程取证》 绝不手工删除 pg_wal
checksum、索引、collation 或逻辑不一致 停写、克隆并保存原始证据 ch35《数据抢救与工程取证》 不在唯一副本上反复试错

分区能力五触点

分区不是一章讲完的孤立技巧,而是跨建模、计划、发布、场景与维护的五触点能力。

1. 分区决策门:分区键、唯一约束与引用限制

2. 规划时/执行时裁剪与父表统计

3. 在线分区化与版本相关锁行为

4. 时间分区在时序场景中的应用

5. 按分区维护、冻结与生命周期