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 pending20.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 printedhelper 从 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 eventprobe 不把异常 token 当成一个新业务动作重试。
执行正式动作
读者使用当前 Pigsty 时,可以先:
pig pt switchover --plan本书 exact v4.4.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 streamingcapture 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=noneall 不移动 leader。
fixture reset
reset:fixture 会 DROP 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 = B-A ]
[ action_to_stable = C-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 swrapper 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 ]
[ 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 policyacknowledged 全在代表什么
可以严格说:
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-1validator 接受 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 hashedreview.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 authorityRecovery 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二者都可能影响排期,但含义不同。
新版本迁移顺序
- 固定目标 release/commit,不用 moving branch;
- 阅读 PostgreSQL、Patroni、Pigsty release notes;
- diff inventory、Patroni dynamic/local config、service definitions;
- 检查 SQL catalog/function 字段变化;
- 更新
requirements.json,不要先改 validator 放宽; - 更新 capture schema 与 source allowlist;
- 用 synthetic evidence 跑 normal/negative;
- 在新 disposable target 重做第 19 章;
- 做只读 chapter-20 capture;
- 获得 L2 authority 后跑 planned drill;
- 保留失败 candidate run,不覆盖;
- 更新 reference outcome 与正文;
- 独立 review;
- 只在证据支持时改变 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_attrsbaseline 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.4.0 / PostgreSQL 18.4 / Patroni 4.1.3 本地沙箱、 异步复制、单 etcd、无 watchdog 的记录条件下,一个健康且明确命名的 candidate 完成计划切换,旧主重新加入,客户端在约 6 秒的采样间隙后 恢复写入;95 个已确认 token 全部存在,25 个结果未知 token 全部核对 为未提交,最终角色基线恢复。
它明确没有证明:
自动故障转移、旧主硬隔离、DCS quorum、零 RPO、生产 RTO、灾难恢复或 生产安全。
把两段话一起交付,才是完整的 HA evidence。
权威参考
- Patroni:
patronictl switchover - Pigsty:
pig pt switchover - Pigsty:Service/Access
- PostgreSQL 18:Warm Standby
- 正式实验合同
- 正式运行摘要
- 迁移 effort 边界
上一节:交付并观察 HA 集群 · 返回本章目录 · 下一章:未雨绸缪:备份体系与恢复演练 · 查看全书目录 · 查看索引中心