Stripe 幂等表:把「不确定的重试」变成「查表返回」

Stripe 幂等表:把「不确定的重试」变成「查表返回」

面试官问支付接口怎么防止重试造成重复扣款,想听的不只是一句「加个唯一索引」,而是你是否理解问题的本质:网络重试时,客户端判断不了上一次请求到底成功没有。Stripe 的答案是幂等键------一个请求头字段加一张幂等表,把「不确定的重试」变成「查表返回」。这篇文章基于 Stripe 官方 API 文档和 Stripe 工程师 Brandur Leach 的公开参考设计(Rocket Rides),讲清楚问题从哪来、表怎么建、请求在表上怎么走、并发和崩溃怎么处理,最后给出面试问答。

注意,当面试官问你如果不让你用Redis,你怎么设计,针对高要求一致性的场景,可以选择事务或者这个sripe幂等表,或者用数据库表结合事务和唯一索引

这个stripe幂等表是为了解决网络超时,重试等带来的有严重副作用的问题。客户端生成唯一的UUID作为主键,放在请求头了,服务端将这个键,连同请求信息,状态等,存入一张幂等表里面,同一个幂等的多次请求,只会执行一次。

这个的最大特点是幂等键和业务表解耦,但是会带来额外的存储开销

问题:扣款成功,响应丢了

一个真实场景

用户在支付页点了「确认支付」,你的服务调用 Stripe 创建一笔 charge。请求发出去了,Stripe 那边钱也扣了,响应却在回来的路上丢了,客户端只看到超时。用户等不及又点了一次。摆在面前的问题不是要不要重试,而是重试会不会扣两次。

两个系统隔着网络传消息,连接失败、中途断掉、响应丢失,这类失败不会消失,只会以某个背景速率持续发生。放在支付场景里,每一起都是潜在的双重扣款。

三种失败,只有一种能放心重试

失败位置 客户端知道什么 直接重试安全吗
连接建立前失败 请求根本没发出去,服务端不知道这件事 安全
处理中途失败 服务端可能已经做了一半 危险
处理成功但响应丢失 操作已完成,客户端毫不知情 最危险

前一种是确定性失败,客户端知道什么都没发生;后两种是模糊失败,重试可能重复扣款,不重试可能白白丢掉一笔订单。

为什么不能靠「幂等接口」解决

HTTP 语义里 PUT 和 DELETE 天然幂等,同一请求执行多少遍,资源状态都一样。但扣款不行。创建一笔 charge 本质是新增一条记录,POST 发两次就是两条。要让接口天然幂等,就得记住所有历史请求,这在支付场景不现实。幂等键正是为这类「必须恰好执行一次」的操作设计的。

幂等键:把「重试」变成「查表」

stripe:不是建的一个幂等表,而是在服务端保存历史记录,客户端传一个自己生成的key,这个key唯一,以后其余所有的请求,都会直接走这个key。判断,如果存在,不会重复请求后端。

核心思路

客户端为每一次业务操作生成一个唯一 ID(Stripe 建议 UUID v4,最长 255 字符),通过 Idempotency-Key 请求头发给服务端。服务端把第一次请求的状态码和响应体按 key 存起来。之后任何携带同一 key 的请求,直接返回第一次存的结果,不再执行业务。

重试必须复用同一个 key。key 标识的是这笔操作,不是这次请求。每次重试都换新 key,服务端会当成三个不同操作,等于扣款三次。

记住这条:key 属于「操作」,不属于「请求」。重试时换 key,幂等就失效了。

Stripe 的行为规则

下面这些规则都来自官方文档,每条都可能在面试里被追问:

  • 结果无论成败都缓存。第一次请求返回 500,之后同 key 重试也返回同一个 500,因为第一次可能已经产生副作用、还在对账。

  • 参数必须一致。同 key 配不同参数,服务端返回 409(错误类型 idempotency_error),绝不静默重放。

  • 并发冲突不缓存。同 key 的两个请求同时处理,后到的返回 409,客户端可以退避后重试。

  • key 至少保留 24 小时。过期后复用会被当作新请求。

  • 只有 POST 接受 key。GET 和 DELETE 本身幂等,传了也没用。

GET:查询数据,只读,不修改

PUT:全量插入数据

POST:局部插入数据,就是插入一条,

PATCH:也是局部插入数据,就是插入的粒度更细

为什么需要一张「幂等表」

要兑现这些行为,服务端必须把 key 和结果持久化。放在内存缓存里,进程一重启,承诺就破了。于是有了专门的 idempotency_keys 表,这就是面试里说的幂等表。

幂等表长什么样

建表语句

