1. 当订单系统撞上"库存超卖"的思考

某电商平台凌晨大促期间,10万用户同时抢购限量商品。开发者使用如下代码更新库存:

// 原始的危险代码(技术栈:C# + MongoDB.Driver 2.19)
var filter = Builders<Product>.Filter.Eq(p => p.Id, productId);
var update = Builders<Product>.Update.Inc(p => p.Stock, -1);
var result = await collection.UpdateOneAsync(filter, update);

第二天运营报告显示:库存实际减少量远大于备货量,超卖损失达百万级。问题根源在于多个并发请求同时读到相同库存值,导致原子操作也无法避免超卖。这揭示了MongoDB在无协调机制下的并发局限性。

2. 解剖MongoDB的并发控制机制

2.1 文档级原子性原理

MongoDB单个文档操作具备原子性,但多个文档的原子性需要事务支持。其WiredTiger存储引擎使用乐观并发控制,通过版本号(_id + version)实现冲突检测。

2.2 并发冲突的三种典型场景

场景类型 发生条件 风险等级
更新丢失 多个写操作覆盖彼此结果 ★★★★
脏读 读到未提交的中间状态 ★★
不可重复读 同一事务内读取结果不一致 ★★★

3. 五大核心解决方案深度剖析

3.1 乐观锁:版本号验证法(推荐方案)

public async Task<bool> SafeUpdateProduct(Product product)
{
    // 创建带版本号的过滤器
    var filter = Builders<Product>.Filter.And(
        Builders<Product>.Filter.Eq(p => p.Id, product.Id),
        Builders<Product>.Filter.Eq(p => p.Version, product.Version)
    );

    // 原子更新操作(版本号自增)
    var update = Builders<Product>.Update
        .Set(p => p.Price, product.Price)
        .Inc(p => p.Version, 1);

    try
    {
        var result = await _collection.UpdateOneAsync(filter, update);
        return result.ModifiedCount > 0;
    }
    catch (MongoWriteException ex) when (ex.WriteError.Code == 11000)
    {
        // 版本冲突处理(需重试机制)
        return false;
    }
}

实现要点:

  1. 在文档中增加Version字段(int类型)
  2. 每次更新必须验证当前版本号
  3. 配合重试策略处理冲突

3.2 事务控制:多文档操作的救星

// 分布式事务示例(需MongoDB 4.0+)
using (var session = await client.StartSessionAsync())
{
    session.StartTransaction();

    try
    {
        // 操作1:扣减库存
        var productFilter = Builders<Product>.Filter.Eq(p => p.Id, productId);
        var productUpdate = Builders<Product>.Update.Inc(p => p.Stock, -1);
        await productsCollection.UpdateOneAsync(session, productFilter, productUpdate);

        // 操作2:创建订单
        var order = new Order { /* ... */ };
        await ordersCollection.InsertOneAsync(session, order);

        await session.CommitTransactionAsync();
    }
    catch
    {
        await session.AbortTransactionAsync();
        throw;
    }
}

事务配置要点:

var client = new MongoClient("mongodb://localhost/?replicaSet=myReplicaSet");

必须配置副本集才能启用事务功能

3.3 原子操作符:单文档操作的利器

// 保证库存不会超卖
var filter = Builders<Product>.Filter.And(
    Builders<Product>.Filter.Eq(p => p.Id, productId),
    Builders<Product>.Filter.Gt(p => p.Stock, 0)
);

var update = Builders<Product>.Update
    .Inc(p => p.Stock, -1)
    .CurrentDate(p => p.LastModified);

var options = new UpdateOptions { IsUpsert = false };
var result = await collection.UpdateOneAsync(filter, update, options);

适用场景:

  • 简单数值增减操作
  • 不需要复杂业务逻辑的更新
  • 单文档原子性操作

3.4 重试策略:提升系统健壮性

// 使用Polly实现智能重试(需安装Polly库)
var retryPolicy = Policy
    .Handle<MongoException>(ex => ex is MongoCommandException cmdEx && 
        cmdEx.Code == 112) // 写冲突错误码
    .WaitAndRetryAsync(new[]
    {
        TimeSpan.FromMilliseconds(100),
        TimeSpan.FromMilliseconds(300),
        TimeSpan.FromMilliseconds(500)
    });

await retryPolicy.ExecuteAsync(async () =>
{
    using var session = await client.StartSessionAsync();
    // 事务操作...
});

3.5 文档设计优化:从根源减少冲突

// 反模式:频繁更新的大文档
public class User
{
    public ObjectId Id { get; set; }
    public int LoginCount { get; set; } // 高频更新字段
    // 其他50个字段...
}

// 优化方案:分离高频字段
public class UserLoginStats
{
    public ObjectId UserId { get; set; }
    public int LoginCount { get; set; }
    public DateTime LastLogin { get; set; }
}

设计原则:

  1. 垂直拆分高频变更字段
  2. 避免超大文档结构
  3. 使用内嵌文档控制更新范围

4. 技术方案选型指南

4.1 方案对比矩阵

方案 适用场景 吞吐量 复杂度 数据一致性
乐观锁 高频单文档更新 最终一致
事务控制 跨文档强一致性 强一致
原子操作符 简单数值操作 极高 强一致
重试策略 所有可能失败的操作 依赖实现

4.2 黄金实践准则

  1. 优先使用原子操作符
  2. 单文档复杂操作用乐观锁
  3. 跨文档操作必须使用事务
  4. 所有写操作都要有重试机制
  5. 监控WriteConflict指标

5. 避坑指南:来自生产环境的教训

5.1 版本号管理陷阱

错误示范:

var product = await collection.Find(...).FirstOrDefaultAsync();
product.Version++; // 客户端修改版本号
await collection.ReplaceOneAsync(...); // 未使用原子操作

正确做法:

var update = Builders<Product>.Update
    .Set(...)
    .Inc(p => p.Version, 1); // 服务端原子操作

5.2 事务超时配置

var sessionOptions = new ClientSessionOptions
{
    DefaultTransactionOptions = new TransactionOptions(
        readPreference: ReadPreference.Primary,
        readConcern: ReadConcern.Local,
        writeConcern: WriteConcern.WMajority,
        maxCommitTime: TimeSpan.FromSeconds(3))
};

5.3 索引设计的隐藏关联

{ Version: 1 }上建立唯一索引:

var keys = Builders<Product>.IndexKeys.Ascending(p => p.Version);
var options = new CreateIndexOptions { Unique = true };
await collection.Indexes.CreateOneAsync(new CreateIndexModel<Product>(keys, options));

这可以强制实现版本号的唯一性约束

6. 总结:构建高可靠的MongoDB应用

通过版本号控制、事务机制、原子操作的三位一体方案,结合合理的重试策略和文档设计,可以有效解决C#操作MongoDB时的并发问题。建议根据具体场景选择合适方案,同时关注:

  1. 监控MongoDB的oplog压力
  2. 合理设置写关注级别(Write Concern)
  3. 定期进行并发压力测试
  4. 使用性能分析工具(如MongoDB Atlas)