Stripe Webhook 重复投递事后分析:重试机制如何导致 18 个账户被重复充值

Stripe 重试暴露了 Webhook 幂等性缺失,导致 18 个账户被重复充值。本文分析根本原因、修复方案及持久化事件台账方案。

Huifer
Huifer
July 20, 202610 min read
其他语言:Deutsch · English

本文由 Huifer 撰写,TanStack Ship 的独立开发者和维护者。这次生产计费事故源于 Stripe 重试机制暴露了幂等性边界的缺失:18 个客户账户收到了重复积分,2 个订阅被错误降级。我在四小时内逆转了财务影响。最重要的教训并非 Stripe 不可靠;而是我的处理器忽略了 Stripe 明文约定的投递契约,最终由客户承担了运营成本。

经验证的信息来源:TanStack Ship GitHub · Stripe Webhook 文档 · Stripe 重复事件处理指南 更新于:2026-07-20 · 更新日志

TL;DR: 一次重试的 invoice.paid 事件通过了没有任何持久化 event.id 声明的处理器。18 个账户收到了两倍的相同积分,一条非原子降级路径影响了 2 个订阅。我修复了账户、添加了唯一事件台账、使下游变更幂等化,并增加了重放测试。Stripe 保证至少一次投递,而非精确一次;接收方必须自行处理重复问题。

背景:正确的签名验证并不足够

受影响的路由负责处理发票和订阅生命周期事件的 Stripe Webhook。它验证了请求体的签名,将 Stripe 对象转换为内部命令、写入积分台账、更新订阅状态并追加审计记录。这套流程在代码审查中看起来很有条理,因为每个单独的操作都有验证和错误处理。

但它缺少一个系统级属性:对同一事件处理两次不会产生与处理一次相同的结果。签名验证回答的是"Stripe 是否发送了这个载荷?",而不是"我已经应用过这个事件了吗?"这两个问题在我最初的实现中被混为一谈。

Stripe 的文档明确告诉集成商要预期自动重试和偶尔的重复事件。因此这次事故并非供应商未告知的意外。问题出在我自己身上:我实现了真实性验证但没有实现幂等性。TanStack Ship 当前的计费功能已明确分离了这些控制。

不安全的处理路径

原始操作顺序实际上是:

  1. 验证签名;
  2. 读取账户余额;
  3. 添加与发票关联的积分;
  4. 检查并可能应用待处理的降级;
  5. 写入审计行;
  6. 返回 HTTP 200。

没有按 Stripe 事件标识符建表的表。积分条目也缺乏唯一 source-event 约束。如果两次投递重叠,两者都可能读取更新前的相同状态并提交看起来有效的变更。

事故与客户影响

一次短暂的失败响应导致 Stripe 重新投递了 invoice.paid 事件。重试携带了相同的 Stripe event.id,符合预期。我的端点将其视为新工作。在禁用受影响的变更路径之前,其他重试投递也遇到了同样的缺失防护。

事故记录中的有限影响:

  • 18 个客户账户收到了重复的内部积分;
  • 2 个订阅在重复降级处理后被移动到了错误的低级;
  • 账户侧财务影响在四小时内被逆转;
  • 本文不声称虚构的损失金额,因为客户余额和价格是私密信息;
  • 不发布客户姓名、发票标识符或载荷。

标题中"导致 18 个客户受损"描述的是 18 个受影响的客户关系,而非 18 次取消。我不能负责任地声称每个受影响的客户都流失了。这一区分在事故报告中很重要。

检测来自对账,而非完美的告警

第一个有用的信号是发票事件与积分台账行之间的不匹配:一个来源发票对应了多条积分。Stripe 投递历史随后显示相同事件标识符的重复投递。最后,订阅审计追踪暴露了同一计费来源的两次降级转换。

我的监控可以报告处理器错误,但无法报告"一个外部事实产生了两个内部影响"。这就是为什么仅靠 HTTP 错误率监控无法保护业务不变量。后来添加的持久化台账使重复尝试可观测为正常计数器,而非客户可见的意外。

调查:追踪每次写入的身份

我将事件作为数据溯源问题进行调查。从 Stripe 事件标识符开始,我追踪了它应该创建的每条内部行。积分表可以指向发票,但不需要唯一的事件来源。降级更新采用了读后写的形态。审计表描述了事后发生的操作,但它并非授权这些操作的权威。

