一、问题定义
1.1 什么是幂等
在数学中,f(x) = f(f(x)) 表示一个函数多次执行的结果与执行一次相同。在分布式系统中,幂等 指:同一个操作执行一次和执行多次,对系统产生的影响相同,即最终状态一致。
1.2 为什么需要幂等
分布式系统中,网络不可靠是常态。一个请求可能因为网络超时、重试机制、消息队列的 at-least-once 投递等原因被执行多次。举几个典型场景:
- 支付回调:支付宝/微信支付的回调通知可能因为网络抖动重发,如果扣款逻辑重复执行,用户会被扣两次钱。
- 消息队列:RocketMQ/Kafka 在消费者 rebalance 或 ACK 超时时,同一消息可能被投递多次。
- RPC 重试:Dubbo 的 failover 策略、HTTP 客户端的自动重试,都会导致同一个请求被服务端处理多次。
- 前端重复提交:用户点击"提交订单"按钮后,页面没来得及禁用按钮,用户又点了一次。

1.3 幂等 ≠ 不重复执行
幂等不要求系统不重复执行同一个请求,而是要求重复执行的结果与执行一次的结果一致。例如:
UPDATE account SET balance = 100 WHERE id = 1是幂等的,执行多少次结果都是 balance=100。UPDATE account SET balance = balance + 100 WHERE id = 1不是幂等的,每次执行都会加 100。

二、核心方案
2.1 唯一约束(数据库唯一索引)
原理:利用数据库的唯一索引来保证某个业务维度上的数据不重复。
实现:在数据库中创建一个唯一索引,将幂等键作为唯一约束字段。第一次插入成功,后续相同的插入会因为唯一约束冲突而失败。
-- 创建幂等表
CREATE TABLE idempotent_records (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
idempotent_key VARCHAR(128) NOT NULL,
business_type VARCHAR(64) NOT NULL,
status TINYINT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_idempotent_key (idempotent_key)
);
-- 业务处理时
INSERT INTO idempotent_records (idempotent_key, business_type) VALUES ('order_123_pay', 'PAYMENT');
-- 如果重复,捕获 DuplicateKeyException,直接返回之前的结果

优点:简单可靠,数据库天然保证。 缺点:依赖数据库,高并发下有性能压力;需要额外的表。
2.2 去重表 + 本地事务
原理:将业务操作和幂等记录放在同一个数据库事务中,保证原子性。
实现:
@Transactional
public void processPayment(String idempotentKey, PaymentRequest request) {
// 1. 检查幂等键是否已存在
if (idempotentRecordMapper.exists(idempotentKey)) {
return; // 已处理过,直接返回
}
// 2. 插入幂等记录
idempotentRecordMapper.insert(idempotentKey, "PROCESSING");
// 3. 执行业务逻辑
accountMapper.deduct(request.getUserId(), request.getAmount());
// 4. 更新幂等记录状态
idempotentRecordMapper.updateStatus(idempotentKey, "SUCCESS");
}
关键:检查-插入-业务操作必须在同一个事务中,否则并发时会出问题。
2.3 状态机
原理:利用状态流转的不可逆性来保证幂等。同一个操作只能在特定状态下执行,执行后状态迁移,后续相同操作发现状态不匹配自动跳过。
实现:
public enum OrderState {
CREATED, PAID, SHIPPED, COMPLETED, CANCELLED
}
public void payOrder(String orderId) {
Order order = orderMapper.selectById(orderId);
// 只有在 CREATED 状态下才能支付
if (order.getState() != OrderState.CREATED) {
return; // 已支付或已取消,幂等返回
}
// 使用 CAS 更新,防止并发
int rows = orderMapper.updateState(orderId, OrderState.CREATED, OrderState.PAID);
if (rows == 0) {
return; // 并发时被其他线程更新了
}
// 执行支付逻辑
paymentGateway.charge(order);
}

状态机设计要点:
- 状态必须是有限的、可枚举的。
- 状态转移必须是单向的(或有限方向的)。
- 使用数据库的
UPDATE ... WHERE state = ?实现 CAS 语义。
2.4 Token 机制
原理:客户端在调用接口前先获取一个 Token,服务端校验 Token 是否有效,用完即失效。
流程:
- 客户端请求 Token:
POST /api/token,服务端返回一个全局唯一 Token 并存入 Redis。 - 客户端发起业务请求时携带 Token:
POST /api/order?token=xxx。 - 服务端校验 Token 是否存在且未使用,如果有效则执行业务,然后删除或标记 Token。
public Result createOrder(String token, OrderRequest request) {
// 使用 Redis 的 SET NX 或 DEL 原子操作
// Lua 脚本保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList("token:" + token), token);
if (result == 0) {
return Result.fail("重复提交");
}
// 执行业务
return orderService.create(request);
}

