Express.js余额扣减并发请求致余额为负问题及原子更新解决方案
并发余额扣减漏洞解决方案
根本原因
当前代码逻辑是「先读取余额→后端做校验→再修改余额→保存」的分步操作,并发场景下多个请求会同时拿到相同的余额快照,全部通过校验后依次执行扣减,最终导致余额被扣为负数。
修复方案
方案1:使用MongoDB原子操作(最优)
将余额校验和扣减合并为单次原子更新操作,利用数据库的行锁机制保证同一时间只有一个更新请求能生效,避免竞态条件。核心使用$inc操作符做增量扣减,同时把余额校验放到更新条件里:
const body = req.body const userId = req.userAuth const cartId = req.params.id // 1. 先查询购物车,和原有逻辑一致 const Cart = await Model.Cart.findOne({ _id: cartId, user_id: userId, status: 'UNPAID' }) if (!Cart) { return res.status(404).json({ message: '无效的购物车记录' }) } const payAmount = Cart.product.price let updateCondition = { _id: userId } let updateContent = {} let errorMsg = '' if (body.payment_method === 'BALANCE') { // 余额大于等于支付金额作为更新条件 updateCondition.balance = { $gte: payAmount } // 原子扣减余额 updateContent.$inc = { balance: -payAmount } errorMsg = 'Not Enough Balance' } else if (body.payment_method === 'POIN') { updateCondition.poin = { $gte: payAmount } updateContent.$inc = { poin: -payAmount } errorMsg = 'Not Enough Poin' } // 2. 执行原子更新,new: true表示返回更新后的用户数据 const updatedUser = await Module.User.findOneAndUpdate( updateCondition, updateContent, { new: true } ) // 3. 没有返回更新结果说明校验没通过,直接返回错误 if (!updatedUser) { return res.status(422).json({ message: errorMsg }) } // 4. 补录余额/积分前后值 if (body.payment_method === 'BALANCE') { updatedUser.balance_before = updatedUser.balance + payAmount updatedUser.balance_after = updatedUser.balance } else if (body.payment_method === 'POIN') { updatedUser.poin_before = updatedUser.poin + payAmount updatedUser.poin_after = updatedUser.poin } await updatedUser.save() // 5. 后续再更新购物车状态、生成交易记录即可
补充防护方案
- 购物车状态锁:在处理支付请求前,先把购物车状态从
UNPAID原子更新为PROCESSING,后续请求查询到状态为处理中就直接拒绝,避免同一个购物车被多次支付 - 幂等校验:为每个支付请求生成唯一的请求号,同一个请求号只能处理一次,防止用户重复点击或重试导致的重复扣减
- 分布式锁:针对用户ID加锁,同一时间只能有一个该用户的支付请求在处理,适合复杂的多步交易场景
内容的提问来源于stack exchange,提问作者Driller
相关产品推荐
相关产品推荐

