接口幂等性设计的六种方案与选型建议

举报
yd_228692151 发表于 2026/10/23 15:37:35 2026/10/23
【摘要】 重复请求是分布式系统的常态而非异常。本文梳理六种幂等实现方案——唯一索引、乐观锁、状态机、分布式锁、令牌机制、消息去重表,给出选型矩阵与四个真实踩过的坑。

一、为什么"幂等"总在出问题之后才被想起

几乎所有线上重复扣款、重复下单、重复发券的事故,根因都可以归成同一句话:请求被投递了不止一次,而服务端默认"一次请求 = 一次业务"。

重复的来源比大多数人想象的多:

  • 用户手抖连点两次提交按钮;
  • 移动端弱网超时后自动重试,第一次请求其实已经成功;
  • Nginx / 网关层超时重发;
  • 消息队列的 at-least-once 语义,消费失败重投;
  • 上游服务 RPC 超时重试(Dubbo、Feign、gRPC 默认都带重试);
  • 运维手动补数据、重放消息。

这些都不是"异常情况",而是分布式系统的常态。所以幂等不是可选优化,而是接口契约的一部分。

二、先区分两个概念:幂等 ≠ 去重

很多人把幂等写成"查一下有没有,有就返回"。这只能挡住串行重复,挡不住并发重复——两个请求同时查到"没有",然后同时插入。

真正的幂等需要满足三个条件:

  1. 唯一标识:能判断"这是不是同一个业务请求",通常是一个业务流水号(bizNo)或请求 ID。
  2. 原子性判定:判定与写入必须在同一个原子操作里完成,或者由存储层保证唯一。
  3. 结果一致:重复请求返回的结果与首次一致,而不是报错或返回空。

第二点是最容易漏的。下面六种方案,本质区别就在于"原子性由谁保证"。

三、方案一:数据库唯一索引(最朴素,也最可靠)

把幂等键做成唯一索引,让数据库替你做并发控制。

CREATE TABLE t_order (
  id          BIGINT PRIMARY KEY,
  biz_no      VARCHAR(64) NOT NULL,
  user_id     BIGINT NOT NULL,
  amount      DECIMAL(12,2) NOT NULL,
  status      TINYINT NOT NULL,
  UNIQUE KEY uk_biz_no (biz_no)
);

插入时捕获唯一键冲突:

try {
    orderMapper.insert(order);
} catch (DuplicateKeyException e) {
    // 说明这个 bizNo 已经处理过,查询后返回既有结果
    return orderMapper.selectByBizNo(order.getBizNo());
}

优点:零额外组件,并发安全由存储引擎保证,最不容易出错。
缺点:幂等键必须有业务含义且能稳定生成;跨库分表时唯一索引失效,需要换成方案四或方案六。

在 GaussDB / MySQL 上,这个方案配合 INSERT ... ON DUPLICATE KEY UPDATE 或 ON CONFLICT DO NOTHING 会更简洁,能直接拿到"是否新插入"的语义。

四、方案二:乐观锁与版本号

适合"更新类"操作,比如账户余额变更、订单状态流转。

UPDATE t_account
SET balance = balance - #{amount}, version = version + 1
WHERE id = #{id} AND version = #{version};

影响行数为 0 就说明被别人改过,重试或直接返回冲突。

要点:更新条件里必须带上旧值或版本号,只判断 WHERE id = ? 是没有任何并发保护的。金额类操作建议再加一层 AND balance >= #{amount},避免把"并发"变成"透支"。

五、方案三:状态机约束

业务状态天然带方向,把状态作为更新的前置条件,是最廉价也最贴合业务的幂等。

-- 只有"待支付"才能变成"已支付"
UPDATE t_order SET status = 2
WHERE id = #{id} AND status = 1;

重复回调打进来时,第二次 status 已经是 2,影响行数为 0,直接返回成功即可。

这个方案的价值在于:它同时解决了幂等和乱序问题。支付成功回调和支付关闭回调如果乱序到达,状态机可以保证结果终态正确,而纯幂等键做不到。

六、方案四:分布式锁(DCS for Redis)

当幂等键无法做成唯一索引(比如跨分片、跨服务)时,用分布式锁把"判定 + 执行"串起来。

