2019 年我做一个电商的下单支付接口。逻辑很直白:用户点"提交订单",后端创建一条订单记录,从用户余额里扣掉对应的钱,返回订单号。本地测试一切正常,上线后大部分时间也好好的。直到有一天,客服转来一个投诉:一个用户说他只下了一单,却被扣了两次钱,账户里平白少了一笔。我查日志,发现这个用户的下单接口,在几秒内被调用了两次,参数一模一样,于是数据库里多了两条几乎相同的订单,扣款也执行了两遍。我第一反应是"用户手抖点了两次"。可继续查下去,发现根本不是——很多重复请求,用户只点了一次。真正的原因是:那天网络有点慢,用户点提交后,前端等了几秒没收到响应,前端的代码自作主张地判定"这次请求失败了",于是自动重试,又发了一个一模一样的请求。可实际上,第一个请求后端早就处理成功了,只是响应在回程的路上慢了。结果就是:后端收到了两个请求,老老实实地处理了两遍。我当时的认知是:"一个用户操作,就对应一个请求;我的接口收到请求,处理一次,就完事了。"后来复盘我才彻底想明白,这个认知错得很彻底。在真实的网络环境里,"一个操作只到达一次"是一个不成立的幻想。前端会因为超时而重试、网关会因为没收到响应而重试、消息队列会重复投递、用户会手抖多点几下——同一个操作,它的请求会以各种方式,到达你的服务不止一次。而我的接口,默认每个请求都是"一次新的操作",来一个就处理一个——这在重复请求面前,就是重复下单、重复扣款。真正的解法,不是去想方设法"保证请求只到一次"(这做不到),而是让接口具备一种能力:同一个操作,无论它的请求到达多少次,产生的效果,都和只执行一次完全一样。这个能力,叫幂等。我以为幂等不过是"处理前先查一下有没有重复",结果真做下来坑一个接一个:查了再处理中间有并发、幂等键谁来生成、幂等记录无限膨胀……那次之后我才认真把幂等从头搞明白。这篇文章就把它梳理一遍:为什么请求会重复到达、幂等的本质是什么、幂等键怎么标识同一个操作、怎么用唯一索引和状态机实现幂等,以及幂等键生成、幂等记录过期、结果缓存这些把幂等真正做对要避开的坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个下单支付接口,用户只操作了一次,却因为前端超时重试,发来了两个一模一样的请求。后端把这两个请求当成两次独立操作,各自处理了一遍——创建了两条订单,扣了两次款。
我当时的错误认知:"一个用户操作对应一个请求,接口收到请求处理一次,就完事了。"
真相:在网络环境下,"一个操作只到达一次"根本不成立。前端重试、网关重试、消息队列重复投递、用户重复点击,都会让同一个操作的请求到达服务多次。你无法阻止重复请求的产生。正确的做法,是让接口幂等:同一个操作,无论它的请求到达几次,最终产生的效果,都和只执行一次完全相同。重复的请求要么被识别出来直接返回上次的结果,要么被安全地忽略——绝不能被当成一次新操作再执行一遍。
要把幂等做对,需要几块认知:
- 为什么请求一定会重复到达,你为什么挡不住它;
- 幂等到底是什么,它和"拒绝重复请求"有什么不同;
- 幂等键怎么标识"这是同一个操作";
- 怎么用数据库唯一索引、状态机来实现幂等;
- 幂等键生成、幂等记录过期、结果缓存这些工程坑怎么处理。
一、为什么请求会重复到达
先把这件最根本的事钉死:你无法保证一个请求只到达一次。
原因在于网络的不确定性。一个请求发出去,迟迟没有响应——这时候,发送方无法分辨到底是哪种情况:是请求根本没送到?是送到了、服务端正在处理?还是处理完了、只是响应在回来的路上丢了或慢了?发送方区分不了,而它又必须做个决定,于是绝大多数的选择是:重试。重试是对的——可一旦原请求其实已经处理成功,这次重试就制造了一个重复。下面这段代码,就是默认"请求只来一次"的下单接口:
def create_order(user_id: int, product_id: int, amount: int):
# 反面教材:默认"一个请求 = 一次新操作",来一个就处理一个。
order_id = db_insert_order(user_id, product_id, amount)
deduct_balance(user_id, amount) # 从用户余额里扣款
return order_id
# 问题:如果前端因超时而重试,这个函数会被调用两次 ——
# 于是 db_insert_order 跑了两遍、deduct_balance 也跑了两遍,
# 创建了两条订单,扣了两次钱。
这段代码的错,不在某一行语法,在它的根本假设:它假设"每次被调用,都是一次全新的操作"。这个假设在本地单机、手动测试时永远成立,所以它能骗过你所有的测试。可一旦上了真实网络,重试、重投递、重复点击就会让同一个操作反复触发这个函数。会重复到达,是网络的既定事实;你能改变的,只有"接口面对重复时的反应"。
二、幂等的本质:执行多次等于执行一次
把上一节的事实接受下来,幂等要解决的问题就清晰了。
幂等(Idempotent)这个词来自数学:一个操作执行一次,和执行很多次,结果完全相同。把它放到接口上:一个幂等的接口,同一个操作的请求无论来多少次,系统的最终状态、以及返回给调用方的结果,都和只处理了一次一模一样。
这里有个容易混淆的点:幂等不等于"拒绝重复请求"。幂等不要求你识别出重复后报错——恰恰相反,一个体验好的幂等接口,面对重复请求时,会平静地返回第一次处理的那个结果,调用方甚至感觉不到自己重复了。关键是"效果只发生一次",而不是"第二个请求收到一个错误"。
还要注意:不是所有操作都需要你额外做幂等。查询天然幂等——查一次和查十次,数据不会变;删除也基本天然幂等——删一次和删十次,最终状态都是"已删除"。真正危险、真正需要你动手的,是"创建"(重复创建出多条记录)和带副作用的更新(比如"余额减 100",执行两次就多扣了)。要让这些操作幂等,第一步是:得有办法认出"两个请求是不是同一个操作"。
三、幂等键:用一个唯一标识认出"同一个操作"
怎么判断到达的两个请求"是不是同一个操作"?光靠参数一样不行——一个用户真的连买两件同样的商品,参数也会一样,那是两次合法操作。要区分,得有一个明确的标识:幂等键(Idempotency Key)。
幂等键的核心规则是:它由客户端在"操作发起的那一刻"生成一个全局唯一的值;而这个操作如果要重试,必须复用同一个幂等键。
import uuid
def new_idempotency_key() -> str:
"""为一次操作生成一个全局唯一的幂等键。
铁律:同一次操作的所有重试,必须复用这同一个 key。"""
return uuid.uuid4().hex
这条铁律——"重试复用同一个 key"——是整个幂等机制的地基。所以幂等键不能在每次发请求时才生成,那样每次重试都是新 key,服务端永远认不出重复。它必须在"用户点下提交按钮"那一刻生成一次,然后被这次操作的所有重试共享:
def submit_order_with_retry(order_data: dict, max_retry: int = 3):
"""客户端:为这次操作只生成一次幂等键,重试时一直复用它。"""
idem_key = new_idempotency_key() # 只在这里、只生成这一次
for attempt in range(max_retry):
try:
return http_post("/orders", order_data,
headers={"Idempotency-Key": idem_key})
except NetworkTimeout:
# 超时重试:带的还是【同一个】 idem_key,
# 服务端才能认出"这是同一笔操作的重试,不是新订单"
continue
raise SubmitFailed("下单失败,请稍后再试")
注意 idem_key 是在 for 循环外面生成的——这是刻意的。三次重试,带的是同一个 key。服务端只要在收到请求时,拿这个 key 去问一句"这个操作我处理过没",就能认出重复。那么服务端这一问,怎么问得又准又并发安全?
四、用唯一索引实现幂等:让数据库挡住重复
服务端最朴素的想法是:收到请求,先拿 idem_key 查一下数据库"这个操作有没有处理过",没有就处理。但这里藏着一个并发陷阱:两个重复请求几乎同时到达,它们同时执行"查一下"——此刻数据库里还没有记录,于是两个请求都查到"没处理过",都往下走,都创建了订单。"先查再处理"这两步之间有缝,并发会从这条缝里钻进去。
堵住这条缝的,是数据库的唯一索引。给订单表的 idem_key 字段建一个唯一索引,数据库就会强制保证:这个 key 最多只能有一条记录。两个并发请求都来插入,数据库只会让一个成功,另一个必然因为唯一约束冲突而失败——这个"只让一个成功",是数据库在底层用锁保证的,没有缝。
import pymysql
def create_order_idem(idem_key: str, user_id: int,
product_id: int, amount: int):
"""用唯一索引实现幂等:幂等键重复时,数据库会拒绝第二次插入。"""
# 先查一次:绝大多数重复请求,在这里就能返回旧结果(快路径)
existing = db_get_order_by_key(idem_key)
if existing is not None:
return existing["order_id"]
try:
with db_transaction(): # 下单和扣款,放进同一个事务
# orders 表的 idem_key 字段上,建了唯一索引
order_id = db_insert_order_with_key(
idem_key, user_id, product_id, amount)
deduct_balance(user_id, amount)
return order_id
except pymysql.err.IntegrityError:
# 并发兜底:两个请求都越过了上面的查询,但唯一索引保证
# 只有一个能插入成功,另一个落到这里,返回那个成功的结果
return db_get_order_by_key(idem_key)["order_id"]
这段代码有两道防线。第一道是开头那次 db_get_order_by_key 查询——它是快路径,绝大多数重复请求(原请求早就处理完了)在这里就直接返回旧结果了。第二道是 IntegrityError 那个 except——它是并发兜底,专门接住那种"两个请求同时越过了第一道查询"的极端情况。靠唯一索引,这第二道防线并发安全。还要注意 db_transaction():下单和扣款必须在同一个事务里,要么都成,要么都不成,不能出现"订单建了、钱没扣"的半截状态。
五、用状态机处理"更新"类操作的幂等
唯一索引解决的是"创建"类操作的幂等。但还有一类操作——状态变更,比如"支付一个订单",它不是创建新记录,而是把一条已有记录从一个状态改到另一个状态。这类操作的幂等,要靠状态机。
一个订单有它的状态流转:未支付 → 已支付。"支付"这个操作的本质,就是把订单从未支付推到已支付。幂等的关键洞察是:这个状态推进,只应该、也只能发生一次。实现它,用一条带状态条件的 UPDATE:
def pay_order(order_id: int):
"""用状态机实现幂等:带状态条件的 UPDATE,只有第一次能改成功。"""
# 对应 SQL:UPDATE orders SET status='paid'
# WHERE id = ? AND status = 'unpaid'
affected = db_update_order_status(
order_id, from_status="unpaid", to_status="paid")
if affected == 1:
# 影响行数为 1:这次调用真正完成了状态推进
do_real_payment(order_id) # 执行真正的支付动作
return "支付成功"
# 影响行数为 0:订单已不在 unpaid 状态,说明早被支付过了
return "订单已支付,请勿重复操作"
这段代码的精髓,全在 WHERE id = ? AND status = 'unpaid' 那个状态条件。数据库执行 UPDATE 时是原子的:并发的两个支付请求同时打来,数据库会让它们排队执行这条 UPDATE——第一个执行时,订单是 unpaid,条件满足,改成 paid,影响 1 行;第二个执行时,订单已经是 paid 了,status = 'unpaid' 这个条件不再满足,UPDATE 影响 0 行。于是靠 affected 是 1 还是 0,就能干净利落地分辨出"这次是真正生效的支付"还是"一个重复请求"。
如果一个操作既不好套唯一索引、也不好用状态条件,还有一个更通用的兜底——用分布式锁:让同一个 idem_key 的请求,同一时刻只有一个能进入处理逻辑。
import redis
r = redis.Redis()
def with_idem_lock(idem_key: str, work):
"""通用兜底:用分布式锁,让同一操作同一时刻只被一个请求处理。"""
lock_key = f"idem_lock:{idem_key}"
# SET NX:只有第一个请求能拿到这把锁
got = r.set(lock_key, "1", nx=True, ex=30)
if not got:
# 同一操作的另一个请求正在处理中 —— 别也冲进去
raise DuplicateInFlight("操作处理中,请勿重复提交")
try:
return work()
finally:
r.delete(lock_key) # 处理完,释放锁
六、工程坑:幂等键生成、记录过期与结果缓存
核心机制都有了,但要把幂等真正做对,还有几个绕不开的工程坑。
坑 1:幂等键必须在操作源头生成,且重试复用。这一点第三节强调过,但它实在太关键、也太容易做错,值得再钉一遍:如果幂等键是在每次发请求前才生成的,那每次重试都是一个新 key,服务端永远把它们看成不同操作,幂等机制整个失效。幂等键要么由客户端在"用户触发操作"那一刻生成一次,要么由一个更靠前的网关统一生成——总之,它必须绑定到"操作",而不是绑定到"请求"。
坑 2:重复请求不只要"不重复执行",最好返回和首次一样的结果。一个重复的下单请求打来,你不能只是"忽略"它、回它一个空或一个错——调用方会困惑。理想的做法是:把第一次执行的结果也存进幂等记录里;重复请求来时,直接把那个结果原样返回。调用方拿到的,和它第一次该拿到的一模一样。
import json
def execute_idempotent(idem_key: str, work):
"""完整的幂等执行:首次执行并缓存结果,重复请求直接返回首次结果。"""
cached = r.get(f"idem_result:{idem_key}")
if cached is not None:
return json.loads(cached) # 命中:返回首次执行的结果
result = work() # 首次执行,拿到结果
# 把结果缓存起来,过期时间要【足够长】,覆盖客户端可能的最长重试窗口
r.setex(f"idem_result:{idem_key}", 7 * 24 * 3600,
json.dumps(result, ensure_ascii=False))
return result
坑 3:幂等记录会无限增长,要能清理。每一个操作都会留下一条幂等记录。日积月累,这张幂等记录表会变得极其庞大。要定期清理掉足够旧的记录。但"足够旧"这个度要把握好——清理的阈值,必须大于客户端可能重试的最长时间窗口,否则你刚清掉一条记录,一个迟到的重试就又把它当成新操作了。
import time
def cleanup_expired_idem_records(days: int = 7):
"""定期清理过期幂等记录,否则记录表会无限膨胀。"""
# 阈值(这里 7 天)必须远大于客户端可能的最长重试间隔
cutoff = time.time() - days * 24 * 3600
deleted = db_delete_idem_records_before(cutoff)
print(f"清理了 {deleted} 条 {days} 天前的幂等记录")
return deleted
坑 4:分清哪些操作天然幂等,别过度设计。查询(GET)天然幂等,删除基本天然幂等,这些不用额外做。但要警惕"相对更新"——像 余额 = 余额 - 100 这种,执行两次就真的多扣了 100,它绝不幂等,必须用幂等键保护;如果能改写成绝对赋值(比如"把状态设为已支付"),那它自身就幂等了。下面这张图,把一个带幂等键的请求的完整处理路径串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 请求重复到达 | 前端重试、网关重试、MQ 重投、用户重复点击,同一操作必然到达多次 |
| 幂等 | 同一操作的请求无论来多少次,最终效果都和只执行一次完全相同 |
| 幂等不等于拒绝重复 | 幂等是让效果只发生一次,重复请求最好平静返回首次结果而非报错 |
| 幂等键 | 客户端在操作源头生成的唯一标识,重试必须复用同一个 key |
| 唯一索引 | 给幂等键字段建唯一索引,数据库强制保证并发下只有一个请求插入成功 |
| 先查再兜底 | 先查询走快路径,唯一索引冲突做并发兜底,两道防线缺一不可 |
| 状态机幂等 | 带状态条件的 UPDATE,靠影响行数 1 或 0 分辨真正生效还是重复请求 |
| 分布式锁兜底 | 同一幂等键同一时刻只放一个请求进处理逻辑,通用但较重 |
| 结果缓存 | 缓存首次执行结果,重复请求直接返回它,过期时间要覆盖最长重试窗口 |
| 相对更新不幂等 | 余额减 100 这类相对更新重复执行就出错,要么幂等键保护要么改绝对赋值 |
避坑清单
- 网络的不确定性让"请求只到一次"成为幻想,前端重试、网关重试、MQ 重投都会制造重复请求。
- 你挡不住重复请求的产生,能改变的只有接口面对重复时的反应——必须把接口设计成幂等的。
- 幂等是同一操作执行多次效果等于一次,不等于拒绝重复;重复请求最好平静返回首次的结果。
- 查询、删除天然幂等,真正要做幂等的是"创建"和带副作用的"更新",别给天然幂等的操作过度设计。
- 幂等键必须由客户端在操作源头生成一次,所有重试复用同一个 key,否则服务端认不出重复。
- "先查再处理"两步之间有并发缝隙,两个请求会同时查到"没处理过",必须用唯一索引堵死。
- 唯一索引方案要两道防线:先查询走快路径,再用唯一约束冲突异常做并发兜底,返回已成功的结果。
- 创建和扣款等多步操作要放进同一事务,避免出现订单建了钱没扣这种半截状态。
- 状态变更类操作用带状态条件的 UPDATE,靠影响行数 1 或 0 干净地分辨生效与重复。
- 缓存首次结果让重复请求拿到一致返回;幂等记录要定期清理,清理阈值须大于最长重试窗口。
总结
回头看那次"网络重试把用户扣款扣了两次"的事故,以及我后来在幂等上接连踩的坑,最该记住的不是某一种实现手段,而是我动手前那个想当然的判断——"一个用户操作,就对应一个请求"。这句话错在它把"操作"和"请求"划了等号。可它们根本不是一回事:"操作"是用户意图层面的——用户想下一单;"请求"是网络传输层面的——为了把这一个意图可靠地送达,网络里可能跑了好几个一模一样的包。一个操作,对应的是一到多个请求。承认这个"一对多",是幂等设计的起点;而幂等做的事,就是在服务端这一侧,把这"多个请求",重新收敛回那一个操作。
所以做幂等,真正的工程量不在"处理前先查一下"那一下。一个 if 已存在: return 谁都会写,它在 Demo 里、在你手动测试时也确实拦得住。真正的工程量在那些魔鬼细节里:你"查一下"和"再处理"这两步之间的那条缝,并发请求会不会从里面钻进去?你的幂等键,是绑在"操作"上、还是不小心绑在了"请求"上,导致每次重试都成了新 key?重复请求被你识别出来之后,你是回它一个让人困惑的错误,还是体面地返回它本该拿到的结果?你的幂等记录,会不会一直涨到把库撑爆?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚请求为什么必然重复,再看幂等的本质,然后是幂等键、唯一索引、状态机这三段主干,最后是幂等键生成、记录过期、结果缓存这几个把幂等真正做对的工程细节。
你会发现,幂等的思路和我们生活里处理"同一件事被反复确认"的经验都是相通的。你给朋友转账,转完不放心,过一会儿又点了一次——一个设计良好的支付 App,会告诉你"该笔订单已支付",而不是真的再扣你一笔。你网购时网络卡了,连点了三下"提交",最后到货的还是一件商品,而不是三件。它们背后是同一件事:系统承认"人和网络都会重复",于是它自己负责把重复的动作,认出来、合并掉,只让真实的意图生效一次。一个幂等的接口,就是在替它的调用方,扛下"重复"这件必然会发生的麻烦事。
最后想说,幂等做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你手动点一次提交,一个请求干干净净地来、干干净净地处理,有没有幂等跑起来一模一样。它只在真实的网络抖动、真实的客户端重试、真实的高并发面前才显形。那时候它会用最难堪的方式给你结账:一个用户因为网络慢多等了两秒,就被你扣了两次钱,然后是客服、是投诉、是退款、是对账时怎么也对不平的那一笔账;一条消息被消息队列重复投递,你的库存就被多扣了一次,直到月底盘点才发现对不上。所以别等用户拿着两笔扣款记录来找你,在你写下第一个有副作用的接口时就该想清楚:这个操作的请求,会不会重复到达?重复到达时,我认得出来吗?认出来之后,我是安全地合并掉,还是又老老实实执行了一遍?这几个问题都有了答案,你的接口才不只是 Demo 里那个单请求跑得通的样子,而是一个无论上游怎么重试、网络怎么抖动,都能让每一个真实操作不多不少只生效一次的可靠系统。
—— 别看了 · 2026