在分布式系统中,"重复“是无法根除的:网络超时重试、消费端崩溃后重新拉取、消息队列 At Least Once 语义……这些都会让同一个请求/消息被执行多次。如果业务不处理重复,就会出现重复扣款、重复下单、数据错乱。幂等(Idempotency) 是分布式系统最核心的兜底能力:让"重复执行"与"执行一次"产生相同的结果。本指南讲透幂等的本质、实现方案与消息可靠性的配合。
关键概念:幂等 = 同一操作执行多次的结果,与执行一次的结果相同。它在分布式中的意义是:把"必然存在的重复"变成"无感知的重复”。与幂等配合的是消息队列的可靠性语义:At Least Once(至少一次,可能重复)、Exactly Once(精确一次,不重不丢)。
一、为什么分布式下必然存在重复
1.1 重复的来源
重复请求的来源:
1. 客户端超时重试
请求可能已处理成功,但响应超时 → 客户端重试 → 重复
2. 消费端崩溃恢复
消息已处理但未提交 ack → 崩溃 → 重新拉取 → 重复
3. 消息队列 At Least Once
多数 MQ 保证「不丢」但不保证「不重」→ 重复投递
4. 调度触发
分布式任务在多个节点/多次触发(见任务调度专题)
5. 用户/前端重复点击
支付按钮连点两次 → 两笔订单
1.2 重复为什么是"事实标准"而非"异常"
要认清的事实:
- 网络是不可靠的,重试是常态
- MQ 默认是 At Least Once,重复是语义内行为
- 分布式锁只能降低并发概率,不能保证不重
→ 「幂等兜底」不是可选项,而是分布式系统的默认要求
设计原则:
先把系统按「必然有重复」来设计
再用幂等/去重把重复消化掉
ℹ️ 核心:先假设会重复,再设计消重。凡是写入、扣款、发券、下单这类"有副作用"的操作,都必须做幂等。
二、幂等的本质与适用范围
2.1 哪些操作天然幂等
天然幂等的操作(可安全重复):
- 查询类:SELECT 不改变状态
- 覆盖写:PUT /x = 值,重复执行结果一致
- 删除类:DELETE 已删除的资源,再删无副作用
- 计数归零 / 幂等性算法(如 set 去重)
天然不幂等的操作(重复会出错):
- 扣减库存:两次扣减 → 少库存
- 新增记录:两次插入 → 两条数据
- 发券 / 转账:两次执行 → 发两遍 / 转两遍
→ 这类操作必须显式做幂等
2.2 幂等的三个层面
| 层面 | 手段 | 说明 |
|---|---|---|
| 接口层 | 幂等 Token | 客户端先取 token,再携带执行 |
| 数据层 | 唯一约束 / 状态机 | 数据库层面拒绝重复 |
| 消息层 | 去重表 / 消费记录 | 消费端记录已处理消息 |
三层可以叠加:接口层防请求重复,数据层兜底,消息层针对异步消费。越靠近底层,兜底越硬。
三、幂等实现的三大硬核方案
3.1 唯一索引 / 唯一键
数据库的唯一约束是"最强硬"的幂等实现——从存储层面拒绝重复。
-- 方案:唯一键 + INSERT IGNORE / ON DUPLICATE KEY
CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL, -- 业务幂等键(唯一)
user_id BIGINT,
amount DECIMAL(10,2),
status TINYINT,
UNIQUE KEY uk_order_no (order_no) -- 唯一约束=幂等兜底
);
-- 执行(幂等写法):
INSERT IGNORE INTO t_order(order_no, user_id, amount, status)
VALUES('20260927-001', 123, 99.00, 1);
-- 影响行数 = 1 → 首次成功;= 0 → 已存在,跳过/返回已存在
唯一键的选择:
- 业务幂等键要能标识"同一逻辑操作"
(如订单号、消息 ID、业务唯一流水号)
- 不要用自增主键做幂等键
- 多表操作时,幂等键所在的表要有唯一约束
3.2 状态机(乐观锁 / 状态流转)
很多业务天然是状态机:待支付 → 已支付 → 已发货 → 已完成。用状态字段做幂等,既防重又防乱序。
-- 方案:乐观锁 + 状态校验,只有「合法状态迁移」才更新
UPDATE t_order
SET status = 2 -- 已支付
WHERE order_no = 'xxx'
AND status = 1; -- 当前必须是「待支付」
-- 影响行数 = 1 → 成功;= 0 → 状态已变更,重复执行被拦
-- 进阶:携带版本号 version,防覆盖
UPDATE t_order
SET status = 2, version = version + 1
WHERE order_no = 'xxx' AND version = 0;
状态机的优点:
- 天然防重复(第二次执行状态已变,条件不满足)
- 天然防乱序(旧状态无法覆盖新状态)
- 语义清晰:状态字段本身就说明"走到哪一步了"
→ 尤其适合订单、支付、审批这类有明确流转的业务
3.3 去重表 / 消费记录表
面向异步消息消费,用一张独立的"已处理消息表"记录消费过的消息 ID。
-- 方案:消息去重表
CREATE TABLE t_msg_record (
msg_id VARCHAR(64) PRIMARY KEY, -- 消息唯一 ID
biz_type VARCHAR(32),
create_time DATETIME
);
-- 消费流程(伪代码):
-- 1. 尝试插入去重记录(msg_id 为主键)
INSERT IGNORE INTO t_msg_record(msg_id, biz_type) VALUES('msg-123', 'order');
-- 2. 影响行数 = 0 → 已消费过,直接 ack 跳过
-- 3. 影响行数 = 1 → 首次消费,执行业务逻辑
-- 4. 业务与插入去重记录要「同库同事务」→ 见消息可靠性一节
ℹ️ 核心:三种方案的本质都是"用存储层的唯一性约束来拦截重复"。唯一索引最硬、状态机语义最清晰、去重表最贴合消息消费。
四、消息队列的可靠性:不丢不重
4.1 语义梳理
消息投递语义:
- At Most Once(至多一次):可能丢,但不会重复
- At Least Once(至少一次):不丢,但可能重复 ← 大多数 MQ 默认
- Exactly Once(精确一次):不丢不重 ← 成本最高,需配合幂等
事实:
- Kafka / RocketMQ / RabbitMQ 默认都是 At Least Once
- "精确一次"在生产中通常 = At Least Once + 消费端幂等
→ 消息本身很难保证不重,靠消费端消化重复
4.2 生产端不丢(生产可靠性)
生产端不丢的关键:
- 同步发送 + ack 确认(Kafka acks=all / RocketMQ SYNC)
- 发送失败要重试,重试有上限与退避
- 不可靠生产 = 数据源头就丢了,消费端无从补
顺序消息注意:
- 有序场景要按业务键分区(同一 key 进同一分区)
- 重试不能破坏分区顺序
4.3 消费端不重(消费幂等)
消费端不重的关键(与去重表配合):
- 消费 + 去重记录「同库同事务」:
业务写库 和 插入消息记录 在同一本地事务中
→ 要么都成功(记录消息已处理),要么都失败(下次重试)
- 用「手动 ack」:业务成功后手动提交 offset/ack
业务失败则不 ack,MQ 会重新投递
- 避免「先 ack 后处理」:ack 了就认为处理完,崩溃会丢
同库同事务的取舍:
- 业务库与去重记录同库:最简单可靠
- 不同库则引入分布式事务(见事务专题),成本高
→ 多数场景选「业务库内建去重表 + 同事务」
五、消息可靠性与分布式事务的配合
消息最终一致(本地消息表 / 事务消息)是"跨服务数据最终一致"的经典模式,其中也蕴含幂等。
5.1 本地消息表
本地消息表流程:
1. 业务操作 + 写本地消息表(同事务)
2. 定时任务/后台把消息表未投递消息发到 MQ
3. MQ 保证至少一次投递
4. 消费端幂等落库(去重表兜底)
这样设计后:
- 业务不丢(本地表兜底)
- 消息不丢(At Least Once)
- 重复消费被幂等消化(消费端去重)
→ 跨服务最终一致 + 不丢不重
5.2 事务消息(RocketMQ)
RocketMQ 事务消息:
1. 发送半消息(half message,暂不可见)
2. 本地事务执行
3. 提交/回滚事务消息
4. 事务未决 → MQ 回查本地事务状态 → 决定投递
5. 投递后消费端同样靠幂等兜底
要点:
- 回查机制保证本地事务与消息的一致性
- 但投递后仍可能重复 → 消费端幂等不可省
ℹ️ 核心:可靠性链路是"生产不丢 + MQ 至少一次 + 消费端幂等"三段配合。MQ 只能减少丢失,幂等才能真正消化重复。
六、接口层幂等:Token 机制
前端重复点击、网络重试导致的"同一个请求发两次",需要在接口入口做幂等。
6.1 幂等 Token 流程
Token 幂等流程:
1. 客户端调用「取 token」接口(如 /order/token)
→ 服务端生成唯一 token,缓存到 Redis(带过期时间)
2. 客户端把 token 放在请求头/参数里发业务请求
3. 服务端处理前先「校验并消费 token」:
- Redis GETDEL / 或 Lua 原子删除
- 删除成功 → 第一次请求,放行执行
- 删除失败(token 已消费)→ 重复请求,直接返回成功
4. 关键:校验+消费必须原子(GETDEL),否则并发请求都会通过
使用场景:
- 下单、支付、提交表单等「用户主动发起」的写操作
- 前端按钮置灰 + 后端 Token 双保险
6.2 请求去重表(服务端兜底)
补充方案(服务端):
- 用「请求 ID + 去重表」:
客户端生成 request_id,服务端唯一索引去重
- 用「分布式锁 + 状态」:
处理前加锁,锁内校验状态,处理后更新状态
→ 与数据层幂等方案同一思路,只是作用在接口层
七、常见避坑
| 坑 | 现象 | 对策 |
|---|---|---|
| 只靠前端防重 | 绕过前端就重复 | 后端幂等兜底 |
| 幂等键选错 | 该幂等的不幂等 | 用业务唯一标识做键 |
| 先 ack 后处理 | 处理中崩溃消息丢失 | 处理成功再 ack |
| 消费与去重分离事务 | 去重失败业务重复 | 同库同事务 |
| 无唯一约束 | 并发插入两条 | 唯一索引兜底 |
| 状态机缺失 | 旧请求覆盖新状态 | 状态字段 + 条件更新 |
| 超时重试无幂等 | 重复扣款 | Token / 唯一键 |
| 认为 Exactly Once 万能 | 仍可能重复 | At Least Once + 幂等 |
八、最佳实践清单
□ 写操作默认做幂等,先假设必然有重复
□ 幂等键用业务唯一标识(订单号/消息 ID/流水号)
□ 数据层用唯一索引 / 状态机做硬兜底
□ 消息消费「同库同事务 + 去重表」
□ 业务成功后再手动 ack,先 ack 是事故源
□ 生产端同步发送 + ack + 重试,保证不丢
□ 接口层 Token 机制防重复提交
□ 幂等与分布式锁、事务消息配合使用
□ 埋点监控重复消费率,异常及时告警
一句话原则
消息可靠性 = 生产不丢 + MQ 至少一次 + 消费端幂等,
幂等 = 用唯一约束把「必然的重复」消化成「无感的重复」。
小结
分布式系统无法根除重复,但可以用幂等把重复"消化"掉。幂等 的三大硬方案是唯一索引/唯一键(最硬)、状态机/乐观锁(语义清晰、防乱序)、去重表(贴合消息消费);消息可靠性 的链路是生产端不丢 + MQ At Least Once + 消费端幂等,而"精确一次"在生产中 = 至少一次 + 幂等。落地记住五件事:先假设有重复再设计、幂等键用业务唯一标识、唯一约束做硬兜底、消费与去重同库同事务、业务成功后再 ack。当"重复执行与执行一次结果一致"成为系统的默认能力,网络重试、消费重启、用户连点这些"必然的重复"就不再是事故,而只是被优雅吸收的噪音。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。