接口幂等设计的三种落地方式:唯一键、状态机与一次性令牌

举报
yd_217917957 发表于 2026/11/07 14:30:27 2026/11/07
【摘要】 写接口被重试是常态。本文对比三种幂等落地方式:唯一键去重(唯一索引 + 冲突读回)、状态机条件更新(把当前状态写进 WHERE,看影响行数)、一次性令牌(Redis 原子消费)。附 SQL/Python 代码、各自适用场景与常见坑,以及组合使用建议。

先说结论

幂等不是"锦上添花",而是任何会被重试的写接口的必备属性。网关超时重试、客户端网络抖动后的补偿、消息队列的至少一次投递、用户手抖双击——现实中一条"只应该发生一次"的请求,平均会被送达 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())

要点有三条:

  1. 冲突不等于失败,要读回已有结果,返回和第一次一样的响应体,否则调用方看到的语义就变成了"失败"。
  2. 唯一索引必须建在会真正落库的那张表上,中间如果还经过 MQ,就要在消费端建,而不是在生产端。
  3. 业务号要由调用方生成(或至少由调用方确定性地带上来);服务端自己生成的 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 等共享存储 无业务号的动作型接口 提前消费、未绑定用户、本地缓存

实践里通常是组合用:下单用唯一键 + 状态机,支付回调再用状态机兜一层,表单类提交配一次性令牌。一个接口里放两层保护并不冗余——它们防的是不同的失败模式。

最后一个小建议

写幂等代码时,先在纸上回答一个问题:“如果这个请求被原样重放两次,第二次应该返回什么?” 答案写清楚,代码就好写了。真正难的不是加一个唯一索引,而是让重放的那次请求返回和第一次完全一致的结果,并且不再触发任何副作用。做到这一点,幂等才算真的落地。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。