重放脱敏事件两次复现了两个缺陷。顺序重放因为没有唯一性规则而复制了积分。并发重放扩大了降级检查周围的时序窗口。同一业务事实通过两个独立有效的请求被接受了两次。

根本原因、促成因素和非原因

根本原因是缺少基于 Stripe event.id 的数据库级幂等性键。重复投递可以在未获得排他声明的情况下进入业务逻辑。

三个因素起了作用:

  • 积分变更没有携带唯一的 source_event_id;
  • 降级处理使用了"检查后写入"而非一条原子语句;
  • 测试覆盖了不同事件、格式错误的签名和处理器失败,但没有覆盖同一有效事件的重复投递。

Stripe 重试是触发因素,而非原因。D1 也不是原因;如果模式和事务边界不变,另一个 SQL 数据库同样可能发生同样的竞态。没有人被追责。设计审查关注了正常路径的正确性,忽略了分布式系统不变量。

持久化修复:声明、变更、完成

修复从 Webhook 事件台账开始。其主键使身份持久化。业务表也保留来源标识符,为系统提供了第二道防线和审计路径。

sql
CREATE TABLE stripe_webhook_events (
  event_id TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  status TEXT NOT NULL CHECK (status IN ('processing', 'completed', 'failed')),
  attempts INTEGER NOT NULL DEFAULT 1,
  received_at INTEGER NOT NULL,
  processed_at INTEGER,
  last_error TEXT
);

CREATE TABLE credit_ledger (
  id TEXT PRIMARY KEY,
  account_id TEXT NOT NULL,
  amount INTEGER NOT NULL,
  source_event_id TEXT NOT NULL UNIQUE,
  created_at INTEGER NOT NULL,
  FOREIGN KEY (source_event_id) REFERENCES stripe_webhook_events(event_id)
);

处理器首先验证原始载荷,然后尝试原子声明。失败的事件可以重新声明;正在处理或已完成的事件则不能。业务写入和完成标记通过 D1 批处理一起提交。特定项目的映射函数已省略,但控制流程是完整的。

typescript
export async function handleStripeWebhook(request: Request, env: Env) {
  const rawBody = await request.text()
  const signature = request.headers.get('stripe-signature') ?? ''
  const event = await verifyStripeEvent(rawBody, signature, env.STRIPE_WEBHOOK_SECRET)
  const now = Date.now()

  // 尝试声明此事件,失败事件可重新声明
  const claim = await env.DB.prepare(`
    INSERT INTO stripe_webhook_events
      (event_id, event_type, status, attempts, received_at)
    VALUES (?, ?, 'processing', 1, ?)
    ON CONFLICT(event_id) DO UPDATE SET
      status = 'processing', attempts = attempts + 1, last_error = NULL
    WHERE stripe_webhook_events.status = 'failed'
    RETURNING event_id
  `).bind(event.id, event.type, now).first<{ event_id: string }>()

  if (!claim) return new Response('Already accepted', { status: 200 })

  try {
    const statements = buildIdempotentBillingStatements(env.DB, event, now)
    statements.push(env.DB.prepare(`
      UPDATE stripe_webhook_events
      SET status = 'completed', processed_at = ?
      WHERE event_id = ? AND status = 'processing'
    `).bind(Date.now(), event.id))
    await env.DB.batch(statements)
    return new Response('Accepted', { status: 200 })
  } catch (error) {
    await env.DB.prepare(`
      UPDATE stripe_webhook_events SET status = 'failed', last_error = ?
      WHERE event_id = ?
    `).bind(String(error), event.id).run()
    return new Response('Retry later', { status: 500 })
  }
}

credit_ledger.source_event_id 的唯一性约束仍然至关重要。事件声明控制执行,而业务表约束则在未来重构绕过包装器时保护类似金钱的效果。

验证与发布

我在启用正常处理之前修复了数据。修复脚本按来源事件对积分行进行分组,识别多余项,仅撤销重复条目,并检查结果余额与发票的一致性。两个错误的订阅状态从最后一个有效审计转换恢复。我审查了每个受影响的账户,而非仅信任批量行数。

回归测试套件现在重复且并发地发送相同的签名 fixture。验收标准基于结果:一条积分效果、一次最终订阅转换、一条已完成事件记录,以及对重复项的成功确认。单独的失败测试强制业务批处理失败,验证 status='failed',并确认后续重试可以重新声明并完成它。

