1. 原子性冲突的"矛与盾"是什么?

想象一个超市收银场景:当两个收银员同时扫描同一件商品的最后库存时,系统需要确保不会出现超卖。MongoDB的文档级原子操作就像收银台的扫码枪,它确保单个文档的更新是原子性的,但在高并发场景中可能产生"更新覆盖"问题。

示例:库存超卖场景

// 技术栈:MongoDB原生操作(Node.js驱动)
async function purchase(productId, quantity) {
  const product = await db.products.findOne({_id: productId});
  if (product.stock >= quantity) {
    // 这里存在时间差,可能被其他请求覆盖
    await db.products.updateOne(
      {_id: productId},
      {$inc: {stock: -quantity}}
    );
    return true;
  }
  return false;
}

当两个请求同时执行时,可能出现:

  1. 请求A查询到库存为1
  2. 请求B也查询到库存为1
  3. 两者都执行扣减导致库存变为-1

2. 四大常见冲突场景剖析

2.1 计数器竞速

实时投票系统需要精确统计,多个客户端同时+1时可能丢失计数

2.2 状态机紊乱

订单状态从"待支付"到"已支付"的转换过程中,可能被其他操作覆盖为"已取消"

2.3 数组元素重复

多人协作编辑文档中的数组字段时,可能产生重复添加元素的问题

2.4 数值边界突破

优惠券领取量限制可能被并发请求突破,比如100张券被领取101次

3. 六种实战解决方案对比

3.1 原子操作符方案

技术栈: MongoDB原生更新操作符

// 直接使用原子操作符消除时间差
await db.products.updateOne(
  {_id: productId, stock: {$gte: quantity}},
  {$inc: {stock: -quantity}}
);

优点:性能最佳,单次操作完成
缺点:无法处理复杂业务逻辑

3.2 乐观锁(版本号控制)

技术栈: 文档版本号字段+重试机制

async function updateWithVersion(productId, quantity, version) {
  const result = await db.products.updateOne(
    {_id: productId, version: version},
    {$inc: {stock: -quantity}, $inc: {version: 1}}
  );
  if (result.modifiedCount === 0) {
    throw new Error('版本冲突,需要重试');
  }
}

适用场景:需要执行复杂业务逻辑的更新
注意点:需要设计合理的重试次数和退避策略

3.3 事务处理方案

技术栈: MongoDB 4.0+ 多文档事务

const session = db.startSession();
try {
  session.startTransaction();
  const product = await db.products.findOne(
    {_id: productId}, 
    {session}
  );
  
  if (product.stock < quantity) {
    await session.abortTransaction();
    return false;
  }
  
  await db.products.updateOne(
    {_id: productId},
    {$inc: {stock: -quantity}},
    {session}
  );
  
  await session.commitTransaction();
  return true;
} catch (e) {
  await session.abortTransaction();
  throw e;
}

优点:支持跨文档操作
缺点:性能损耗是单文档操作的5-10倍

3.4 队列缓冲方案

技术栈: Redis队列+MongoDB消费

// 先将请求放入队列
await redis.lpush('order_queue', JSON.stringify({
  productId,
  quantity
}));

// 消费者单线程处理
while(true) {
  const task = await redis.rpop('order_queue');
  processTask(task); // 串行执行更新
}

适用场景:可以接受一定延迟的强一致性场景

3.5 文档分片方案

通过拆分文档减少冲突概率:

// 将库存拆分为10个分片
{
  _id: "product_123",
  shards: [
    {id: 0, stock: 10},
    {id: 1, stock: 10},
    // ...其他分片
  ]
}

// 随机选择分片扣减
const shardId = Math.floor(Math.random() * 10);
await db.products.updateOne(
  {_id: "product_123", "shards.id": shardId},
  {$inc: {"shards.$.stock": -1}}
)

优点:将并发压力分散到多个分片
缺点:增加查询复杂度

3.6 混合锁方案

结合Redis分布式锁和MongoDB更新:

const lockKey = `lock:product_${productId}`;
const lock = await redis.set(lockKey, '1', 'EX', 5, 'NX');
if (lock) {
  try {
    // 执行需要强一致性的操作
  } finally {
    await redis.del(lockKey);
  }
}

注意点:需要处理锁过期和业务执行时间的平衡

4. 方案选择决策树

根据业务场景选择方案:

  1. 简单数值操作 → 原子操作符
  2. 复杂业务逻辑 → 乐观锁/事务
  3. 超高并发 → 分片/队列
  4. 分布式系统 → 混合锁方案

5. 避坑指南

  1. 版本兼容性:事务需要MongoDB 4.0+和副本集配置
  2. 索引优化:版本号字段需要包含在查询条件中
  3. 监控指标:关注重试次数、冲突率、事务回滚率
  4. 超时设置:事务默认60秒超时,需要根据业务调整
  5. 重试策略:采用指数退避算法避免雪崩效应

6. 总结与展望

在日均订单百万级的电商系统中,我们通过分片方案将库存冲突降低了80%。但技术没有银弹,需要根据业务特点选择方案:

  • 优先使用原子操作符
  • 次选乐观锁控制
  • 谨慎使用事务
  • 特殊场景考虑分片或队列

未来随着MongoDB分布式事务的成熟,可能会出现更多优雅的解决方案。但核心思路不变:在一致性、可用性和性能之间找到最佳平衡点。