Skip to content

仲裁节点不存数据:PSA 副本集里 w:majority 与回滚 ​

为了省一台机器,副本集用了「一主、一从、一仲裁」:仲裁节点只投票、不存数据,资源占用很小,三个成员也满足了「奇数个投票者」的要求。一次从节点维护期间,应用里所有使用 w: "majority" 的写入都开始超时;而另一个使用默认写关注的服务一切如常,直到主节点也出了故障,它在维护期间写入的数据不见了。

MongoDB 副本集的选举和写确认都基于「多数」,但这是两个不同的数:选举的多数是全部投票成员的多数;w: "majority" 的确认门槛取「全部投票成员的多数」与「存数据的投票成员数」中较小的那个。仲裁节点有投票权,却不存数据,只能计入前者。在两个数据节点加一个仲裁节点(PSA)的结构里,两个门槛都是 2:三票中两票就能选出主节点,写确认却要两个数据节点都确认,只要其中一个不可用就无法满足。

本文用三个 MongoDB 9.0.4 容器组成 PSA 副本集,在实验过程中停止和启动数据节点,看写关注、读关注和回滚的实际表现,最后验证官方给出的缓解办法。实验见文末。

① m2 停机m1 主节点a、b、cm2停机arb只投票选举:m1 + arb = 2 / 3,主节点不变写确认门槛 min(2, 2) = 2:写 b 超时默认写关注(PSA 下为 w:1)写 c 立即确认② m1 停机,m2 回来m1停机,持有 b、cm2 主节点a、darb投给 m2m2 + arb = 2 / 3 当选;仲裁节点不知道 m2 缺数据③ m1 重新加入m1 回滚 b、c → /data/db/rollback;两个节点都是 a、d留下 a、d:不能据此反推写关注
图 1 · 实测(MongoDB 9.0.4 三容器):m2 停机时 m1 与仲裁节点仍是投票多数,主节点不变;w: majority 写入 b 超时,但 b 已在 m1 上;默认写关注写入 c 立即确认。之后 m1 停机、m2 回来当选,m1 重新加入时回滚 b、c,写进 rollback 目录。最终留下的 a 经过多数确认,d 只是写在胜出的节点上。

一、投票的多数与数据的多数 ​

text
投票成员 3 个,存数据的成员 2 个,多数为 2
两个数据节点都在:w:majority 写入 a 成功
停掉 m2 之后,主节点仍是 m1(m1 与 arb 两票构成多数)

停掉数据节点 m2 后,m1 加上仲裁节点仍有两票,主节点不会下台,系统看起来是「可用的」。但存数据的成员只剩一个:

text
w:majority(wtimeout=3000)写入 b:WTimeoutError,错误码 64
主节点上 readConcern local 读到 ['a', 'b'];readConcern majority 读到 ['a']

这里写确认的门槛是 min(2, 2) = 2,而在线的数据节点只剩一个,所以一直等到超时。关键在于:超时不代表写入失败。b 已经写进了主节点,用 local 读得到;它只是没有达到多数确认,所以 majority 读不到。应用如果把超时当作失败去重试,可能写入两次;如果当作成功,又可能在后面被回滚。超时的结果是「未知」,处理方式和超时之后对方到底执行了没有一样。

官方文档还提到,majority 提交点长时间无法推进时,存储引擎需要保留更多历史数据,可能带来缓存压力。这一点本文没有测量。

二、默认写关注:PSA 下是 w:1 ​

text
getDefaultRWConcern:defaultWriteConcern 字段不存在,来源 implicit(隐式默认值由拓扑决定,下面用行为验证)
用默认写关注写入 c:立即确认,没有任何错误

MongoDB 5.0 起,隐式默认写关注一般是 majority,但有一个例外:存在仲裁节点,并且存数据的投票成员数不超过投票多数时,隐式默认值是 w: 1。PSA 正好落在这个例外里:2 个数据节点,投票多数也是 2。所以同样在 m2 停机时,没有显式指定写关注的写入被主节点立即确认,没有任何报错。开头那个「一切如常」的服务就是这种情况。

三、主节点切换:只在旧主节点上的写入被回滚 ​

接着停掉 m1,再启动 m2:

text
停掉 m1、启动 m2 之后,m2 与 arb 两票选出主节点 m2
新主节点上的数据:['a'](b、c 只在 m1 上)
在新主节点上写入 d

