库存必须与商品强绑定在同一个文档中,SKU以数组形式嵌套在product文档内,扣减时用findOneAndUpdate配合$gte条件原子更新skus.$.stock字段,并建立复合索引确保查询高效。

库存必须和商品强绑定在同一个文档里
拆成独立集合是常见错误。比如把 inventory 单独建表,再用 product_id 关联,这会让一次扣减变成跨集合操作——MongoDB 事务虽支持多集合,但性能差、延迟高,且分片集群下 mongos 路由复杂,容易触发写冲突或超时。
正确做法是把库存字段直接嵌入商品文档的 skus 数组中。每个 SKU 子文档包含 stock 字段,更新时只操作该数组元素,避免跨文档锁竞争。
- SKU 必须用数组嵌套,不能用对象键名(如
{"sku_123-a": {...}}),否则无法用$[<identifier>]</identifier>定位更新 - 不要把
stock提到商品顶层,否则无法区分不同规格的库存 - 所有 SKU 共享字段(如重量、发货地)放外层,避免重复存储和不一致
扣减必须用 findOneAndUpdate 带条件原子更新
先查后改(find() + updateOne())在事务里也无效。MongoDB 读操作默认不加文档级锁,两个并发事务可能同时读到 stock: 5,然后都执行 $inc: {stock: -1},最终变成 3 而非预期的 4 和 3。
真正起作用的是在 findOneAndUpdate 的查询条件里显式校验库存余量:
db.products.findOneAndUpdate(
{ _id: "prod_123", "skus.sku_id": "sku_123-a", "skus.stock": { $gte: 1 } },
{ $inc: { "skus.$.stock": -1 } },
{ returnDocument: "after" }
)- 条件中的
"skus.stock": { $gte: 1 }是防超卖的关键,不是靠事务自动保证 - 更新路径必须用
$操作符定位匹配的数组元素,不能硬编码索引 - 返回值
result.value为null表示没匹配到,即库存不足,业务层需立即拒绝
事务必须运行在副本集或分片集群上
本地开发用 mongod --port 27017 直接启动,默认是 standalone 模式,调用 session.startTransaction() 会直接报错:Transaction numbers are only allowed on a replica set member or mongos。这不是代码问题,是服务端硬限制。
哪怕只是单机开发,也得启动副本集:
mongod --replSet rs0 --port 27017
# 连接后执行
rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "127.0.0.1:27017" }] })
- Docker 镜像默认不带
--replSet,必须显式传参,否则 Spring Data MongoDB 的executeInTransaction()一样失败 - 事务超时默认 60 秒(
transactionLifetimeLimitSeconds),长流程要主动控制生命周期 - 事务内不能执行
countDocuments()这类聚合操作,部分驱动版本会静默失败
高频查询字段必须单独建索引
没有索引的 skus.stock 查询,在商品数过万后就会退化为全表扫描。用 explain() 看到 stage: COLLSCAN 就说明已踩坑。
复合索引要覆盖查询路径:
db.products.createIndex({ "skus.sku_id": 1, "skus.stock": 1 })
- 索引字段顺序很重要:等值查询字段(
sku_id)放前,范围查询字段(stock)放后 - 避免对
skus.attrs.color这种深层嵌套字段建索引,MongoDB 对嵌套超 2 层的索引效率明显下降 - 不要依赖
_id索引加速 SKU 查询,它不包含skus数组内容
事务本身不解决逻辑超卖,它只提供写冲突检测和回滚能力。真正拦住超卖的,是 findOneAndUpdate 查询条件里的 $gte 判断,以及索引是否能让这个判断快速命中。这两点漏掉任何一环,库存就不可信。


















