Kotonia
ログイン今すぐ始める

Kotonia Articles

Stripe 已扣款、数据库却显示未订阅——排查并防止重复扣费

面向使用 Stripe 实现订阅计费的开发者。支付成功但 Webhook 的数据库更新回滚,用户的自然重试导致创建了多个订阅。从退款、数据恢复到数据库加锁、Webhook 幂等化、Sandbox 测试的真实事故复盘。

作者 4分钟阅读
#Stripe#Rust#PostgreSQL#Webhook#订阅计费
其他语言日语英语

TL;DR

Stripe 里支付已经成功,但只有 Webhook 的数据库更新失败,界面仍显示旧套餐。用户每次重试都会创建一个独立的订阅。退款并恢复数据库后,我加上了 PostgreSQL 加锁、未完成 Checkout 的持久化,以及 Webhook 的状态迁移测试。

本文的读者对象

正在用 Stripe Checkout 和 Webhook 实现订阅计费的人。尤其是那些认为「我加了 Stripe 的幂等键,所以不会重复扣费」的人。

BeforeAfter
每次重试都创建新的 Customer 和新的 Subscription在同一个对象上修改已有的 Subscription
Webhook 部分失败就回滚全部数据库更新可重跑的 upsert 与状态守卫
依赖只在几秒钟内有效的幂等键数据库加锁 + 未完成 Checkout 的唯一约束
假设 Webhook 大致按顺序到达对重复、延迟、乱序都做了测试

起因是「没生效,所以又付了一次」

我个人运营的 Kotonia 收到了一条咨询。

用户本想从旧套餐升级到更高的套餐。但界面一直显示旧套餐,「管理订阅」里也看不到新的合约。以为没生效,就又付了一次。

与此同时,邮箱里收到了 3 张账单。此时已经很清楚,这不是「界面显示延迟」,而是很可能创建了多个计费对象。

这种重试并不是异常行为。付了款界面却没变,就会想「是不是没提交成功」,于是再按一次。这本是设计一侧应当安全承接的操作。

在 Stripe 和应用数据库里,形成了两个不同的「事实」

排查后发现,Stripe 里存在多个 Customer 和 Subscription,它们的 metadata 都指向同一个应用用户。而且支付本身都成功了。

但应用数据库仍然保存着最初那份旧套餐的 Customer ID 和 Subscription ID。Stripe 的 Customer Portal 是按那个 Customer ID 打开的,因此在其他 Customer 下创建的合约对用户是不可见的。

Stripe:    旧套餐 + 变更后的套餐 + 重试产生的份额
应用数据库: 只有旧套餐
Portal:    只显示数据库所指向的那个 Customer 的合约

无论是支付系统还是应用,在各自持有的数据范围内都是正确的。但作为整个系统来看,它是错的。

Webhook 到达了,但事务失败了

合约完成 Webhook 的处理大致是这个顺序:

  1. 更新 users 里的合约状态
  2. 为订阅者创建工作区
  3. 把用户添加为管理员
  4. 全部成功后 commit

首次签约时「我的工作区」已经创建过了。变更套餐时又用相同的名字 INSERT,撞上了唯一约束。整个事务回滚,连第 1 步的用户更新也一并被抹掉。

此外,所用 Stripe 客户端的类型定义比 Webhook Endpoint 的 API 版本旧,在某些 Subscription 事件上反序列化会失败——这个条件也叠加了进来。

这不是单一的 bug。「能创建新合约的 Checkout」「Webhook 事务缺乏可重跑性」「API 版本差异」串联在一起,酿成了事故。

我最先做的不是修根因,而是退款和恢复

在做根治之前,我先让用户处于不吃亏的状态。

  • 通过 metadata 在 Stripe 上比对 Customer、Subscription、Invoice、Charge
  • 只保留一份符合意图的、更高套餐的 Subscription
  • 取消旧套餐和重复的份额,并对相应的支付退款
  • 把应用数据库的 Customer ID、Subscription ID、套餐对齐到保留下来的合约
  • 把工作区的每月使用上限也对齐到新套餐

排查过程中有些 Charge 已经退过款,因此为了不对同一笔支付重复退款,我在操作前一定先确认 Stripe 的最新状态。在事故处置中,重要的不只是「快速做正确的事」,还有「不重复已经做过的正确操作」。

对已有合约的套餐变更,不创建新的 Subscription

最大的修改,是把签约流程一分为二。

有生效中的 Subscription
  → 在 Billing Portal 里变更同一个 Subscription 的 Price

没有生效中的 Subscription
  → 创建 Checkout Session

对已有订阅者,不再让其打开 Checkout,而是通过 Billing Portal 的 subscription_update_confirm 流程去更新已有的 Subscription。这样就消除了「每次变更套餐都会增加 Customer 和 Subscription」的结构。

即便确实需要新建 Checkout,只要数据库里还留有 Customer ID 就复用它。仅在首次传入账户的邮箱地址,并把用户 ID 和套餐也保存到 client_reference_id 和 metadata 中,以便在支持时进行比对。

用 PostgreSQL 加锁和未完成 Checkout 来拦住重试

Stripe 的 Idempotency Key(把相同 key 的 API 操作当作一次来处理的功能)是必要的,但仅凭它无法保证整个应用层面的唯一性。

如果 key 在请求之间变了,就会变成另一次操作。在多实例部署中,进程内的 Mutex 也不共享。于是,我在创建 Checkout 时,按用户获取 PostgreSQL 的 advisory 事务锁。

