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 锁住这一行。两个并发请求同时到达,只有先到者能拿到锁。
拿到行之后,按顺序做三次检查
-
request_hash 不一致:返回 409,这是客户端 bug,不碰锁。
-
response_code 已存在:直接回放缓存的响应,并在响应头标记重放(Stripe 用 Idempotent-Replayed: true)。
-
都没命中:条件更新拿到锁(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 对账确认最终结果,而不是盲目继续重试。
和数据库唯一索引加事务的区别
-
数据库唯一索引加事务是在数据库表中加一个唯一键,再次请求的时候,如果插入失败,说明这个请求已经处理过
-
两者的核心区别
-
保护的对象不一样,数据库唯一索引保护的是一张表,一次请求可能不只是只操作一张表,在这个过程中处理的其余操作可能无法阻止
-
stripe幂等表保护的是一次完整的请求,因为唯一key是在服务端存储的,从这个请求最开始就进行了校验拦截。
-
两者的返回不同,stripe幂等表可以返回请求处理中,成功,失败状态,而数据库唯一索引不可以。
当请求进来后,会标记为处理中,如果请求完成,会将这个状态标记为成功。如果中途崩溃,这个请 求会一直在处理中状态
- 返回结果的索引不同,stipe幂等表会保留这次请求的返回结果,以后的所有请求,都能直接拿到对应的返回结果。如果 用数据库索引的话,查到这次请求重复,还需要去各个地方拼接返回结果。
面试怎么答
一句话版本
幂等键是客户端为一次业务操作生成的唯一 ID。服务端第一次处理并存结果,之后同 key 的请求直接返回缓存结果,把不确定的重试变成查表返回。
常见追问
| 追问 | 回答要点 |
|---|---|
| 加个唯一索引不就够了吗? | 唯一索引只能挡重复插入,挡不了处理中崩溃和结果回放。幂等表记录的是整个请求的生命周期。 |
| 同 key 不同参数怎么办? | 返回 409,不能静默重放,否则客户端会误以为参数生效了。 |
| 两个并发请求同时到? | 行锁加条件更新,赢家处理,输家拿 409,退避后带同 key 重试拿缓存结果。 |
| 处理到一半进程崩溃? | 靠 recovery_point 从断点续跑;前提是外部调用自带幂等。 |
| 结果要缓存多久? | 覆盖客户端重试和对账窗口,Stripe 至少 24 小时,过期由 reaper 清理。 |
| 和 Outbox 模式什么关系? | 都是靠持久化状态换确定性:幂等表管请求重试,Outbox 管本地写库与发消息的一致性,两者经常一起出现。 |
面试前自检
-
能讲清三种网络失败里哪两种是模糊失败
-
能画出幂等表字段并说出各自的作用
-
能说清同 key 不同参数、并发同 key、崩溃恢复三种场景的处理
-
知道锁是租约,知道租约时长为什么是坑
-
知道外部调用必须自己幂等
-
知道 24 小时保留策略和 reaper 清理
参考资料
(注:部分内容由豆包工作 AI 生成)