·更新于 2026年10月25日· 17 次浏览 Node.jsRedis支付系统幂等微信小程序BUG解决

支付回调被重放三次后,我把时间戳、nonce 和 Redis 幂等校验串成一条防线

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

支付回调被重放三次后,我把时间戳、nonce 和 Redis 幂等校验串成一条防线 封面
ZHOUYI

作者

ZHOUYI

凌晨排查线上消息,发现同一笔支付回调在十几秒里进来了三次。签名都对,订单也都真实存在,结果订单状态被推进了三次,权益也差点重复发放。第一反应不是支付平台异常,而是我们的回调接口太“诚实”:只要签名合法,就当成新请求处理。

签名只能证明没被篡改

很多人会把签名当成防重放的全部,其实签名只证明报文没被篡改,不证明这次请求是第一次。攻击者拿到一条合法回调后,可以在有效期内反复发送;网络重试、网关重发也可能造成重复。于是我把它拆成四层:签名校验、时间戳窗口、nonce 去重、业务幂等键。

时间戳和 nonce 先挡住旧请求

  1. 签名通过后,先看时间戳是否在 5 分钟窗口内,过期直接拒绝。
  2. nonce 写入 Redis:SET pay:nonce:xxx NX EX 300,写不进说明已消费。
  3. 同一订单号加支付状态组成幂等键,避免同一结果重复推进。
  4. 所有判断尽量放在进入业务前完成,减少数据库压力。
js
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,重复幂等键只返回成功但不推进业务。对小程序支付、个人站会员购买这类场景,越早把这条链路做完整,越能避免半夜被重复订单叫醒。具体支付平台字段和重试规则,还是以官方文档为准。

Guestbook

留言

若这篇文章也曾停在你心上,留一行字吧,我会看的。

先确认一下邮箱

多这一步,是为了少些机器骚扰,也让我能更踏实地读每一条真人写下的字。验证码 10 分钟内有效;同一邮箱 60 秒内只能发一封。

本文留言

共 0 条已加载 0 条

这里还静悄悄的——愿意做第一个留下脚印的人吗?