·更新于 2026年10月25日· 9 次浏览 Node.js支付系统幂等RedisMongoDB工程实践
Node.js 支付回调防重实践:一次重复通知后的订单幂等改造
支付回调重复触发并不可怕,关键是把通知去重、订单状态机和并发锁组合成一套兜底机制,避免权益重复发放和订单状态被覆盖。

作者
ZHOUYI
周末排查到一个线上问题:用户付完款后,会员有时会被重复开通,订单状态也从已支付跳回异常。最初以为是前端重复点击,后来查日志才发现,支付平台的回调在短时间收到了多次相同通知。只要回调处理里有副作用,比如发权益、写流水、发通知,重复触发就会被放大成业务事故。
先把重复当成常态
我后来把回调处理拆成三步:先识别通知是否处理过,再判断订单当前状态是否允许变更,最后才执行权益发放。这样即使同一个通知被并发调用两次,第二次也会在数据库条件更新时被挡下。
- 平台可能因为超时、重试、网关抖动而重复投递通知。
- 商户侧如果响应慢,平台会认为失败并继续重试。
- 同一订单可能出现多笔支付流水,回调顺序也不一定稳定。
三层防重:唯一键、状态机、Redis锁
- 用支付通知号或订单号加业务类型建唯一索引,保证同一通知只落一条记录。
- 订单状态机只允许合法迁移,例如 PENDING 到 PAID,不允许 PAID 再被覆盖。
- Redis 锁负责压缩并发窗口,避免同一时刻多个请求同时处理同一订单。
MongoDB 唯一键与状态机
javascript
async function handlePaymentNotify(notify) {
const locked = await redis.set(`pay:lock:${notify.orderNo}`, '1', 'EX', 10, 'NX');
if (!locked) return 'success';
const record = await db.collection('payment_notify').updateOne(
{ notifyId: notify.id },
{ $setOnInsert: { orderNo: notify.orderNo, createdAt: new Date() } },
{ upsert: true }
);
if (!record.upsertedCount) return 'success';
await db.collection('orders').updateOne(
{ orderNo: notify.orderNo, status: 'PENDING' },
{ $set: { status: 'PAID', paidAt: new Date(), txId: notify.txId } }
);
return 'success';
}这里的重点不是锁,而是数据库最终约束。即使 Redis 锁失效、服务重启、任务重复消费,唯一索引和状态条件仍然能拦住大部分危险写入。对已经支付成功的订单,接口应直接返回成功,让平台停止重试,同时把权益发放放到事务或可靠队列中处理。
踩坑与兜底
- 不要把锁当最终幂等,锁过期后仍可能重复进入。
- 不要只更新状态,权益表也要有唯一键,例如订单号加权益类型。
- 失败时不要盲目返回成功,可让平台重试,但重试必须可幂等。
- 日志里保留通知原文、通知 ID、订单状态变化,方便对账。
“支付回调接口不是普通业务接口,更像一个事件处理器:先承认消息会重复,再用唯一键、状态机和锁把副作用关进笼子里。”