优点:不依赖数据库唯一约束,适合高并发。 缺点:需要额外的 Token 获取步骤,增加了交互复杂度;Token 需要设置过期时间。
2.5 乐观锁(版本号)
原理:通过版本号控制并发更新,相同的版本号只能更新一次。
public void deductStock(String productId, int quantity) {
Product product = productMapper.selectById(productId);
int rows = productMapper.update(
"UPDATE product SET stock = stock - ?, version = version + 1 WHERE id = ? AND version = ?",
quantity, productId, product.getVersion()
);
if (rows == 0) {
throw new OptimisticLockException("版本冲突,重试");
}
}
注意:乐观锁本身不解决幂等,它解决的是并发冲突。但结合正确的重试逻辑,可以实现幂等。
2.6 分布式锁
原理:使用 Redis/ZooKeeper 分布式锁,保证同一时间只有一个请求在处理。
public void process(String idempotentKey) {
String lockKey = "lock:" + idempotentKey;
if (!redisLock.tryLock(lockKey, 30, TimeUnit.SECONDS)) {
return; // 获取锁失败,说明已有请求在处理
}
try {
// 获取锁后再次检查是否已处理
if (processed(idempotentKey)) return;
doBusiness();
markProcessed(idempotentKey);
} finally {
redisLock.unlock(lockKey);
}
}
注意:分布式锁有超时风险,需要配合续期机制(如 Redisson 的 WatchDog)。
三、幂等键设计
3.1 幂等键的要素
一个好的幂等键需要具备以下特征:
- 全局唯一:在分布式系统中,不同节点、不同业务类型的幂等键不能冲突。
- 业务含义明确:幂等键应该携带业务上下文,方便排查问题。
- 长度可控:不能太长,影响存储和索引性能。
3.2 幂等键的生成策略
| 策略 | 示例 | 适用场景 |
|---|---|---|
| 业务自然键 | orderId + operationType |
订单支付、退款 |
| 请求 ID | requestId (UUID) |
RPC 调用 |
| 消息 ID | topic + partition + offset |
消息队列消费 |
| 客户端生成 | clientId + timestamp + seq |
前端防重提交 |
| 组合键 | userId + productId + timestamp |
秒杀等场景 |
3.3 幂等键的存储
- 短期幂等(几小时到几天):Redis,带 TTL,内存效率高。
- 长期幂等(几个月到永久):MySQL 唯一索引,持久可靠。
- 混合策略:热数据用 Redis + 冷数据用 MySQL,通过 Bloom Filter 减少穿透。
四、各层级的幂等实践
4.1 接口层幂等
HTTP 方法的天然幂等性:
GET、PUT、DELETE是幂等的(HTTP 规范)。POST不是幂等的,需要额外处理。
实践:在网关层或拦截器层统一处理幂等。
@Idempotent(key = "#request.orderId", expire = 3600)
@PostMapping("/order/create")
public Result createOrder(@RequestBody OrderRequest request) {
// 业务逻辑
}
4.2 消息队列消费幂等
消息队列的 at-least-once 语义决定了消费端必须幂等。
@RocketMQMessageListener(topic = "order-paid", consumerGroup = "order-service")
public class OrderPaidConsumer implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt message) {
String msgId = message.getMsgId(); // 消息 ID 可作为幂等键
// 或者使用业务 key
String bizKey = message.getKeys();
if (processed(msgId)) return;
doProcess(message);
markProcessed(msgId);
}
}

4.3 数据库层面幂等
- INSERT:通过唯一索引防止重复插入。
- UPDATE:使用
UPDATE ... SET ... WHERE ... AND status = ?状态机方式。 - DELETE:天然幂等。
五、生产实践与常见陷阱
5.1 并发幂等
仅仅检查"是否已处理"是不够的,必须考虑并发场景:
线程A:检查幂等键 → 未处理 → 处理中...
线程B:检查幂等键 → 未处理 → 处理中...(并发问题!)

