2023 年我做一个支付下单接口。逻辑很简单:用户点"支付",前端把请求发到后端,后端做两件事——扣款,然后创建订单。第一版做得很直接,本地测、上线初期,都挺好。可上线一段时间后,客服转来一个投诉:有用户说自己只买了一件东西,却被扣了两次钱,后台还生成了两个一模一样的订单。我赶紧去翻日志,翻出一个很扎眼的事实:同一个用户、同样的金额、几乎同一时刻,有两条一模一样的请求打进了后端。我第一反应是:用户手抖点了两下。这个能理解——网络慢的时候,按钮点下去没反应,人本能地会再点一下。于是我条件反射地想:"那让前端在点击后把按钮禁用掉不就行了?"我让前端加了"点击即禁用按钮"。结果——还是有重复。我又翻了更多日志,才彻底傻眼:重复的请求,来源根本不止"用户手抖"一种。有的是前端请求超时了,浏览器或 HTTP 客户端自动重试发了第二遍;有的是请求在网关层被重发了;还有一条链路上的消息被重复投递……这些重复,没有一个是那个"禁用按钮"能拦住的。我盯着这些日志想了很久才彻底想明白,我错在一个根本的认知上:我以为"重复请求是个前端问题,在前端拦住就行了"。可它根本不是。重复请求,是分布式系统里一个无法消灭的事实——只要有网络,就有超时;只要有超时,就有重试;只要有重试,就有重复。你永远堵不住所有的源头。所以正确的思路不是"消灭重复请求",而是让你的接口"即使被重复调用,也不会出错"——同一个操作,无论被执行一次还是一百次,产生的结果都和执行一次完全相同。这,就是接口幂等性。我以为它不过是"加个判断",结果真做下来,坑一个接一个。这篇文章就把它梳理一遍:为什么重复请求根本防不住、幂等性的本质是什么、幂等键是什么、怎么用数据库唯一约束兜底、怎么用 Redis 实现幂等,以及幂等键怎么生成、并发竞态、结果重放、失败回滚这些把幂等真正做对要避开的坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个支付下单接口,扣款 + 创建订单。上线后有用户被扣两次钱、生成两个订单。日志显示同一笔支付,几乎同一时刻有两条完全相同的请求打进后端。让前端"点击即禁用按钮"后,重复依然存在。
我当时的错误认知:"重复请求是用户手抖造成的前端问题,让前端禁用按钮、防住重复提交就行了。"
真相:重复请求的来源远不止用户手抖——还有前端超时自动重试、网关重发、消息重复投递。这些都绕过前端防护。重复请求是分布式系统里无法消灭的事实。正确的做法不是消灭重复,而是让接口幂等:同一个操作无论执行多少次,结果都和执行一次相同。实现的核心是幂等键——客户端给每次操作分配一个唯一标识,服务端用它识别并拦截重复执行,可以靠数据库唯一约束兜底,也可以用 Redis 配合状态记录来实现。
要把幂等做对,需要几块认知:
- 重复请求有哪些来源,为什么前端根本防不住;
- 幂等性的本质——同一操作执行多次等于执行一次;
- 幂等键是什么,它怎么给每个操作一个唯一身份;
- 怎么用数据库唯一约束、用 Redis 来实现幂等拦截;
- 幂等键生成、并发竞态、结果重放、失败回滚这些工程坑怎么处理。
一、为什么重复请求根本防不住
先把这件最根本的事钉死:重复请求不是 bug,是分布式系统的常态;它的来源有很多种,你不可能在源头把它们全堵住。
下面这段代码,就是我那个会"扣两次钱"的第一版——它默认每个请求都是一次全新的、独一无二的操作:
def pay_naive(user_id: int, order_id: str, amount: int) -> dict:
# 反面教材:来一个请求就扣一次钱、建一个订单,
# 完全不管这个请求是不是刚刚已经处理过了。
deduct_balance(user_id, amount) # 扣款
create_order(order_id, user_id, amount) # 创建订单
return {"status": "ok"}
# 问题:用户网络卡点了两下"支付";或前端请求超时后
# 自动重试;或网关把请求重发了一次 —— 同一笔支付的
# 请求来了两遍,这里就【扣两次钱、建两个订单】。
这段代码本身没有语法错误,处理单个请求时完全正确。它的问题是一个隐藏的假设:"我收到的每个请求,都是一次新的操作"。而这个假设,在真实网络里站不住。我当时的第一反应,是在前端补一刀:
// 反面教材:只靠前端禁用按钮来防重复提交。
payButton.addEventListener('click', async () => {
payButton.disabled = true; // 点了就禁用,防止再点
try {
await fetch('/api/pay', { method: 'POST', body: data });
} finally {
payButton.disabled = false;
}
});
// 问题:前端防护【根本不可靠】——
// 用户刷新页面重新提交、网络超时后 HTTP 客户端自动重试、
// 请求在网关层被重发……这些统统绕过了这个 disabled。
// 重复请求拦不拦得住,只能由【服务端】说了算。
这个前端补丁,只能挡住最表层的那一种重复——"用户在同一个页面上手动连点"。可重复请求的来源远不止这一种:前端请求超时了,HTTP 客户端自动重试会再发一遍;请求经过的网关、代理,在它们自己的超时重试逻辑下也会重发;如果链路里有消息队列,消息的"至少一次"投递语义意味着同一条消息可能被消费两次。这些重复,没有一个经过那个 disabled 按钮。
所以结论很清楚:重复请求不可能在源头被消灭,它一定会到达你的服务端。既然拦不住它来,那就只能换个思路——让服务端有能力认出它、并且正确地处理它。
二、幂等性的本质:执行多次等于执行一次
上一节的结论是:重复请求必然会来。幂等性(Idempotency)就是正面回应这件事的那个概念。
它的定义,一句话:一个操作,无论被执行一次,还是被重复执行很多次,产生的效果(对系统状态的影响)都和执行一次完全相同。注意,幂等不是"拒绝重复请求",而是"重复请求来了也不会造成额外的影响"。第一次请求正常处理,后续的重复请求,要么被识别出来直接忽略,要么返回和第一次一模一样的结果——但绝不会再扣一次钱、再建一个订单。
这里有个值得留意的点:有些操作天生就是幂等的。比如"把用户余额设置为 100"——执行一次是 100,执行十次还是 100;再比如"删除 id=5 的订单"——删一次它没了,再删它还是没了。这类操作,你什么都不用做,它就是幂等的。真正危险的,是那些带"增量"语义的操作:"余额减去 100""插入一条订单""库存扣减 1"——这种操作,每执行一次,系统状态就往前走一步,执行两次的结果和执行一次截然不同。我那个支付接口,扣款和建订单,恰恰都是这种增量操作。
所以幂等性要解决的核心矛盾就是:对于一个本身不幂等的增量操作,怎么让它在被重复调用时,表现得像幂等的一样?办法只有一个:服务端必须能认出"这两个请求其实是同一个操作"。可两个请求长得一模一样,服务端凭什么区分"这是同一操作的重复"还是"这是两次巧合相同的独立操作"(比如用户真的想买两件同样的东西)?这就需要给每个操作,一个明确的、唯一的身份。
三、幂等键:给每个操作一个唯一身份
上一节的问题是:服务端无法仅凭请求内容,区分"重复"和"巧合相同"。幂等键(Idempotency Key)就是来解决这个的。
它的思路朴素到极致:客户端在发起一次操作之前,先为这次操作生成一个全局唯一的标识符;这一次操作,无论之后因为超时、重试被发送多少遍,都带着同一个标识符。这样,服务端只要看这个标识符——键相同,就是同一个操作的重复;键不同,才是真正独立的两次操作。一个关键点:这个键必须由客户端生成,而且要在"发起操作前"就生成好。因为只有客户端知道"我这几次发送,其实是同一次操作的重试";如果让服务端生成,服务端收到两个请求时根本分不清,生成的就是两个不同的键了。
import uuid
def new_idempotency_key() -> str:
"""客户端在【发起一次操作前】生成幂等键。
这一次操作无论重试多少遍,都复用【同一个】键。"""
return uuid.uuid4().hex
def get_idempotency_key(request) -> str:
"""服务端:从请求头里取出客户端带来的幂等键。"""
key = request.headers.get("Idempotency-Key")
if not key:
# 没带幂等键的写操作,直接拒绝 —— 不给"裸奔"的机会
raise ValueError("缺少 Idempotency-Key 请求头")
return key
幂等键通常放在 HTTP 请求头里(约定俗成的名字是 Idempotency-Key),这样它和业务参数解耦,任何写接口都能统一处理。有了这个键,服务端的任务就具体了:维护一份"已经处理过的幂等键"的记录;每来一个请求,先拿键去查这份记录——查到了,说明是重复,走重复的逻辑;没查到,才是第一次,正常处理并把键记下来。问题就归结为:这份"已处理键"的记录,该怎么存、怎么查,才不出错?先看最可靠的一种:数据库唯一约束。
四、用数据库唯一约束兜底
"记录已处理的键"这件事,最可靠的实现,是利用数据库的唯一约束。它的可靠,来自一个事实:数据库保证,一个带唯一约束的字段,同一个值绝不可能被成功插入两次——这个保证是数据库从底层用并发控制实打实兜住的,不是应用代码的"判断"能比的。
先建一张专门记录幂等键的表,核心是 idempotency_key 字段上的 UNIQUE 约束:
-- 给幂等键建一张表,关键是 idempotency_key 上的 UNIQUE 约束。
CREATE TABLE payment_request (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
idempotency_key VARCHAR(64) NOT NULL,
order_id VARCHAR(64) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_idem (idempotency_key) -- 同一个键只能插入成功一次
);
有了这张表,处理逻辑就反过来了:不是"先查再插",而是直接抢着插——靠 INSERT 的成功或失败来判断这是不是第一次。这一点很关键:"先查后插"是两步,两步之间有缝,并发下会出竞态;而直接 INSERT 是一步,由数据库原子地裁决。
def pay_with_unique(key: str, user_id: int,
order_id: str, amount: int) -> dict:
try:
# 先抢着插入这条幂等记录:UNIQUE 约束保证
# 同一个 key 第二次插入【必然失败】抛异常。
db.execute(
"INSERT INTO payment_request(idempotency_key, order_id) "
"VALUES (%s, %s)", (key, order_id))
except IntegrityError:
# 插入失败 = 这个 key 之前已经处理过 —— 直接返回,不再扣款
return {"status": "duplicate"}
# 插入成功 = 这是第一次,放心执行真正的业务
deduct_balance(user_id, amount)
create_order(order_id, user_id, amount)
return {"status": "ok"}
更进一步:如果"插入幂等记录"和"扣款、建订单"能放进同一个数据库事务里,那就更稳——要么一起成功,要么一起回滚,绝不会出现"键记下了、钱却没扣"或者反过来的半截状态。数据库唯一约束这条路,胜在绝对可靠;它的代价是每次请求都要落一次库。在高并发场景下,有时会想用更快的 Redis 来扛这一层——但那条路,坑会更多一些。
五、用 Redis 实现幂等:占位与状态记录
用 Redis 做幂等,基本思路和唯一约束类似:用 Redis 的 SET NX(key 不存在才设置成功)来抢"第一次处理"的资格。但 Redis 这条路,有一个数据库唯一约束不需要操心、而它必须操心的问题:处理"正在处理中"这个中间状态。
设想:第一个请求抢到了资格,正在慢慢扣款(花了 2 秒);就在这 2 秒内,重复请求来了。这个重复请求不能再扣一次款,但它也不能简单返回"成功"——因为第一个请求还没处理完,结果还不知道。所以幂等记录不能只存"有没有这个键",还得存这个键处理到哪一步了:是 processing(处理中),还是 done(已完成、且结果是什么)。
import json
import redis
r = redis.Redis(host="localhost", port=6379)
class IdempotencyGuard:
"""用 Redis 做幂等:记录每个幂等键的处理状态和结果。"""
def begin(self, key: str, ttl: int = 86400) -> bool:
# SET NX:只有 key 不存在才设置成功。
# 设置成功 = 抢到了"第一次处理"的资格,返回 True;
# 设置失败 = 这个 key 已经存在,是重复请求,返回 False。
ok = r.set(f"idem:{key}",
json.dumps({"status": "processing"}),
nx=True, ex=ttl)
return ok is not None
begin 用 SET NX 一步完成"抢资格",原子、无竞态。抢到资格的请求去执行业务,执行完要把结果写回;而重复请求,需要能读出这个键当前的状态和结果:
def finish(self, key: str, result: dict, ttl: int = 86400):
# 业务处理完,把最终结果写回去,覆盖掉 processing 状态。
r.set(f"idem:{key}",
json.dumps({"status": "done", "result": result}),
ex=ttl)
def get_record(self, key: str) -> dict | None:
# 给重复请求用:取出这个 key 当前的处理状态和结果。
raw = r.get(f"idem:{key}")
return json.loads(raw) if raw else None
这套 begin / finish / get_record,就把"抢资格""记结果""查状态"三件事都备齐了。把它们串成一个完整的、能正确应对重复请求的支付流程,就是下一节的事——但在那之前,得先把几个真正会出事的工程坑说清楚。
六、工程坑:结果重放、失败回滚与幂等键过期
把上一节的 IdempotencyGuard 串成完整流程,关键在于对重复请求的不同状态,要分别对待:
guard = IdempotencyGuard()
def idempotent_pay(key: str, user_id: int,
order_id: str, amount: int) -> dict:
"""带幂等保护的支付,并正确处理失败回滚。"""
if not guard.begin(key):
# 没抢到首次资格 —— 这是个重复请求,看上次处理到哪了
record = guard.get_record(key)
if record and record["status"] == "done":
return record["result"] # 上次已完成:重放上次的结果
# 上次还在 processing:首个请求正在处理,别重复执行
return {"status": "processing", "msg": "请求处理中,请勿重复提交"}
# 抢到了首次资格,执行真正的业务
try:
deduct_balance(user_id, amount)
create_order(order_id, user_id, amount)
result = {"status": "ok", "order_id": order_id}
guard.finish(key, result) # 成功:把结果存回去,供重放
return result
except Exception:
# 关键:业务失败,必须删掉这条 processing 记录,
# 否则这个 key 会被永远卡在 processing,客户端再也重试不了。
r.delete(f"idem:{key}")
raise
这段代码里藏着几个不处理就出事的坑。坑 1:重复请求要"重放结果",不能只回一句"重复了"。如果第一次请求已完成,重复请求应该拿到和第一次一模一样的结果(上面的 return record["result"])——因为客户端发重试,往往是因为它没收到第一次的响应,它需要那个结果。只回一个"duplicate",客户端会一脸茫然。所以 finish 时一定要把结果存下来。
坑 2:业务失败了,必须清掉幂等记录。这是最容易漏的坑。如果 begin 抢到了资格,但业务执行抛异常失败了,而你不去删那条 processing 记录——那么这个幂等键就永远卡在 processing。客户端之后拿同一个键来正经重试,会被当成"处理中"一直挡在外面,这笔业务再也做不成了。所以失败路径上的 r.delete 绝不能少。
坑 3:幂等键的过期时间要权衡。幂等记录不能永久留存,否则 Redis(或那张表)会无限膨胀。但 TTL 也不能太短:它必须长于客户端所有可能的重试窗口。如果你的重试最长可能在 1 小时后发生,而幂等键 10 分钟就过期了,那 1 小时后的重试就会被当成"新请求",幂等失效。一般设成一天是个稳妥的起点。坑 4:幂等键要够"唯一"。必须用 UUID 这类全局唯一的生成方式;要是图省事用"用户 ID + 时间戳(秒)"凑一个,同一用户在同一秒内的两次真实独立操作,就会撞成同一个键,第二次会被误判成重复而吞掉。下面这张图,把一次带幂等保护的请求处理流程串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 重复请求的来源 | 用户手抖、前端超时重试、网关重发、消息重复投递,无法在源头消灭 |
| 前端防护不可靠 | 禁用按钮只挡得住手动连点,其余来源全部绕过,只能由服务端兜底 |
| 幂等性 | 同一操作执行多次,对系统状态的影响和执行一次完全相同 |
| 增量操作不幂等 | 扣款、插入、库存扣减这类带增量语义的操作,天生不幂等 |
| 幂等键 | 客户端发起操作前生成的全局唯一标识,重试时复用同一个键 |
| 键必须客户端生成 | 只有客户端知道哪几次发送是同一操作的重试,服务端生成分不清 |
| 唯一约束兜底 | 靠数据库 UNIQUE 约束,同一键插入第二次必然失败,绝对可靠 |
| Redis 占位与状态 | SET NX 抢首次资格,并记录 processing 或 done 状态与结果 |
| 结果重放 | 重复请求要返回和第一次相同的结果,因为客户端可能没收到首次响应 |
| 失败要清记录 | 业务失败必须删掉 processing 记录,否则键永久卡死无法重试 |
避坑清单
- 重复请求来源众多:用户手抖、前端超时重试、网关重发、消息重投,不可能在源头全部消灭。
- 前端禁用按钮只挡得住手动连点,超时重试和网关重发全部绕过,重复拦截只能由服务端做。
- 幂等性是让同一操作执行多次的效果等于执行一次,不是拒绝重复,而是重复来了也不出错。
- 扣款、插入、库存扣减这类带增量语义的操作天生不幂等,是幂等设计真正要对付的对象。
- 幂等键必须由客户端在发起操作前生成,重试时复用同一个键,服务端生成无法区分重复。
- 幂等键要用 UUID 这类全局唯一方式,别用用户 ID 加秒级时间戳,会把独立操作误判成重复。
- 数据库唯一约束最可靠,直接抢着 INSERT 靠成功失败判断,别先查后插留下并发竞态缝隙。
- 用 Redis 做幂等要记录 processing 和 done 状态,处理中的重复请求不能返回成功。
- 重复请求要重放第一次的结果,所以处理完成时必须把结果存下来,只回一句重复没用。
- 业务失败必须删掉 processing 幂等记录否则键永久卡死,幂等键 TTL 要长于所有重试窗口。
总结
回头看那次"用户点了两下、被扣两次钱"的事故,以及我后来在幂等上接连踩的坑,最该记住的不是某一段 Redis 代码,而是我动手前那个想当然的判断——"重复请求是个前端问题,让前端拦住就行"。这句话错在它把"重复请求",当成了一个可以从源头根除的异常。可它不是异常,它是分布式系统的底色。只要请求要跨网络传输,就会有超时;只要有超时,收到请求的一方就无法确定"对方到底收没收到我的回复",于是重试就成了唯一理性的选择;而重试,就意味着重复。这是一条逻辑上闭合的链,你堵不掉其中任何一环。所以幂等性想清楚的,正是这件事:既然重复无法避免,那就不要去避免它,而要让你的系统有能力坦然接住它——重复来了,认出来,然后什么坏事都不发生。
所以做幂等,真正的工程量不在"加个判断"那一下。那一下,你以为就是"查一下这个键有没有处理过"。但真正难的,全在这个判断的边边角角:这个键,该由谁来生成——客户端还是服务端?"查"和"记"是两步,并发下它们之间的缝怎么焊死?第一个请求还在处理中,重复请求该得到什么?第一个请求已经成功了,重复请求该拿到一句"重复了",还是该拿到和第一次一样的完整结果?第一个请求失败了,那条幂等记录该留着还是该删掉?这每一个问题答错一个,你的幂等就有一个漏洞。这篇文章的几节,其实就是顺着这些问题展开的:先想清楚重复为什么防不住、幂等的本质是什么,再看幂等键这个唯一身份,然后是唯一约束、Redis 这两条实现主干,最后是结果重放、失败回滚、键过期这几个把幂等真正做对的工程细节。
你会发现,幂等的思路,和现实世界里处理"怕重复"的事情完全相通。你去银行办一笔转账,柜员会给你一个业务流水号;万一系统卡了、你不确定转成没成,你拿着这个流水号去问,柜员一查就知道"这笔已经办过了",不会让你再转一次——那个流水号,就是幂等键。你网购,同一个订单你刷新了好几次付款页,但订单号始终是那一个,系统认订单号,不会因为你多刷几次就多发几件货。这些设计背后是同一个朴素的智慧:对于"做一次"和"不小心做两次"后果天差地别的事,一定要给这件事一个唯一的名字,然后认名字、不认次数。幂等键,就是你给每一次操作起的那个"唯一的名字"。
最后想说,幂等做没做扎实,差距永远不会在开发期暴露——开发时你点一下按钮、发一个请求,有没有幂等保护,功能跑起来一模一样。它只在真实的、有网络抖动、有超时重试、有海量并发的生产环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会像我一样,在某个清晨被客服的投诉叫醒——有用户被重复扣了款,有人收到了两件只买了一件的货,你的对账系统对不上,你得一笔一笔人工去退、去赔,还要赔上用户的信任。而做对了,它会安安静静地、不被任何人察觉地,把那些因为超时、因为重试、因为网关重发而重复打进来的请求,一个一个地认出来、接住、化解掉——用户重复点了五次支付,他的钱只被扣了一次,他甚至不知道后台默默挡掉了四次。所以别等重复扣款的投诉找上门,在你写下第一个"会扣钱、会建单、会改库存"的写接口时就该想清楚:这个操作,如果被调用两次,会发生什么?我的客户端,会带幂等键来吗?重复请求来了,我认得出吗?它正在处理中、已经完成、或者刚刚失败,我分别该怎么回?这几个问题都有了答案,你的接口才不只是开发库里那个点一下就成功的样子,而是一个无论被网络重复轰炸多少遍,都能稳稳地只做一次、不多不少的可靠系统。
—— 别看了 · 2026