MongoDB事务与WiredTiger存储引擎:原理、机制与实战避坑
前阵子帮朋友排查一个线上问题订单服务和库存服务都跑在MongoDB上用户下单时先后更新订单文档和库存文档结果两个更新之间服务发生重启订单创建了、库存没扣掉用户反复下单把库存刷成了负数。排查到最后根子在于代码把多文档事务当成了可选项而更深的教训是MongoDB的事务能力与它的存储引擎原理深度绑定不理解底层机制就很容易在上层设计里踩坑。这篇文章我想从WiredTiger存储引擎讲起再走到多文档事务、分布式事务的实现逻辑最后结合几个真实场景聊聊使用建议。内容会兼顾概念、原理和实操适合正在用MongoDB做业务开发、又对事务和存储机制一知半解的工程师也适合准备深入理解MongoDB底层行为的DBA。1. WiredTiger存储引擎文档是怎么被写进磁盘的1.1 从BSON开始文档在存储层长什么样MongoDB里所有数据最终以**BSONBinary JSON**格式落盘。BSON和JSON的对应关系很好理解字段名和值用二进制编码支持嵌套文档和数组还额外支持了Date、ObjectId、Decimal128这些JSON没有的类型。很多人会忽略一个关键点BSON文档在磁盘上不是一行一行排布的。WiredTiger存储引擎采用B树组织数据每个集合collection对应一棵B树每个索引也是一棵独立的B树。树的叶子节点存的是实际文档内容或者索引键值内部节点存的是用于导航的键范围。WiredTiger没有像MySQL那样分record、page、extent的复杂层次它把数据切分成固定大小的page默认4KB。换句话说一个集合在磁盘上就是一棵B树而树的节点被拆成4KB的page这些page散布在集合文件里。一个容易误解的地方文档和page不是一一对应。一个小文档可能多个共享一个page一个大文档则可能跨越多个page。所以写一个文档时WiredTiger需要修改的是某个page里的部分字节而不是追加一行记录。1.2 内存缓冲与Checkpoint宕机不丢数据的秘密WiredTiger写入路径的核心是Cache缓存。所有读写都先经过缓存缓存大小默认是系统内存的50%通过storage.wiredTiger.engineConfig.cacheSizeGB可以改。如果缓存满了WiredTiger会把不活跃的page淘汰到磁盘这个过程叫eviction。缓存里被修改的page叫dirty page。脏数据不会立刻刷盘而是等待一个**Checkpoint检查点**时机。默认情况下WiredTiger每60秒或者当脏数据比例达到一定阈值时触发一次checkpoint。checkpoint会把当前缓存里的所有修改固化到磁盘并生成一个新的数据库快照。这里有个非常关键的设计MongoDB的数据文件在两次checkpoint之间是不保证一致的。你不论在哪个时间点直接看数据文件可能都看不到最后一条写操作的完整状态。真正保证一致性的是checkpoint加上journal日志的组合。1.3 Journal、oplog与存储引擎的分工Journal是WiredTiger自带的重做日志记录每一个写操作的内存变更。数据在进入page缓存的同时会先记录到journal缓冲。默认每100毫秒或每次写操作后取决于配置文件会把journal刷到磁盘。这样的话崩溃恢复的流程就很清晰了从最近的checkpoint恢复一个一致的数据库快照。重放checkpoint之后的journal让数据恢复到崩溃前的最新状态。有了journal才保证了MongoDB在宕机后不丢已确认的写入在write concern为majority或j:true的情况下。oplog则是另一回事。oplog是MongoDB副本集同步机制的核心它本质是一个capped collection固定大小集合记录所有节点的写操作。primary执行写操作后会在oplog中记录一条条目secondary拉取oplog并重放从而保持数据同步。一个常见的认知误区是oplog就是MongoDB的binlog用来恢复数据。实际上oplog主要服务于副本同步数据恢复主要靠journal和checkpoint。事务处理时oplog也会写特殊的条目这点在后面展开。2. 事务能力演进与使用边界单文档原子性到分片集群事务2.1 4.0之前为什么只能保证单文档原子性在MongoDB 4.0之前多文档操作没有事务保护。官方文档一直强调document-level atomicity也就是单个文档的写入是原子的但跨文档不保证。原因是存储引擎层面不支持多文档快照和回滚。WiredTiger在底层本来就有事务API但MongoDB在4.0之前没有把存储引擎的事务能力暴露给客户端层。写入一个文档引擎内部在事务里完成写入多个文档就是多个独立事务。这就导致很多业务场景得非常痛苦地绕路支付系统里必须先更新用户余额再更新订单状态中间任何一步失败就得靠补偿逻辑人工对账。我在早期的电商项目里就干过这类事自己维护了一张补偿任务表定时扫描不一致数据来做修正写起来非常痛苦。2.2 副本集与分片集群多文档事务的硬性条件MongoDB 4.0在副本集上引入了多文档事务4.2扩展到分片集群。但要注意几个硬性条件单机standalone模式不支持事务。事务需要复制集或分片集群因为事务提交要写入oplog并协调多个副本没有复制就没法保证提交一致性。事务内的操作限制很多。4.2版本之前不能创建集合4.4开始才支持在事务里创建集合但跨分片创建集合仍然受限。生产环境建议至少使用readConcern: majority和writeConcern: majority。如果只用writeConcern: 1事务可能出现提交成功但副本丢失的情况这是事务语义最忌讳的。另外事务对表结构有一个隐性要求集合和索引必须在事务前存在。如果你在事务里第一次插入数据到一个不存在的集合会直接报错。2.3 快照隔离并发事务到底看到了什么MongoDB多文档事务默认的隔离级别是快照隔离snapshot isolation。事务开始时会记录一个全局快照时间点之后所有读操作都基于这个快照不会被其他事务的未提交修改影响也不会被已提交修改影响。举个例子事务A在T1时间点开始读到库存数量是10。事务B在T2时间点扣了库存变成9并提交。事务A在T3再读库存看到的还是10。快照隔离的好处是读操作不用加锁并发性能好。代价是写入冲突需要检测并处理两个事务同时修改同一个文档时后提交的那个会收到WriteConflict错误。MongoDB不会自动重试你需要在应用层捕获这个错误重新执行整个事务。这个重试逻辑很容易被漏掉。我见过不少线上事故事务本身写对了但并发冲突出现时没有重试结果用户操作直接失败。3. 一次事务提交的完整旅程快照、写冲突与两阶段提交3.1 事务从session开始生命周期与超时在MongoDB里事务和**session会话**绑定。你需要先开启一个session然后调用startTransaction()之后在这个session里执行的操作才会被包裹进事务。一个典型的事务代码Node.js驱动风格大致是const session client.startSession(); try { session.startTransaction({ readConcern: { level: snapshot }, writeConcern: { w: majority } }); await inventoryCollection.updateOne( { _id: productId, stock: { $gt: 1 } }, { $inc: { stock: -1 } }, { session } ); await ordersCollection.insertOne(orderDoc, { session }); await session.commitTransaction(); } catch (err) { await session.abortTransaction(); } finally { session.endSession(); }这里很容易被忽视的参数是transactionLifetimeLimit默认60秒。如果一个事务从开始到提交超过60秒就会被当作过期事务强制中止。再叠加事务内部的锁等待超时默认5毫秒的maxTransactionLockRequestTimeoutMillis你会发现事务根本不适合跑长流程。3.2 写冲突检测为什么事务会被迫中止事务提交过程中WiredTiger需要检查本事务修改过的所有文档是否在事务快照之后被其他事务修改过。如果有就产生写冲突。写冲突的检测发生在提交阶段而不是写入阶段这是WiredTiger的一个特点。也就是说事务B可能在事务A提交前一直正常执行写操作直到提交那一刻才被告知失败并回滚。这个机制有一个直接后果事务的失败率与写冲突概率成正比。在高并发写同一批文档的场景下比如秒杀扣库存多文档事务的失败率会很明显。不是事务本身性能差而是冲突检测的设计使然。提高吞吐量的方式是精细化设计文档结构让高频写入尽量分散到不同文档上减少冲突面。3.3 提交的本质两阶段与oplog写入在副本集或分片集群上事务提交比单机版要复杂得多。MongoDB采用了类似两阶段提交的协议。单分片副本集上的事务提交流程大致是事务在所有参与的副本上本地prepare持久化事务记录。协调者primary在oplog中写入事务提交条目。事务对客户端返回提交成功。后台线程异步清理事务现场。在分片集群场景下mongos担任协调者角色。事务涉及多个分片时每个分片都要prepare本地事务全部就绪后mongos再触发commit。任何一个分片prepare失败整体回滚。这个设计很像分布式系统里的两阶段提交2PC但也有区别MongoDB的prepare阶段不持有资源锁所以它不会出现传统2PC里prepare后长时间挂起的问题。不过如果事务协调者mongos崩溃事务会依赖超时机制最终回滚不会长期残留。4. read concern和write concern一致性不是免费得来的4.1 write concern等级从acknowledged到majoritywrite concern决定了写操作何时算成功。常见等级有等级含义使用场景w: 0不等待确认写操作直接返回日志类、监控类可容忍丢失w: 1primary确认写入即可大多数非关键业务w: majority大多数副本确认写入事务、关键业务要求高可靠w: 自定义tag指定副本写入机房级容灾拓扑定制多文档事务必须使用writeConcern: majority吗严格说不是强制但不用majority就没法保证事务提交后的数据在故障切换时不丢失。如果primary写入后尚未复制到secondaryprimary宕机事务数据就丢了业务却收到了提交成功的响应这会让事务语义形同虚设。我在实际项目里的经验是事务场景固定w: majority普通读写按业务容忍度用w: 1。不要把w: majority全局打开写入性能会显著下降尤其是在跨机房部署时网络往返时间会叠加。4.2 read concern的三种主要等级等级含义关键特点local读本节点最新数据默认值不保证多数派确认可能读到未回滚的数据majority只读已提交且被多数派确认的数据防止读到未来会被回滚的数据snapshot读取特定快照版本常用于事务保证整个事务看到一致的快照事务内的读操作如果使用readConcern: local其实事务仍然会基于快照读取但快照的边界可能不够稳固。官方推荐事务使用readConcern: snapshot或majority。一个细节readConcern: majority需要服务端有majority read支持。从MongoDB 3.4版本开始如果存储引擎不支持会报错。新版本默认都支持。4.3 订单与库存场景的推荐组合回到文章开头那个订单和库存分布式事务场景我的推荐组合是事务内readConcern: snapshotwriteConcern: majority。事务外普通读用readConcern: majority普通写用writeConcern: 1或majority按需选择。为什么要用snapshot而不是majority因为在事务里snapshot能保证整个事务期间读到的是同一个一致的快照而majority只能保证每一条读都是已多数派确认的但不同read之间可能存在版本跳变。订单和库存模型设计上也要配合事务不要把一个用户的全部订单塞进一个超大文档也不要把库存拆得太碎。理想的设计是高频扣减集中在少数几个文档用事务保护跨文档一致性配合索引和预分配文档提高写入性能。5. 实战避坑聚合查询、嵌套list与安装运维中的高频问题5.1 聚合管线与嵌套list查询的正确姿势MongoDB的聚合框架aggregation pipeline是处理复杂查询的主要手段但很多人容易写出性能很差的流水线。最常见的错误是没有把$match放在管道最前面。比如查所有购买过商品A的用户中2024年下单金额大于1000的用户数量。正确姿势是db.orders.aggregate([ { $match: { productId: A, orderDate: { $gte: ISODate(2024-01-01) } } }, { $group: { _id: $userId, totalAmount: { $sum: $amount } } }, { $match: { totalAmount: { $gt: 1000 } } }, { $count: count } ]);如果先把整个集合$group再$match管道只能扫描全表数据量一大就卡死。嵌套list查询是另一个高频问题。假设订单文档里有数组items: [{prodId, qty, price}]想查包含某个商品的所有订单// 直接匹配数组内嵌套字段 db.orders.find({ items.prodId: A001 }); // 多个条件需要同时满足在同一元素内时用$elemMatch db.orders.find({ items: { $elemMatch: { prodId: A001, qty: { $gt: 2 } } } });上面两条语句的区别很微妙。不加$elemMatch时items.prodId和items.qty可能匹配的是数组里两个不同元素加了以后要求同一个元素同时满足。这个坑我踩过统计结果是错的排查了好久才反应过来。如果需要按元素分组统计就得配合$unwinddb.orders.aggregate([ { $unwind: $items }, { $match: { items.prodId: A001 } }, { $group: { _id: $userId, totalQty: { $sum: $items.qty } } } ]);$unwind会把数组元素拆成多个文档数据会膨胀所以务必在unwind之前先用$match过滤掉无关订单。5.2 安装部署与运维中的那些坑这里结合我自己经历过的几种典型问题1. apt/yum安装时有依赖冲突。常见于老系统自带libssl版本和MongoDB要求的版本不匹配。一个稳妥做法是使用官方源安装避免用发行版自带的旧包。2. mongod起不来报permission问题。data目录和日志目录的属主必须和运行mongod的用户一致。很多人把data目录手动创建为root属主mongod用普通用户跑就报错。3. 配置文件是YAML格式对缩进极其敏感。一个常见错误是把systemLog.destination和systemLog.path写错层级导致服务启动时解析失败。建议初始化配置文件后先跑一句mongod --config /etc/mongod.conf --fork看日志输出确认无误再启动。4. WiredTiger的lock文件问题。如果mongod异常崩溃data/db目录下可能残留WiredTiger.lock或mongod.lock。下次启动时如果是干净宕机锁文件会被自动清理但如果进程还活着你手动删锁去拉服务会破坏数据完整性。关键是别一看到锁文件就删。先ps aux | grep mongod确认没有残留进程再决定下一步。5.3 fassert()与数据完整性MongoDB内部有一套断言机制核心API叫fassert()。当内部条件不成立时fassert会终止进程并记录一条fassert错误日志。日常运维看到包含fassert的崩溃日志通常意味着存储引擎或数据文件出现了严重不一致。这时候第一反应不应该是简单重启拉服务而是保留崩溃现场日志。对数据文件做完整性检查必要时用db.validate()或存储引擎工具。评估是否需要从最近的备份恢复。我见过有人遇到fassert后直接删库重拉副本结果既没定位原因还把可能存在的备份链路问题掩盖了。遇到这类崩溃先看错误信息里提到的page、索引、table id往往能定位到具体坏在哪棵B树上。5.4 事务使用前的最后检查清单结合前面所有内容我给自己内部团队定了一份事务使用检查清单分享出来供参考检查项说明副本集或分片集群standalone不支持多文档事务事务内集合与索引已存在避免事务内首次建集合报错事务体量尽量小默认60秒超时大对象和批量操作要拆小写冲突重试逻辑捕获WriteConflict后重新尝试整个事务writeConcern: majority配合readConcern: snapshot确保事务语义监控事务相关指标关注事务中止率、平均耗时及时调优写在最后的一点个人体会把存储原理和事务机制串起来之后再看MongoDB多文档事务很多东西都变得顺理成章事务的边界由底层B树和快照机制决定事务的可靠性由journal和oplog共同保障事务的性能则受冲突检测和网络往返双重影响。我自己经历过的教训是遇到为什么事务老超时为什么明明提交了却出现不一致这类问题回头看WiredTiger的checkpoint和副本确认机制答案往往就藏在存储引擎的行为细节里。如果你也要在生产环境大规模使用MongoDB事务建议先拿一个真实的读写模型压一压观察write conflict和提交延迟这两项指标再决定事务粒度怎么定。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →