必须用 findAndModify 或 findOneAndUpdate 实现原子扣减发券,因其支持文档级锁和条件更新返回,可避免并发超发;普通 updateOne 在库存临界时可能匹配成功但修改失败,导致业务误判。

用 findAndModify 做原子扣减发券,别用普通 update
优惠券超发根本原因是并发下「查库存→判断是否充足→扣减」三步不原子。MongoDB 里唯一能靠单次操作保一致性的就是 findAndModify(或新版 findOneAndUpdate)。它底层走的是 WiredTiger 的文档级锁,只要操作条件明确、更新字段可控,就能堵住并发缝隙。
常见错误现象:updateOne({ code: "ABC123", stock: { $gt: 0 } }, { $inc: { stock: -1 } }) 看似安全,但实际可能返回 { matchedCount: 1, modifiedCount: 0 }——因为匹配时 stock 还大于 0,执行时已被其他请求扣成 0,$inc 失败却没报错,业务层误以为发成功了。
- 必须用
findAndModify或findOneAndUpdate,且设置returnDocument: "after",靠返回结果判断是否真发出了 - 查询条件要包含状态和库存双重约束,例如
{ code: "ABC123", status: "active", stock: { $gt: 0 } } - 更新动作只做两件事:扣库存
{ $inc: { stock: -1 } }+ 记录发放时间{ $set: { lastUsedAt: new Date() } },别在同个操作里改太多字段 - 如果用副本集,确保写关注
w: "majority",避免主节点宕机前写入丢失
唯一券码得靠 unique 索引 + 应用层重试,别指望数据库自增
MongoDB 没有全局自增 ID 机制,券码唯一性只能靠 unique 索引来硬保。但索引只防重复插入,不防生成逻辑冲突——比如两个请求同时生成了相同随机码,其中一个会因索引报错 E11000 duplicate key。
使用场景:券码是随机字符串(如 8 位大小写字母+数字),不是订单号那种可预测序列。
- 必须在
code字段建unique索引:db.coupons.createIndex({ code: 1 }, { unique: true }) - 应用层生成券码后直接
insertOne,捕获E11000 duplicate key错误并重试(建议限重试 3–5 次,避免死循环) - 别用
ObjectId当券码——它对用户不友好,也难做防刷校验 - 如果券码需要带业务含义(如前缀区分渠道),确保拼接逻辑在应用层完成,数据库只负责验证唯一性
防超发还得加一层 ttl 索引清理未完成的预占记录
单纯靠原子扣减还不够。真实场景中,用户领券后可能卡在支付环节,或者前端调用发券接口后没收到响应就重试,导致“预占但未确认”的脏数据堆积。这类记录不能一直挂着,得设自动过期。
性能影响:ttl 索引本身开销极小,但后台删除线程每 60 秒扫描一次,如果大量券码集中在同一秒过期,可能触发短时 I/O 尖峰。
- 在文档里加一个
reservedAt时间戳字段,发券预占时写入new Date() - 建
ttl索引:db.coupons.createIndex({ reservedAt: 1 }, { expireAfterSeconds: 300 })(5 分钟过期) - 预占逻辑用
findAndModify更新reservedAt和状态为"reserved",而不是直接扣库存 - 最终核销时再用一次
findAndModify把状态改成"used"并扣库存——两次原子操作,中间失败由 TTL 清理
聚合查询统计发放量时,$sum 别漏掉 $cond 过滤状态
运营要看「已发多少张」「核销率多少」,容易直接写 {$group: {_id: null, total: {$sum: "$stock"} }},结果把所有历史券都算进去,包括已过期、已禁用、甚至还没生成的模板。
错误示例:db.coupons.aggregate([{$group: {_id: null, issued: {$sum: 1}}}]) —— 这数的是文档总数,不是真实发放数。
- 统计已发放量,得先
$match状态:{ status: { $in: ["used", "reserved"] } },再$sum: 1 - 如果要算「发放未核销」数量,用
$sum: {$cond: [{ $eq: ["$status", "reserved"] }, 1, 0]} - 别在聚合里用
$where或 JS 表达式——性能差,还可能被禁用 - 高频统计需求建议单独维护计数器文档,用
findAndModify原子更新,避免每次扫全表
最易被忽略的是预占和核销之间的时间窗——它既不能太短(否则弱网用户总失败),也不能太长(否则库存被无效占用)。5 分钟 TTL 是经验值,具体得看你的用户平均下单时长和并发峰值分布。










