这是本节的多页打印视图。 .
故障切换模型
Patroni 故障按故障对象分类可以分为以下 10 类,按照检测路径不同,可以进一步归纳为五类,在本节内详细展开。
| # | 故障场景 | 描述 | 最终走哪条路径 |
|---|---|---|---|
| 1 | PG 进程崩溃 | crash、OOM killed | 主动检测 |
| 2 | PG 拒绝连接 | max_connections | 主动检测 |
| 3 | PG 假活 | 进程在但无响应 | 主动检测 (检测超时) |
| 4 | Patroni 进程崩溃 | kill -9、OOM | 被动检测 |
| 5 | Patroni 假活 | 进程在但卡住 | Watchdog |
| 6 | 节点宕机 | 断电、硬件故障 | 被动检测 |
| 7 | 节点假活 | IO hang、CPU 饥饿 | Watchdog |
| 8 | 主库 ↔ DCS 网络中断 | 防火墙、交换机故障 | 网络分区 |
| 9 | 存储故障 | 磁盘坏、磁盘满、挂载失败 | 主动检测 或 Watchdog |
| 10 | 手动切换 | Switchover/Failover | 手动触发 |
但是在 RTO 计算上,最终所有故障都会收敛到两条路径上,本节深入探讨了这两种情况下的 RTO 上下限与均值。
flowchart LR
A([主库故障]) --> B{Patroni<br/>检测到?}
B -->|PG崩溃| C[尝试本地重启]
B -->|节点宕机| D[等待 TTL 过期]
C -->|成功| E([本地恢复])
C -->|失败/超时| F[释放 Leader 锁]
D --> F
F --> G[从库竞选]
G --> H[执行 Promote]
H --> I[HAProxy 感知]
I --> J([服务恢复])
style A fill:#dc3545,stroke:#b02a37,color:#fff
style E fill:#198754,stroke:#146c43,color:#fff
style J fill:#198754,stroke:#146c43,color:#fff
1 - 被动故障切换
infographic list-row-simple-horizontal-arrow
data
title 租约过期故障切换流程
desc 当整个节点宕机,Patroni 无法主动释放租约,只能等待 TTL 过期
items
- label 租约过期
desc Patroni 失联,被动等待主库租约 TTL 过期
icon mingcute/close-circle-fill
- label 从库检测
desc 从库从循环中醒来后发现租约过期,开始竞选
icon mingcute/key-2-fill
- label 抢锁提拔
desc 从库相互比较并抢锁,胜利者提升自己的PG
icon mingcute/radar-fill
- label 健康检查
desc HAPROXY 健康检查发现新主上线,分配流量
icon mingcute/arrow-up-circle-fill
theme light
palette antvRTO 时序图
tooltip: { trigger: axis, axisPointer: { type: shadow }, formatter: $fn:fmt }
legend: { top: 0, itemGap: 12, data: [租约过期, 从库检测, 抢锁提拔, 健康检查] }
grid: { left: 64, right: 24, bottom: 32, top: 40 }
xAxis: { type: value, name: 秒, nameLocation: end, max: 160, axisLine: { show: true }, axisTick: { show: true }, splitLine: { show: true, lineStyle: { type: dashed, opacity: 0.5 } }, minorTick: { show: true, splitNumber: 5 }, minorSplitLine: { show: true, lineStyle: { type: dotted, opacity: 0.2 } } }
yAxis: { type: category, axisLine: { show: true }, axisTick: { show: true }, splitLine: { show: false }, axisLabel: { fontSize: 10, fontFamily: monospace }, data: [wide-max, wide-avg, wide-min, "", safe-max, safe-avg, safe-min, "", norm-max, norm-avg, norm-min, "", fast-max, fast-avg, fast-min] }
series:
- { name: 租约过期, type: bar, stack: main, barWidth: 20, z: 2, emphasis: { focus: series }, itemStyle: { color: "#e15759" }, data: [120, 110, 100, "-", 60, 55, 50, "-", 30, 27, 25, "-", 20, 17, 15] }
- { name: 从库检测, type: bar, stack: main, z: 2, emphasis: { focus: series }, itemStyle: { color: "#edc949" }, data: [20, 10, 0, "-", 10, 5, 0, "-", 5, 3, 0, "-", 5, 3, 0] }
- { name: 抢锁提拔, type: bar, stack: main, z: 2, emphasis: { focus: series }, itemStyle: { color: "#59a14f" }, data: [2, 1, 0, "-", 2, 1, 0, "-", 2, 1, 0, "-", 2, 1, 0] }
- { name: 健康检查, type: bar, stack: main, z: 2, emphasis: { focus: series }, itemStyle: { color: "#4e79a7" }, data: [8, 6, 4, "-", 6, 5, 3, "-", 4, 3, 2, "-", 2, 2, 1] }
- { name: RTO总计, type: bar, barGap: "-100%", barWidth: 20, z: 1, itemStyle: { color: "#888", opacity: 0 }, emphasis: { itemStyle: { opacity: 0 } }, data: [150, 127, 104, "-", 78, 66, 53, "-", 41, 34, 27, "-", 29, 23, 16] }
- { name: RTO预算, type: bar, barGap: "-100%", barWidth: 20, z: 0, itemStyle: { color: "rgba(0,0,0,0.08)" }, emphasis: { itemStyle: { color: "rgba(0,0,0,0.12)" } }, data: [150, 150, 150, "-", 90, 90, 90, "-", 45, 45, 45, "-", 30, 30, 30] }故障模型
| 项目 | 最好 | 最坏 | 平均 | 说明 |
|---|---|---|---|---|
| 租约过期 | ttl - loop |
ttl |
ttl - loop/2 |
最好:即将刷新时宕机 最坏:刚刷新完就宕机 |
| 从库检测 | 0 |
loop |
loop / 2 |
最好:恰好在检测点 最坏:刚错过检测点 |
| 抢锁提拔 | 0 |
2 |
1 |
最好:直接抢锁提升 最坏:API 超时+Promote |
| 健康检查 | (rise-1) × fastinter |
(rise-1) × fastinter + inter |
(rise-1) × fastinter + inter/2 |
最好:检查前状态变化 最坏:检查后瞬间状态变化 |
被动故障与主动故障的核心区别:
| 场景 | Patroni 状态 | 租约处理 | 主要等待时间 |
|---|---|---|---|
| 主动故障(PG 崩溃) | 存活,健康 | 主动尝试重启 PG,超时后释放租约 | primary_start_timeout |
| 被动故障(节点宕机) | 随节点一起死亡 | 无法主动释放,只能等待 TTL 过期 | ttl |
在被动故障场景中,Patroni 随节点一起宕机,无法主动释放 Leader Key。 DCS 中的租约只能等待 TTL 自然过期后触发集群选举。
时序分析
阶段 1:租约过期
Patroni 主库会在每个 loop_wait 周期刷新 Leader Key,将 TTL 重置为配置值。
- 最好情况:故障发生在即将刷新租约之前(距上次刷新已过
loop),剩余 TTL =ttl - loop - 最坏情况:故障发生在刚刷新租约之后,需等待完整
ttl - 平均情况:
ttl - loop/2
阶段 2:从库检测
从库在 loop_wait 周期醒来后检查 DCS 中的 Leader Key 状态。
- 最好情况:租约过期时从库恰好醒来,等待
0 - 最坏情况:租约过期后从库刚进入睡眠,等待
loop - 平均情况:
loop/2
阶段 3:抢锁提拔
从库发现 Leader Key 过期后,开始竞选过程,获得 Leader Key 的从库执行 pg_ctl promote,将自己提升为新主库。
- 通过 Rest API,并行发起查询,查询各从库的复制位置,通常 10ms,硬编码 2 秒超时。
- 比较 WAL 位置,确定最优候选,各从库尝试创建 Leader Key(CAS 原子操作)
- 执行
pg_ctl promote提升自己为主库(很快,通常忽略不计)
- 最好情况:单从库或直接抢到锁并提升,常数开销
0.1s - 最坏情况:DCS API 调用超时:
2s - 平均情况:
1s常数开销
阶段 4:健康检查
HAProxy 检测新主库上线,需要连续 rise 次健康检查成功。
- 最好情况:新主提升时恰好赶上检查,
(rise-1) × fastinter - 最坏情况:新主提升后刚错过检查,
(rise-1) × fastinter + inter - 平均情况:
(rise-1) × fastinter + inter/2
RTO 公式
将各阶段时间相加,得到总 RTO:
最好情况
平均情况
最坏情况
模型计算
将四种 RTO 模型的参数带入上面的公式:
四种模式计算结果(单位:秒,格式:min / avg / max)
| 阶段 | fast | norm | safe | wide |
|---|---|---|---|---|
| 租约过期 | 15 / 17 / 20 |
25 / 27 / 30 |
50 / 55 / 60 |
100 / 110 / 120 |
| 从库检测 | 0 / 3 / 5 |
0 / 3 / 5 |
0 / 5 / 10 |
0 / 10 / 20 |
| 抢锁提拔 | 0 / 1 / 2 |
0 / 1 / 2 |
0 / 1 / 2 |
0 / 1 / 2 |
| 健康检查 | 1 / 2 / 2 |
2 / 3 / 4 |
3 / 5 / 6 |
4 / 6 / 8 |
| 总计 | 16 / 23 / 29 |
27 / 34 / 41 |
53 / 66 / 78 |
104 / 127 / 150 |
2 - 主动故障检测
infographic list-row-simple-horizontal-arrow
data
title 崩溃故障切换流程
desc 当 Patroni 健康,但 PostgreSQL 因故崩溃时的故障切换流程
items
- label 故障检测
desc Patroni 在循环中检测到 PG 崩溃
icon mingcute/close-circle-fill
- label 重启超时
desc Patroni 尝试重启 PG,超时后释放租约
icon mingcute/refresh-2-fill
- label 从库检测
desc 从库从循环中醒来发现租约释放,开始竞选
icon mingcute/key-2-fill
- label 抢锁提拔
desc 从库相互比较并抢锁,胜利者提升自己的 PG
icon mingcute/radar-fill
- label 健康检查
desc HAProxy 健康检查发现新主上线,分配流量
icon mingcute/arrow-up-circle-fill
theme light
palette antvRTO 时序图
tooltip: { trigger: axis, axisPointer: { type: shadow }, formatter: $fn:fmt }
legend: { top: 0, itemGap: 12, data: [故障检测, 重启超时, 从库检测, 抢锁提拔, 健康检查] }
grid: { left: 64, right: 24, bottom: 32, top: 40 }
xAxis: { type: value, name: 秒, nameLocation: end, max: 160, axisLine: { show: true }, axisTick: { show: true }, splitLine: { show: true, lineStyle: { type: dashed, opacity: 0.5 } }, minorTick: { show: true, splitNumber: 5 }, minorSplitLine: { show: true, lineStyle: { type: dotted, opacity: 0.2 } } }
yAxis: { type: category, axisLine: { show: true }, axisTick: { show: true }, splitLine: { show: false }, axisLabel: { fontSize: 10, fontFamily: monospace }, data: [wide-max, wide-avg, wide-min, "", safe-max, safe-avg, safe-min, "", norm-max, norm-avg, norm-min, "", fast-max, fast-avg, fast-min] }
series:
- { name: 故障检测, type: bar, stack: main, barWidth: 20, z: 2, emphasis: { focus: series }, itemStyle: { color: "#b07aa1" }, data: [20, 10, 0, "-", 10, 5, 0, "-", 5, 3, 0, "-", 5, 3, 0] }
- { name: 重启超时, type: bar, stack: main, z: 2, emphasis: { focus: series }, itemStyle: { color: "#f28e2c" }, data: [95, 95, 0, "-", 45, 45, 0, "-", 25, 25, 0, "-", 15, 15, 0] }
- { name: 从库检测, type: bar, stack: main, z: 2, emphasis: { focus: series }, itemStyle: { color: "#edc949" }, data: [20, 10, 0, "-", 10, 5, 0, "-", 5, 3, 0, "-", 5, 3, 0] }
- { name: 抢锁提拔, type: bar, stack: main, z: 2, emphasis: { focus: series }, itemStyle: { color: "#59a14f" }, data: [2, 1, 0, "-", 2, 1, 0, "-", 2, 1, 0, "-", 2, 1, 0] }
- { name: 健康检查, type: bar, stack: main, z: 2, emphasis: { focus: series }, itemStyle: { color: "#4e79a7" }, data: [8, 6, 4, "-", 6, 5, 3, "-", 4, 3, 2, "-", 2, 2, 1] }
- { name: RTO总计, type: bar, barGap: "-100%", barWidth: 20, z: 1, itemStyle: { color: "#888", opacity: 0 }, emphasis: { itemStyle: { opacity: 0 } }, data: [145, 122, 4, "-", 73, 61, 3, "-", 41, 35, 2, "-", 29, 24, 1] }
- { name: RTO预算, type: bar, barGap: "-100%", barWidth: 20, z: 0, itemStyle: { color: "rgba(0,0,0,0.08)" }, emphasis: { itemStyle: { color: "rgba(0,0,0,0.12)" } }, data: [150, 150, 150, "-", 90, 90, 90, "-", 45, 45, 45, "-", 30, 30, 30] }故障模型
| 项目 | 最好 | 最坏 | 平均 | 说明 |
|---|---|---|---|---|
| 故障检测 | 0 |
loop |
loop/2 |
最好:PG 恰好在检测前崩溃 最坏:PG 刚检测完就崩溃 |
| 重启超时 | 0 |
start |
start |
最好:PG 瞬间自愈 最坏:等满 start 超时才释放租约 |
| 从库检测 | 0 |
loop |
loop/2 |
最好:恰好在检测点 最坏:刚错过检测点 |
| 抢锁提拔 | 0 |
2 |
1 |
最好:直接抢锁提升 最坏:API 超时 + Promote |
| 健康检查 | (rise-1) × fastinter |
(rise-1) × fastinter + inter |
(rise-1) × fastinter + inter/2 |
最好:检查前状态变化 最坏:检查后瞬间状态变化 |
主动故障与被动故障的核心区别:
| 场景 | Patroni 状态 | 租约处理 | 主要等待时间 |
|---|---|---|---|
| 主动故障(PG 崩溃) | 存活,健康 | 主动尝试重启 PG,超时后释放租约 | primary_start_timeout |
| 被动故障(节点宕机) | 随节点一起死亡 | 无法主动释放,只能等待 TTL 过期 | ttl |
在主动故障场景中,Patroni 仍然存活,能够 主动检测到 PG 崩溃并尝试重启。 如果重启成功,服务自愈;如果超时仍未恢复,Patroni 会 主动释放 Leader Key,触发集群选举。
时序分析
阶段 1:故障检测
Patroni 在每个 loop_wait 周期检查 PostgreSQL 状态(通过 pg_isready 或检查进程)。
- 最好情况:PG 恰好在 Patroni 检测前崩溃,立即被发现,等待
0 - 最坏情况:PG 刚检测完就崩溃,需等待下一个周期,等待
loop - 平均情况:
loop/2
阶段 2:重启超时
Patroni 检测到 PG 崩溃后,会尝试重启 PostgreSQL。此阶段有两种可能的结果:
路径 A:自愈成功(最好情况)
- PG 成功重启,服务恢复
- 不触发故障切换,RTO 极短
- 等待时间:
0(相对于 Failover 路径)
路径 B:需要 Failover(平均/最坏情况)
- 等待
primary_start_timeout超时后 PG 仍未恢复 - Patroni 主动释放 Leader Key
- 等待时间:
start
注意:平均情况假设需要进行故障切换。如果 PG 能够快速自愈,则整体 RTO 会大幅降低。
阶段 3:从库检测
从库在 loop_wait 周期醒来后检查 DCS 中的 Leader Key 状态。当主库 Patroni 释放 Leader Key 后,从库发现后开始竞选。
- 最好情况:租约释放时从库恰好醒来,等待
0 - 最坏情况:租约释放后从库刚进入睡眠,等待
loop - 平均情况:
loop/2
阶段 4:抢锁提拔
从库发现 Leader Key 空缺后,开始竞选过程,获得 Leader Key 的从库执行 pg_ctl promote,将自己提升为新主库。
- 通过 Rest API,并行发起查询,查询各从库的复制位置,通常 10ms,硬编码 2 秒超时。
- 比较 WAL 位置,确定最优候选,各从库尝试创建 Leader Key(CAS 原子操作)
- 执行
pg_ctl promote提升自己为主库(很快,通常忽略不计)
- 最好情况:单从库或直接抢到锁并提升,常数开销
0.1s - 最坏情况:DCS API 调用超时:
2s - 平均情况:
1s常数开销
阶段 5:健康检查
HAProxy 检测新主库上线,需要连续 rise 次健康检查成功。
- 最好情况:新主提升时恰好赶上检查,
(rise-1) × fastinter - 最坏情况:新主提升后刚错过检查,
(rise-1) × fastinter + inter - 平均情况:
(rise-1) × fastinter + inter/2
RTO 公式
将各阶段时间相加,得到总 RTO:
最好情况(PG 瞬间自愈)
平均情况(需要 Failover)
最坏情况
模型计算
将四种 RTO 模型的参数带入上面的公式:
四种模式计算结果(单位:秒,格式:min / avg / max)
| 阶段 | fast | norm | safe | wide |
|---|---|---|---|---|
| 故障检测 | 0 / 3 / 5 |
0 / 3 / 5 |
0 / 5 / 10 |
0 / 10 / 20 |
| 重启超时 | 0 / 15 / 15 |
0 / 25 / 25 |
0 / 45 / 45 |
0 / 95 / 95 |
| 从库检测 | 0 / 3 / 5 |
0 / 3 / 5 |
0 / 5 / 10 |
0 / 10 / 20 |
| 抢锁提拔 | 0 / 1 / 2 |
0 / 1 / 2 |
0 / 1 / 2 |
0 / 1 / 2 |
| 健康检查 | 1 / 2 / 2 |
2 / 3 / 4 |
3 / 5 / 6 |
4 / 6 / 8 |
| 总计 | 1 / 24 / 29 |
2 / 35 / 41 |
3 / 61 / 73 |
4 / 122 / 145 |
与被动故障对比
| 阶段 | 主动故障(PG 崩溃) | 被动故障(节点宕机) | 说明 |
|---|---|---|---|
| 检测机制 | Patroni 主动检测 | TTL 被动过期 | 主动检测更快发现故障 |
| 核心等待 | start |
ttl |
start 通常小于 ttl,但需要额外的故障检测时间 |
| 租约处理 | 主动释放 | 被动过期 | 主动释放更及时 |
| 自愈可能 | ✅ 有 | ❌ 无 | 主动检测可尝试本地恢复 |
RTO 对比(平均情况):
| 模式 | 主动故障(PG 崩溃) | 被动故障(节点宕机) | 差异 |
|---|---|---|---|
| fast | 24s | 23s | +1s |
| norm | 35s | 34s | +1s |
| safe | 61s | 66s | -5s |
| wide | 122s | 127s | -5s |
分析:在
fast和norm模式下,主动故障的 RTO 略高于被动故障,因为需要等待primary_start_timeout(start); 但在safe和wide模式下,由于start < ttl - loop,主动故障反而更快。 不过主动故障有自愈的可能性,最好情况下 RTO 可以极短。
3 - 网络分区
infographic list-row-simple-horizontal-arrow
data
title 网络分区故障切换流程
desc 主库与 DCS 网络分区,Patroni 主动降级防止脑裂,等待 TTL 过期后切换
items
- label 主库降级
desc Patroni 重试超时后主动降级 PG
icon mingcute/shield-fill
- label 租约过期
desc Leader Key TTL 过期
icon mingcute/close-circle-fill
- label 从库检测
desc 从库发现租约过期,开始竞选
icon mingcute/key-2-fill
- label 抢锁提拔
desc 从库抢锁并提升为新主库
icon mingcute/radar-fill
- label 健康检查
desc HAProxy 检测新主上线
icon mingcute/arrow-up-circle-fill
theme light
palette antvRTO 时序图
tooltip: { trigger: axis, axisPointer: { type: shadow }, formatter: $fn:fmt }
legend: { top: 0, itemGap: 12, data: [主库降级, 租约过期, 从库检测, 抢锁提拔, 健康检查] }
grid: { left: 64, right: 24, bottom: 32, top: 40 }
xAxis: { type: value, name: 秒, nameLocation: end, max: 160, axisLine: { show: true }, axisTick: { show: true }, splitLine: { show: true, lineStyle: { type: dashed, opacity: 0.5 } }, minorTick: { show: true, splitNumber: 5 }, minorSplitLine: { show: true, lineStyle: { type: dotted, opacity: 0.2 } } }
yAxis: { type: category, axisLine: { show: true }, axisTick: { show: true }, splitLine: { show: false }, axisLabel: { fontSize: 10, fontFamily: monospace }, data: [wide-max, wide-avg, wide-min, "", safe-max, safe-avg, safe-min, "", norm-max, norm-avg, norm-min, "", fast-max, fast-avg, fast-min] }
series:
- { name: 主库降级, type: bar, stack: main, barWidth: 20, z: 2, emphasis: { focus: series }, itemStyle: { color: "#76b7b2" }, data: [50, 40, 30, "-", 30, 25, 20, "-", 15, 13, 10, "-", 10, 8, 5] }
- { name: 租约过期, type: bar, stack: main, z: 2, emphasis: { focus: series }, itemStyle: { color: "#e15759" }, data: [70, 70, 70, "-", 30, 30, 30, "-", 15, 15, 15, "-", 10, 10, 10] }
- { name: 从库检测, type: bar, stack: main, z: 2, emphasis: { focus: series }, itemStyle: { color: "#edc949" }, data: [20, 10, 0, "-", 10, 5, 0, "-", 5, 3, 0, "-", 5, 3, 0] }
- { name: 抢锁提拔, type: bar, stack: main, z: 2, emphasis: { focus: series }, itemStyle: { color: "#59a14f" }, data: [2, 1, 0, "-", 2, 1, 0, "-", 2, 1, 0, "-", 2, 1, 0] }
- { name: 健康检查, type: bar, stack: main, z: 2, emphasis: { focus: series }, itemStyle: { color: "#4e79a7" }, data: [8, 6, 4, "-", 6, 5, 3, "-", 4, 3, 2, "-", 2, 2, 1] }
- { name: RTO总计, type: bar, barGap: "-100%", barWidth: 20, z: 1, itemStyle: { color: "#888", opacity: 0 }, emphasis: { itemStyle: { opacity: 0 } }, data: [150, 127, 104, "-", 78, 66, 53, "-", 41, 34, 27, "-", 29, 23, 16] }
- { name: RTO预算, type: bar, barGap: "-100%", barWidth: 20, z: 0, itemStyle: { color: "rgba(0,0,0,0.08)" }, emphasis: { itemStyle: { color: "rgba(0,0,0,0.12)" } }, data: [150, 150, 150, "-", 90, 90, 90, "-", 45, 45, 45, "-", 30, 30, 30] }故障模型
| 项目 | 最好 | 最坏 | 平均 | 说明 |
|---|---|---|---|---|
| 主库降级 | retry |
loop + retry |
loop/2 + retry |
Patroni 检测分区后重试,超时后主动降级 |
| 租约过期 | ttl - loop - retry |
ttl - loop - retry |
ttl - loop - retry |
降级后剩余的 TTL 时间(近似常数) |
| 从库检测 | 0 |
loop |
loop/2 |
最好:恰好在检测点 最坏:刚错过检测点 |
| 抢锁提拔 | 0 |
2 |
1 |
最好:直接抢锁提升 最坏:API 超时+Promote |
| 健康检查 | (rise-1) × fastinter |
(rise-1) × fastinter + inter |
(rise-1) × fastinter + inter/2 |
最好:检查前状态变化 最坏:检查后瞬间状态变化 |
网络分区与节点宕机的核心区别:
| 场景 | Patroni 状态 | PostgreSQL 状态 | 租约处理 | 脑裂风险 |
|---|---|---|---|---|
| 节点宕机(过期故障) | 随节点死亡 | 完全不可用 | 被动等待 TTL 过期 | 无 |
| 网络分区(本文场景) | 存活但无法访问 DCS | 可能仍在运行(需要主动降级) | 被动等待 TTL 过期 | 有,需防护 |
在网络分区场景中,主库 PostgreSQL 可能仍在运行并接受写入,这会导致 脑裂 问题。 Patroni 通过 主动降级 机制解决:当无法刷新 Leader Key 时,主动将 PostgreSQL 降级为只读或关闭。
时序分析
阶段 1:主库降级
当主库 Patroni 与 DCS 网络分区后,无法刷新 Leader Key,开始重试。
- 检测延迟:分区发生后,需要等待下一个
loop_wait周期才能检测到 - 重试阶段:Patroni 会在
retry_timeout期间持续重试 DCS 操作 - 主动降级:重试超时后,Patroni 主动降级 PostgreSQL(防止脑裂)
关键设计:Patroni 要求参数满足约束 loop_wait + 2 × retry_timeout ≤ ttl,确保主库在 TTL 过期之前完成降级。
阶段 2:租约过期
主库降级后,Leader Key 仍然存在于 DCS 中,需要等待 TTL 自然过期。
由于主库已经降级,此阶段的等待时间是 TTL 剩余时间。由于分区检测和 TTL 剩余时间是负相关的(分区发生得越早,检测越慢,但 TTL 剩余越长),两者相加是常数:
注意:主库降级 + 租约过期的总时间仍然约等于 ttl,与过期故障相同。
阶段 3:从库检测
从库在 loop_wait 周期醒来后检查 DCS 中的 Leader Key 状态。
- 最好情况:租约过期时从库恰好醒来,等待
0 - 最坏情况:租约过期后从库刚进入睡眠,等待
loop - 平均情况:
loop/2
阶段 4:抢锁提拔
从库发现 Leader Key 过期后,开始竞选过程。
- 最好情况:单从库或直接抢到锁并提升,
≈ 0 - 最坏情况:DCS API 调用超时,
2s - 平均情况:
1s
阶段 5:健康检查
HAProxy 检测新主库上线,需要连续 rise 次健康检查成功。
- 最好情况:
(rise-1) × fastinter - 最坏情况:
(rise-1) × fastinter + inter - 平均情况:
(rise-1) × fastinter + inter/2
RTO 公式
将各阶段时间相加,得到总 RTO。
由于主库降级 + 租约过期 ≈ ttl,网络分区的 RTO 公式与过期故障相同:
最好情况
平均情况
最坏情况
模型计算
将四种 RTO 模型的参数带入上面的公式:
Patroni 约束验证(loop + 2×retry ≤ ttl):
| 模式 | loop | retry | TTL | loop + 2×retry | 满足约束? |
|---|---|---|---|---|---|
| fast | 5 | 5 | 20s | 15s | ✓ 安全 |
| norm | 5 | 10 | 30s | 25s | ✓ 安全 |
| safe | 10 | 20 | 60s | 50s | ✓ 安全 |
| wide | 20 | 30 | 120s | 80s | ✓ 安全 |
四种模式计算结果(单位:秒,格式:min / avg / max)
| 阶段 | fast | norm | safe | wide |
|---|---|---|---|---|
| 主库降级 | 5 / 8 / 10 |
10 / 13 / 15 |
20 / 25 / 30 |
30 / 40 / 50 |
| 租约过期 | 10 |
15 |
30 |
70 |
| 从库检测 | 0 / 3 / 5 |
0 / 3 / 5 |
0 / 5 / 10 |
0 / 10 / 20 |
| 抢锁提拔 | 0 / 1 / 2 |
0 / 1 / 2 |
0 / 1 / 2 |
0 / 1 / 2 |
| 健康检查 | 1 / 2 / 2 |
2 / 3 / 4 |
3 / 5 / 6 |
4 / 6 / 8 |
| 总计 | 16 / 23 / 29 |
27 / 34 / 41 |
53 / 66 / 78 |
104 / 127 / 150 |
结论:网络分区的 RTO 与过期故障(节点宕机)相同,因为瓶颈都是 TTL 过期时间。
脑裂防护
网络分区的最大风险是 脑裂:老主库可能仍在运行并接受写入。Patroni 提供多重防护机制:
1. 主库自我降级
Patroni 的核心防护机制:当无法刷新 Leader Key 时,主动降级 PostgreSQL。
2. Linux Watchdog
如果 Patroni 进程卡住无法执行降级,Linux watchdog 会强制重启系统。
3. Fencing 机制
可以配置 fencing 脚本来强制隔离老主库(如关闭网络接口、停止服务等)。
特殊场景
场景 A:主库与 DCS 分区,从库正常
这是最常见的网络分区场景,本文主要分析此场景。
- 主库 Patroni 无法刷新 Leader Key → 主动降级
- 从库正常检测到 TTL 过期 → 竞选成为新主库
- RTO ≈ 过期故障 RTO
场景 B:主库正常,从库与 DCS 分区
- 主库正常刷新 Leader Key
- 从库无法参与竞选(但复制仍可继续)
- 不会触发故障切换,服务继续正常运行
场景 C:所有节点与 DCS 分区
- 主库降级,从库无法竞选
- 集群完全不可用
- 需要人工干预恢复 DCS 连接
与其他故障对比
| 故障类型 | 主库状态 | 租约处理 | RTO | 脑裂风险 |
|---|---|---|---|---|
| 过期故障 | 节点宕机 | 被动等待 TTL 过期 | 16s ~ 150s | 无 |
| 崩溃故障 | PG 崩溃,Patroni 存活 | 重启超时后主动释放 | 1s ~ 111s | 无 |
| 网络分区 | 存活但与 DCS 隔离 | 被动等待 TTL 过期 | 16s ~ 150s | 有,需防护 |
| 人工切换 | 正常或故障 | 直接释放/获取 | 1s ~ 11s | 无 |
关键洞察:网络分区的 RTO 与过期故障相同,但需要额外的脑裂防护机制。
确保满足 loop_wait + 2 × retry_timeout ≤ ttl 约束是防止脑裂的关键设计。