m2 停机期间写入的 b、c 只存在于 m1 上。m1 不可用之后,m2 和仲裁节点仍构成投票多数,m2 当选为主节点,它的数据里没有 b、c。仲裁节点只能判断「谁可以当主节点」,无法知道 m2 缺了哪些数据。

text
m1 重新加入后的状态:从节点;主节点 m2
m1 上的数据:['a', 'd'];m2 上的数据:['a', 'd'];b、c 都不在了
m1 的 /data/db/rollback 下按集合 UUID 存放的被回滚文档文件:1 个

m1 回来后发现自己的历史与新主节点分叉了,于是回滚分叉之后的写入,再从 m2 同步。b 和 c 被写进 rollback 目录,数据库不会自动把它们重新应用,需要人工检查和处理。

回顾一下:c 在写入时被确认了,b 在写入时超时了,两者最后都消失了。留下来的 a 和 d 也不是同一种情况:a 在两个数据节点都在线时经过了多数确认;d 只是写在了最终胜出的那个节点上,用的是默认写关注。所以不能从「最后留了下来」反推它当初被多数确认过。只有经过多数确认的写入,才能保证不因为主节点切换而回滚。

四、怎么选 ​

结构一个数据节点不可用时代价
PSA(两个数据节点 + 仲裁)仍有主节点;w: majority 写入超时;默认 w: 1 的写入可能在之后被回滚省一份数据副本
PSS(三个数据节点)剩下两个数据节点仍是多数,w: majority 正常多一份存储与同步
PSA,从节点长时间不可用时把它改为 votes: 0、priority: 0写确认门槛降为 1,w: majority 由主节点一个确认即可,写入不再超时需要运维流程配合;此时「多数确认」也只有一份在线数据

从节点长时间不可用:改为不投票 ​

官方针对 PSA 给出的缓解办法,是把长时间不可用的从节点重新配置为 votes: 0、priority: 0。在上面的实验结束后(此时 m2 是主节点),停掉从节点 m1,用正常的 replSetReconfig(不加 force)做前后对照,m1 在整个过程中保持停机:

text
m1 停机仍有投票权:writeMajorityCount=2,majorityVoteCount=2,写入 e 超时(错误码64),local 已读到 e
m1 votes=0/priority=0 且仍停机:writeMajorityCount=1,majorityVoteCount=2;3次 w:majority(wtimeout=3000)写入均确认
调整后的 majority 读到 ['a', 'd', 'e', 'f1', 'f2', 'f3'];此前超时的 e 也已进入提交视图

调整后投票成员变成 m2 和仲裁节点两个,选举多数仍是 2;存数据的投票成员只剩 m2,写确认门槛变为 min(2, 1) = 1,w: majority 不再超时。之前超时的 e 也进入了 majority 读的视图,这再次说明写关注超时并不等于写入被撤销。

这个调整改变的是确认门槛,没有增加任何数据副本:此时的「多数确认」只代表写进了唯一在线的那个数据节点。从节点恢复后怎样安全地恢复它的投票权,以及调整之后再失去主节点会怎样,本文都没有验证。

如果数据不能丢,副本集应该至少有三个存数据的节点,并在应用里显式使用 w: "majority",把超时当作结果未知来处理。PSA 适合能接受少量数据回滚、换取成本的场景,前提是清楚这个代价。

五、检查清单 ​

  • 区分两个门槛:选举看全部投票成员的多数;w: majority 取它与存数据的投票成员数中较小的那个。
  • 显式设置写关注,不依赖隐式默认值;PSA 下默认是 w: 1。
  • w: majority 超时意味着结果未知:写入可能已在主节点上,也可能在之后被回滚。
  • 监控 rollback 目录与回滚事件,回滚的数据需要人工处理。
  • 数据不能丢就用三个数据节点;PSA 的从节点长时间不可用时,按官方流程把它设为 votes: 0、priority: 0,并清楚此时只有一份在线数据。

配套实验

  • codesphere-labs/storage/mongodb-arbiter-majority:MongoDB 9.0.4 三容器 PSA 副本集、PyMongo 4.19.0:一个数据节点停机后的 w: majority 超时与读关注、默认写关注、主节点切换与回滚,以及停机从节点改为 votes: 0、priority: 0 前后的写确认门槛(验证记录)。首次验证用的是 MongoDB 8.2.12,当时 Docker 虚拟机内核 7.0.12 上更新的版本拒绝启动,见验证记录的历史。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权