状态: 实测 (2026-10-31)。 此数据是在 TT Lab 三台实验 VM(KubeVirt,Ubuntu 24.04)的 Docker 环境中测得的。每项测试进行了一次,且未执行 VM 断电操作(未实测)。所有数字均提取自原始数据(每次试验留下一行的写入记录以及恢复后的对比结果)。编写规则:实测数据作为基础,未能测得的标为「未实测」,原因未明的解释标为「推测」。
目录
- 1. 疑问:两台机器坏一台难道不行吗
- 2. 为什么多数派需要 3 个节点
- 3. 这五种服务各自如何选出主节点
- 4. 配置摘录
- 5. 测试方法
- 6. 当真正只有两台机器时
- 7. 手动提升:人工宣布「对方已死」
- 8. 总结
- 参考
1. 疑问:两台机器坏一台难道不行吗
在本地部署(On-Premise)中接到要求对数据库做双机热备时,脑海中最先浮现的架构通常是两台服务器。一台作为主节点(Primary)接收写入,另一台持有副本,等主节点挂了之后接管。服务器成本和机位成本两台都是最便宜的。
这个系列记录了通过这五种服务亲自验证 该架构无法自动运行的过程。先说结论。
- 在只有两台服务器的配置中,无论丢失哪一台,五种服务的写入全部停止。 隔离 60 秒时,PostgreSQL 需 108 秒,SeaweedFS S3 需 108 秒,其余在 34 秒到 62 秒之间。仅丢失副本侧也会停止。
- 打破停滞状态的唯一途径是 人工进行手动提升。即使提前写好了步骤,各个服务仍需 0.9 秒到 44.2 秒不等的时间。
- 只要增加一个小型的、不存储数据的见证者 VM(2 vCPU·2 GiB), 就能实现自动切换。在节点隔离测试中,PostgreSQL 在 22.6 秒到 32.7 秒内完成提升,且已确认的写入数据丢失量为 0。该结果将在 第 2 篇中详细探讨。
2. 为什么多数派需要 3 个节点
2.1 无法区分是宕机还是网络中断
假设 db1 和 db2 互相监视。在 db1 看来,db2 的响应中断了。有两种可能性:
- db2 确实挂了。
- db2 完好无损,但两者之间的网络中断了(网络分区,network partition)。
db1 独自一人无法区分这两种情况。因为两者看起来都只是「无响应」。如果 db1 坚信是第 1 种情况并成为了主节点,而实际上是第 2 种情况,那么 db2 也会认为自己依然是主节点并接收写入。 从而导致双主(split brain) ,当网络恢复时,两侧的数据已经产生分歧。其中一侧的写入必须被丢弃。
2.2 因此「只有获得多数派的一方才能成为主节点」
避免这一问题的标准规则是 多数派(法定人数,quorum) 。只有获得 N 个投票成员中超过半数(⌊N/2⌋ + 1)同意的一方才能成为主节点。无论网络如何分割,拥有多数派的一方最多只有一个,因此不会产生两个主节点。
问题在于只有两台机器时。如果成员数量 N 为 2,多数派就是 2。一旦线路中断,双方都只有 1 票, 因此哪一方都无法成为主节点,原本是主节点的一方也会 主动降级。因为如果不这样,它就会在对端发生什么完全未知的情况下接收写入。正如图片下方的表格所示,两台机器和 1 台机器一样,完全无法容忍任何故障。要想容忍一个故障,投票成员必须为 3 个。
2.3 第三个节点即使没有数据,只需参与投票即可
第三个成员不一定非得是存储数据的服务器。因为计算多数派所需的只有 选票。数据保存在两台机器上,第三个位置只放置一个仅用于投票的小进程。这被称为 见证者(Witness) 或决胜节点(Tie-breaker)。本次实验中的见证者 VM 为 2 vCPU·2 GiB,安装后实际使用的内存为 1.5 GiB。
3. 这五种服务各自如何选出主节点
虽然这五种服务都使用了多数派,但统计多数派的主体各不相同。因此,见证者上应该部署什么也随之改变。
| 服务 | HA 方式 | 决定主节点的机制 | 见证者上部署的内容 |
|---|---|---|---|
| PostgreSQL 17 | Patroni 4.1 + 流复制, 同步复制(synchronous_mode: true, strict: false),pg_rewind |
etcd 3 成员(双向 TLS) | etcd 成员之一 |
| Redis 8.8 | 主 1 + 副本 1,AOF everysec | Sentinel 3 个,quorum 2 | Sentinel 之一 |
| OpenSearch 3.9 | 数据节点 2,索引副本 1,开启安全插件 | cluster_manager 投票节点 3 | 无数据的 cluster_manager 专用节点 |
| ClickHouse 26.8 | 分片 1 × 副本 2, ReplicatedMergeTree |
ClickHouse Keeper 3 个(Raft) | Keeper 之一 |
| SeaweedFS 4.51 | master 3(Raft),volume 2(复制 001),filer 2(共享 PostgreSQL),S3 网关 2 |
master Raft 3 | master 之一 |
选择各个服务的理由及其代价如下。
- PostgreSQL 选择了同步复制。 由于提交确认是在数据到达副本之后才返回的,所以在自动提升时不会丢失已确认的写入。
strict: false因此,如果副本挂了,Patroni 会解除同步要求并继续接收写入。代价是在没有副本期间,提交的数据将只剩下一份。这是一个偏向可用性的选择。 - OpenSearch 没有「仅投票」的角色。 在 3.9.0 发行版中我们寻找了
voting_only却没找到(该角色是 Elasticsearch 侧的功能)。因此,见证者被设为不包含数据的cluster_manager专用节点。由于见证者也可能被选为集群管理器,因此堆内存不能设得太小。我们将其设为 heap 384m·容器限制 1100m,而在限制为 700m 时因 OOM 而崩溃(实测)。 - ClickHouse 使用了 Keeper 而不是 ZooKeeper。 不需要单独的 JVM,并且与服务器打包在同一个版本中。3 个 Keeper 很快选出了 1 个 Leader 和 2 个 Follower。
- SeaweedFS filer 将元数据存储在 PostgreSQL 中。 由于两个 filer 查看相同的元数据,因此即使一个 filer 挂了,另一个 filer 也会直接接管。相应地,PostgreSQL 的故障会波及到 filer。
- SeaweedFS 的复制
001意为「在同一机房、同一机架的其他服务器上多存一份副本」。 如果只有两个卷服务器(Volume Server),所有文件都将在两台服务器上各存一份,一旦少了一台服务器,将没有满足该规则的空间,导致新的写入失败。 - 前端是 HAProxy。 PostgreSQL 仅将流量转发至 Patroni REST 的
GET /primary状态为 200 的节点,ClickHouse 和 S3 则分发到存活的一侧。在应用服务器位置(本次为见证者 VM)部署一个,并在每个数据节点上部署一个以便 filer 能够找到 PostgreSQL。
数据节点容器内存限制总计约为 4.6 GiB(OpenSearch 1.4g、ClickHouse 1.2g、PostgreSQL 768m、Redis 384m 等),安装后即时测得的实际用量为:数据节点 2.5 GiB,见证者 1.5 GiB。
4. 配置摘录
密码和密钥已省略(实际中仅保存在节点的 .env 文件且权限为 600 中)。地址已替换为节点名称或变量。所有服务均仅绑定到局域网地址和 127.0.0.1。
etcd(Patroni 的共识存储)。 三个节点使用相同的 compose 配置,仅修改节点名称。最后一行是仅有两台机器时的手动恢复位置。
services:
etcd:
image: gcr.io/etcd-development/etcd:v3.6.16
network_mode: host
command:
- etcd
- --name=${NODE_NAME}
- --initial-cluster=${ETCD_INITIAL_CLUSTER} # db1, db2, witness 세 멤버
- --client-cert-auth # 클라이언트·피어 모두 상호 TLS
- --peer-client-cert-auth
- --heartbeat-interval=100
- --election-timeout=1000
- --force-new-cluster=${ETCD_FORCE_NEW:-false} # 살아남은 한 대를 단일 멤버로 다시 세울 때만 true
mem_limit: 256m
Patroni。 Leader 密钥寿命 ttl 决定了「主服务器消失到完成提升」的上限。
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
synchronous_mode: true # 커밋이 복제본에 닿은 뒤 확인
synchronous_mode_strict: false # 복제본이 죽으면 동기 요구를 내리고 계속
failsafe_mode: false
postgresql:
use_pg_rewind: true
use_slots: true
watchdog:
mode: off # 컨테이너에 /dev/watchdog 이 없다
Redis 与 Sentinel。 min-replicas-* 这两行是本系列中付出最大代价的配置(第 2 篇)。
# redis.conf
appendonly yes
appendfsync everysec
min-replicas-to-write 1 # 10초 안에 응답한 복제본이 1개 이상일 때만 쓰기를 받는다
min-replicas-max-lag 10
# sentinel.conf
sentinel monitor mymaster <db1> 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
OpenSearch。 最初三方集结共同创建投票配置。角色通过环境变量指定(参考第 2 篇中的陷阱)。
# opensearch.yml
cluster.initial_cluster_manager_nodes: ["db1", "db2", "witness"]
# docker-compose.yml 의 environment
node.roles: cluster_manager,data,ingest # 증인은 cluster_manager 만
ClickHouse Keeper。 三个 Raft 成员之一作为见证者。30 秒会话超时与第 2 篇中的陷阱息息相关。
<coordination_settings>
<operation_timeout_ms>10000</operation_timeout_ms>
<session_timeout_ms>30000</session_timeout_ms>
</coordination_settings>
<raft_configuration>
<server><id>1</id><hostname>db1</hostname><port>9234</port></server>
<server><id>2</id><hostname>db2</hostname><port>9234</port></server>
<server><id>3</id><hostname>witness</hostname><port>9234</port></server>
</raft_configuration>
SeaweedFS master。 见证者上仅运行 master。
master:
profiles: ["data", "witness"]
command:
- master
- -peers=${DB1_IP}:9333,${DB2_IP}:9333,${WITNESS_IP}:9333
- -defaultReplication=001
mem_limit: 192m # 128m 에서 89MiB 까지 오르는 것을 보고 늘렸다(실측)
HAProxy(应用侧)。 应用仅知道 127.0.0.1:5000。当主服务器发生变更时,HAProxy 通过间隔 1 秒的两次检查来跟进。
listen pg_primary
bind 127.0.0.1:5000
option httpchk GET /primary
http-check expect status 200
default-server inter 1s fall 2 rise 2 on-marked-down shutdown-sessions
server db1 <db1>:5432 check port 8008
server db2 <db2>:5432 check port 8008
5. 测试方法
- 测试客户端在应用服务器位置(见证者 VM)运行。写入间隔为 PostgreSQL·Redis 0.05 秒、OpenSearch 0.1 秒、ClickHouse·S3 0.2 秒,超时限制为 2 到 5 秒。每次尝试记录一行,测试结束后读取存储库, 已确认的写入(收到成功响应的写入)是否全部保留进行了对比。角色变更情况每 0.25 秒监控一次。
- 隔离是在该节点内将会话网络接口的输入输出通过 iptables 丢弃。节点虽然存活,但与对方断开,即发生网络分区。 从对方来看,这与断电无法区分。
- 正常停止是指将该节点的所有容器以
docker stop(30秒宽限期)关闭。 - 最长写入中断是指一次成功的写入与下一次成功写入之间的最大间隔。VM 之间的时间差在 ±0.3 秒以内(通过 ssh 往返测得的上限)。
6. 真的只有 2 台时
6.1 是如何配成 2 台的
在原本有见证节点(Witness)的配置中移除了见证节点侧。
- etcd:
member remove用命令移除了见证节点,变成了 实际 2 成员。 - Sentinel: 停止了见证节点的 Sentinel 并发送了
SENTINEL RESET。剩下的两台只互相认识,quorum 为 2。 - OpenSearch:
voting_config_exclusions移除了见证节点并将其停止。于是 投票配置减少到了 db2 单台。 因为 2 是偶数,所以 OpenSearch 将其调整为了奇数(实测结果)。 - ClickHouse Keeper·SeaweedFS master: 停止了见证节点的服务。在三节点配置中只存活两台的状态下,“必须由双方都凑齐过半数 2”的算术逻辑与 2 成员相同,但并没有进行实际减少成员数的重配置(2 成员重配置本身未实测)。
6.2 如果将其中一台隔离 60 秒
当时 PostgreSQL 和 Redis 的主节点都是 db2。因此,隔离 db1 相当于“失去副本侧”,隔离 db2 相当于“失去主节点侧”。
| 服务 | 副本侧 (db1) 隔离 60 秒 | 主节点侧 (db2) 隔离 60 秒 | 原因 |
|---|---|---|---|
| PostgreSQL | 中断 108.4 秒. 存活的主服务器在 +23 秒时自动降级 | 中断 107.8 秒. 无提升 | etcd 2 成员的过半数为 2。失去对方后无法更新 Leader 键,因此 主节点侧也会下线. 恢复后也需要等待键过期,额外花了 47 秒 |
| Redis | 50.3 秒。主节点虽然保留,但从 +11 秒开始 NOREPLICAS |
61.1 秒。 MasterNotFound, 无主备切换 |
2 个 Sentinel 中的 1 个无法获取故障转移权限(过半数) |
| OpenSearch | 34.2 秒后继续(db2 独自作为投票配置) | 62.3 秒,停止直到恢复 | 投票配置仅剩 db2 一台。失去 db2 则集群不复存在 |
| ClickHouse | 60.5 秒(插入挂起,读取失败 0) | 60.9 秒 | Keeper 3 个位置中只能联系到 1 个 |
| SeaweedFS S3 | 108.9 秒 | 107.9 秒 | 从 +18 秒起 master 无 leader,且复制 001 |
有三个值得关注的点。
- “既然还剩一台,应该会继续运行吧”的预期在 PostgreSQL 中偏差最大。 仅仅失去了副本,主服务器却自行下线了。
这是因为失去了 etcd 2 成员的过半数。这不是缺陷,而是为了防止出现双主的正常行为。Patroni 的
failsafe_mode是一项即使失去共识存储,只要能通过 REST 联系到所有成员就能维持主节点的功用,但在网络分区中无法联系到对方,因此无法挽救这种情况 (仅确认了配置,未实测)。 - OpenSearch 的结果取决于“失去的是哪一侧”。 由于投票配置仅剩 db2 一台,因此失去 db1 时能继续运行,失去 db2 时则会停止。被隔离的 db2 独自扮演集群节点角色,接收了 77 条本地写入,恢复后这 77 条全部保留。由于 db1 侧没有集群,因此数据并没有分叉。
- 无论失去哪一侧,已确认写入的数据丢失量均为 0。 只会停止而不会丢失,这就是过半数规则提供的保障。
7. 手动提升:由人工声明“对方已死”
要在只有 2 台的配置中让停止的服务重新运行,需要人工干预。即声明“对方已死,剩下的一台即为过半数”。 在继续隔离 db2 的同时,在 db1 上按顺序对各个服务执行了操作。操作步骤已提前编写为脚本。
| 服务 | 步骤 | 从步骤开始到恢复写入 | 从隔离到恢复写入 |
|---|---|---|---|
| PostgreSQL | ① 将 etcd 以 --force-new-cluster=true 重新建立为单成员集群(8.7秒时健康) ② Patroni 在等待旧 Leader 键过期(ttl 30)后提升 db1(同步副本) |
44.2秒 | 60.2秒 |
| Redis | REPLICAOF NO ONE → CONFIG SET min-replicas-to-write 0 → CONFIG REWRITE. Sentinel 在 SENTINEL REMOVE 后将 db1 重新设为 quorum 1 SENTINEL MONITOR |
0.9秒 | 59.9秒 |
| OpenSearch | 停止容器 → bin/opensearch-node unsafe-bootstrap(相同的数据卷) → 启动。集群状态为 red(primary 位于 db2 的索引) |
27.0秒 | 87.3秒 |
| ClickHouse | 以单服务器配置启动 Keeper + --force-recovery → 2.5秒时为 standalone → 9秒时表格 is_readonly=0 |
11.1秒 | 98.1秒 |
| SeaweedFS | 将 master 以 -peers=none 单节点重新拉起(1.4秒时成为 leader) → fs.configure -locationPrefix=/buckets/ -replication=000 -apply 降低复制要求 |
24.2秒 | 121.2秒 |
整个流程在隔离发生 15.9 秒后从 PostgreSQL 开始,按服务逐个执行。因此顺序越靠后,“从隔离起”的耗时数值就越大。在实际故障中花费的时间约等于“察觉并做决定的时间 + 倒数第二列”。所有五个服务中,已确认写入的数据丢失量均为 0。
PostgreSQL 耗时最长的原因在于,即使重建 etcd 后,也必须等待旧 Leader 键过期。这 30 秒即为 ttl 。
7.1 手动提升时的注意事项
- 必须由人工保证“对方确实已死”。 如果对方仍然存活并正在接收写入,此时就会出现两个主节点。在生产环境中,应先通过关闭对方电源或断开网络来进行 隔离防护(fencing) ,然后再进行提升。
- ClickHouse Keeper 在强制恢复刚结束的大约 20 秒内,
insert_quorum=2插入请求响应了成功。 这是在隔离基准的 +100 秒到 +120 秒之间发生的 98 条写入。由于 db2 仍然处于隔离状态,因此不可能获得 2 个副本的确认。推测这是因为恢复后的 Keeper 状态中,在会话过期之前一直保留着 db2 旧的“存活”标记,从而误算成了 2 个副本(推测。依据是成功响应集中在会话超时 30 秒内,并且此后通过TOO_FEW_LIVE_REPLICAS报错了 1346 条)。在强制恢复后,在会话时间过去之前,不能轻信法定人数插入的成功响应。 - 回滚(将死掉的节点重新加入)未进行测试(未实测)。 这里仅记录步骤。通过 etcd
member add重新加入 db2 并清空数据目录启动 → Patronireinit db2→ RedisREPLICAOF重新注册 Sentinel → OpenSearch 清空 db2 数据并加入(UUID 与unsafe-bootstrap的集群不同) → Keeper 扩展回 3 服务器配置 → 恢复 SeaweedFS master peers → 通过fs.configure恢复001。volume.fix.replication -apply.
8. 总结
| 问题 | 答案(实测) |
|---|---|
| 2 台机器能否实现自动 HA | 不能。失去一台后,所有五个服务的写入都会停止 |
| 仅仅失去副本也会停止吗 | PostgreSQL(etcd 2 成员)的主服务器自行下线,中断了 108.4 秒 |
| 是否丢失了数据 | 已确认写入的数据丢失量为 0。只会停止而不会丢失 |
| 若要靠 2 台机器坚持运行 | 需手动提升。各服务耗时 0.9 秒至 44.2 秒不等,需先进行隔离防护 |
| 若要实现自动切换 | 投票成员应设为 3 个。第三个节点只需是一个不存数据的小型见证节点即可 |
加入了见证节点的配置在各种故障下的表现,以及“数据节点仍然只有 2 台”这一条件留下的陷阱,将在 第 2 篇中探讨。
Titi 的小贴士。 两个人少数服从多数时,永远都是 1 对 1。所以双方都会想“我来决定真的可以吗?”然后就停下来了。只要再多一个能帮忙投个票的朋友就行了!那个朋友根本不需要背负任何数据包袱!
参考
- ClickHouse Keeper 指南(配置示例与单节点强制恢复步骤): https://clickhouse.com/docs/guides/sre/keeper/clickhouse-keeper (访问于 2026-10-31)