2023 年我负责一个支付下单接口。逻辑很直接:用户点"确认支付",前端调我的接口,我扣余额、生成订单、返回成功。测试一路绿,上线后平稳跑了几个月。直到有一天,客服转来一个投诉:一个用户说他只买了一次东西,余额却被扣了两次,订单列表里赫然躺着两笔一模一样的订单。我第一反应是"不可能,我的接口逻辑没问题"。翻日志才发现:那个用户的请求,在我的服务器上确确实实被完整执行了两次——两次扣款、两次建单,每一次都"成功"了。问题不在我的逻辑,在我的逻辑之外:用户网络抖了一下,第一次请求其实已经到达服务器、也处理完了,但响应在回程的路上丢了;前端等不到响应、超时,于是自动重发了一次;第二次请求又被我老老实实地执行了一遍。我当时的认知是"我的接口只要自己不写 bug 就不会重复扣款",而真相是——在分布式系统里,一个请求被发送多次,是常态,不是异常。网络会抖、客户端会重试、负载均衡会重发、消息队列会重复投递,你根本没法保证"一个操作只到达一次"。你能做的、也必须做的,是让你的接口具备一种能力:同一个操作,无论被执行多少次,产生的结果和只执行一次完全一样。这种能力,叫幂等性。我以为幂等不过是"操作前先查一下,查到了就不做",结果真做下来坑一个接一个:查和做之间有个缝,并发请求会同时查到"没有"然后都去做;就算用了数据库唯一约束,捕获到冲突之后该返回什么也是个问题;用 Redis 做幂等键,键怎么生成、结果怎么存、第二次请求来了拿什么返回……那次之后我才认真把幂等性从头搞明白。这篇文章就把它梳理一遍:为什么必须做幂等、"先查再做"为什么不行、唯一约束怎么用、幂等键怎么设计、状态机怎么兜底,以及把幂等真正做进生产要避开的那些坑。
问题背景
先把那次事故的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:支付下单接口在用户网络抖动、前端超时重发的情况下,被完整执行了两次,造成重复扣款、重复建单——而且两次都返回"成功",接口自身的日志里看不出任何异常。
我当时的错误认知:"我的接口逻辑自己没 bug,就不会重复处理一个请求;就算要防,操作前先查一下,查到记录就跳过,不就行了。"
真相:在分布式系统里,同一个请求被发送多次是常态——网络重传、客户端超时重试、负载均衡重发、消息队列重复投递,任何一个环节都会让你的接口收到重复请求。你无法消除重复,只能让接口对重复"免疫":这就是幂等性——同一操作执行 N 次,效果和执行 1 次完全相同。而"先查再做"这种朴素方案在并发下会失效,因为"查"和"做"不是一个原子操作,中间的缝隙会被并发请求钻进来。真正可靠的幂等,要靠数据库唯一约束、幂等键、状态机这些有原子性保证的机制。
要把幂等做稳,需要几块认知:
- 为什么分布式系统里重复请求是无法避免的常态;
- 为什么"先查再做"在并发下一定会失效;
- 怎么用数据库唯一约束把幂等性交给数据库强制保证;
- 幂等键怎么设计、第二次请求来了该返回什么;
- 状态机、幂等键生成、并发等待这些工程坑怎么处理。
一、为什么需要幂等:重试是分布式系统的常态
先想清楚一件事:重复请求不是 bug,是分布式系统的物理常态。
一个请求从客户端出发,要经过网络、负载均衡,才到你的服务,处理完还要原路把响应送回去。这条链路上每一跳都可能丢包。而最关键的一点是:当客户端迟迟收不到响应,它面对的是两种它无法区分的可能——请求根本没送达,或者请求送达了、也处理完了、只是响应在回来的路上丢了。客户端没有上帝视角,它分不清这两者,于是它只能赌一把:重发。下面这个接口,就是完全没有为这种重发做任何准备的样子:
from fastapi import FastAPI
app = FastAPI()
@app.post("/pay")
def pay(user_id: int, order_amount: int):
# 反面教材:接口对"同一个请求被发来两次"毫无防备。
# 它老老实实地执行收到的每一个请求 —— 用户网络抖一下、
# 前端超时重发一次,这段代码就会把余额扣两次、订单建两份,
# 而且两次都"成功"。问题不在这段逻辑本身,在它之外:
# 它默认了"一个请求只会到达一次",而这个前提根本不成立。
balance = db_get_balance(user_id)
db_set_balance(user_id, balance - order_amount)
order_id = db_insert_order(user_id, order_amount)
return {"ok": True, "order_id": order_id}
这段代码本身找不出一行 bug,它忠实地完成了"扣款、建单"。它的问题是一个隐含的假设:它假设自己收到的每一个请求都代表一次独立的、用户真实意图的操作。可超时重发的那个请求,代表的根本不是"用户想再买一次",而是"用户不确定上一次成没成"。接口分不出这个区别,就只能把它当成新订单。要解决问题,就得让接口具备一种能力:认出"这个请求我已经处理过了"。这正是后面所有机制要做的事。
二、为什么"先查再做"不行:check-then-act 的陷阱
想到要防重复,大多数人的第一反应——我在做之前先查一下,查到就不做了。这个想法对了一半,但实现方式几乎一定是错的。
@app.post("/pay")
def pay(user_id: int, order_no: str, amount: int):
# 反面教材:先查一下有没有处理过,再决定做不做。
# 看起来很合理,但"查"和"做"是两步、不是一个原子操作。
existing = db_find_order(order_no) # 第 1 步:查
if existing:
return {"ok": True, "order_id": existing["id"]}
# —— 致命的缝隙就在这里 ——
# 两个并发的重复请求会【同时】走到这行:它们都在第 1 步
# 查到了"没有",于是都认为自己是第一个,都往下走去扣款。
order_id = db_insert_order(user_id, order_no, amount) # 第 2 步:做
db_decrease_balance(user_id, amount)
return {"ok": True, "order_id": order_id}
这段代码的逻辑漏洞,有个专门的名字叫 TOCTOU(Time-of-check to time-of-use):你检查的那一刻("没有这个订单")和你使用这个检查结果去操作的那一刻,中间隔着一段时间。在单线程、请求一个一个排队到达时,这段代码确实有效——这恰恰是它最坑的地方:它在你本地测试、在低并发时一切正常,你会以为它对了。它只在两个重复请求几乎同时到达时才暴露:两个请求前后脚进来,都执行第 1 步,都查到"没有",都觉得自己是第一个,然后都往下走去建单扣款。
而"两个重复请求几乎同时到达",恰恰是网络重试最典型的场景:客户端超时重发时,原来那个慢请求和新发的重试请求,往往就是前后脚一起到的。所以"先查再做"不是"大部分情况下能用、偶尔出问题",而是专门在它最该生效的那个场景下失效。要堵住这个缝,核心思路只有一个:把"检查是否重复"和"执行操作"合并成一个不可分割的原子操作。下面三节的三种机制——唯一约束、幂等键、状态机——本质上都是在用不同的手段做这同一件事。
三、唯一约束:把幂等性交给数据库
第一种、也是最稳的机制:让数据库替你保证唯一。数据库的唯一约束(unique constraint),天生就是一个原子的"检查+插入"——它在插入一行时检查唯一列有没有重复,这两件事由数据库的锁机制保证是一体的、并发安全的。
用法是:给订单表里那个能唯一标识一笔业务的列(订单号)加上唯一约束。这个订单号由客户端在发起请求时生成,同一笔业务它永远不变:
-- 给订单表的"业务唯一标识"加唯一约束。
-- order_no 由客户端在发起请求时生成,同一笔业务它永远不变。
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
amount INT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'CREATED',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_order_no (order_no) -- 关键:同一 order_no 只能插入一次
);
有了这个约束,插入订单的代码就变成"尽管插,冲突了说明重复":
import pymysql
def create_order(conn, order_no: str, user_id: int, amount: int) -> dict:
"""靠唯一约束实现幂等:重复的 order_no 插入会被数据库直接拒绝。"""
try:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO orders (order_no, user_id, amount) "
"VALUES (%s, %s, %s)",
(order_no, user_id, amount),
)
conn.commit()
return {"ok": True, "duplicated": False}
except pymysql.err.IntegrityError:
# 唯一约束冲突 —— 说明这个 order_no 之前已经成功插入过。
# 这【不是错误】,恰恰证明幂等生效了:第二次请求被数据库挡下。
conn.rollback()
return {"ok": True, "duplicated": True}
这里的关键,是它彻底绕开了第二节那个 TOCTOU 缝隙:你不再"先查再插",而是直接插,把"检查重复"这件事完全交给数据库,让它在插入的那一瞬间原子地完成。并发的两个 INSERT 打过来,数据库只会让一个成功,另一个必然抛 IntegrityError——没有任何缝隙可钻。
注意捕获到 IntegrityError 时,代码返回的是 {"ok": True} 而不是一个错误。这是幂等思维里一个重要的转变:重复本身不是失败,它说明幂等生效了,第二次请求被正确地挡下了。但唯一约束有它管不到的地方:它只能保证"orders 这张表里 order_no 不重复"。可一笔支付往往不止插一条订单——还要扣余额、记流水、发消息通知。这一整组操作的幂等,单靠一个表的唯一约束保证不了。要让"一整组操作"作为一个整体幂等,需要一个更上层的机制。
四、幂等键:为每个请求发一张"身份证"
幂等键(idempotency key)的思路是:让客户端在发起请求时,为这一次"业务操作"生成一个全局唯一的 ID,放进请求里带过来。服务端拿这个 key 做文章——第一次见到它,正常处理、并把 key 连同结果记下来;再次见到同一个 key,直接把上次的结果返回,根本不重新执行。
这里有个绝对不能错的细节:幂等键必须由客户端生成,而且在重试时保持不变。客户端因为超时而重发的那个请求,带的必须是同一个 key——服务端正是靠这个"不变的 key"认出"这是同一笔操作的重试":
import uuid
# 幂等键由【客户端】生成:用户点一次"确认支付",就生成一个 key,
# 并把它"钉死"在这次操作上 —— 后续因超时发起的每一次重试,
# 都必须复用这同一个 key,服务端才能认出"这是同一笔操作"。
def start_payment(http, user_id, amount):
idem_key = str(uuid.uuid4()) # 一次操作 = 一个 key
for attempt in range(3): # 失败重试,但 key 始终不变
resp = http.post("/pay",
json={"user_id": user_id, "amount": amount},
headers={"Idempotency-Key": idem_key})
if resp.status_code == 200:
return resp.json()
raise RuntimeError("支付请求多次重试仍失败")
服务端拿到这个 key 后,用 Redis 的 SETNX(SET if Not eXists)来识别"谁是第一个":
import json
import redis
r = redis.Redis()
def pay_idempotent(idem_key: str, user_id: int, amount: int) -> dict:
cache_key = f"idem:pay:{idem_key}"
# SET NX:只有 key 不存在时才设置成功 —— 这一步是【原子】的,
# 并发的重复请求里,只有一个能拿到 True,它才是"第一个"。
first = r.set(cache_key, "PROCESSING", nx=True, ex=600)
if not first:
# 不是第一个:这个 key 处理过(或正在处理),取上次的结果
cached = r.get(cache_key)
if cached == b"PROCESSING":
return {"ok": False, "msg": "请求处理中,请勿重复提交"}
return json.loads(cached)
# 是第一个:真正执行业务,然后把结果写回这个 key
result = do_real_payment(user_id, amount)
r.set(cache_key, json.dumps(result), ex=600)
return result
SETNX 的原子性,正是这套机制的命门所在:它把"检查 key 在不在"和"占住这个 key"合成了一个原子动作,从根上消除了第二节那个 check-then-act 缝隙——并发的多个请求同时 SETNX,Redis 保证只有一个返回成功。这个 key 在 Redis 里有三种状态:不存在(从没处理过)、值为 PROCESSING(占位中,第一个请求正在处理)、值为结果 JSON(已处理完)。
注意 PROCESSING 这个中间态:第一个请求占住 key 之后、到写回结果之前,有一个时间窗。如果第二个请求恰好在这个窗里进来,它拿到的就是 PROCESSING——这时它既不能返回旧结果(还没有),也不能去执行(会重复),正确的处理是告诉客户端"处理中,稍后再来"。这个中间态怎么处理得更稳,第六节会专门讲。
五、状态机:让操作只能单向流动
唯一约束和幂等键,防的是"同一个请求来两次"。但还有一类重复更隐蔽:不同的请求,试图把一个对象推向它不该再去的状态。比如一个订单已经是"已支付",又来一个"支付"请求;一个订单已经"已退款",又来一个"退款"请求。这两个请求可能带着不同的幂等键,前面两种机制都拦不住它们。这时候要靠状态机。
状态机的做法是:明确定义这个对象有哪些状态、以及"从哪个状态允许走到哪些状态"。任何不在这张表里的跳转,都是非法的:
# 订单状态机:明确定义"从哪个状态,允许走到哪些状态"。
# 任何不在这张表里的跳转,都是非法的、要被拒绝的。
ORDER_TRANSITIONS = {
"CREATED": {"PAID", "CANCELLED"},
"PAID": {"REFUNDED", "SHIPPED"},
"SHIPPED": {"COMPLETED"},
"REFUNDED": set(), # 终态:退款后不能再去任何状态
"COMPLETED": set(), # 终态
"CANCELLED": set(), # 终态
}
def can_transit(current: str, target: str) -> bool:
"""当前状态是否允许流转到目标状态。"""
return target in ORDER_TRANSITIONS.get(current, set())
但光有这张表还不够——真正实现幂等的,是把状态判断嵌进那条 UPDATE 语句里。不要"先查状态、再改状态"(那又是 TOCTOU),而要用一条带状态条件的 UPDATE:
def pay_order(conn, order_no: str) -> dict:
"""用带状态条件的 UPDATE 做幂等:已支付的订单再支付,影响 0 行。"""
with conn.cursor() as cur:
# 关键:WHERE 里带上"当前必须是 CREATED"这个条件。
# 数据库的行锁保证这条 UPDATE 是原子的 —— 并发的两个
# "支付"请求,只有一个能把 CREATED 改成 PAID,
# 另一个的 WHERE 条件不再成立,影响行数为 0。
cur.execute(
"UPDATE orders SET status = 'PAID' "
"WHERE order_no = %s AND status = 'CREATED'",
(order_no,),
)
affected = cur.rowcount
conn.commit()
if affected == 1:
return {"ok": True, "msg": "支付成功"}
return {"ok": True, "msg": "订单已是支付状态,无需重复支付"}
这条 UPDATE ... WHERE status = 'CREATED' 又是一个原子的"检查+执行":检查(status 是不是 CREATED)和执行(改成 PAID)写在同一条 SQL 里,由数据库行锁保证。看 rowcount 就知道结果——是 1,说明这次真的改动了,是这次请求把订单支付的;是 0,说明它早就不是 CREATED 了(已经被支付过),这次请求是个重复。这个模式叫 CAS(compare-and-swap,比较并交换),也常被称作"乐观锁",在状态流转的幂等上极其常用。
状态机和前两种机制并不冲突,它们各管一段:唯一约束、幂等键防的是"同一次提交的物理重复",状态机防的是"对同一对象的逻辑重复操作"。一个健壮的支付系统,这三者往往是叠加使用的——幂等键挡住超时重发,唯一约束兜底订单不重建,状态机保证一笔订单不会被支付两次或退款两次。
六、工程坑:幂等键生成、结果返回、失败清理
机制都对了,但要把幂等真正做进生产,还有几个绕不开的工程细节。
坑 1:幂等键必须绑定到"业务操作",不能每次请求新生成。如果你在每次 HTTP 请求发出时才生成 key,那重试时生成的就是一个新 key,服务端根本认不出这是重复——等于白做。正确的做法是:key 跟着"用户的一次意图"走。用户点一次"提交订单",就在那一刻生成一个 key,这次操作后续所有因网络问题发起的重试,统统复用它。前端常见的做法是进入支付页面时就把 key 生成好、存起来。
坑 2:第二次请求,要返回和第一次完全一样的结果。幂等的定义是"执行 N 次效果同执行 1 次",这里的"效果"包含返回给调用方的东西。如果第一次返回了订单号、第二次却返回一个"重复提交"的错误,那么对那个发起重试的调用方来说,它拿不到订单号,这次重试在它看来就是失败的。所以第一次执行成功后,必须把完整结果(订单号、状态等)存下来,第二次直接把这份存下来的结果原样返回:
import json
import redis
r = redis.Redis()
def handle_with_idempotency(idem_key: str, do_work) -> dict:
"""通用幂等包装:第一次执行并缓存结果,重复请求直接返回缓存。"""
cache_key = f"idem:{idem_key}"
first = r.set(cache_key, "PROCESSING", nx=True, ex=86400)
if first:
try:
result = do_work() # 真正干活
except Exception:
r.delete(cache_key) # 失败要删占位,把重试权还给客户端
raise
# 成功:把【完整结果】缓存下来,重复请求就靠它原样返回
r.set(cache_key, json.dumps(result), ex=86400)
return result
# 不是第一个请求
cached = r.get(cache_key)
if cached == b"PROCESSING":
# 第一个还没跑完 —— 不能瞎返回,告诉调用方稍后重试
return {"ok": False, "code": "PROCESSING", "msg": "处理中,请稍后重试"}
return json.loads(cached) # 已有结果,原样奉还
坑 3:第一个请求执行失败时,占位的 key 必须删掉。上面代码 except 分支里那行 r.delete(cache_key) 极其关键。设想第一个请求执行到一半抛了异常(扣款时数据库挂了),业务其实没成功。这时如果把那个 PROCESSING 占位留着不删,客户端后续的重试就会一直撞到"处理中",而那个真正的操作永远不会再被执行了——它被一个失败的占位永久锁死了。所以失败路径上一定要删掉占位 key,把"重试的权利"还给客户端。
坑 4:幂等键不能永久保存,但过期时间要够长。把所有幂等键永久堆在 Redis 里,内存会无限膨胀,所以必须设过期时间(上面用的是 86400 秒,即一天)。但这个时间不能太短:它必须长于"客户端可能重试的最大时间跨度"。设想一个极端——客户端因网络故障,过了半小时才发起重试;如果幂等键只存 5 分钟,这时 key 早过期了,服务端会把这个迟到的重试当成一个全新请求,再执行一遍。过期时间的下限,是你系统里"一次重试链路"可能持续的最长时间,并留足余量。
坑 5:并发撞上 PROCESSING,客户端要退避重试,而不是当成失败。当第二个请求拿到 PROCESSING,说明第一个正在跑。服务端返回"处理中"后,客户端不应该直接报错给用户,而应该等一小会儿再来查询结果(或重试)。这和限流里的退避重试是一个道理:给第一个请求一点时间把活干完,结果自然就出来了。下面这张图,把一个带幂等键的请求从到达到返回的完整路径串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 幂等性 | 同一操作执行 N 次,效果与执行 1 次完全相同,包括返回结果 |
| 重复请求 | 网络重传、超时重试、负载均衡重发、消息重复投递都会造成,无法消除 |
| check-then-act | "先查再做"两步非原子,并发下会同时查到"没有"而都去做 |
| 唯一约束 | 数据库层原子的"检查+插入",重复键插入直接被拒,最稳的底层保证 |
| 幂等键 | 客户端为一次业务操作生成的唯一 ID,重试时必须保持不变 |
| SETNX | Redis 原子的"不存在才设置",用来占位、识别"第一个请求" |
| 结果缓存 | 第一次执行的结果要存下,重复请求原样返回,保证"效果"也一致 |
| 状态机 | 定义合法状态流转,带状态条件的 UPDATE 防逻辑上的重复操作 |
| CAS / 乐观锁 | UPDATE ... WHERE status=旧值,靠 rowcount 判断是否真的改动 |
| 占位键清理 | 业务执行失败要删除 PROCESSING 占位,否则重试被永久锁死 |
避坑清单
- 分布式系统里重复请求是常态——网络重传、超时重试、负载均衡重发、消息重复投递,你无法消除,只能让接口对重复免疫。
- 客户端收不到响应时,无法区分"请求没送达"和"响应丢了",它只能重发;对已处理过的服务端,这个重发就是重复扣款的根源。
- "先查再做"在并发下必失效:查和做不是原子操作,两个重复请求会同时查到"没有"然后都去执行——本地低并发根本测不出来。
- 数据库唯一约束是最稳的底层幂等:它把"检查+插入"做成一个原子操作,并发 INSERT 只有一个成功,另一个抛 IntegrityError。
- 捕获唯一约束冲突时要返回成功而非错误——重复本身不是失败,它恰恰证明幂等生效了。
- 幂等键必须由客户端生成,并绑定到"一次业务意图"上;后续所有重试复用同一个 key,每次请求新生成 key 等于没做幂等。
- 第二次请求要返回和第一次完全一样的结果,不能返回"重复提交"错误——否则调用方拿不到订单号,对它就是失败。
- 用 Redis SETNX 占位识别"第一个请求";执行成功要缓存完整结果,执行失败必须删除 PROCESSING 占位,否则重试被永久锁死。
- 状态流转用带状态条件的 UPDATE(UPDATE ... WHERE status=旧值),靠 rowcount 判断,防的是对同一对象的逻辑重复操作。
- 幂等键要设过期时间防内存膨胀,但过期时间必须长于客户端可能的最大重试跨度,否则迟到的重试会被当成新请求再执行一遍。
总结
回头看那次"网络抖动让用户被扣两次款"的事故,以及我后来在幂等这条路上接连踩的坑,最该记住的不是某一段 SETNX 代码,而是我动手前那个想当然的判断——"我的接口逻辑自己没 bug,就不会重复处理一个请求"。这句话错在它把视线只放在了"我的逻辑"里面,而重复请求恰恰来自"我的逻辑"外面:网络、客户端、负载均衡、消息队列。你的代码可能一行 bug 都没有,它仍然会被同一个请求驱动两次——因为决定"一个请求来几次"的,从来不是你的业务逻辑。
所以做幂等,真正的工程量不在"写业务逻辑"那一下。扣余额、建订单的代码谁都会写,它在 Demo 里、在你手点测试时也确实跑得好好的。真正的工程量在业务逻辑的两端:进来之前,怎么用一个原子操作(唯一约束、SETNX、带条件的 UPDATE)判断"这个操作是不是已经做过了";做完之后,怎么把结果存下来,好让那个迟到的重复请求拿到和第一次一模一样的答案。这篇文章的几节,其实就是顺着这条思路展开的:先想清楚重复为什么不可避免,再看"先查再做"为什么是个陷阱,然后是唯一约束、幂等键、状态机这三种有原子性保证的机制,最后是键怎么生成、结果怎么返回、失败怎么清理这几个把幂等真正做稳的工程细节。
你会发现,幂等的思路和我们处理任何"不可靠环境"的工程经验都是相通的。我们不相信网络一定送达,所以有重传;我们不相信消息只投递一次,所以有去重;我们不相信一次写入一定成功,所以有重试。幂等是这条链路的最后一环,也是最关键的一环——正因为前面每一环都在"为了可靠而重复",才必须有一环站出来保证"重复了也不出错"。重试和幂等,从来是一对:你给了系统重试的权利,就必须同时给它幂等的保障,否则重试本身就成了制造脏数据的元凶。
最后想说,幂等做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你每个请求都只发一次,网络又快又稳,有没有幂等跑起来一模一样。它只在真实的网络抖动、真实的客户端超时重试、真实的高并发面前才显形。那时候它会用最难堪的方式给你结账:一个用户被扣两次款,一笔订单生成两份,一条消息触发两次发货。这些脏数据一旦落库,清理起来要对账、要追溯、要赔偿,代价远远大于你当初多写的那几行 SETNX。所以别等客服拿着投诉单来找你,在你写下第一个写操作接口的时候就该问自己:这个请求要是来两次会怎样?我有没有一个原子操作能挡住第二次?第二次来了我返回什么?这几个问题都有了答案,你的接口才不只是 Demo 里那个跑得通的接口,而是一个在真实、不可靠的网络里也能守住数据正确的接口。
—— 别看了 · 2026