String key = "idem:" + bizNo;
boolean locked = redis.set(key, "1", SetParams.setParams().nx().ex(60));
if (!locked) {
    throw new BizException("请求处理中,请勿重复提交");
}
try {
    doBusiness();
} finally {
    // 注意:不要在这里无条件删锁,见下方"坑"
}

三个必须注意的点:

  • SET NX EX 必须是原子命令,不要写成 setnx + expire 两条。
  • 锁的过期时间要大于业务最长耗时,否则业务没跑完锁就释放了,等于没加。
  • 释放锁要校验持有者(用 value 存 requestId,配合 Lua 脚本比对后删除),否则可能删掉别人的锁。

另外,分布式锁只解决"同时处理",不解决"处理成功后再次请求"。所以通常还需要方案一或方案五兜底——锁是并发控制,不是幂等本身,这一点经常被混淆。

七、方案五:令牌机制(先取票,后消费)

客户端先申请一个一次性令牌,提交业务时带上;服务端校验并删除令牌,删除成功才执行业务。

// 取令牌
String token = UUID.randomUUID().toString();
redis.set("token:" + token, "1", SetParams.setParams().ex(300));

// 提交时校验
Long deleted = redis.del("token:" + token);  // 返回 1 表示令牌有效且已消费
if (deleted == null || deleted == 0) {
    throw new BizException("重复提交");
}

适合表单提交、支付发起这类"用户主动触发"的场景。缺点是增加一次网络交互,且令牌丢失后用户需要重新取票。

八、方案六:消息消费去重表

消息队列场景(DMS for Kafka / RabbitMQ)下,消费端必须自己保证幂等,因为 MQ 只承诺 at-least-once。

标准做法是"业务表 + 去重表,同一事务写入":

@Transactional
public void consume(Message msg) {
    int inserted = dedupMapper.insertIgnore(msg.getMsgId());  // 唯一索引
    if (inserted == 0) {
        log.info("消息 {} 已消费,跳过", msg.getMsgId());
        return;
    }
    doBusiness(msg);
}

关键点:去重记录和业务操作必须在同一个本地事务里。如果先写去重表、再调远程服务,中间失败就会造成"消息被标记为已消费但业务没做"的数据丢失——这比重复消费更危险。

如果业务本身跨服务无法放进一个事务,退而求其次:去重表只记录"处理中"和"已完成"两个状态,配合定时任务补偿"处理中"超时的记录。

九、选型建议

场景 推荐方案 理由
单库单表的下单、发券 唯一索引 最简单,并发安全由存储保证
余额、库存变更 乐观锁 / 状态机 天然带前置条件,顺带解决乱序
跨分片、跨服务的业务流水 分布式锁 + 唯一索引兜底 锁管并发,索引管重复
用户表单提交、支付发起 令牌机制 交互层拦截,用户体验清晰
MQ 消费 去重表 + 本地事务 应对 at-least-once 的必要手段
支付/退款回调 状态机 必须同时处理重复和乱序

十、几个真实踩过的坑

坑一:幂等键用错了维度。
用 userId + amount 做幂等键,结果用户连续买两笔同价商品,第二笔被当成重复直接丢弃。幂等键必须是"一次业务动作"的唯一标识,通常由客户端生成并透传,不能由服务端从业务参数拼出来。

坑二:把幂等做成了"防重"。
只在网关层做 N 秒内相同参数拦截。这在分布式部署下毫无意义(请求可能落到不同节点),而且会误杀合法的重复业务。

坑三:锁释放写在 finally 里但不校验持有者。
A 业务卡住 70 秒,锁 60 秒过期,B 拿到锁开始处理;A 结束时在 finally 里把 B 的锁删了。C 立刻又拿到锁——三个请求同时跑。这是分布式锁最经典的事故模型。

坑四:只做接口幂等,不做数据幂等。
接口层挡住了重复请求,但下游服务自己还有重试。幂等要在每一个会改变状态的地方做,而不是只在入口做一次。

十一、小结

幂等设计的核心不是选哪个方案,而是先回答三个问题:

  1. 这个操作重复执行会不会产生副作用?
  2. 我能不能拿到一个稳定的、一次业务动作唯一的标识?
  3. 判定"是否已执行"和"执行"能不能原子完成?

三个问题回答清楚,方案自然就定了。反过来,如果只记住"加个分布式锁",那大概率会在某个并发窗口里翻车。

一句话总结:幂等不是加锁,而是让"重复"这件事在数据层变得不可能。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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