接口幂等设计的三种落地方式:唯一键、状态机与一次性令牌
先说结论
幂等不是"锦上添花",而是任何会被重试的写接口的必备属性。网关超时重试、客户端网络抖动后的补偿、消息队列的至少一次投递、用户手抖双击——现实中一条"只应该发生一次"的请求,平均会被送达 1.x 次。本文把我实际项目里用过的三种落地方式摆在一起:唯一键去重、状态机条件更新、一次性令牌。三种都不是银弹,关键在选对场景。
一、唯一键去重:让数据库当裁判
最朴素也最可靠的一招。调用方在请求里带一个幂等的业务号(request_id、外部订单号、客户端生成的 UUID),服务端在写入表上建唯一索引。
CREATE TABLE pay_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'CREATED',
amount DECIMAL(10,2) NOT NULL,
UNIQUE KEY uk_request_id (request_id)
);
写的时候不要先 SELECT 再 INSERT,那中间有窗口。直接 INSERT,捕获唯一键冲突:
try:
cur.execute(
"INSERT INTO pay_order(request_id, amount) VALUES (%s, %s)",
(request_id, amount))
except pymysql.err.IntegrityError as e:
if e.args[0] != 1062: # 1062 = Duplicate entry
raise
# 重复请求:把上一次的结果捞出来原样返回
cur.execute("SELECT id, status FROM pay_order WHERE request_id=%s",
(request_id,))
return build_response(cur.fetchone())
要点有三条:
- 冲突不等于失败,要读回已有结果,返回和第一次一样的响应体,否则调用方看到的语义就变成了"失败"。
- 唯一索引必须建在会真正落库的那张表上,中间如果还经过 MQ,就要在消费端建,而不是在生产端。
- 业务号要由调用方生成(或至少由调用方确定性地带上来);服务端自己生成的 UUID 解决不了客户端的重复提交。
适用:创建类操作,天然有业务唯一标识——下单、开户、提交工单。不适用:更新类操作,同一个业务号可能合法地改多次。
二、状态机条件更新:把"当前状态"写进 WHERE
老系统最常见的 bug 是"查—改"两步走:
SELECT status FROM pay_order WHERE id = 1001; -- 得到 CREATED
-- 业务判断通过
UPDATE pay_order SET status='PAID' WHERE id = 1001; -- 两次请求都执行
两个并发请求都读到 CREATED,于是都认为自己该处理,结果一次支付被记了两笔流水。
改成条件更新,把状态迁移当成一次 CAS:
UPDATE pay_order
SET status = 'PAID', paid_at = NOW()
WHERE id = 1001 AND status = 'CREATED';
然后只看影响行数:
affected = cur.rowcount
if affected == 1:
do_business_after_paid() # 只在这里放行后续动作
else:
return current_state() # 已支付或已关闭,走幂等返回路径
这样数据库成了串行化点,谁先到谁生效,后来的那次影响行数为 0,自然被识别成重复。
有两点容易踩:
- 状态迁移要单向、要命名清楚。CREATED → PAID → REFUNDED 这种图先画出来,代码里只允许图上的边,其他一律拒绝。
- 真正的副作用(扣款、发券、发短信)必须挂在
affected == 1的分支上,放在事务提交之后,用本地消息表或事务消息补一致性。不要用"事务里先调外部接口"这种写法去对抗网络。
适用:有明确生命周期的实体。这是我最推荐的一种,因为它同时解决了并发和顺序两个问题。
三、一次性令牌:给"无法用业务号"的接口兜底
有些接口既没有天然业务号,也不是状态迁移——比如"提交一条表单"、“触发一次报表生成”。这时候可以让客户端先申请令牌,服务端消费一次即作废。
import uuid, redis
r = redis.Redis()
def issue_token(uid: int) -> str:
token = uuid.uuid4().hex
# nx=True:只在键不存在时写入,5 分钟过期
r.set(f"idem:{uid}:{token}", "1", ex=300, nx=True)
return token
def consume(uid: int, token: str) -> bool:
# DELETE 是原子的,返回 1 表示这次真的消费到了
return r.delete(f"idem:{uid}:{token}") == 1
调用方带着 token 发请求,服务端 consume() 返回 False 就直接回一个"重复提交"的提示,不要继续往下走。
这类方案的边界很清楚:
- 令牌不能二次使用,所以只有在"提交"那一刻才消费;校验阶段不要提前删。
- 令牌必须绑定用户,否则 A 用户的 token 被 B 用掉就是越权。
- 令牌有有效期,过期后重试会拿到"重复"或"过期",客户端要能区分这两个状态,前者不该重试,后者可以重新申请。
- 如果服务端是多实例,令牌必须放在共享存储里;放本地内存等于没做。
适用:无业务号、动作型接口。代价是多一次往返和一份 Redis 依赖。
三种方式怎么选
| 方式 | 依赖 | 最擅长的场景 | 主要风险 |
|---|---|---|---|
| 唯一键去重 | 数据库唯一索引 | 创建类、有业务号 | 冲突后忘了读回结果;表分库后唯一性难保证 |
| 状态机条件更新 | 一行 UPDATE 的影响行数 | 有生命周期的实体 | 状态图没画清,副作用挂错分支 |
| 一次性令牌 | Redis 等共享存储 | 无业务号的动作型接口 | 提前消费、未绑定用户、本地缓存 |
实践里通常是组合用:下单用唯一键 + 状态机,支付回调再用状态机兜一层,表单类提交配一次性令牌。一个接口里放两层保护并不冗余——它们防的是不同的失败模式。
最后一个小建议
写幂等代码时,先在纸上回答一个问题:“如果这个请求被原样重放两次,第二次应该返回什么?” 答案写清楚,代码就好写了。真正难的不是加一个唯一索引,而是让重放的那次请求返回和第一次完全一致的结果,并且不再触发任何副作用。做到这一点,幂等才算真的落地。
- 点赞
- 收藏
- 关注作者
评论(0)