解决:检查 + 插入必须原子化,使用唯一索引或分布式锁。
5.2 幂等键的过期
幂等记录不能无限存储,需要设置合理的过期时间。
- 太短:用户重试时幂等记录已过期,导致重复处理。
- 太长:存储压力大,查询性能下降。
经验值:支付类 30 天,订单类 7 天,一般接口 1 小时。
5.3 幂等返回结果的一致性
重复请求不仅要阻止重复处理,还需要返回与第一次一致的结果。
public Result pay(String idempotentKey) {
IdempotentRecord record = mapper.selectByKey(idempotentKey);
if (record != null && record.getStatus() == SUCCESS) {
return Result.success(record.getResult()); // 返回第一次的结果
}
// 处理...
}
5.4 幂等与重试的配合
public Result processWithRetry(String idempotentKey) {
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
// 幂等检查在业务方法内部
return doProcess(idempotentKey);
} catch (DuplicateKeyException e) {
// 并发冲突,查询已处理的结果
return queryResult(idempotentKey);
} catch (OptimisticLockException e) {
// 乐观锁冲突,重试
continue;
}
}
throw new BizException("处理失败");
}
六、故障案例
案例1:支付回调重复扣款
现象:某支付系统收到微信支付回调后,用户被扣款两次。
原因:回调接口没有幂等处理。微信支付回调因网络问题重发了通知,服务端直接执行了两次扣款。
修复:以 transactionId(微信订单号)作为幂等键,在数据库建唯一索引,回调前先检查是否已处理。
案例2:消息队列重复消费导致库存超扣
现象:秒杀系统使用 RocketMQ 异步扣库存,出现库存扣为负数。
原因:消费端没有幂等处理,RocketMQ 因为 Consumer 重启导致消息重复投递。
修复:以 msgId 为幂等键,消费前先检查;同时使用 UPDATE stock SET stock = stock - 1 WHERE id = ? AND stock > 0 做防护。
案例3:Token 机制在高并发下失效
现象:使用 Redis Token 机制防重,大促时仍有重复提交。
原因:GET + DEL 不是原子操作,高并发下两个请求同时 GET 到 Token 都是有效的。
修复:使用 Lua 脚本保证 GET + DEL 的原子性。
七、方案选型对比
| 方案 | 复杂度 | 性能 | 可靠性 | 适用场景 |
|---|---|---|---|---|
| 唯一约束 | 低 | 中 | 高 | 通用场景 |
| 状态机 | 中 | 高 | 高 | 有状态流转的业务 |
| Token | 中 | 高 | 中 | 前端防重提交 |
| 乐观锁 | 低 | 高 | 中 | 并发更新场景 |
| 分布式锁 | 中 | 中 | 中 | 需要排他锁的场景 |
| 去重表 | 中 | 中 | 高 | 需要存储结果的场景 |

