MongoDB不支持\_id自增,因ObjectId非连续且无序;业务需有序ID时,应使用专用counters集合配合findOneAndUpdate原子操作实现反向计数,确保并发安全、不跳号、不重复。

为什么不能直接用 _id 自增?
MongoDB 默认的 _id 是 ObjectId,按时间戳+机器码+进程号+自增数生成,全局唯一但不连续、不可预测顺序。业务中常需要类似 MySQL 的 INT AUTO_INCREMENT 字段(如订单号、工单号),尤其当 ID 需对外展示或参与排序逻辑时,ObjectId 显得冗长且无序。
官方明确不支持传统自增主键,强行在应用层维护全局变量或用 Redis 计数器都存在并发冲突和单点故障风险。最稳妥的替代方案,是用一个专用集合(比如 counters)配合 findAndModify(现推荐 findOneAndUpdate)原子操作实现反向计数 —— 即每次取值后递减,避免高并发下“预分配但未使用”的 ID 浪费。
如何创建并初始化计数器集合?
先手动插入一条初始记录,确保 seq 字段存在且为正整数。推荐从大数开始(如 1000000),方便后续调试和预留空间:
db.counters.insertOne({ _id: "order_id", seq: 1000000 })
关键点:
-
_id字段用字符串标识业务类型(如"order_id"、"user_code"),便于多序列共存 - 不要用数字做
_id,MongoDB 会自动转成NumberLong,和字符串查询不一致 - 务必加唯一索引:
db.counters.createIndex({ _id: 1 }, { unique: true }),防止重复插入
Node.js 中用 findOneAndUpdate 安全取号
核心是利用 MongoDB 的原子更新能力:查出当前值 → 减 1 → 返回旧值。注意必须设 returnDocument: 'before'(旧版叫 new: false),否则返回的是递减后的值。
示例代码(MongoDB Node.js Driver v6+):
const getNextSequence = async (collection, name) => {
const result = await collection.findOneAndUpdate(
{ _id: name },
{ $inc: { seq: -1 } },
{ returnDocument: 'before', upsert: false }
);
if (!result.value) throw new Error(`Counter '${name}' not found`);
return result.value.seq;
};
// 使用
const orderId = await getNextSequence(db.collection('counters'), 'order_id');
常见坑:
-
upsert: false必须显式指定,否则首次调用会静默创建{ _id: "order_id", seq: 0 },导致下一次取到-1 - 如果想支持自动初始化,可把
upsert: true和default: 1000000逻辑放在上层封装里,而不是裸用 driver - 别用
updateOne+findOne组合 —— 中间有竞态窗口,两个请求可能拿到同一个seq
并发安全与性能边界在哪?
这个方案本质是把并发压力集中到单个文档上。counters 集合里每个业务序列对应一个独立文档,所以不同业务(如订单、用户、商品)互不影响;但同一序列(如高频下单)的 QPS 上限取决于 MongoDB 单文档更新吞吐,实测在 WiredTiger 引擎下可达 3000–5000 ops/s(具体看硬件和网络)。
真正要警惕的是:
- 没加超时和重试:网络抖动时
findOneAndUpdate可能挂起,建议包装一层带maxTimeMS和指数退避的封装 - 误把
seq当时间戳用:反向计数的值本身无时间含义,若需时间维度,请额外存createdAt - 长期运行后
seq趋近于 0:需监控告警,并提前人工重置(如重置为9999999)或设计自动翻转逻辑
反向计数不是银弹,它用空间换确定性 —— 每次只消耗一个 ID,绝不会跳号或重复,但要求你接受 ID 从大往小走的事实。如果你的系统真需要“1,2,3…”正向增长,就得接受预分配段(如一次拿 100 个缓存在内存)带来的复杂度和潜在浪费。











