使用MongoDB Upsert与乐观并发(版本控制)时如何避免DuplicateKeyException?
带版本控制的MongoDB乐观并发Upsert问题
我正在使用MongoDB的.NET驱动,尝试通过UpdateOneAsync和IsUpsert = true实现带版本控制的乐观并发模式。
现有代码
public Task UpsertAsync(User user, CancellationToken cancellationToken) => context.Users.UpdateOneAsync( Builders<User>.Filter.Eq(u => u.Id, user.Id) & Builders<User>.Filter.Lt(u => u.Version, user.Version), Builders<User>.Update .SetOnInsert(u => u.Id, user.Id) .Set(u => u.IsDeleted, user.IsDeleted) .Set(u => u.Version, user.Version) .Set(u => u.Name, user.Name) .Set(u => u.UserName, user.UserName) .Set(u => u.Media, user.Media), new UpdateOptions { IsUpsert = true }, cancellationToken );
目标
- 文档不存在时执行插入操作
- 仅当传入的
Version大于存储版本时才执行更新
问题
当文档已存在且存储版本更高时,过滤器条件Id == user.Id AND Version < user.Version不匹配,此时因为IsUpsert = true,MongoDB会尝试插入新文档,最终引发DuplicateKeyException(Id重复)。
疑问
处理该场景的推荐方式是什么?
- 捕获并忽略
DuplicateKeyException是否是该模式下的正确/预期做法? - 有没有更好的方式实现“仅版本更新时执行更新,否则不操作”且不触发插入尝试?
我希望保持操作原子性并避免竞态条件,因此优先选择单查询解决方案。
更新补充
- 文档存在且传入版本更新 → 执行更新
- 文档存在且传入版本更旧 → 忽略操作
- 文档不存在 → 执行插入
我的场景中大多数操作是插入(文档尚未存在),版本更新操作相对较少。我正在实现CQRS模式,包含命令和查询两个微服务。命令侧已采用“读取→更新”的乐观并发流程,冲突时会抛出并发异常。
提出此问题是针对查询侧的特殊边缘情况:“UserCreated”事件可能在“UserUpdated”事件之后被处理。不过通常事件会按顺序处理——先处理“UserCreated”事件,再按版本顺序处理“UserUpdated”事件。我的核心目标是保持操作原子性,理想情况下使用单查询且不使用事务。
解决方案
方案1:调整过滤器逻辑,避免不必要的插入尝试
要实现目标中的原子性单查询逻辑,可以修改过滤器,让插入操作仅在文档不存在时触发,而非过滤器不匹配时都尝试插入。
具体是用$or组合两个互斥条件:
- 文档存在,且存储版本小于传入版本(满足更新要求)
- 文档不存在(满足插入要求)
修改后的代码如下:
public Task UpsertAsync(User user, CancellationToken cancellationToken) => context.Users.UpdateOneAsync( Builders<User>.Filter.Or( // 条件1:文档存在且版本低于传入版本,执行更新 Builders<User>.Filter.And( Builders<User>.Filter.Eq(u => u.Id, user.Id), Builders<User>.Filter.Lt(u => u.Version, user.Version) ), // 条件2:文档不存在,执行插入 Builders<User>.Filter.Not(Builders<User>.Filter.Exists(u => u.Id)) ), Builders<User>.Update .SetOnInsert(u => u.Id, user.Id) .Set(u => u.IsDeleted, user.IsDeleted) .Set(u => u.Version, user.Version) .Set(u => u.Name, user.Name) .Set(u => u.UserName, user.UserName) .Set(u => u.Media, user.Media), new UpdateOptions { IsUpsert = true }, cancellationToken );
该方案的优势:
- 完全原子性,单查询完成所有逻辑
- 避免版本冲突时的插入尝试,不会抛出
DuplicateKeyException - 完美匹配你的三个业务规则:存在且版本更新则更新,存在且版本旧则忽略,不存在则插入
方案2:捕获并忽略DuplicateKeyException(不推荐)
如果坚持原有过滤器逻辑,捕获并忽略DuplicateKeyException确实能解决报错问题,但存在明显缺点:
- 异常处理会增加额外性能开销,高并发场景下影响更明显
- 无法区分是版本冲突导致的插入失败,还是其他业务逻辑错误引发的Id重复问题
- 属于被动处理错误,而非主动避免不必要的插入尝试
因此方案1是更优的选择。
内容的提问来源于stack exchange,提问作者Muhammed Furkan Güler
相关产品推荐
相关产品推荐