八、面试追问
Q1:幂等和防重有什么区别?
参考答案:幂等是服务端的能力,保证同一个请求多次执行的结果一致;防重是客户端的策略,避免同一个请求被重复发送。幂等是兜底,防重是前置优化。两者应该配合使用:客户端做防重(按钮置灰、Token),服务端做幂等(唯一约束、状态机)。
Q2:分布式事务中的幂等如何处理?
参考答案:在 TCC、Saga 等分布式事务模式中,每个参与方都需要实现幂等。TCC 的 Try/Confirm/Cancel 每个阶段都要支持幂等调用。通常使用业务唯一键 + 状态机来实现:Try 阶段检查状态是否为 INIT,Confirm 阶段检查状态是否为 TRYING,Cancel 阶段检查状态是否为 TRYING 或 INIT。
Q3:如何设计一个通用的幂等框架?
参考答案:核心组件包括:
- 幂等键解析器:从请求中提取或生成幂等键(支持 SpEL、注解等)。
- 幂等存储:支持 Redis、MySQL 等多种存储后端。
- 幂等处理器:统一的检查-处理-记录流程。
- 结果缓存:返回首次处理的结果,保证一致性。
- 过期策略:按业务类型配置不同的过期时间。
Q4:为什么 UUID 不能直接作为幂等键?
参考答案:UUID 可以作为幂等键,但需要结合业务场景。UUID 的问题在于:
- 它是随机的,相同的业务请求每次生成的 UUID 不同,无法起到去重作用。
- 幂等键需要是"同一个请求"的标识,UUID 如果由客户端在每次请求时重新生成,就失去了幂等键的意义。
- 正确做法:客户端在首次请求时生成 UUID,后续重试使用同一个 UUID。
Q5:如果幂等记录存储(Redis/MySQL)宕机了怎么办?
参考答案:这是一个典型的可用性 vs 一致性权衡:
- 降级策略:允许短暂的非幂等,依靠业务层面的补偿(如对账)来修复。
- 多级存储:Redis 不可用时降级到 MySQL,MySQL 不可用时降级到本地缓存。
- 熔断:当幂等存储不可用时,拒绝服务比重复处理更安全(对于支付等敏感场景)。
- 兜底:即使幂等检查失败,业务逻辑本身也要有防护(如
UPDATE ... WHERE stock > 0)。
Q6:Saga 模式中,补偿操作失败怎么办?
参考答案:补偿操作本身也必须是幂等的。如果补偿失败:
- 重试:补偿操作支持幂等,可以安全重试。
- 持久化:将失败的补偿任务持久化到数据库,由定时任务重试。
- 人工介入:超过最大重试次数后,告警通知人工处理。
- 最终一致性:通过各种对账机制发现不一致并修复。
Q7:设计一个秒杀系统的幂等方案
参考答案:秒杀场景的特点是高并发、低延迟要求。推荐方案:
- 前端:按钮防重(点击后置灰)+ Token 机制。
- 网关层:基于
userId + activityId的限流,同一用户同一活动只能通过一个请求。 - 业务层:Redis 分布式锁 + Lua 脚本原子扣库存。
- 数据库层:
userId + activityId唯一索引兜底。 - 异步层:消息队列消费时用
msgId去重。
九、面试官视角深度追问
以下问题从面试官视角出发,考察候选人对幂等设计的深层理解,而非对文档内容的复述。每个问题都聚焦实际工程中的权衡与边界判断。
Q8:为什么 INSERT ... ON DUPLICATE KEY UPDATE 是幂等的,但你说它「安全」吗?
面试官意图:考察候选人是否理解幂等的局限——幂等只保证结果一致,不保证业务正确。
参考答案:这句话本身是幂等的,但业务上可能是灾难性的。假设两个并发请求:请求A要将余额设为 100(充值 100),请求B要将余额设为 200(充值 200)。如果 A 先执行 balance=100,B 再执行 balance=200,最终 balance=200,A 的充值被覆盖了——这是一个丢失更新问题。幂等不解决并发写冲突,解决并发写冲突需要乐观锁(版本号)或悲观锁。所以面试中不要只说「这个 SQL 是幂等的」,要补充「但需要配合并发控制手段」。
Q9:你设计了一个基于 Redis 的幂等方案,业务高峰期 Redis 集群发生了主从切换,会有什么问题?你怎么解决?
面试官意图:考察候选人对 Redis 高可用与幂等组合的边界条件理解。
参考答案:Redis 主从切换可能导致两个问题:
- 幂等记录丢失:如果主节点宕机时幂等 key 尚未同步到从节点,新主节点上没有这条记录,重复请求会穿透幂等检查。解决方案:对核心业务(支付、转账),幂等记录必须同时写 MySQL 做持久化兜底;或使用 Redis 的 WAIT 命令强制同步到至少 N 个从节点后再返回。
- 分布式锁失效:如果使用 Redisson 的分布式锁,主从切换时 WatchDog 续期机制可能中断,导致锁提前释放。解决方案:使用 RedLock 算法(多节点独立加锁),或使用 ZooKeeper 的临时顺序节点方案。
Q10:你有一个支付接口,调用支付宝后进行扣款。如果支付宝返回了「支付成功」但你的幂等记录写入失败了,你怎么处理?反过来,如果幂等记录写入成功但扣款失败了,又怎么处理?
面试官意图:考察分布式系统中「写本地」和「写远程」不是原子的情况下,候选人如何设计补偿与对账。
参考答案:这是典型的分布式不一致问题,两种场景需要不同策略:
场景A(第三方成功,本地记录失败):
- 用户已扣款但本地未记录,后续重试会再次调用支付宝,可能重复扣款。
- 解决方案:先写幂等记录(状态=PROCESSING),再调支付宝。如果支付宝返回成功但更新幂等状态失败,依靠定时对账任务:以支付宝的交易流水为准,发现本地状态仍为 PROCESSING 的订单,通过 transactionId 查询支付宝确认状态后推进。
- 不要把「调用支付宝」和「写幂等记录」放在一个本地事务里——它们根本不在同一个系统。
场景B(本地记录成功,第三方失败):
- 幂等记录标记为 FAILED,允许用户重试。重试时幂等键相同,但状态机允许从 FAILED 重试到 PROCESSING。
- 关键设计:幂等键不绑定结果状态,只绑定请求。是否允许重试由状态机决定,而非幂等键本身。
Q11:你提到幂等记录要设置过期时间。假设一个订单的幂等记录过期后被删除了,用户恰好在这之后重试了支付请求,会发生什么?你怎么设计来避免?
面试官意图:考察候选人对幂等过期的边界条件处理,以及是否理解「幂等不是银弹」。
参考答案:幂等记录过期后用户重试,系统会认为这是一个新请求,再次执行支付逻辑。这会导致重复扣款。解决方案是一个多层次的防护体系:
- 上游拦截:支付宝/微信的支付回调带有 transactionId,即使你的幂等记录过期了,也可以通过 transactionId 去支付宝查询这笔订单的真实状态,而不是直接执行扣款。
- 业务状态机兜底:订单状态机已经流转到 PAID,即使幂等记录过期,payOrder 方法检查订单状态时发现已经是 PAID,直接返回,不会重复扣款。幂等键是防止重复执行的第一道防线,业务状态机是第二道防线。
- 过期时间分层:核心支付类的幂等记录保留 30 天以上,覆盖支付宝回调的最长重试周期;非核心接口的幂等记录可以更短。
- 对账机制:即使以上全部失效,T+1 对账也会发现资金不平,触发人工处理。
Q12:在你的架构中,幂等键是由客户端生成的还是服务端生成的?各有什么利弊?
面试官意图:考察分布式系统中信任边界的设计理念。
参考答案:
| 维度 | 客户端生成 | 服务端生成 |
|---|---|---|
| 生成方式 | 客户端用 UUID/雪花算法生成 | 服务端接收请求后生成 |
| 优点 | 无需额外请求,延迟低 | 可控,不会被恶意构造 |
| 缺点 | 客户端可能恶意构造冲突键 | 需要额外请求或服务端首次生成后返回 |
| 安全风险 | 恶意客户端可以伪造他人的幂等键 | 无此风险 |
推荐方案:服务端生成 + 客户端透传。客户端首次请求时不带幂等键,服务端生成后返回给客户端;客户端后续重试时携带这个幂等键。这样既保证了安全性,又减少了额外请求。对于查询类接口,客户端可以直接用 requestId 作为幂等键,因为查询天然幂等。
Q13:RocketMQ 消费端你用了 msgId 做幂等,但 msgId 在 Producer 重试时是相同的吗?如果不是,你的幂等方案还 work 吗?
面试官意图:考察候选人对消息队列消息 ID 机制的深入理解。
参考答案:RocketMQ 中 msgId 由 Producer 端生成,由 clientIP、processId、时间戳和计数器组成。关键问题在于:Producer 发送重试时,msgId 是否相同取决于重试方式:
- 如果 Producer 捕获 send 失败后,用同一个 Message 对象重试 send,则 msgId 相同(因为 msgId 在 Message 构造时就确定了)。
- 如果 Producer 重新构造了一个 Message 对象来重试,则 msgId 不同。
因此,不能完全依赖 msgId 做幂等。推荐做法:使用业务 key(message.setKeys)作为幂等键,而不是 msgId。业务 key 由业务逻辑生成,无论 Producer 重试多少次,同一个业务请求的 key 始终相同。同时,将 msgId 作为辅助标识,用于排查消息轨迹。
Q14:说说你在生产环境中遇到过的最棘手的幂等问题,以及你是怎么解决它的。
面试官意图:考察候选人是否有真实的线上问题处理经验,以及问题分析和沟通能力。
参考答案(面试官期待的答案结构):
- 描述场景:什么业务、什么量级、什么触发条件。
- 描述现象:用户反馈什么、监控发现什么。
- 分析过程:怎么定位根因的,用了什么工具/日志。
- 解决方案:临时止血方案 + 长期根治方案。
- 复盘总结:学到了什么,系统层面做了什么改进。
示例回答框架:「我们有一个跨境支付系统,用户反馈一笔订单被扣了两次款。排查发现是支付网关的回调通知因为网络问题重发了,而我们的幂等逻辑有一个 bug——幂等检查用的是 SELECT FOR UPDATE,但在 Spring 事务中,FOR UPDATE 的锁在事务提交前就释放了(因为我们在 Service 层用了 REQUIRES_NEW 传播级别,导致幂等记录的插入和支付扣款不在同一个事务中)。临时方案是加 Redis 分布式锁兜底,长期方案是重构事务边界,把幂等检查和业务操作放在同一个事务中,并上线了自动化对账任务。」
全文完 | 共 8 张配图 | 14 道面试追问 | 预计阅读时间:45 分钟