大多数复制架构图画的是一个主库、两个副本以及它们之间的箭头。这张图没错,但在故障处理时几乎没用,因为它说明不了此刻哪个节点在接收写入、哪个副本可能返回旧数据,也说明不了主库不再响应时会发生什么。
一张有用的复制架构图要回答四个问题。本文用 PostgreSQL、Patroni 和 etcd 逐一回答这些问题,然后把结果画出来。同样的问题也适用于 MySQL、其他故障转移管理工具以及托管服务。
每张复制架构图都必须回答的四个问题
- 写入去哪里? 必须恰好有一个主库,而且客户端需要有办法在故障转移后找到它。
- 读取可能有多旧? 每个副本要么是同步的,要么是异步的,异步副本会有延迟。
- 由谁决定故障转移? 必须有某个组件来检测故障、挑选副本并把它提升为主库。
- 如何防止出现两个主库? 被隔离的主库必须在另一个节点被提升之前停止接收写入。
同步复制与异步复制
PostgreSQL 的流复制默认是异步的。主库在备库收到数据之前就确认提交,所以一旦崩溃,尚未送达的事务就可能丢失。PostgreSQL 文档说得很直白:丢失数据的多少与故障转移时的复制延迟成正比。
同步复制会让提交等待 synchronous_standby_names 中列出的一个或多个备库。等待到什么程度,取决于 synchronous_commit:
synchronous_commit |
同步备库达到以下状态后,提交才返回 |
|---|---|
remote_apply |
已应用,备库上的查询能看到它 |
on(默认) |
已刷写到持久存储 |
remote_write |
已写入操作系统,但尚未刷盘 |
local |
不等待备库,只等待本地刷盘 |
off |
什么都不等,连本地磁盘也不等 |
代价是延迟:每次提交都包含一次到备库的往返,所以要把同步备库放在邻近的可用区,而不是另一个区域。而且,如果唯一的同步备库消失了,提交可能会一直等下去。请指定多个候选备库,或者让故障转移管理工具替你修改这个设置。
只读副本与读取路由
只要代码知道数据可能有延迟,异步副本就很适合承担报表和读取密集的接口。在主库上,pg_stat_replication 视图会显示每个备库的 write_lag、flush_lag 和 replay_lag;replay_lag 大致表示一次提交要过多久才能在副本上可见。
按查询逐一决定哪些可以读到旧数据。刚保存完表单又刷新页面的用户应该从主库读取。仪表盘则可以从副本读取。
故障转移:由谁决定
PostgreSQL 自身既不检测主库故障,也不提升备库;它的文档把这件事交给外部工具。Patroni 是常见的选择。每个节点上运行一个 Patroni 代理,代理之间通过 etcd 这样的分布式配置存储共享状态:
- 主库持有一个带 TTL 的领导者键,并在每个循环中续期(
loop_wait,默认 10 秒)。 - 如果它在
ttl秒内(默认 30 秒)无法续期,键就会过期,符合条件的副本开始竞选领导者。 - 最合适的候选者拿到键并把自己提升为主库,其他副本转而跟随它。
客户端通过代理找到新的主库。Patroni 的 REST API 只在持有领导者键的节点上对 GET /primary 返回 HTTP 200,所以 HAProxy 可以把写入发给回答“是”的那个节点:
listen primary
bind *:5000
option httpchk OPTIONS /primary
http-check expect status 200
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
server pg-a 10.0.1.10:5432 check port 8008
server pg-b 10.0.2.10:5432 check port 8008
server pg-c 10.0.3.10:5432 check port 8008
5001 端口上的第二个监听器检查 /replica?lag=16MB,WAL 落后超过 16 MB 的副本会被摘除。HAProxy 要部署在多台机器上,否则它本身就会成为你想要消除的单点故障。
脑裂:如何防止出现两个主库
两个同时接收写入的主库意味着两段分叉的历史,其中一段最终会被丢弃。PostgreSQL 文档把对策称为 STONITH:必须告诉旧主库,它已经不再是主库了。Patroni 叠加了多层防线:
- 租约。 无法更新领导者键的主库会停止接收写入:Patroni 会停止 PostgreSQL 或将其降级。如果存储本身在超过
retry_timeout的时间内都无法访问,Patroni 会将主库降级,除非开启了它的 DCS 故障保护模式。 - 看门狗。 如果 Patroni 进程卡住,Linux 看门狗会重置这台机器。Patroni 会把它设为在 TTL 到期前 5 秒触发。
- 法定人数(quorum)。 etcd 需要多数成员(三个中的两个)同意才能接受写入。三个成员分布在三个可用区,可以承受一个可用区的丢失,而被网络隔开的少数派无法选出任何人。
- 只有一个进程说了算。 只能由 Patroni 启动 PostgreSQL。崩溃后自行重启 Postgres 的系统服务,可能会让第二个主库重新出现。
旧主库恢复后,必须以副本身份重新加入。pg_rewind 会让它的数据目录与新主库重新同步,而不复制没有变化的数据块;开启 use_pg_rewind 时,Patroni 会自动运行它。
# Patroni dynamic configuration (patronictl edit-config)
ttl: 30
loop_wait: 10
retry_timeout: 10
synchronous_mode: on # promote only a node that has every acknowledged commit
postgresql:
use_pg_rewind: true
parameters:
wal_log_hints: "on"
# Local patroni.yml tags
# pg-a: noloadbalance: true (failover target, no reads)
# pg-c: nosync: true, nofailover: true (reads only)
开启 synchronous_mode 后,Patroni 只会提升它确认拥有全部已报告成功提交的备库。代价是可用性:如果主库在没有任何同步备库连接时宕机,Patroni 不会提升任何节点,写入会一直中断,直到有人介入。
复制不是备份
一条 DROP TABLE 会在几毫秒内传到所有副本。请保持持续的 WAL 归档和定期的基础备份,它们能让你恢复到基础备份之后的任意时间点,并按计划测试恢复。
图
图表内容
- Order service
- HAProxy
- AZ a
- AZ b
- AZ c
- pg-a · sync standby
- pg-b · primary
- pg-c · async replica
- etcd
- WAL archive + base backups
- SQL
- writes :5000
- reads :5001
- WAL (sync)
- WAL (async)
- leader key, TTL 30 s
- races if key expires
- archived WAL
这张图的读法如下:
- 写入。 订单服务连接 HAProxy。5000 端口通往 pg-b,它是唯一对
/primary返回 200 的节点。 - 读取。 5001 端口通往 pg-c,一个位于另一个可用区的异步副本。pg-a 不承担读取。
- 复制。 pg-b 以同步方式向 pg-a 传送 WAL(实线),以异步方式向 pg-c 传送(虚线)。
- 故障转移。 pg-b 在 etcd 中为领导者键续期。如果键过期,同步备库 pg-a 会去争取它。pg-c 带有
nofailover标签。 - 恢复。 pg-b 把 WAL 和基础备份归档到对象存储。
- 目标。 左上角的注释写明了 RPO、RTO、延迟预算和备份保留期。
复制架构图上应该标注什么
- 每支复制箭头的模式(同步或异步),以及异步副本的延迟预算。
- 每个副本的角色: 故障转移候选、只读副本,或两者兼有。
- 领导者锁放在哪里以及它的 TTL,或者由哪个托管服务承担这个角色。
- 客户端如何找到主库: 代理、DNS 名称或虚拟 IP。
- 故障域: 用框架表示可用区,包括共识成员运行在哪里。
- RPO 和 RTO 目标,让评审者可以据此检查设计。
托管服务替你做了什么
托管数据库回答的是同样的问题,只是有些答案是固定的。在 Amazon RDS 多可用区数据库实例中,备库是同步的,位于另一个可用区,不能承担读取。故障转移会把 DNS 记录切换到备库,通常需要 60 到 120 秒。多可用区数据库集群有两个可读的备库,使用半同步复制,故障转移通常在 35 秒以内完成。在图上,用终端节点代替 HAProxy,用 AWS 代替 Patroni 和 etcd。延迟、读取路由和目标仍然需要你自己标注。
测试故障转移
主动切换主库,例如在每个维护窗口用 patronictl switchover 切换一次。PostgreSQL 文档建议定期切换,因为这同时也是对故障转移机制的测试。测量写入失败持续了多久,确认 HAProxy 跟上了切换,并确认没有丢失任何已确认的提交。跨区域的故障转移请参阅 AWS 多区域架构图。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 为可用区添加三个基础节点包中的 框架,并在每个框架中放一个数据节点包中的 Postgres 节点:pg-a(同步备库)、pg-b(主库)和 pg-c(异步副本)。
- 在可用区上方,为订单服务添加一个 C4 模型节点包中的 容器,为 HAProxy 添加一个网络节点包中的 负载均衡器。
- 在下方,为 etcd 添加一个 C4 模型节点包中的 容器,为 WAL 归档添加一个数据节点包中的 对象存储库。
- 给连线加标签:“writes :5000”、“reads :5001”、实线的“WAL (sync)”、虚线的“WAL (async)”、从 pg-b 到 etcd 的“leader key, TTL 30 s”,以及一条表示领导者竞选、从 pg-a 到 etcd 的虚线。
- 添加一个基础节点包中的 Markdown 块,写明 RPO、RTO、延迟预算和备份保留期。
这个示例共用 11 个图表节点,在 Free 套餐的限制之内。要让图随着集群变化保持更新,请参阅不会过时的架构图;通用方法请参阅系统设计图示例。
常见问题
只读副本算备份吗? 不算。删除操作和出错的迁移同样会被复制。带 WAL 归档的备份可以让你恢复到出错之前的时间点。
同步复制就意味着零数据丢失吗? 对于同步备库已确认的提交,是的,前提是故障转移只提升这个备库。在没有任何同步备库连接时接受的提交,只存在于主库上。
为什么 etcd 要三个成员,而不是两个? etcd 需要多数成员同意才能接受写入。两个成员时任何一个都不能丢;三个成员时可以承受丢失一个,并且在网络分区时仍能在一侧保住多数。
可以用 DNS 代替代理来找到主库吗? 可以,托管服务就是这么做的。保持较短的 DNS TTL,并确保客户端不会长时间缓存解析结果;AWS 建议把 JVM 的 DNS 缓存 TTL 设为不超过 60 秒。
应该多久测试一次故障转移? 频率要足以让团队信任它,例如在每个维护窗口做一次计划内的切换,再在预发布环境中做一次计划外的故障测试。