圣灯在线
2026-11-09· 7 分钟Stripe运维排障支付

客户收到两份货:Stripe 重复发货的完整排查流程

同一个订单发了两封交付邮件,是谁的问题?从 Stripe 后台到数据库到代码,六步定位根因。含可直接运行的排查命令和三种修复写法。

客户发来截图:「我收到两份了。」

第一反应通常是「Stripe 重复扣款了」。大概率不是。 扣款通常只发生一次,重复的是你的系统对同一个支付成功的响应。

这个区别很重要:扣款重复要找 Stripe 客服退款,发货重复是你自己的代码问题。

下面是完整的排查路径。

先说结论

重复发货有且只有三个来源:

来源 占比 特征
Webhook 被投递多次 80% 同一 event.id 触发多轮处理
用户刷新成功页 15% 前端每次进入都调用「确认订单」
对账脚本重复跑 5% 定时任务补单时没做幂等

80% 的情况:Stripe 发了两次 webhook,你的代码处理了两次。

Stripe 重复投递是设计行为,不是 bug——它保证至少送达一次(at-least-once)。所以幂等必须由接收方做。


第一步:确认是不是重复扣款

先排除最坏情况。

Stripe Dashboard → Payments → 搜订单号或客户邮箱。

看两件事:

  1. 有没有两条成功的 charge(金额相同、时间接近)
  2. 如果只有一条,问题在你的系统
# 用 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 支付集成服务里有完整的审计清单。技术层面的幂等写法看这篇。

你的站有这些问题吗?

用免费体检工具输入域名,30 秒看结果。也可以直接写信说清情况,我告诉你值不值得修。