客户收到两份货:Stripe 重复发货的完整排查流程
同一个订单发了两封交付邮件,是谁的问题?从 Stripe 后台到数据库到代码,六步定位根因。含可直接运行的排查命令和三种修复写法。
客户发来截图:「我收到两份了。」
第一反应通常是「Stripe 重复扣款了」。大概率不是。 扣款通常只发生一次,重复的是你的系统对同一个支付成功的响应。
这个区别很重要:扣款重复要找 Stripe 客服退款,发货重复是你自己的代码问题。
下面是完整的排查路径。
先说结论
重复发货有且只有三个来源:
| 来源 | 占比 | 特征 |
|---|---|---|
| Webhook 被投递多次 | 80% | 同一 event.id 触发多轮处理 |
| 用户刷新成功页 | 15% | 前端每次进入都调用「确认订单」 |
| 对账脚本重复跑 | 5% | 定时任务补单时没做幂等 |
80% 的情况:Stripe 发了两次 webhook,你的代码处理了两次。
Stripe 重复投递是设计行为,不是 bug——它保证至少送达一次(at-least-once)。所以幂等必须由接收方做。
第一步:确认是不是重复扣款
先排除最坏情况。
Stripe Dashboard → Payments → 搜订单号或客户邮箱。
看两件事:
- 有没有两条成功的 charge(金额相同、时间接近)
- 如果只有一条,问题在你的系统
# 用 CLI 查(需要 stripe login)
stripe charges list --limit 10 --customer cus_xxxxx
只有一条 charge → 不是重复扣款,继续往下。
第二步:看这个订单触发了几次 webhook
Stripe Dashboard → Developers → Events → 搜索订单相关的 payment_intent.succeeded 或 checkout.session.completed。
看同一个 pi_xxx 出现了几次。
stripe events list --limit 20 --type payment_intent.succeeded
如果看到同一条 payment_intent 有 2 条以上 event,且 event.id 不同——这是 Stripe 的正常重试(可能因为你的服务超时没返回 200)。
如果 event.id 相同但出现了两次——你的端点被调用两次,可能是负载均衡或客户端重试。
两种情况的处理方式一样:做幂等。
第三步:查数据库有没有记录处理过的 event
这是判断「有没有做幂等」的关键。
-- 假设你有 processed_events 表
SELECT event_id, created_at, COUNT(*) as cnt
FROM processed_events
WHERE event_id = 'evt_xxxxx'
GROUP BY event_id, created_at;
如果 cnt > 1,说明幂等表写了但没生效(可能没加唯一索引)。
如果没有这张表,那就是纯裸奔——每次 webhook 都直接执行业务逻辑。
-- 查订单的交付记录
SELECT id, order_id, status, created_at
FROM deliveries
WHERE order_id = 'ORD-1234'
ORDER BY created_at;
两条 delivery 记录、时间相差几秒 → 确认是重复处理。
第四步:看日志里同一个订单处理了几次
# 如果你的日志有这个格式
grep "ORD-1234" /var/log/app.log | grep -i "deliver\|fulfill"
或者查平台的日志(Vercel、Cloudflare、自建)。
重点看时间间隔:
- 相隔 < 1 秒:并发重复(同一时刻两个请求都通过了检查)
- 相隔几秒到几分钟:Stripe 重试(第一次处理超时或被判定失败)
并发重复最难查,因为「先查后写」在这个场景下必然失败。
第五步:定位具体的代码缺陷
三种典型写法,按危险程度排:
错误写法一:靠状态判断(并发时失效)
// ❌ 两个并发请求都会通过这个检查
const order = await db.orders.findUnique({ where: { id } });
if (order.status === "paid") return; // 已经被处理过了
await deliver(order); // 两个请求都执行到这里
await db.orders.update({ where: { id }, data: { status: "fulfilled" } });
问题:检查和处理之间有窗口期,两个请求同时进来时都读到 paid。
错误写法二:没有事件表
// ❌ Stripe 重试时会重复执行
export async function POST(req: Request) {
const event = await verifySignature(req);
if (event.type === "checkout.session.completed") {
await deliver(event.data.object); // 直接执行,无去重
}
return new Response("ok");
}
问题:Stripe 没收到 200 就会重试(最长 3 天),每次都重新发货。
错误写法三:唯一索引加错字段
-- ❌ 这个唯一索引保护不了重复
CREATE UNIQUE INDEX idx ON orders (order_id);
-- 应该建在处理记录表上
第六步:修复
方案 A:事件 ID 去重(推荐)
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY, -- 唯一约束是关键
event_type TEXT NOT NULL,
processed_at TIMESTAMPTZ DEFAULT now()
);
export async function POST(req: Request) {
const event = await verifySignature(req);
// 插入失败 = 已处理过,直接返回 200
try {
await db.processed_events.create({
data: { event_id: event.id, event_type: event.type },
});
} catch (e) {
if (isUniqueViolation(e)) {
return new Response("already processed", { status: 200 });
}
throw e;
}
// 这里才执行业务逻辑
if (event.type === "checkout.session.completed") {
await deliver(event.data.object);
}
return new Response("ok", { status: 200 });
}
关键:用数据库唯一约束保证原子性,不靠应用层的「先查后写」。
方案 B:状态机 + 条件更新
// 只有从 pending 才能转到 fulfilled,用条件更新保证原子
const result = await db.orders.updateMany({
where: { id: orderId, status: "paid" }, // 条件在 SQL 里
data: { status: "fulfilled" },
});
if (result.count === 0) {
// 没更新到,说明已经被别的请求处理了
return new Response("no-op", { status: 200 });
}
await deliver(order);
关键:where 里带状态条件,让数据库帮你做并发控制。
方案 C:两者都用
事件去重挡住重试,状态机挡住并发。生产环境建议两个都上。
第七步:修复已有数据
修完代码还要处理已经重复发货的订单。
-- 找出重复的交付记录
SELECT order_id, COUNT(*) as cnt, MIN(created_at), MAX(created_at)
FROM deliveries
GROUP BY order_id
HAVING COUNT(*) > 1;
处理方式:
- 数字商品:通常不用追回,记一笔损失
- 实物:联系客户,要么退回要么补款
- 服务:确认对方是否重复使用
同时给这些订单打标记,避免对账时重复计入。
怎么提前发现
三个监控点:
1. 同一 event.id 处理两次就告警
if (isUniqueViolation(e)) {
logger.warn(`duplicate webhook: ${event.id}`); // 记录但正常返回
return new Response("already processed", { status: 200 });
}
日志里出现这条说明幂等在起作用——这是好事,但要能看到。
2. 每天对账
-- Stripe 侧的成功支付数 vs 本地完成的订单数
SELECT DATE(created_at), COUNT(*) FROM orders WHERE status = 'fulfilled' GROUP BY 1;
数字对不上就是有问题。
3. 交付邮件发送量监控
同一个邮箱一天收到两封相同内容的交付邮件,应该能被发现。
一个常被忽略的点
返回 200 的时机。
如果业务逻辑跑了 30 秒,Stripe 的超时是 20 秒左右,它会认为失败并重试。
正确做法:先落库标记,立即返回 200,业务逻辑异步处理。
// 快速确认,异步处理
await db.processed_events.create({ data: { event_id: event.id, ... } });
queue.push({ event }); // 丢进队列
return new Response("ok", { status: 200 }); // 立刻返回
这样即使发货要 1 分钟,Stripe 也不会重试。
一句话总结
Stripe 保证「至少一次」,幂等必须由你保证「恰好一次」。 靠状态判断不行,要在数据库层用唯一约束或条件更新,并且先返回 200 再慢慢处理。
如果你正在排这个问题,Stripe 支付集成服务里有完整的审计清单。技术层面的幂等写法看这篇。