与不变量匹配的监控

我现在监控重复尝试、失败事件年龄和对账不匹配。重复计数器大于零并不自动构成事故;它证明防护正在工作。未解决的失败事件,或两个业务效果共享一个来源标识,都是可操作的。

管理界面还将事件台账与计费审计追踪链接。那个操作视图比在支付事故期间搜索非结构化日志更有用。评估更广泛平台的读者可以查看功能目录并与对比中心中的其他入门套件比较方法。

仍然存在的局限和故障模式

幂等性关闭了一条危险路径,但不会使整个计费系统无懈可击。我记录了四个边界,以便下一位事故响应者不会高估保护范围。

首先,事件唯一性不保证事件顺序。订阅更新可能先于更早的发票事件到达,不同的 Stripe 事件类型不会与我的数据库形成事务。因此处理器尽可能从权威对象字段派生状态,并拒绝不可能的逆向转换。对于高风险转换,恢复路径从 Stripe 检索当前状态,而非从到达顺序猜测。

其次,端点可以成功提交但丢失 HTTP 响应。Stripe 可能因为未看到确认而重试。这正是已完成台账处理的确切情况:下一次投递找到相同标识符并在不重复效果的情况下返回 200。该行为通过强制在数据库批处理提交后响应失败来测试。

第三,永久映射错误需要死信工作流。无限期重放格式错误的内部命令会产生噪音而无进展。失败行保留脱敏诊断、尝试次数和时间戳;操作员可以修复映射器并通过受控命令重放原始提供商事件。原始支付载荷不会喷洒到通用日志中,保留期遵循应用程序的隐私政策。

第四,事件台账无法证明提供商和本地账户一致。Stripe 的手动更改、台账建立前的事件和管理员操作仍然可能产生分歧。对账任务比较活动订阅、发票状态和积分授权,然后发出差异供审查。它不会盲目覆盖客户状态。

这些局限性塑造了发布策略。我没有将补丁宣传为"精确一次 Webhook"。我将其描述为重复安全的处理,具有可恢复的失败和对账。这种语言不那么戏剧化,但它告诉值班开发者实际可用的保证是什么。

实用审查清单

在我批准另一个提供商 Webhook 之前,我会问:签名验证是否使用了未修改的请求体、是否唯一存储了稳定的提供商标识符、并发投递是否安全、失败的声明是否可以重试、业务效果是否也携带来源身份、以及操作员是否可以在不手动编辑生产行的情况下对账。如果任何答案不明确,处理器就没有完成。

我坚持的教训

精确一次是一种结果,而非投递模式

Webhook 传输可能至少投递一次。"精确一次"必须通过稳定身份、原子声明、唯一约束和安全重试在应用程序边界处近似。在内存集合、缓存键或在并发 Workers 之间的预检 SELECT 是不够的。

将不变量放入数据库

应用程序检查改善了错误消息;数据库约束在竞态条件下保持真值。金钱、积分、授权和订阅转换需要来源身份和唯一性规则。审计日志描述历史,但不应成为防止无效历史的唯一机制。

对账应与 Webhook 处理并列

即使是正确的接收方也可能面临映射错误或手动更改。提供商事实与内部效果的定期比较会捕获不同类别的故障。幂等性防止重复;对账检测分歧。

诚实的事后分析缩小声明范围

我可以根据提供的事故记录验证受影响账户数量、两个错误的降级和四小时修复窗口。我不能发布私密载荷或推断取消行为。命名这些局限性是修复的一部分:运营信任依赖于精确的语言,如同精确的代码。

TanStack Ship 如何防止重蹈覆辙

当前 TanStack Ship 计费模式将 Stripe 事件身份视为 Schema 级契约。它结合了签名验证、事件声明、幂等业务写入、审计追踪、重放测试和对账可见性。相关的 Stripe 集成 围绕重试设计,而非假设重试是例外的。

如果你已有 Webhook 路由,从一个审查问题开始:同一个有效的 event.id 是否可以同时两次到达而不产生第二个业务效果? 如果答案取决于时序,请在添加更多事件类型之前添加台账和唯一来源约束。

了解 TanStack Ship 的 Stripe 集成如何在计费模块中处理幂等性,然后在您准备好交付完整系统时查看定价。