Mongoose如何在执行更新前校验预览结果,异常时回滚文档?
MongoDB更新votes数组时保证percentage总和不超过100的实现方案
内置更新验证器确实无法直接对数组更新后的聚合值做规则校验,以下是三种可直接落地的方案,覆盖原子阻止非法写入、预览更新后回滚两类需求:
方案1:带条件的原子更新(优先推荐,无需回滚)
MongoDB 3.6及以上版本支持在更新查询条件中使用$expr聚合表达式,可以直接把「总和不超过100」作为更新执行的前置条件,不满足条件时根本不会匹配到文档,不会产生脏数据,完全不需要额外回滚逻辑,也没有并发安全问题。
新增数组元素示例
比如要新增一个占比2的投票项,写法如下:
db.ballots.updateOne( { _id: "目标文档ID", // 前置校验:现有总和 + 新增项占比 ≤100 才执行更新 $expr: { $lte: [ { $add: [ { $sum: "$votes.percentage" }, 2 ] }, 100 ] } }, { $push: { votes: { name: "Donald Duck", percentage: 2 } } } )
如果执行返回的matchedCount为0,就说明总和超限,更新没有生效。
修改数组内已有元素示例
MongoDB 4.2及以上支持聚合管道更新,可以在更新逻辑中直接计算修改后的总和,超限则不写入:
db.ballots.updateOne( { _id: "目标文档ID" }, [ { $set: { // 先执行数组元素修改 votes: { $map: { input: "$votes", in: { $cond: [ { $eq: ["$$this.name", "Bugs Bunny"] }, { name: "$$this.name", percentage: 51 }, // 修改为目标值 "$$this" ] } } } } }, { $set: { // 校验修改后的总和,超限则保持votes原值 votes: { $cond: [ { $lte: [ { $sum: "$votes.percentage" }, 100 ] }, "$votes", "$votes" ] } } } ] )
方案2:事务模式(支持预览更新内容,自动回滚)
如果你的Mongo是副本集/分片集群环境(支持多文档事务),可以用事务实现「先模拟计算预览,合法再提交,非法直接回滚」的逻辑,适合有复杂校验规则、需要提前返回更新后结果的场景,步骤如下:
- 开启数据库会话和事务
- 在事务内查询目标文档的当前数据
- 在内存中模拟更新操作(修改数组项/新增项都可以),计算更新后的percentage总和
- 如果总和超过100:直接抛出错误终止事务,原文档不会有任何改动,自动完成回滚
- 如果总和符合要求:可以直接把内存中模拟的更新结果返回做预览,确认后再执行写入
- 提交事务
Node.js驱动示例代码:
const session = await mongoClient.startSession() try { let previewVotes = null await session.withTransaction(async () => { const targetDoc = await Ballot.findOne({ _id: "目标文档ID" }).session(session) // 模拟更新:比如修改Bugs Bunny的占比为51 const updatedVotes = targetDoc.votes.map(item => { return item.name === "Bugs Bunny" ? { ...item, percentage: 51 } : item }) const total = updatedVotes.reduce((sum, item) => sum + item.percentage, 0) if (total > 100) throw new Error("投票占比总和不能超过100") previewVotes = updatedVotes // 这里拿到的就是更新后的预览内容 await Ballot.updateOne( { _id: "目标文档ID" }, { $set: { votes: updatedVotes } } ).session(session) }) // 事务提交后可以返回previewVotes给前端 return previewVotes } finally { await session.endSession() }
这个方案逻辑完全可控,事务会自动处理并发冲突,不会出现脏写问题。
方案3:Mongoose钩子校验(适合Mongoose场景)
如果你用Mongoose作为ODM,可以通过Schema的预写入钩子做校验,在所有写入操作执行前拦截非法请求:
// 保存操作校验 ballotSchema.pre('save', function(next) { const total = this.votes.reduce((sum, item) => sum + item.percentage, 0) if (total > 100) return next(new Error("投票占比总和不能超过100")) next() }) // 更新类操作校验 ballotSchema.pre(['updateOne', 'findOneAndUpdate', 'updateMany'], async function(next) { const currentDoc = await this.model.findOne(this.getQuery()) const updateContent = this.getUpdate() // 根据updateContent里的$push/$set/$pull等操作,模拟计算更新后的votes数组 // 计算总和后判断,超了就抛错终止写入 next() })
注意事项
不要用「先查文档、本地校验、再直接更新」的非原子写法,高并发场景下会出现两个请求同时查到总和为99,各写入占比2的项,最终总和变成103的问题。
内容的提问来源于stack exchange,提问作者lukas_o
相关产品推荐
相关产品推荐