SELECT pg_advisory_xact_lock(:billing_namespace, :user_id);

此外,把 Stripe Checkout 的 Session ID、套餐、URL、有效期、状态都保存到数据库。对 status = 'open' 的行,设置一个每个用户只允许一条的部分唯一索引。

CREATE UNIQUE INDEX one_open_checkout_per_user
ON subscription_checkout_sessions (user_id)
WHERE status = 'open';

这样一来,连点同一个套餐的按钮也只会返回已有的 Checkout URL。即便是另一个标签页、几分钟后的重试、或来自多台服务器的并发执行,也无法创建出两个未完成的 Checkout。若已存在另一个套餐的未完成 Checkout,则拒绝新建,并让该 Session 在 31 分钟后失效。

Webhook 不会「只来一次、按顺序来」

Stripe 官方的 Webhook 文档也明确写着:同一个事件可能会多次到达,且不保证按生成顺序投递。绝不能把重复和延迟当成异常来处理。

作为对策,我实现了下面这些规则:

  • 唯一保存 Stripe Event ID,防止对同一事件重复处理
  • checkout.session.completed 时用 FOR UPDATE 锁住用户行
  • 若存在另一个 active/trialing 的 Subscription,就不让迟到的 Checkout 把它替换掉
  • 同一个 Subscription 的重发可以安全地重新应用
  • subscription.deleted 仅当它与数据库当前所指向的 Subscription ID 一致时才生效
  • 工作区和成员添加改为 ON CONFLICT ... DO UPDATE
  • 保留一个兜底:当新的 Stripe API 版本无法用类型读取时,在验签之后只读取最小必要字段

套餐变更是通过 subscription.updated 送达的,因此不仅更新用户的套餐,也在同一事务里更新已有工作区的每月上限。

与其做猴子测试,不如去打破状态迁移

计费流程与「在浏览器里随机点按钮」的猴子测试很不搭。它牵涉真实支付、异步 Webhook 和外部的 Customer Portal,等待结果完成既慢又不稳定。

这次,我不用 UI 操作,而是针对计费状态生成了一串事件序列。

CheckoutCompleted(正常签约)
CheckoutCompleted(同一事件的重发)
CheckoutCompleted(迟到的重复签约)
SubscriptionDeleted(过去的合约)

在固定用例之外,我用确定性的随机种子跑了 50,000 步的重复与延迟事件。不变式是:无论顺序如何,所期望的 Subscription ID 和 active 状态都不会被另一个更旧的事件改变。

在此基础上,我实际连上 Stripe Sandbox,确认用相同的 Idempotency Key 创建两次 Checkout 会返回相同的 Session ID。测试用的 Session 当场 expire。我也把迁移应用到 PostgreSQL,确认第二条 open 行会被唯一约束拒绝。

即便测试也难以发现的「外部与数据库的割裂」

即使做到这一步,也不能说能把计费的不一致降到零。因为 Stripe 上 API 操作的成功,与提交到自己数据库,无法合并成一个 ACID 事务。

比如,Stripe 刚创建完 Checkout 网络就断了,应用就收不到那个成功。当数据库保存失败时,我让无法追踪的 Checkout Session 自动 expire,但故障模式的空间很大。

接下来奏效的是 reconciliation(定期比对)。每天把 Stripe 上生效中的 Subscription 与应用数据库里的合约状态做对比,并对以下情况告警:

  • 单个用户存在多个生效中的 Subscription
  • Stripe 是 active 但数据库是 inactive
  • 数据库所指向的 Customer 与 Subscription 的归属对不上
  • Stripe 的 Price 与数据库的套餐、使用上限对不上

测试是为了「不制造出本不该发生的违规」。比对是为了「即便如此仍发生了的违规,也能尽早发现」。我认为计费两者都需要。

从这次事故得到的 7 条教训

  1. **不要把用户的重试当成异常。**没生效就再按一次,是很自然的。
  2. **把「变更已有合约」和「创建新合约」区分开。**即便从同一个按钮开始,含义也不同。
  3. **不要拿 Idempotency Key 当互斥锁用。**你需要应用一侧的唯一约束和状态管理。
  4. **默认 Webhook 会重复、延迟、乱序。**用当前状态和目标 ID 来决定是否应用,而不只是靠 event ID。
  5. **让 Webhook 事务里的每一个操作都可重跑。**要怀疑那些应该用 upsert 而不是 INSERT 的地方。
  6. **让外部 API 版本与 SDK 的类型定义保持一致。**验签成功和 payload 类型转换成功是两回事。
  7. **测试和定期比对都要有。**预防和检测不一致,是不同的防御层。

它在 Kotonia 里是怎么发挥作用的

这次的修改,已经进入了从 Kotonia 的价格页面发起的订阅签约与套餐变更之中。不只是连点和另开标签页,几分钟后的重试也会汇入同一个未完成的 Checkout。

同时,我开始把计费工作当作不止是「写支付代码」——而是把退款、数据库恢复、复发防止、检测当作一个整体运维来对待。凡是牵涉钱的地方,我深切体会到:要一路做到「即便失败也能安全回退」,而不只是「能跑通」,它才终于算是一个功能。


※ 本文基于真实的事故处置,但账单编号、邮箱地址、Stripe Customer ID、Subscription ID 等客户信息已省略或匿名化。

Kotonia 将语音 AI、AI 聊天、图像生成和团队协作整合到一个 AI 工作区中。

试用 Kotonia