状态:实际测量(2026-10-31)。 此数值在运行于 3 台 TT Lab 实践 VM(KubeVirt,Ubuntu 24.04)上的 Docker 中测得。每项测试进行了一次,未进行 VM 电源直接关闭测试(未测量)。配置与测试方法请参考第 1 篇。 编写规则:以实测数据为基础,未测出的标记为“未测量”,原因未确认的解释标记为“推测”。
目录
1. 概览
在第 1 篇中,我们看到仅有 2 台数据节点时,丢失一台节点会导致全部 5 个服务停止。这次我们在引入了仅参与投票的见证 VM 的架构(数据节点 db1、db2 + 见证节点)中引入了 3 种类型的故障。
- 隔离:通过 iptables 切断单个节点的网络。节点虽然存活,但处于与对端断开的分区状态。
- 正常停止:通过
docker stop(30秒宽限期)完全关闭单个节点的容器。 - 见证节点停止:停止见证节点的 etcd、Sentinel、OpenSearch、Keeper、SeaweedFS master。
首先记录结果。
- 实现了自动故障转移。 在隔离状态下,PostgreSQL 在 22.6 秒到 32.7 秒、Redis Sentinel 在 6.5 秒到 7.0 秒内选出了新主节点。在正常停止状态下,PostgreSQL 在 2.8 秒到 3.1 秒内完成了切换。
- 在所有测试和所有服务中,确认收到的写入丢失数均为 0。
- 即使将见证节点关闭 90 秒,PostgreSQL、Redis 和 OpenSearch 的写入失败次数也为 0。 这是因为只要两台数据节点中存活两台,就满足过半数(2/3)。
- 然而, “数据节点只有 2 台”这一条件并未改变。 Redis 的
min-replicas-to-write 1、ClickHouse 的insert_quorum=2以及 SeaweedFS 的复制001均属于“必须有对端数据节点存在才能写入”的配置,因此会阻断写入,直到对端恢复。下表中较长的数据大多源于此。
2. 按故障类型划分的最长写入中断
最长写入中断是指成功写入与下一次成功写入之间最长的时间间隔(秒)。括号内为失败次数/尝试次数。 ClickHouse 为默认插入,
quorum=2 是通过 insert_quorum=2 插入的, S3 是 HAProxy 后端的 2 个 SeaweedFS S3 网关。
| 服务 | db1 隔离 90秒 | db2 隔离 90秒 | db1 隔离 40秒 | db2 正常停止 90秒 | db1 正常停止 90秒 | 见证节点停止 90秒 |
|---|---|---|---|---|---|---|
| PostgreSQL | 23.7 (400/5910) | 28.3 (491/5915) | 33.7 (601/3120) | 3.4 (25/5944) | 3.7 (34/5943) | 0.09 (0/4780) |
| Redis | 105.0 (471/4364) | 105.1 (1964/5855) | 55.2 (371/2463) | 268.8 ※ (5349/5968) | 134.8 (2673/5970) | 0.09 (0/4782) |
| OpenSearch | 66.3 (16/2347) | 64.7 (15/2364) | 40.2 (9/1196) | 0.3 (0/2990) | 0.7 (0/2986) | 0.13 (0/2392) |
| ClickHouse | 5.3 (1/1476) | 5.2 (1/1476) | 5.2 (1/776) | 2.5 (2/1491) | 2.4 (2/1490) | 5.4 (1/1175) |
| ClickHouse quorum=2 | 60.6 (303/1475) | 59.9 (300/1451) | 10.0 (51/752) | 122.9 (601/1488) | 123.6 (602/1486) | 5.3 (1/1175) |
| SeaweedFS S3 | 95.7 (19/898) | 111.2 (11/955) | 61.2 (11/505) | 122.4 (11/898) | 131.1 (12/856) | 27.6 (2/1055) |
※ db2 正常停止测试中的 Redis 268.8 秒,是由于官方镜像缺陷导致重启的 Redis 陷入重启死循环而最终未能恢复的值(4.5 节)。修复后在 db1 上执行相同测试的结果位于右侧第二列。写入间隔为 PostgreSQL 与 Redis 0.05 秒,因此 0.09 秒意味着没有中断。
读取(查询)失败几乎没有发生。OpenSearch _count 在每次测试中为 0 到 1 次,ClickHouse 与 S3 在所有带见证节点的测试中为 0 到 1 次。
2.1 主节点切换过程
从注入故障时起计算的秒数。
| db1 隔离 90秒 | db2 隔离 90秒 | db1 隔离 40秒 | db2 正常停止 | db1 正常停止 | |
|---|---|---|---|---|---|
| Patroni 新主节点 (primary) | 22.6 | 27.4 | 32.7 | 3.1 | 2.8 |
| Sentinel 新主节点 (master) | 6.5 | 6.6 | 7.0 | 7.9 | 7.8 |
| OpenSearch 节点移除 (yellow) | 64.8 | 65.2 | 移除前恢复 | 0.6 | 0.5 |
| SeaweedFS 新 master 领导者 | 12.1 | 领导者不是被隔离节点 | 领导者不是被隔离节点 | 无影响 | 15.0 |
| 恢复后加入 | PG 15.5(包含 pg_rewind 与重启),OpenSearch green 5.1 | PG 6.6,OpenSearch green 2.8 | PG 14.2,OpenSearch green 6.9 | OpenSearch green 23.2(以开始为准) | OpenSearch green 26.3 |
2.2 按服务查看读取情况
- PostgreSQL:在隔离情况下,必须等待老主节点的领导者键寿命(
ttl: 30)结束才会提升,因此花费了 22 秒到 34 秒。在正常停止情况下,Patroni 主动释放了键,因此在 3 秒内完成了切换。老主服务器在恢复后通过 pg_rewind 进行回滚,并作为副本重新加入。如果想缩短隔离时间,减少ttl是直接的手段,但未对缩短后的数值进行测量(未测量)。 - Redis:切换本身在 6.5 秒到 7.9 秒内完成。但是, 新主节点也配置了
min-replicas-to-write 1,因此在副本(老主节点)恢复之前,会通过NOREPLICAS拒绝写入。 写入中断时间为“故障时间 + 10 秒到 15 秒”。如果只有两台数据节点,该设置等同于“一台死亡即停止写入”。代价与选项将在 4.1 节中讨论。 - OpenSearch:正常停止时节点主动退出,中断时间不到 1 秒。隔离则导致部分写入暂停了约 65 秒(4.4 节)。
- ClickHouse:由于两个副本都接收写入,默认插入仅因 HAProxy 剔除故障端而中断了 2 到 5 秒。
insert_quorum=2会因TOO_FEW_LIVE_REPLICAS报错,直到对端恢复。追赶数据非常快:在停止 150 秒后,用 16 秒清空了 896 个复制队列。 - SeaweedFS:在复制
001下,如果只剩下一个卷服务器,新的写入分配将会失败。这是预料之中的。出乎意料的是,错误并没有立即返回,而是 每个请求都会挂起约 10 秒钟,最终以超时结束(4.7 节)。所有测试中的读取均未失败。
2.3 关闭见证节点的情况
| PostgreSQL | Redis | OpenSearch | ClickHouse 默认 / quorum=2 | S3 |
|---|---|---|---|---|
| 失败 0 / 4780 | 失败 0 / 4782 | 失败 0 / 2392 | 5.4秒 1条 / 5.3秒 1条 | 27.6秒,2条 |
ClickHouse 和 S3 的短暂中断是 因为当时 Keeper 领导者和 SeaweedFS master 领导者正好在见证节点上。重新选举领导者分别花费了约 5 秒和 12 秒,而 S3 在卷服务器重新连接到新领导者之前中断得更久。虽然见证节点只是一个“仅用于投票”的角色,但值得记住的是,在 Raft 协议中它也有可能成为领导者。
3. 网络分区:被隔离的老主节点是否会接收写入
实现了自动故障转移并不意味着万事大吉。如果断网一侧的老主节点在此期间接收了写入并在随后丢弃,应用程序在收到成功响应后仍会丢失数据。 将 db1(当时的主节点)隔离 40 秒,并在 db1 内部 每隔 0.5 秒直接写入其本地服务。恢复后,对比了这些写入是否被保留。
| 服务 | 被隔离的老主节点接收到的写入 | 恢复后保留的数据 | 防范机制 |
|---|---|---|---|
| PostgreSQL | 0。隔离后立即失败 | 不适用 | 同步复制(等待副本确认)+ 丢失共识存储则降级 |
| Redis | 隔离后 0 秒到 10.7 秒之间 22 条 | 0 条(22 条全部丢失) | min-replicas-max-lag 10 过去后才 NOREPLICAS。这 10 秒即为丢失窗口 |
| OpenSearch | 100 条 | 100 条 | ※ 这是刚创建的索引,副本尚未进入 in-sync 集合(常规索引的写入被阻止)。没有发生分支 |
| ClickHouse | 0。由于丢失 Keeper 过半数,插入挂起 | 不适用 | Keeper 过半数 |
| SeaweedFS filer | 0。超时 | 不适用 | master 过半数 |
严格遵循过半数规则的 PostgreSQL、ClickHouse、SeaweedFS 拒绝了被隔离一侧的写入。唯独 Redis 出现了数据丢失。
4. 运维人员必须了解的陷阱
以下内容是在本次测量中实际经历过的。
4.1 Redis min-replicas-max-lag 本质上就是网络分区时的丢失窗口
Redis 的复制是异步的。主节点接收写入后会立即返回成功,并随后发送给副本。 min-replicas-to-write 1 与
min-replicas-max-lag 10 意味着“仅当最近 10 秒内有至少 1 个副本响应时才接受写入”。被隔离的主节点在副本的最后响应时间超过 10 秒之前,会继续接收写入。
在实际测量中,这个窗口完全如预期般开启。被隔离的 db1 在最初的 10.7 秒内成功接收了 22 条写入,而在此期间的 +7.0 秒时,Sentinel 已将 db2 立为新主节点。恢复后,db1 成为 db2 的副本并被 db2 的数据覆盖, 这 22 条写入没有一条留存下来。 但从应用端来看,这是收到了“成功”响应的写入。
有三种选择,没有一种是免费的。
min-replicas-max-lag降低。窗口会缩短,但只要副本稍有延迟就会拒绝写入。min-replicas-to-write设为 0。即使丢失一个数据节点,写入也会继续(消除了 2.2 节中 105 秒的中断),但在发生网络分区时,旧主节点接收到的写入量将无限期增长。- 要完全消除这种窗口,需要使用具备同步共识机制的存储,而不仅仅是 Redis。对于像缓存这样即使丢失也无关紧要的数据,使用 Redis 也是一种解决方案。
本次配置采用了第一种设置(1, 10),其代价经测算表现为两点:“分区时最多约 10 秒的写入丢失”以及“丢失一个数据节点时写入暂停”。
4.2 返回失败但实际已存储的写入,以及幂等键
确认收到的写入丢失数量为 0,但出现了反方向的偏差。 收到失败响应但实际上已经存储的写入。
| 带证人节点的配置测试 | OpenSearch | ClickHouse quorum=2 | S3 |
|---|---|---|---|
| db1 隔离 90 秒 | 16 / 失败 16 | 303 / 失败 303 | 4 / 失败 19 |
| db2 隔离 90 秒 | 15 / 失败 15 | 300 / 失败 300 | 3 / 失败 11 |
| db1 隔离 40 秒 | 9 / 失败 9 | 51 / 失败 51 | 4 / 失败 11 |
| db2 正常停止 90 秒 | 0 / 失败 0 | 601 / 失败 601 | 2 / 失败 11 |
| db1 正常停止 90 秒 | 0 / 失败 0 | 601 / 失败 602 | 2 / 失败 12 |
(表中的值为“失败响应却已存储的数量 / 失败响应数量”)
- OpenSearch 发生的情况是客户端超时后才反映出来的写入。
- ClickHouse
insert_quorum=2回答了“未满足法定人数”,但其实已经写入了一个副本中,并在对方恢复后直接进行了复制。几乎所有的失败响应都是这种情况。不能将“失败”简单等同于“未写入”。 - 当丢失 Keeper 大多数节点时(测试中停止了 3 个 Keeper 中的 2 个)也是如此。插入操作在没有报错的情况下悬挂了 5 秒超时(60 秒内 13 条),而当重新激活 Keeper 后,这 13 条数据最终全部被存储。
如果客户端因为失败而重新发送,会导致同一行数据被写入两次。因此, 重试写入需要用到幂等键。OpenSearch 由应用指定文档的 _id 并写入,而 ClickHouse 则附加了 insert_deduplication_token 。首先需要确认应用的重试逻辑是否假设了“因为失败了所以肯定没写进去”。
4.3 ClickHouse Keeper 强制恢复后的法定人数窗口
这是在第 1 篇 7.1 节中看到的内容。在只有 2 个节点的配置中,将 Keeper 以 --force-recovery 作为单服务器恢复后的大约 20 秒内,尽管对方副本仍处于隔离状态,但
insert_quorum=2 有 98 个插入操作响应为成功。这之后,有 TOO_FEW_LIVE_REPLICAS 个操作以 失败。据推测(推测),恢复后的 Keeper 在会话超时(30 秒)之前,依然保留着对方旧的“存活”标志。在强制恢复后,在会话超时之前,切勿盲目相信法定人数插入会成功。
4.4 OpenSearch 察觉到隔离节点花去了 65 秒钟
在正常停止的情况下,节点会主动离开集群,因此写入中断时间不到 1 秒。但隔离则不同。节点被剔除(变为 yellow)花了 64.8 秒和 65.2 秒,在此期间, 发往该节点上拥有 primary 分片的索引的写入操作陷入了停滞。 搜索(_count)在每次隔离测试中仅失败了 0 到 1 次。
OpenSearch 使用默认的故障检测配置(单次检查超时时间 10 秒,连续失败 3 次判定为故障)来监视节点。由于在隔离情况下此判定需要经过两个阶段,因此耗时大约为 65 秒。 cluster.fault_detection.* 虽然减小
的值可以缩短这段时间,但我们并未对调整后的值进行过测量(未实测)。由于暂时的网络抖动也可能导致节点被剔除,因此如果缩短该值,必须重新对其进行测量。 redis:8 4.5 官方
镜像与 Sentinel 的重启死循环
- 这对应了第 2 节表格中的 268.8 秒。其原因是多种因素交织在一起导致的。
redis:8官方.so镜像的入口点会将模块文件夹中的所有--loadmodule以 - 的形式附加并启动 Redis。
CONFIG REWRITESentinel 在发生故障转移时会向 Redis 发送loadmodule。这样一来,正在运行的配置会被重新写入文件,导致这些redis.conf行被追加到 - 中。
Can't load module ... server aborting在下一次启动时,入口点附加的模块与文件中写入的模块发生重叠,导致同一模块被加载两次,从而因
而崩溃。容器随后重启,然后再次崩溃。
redis:
image: redis:8.8.4
command: ["redis-server", "/conf/redis.conf"]
tmpfs:
- /usr/local/lib/redis/modules # 진입점이 붙일 모듈이 없게 한다
sed -i '/^loadmodule /d' /path/to/redis.conf # 이미 박힌 줄 지우기
由于该问题仅在使用 Sentinel、经历过一次故障转移并重新启动该 Redis 时才会暴露出来,因此在刚安装完的测试中是无法看到的。如果不使用模块,解决方法就很简单:用一个空的 tmpfs 覆盖模块文件夹,彻底不加载任何模块。并将已经写入的行删除。 min-replicas 修复后在同样的测试(db1 正常停止)中,Redis 恢复正常,剩余的 134.8 秒中断则是 4.1 节中redis/docker-library-redis#468,
redis/redis#14178所带来的代价。该问题已向上游报告(redis/redis#16009),Redis 本体的修复已于 2026-10-29 合并,预计将在下一个版本中发布。
同根源还有一个小陷阱。 Sentinel 会将 requirepass 改写为 ACL 哈希行。 由于从配置文件读取密码的健康检查会失败,这次我们让认证前的响应(NOAUTH)也被视为“存活”。
4.6 将见证节点放在第三个故障域
在本次测试中,见证节点 VM 与 db2 处于 同一物理服务器 上。由于只引入了 VM 级别的故障,因此结果没有受到影响,但如果该物理服务器宕机,db2 和见证节点将同时丢失,3 票中会失去 2 票。这样就会像只有 2 台机器的配置一样停止运行。
见证节点的价值在于“不与任何一方数据节点同时宕机”。在生产环境中,应将见证节点部署在 第三个故障域,即不同的机架、不同的机房、不同的电源与交换机,或者是云上的小型 VM。见证节点所做的工作仅仅是投票,因此小型 VM 就足够了。本次的见证节点使用的是 2 vCPU 和 2 GiB 内存,在安装后立即使用了 1.5 GiB。不过,正如 2.3 节所看到的,见证节点也可能成为 Raft 领导者,因此如果与数据节点之间的往返延迟太大,可能会延长重新选举领导者期间的中断时间(未实际测量)。
4.7 SeaweedFS:无法仅凭一个卷服务器运行,但也不会立即失败
在复制 001 中,如果只剩下一个卷服务器,就没有地方放置新的写入。到这里为止都符合预期。问题在于,请求在挂起约 10 秒后才返回失败。这里有两个因素交织在了一起。
- 幸存的卷服务器持续向已宕机的旧 master 领导者发送心跳,直到 54 秒 后才连接到新领导者。在此期间,新领导者的注册容量为 0,因此输出了 “no volume server capacity registered yet”。这与实际原因不符。
- 当被隔离的 master 恢复时,它会扰乱原本正常的领导者,导致 6 秒到 19 秒内没有领导者。
这两个问题都已提交给上游。针对卷服务器迟迟找不到新领导者的问题,我们提交了修复 PR(seaweedfs#12259,复现中的时间从 25 秒缩短至 4 秒);针对恢复的 master 拉低领导者的问题,我们提交了 Issue(seaweedfs#12260)。在同样的实验中,如果切换到 -raftHashicorp(开启了 PreVote 的 Raft),领导者就不会被扰乱。如果在生产环境中使用 2 个数据节点来运行 SeaweedFS,请将“当其中一台机器离线时,上传不会立即失败而是会挂起”这一情况计入应用程序的超时设置中。如果是整个卷服务器磁盘丢失的情况, volume.fix.replication -apply 在 30.7 秒内匹配了全部 24 个卷,在此期间 S3 写入失败 1 次,读取失败 0 次。在让单个 filer 停止 60 秒的测试中,748 次 S3 写入的失败次数为 0 次。
4.8 常见短小陷阱汇总
| 陷阱 | 症状 | 应对 |
|---|---|---|
OpenSearch node.roles: ${OS_ROLES}(yml 中的环境变量替换) |
变成了一个单一字符串而不是列表,导致节点之间无法组成集群。仅在 DEBUG 日志中可见 | 通过环境变量 node.roles=cluster_manager,data,ingest(逗号分隔列表)传入 |
| OpenSearch 3.9 见证节点堆内存 320m·上限 700m | OOM 重启死循环 | 堆内存 384m,限制 1100m |
| SeaweedFS 镜像以降 uid 1000 运行 | 无法读取 root 600 的 S3 配置文件,导致 S3 崩溃 | 将文件所有者设为 1000 |
| SeaweedFS master 限制 128m | 上升到 89 MiB | 192m |
| ClickHouse 26.8 HTTP 插入的默认值 | async_insert=1, wait_for_async_insert=1 出现了 |
法定人数测试 async_insert=0 单独 |
| ClickHouse Keeper Raft 端口 | listen_host 即使指定了,也会在所有地址 (9234) 上打开 |
使用主机防火墙阻止外部接口 |
iptables -m multiport |
单条规则最多 15 个端口(“too many ports specified”) | 将规则拆分为两条 |
容器中 /dev/watchdog 没有 |
Patroni 隔离仅依赖于共识存储的 TTL。如果主服务器没有死机而是卡住并超过了 TTL,则可能会短暂出现两个主服务器 | 传递宿主机 watchdog 或 softdog(未实测) |
docker stop 宽限 30 秒 |
由于容器耗尽了宽限期,导致整个节点停止花费了 30 秒 | 将其计入正常停止流程的时间预算中 |
5. 本次测量的局限性
在套用具体数据之前,请先确认相关条件。
- 这是运行在 VM 上的 Docker。 它是在 KubeVirt VM 内部通过 Docker Compose 运行的。在物理服务器、其他虚拟机监控程序(hypervisor)以及 Kubernetes operator 配置下,数值可能会有所不同。容器的内存限制也比生产环境中设置得更小(例如 OpenSearch 为 1.4g 等)。
- 各项测试均仅进行了一次。 网络隔离测试在改变节点和长度的情况下进行了三次,正常停止进行了两次(其中有一次 Redis 的数值由于 4.5 节中提及的缺陷需要单独看待),证人(witness)停止以及仅有 2 台成员配置的网络隔离与手动提升分别进行了一次。这不是重复相同条件得到的分布或中位数。
- 未进行 VM 电源关闭测试(未实测)。 我们认为“网络隔离”与从对方视角来看的“电源关闭”效果相同,故以此代替。诸如磁盘缓存丢失等仅在电源关闭时才会出现的现象并未包含在本次测量中。
- 证人(witness)与其中一个数据节点位于同一台物理服务器上。 由于只引入了 VM 级别的故障,因此对结果没有影响,但并未测量物理服务器故障。
- 未测量的项目:回滚(将宕机的节点重新加入)、通过 libpq 多主机连接(
target_session_attrs=read-write) 寻找主服务器、Patronifailsafe_mode、缩短 OpenSearch 故障检测值、使用 2 个成员实际重新配置的 Keeper 与 SeaweedFS master、watchdog 隔离(fencing)。 - 安全配置的漏洞: etcd·PostgreSQL·OpenSearch 使用了自签 CA 的 TLS,但 Redis·Sentinel 仅使用密码认证,Patroni REST·SeaweedFS·ClickHouse HTTP 在会话网络内是明文传输。在生产环境中,这三者也需要加上 TLS。
- VM 之间的时钟差在 ±0.3 秒以内,因此未对小于 1 秒的差值进行解读。
6. 总结:采用“2 台 + 见证节点”方案时的检查清单
| 检查项 | 原因(本文的实际测量结果) |
|---|---|
| 将见证节点部署在与其他数据节点不同的故障域中 | 如果部署在同一位置且同时宕机,则等同于只有 2 台的架构 |
根据对 Redis 的 min-replicas-* 的考量来选择 |
(1, 10) 在网络分区时丢失 22 条写入,丢失一个节点 90 秒会导致 105 秒写入暂停 |
| 为重试的写入操作添加幂等键 | 在 OpenSearch·ClickHouse·S3 中出现了返回失败响应但实际已保存写入的情况 |
| 在强制恢复 ClickHouse 后,在会话期间内不盲目相信法定人数 | 98 条虚假成功 |
| 将 OpenSearch 隔离检测时间纳入预算 | 默认值为 65 秒 |
官方 redis:8 + Sentinel 时,请清空模块文件夹 |
故障转移后的重启循环 |
| 提前写好手动提升流程并从隔离(fencing)开始 | 仅有 2 台时的唯一出路。各服务的耗时从 0.9 秒到 44.2 秒不等 |
TT 的寄语。 有了见证节点朋友,多数表决就能很好地运转了。但是帮忙搬东西的朋友依然只有两个,所以当其中一个休息时,有些服务会说:“等搭档来了我再放下行李。”还有,听到“失败了”而重新发送时,请务必贴上名牌(幂等键)。因为同样的行李送来两次会很麻烦的!