支付回调被重放三次后,我把时间戳、nonce 和 Redis 幂等校验串成一条防线
一次合法支付回调被重复消费,订单被推进三次。我把签名、时间窗、nonce 去重和业务幂等键串起来,用 Redis 原子校验先拦重放,再用状态机兜底。

作者
ZHOUYI
凌晨排查线上消息,发现同一笔支付回调在十几秒里进来了三次。签名都对,订单也都真实存在,结果订单状态被推进了三次,权益也差点重复发放。第一反应不是支付平台异常,而是我们的回调接口太“诚实”:只要签名合法,就当成新请求处理。
签名只能证明没被篡改
很多人会把签名当成防重放的全部,其实签名只证明报文没被篡改,不证明这次请求是第一次。攻击者拿到一条合法回调后,可以在有效期内反复发送;网络重试、网关重发也可能造成重复。于是我把它拆成四层:签名校验、时间戳窗口、nonce 去重、业务幂等键。
时间戳和 nonce 先挡住旧请求
- 签名通过后,先看时间戳是否在 5 分钟窗口内,过期直接拒绝。
- nonce 写入 Redis:SET pay:nonce:xxx NX EX 300,写不进说明已消费。
- 同一订单号加支付状态组成幂等键,避免同一结果重复推进。
- 所有判断尽量放在进入业务前完成,减少数据库压力。
const ts = Number(body.timestamp);
if (Math.abs(Date.now() - ts) > 5 * 60 * 1000) return res.status(401).end();
const nonceKey = `pay:nonce:${body.nonce}`;
const nonceOk = await redis.set(nonceKey, '1', 'EX', 300, 'NX');
if (!nonceOk) return res.status(409).end();
const idemKey = `pay:idem:${orderId}:${body.status}`;
const idemOk = await redis.set(idemKey, '1', 'EX', 86400, 'NX');
if (!idemOk) return res.json({ code: 0 });
await advanceOrder(orderId, body.status);业务层还要有状态机兜底
Redis 能挡住大多数重复,但不能当唯一事实来源。我们在 MongoDB 里更新订单时,不会只查一次再改,而是用条件更新:只有 pending 的订单才能变成 paid。这样即使两个请求并发漏到业务层,也只有一个能真正改状态。踩过的坑是“先查后写”:查询和更新之间有时间差,重放并发时会同时认为自己能处理。
“防重放不是多加一个字段,而是让每一次回调都有不可重复消费的身份。”
补完后我专门重放旧回调:时间过期返回 401,重复 nonce 返回 409,重复幂等键只返回成功但不推进业务。对小程序支付、个人站会员购买这类场景,越早把这条链路做完整,越能避免半夜被重复订单叫醒。具体支付平台字段和重试规则,还是以官方文档为准。