前阵子帮朋友排查一个线上问题:订单服务和库存服务都跑在MongoDB上,用户下单时先后更新订单文档和库存文档,结果两个更新之间服务发生重启,订单创建了、库存没扣掉,用户反复下单把库存刷成了负数。排查到最后,根子在于代码把多文档事务当成了可选项,而更深的教训是:MongoDB的事务能力与它的存储引擎原理深度绑定,不理解底层机制,就很容易在上层设计里踩坑。这篇文章我想从WiredTiger存储引擎讲起,再走到多文档事务、分布式事务的实现逻辑,最后结合几个真实场景聊聊使用建议。内容会兼顾概念、原理和实操,适合正在用MongoDB做业务开发、又对事务和存储机制一知半解的工程师,也适合准备深入理解MongoDB底层行为的DBA。
1. WiredTiger存储引擎:文档是怎么被写进磁盘的
1.1 从BSON开始:文档在存储层长什么样
MongoDB里所有数据最终以**BSON(Binary 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到majority
write concern决定了写操作何时算成功。常见等级有:
| 等级 | 含义 | 使用场景 |
|---|---|---|
| w: 0 | 不等待确认,写操作直接返回 | 日志类、监控类可容忍丢失 |
| w: 1 | primary确认写入即可 | 大多数非关键业务 |
| w: majority | 大多数副本确认写入 | 事务、关键业务,要求高可靠 |
| w: 自定义tag | 指定副本写入 | 机房级容灾,拓扑定制 |
多文档事务必须使用writeConcern: majority吗?严格说不是强制,但不用majority就没法保证事务提交后的数据在故障切换时不丢失。如果primary写入后尚未复制到secondary,primary宕机,事务数据就丢了,业务却收到了提交成功的响应,这会让事务语义形同虚设。
我在实际项目里的经验是:事务场景固定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: snapshot,writeConcern: 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可能匹配的是数组里两个不同元素,加了以后要求同一个元素同时满足。这个坑我踩过:统计结果是错的,排查了好久才反应过来。
如果需要按元素分组统计,就得配合$unwind:
db.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和提交延迟这两项指标,再决定事务粒度怎么定。