NodeJS与MongoDB金融项目如何防止并发重复提现?
解决NodeJS+MongoDB金融应用并发提现超余额问题
你遇到的是典型的并发竞态条件问题:当两个提现请求同时发起时,它们都会先读取到相同的账户余额(比如10K),都通过余额校验后,各自执行扣减操作,最终导致账户余额被超额扣除。
下面从数据库和服务器层面给出具体解决方案:
一、数据库层面:原子更新+事务(推荐)
核心思路是让数据库在更新时直接完成余额校验和扣减,避免先读再改的中间竞态窗口,同时用事务保证操作的原子性(要么全部成功,要么全部回滚)。
修改代码步骤:
- 替换原有的余额查询校验逻辑,改用MongoDB的
findOneAndUpdate做原子操作,确保只有当余额足够时才会执行扣减。 - 调整Fawn事务中的余额更新逻辑,基于数据库当前余额计算,而非提前读取的旧值。
修改后的核心代码如下:
//Code checks if the account number is valid const accountValidation = await handleBankAccountVerification(account_no, bank); if(accountValidation.status === 'error' || !accountValidation.data) return res.status(400).send(accountValidation); // 替换原有的余额校验逻辑:原子操作完成校验+扣减 const updatedUser = await User.findOneAndUpdate( { _id: req.user._id, balance: { $gte: amount } }, // 条件:用户存在且余额足够 { $inc: { balance: -amount } }, // 原子扣减余额 { new: true } // 返回更新后的用户数据 ); if (!updatedUser) { return res.status(400).send({ message: "Insufficient balance!" }); } const payWithManual = async (user, account_name) => { try { const task = new Fawn.Task(); const data = req.body; data.status = "pending"; data.balance = updatedUser.balance; // 使用更新后的余额 data.account_name = account_name || ''; data.user = req.user._id; const { error } = validatePayment(data); if (error) return res.status(400).send({ message: error.details[0].message }); // 事务中不再更新余额(已通过原子操作完成),直接记录支付和交易 const result = await task .save("payments", data) .save("transactions", { ...transaction, action_id: { $ojFuture: "1._id" }, balance_before: updatedUser.balance + amount, // 反向计算更新前的余额 balance_after: updatedUser.balance, }) .run({ useMongoose: true }); // 后续邮件通知逻辑保持不变 const date = dayjs(); const payload = { "country": "NG" }; const response = await flw.Bank.country(payload); bankName = response.data.filter(item => item.code === bank); const html = WithdrawTemplate(amount, updatedUser.balance, date.format("hh:mmA MMM DD, YYYY"), account_no, bankName[0]?.name); const adminHtml = WithdrawNotificationTemplate(user.email, amount, updatedUser.balance, date.format("hh:mmA MMM DD, YYYY"), account_no, bankName[0]?.name); await user.sendEmailToUser(`Your made a withdraw`, html, user.email); await user.sendEmailToUser(`A user is requesting withdrawal`, adminHtml, config.get('NOTIFICATION_EMAIL')); return res.status(200).send({ message: "Fund has been sent to account you provided!", result: result[0]._id }); } catch (e) { res.status(400).send(e); } }; const account_name = accountValidation.data.data.account_name; await payWithManual(updatedUser, account_name);
补充:乐观锁方案(可选)
如果需要更严格的并发控制,可以给用户表加一个version字段,每次更新时校验版本号,避免并发冲突:
// 用户表Schema添加version字段 const userSchema = new mongoose.Schema({ // 其他字段... balance: Number, version: { type: Number, default: 0 } }); // 更新时校验版本号 const updatedUser = await User.findOneAndUpdate( { _id: req.user._id, balance: { $gte: amount }, version: user.version }, { $inc: { balance: -amount, version: 1 } }, { new: true } );
如果版本不匹配,说明有并发操作,此时返回错误让用户重试。
二、服务器层面:分布式锁(适用于多实例部署)
如果你的应用是多服务器实例部署,还可以用Redis实现分布式锁,确保同一用户同一时间只有一个提现请求在处理:
const redis = require('redis'); const client = redis.createClient(); // 获取锁,超时时间10秒 const acquireLock = async (userId, timeout = 10000) => { const lockKey = `lock:withdraw:${userId}`; const result = await client.set(lockKey, 'locked', 'NX', 'PX', timeout); return result === 'OK'; }; // 释放锁 const releaseLock = async (userId) => { const lockKey = `lock:withdraw:${userId}`; await client.del(lockKey); }; // 在提现接口中使用锁 const isLocked = await acquireLock(req.user._id); if (!isLocked) { return res.status(429).send({ message: "Please wait, your withdrawal is being processed." }); } try { // 执行提现逻辑(数据库原子操作+事务) } finally { // 无论成功失败都释放锁 await releaseLock(req.user._id); }
内容的提问来源于stack exchange,提问作者Olamide
相关产品推荐
相关产品推荐