下面的结构来自 Brandur Leach 的 Rocket Rides 参考设计,是社区广泛采用的版本:

sql 复制代码
CREATE TABLE idempotency_keys (
  id              BIGSERIAL PRIMARY KEY,
  user_id         TEXT NOT NULL,            -- 归属用户 / 账户
  key             TEXT NOT NULL,            -- 客户端传来的幂等键
  request_hash    TEXT NOT NULL,            -- 请求指纹:方法 + 路径 + 参数
  locked_at       TIMESTAMPTZ,              -- 正在处理中:锁 / 租约
  recovery_point  TEXT NOT NULL DEFAULT 'started',  -- 处理到哪一步
  response_code   INT,                      -- 缓存的结果状态码
  response_body   JSONB,                    -- 缓存的结果响应体
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (user_id, key)                     -- 幂等约束的核心
);

每个字段干什么

字段 作用 对应的面试点
(user_id, key) 唯一约束 两个不同用户可能生成相同的 UUID,key 必须按账户隔离 为什么唯一约束是 (user_id, key) 而不是 key 单独唯一
request_hash 请求指纹,同 key 第二次来先比参数 同 key 不同参数是客户端 bug,返回 409
locked_at 标记正在处理的请求,并发请求能看到 这是租约不是永久锁,会过期
recovery_point 记录处理到哪个阶段 崩溃后重试从断点续跑
response_code + response_body 缓存第一次的结果 包括 500 也缓存,重试直接回放
created_at 配合清理任务删除过期 key 24 小时保留策略与 reaper

一次请求在表上怎么走

先认领 key

处理业务之前,先做一件事:认领 key。INSERT ... ON CONFLICT (user_id, key) DO NOTHING 保证表里只有一行,再用 SELECT ... FOR UPDATE 锁住这一行。两个并发请求同时到达,只有先到者能拿到锁。

拿到行之后,按顺序做三次检查

  1. request_hash 不一致:返回 409,这是客户端 bug,不碰锁。

  2. response_code 已存在:直接回放缓存的响应,并在响应头标记重放(Stripe 用 Idempotent-Replayed: true)。

  3. 都没命中:条件更新拿到锁(locked_at 为空或已过期才允许),开始执行业务。

回放为什么对客户端友好

重试拿到的响应和第一次成功时完全一样,只是多了一个重放标记。客户端可以把重试结果直接当成功处理,不需要写特殊分支。

处理到一半崩了:原子阶段与恢复点

难点在外部调用

一个支付请求很少只写一次库。典型流程是:写 ride 记录 → 调 Stripe 扣款 → 把 charge_id 存回记录 → 准备通知。其中调 Stripe 是外部状态变更,不在你的数据库事务里,崩溃了也没法回滚。本地写库和外部调用放不进同一个事务,这才是幂等最难的部分。

原子阶段 + 恢复点

解法是把整个操作切成几个原子阶段,每个阶段结束后在 recovery_point 写一个标记。重试时读标记,就知道从哪接着跑:

  • started:还没开始,从写本地记录开始。

  • ride_created:本地记录已提交,从调支付开始。

  • charge_created:钱已扣、charge_id 已存,从保存最终响应开始。

  • finished:全部完成,返回缓存结果。

外部调用必须自己幂等

恢复点成立的前提,是外部调用本身幂等。Stripe 就是这样做的:用同一个 key 重试扣款,它返回同一个 charge_id。如果下游接口不支持幂等,你的接口只是数据库层幂等,业务层重试照样可能造成重复副作用。

并发、锁与清理

锁是租约,不是永久锁

locked_at 不是普通锁,是带过期时间的租约。进程崩溃时,永久锁会让 key 永远卡在处理中,所有重试都拿 409。租约到期后,后来的请求可以接管继续处理。(这几句话的意思是,key是带有过期时间的,就算一次请求崩溃了,导致状态一直是在处理中,到时间后,这个key会释放,接下来的请求还可以重新)

租约时长是最容易踩的坑

租约必须比最慢请求的耗时更长。设短了,请求还在处理,锁已经过期,另一个请求接管后可能重复执行。社区里的结论很直白:锁超时比最慢请求短,等于重复操作的生成器。更稳的做法是租约加续期或 fencing token,再用数据库唯一约束兜底,让重复尝试在数据库层失败,而不是静默重复。

409 是设计好的流程

处理中的 key 收到并发请求返回 409,而不是 500。客户端收到 409 应该退避后用同一个 key 重试,那时大概率能拿到回放的响应。

清理:reaper

表不能无限增长。一个后台任务定期删除过期的 key。Stripe API v1 至少保留 24 小时,社区参考设计建议 72 小时。窗口要覆盖客户端重试和对账的时间。

