API 幂等设计:让重试安全
网络超时、用户双击、自动重试——同一请求可能发多次。幂等设计让"同一操作执行 N 次结果一致"。本文讲清幂等的本质(GET 天然幂等 vs POST 不幂等)、实现手段(Idempotency Key + 去重表)、支付/下单等关键场景的幂等保证,以及幂等与并发的配合。
本文要点
- 主题聚焦:TypeScript、DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
API 幂等设计:让重试安全
"网络是不可靠的,用户是焦虑的,重试是自动的——这三件事加在一起,你的接口就必须幂等。"
作为一个后端工程师,你一定经历过这种事故复盘:
- 用户点了一下"支付",扣了两次款。
- 网关超时,客户端自动重试,结果下了两单。
- 消息队列消息重复投递,同一个积分加了两次。
这些问题的根都是同一个:同一个操作被执行了多次,而你的系统没有保证"多次执行等于一次执行"。这就是幂等(Idempotency)要解决的事。
幂等不是锦上添花,是分布式系统的底线。任何一个对外暴露的写接口,如果没有幂等保证,就是在裸奔。本文把幂等的本质、实现手段、关键场景和并发配合一次讲透。
一、什么是幂等:从"重复"这个麻烦说起
数学上,幂等(idempotent)的定义很朴素:
如果一个操作执行一次和执行多次产生的效果相同,这个操作就是幂等的。
形式化一点:对于函数 f,如果 f(f(x)) = f(x),那么 f 是幂等的。
映射到 API 世界,这句话翻译过来就是:同样的请求,你发一次和发十次,系统状态的变化是一样的,返回给客户端的结果也是一致的(或者说至少不会产生新的副作用)。
注意这里的两个关键词:
- 系统状态的变化是一样的:不是"返回相同的结果",而是"对系统的副作用一致"。一个查询请求每次返回的内容可能因为时间不同而不同,但它不改变状态,所以是幂等的。
- 副作用的边界要清晰:扣款 100 元执行两次,扣 200 元——这不叫幂等。扣 100 元执行两次,仍然只扣 100 元——这才叫幂等。
为什么分布式系统对幂等如此执着?因为"重复"在这个世界里是常态,而不是异常:
- 网络不可靠:客户端发请求,服务端处理完了,但响应在回去路上丢了。客户端没收到响应,以为失败了,自动重试。
- 用户不可靠:用户等了 3 秒没看到反馈,又点了一次"提交"。
- 中间件不可靠:消息队列的 At-Least-Once 语义、网关的超时重试、服务网格的重试策略,都会让同一个请求出现多次。
一个成熟的系统必须假设:任何请求都可能被重复发送。基于这个假设去做设计,而不是寄希望于"客户端会做好"。
一句话:幂等不是把"重复"消灭掉,而是让"重复"变得无害。
二、HTTP 方法的天然分野
讨论 API 幂等,绕不开 HTTP 方法本身。RFC 7231 给每个方法规定了语义,其中一部分方法天然就是幂等的,另一部分天然不是。
| 方法 | 安全 | 幂等 | 说明 |
|---|---|---|---|
| GET | 是 | 是 | 只读,无副作用 |
| HEAD | 是 | 是 | 同 GET,只返回 header |
| OPTIONS | 是 | 是 | 预检/查询支持的方法 |
| PUT | 否 | 是 | 用请求体替换目标资源,多次替换结果一致 |
| DELETE | 否 | 是 | 删除一个资源,删一次和删十次结果都是"不存在" |
| POST | 否 | 否 | 创建资源/触发动作,每次都可能产生新副作用 |
| PATCH | 否 | 否 | 部分更新,语义上不保证幂等(取决于实现) |
很多人会困惑:PUT 和 DELETE 凭什么是幂等的?它们明明会改状态。
关键在于"幂等"看的是多次执行后的最终状态,而不是"有没有副作用"。
PUT /users/123 {name: "Alice"}:无论你执行多少次,user 123 的名字最终都是 Alice。第一次执行改变了状态,后续执行是"覆盖为同样的值",不再产生新的状态变化。所以幂等。DELETE /users/123:第一次执行把用户删了,后续执行发现用户已经不存在,什么都不做。最终状态都是"用户不存在"。所以幂等。POST /users {name: "Alice"}:每执行一次就创建一个新用户,执行十次就有十个 Alice。状态不断变化,所以不幂等。
理解了这一点,API 设计的第一条原则就清晰了:
用对 HTTP 方法,就免费拿到了一半的幂等。
能用 PUT 表达"替换"语义的,不要用 POST。能用 DELETE 表达"删除"的,不要用 POST 模拟。很多人喜欢万物皆 POST,理由是"灵活",代价就是放弃了 HTTP 方法自带的幂等保证,所有重试逻辑都得自己重新实现一遍。
但现实是残酷的:业务里大量操作本质上就是不幂等的 POST——下单、支付、转账、发券。这些才是幂等设计真正的战场。HTTP 方法帮不了你,你得自己上。
PATCH 是个灰色地带。如果 PATCH 的实现是"把字段设为某值"({op: "replace"}),它是幂等的;如果是"把字段增加某值"({op: "increment"}),就不幂等。所以 RFC 说 PATCH "不保证幂等",具体看你的语义。设计 PATCH API 时要心里有数。
三、Idempotency Key:让 POST 也能安全重试
既然 POST 天然不幂等,那要怎么让它"幂等"?业界给出的标准答案是:Idempotency Key(幂等键)。
核心思路一句话:客户端为每一次"逻辑请求"生成一个唯一 ID,带着这个 ID 发请求;服务端记住这个 ID 和它对应的结果,下次同一个 ID 再来,直接返回记住的结果,不再执行真正的副作用。
这里有几个细节值得展开:
第一,ID 由谁生成?
必须是客户端生成,而且要在"重试的是同一个请求"这件事上有语义。如果客户端发"下单"请求,超时了,客户端重试——这两次必须带同一个 ID,服务端才能识别出"这是同一个下单,别重复处理"。如果每次重试都换一个 ID,那这个机制就形同虚设。
// 客户端:同一笔业务,始终用同一个 key
async function submitOrder(order: Order) {
const idempotencyKey = order.clientOrderId // 用业务上的唯一 ID
// 或 crypto.randomUUID(),但要保存在客户端,重试时复用
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(order),
})
// 超时重试时,仍然用同一个 idempotencyKey
return withRetry(() => submitOrder(order), { key: idempotencyKey })
}
第二,服务端怎么"记住"?
要么放数据库(去重表),要么放缓存(Redis)。两者各有利弊,后面详细讲。
第三,记住的到底是"结果"还是"正在处理"?
这是个容易被忽视的点。如果一个请求处理需要 2 秒,客户端 1 秒就超时重试了,第二个请求到达时第一个还没处理完——这时候服务端不能简单地"返回缓存结果",因为还没有结果可返回。所以幂等记录需要有一个状态机:processing → done。第二个请求看到 processing 时,要么等(同步阻塞)、要么直接返回"处理中"(异步轮询),但绝对不能再启动一次真正的处理。
type IdempotencyRecord = {
key: string
status: 'processing' | 'done' | 'error'
result?: unknown
createdAt: number
expiresAt: number
}
这三点合起来,就是 Stripe、PayPal、AWS 等所有成熟 API 平台采用的幂等模型。它的核心是:把"请求"和"副作用"解耦——请求可以来 N 次,但副作用只发生一次。
四、去重表:幂等的落地实现
讲清楚了原理,我们来看最经典的实现:去重表(deduplication table)。
思路非常直白:建一张表,每处理一个请求,把它的幂等键和处理结果存进去。下次同一个键再来,先查表,命中就返回缓存的结果。
CREATE TABLE idempotency_records (
idempotency_key VARCHAR(128) PRIMARY KEY,
request_method VARCHAR(10) NOT NULL,
request_path VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL, -- processing / done / error
response_status INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_idempotency_expires ON idempotency_records(expires_at);
几个设计要点:
- 主键就是 idempotency_key:用数据库的唯一约束直接兜底并发,后面会讲。
- 记录请求方法和路径:同一个 key 在不同接口的语义不同,要区分。Stripe 的做法是 key + method + path + params 一起作为去重维度。
- status 字段:支持异步处理的状态机,避免"处理中"被重复触发。
- expires_at:幂等记录不能永久保留,要定期清理。
服务端的处理逻辑:
async function withIdempotency<T>(
req: Request,
handler: () => Promise<T>
): Promise<{ status: number; body: T | { error: string } }> {
const key = req.headers.get('Idempotency-Key')
if (!key) {
// 没带 key 的请求,按普通请求处理(不享受幂等保护)
return { status: 200, body: await handler() }
}
const repo = new IdempotencyRepository(db)
// 1. 尝试插入一条 processing 记录
// 利用唯一约束,并发下只有一个请求能成功插入
const inserted = await repo.tryInsert({
key,
status: 'processing',
requestMethod: req.method,
requestPath: new URL(req.url).pathname,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h TTL
})
if (!inserted) {
// 2. 已存在记录,读取状态
const existing = await repo.findByKey(key)
if (!existing) {
// 极端情况:刚被并发删掉,提示客户端换 key 重试
return { status: 409, body: { error: 'conflict, please retry with a new key' } }
}
if (existing.status === 'processing') {
// 3. 还在处理中:返回 409,让客户端稍后重试(或轮询)
return { status: 409, body: { error: 'request is being processed' } }
}
if (existing.status === 'done') {
// 4. 已完成:直接返回缓存的结果(关键!)
return { status: existing.responseStatus!, body: existing.responseBody as T }
}
// 5. 上次失败:允许用同一个 key 重新尝试
// 重新置为 processing,继续往下走
await repo.renewAsProcessing(key)
}
// 6. 真正执行业务逻辑
try {
const result = await handler()
await repo.markDone(key, 200, result)
return { status: 200, body: result }
} catch (err) {
await repo.markError(key)
throw err
}
}
这段代码体现了去重表的精髓:插入成功 = 拿到"执行权",插入失败 = 走缓存或排队。这是用数据库的原子性来协调并发,比应用层加锁更稳。
去重表 vs Redis 缓存
很多人会问:为什么不直接用 Redis,要写一张表?
我的观点是:关键业务的幂等,优先用数据库表;非关键业务可以缓存。理由:
- 持久性:数据库表即使服务重启也不会丢,Redis 默认可能丢数据。幂等一旦失效就是重复扣款,代价极高。
- 事务一致性:去重记录可以和业务写入放在同一个事务里(扣款 + 写幂等记录一起 commit),从根本上杜绝"扣了款但幂等记录没写"的不一致。Redis 做不到这一点。
- 可观测:表里的记录天然就是审计日志,排查问题方便。
Redis 的优势是性能和TTL 自动过期。适合那些"短时间内可能重复,但不需要长期追溯"的场景,比如限流、防抖。如果是支付级别的幂等,老老实实上表。
当然,两者也可以结合:Redis 做第一层快速去重(挡住大部分重复),数据库表做第二层持久兜底。这是大型系统的常见做法。
五、支付/下单:幂等的主战场
讲了通用方案,来看业务里最敏感的场景:下单和支付。这里的幂等一旦失效,直接是资损,所以是重中之重。
下单:用业务唯一 ID + 数据库唯一约束
下单的本质是"创建一笔订单"。我们要保证:用户因为网络问题或手抖提交了两次,只产生一笔订单。
最稳妥的做法不是依赖额外的幂等表,而是让订单本身具有天然唯一性。客户端在提交前生成一个 client_order_id(比如 UUID),作为订单的一部分。服务端在订单表上对 client_order_id + user_id 建唯一约束:
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
client_order_id VARCHAR(64) NOT NULL, -- 客户端生成的业务唯一 ID
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, client_order_id) -- 关键:数据库唯一约束兜底
);
服务端逻辑:
async function createOrder(userId: number, clientOrderId: string, items: Item[]) {
return await db.transaction(async (tx) => {
try {
const order = await tx.orders.create({
data: { userId, clientOrderId, amount: calcAmount(items), status: 'pending' },
})
// 扣库存、生成快照等
await tx.inventory.decrement(items)
return { status: 'created', order }
} catch (err) {
if (isUniqueViolation(err)) {
// 唯一约束触发:说明已存在同一 clientOrderId 的订单
const existing = await tx.orders.findFirst({
where: { userId, clientOrderId },
})
return { status: 'already_exists', order: existing }
}
throw err
}
})
}
注意返回 { status: 'already_exists' } 而不是抛错——这是幂等设计的细节:重复请求对客户端应该是透明的,告诉它"你的订单已经在了",而不是报错让它崩溃。
为什么不用前面讲的通用幂等表?因为下单这件事,订单表自己就能充当"去重表",没必要多维护一张表。能用业务唯一性解决的问题,不要引入额外的机制。
支付:Idempotency Key + 去重表
支付比下单更复杂,因为:
- 支付操作不创建实体:支付是"触发一个外部副作用"(调用银行/第三方渠道),没有自己的表来当去重表。
- 结果可能延迟:支付渠道回调可能几秒到几分钟才到。
- 金额必须精确:重复支付直接是钱的事。
所以支付场景必须用独立的幂等记录:
async function charge(req: ChargeRequest): Promise<ChargeResult> {
return await withIdempotency(req, async () => {
// 1. 业务校验
const order = await db.orders.findById(req.orderId)
if (order.status === 'paid') {
return { code: 'already_paid', orderId: order.id }
}
// 2. 调用支付渠道(外部副作用)
const channelResp = await paymentChannel.createCharge({
orderId: order.id,
amount: order.amount,
})
// 3. 更新订单状态
await db.orders.update(order.id, { status: 'paying', channelChargeId: channelResp.id })
return { code: 'charging', orderId: order.id, channelChargeId: channelResp.id }
})
}
withIdempotency 就是上一节那套去重表逻辑。这里它的价值是:支付渠道只会被调用一次。哪怕客户端把同一个 Idempotency-Key 重试十次,真正打到第三方渠道的请求只有一次——这正是我们要的。
支付的幂等铁律:外部副作用的调用,必须包在幂等保护里。 你调银行接口扣款,这一步没有重试余地,重复一次就是重复扣款。
一个隐蔽的陷阱:幂等不是只在外层
新手常犯的错误:只在最外层 Controller 包了一层 withIdempotency,但内部又调用了另一个不幂等的服务。结果外层去重挡住了重复请求,内部服务却被多次触发(比如内部有另一个重试机制、另一个消费者)。
正确的心智模型是:每一层都要想"如果我这一层被重复调用,会发生什么"。外层挡得住客户端重试,挡不住内部消息队列的重投递、定时任务的重复执行、回调的重复到达。每一处关键副作用,都要有自己的幂等保护。
六、并发下的幂等:锁与唯一约束
幂等设计里最容易被低估的难点是并发。
"同一个请求来两次"听上去是顺序的:第一次处理完了,第二次来,查表命中,返回。但真实世界里的"两次"经常是同时到达——客户端重试和原始请求几乎同时打到服务端的不同实例上。这时候如果你只是"先查表,没有就处理",两个请求都会查到"没有",然后都去处理——幂等彻底失效。
看一个错误的实现:
// ❌ 反面教材:先查后写,典型的 TOCTOU 漏洞
async function badIdempotency(key: string, handler: () => Promise<void>) {
const exists = await repo.findByKey(key)
if (exists) {
return exists.result
}
// 两个并发请求都会走到这里!
const result = await handler()
await repo.save(key, result)
return result
}
这是经典的 TOCTOU(Time of Check to Time of Use)问题。在高并发下必挂。
正确的做法有两条路:
方案 A:数据库唯一约束(推荐)
把"插入"作为获取执行权的原子操作。利用 INSERT ... ON CONFLICT 或捕获唯一约束冲突:
async function safeIdempotency(key: string, handler: () => Promise<Result>) {
try {
// 原子插入 processing 记录。成功 = 拿到执行权
await db.none(
`INSERT INTO idempotency_records(key, status, expires_at)
VALUES($1, 'processing', now() + interval '24 hours')`,
[key]
)
} catch (err) {
if (isUniqueViolation(err)) {
// 已有记录:走缓存/排队逻辑(同上一节)
return await handleExistingRecord(key)
}
throw err
}
// 拿到执行权,执行业务
const result = await handler()
await db.none(`UPDATE idempotency_records SET status='done', result=$2 WHERE key=$1`, [key, result])
return result
}
这条路的精髓:不用应用层锁,直接靠数据库的原子约束来当裁判。数据库天然串行化唯一约束的写入,无论并发多少个请求,只有一个能 INSERT 成功。简单、可靠、跨实例(多个服务节点也成立)。
方案 B:分布式锁
如果业务逻辑不能简单靠数据库约束表达(比如要调用一长串外部服务,中间状态复杂),可以用 Redis 分布式锁:
async function lockBasedIdempotency(key: string, handler: () => Promise<Result>) {
const lockKey = `idempotency:${key}`
const lock = await redis.set(lockKey, '1', 'PX', 30000, 'NX') // SETNX, 30s TTL
if (!lock) {
// 没拿到锁:说明其他请求正在处理同一个 key
return await waitForResult(key) // 轮询幂等记录,等它变 done
}
try {
// 双重保险:再查一次,因为可能是"上一个持有者刚释放锁但记录已 done"
const existing = await repo.findByKey(key)
if (existing?.status === 'done') return existing.result
const result = await handler()
await repo.save(key, result)
return result
} finally {
await redis.del(lockKey)
}
}
锁方案要注意锁的释放问题(避免锁泄露),以及"锁内执行时间长于锁 TTL"的边界。生产环境建议用 Redlock 或基于 etcd/Zookeeper 的强一致锁。
我的观点
能用唯一约束的,别用分布式锁。 唯一约束是声明式的、事务安全的、几乎零运维成本的;分布式锁是命令式的、容易出 bug 的、需要小心处理各种边界条件的。我见过太多线上事故的根因都是"分布式锁用错了"。
只有当业务流程涉及多个外部系统的长事务、确实无法靠数据库约束表达时,才考虑分布式锁。即便如此,也建议锁 + 唯一约束双保险:锁挡住大部分并发,唯一约束兜底防止锁失效时的极端情况。
七、过期策略:幂等记录不是永远
幂等记录不能永久保留,原因有三:
- 存储成本:所有写请求都要留痕,表会无限膨胀。
- 语义失效:两周前的请求,业务上下文可能早就变了(比如订单已退款、商品已下架),用老 key 重试反而会触发"恢复一个本不该恢复的状态",引入新 bug。
- 安全边界:无限期保留幂等记录,意味着攻击者只要拿到一个 key 就能长期复现响应。
所以幂等记录必须有 TTL(Time To Live)。
TTL 设多长?
没有标准答案,要看业务的重试特征:
- 普通 API:24 小时是 Stripe 的默认值,也是大部分平台的事实标准。覆盖了一天内所有正常的重试、人工排查、客户端 bug 修复周期。
- 支付/对账类:可以延长到 7-30 天,因为对账、补单这类操作可能在几天后才发生。
- 配置更新类:几分钟到几小时即可,因为配置类重试窗口短。
不要无限期。 有人觉得"保留越久越安全",实际上超过一定窗口后,保留更久带来的边际收益几乎为零,而存储和复杂度的边际成本是线性增长的。
怎么清?
两种方式:
方式一:Redis TTL(自动过期)
如果幂等记录放在 Redis,直接用 EXPIRE 即可,自动清理。
await redis.set(key, JSON.stringify(record), 'EX', 24 * 60 * 60) // 24h
方式二:数据库定时任务(定期删)
如果放在数据库,靠一个定时任务清理过期记录:
// 每天凌晨执行
async function cleanupExpiredIdempotencyRecords() {
const deleted = await db.result(
`DELETE FROM idempotency_records WHERE expires_at < now()`
)
log.info(`cleaned up ${deleted.rowCount} expired idempotency records`)
}
配合 expires_at 上的索引,这个清理是高效的。
查询时也要带过期判断——避免一个 key 在"已过期但还没被清理"的窗口内被错误命中:
async function findByKey(key: string) {
return await db.oneOrNone(
`SELECT * FROM idempotency_records
WHERE key = $1 AND expires_at > now()`,
[key]
)
}
key 复用的问题
TTL 过期后,同一个 key 又能被用来发新请求了。这里有一个坑:客户端不能复用过期的 key。
如果客户端的代码是把 key 硬编码或者用自增 ID 生成,过一段时间后,同一个 key 可能被赋给两个语义上不同的请求,造成"用旧 key 提了新请求,结果返回了旧结果"的诡异 bug。所以客户端生成 key 的最佳实践是:每次新的逻辑请求都用一个新的随机 UUID,只在自己的"重试"流程内复用。
八、实战:向 Stripe 学幂等设计
讲了这么多理论和自己的实现,我们来看看行业标杆是怎么做的。Stripe 的 Idempotency-Key 设计是公认的教科书级实践,几乎所有后来的支付平台都在模仿它。值得逐点拆解。
Stripe 的规则
- 客户端通过
Idempotency-KeyHTTP header 传一个不超过 255 字符的字符串,建议用 UUID。 - 服务端把 key + 请求参数(method、path、body)一起作为去重维度。同一个 key 配不同的请求参数会被拒绝(返回 400),防止误用。
- 结果会缓存 24 小时,24 小时后同一个 key 可以复用。
- 支持自动重试:Stripe 官方 SDK 内置了自动重试,会自动复用同一个 key,开发者完全无感。
- 覆盖所有写接口:不只支付,包括退款、创建 customer、转账等,所有 POST 都支持。
它做对了什么?
第一,把幂等变成协议,而不是约定。
很多团队的幂等是"我们尽量保证"这种口头约定,真出问题就甩锅。Stripe 把它变成了 HTTP header 这种标准协议,客户端必须带、服务端必须处理,白纸黑字写进 API 文档。这才叫工程化。
第二,SDK 内置,对开发者透明。
// Stripe SDK 自动处理幂等
const charge = await stripe.charges.create({
amount: 1000,
currency: 'usd',
source: 'tok_visa',
}, {
idempotencyKey: 'client-generated-uuid', // 只需要提供 key
})
开发者只需要提供一个 idempotencyKey,SDK 自己处理超时、重试、key 复用。这才是好的开发者体验。把复杂度收敛到框架层,别让每个业务开发者都重复实现一遍。
第三,key + 参数 一起校验。
这一步很多人会漏。如果客户端 bug 导致同一个 key 被用在了两个不同的请求上(比如先用来下单,又误用为退款),没有这个校验就会出现"用下单的结果返回给了退款请求"。Stripe 通过校验参数一致性,在源头拦截这种误用。
// 服务端校验:key 命中后,比较当前请求参数与缓存参数
async function verifyParams(key: string, req: Request) {
const cached = await repo.findByKey(key)
if (cached && !paramsEqual(cached.params, await serialize(req))) {
throw new BadRequestError(
`idempotency key was used for a different request`
)
}
}
第四,清晰的失败语义。
Stripe 规定:如果原始请求最终失败了,这个 key 不会"锁定失败结果",客户端可以用同一个 key 安全地重试。失败状态的记录会在短时间后被清理,允许重试。这点很反直觉(很多实现是把失败结果也缓存住),但更符合开发者心智:"失败了当然要能重试"。
抄作业的正确姿势
如果你的系统对外提供 API,或者内部有跨服务的写调用,直接照着 Stripe 的模式抄一份:
- 定义一个
Idempotency-Keyheader,要求客户端生成 UUID。 - 服务端建去重表,记录 key + 参数 + 状态 + 结果。
- TTL 24 小时(根据业务调整)。
- 提供 SDK / 中间件,让上层开发者不用关心细节。
- 写进 API 文档,明确告诉用户"这是支持的"。
不要小看"写进文档"这一步。很多团队内部有幂等机制,但没有人知道,业务开发还是各写各的。没有被广泛采用的机制,等于没有机制。
小结
- 幂等的本质:同一个操作执行多次,系统状态的变化和单次执行一致。它不是消灭重复,而是让重复无害。
- HTTP 方法自带一半的幂等:GET/PUT/DELETE 天然幂等,POST/PATCH 不保证。用对方法,是设计的第一步。
- POST 类操作的幂等靠 Idempotency Key:客户端生成唯一 key,服务端去重表/缓存,重复请求返回缓存结果。这是行业标配。
- 去重表 vs Redis:关键业务(支付/下单)优先数据库表——持久、可审计、可事务;非关键场景用 Redis 提速。
- 并发幂等的两条路:优先用数据库唯一约束(声明式、可靠),业务复杂时才上分布式锁,且建议双保险。
- 过期策略必须有:24 小时是常见 TTL,数据库定时清理 + 查询带过期判断。客户端禁止跨逻辑请求复用 key。
- 向 Stripe 学:把幂等做成协议(标准 header + 参数校验)、做进 SDK、写进文档。工程化的机制才靠谱。
金句:在网络、用户、中间件都不可信的世界里,幂等不是优化项,是接口的及格线。每一个写接口,都要默认假设它会被重试十次——然后让它扛得住。
能扛住重复的接口,才是生产可用的接口。
Related