TL;DR
Stripe 里支付已经成功,但只有 Webhook 的数据库更新失败,界面仍显示旧套餐。用户每次重试都会创建一个独立的订阅。退款并恢复数据库后,我加上了 PostgreSQL 加锁、未完成 Checkout 的持久化,以及 Webhook 的状态迁移测试。
本文的读者对象
正在用 Stripe Checkout 和 Webhook 实现订阅计费的人。尤其是那些认为「我加了 Stripe 的幂等键,所以不会重复扣费」的人。
| Before | After |
|---|---|
| 每次重试都创建新的 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 的处理大致是这个顺序:
- 更新
users里的合约状态 - 为订阅者创建工作区
- 把用户添加为管理员
- 全部成功后 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 条教训
- **不要把用户的重试当成异常。**没生效就再按一次,是很自然的。
- **把「变更已有合约」和「创建新合约」区分开。**即便从同一个按钮开始,含义也不同。
- **不要拿 Idempotency Key 当互斥锁用。**你需要应用一侧的唯一约束和状态管理。
- **默认 Webhook 会重复、延迟、乱序。**用当前状态和目标 ID 来决定是否应用,而不只是靠 event ID。
- **让 Webhook 事务里的每一个操作都可重跑。**要怀疑那些应该用 upsert 而不是
INSERT的地方。 - **让外部 API 版本与 SDK 的类型定义保持一致。**验签成功和 payload 类型转换成功是两回事。
- **测试和定期比对都要有。**预防和检测不一致,是不同的防御层。
它在 Kotonia 里是怎么发挥作用的
这次的修改,已经进入了从 Kotonia 的价格页面发起的订阅签约与套餐变更之中。不只是连点和另开标签页,几分钟后的重试也会汇入同一个未完成的 Checkout。
同时,我开始把计费工作当作不止是「写支付代码」——而是把退款、数据库恢复、复发防止、检测当作一个整体运维来对待。凡是牵涉钱的地方,我深切体会到:要一路做到「即便失败也能安全回退」,而不只是「能跑通」,它才终于算是一个功能。
※ 本文基于真实的事故处置,但账单编号、邮箱地址、Stripe Customer ID、Subscription ID 等客户信息已省略或匿名化。
