ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MongoDB集群一致性实战:writeConcern、readConcern与故障恢复

MongoDB集群一致性实战:writeConcern、readConcern与故障恢复 1. 一次主备切换暴露出的问题ack 不等于持久化我印象里最惨的一次数据事故发生在一次机房电源抖动后的主备切换。三节点的 Mongo 副本集主节点先掉了两个从节点马上推选出新主业务继续写入看起来一切正常。等故障节点重启追上线之后运维去跑对账发现丢了十三分钟的数据。查原因的时候大家盯着写日志看——业务端明明收到了写入返回日志里也显示写成功了为什么数据会丢问题就出在writeConcern上。当时业务为了追求写入速度统一使用的都是默认配置也就是w:1。这个配置意味着主节点在内存里接住这条写入、返回 ack客户端就认为写成功了。但主节点还没来得及把这条 oplog 同步到任何一个从节点进程就宕机了。等旧主重新恢复、追不上新主的 oplog 时它会把这一段自己独有但没同步到多数的写操作全部回滚掉。从集群视角看这些数据等于从来没有存在过。这个事故让我重新梳理了 MongoDB 集群一致性的基本认知写成功和真持久化是两回事。你收到的 ack 是哪个节点给的决定了这条数据能扛过什么样的故障。MongoDB 不像传统关系库那样只有一个事务提交的开关它的一致性由一套旋钮组合控制包括写关注writeConcern、读关注readConcern、读偏好readPreference以及事务隔离级别。这篇东西就是围绕这套组合讲清楚它们各自的边界、常见坑以及主节点故障、rollback 和fassert()发生时我们应该怎么处理。1.1 一个写操作到底走了多少步一条写请求进入副本集后实际路径是这样的客户端把写入发给当前主节点。主节点应用写入生成一条 oplog 条目并写入自己的数据文件。从节点持续同步主节点的 oplog并按照 oplog 顺序应用到本地。从节点应用成功后会向主节点反馈确认信息。主节点根据配置的 writeConcern在收到足够数量的确认后才向客户端返回写入成功。所以w:1只是在第 2 步结束后就返回w:majority要等第 4 步里超过一半投票节点反馈完成才返回。这个差异在单机测试时几乎看不出区别但在集群故障场景下会直接决定数据生死。1.2 一致性由四个旋钮共同决定MongoDB 集群的一致性不是一个开或关的状态而是四个维度叠加出来的结果写入侧writeConcern控制写入需要多少节点确认。读取侧readConcern控制读操作能看到哪个层级的数据。路由侧readPreference控制请求由主节点还是从节点响应。事务侧多文档事务提供独立的隔离级别控制。日常排查数据不一致问题时只盯着某一项是没用的。比如你明明配了w:majority但业务读走从节点依然可能读到旧数据。这不是配置失效而是读写两侧的保证没有组合起来。2. writeConcern把写入确认拉到多数派这一端writeConcern是 MongoDB 集群一致性的地基。它回答的问题很简单这条写入要得到多少个节点的确认才向客户端返回成功官方提供的几个级别分别对应不同的故障容忍能力。2.1 w:0、w:1、w:majority 分别承诺了什么w:0不要求任何确认相当于发出去就不管了可能因为网络问题根本没到主节点。w:1只要主节点在内存里处理成功就返回。它能扛住主节点的普通重启但只要主节点在 oplog 尚未同步给从节点时宕机这条写就会在旧主重新加入时被回滚。w:majority要求超过一半的投票节点包括主节点都确认应用了这条写。此时即使原主节点马上宕机多数派里已经有这条数据新主节点必然包含它不会被回滚。自定义w例如{ w: 5 }或{ w: dc1 }可以精确指定需要多少节点、或某个 tag 集合里的节点确认用于多数据中心场景。所有级别里最容易被误解的是w:majority。它保证的是这条操作已经出现在多数节点的 oplog 里并不保证这些节点已经把它刷新到磁盘日志。所以如果同时要求进程崩溃也不丢数据还需要叠加j: true。2.2 j:true 和 fsync:true 有什么区别j:true表示要等待该写入进入节点的 on-disk journal日志文件才返回。MongoDB 使用 WiredTiger 存储引擎写入会先落到内存再由 journal 定期组提交刷盘。如果不等待 journal一旦整个进程被 kill 或机器断电内存中的数据可能丢失即使这条写已经进了 oplog。fsync:true要求将所有数据文件强制刷到磁盘代价比j:true高得多日常基本不用。对绝大多数业务来说w:majority加j:true已经是在数据安全与性能之间比较合理的平衡点。一个真实的配置示例db.getSiblingDB(app).orders.insertOne( { _id: 1, amount: 99, status: pending }, { writeConcern: { w: majority, j: true, wtimeout: 2000 } } )如果超过wtimeout还没收到足够确认客户端会收到一个超时异常。需要注意的是这并不代表写入没有发生只是确认没凑齐。对这类操作不能盲目重试否则可能造成重复写入。最稳妥的做法是使用带业务幂等键的设计重试前先查一下。2.3 分片集群里的 majority 不是简单多数分片集群下每个分片本身是一个副本集客户端通过 mongos 写入。w:majority生效的范围是目标分片所在的副本集而不是所有分片。跨分片事务提交时事务协调者会要求每个参与分片都完成 prepare 和 commit这个层面的多数是按分片内部投票节点来计算的。这里有一个容易踩的坑在一个分片里如果投票节点是 3w:majority需要 2 个节点但如果你给某个分片配置了 1 个数据节点加 1 个仲裁节点arbiter那它只需要 1 个数据节点确认就算 majority。仲裁节点不存数据所以实际上数据只存在于一个节点上这与多数派持久化的直觉相差很大。关于仲裁节点的坑我会在第五节专门展开。2.4 选型建议不同写入场景不要用同一套配置用一个表来总结我个人常用的配置组合可以少走很多弯路业务场景推荐 writeConcern原因支付、库存、订单状态w: majority, j: true不能容忍主备切换丢数据分布式锁、账户余额变更w: majority, j: true配合线性化读保证互斥语义日志、埋点、监控数据w: 1或w: 0能接受少数数据丢失换吞吐批量数据导入w: 2关j折中方案提速明显多文档事务事务内默认majority事务提交协议本身要求多数确认我见过很多团队把埋点数据的配置直接复用到交易业务上直到出现一次主备切换才发现问题。配置之前先想清楚这条数据丢了业务能不能接受不能接受就老老实实w: majority, j: true。3. 读取一侧的保证readConcern、从库读偏好与因果一致如果你以为写完配了w:majority就可以高枕无忧那读取侧照样会给你上眼药。MongoDB 的读取一致性由readConcern和readPreference共同决定。3.1 readConcern 的四个等级local默认值直接读当前节点的最新数据不关心这条数据是否已被多数节点提交。从节点上可能读到尚未同步完成的数据也可能读到自己本地因故障未应用完整的数据。available和local类似但在分片集群中不保证过滤迁移过程中的孤儿文档。majority只读取已经被多数节点提交的数据。能避免读到可能被 rollback 的脏数据但不等同于线性化。linearizable在 majority 基础上增加线性化保证读操作会持续确认当前主节点仍然有效避免读到历史上某个已失效主节点的残留数据。代价很高通常只用于分布式锁或账户余额这类单文档强一致查询。snapshot用于事务读取某个时间点的一致性快照。实际排查问题时最经典的场景是业务读写都走了主节点后来为了给报表分流把读请求切到了从节点。结果主节点写入返回成功后从节点立刻读却读不到。这不代表集群出了问题只是w:majority保证的是写已经被多数节点应用但从节点的本地读取如果使用local它可能还没有应用到那一条 oplog或者从节点的同步延迟本来就存在。3.2 因果一致性会话解决自己读不到自己写的如果你既想从从节点分流又想让业务感知到因果顺序MongoDB 提供了因果一致性会话Causal Consistency Sessions。原理是客户端在会话里记录当前操作对应的operationTime后续读取请求带上afterClusterTime接收节点会确保返回的数据时间点不早于这个值。使用因果一致性会话之后从节点读取即使在复制延迟期间也会至少返回那个时间点之后的数据不会出现写返回了自己却读不到的诡异现象。注意要配合readConcern: majority或者linearizable使用因为只有这些级别才会基于集群提交时间点过滤数据。const session db.getMongo().startSession(); session.advanceOperationTime(operationTime); const coll session.getDatabase(app).getCollection(orders); coll.find({ _id: 1 }).readConcern(majority);需要留意的坑是事务开启后不能直接复用普通会话的因果一致性假设。事务有自己的快照时间会话操作时间仍然会推进但事务内读到的内容是事务开始时的快照不要期望事务里能实时看到别的事务的未提交写入。3.3 从节点读与数据回拨从节点读还有一个隐患是数据回拨。主节点写完、从节点 A 已经应用了某条 oplog但从节点 B 落后。如果客户端两次读分别打到了 A 和 B先看到新数据后看到旧数据这在分布式系统里叫非单调读也就是常说的回拨。因果一致性会话只能保证同一会话内不回拨跨会话没有这个保证。所以做报表这类对一致性要求不高的场景从节点读完全没问题但如果你的查询链路会基于第一次结果做第二次判断比如查余额后扣减就必须走主节点加majority读。没有银弹关键是提前理解语义。3.4 什么时候才用 linearizableReadConcernlinearizableReadConcern读一次需要询问副本集中的多数节点确认当前主节点仍然有效并且所有已提交的写入都包含在内。这是 MongoDB 最接近分布式线性一致性的读取方式但性能开销很大而且只能用于单文档操作不能用于游标查询。我用它最多的场景是分布式锁续期、发号器中的序号分配以及查询余额并判断是否足够扣减这种强一致操作。代码上也就是在查询时加上db.runCommand({ find: accounts, filter: { _id: user:123 }, readConcern: { level: linearizable }, maxTimeMS: 5000 });注意如果主节点发生切换或网络抖动这个读操作可能返回错误。所以超时时间不要设置太长业务侧要能接受重试。4. 多文档事务的隔离性快照、写冲突与提交协议MongoDB 在 4.0 版本引入多文档事务4.2 扩展到分片集群。很多从关系库转过来的同学会下意识认为事务就一定是可串行化但实际上 Mongo 事务默认提供的是快照隔离级别的保证更接近传统数据库的可重复读而不是严格可串行化。4.1 事务提交协议prepare 与 commit多文档事务在副本集里的提交不是一条简单记录而是两阶段提交。协调者会把事务操作拆成带prepare标记的 oplog 条目发送给参与节点所有参与节点都 prepare 成功之后协调者再发送commit标记。这样即使协调者在中间崩溃恢复后的新主也能根据 prepare/commit 记录决定事务是继续提交还是回滚。这带来的一个运维问题是如果一个事务长时间挂住不提交你会看到节点上存在preparedTransaction状态。这类事务会占用 oplog space甚至会阻塞部分后续提交。排查时用db.currentOp()可以看到事务的状态和运行时长必要时需要 kill 掉悬挂事务。4.2 隔离级别具体怎么理解用一个最常见的转账场景来说明事务 A读取账户 X扣减 100给账户 Y 增加 100。事务 B同时读取账户 X把余额改成 0。在快照隔离下A 和 B 都基于各自的快照执行。如果 B 先提交A 在提交时发现它修改过的 X 已经和当前最新版本冲突就会抛出WriteConflict错误。MongoDB 不会自动帮你无限重试需要业务代码捕获TransientTransactionError并重试整个事务。from pymongo import MongoClient from pymongo.read_concern import ReadConcern from pymongo.write_concern import WriteConcern client MongoClient(mongodb://localhost:27017/?replicaSetrs0) def transfer(session, from_id, to_id, amount): coll client.app.accounts coll.update_one( {_id: from_id, balance: {$gte: amount}}, {$inc: {balance: -amount}}, sessionsession, ) coll.update_one( {_id: to_id}, {$inc: {balance: amount}}, sessionsession, ) with client.start_session() as s: while True: try: with s.start_transaction( read_concernReadConcern(snapshot), write_concernWriteConcern(wmajority, jTrue), ): transfer(s, user:1, user:2, 100) s.commit_transaction() break except Exception as e: if hasattr(e, has_error_label) and e.has_error_label(TransientTransactionError): continue raise这个示例里read_concern: snapshot让事务内两次读取看到同一份数据write_concern: majority让事务提交不被回滚。驱动会把事务标记为可重试错误业务方在捕获到该错误后重新执行整个事务即可。4.3 事务里的脏读和幻读边界快照隔离防止了脏读和不可重复读但和可重复读一样它没法完全防住写偏斜write skew。举个例子事务 A 读取所有状态为available的订单然后尝试把所有订单置为booked。事务 B 同时读取所有状态为available的订单然后把自己新创建的订单状态置为available。两个事务基于同一个旧快照判断都认为仍有可用订单随后各自提交。提交后实际库存可能超卖。这和关系库默认的可重复读面临的问题类似并不是 MongoDB 特有的 bug。如果你需要在 Mongo 上实现严格可串行化只能通过外围的分布式锁或者预置冲突文档来人为串行化不能指望隔离级别自动兜底。4.4 长事务与性能取舍多文档事务的开销比单文档写高一个量级。每增加一个参与副本提交时都要多一轮网络往返。更关键的是长时间持有快照会让 WiredTiger 的版本链变长导致读取和写入的开销同步上升。我见过最夸张的例子是有人在事务里调外部 HTTP 服务一个事务耗时十几秒直接把整个副本集的写入申请打满所有其他操作全部阻塞。MongoDB 官方对事务的建议是尽快完成事务内不要包含远程调用单个事务的文档数量控制在可接受范围。如果数据量很大宁可用多条独立写入加补偿逻辑也不要硬塞进一个大事务。5. 主节点故障、fassert 与 rollback最后一道防线和现场恢复前面讲的是正常配置下的语义真正让人印象深刻的永远是故障瞬间。这一节把主备切换、fassert()崩溃、以及 rollback 数据恢复串起来讲。5.1 选举与多数派集群如何选出新主MongoDB 副本集的每个节点之间通过心跳维持状态。主节点失去联系后从节点会等待electionTimeoutMillis默认 10 秒然后发起选举。选举需要获得超过一半投票节点的同意才有效。没有达到多数派之前任何节点都不会把自己提升为主节点这就是防止脑裂的基本机制。很多人对多数派的理解有偏差。三节点副本集挂一台剩余两台可以选主因为 2 1.5两节点副本集挂一台剩余一台无法选主因为 1 不大于 1。这时候即使另一台还活着集群也只能变成只读。所以生产环境至少三节点是底线原因就在这里。5.2 旧主恢复后rollback 是怎么发生的故障切换后旧主节点如果重新加入集群会先尝试追赶新主的 oplog。如果发现自己的 oplog 里有几条记录和新主冲突比如新主写入了相同_id的数据或者旧主有本地独有的写操作而这些写操作从未同步到多数节点那么这些记录会被回滚掉。回滚的数据不会直接被删除而是被导出到数据目录下的rollback文件夹以 BSON 格式保存。正确的恢复动作是mkdir -p /data/rollback_backup cp -r /data/db/rollback/* /data/rollback_backup/然后用mongorestore恢复到临时库先人工比对再决定是否合并回业务集合。千万不能直接执行mongorestore --drop那样很可能把新主上后来更新的数据覆盖掉反而造成更大的数据错乱。我之前踩过这个坑老运维图省事把回滚文件直接导回线上集合结果把新主一段时间内的有效修改全部冲掉了最后只能从备份回退一整天。5.3 理解 fassert()用崩溃换取一致性fassert()是 MongoDB 内部的一种防御逻辑。当服务检测到本地状态已经无法保证一致性——比如 oplog 校验失败、WiredTiger 数据文件损坏、启动时的 featureCompatibilityVersion 不兼容、索引格式不一致——它会主动调用fassert()让进程退出而不是继续带病运行。日志里通常会有类似这样的行Fatal assertion 40484: Local invariant violated: ... F - ... fassert() failure, exiting看到fassert第一反应不是直接重启而是先保存现场。把整个dbPath目录做一份快照再检查日志定位具体原因。如果该节点上已经没有独有数据最干净的恢复方式是把数据目录清空让它从当前主节点重新做一次全量同步initial sync。这种做法等同于把故障节点废掉重来。它比mongod --repair可靠因为--repair只能尽量修复存储引擎层面的损坏无法恢复已经丢失且未同步的 oplog 记录。5.4 仲裁节点的坑不要为了凑多数派随便加副本集出现奇数个节点是推荐配置但如果你只有两台数据节点千万别在它们之间加一个仲裁节点来凑 3 个投票者。仲裁节点不存数据、不参与 oplog 同步只参与选举投票它能解决的是两节点副本集挂一台后无法选主的问题但它会带来更隐蔽的风险如果数据节点 A、B 跨机房部署仲裁节点放在其中一个机房那么当机房链路断开时该机房的数据节点加上仲裁节点就可能形成多数派在新机房切换到另一个主而旧机房仍然以为自己是主并继续接受写入最终产生脑裂式数据分裂。仲裁节点不承载数据集群的持久化容错能力并没有因为加了它而增强。真正想要数据安全应该加一个完整的数据节点而不是仲裁节点。5.5 把故障期数据丢失概率压到最低的配置根据我处理过的事故复盘下面这份组合能过滤掉绝大多数主备切换丢数据的投诉副本集节点数保持奇数优先 3 或 5且每个节点都承载数据。关键业务的写操作统一w: majority, j: true。关闭从节点的非一致性读偏好给核心链路使用除非明确能接受弱一致。将electionTimeoutMillis保持默认不要为了追求切换速度把它调太低否则网络抖动会频繁触发主备切换。对rollback目录做定期巡检和告警确保一旦发生回滚有数据文件可恢复。6. 集群一致性体检日常监控与校验手段配置对了不代表永远不出问题关键是能在问题发生的早期发现苗头。最后分享一些我日常会做的检查项。6.1 需要长期盯住的指标最核心的指标是复制延迟。通过rs.status()查看每个成员的optimeDate和主节点当前时间之间的差距正常情况下应该在 1 秒以内。如果业务峰值期延迟持续超过几秒就要检查从节点的磁盘 IO、oplog 窗口是否够用或者是否存在慢查询拖慢了操作应用。另一个容易忽略的是 oplog 容量。如果 oplog 太小从节点追日志时赶不上主节点生产速度落后太多之后就无法继续增量同步只能做全量重同步这是很多集群故障的隐形炸弹。事务场景下还要关注WriteConflict的数量。用db.serverStatus().wiredTiger.transaction能看到事务冲突的统计。如果冲突比例持续走高先优化业务写入模式让并发事务尽量操作不同文档否则即使驱动做了重试也会造成大量无谓等待。6.2 一道实用的命令速查# 查看副本集成员状态和 optime rs.status() # 查看当前正在运行的事务和操作 db.currentOp({ active: true, transaction: { $exists: true } }) # 查看 serverStatus 中与复制和事务相关的指标 db.serverStatus().repl db.serverStatus().wiredTiger.transaction # 检查集合的存储一致性 db.orders.validate({ full: true })如果公司已经部署了 Ops Manager 或其他监控平台重点配置复制延迟、oplog 时间窗口、主节点瞬时连接数这几个面板就够了。没有监控平台时用脚本每隔 30 秒采集一次rs.status()把optimeDate和electionCandidateMetrics落库就能在故障发生后快速定位时间线。6.3 集群间数据一致性校验在做跨集群迁移比如从旧集群迁移到新集群时只对比文档总数是不够的要核对数据内容。比较通用的做法是对每个集合按照_id范围分批扫描。对每批文档计算哈希值例如取所有字段的二进制表示后再做 MD5 或 CRC32。在源集群和目标集群分别执行同样的扫描比对每批哈希结果。需要注意扫描不要影响线上性能建议放在业务低峰期并且利用_id索引做范围扫描避免全表扫描占用大量 IO。对超大集合可以只做抽样校验但抽样比例要覆盖到每个分片和每个_id前缀区间。使用 MongoDB 自带的validate只能验证单机的数据文件逻辑一致性无法对比两个集群的数据是否相同。这是迁移项目中经常被忽视的环节很多同学只看了count对上就宣布迁移完成结果业务跑到一半发现某几条记录因为历史数据问题在源集群存在、目标集群缺失这种坑很难追查。6.4 一致性体检清单检查项频率异常信号副本集各成员 optime 延迟每分钟持续超过 2 秒oplog 时间窗口覆盖每小时窗口小于 6 小时WriteConflict计数趋势每分钟峰值期超过基线 3 倍rollback目录每天目录中存在新文件日志中的fassert关键词实时出现即告警集合大小与索引统计每天明显偏离预期最后再说一个我自己的习惯。每次变更集群配置哪怕只是调大一个超时时间我都会先在测试环境里模拟主节点宕机观察从节点选主时间、旧主回滚情况以及业务端收到的异常类型。因为 MongoDB 集群的一致性不是靠某一条配置保证的而是靠写入多数派 读取唯一时序 事务重试 故障恢复这一整套机制联动。只有提前把故障演练做透了线上出问题时才不会手忙脚乱。
返回列表