为什么`createIndex`会被事务无情抛弃?
根本原因,得从“可回滚”三个字说起。
MongoDB事务的原子性,建立在oplog日志能够记录并回滚已执行操作的基础上。但是,索引创建这件事——它要修改WiredTiger的元数据文件、写入`system.indexes`集合、还要更新内存中的索引结构——这一连串动作一旦落下去,就没办法安全地“撤销”了。你不可能通过一个所谓的“逆操作”,来保证事务回滚后,磁盘上的索引文件和内存里的状态跟压根没建过一样。
这跟`createCollection`被禁止的逻辑是一致的,但有个有趣的细节值得注意:在MongoDB 4.4版本中,曾经短暂地允许在事务内部,对一个已存在的集合创建**普通索引**(非唯一、非TTL)。不过,这个“放松”很快就被回退了。所以,无论你用的是哪个版本(包括2026年的最新稳定版),规则都是统一的、硬性的——不行。
* 事务只处理CRUD类操作,比如`insertOne`、`updateMany`、`find`。
* `createIndex`,不管是加`background: true`还是`unique: false`,一律不被接受。
* 就算你刚刚在事务外创建完集合,立刻进入事务想建索引,也可能因为元数据同步的毫秒级延迟而失败。驱动缓存没刷新,判断就是不过。
DDL和DML必须物理分离:索引是索引,业务是业务
最容易出错的场景,就是写迁移脚本或者初始化逻辑时,习惯性地把建索引跟写数据放在一起,像下面这样:
session.withTransaction(() => {
db.users.insertOne({...});
db.users.createIndex({ email: 1 }); // 这里立刻失败
});
正确的做法是:把DDL操作(建索引)提到事务外面,等它彻底完成之后,再启动事务。
* **先独立执行**建索引命令。生产环境强烈建议加上`{ background: true }`,避免前台建索引阻塞所有读写。
* **不要急着写数据**。先用`db.users.getIndexes()`轮询,确认索引状态从`"building"`变为`"ready"`。
* **确认就绪后**,再调用`session.withTransaction(...)`进行业务写入。
* 如果你的业务需要在多租户场景下动态建索引,并且需要幂等保障,可以捕获`NamespaceExists`或`IndexOptionsConflict`这类错误来处理,千万别指望通过事务回滚来做清理工作。
替代方案:如何优雅地实现“逻辑原子性”
有些业务确实希望“建索引”和“写数据”这两个动作看起来像一个不可分割的整体。但MongoDB不提供跨DDL和DML的原子性,这种时候,只能靠应用层自己协调。
一个比较成熟的模式是**两阶段标记**。你可以用一个专门的配置集合,先写入一条状态为`"pending"`的记录,比如`{_id: "index_ready_email", status: "pending"}`。然后开始建索引。索引一旦就绪,就更新这条记录为`"ready"`。业务代码在查询时,先去读取这个状态标记,只有读到`"ready"`,才走新的查询路径。这样就避免了在索引还没建好时读到不完整的数据。
还有几点需要特别注意:
* **不要在业务流量高峰的入口动态建索引。** 索引创建本身是资源密集型操作,`background: true`只是不阻塞读写,但它依然会抢I/O和CPU资源。
* **不要试图用歪招绕开限制。** 比如试图在聚合管道的`$function`里调用`createIndex`——这条路行不通。事务内部会拦截所有DDL命令,而且`$function`在4.4版本后默认就是禁用的,5.0+也需要显式开启才能用。
最后,也是最容易被忽略的一点:**`background: true`只决定了建索引时是否阻塞其他操作,它改变不了`createIndex`本身是DDL操作的属性。** 所以哪怕加上这个参数,也依然不能放进事务里。这不是配置问题,这是MongoDB事务模型的设计边界。能理解这一点,以后做架构设计时,就能少走很多弯路。