客户端怎么用

  • key 标识操作,不标识请求。同一笔订单的所有重试共用一个 key,新订单用新 key。

  • 随机性要够。用 UUID v4 或等价熵的随机串;不要用邮箱、手机号等敏感信息当 key,它会出现在两边的日志里。

  • 指数退避加抖动。第一次失败等一小会,之后按 2 的 n 次方递增并加随机抖动,避免所有客户端同时重试形成惊群。

  • 把 500 当「未知」处理。重试 500 仍得到 500 时,用 webhook 对账确认最终结果,而不是盲目继续重试。

和数据库唯一索引加事务的区别

  1. 数据库唯一索引加事务是在数据库表中加一个唯一键,再次请求的时候,如果插入失败,说明这个请求已经处理过

  2. 两者的核心区别

  3. 保护的对象不一样,数据库唯一索引保护的是一张表,一次请求可能不只是只操作一张表,在这个过程中处理的其余操作可能无法阻止

  4. stripe幂等表保护的是一次完整的请求,因为唯一key是在服务端存储的,从这个请求最开始就进行了校验拦截。

  5. 两者的返回不同,stripe幂等表可以返回请求处理中,成功,失败状态,而数据库唯一索引不可以。

当请求进来后,会标记为处理中,如果请求完成,会将这个状态标记为成功。如果中途崩溃,这个请 求会一直在处理中状态

  1. 返回结果的索引不同,stipe幂等表会保留这次请求的返回结果,以后的所有请求,都能直接拿到对应的返回结果。如果 用数据库索引的话,查到这次请求重复,还需要去各个地方拼接返回结果。

面试怎么答

一句话版本

幂等键是客户端为一次业务操作生成的唯一 ID。服务端第一次处理并存结果,之后同 key 的请求直接返回缓存结果,把不确定的重试变成查表返回。

常见追问

追问 回答要点
加个唯一索引不就够了吗? 唯一索引只能挡重复插入,挡不了处理中崩溃和结果回放。幂等表记录的是整个请求的生命周期。
同 key 不同参数怎么办? 返回 409,不能静默重放,否则客户端会误以为参数生效了。
两个并发请求同时到? 行锁加条件更新,赢家处理,输家拿 409,退避后带同 key 重试拿缓存结果。
处理到一半进程崩溃? 靠 recovery_point 从断点续跑;前提是外部调用自带幂等。
结果要缓存多久? 覆盖客户端重试和对账窗口,Stripe 至少 24 小时,过期由 reaper 清理。
和 Outbox 模式什么关系? 都是靠持久化状态换确定性:幂等表管请求重试,Outbox 管本地写库与发消息的一致性,两者经常一起出现。

面试前自检

  • 能讲清三种网络失败里哪两种是模糊失败

  • 能画出幂等表字段并说出各自的作用

  • 能说清同 key 不同参数、并发同 key、崩溃恢复三种场景的处理

  • 知道锁是租约,知道租约时长为什么是坑

  • 知道外部调用必须自己幂等

  • 知道 24 小时保留策略和 reaper 清理

参考资料

  1. Stripe API 文档:Idempotent requests

  2. Stripe 工程博客:Designing robust and predictable APIs with idempotency(Brandur Leach,2017)

  3. Brandur Leach:幂等键的完整实现思路(原子阶段、恢复点与租约)

(注:部分内容由豆包工作 AI 生成)

相关推荐
浮生望1 小时前
Agentic RAG(中):复杂问题如何拆成多跳检索闭环
agent
李纲明1 小时前
WordPress 站点变慢:按 TTFB → 缓存 → 查询 → 前端 分层排查(含配置注意)
前端·缓存·性能优化·wordpress·后端开发
小盆女神节奶粉2 小时前
LangChain 中间件
langchain·agent
程序人生8882 小时前
PDF 转 Word 版式错乱、表格丢失?DocConverter Web 格式互转引擎的保真实践
前端·人工智能·opencv·机器学习·pdf·word
PHP实战开发录2 小时前
MySQL数字排序为什么乱
数据库·mysql·php
陈然信息站2 小时前
皮尔磁纸板进料安全方案选型指南:O300传感器与myPNOZ、PNOZmulti 2对比
大数据·安全·业界资讯
粥里有勺糖3 小时前
安利一下最近用的桌面“Agent” | T3 Code
前端·github·ai编程
xy34533 小时前
Axure 9.0 动态面板基本操作
前端·ui·html·axure·原型·产品设计
不停喝水3 小时前
【前端转全栈java速通课】 项目实战④7-11节 操作数据库-登录-注册-修改密码-注销-mubatis-plus简化crud-
java·前端·数据库