用户连点两下支付按钮,钱扣了两次;网络超时客户端自动重试,订单建了两笔------这类"重复扣款"工单,我以前每个月要处理十几单。后来落地了一套完整的幂等防御体系,这个数字直接归零。
这篇文章把接口幂等一次性讲透:从概念辨析到方案选型,从完整代码到踩坑指南。面试备战还是生产落地,看这一篇就够了。
一、别急着背方案,先把幂等是什么讲清楚
面试里 90% 的人上来就是"我用 Redis + Token",但面试官真正想听的第一个点是你对幂等的判定口径。
标准定义只有一句话:
同一个请求,执行一次和执行 N 次,对系统产生的副作用完全相同。 数学表达:
f(f(x)) = f(x)。
这里有两个特别容易误解的地方。
幂等保证的是"副作用相同",不是"只被调用一次"。 接口可以被调 100 次,但结果只能生效一次。所以别总想着在网络层"拦住所有重复请求",那既做不到,也没必要。
幂等 ≠ 去重,更 ≠ 并发控制。 这三个概念混为一谈,追问一轮就露馅:
| 概念 | 含义 | 关注点 | 典型手段 |
|---|---|---|---|
| 去重 | 过滤掉重复到达的请求 | "不执行" | 过滤器、拦截 |
| 幂等 | 执行多次结果也一致 | 防重复副作用(不重复扣款、不重复下单) | 唯一索引、状态机、幂等表 |
| 并发控制 | 同一时刻只允许一个执行 | 防竞态(超卖、库存击穿) | 锁、CAS |
再强调一句:分布式锁 ≠ 幂等。锁只能拦住"同时进来"的请求,拦不住"隔了 3 秒重试"的请求。锁是手段之一,幂等是目标,两者经常搭配出现,但不是一回事。
顺带补一个 RESTful 视角:按规范 GET、PUT、DELETE 都是天然幂等的,POST 不是。而实际业务里大部分写操作(创建订单、支付)恰恰都是 POST------所以幂等设计,本质上就是给"非幂等的 POST 业务"打补丁。
按接口类型看,侧重点也不一样:
| 接口类型 | 幂等情况 | 处理姿势 |
|---|---|---|
| 查询接口 | 天然幂等 | 无需处理,多次查询结果一致 |
| 删除接口 | 容易做到幂等 | 删除不存在的资源也返回成功(幂等删除) |
| 创建接口 | 重灾区 | 唯一业务单号 + 唯一索引 |
| 更新接口 | 重灾区 | 状态机 / 乐观锁 |
概念清楚了,接下来看看重复请求到底从哪来------比你想的多得多。
二、重复请求从哪来?先搞清来源,再谈方案
实际开发中,重复请求的来源至少有六类:
| 来源 | 典型场景 | 特征 |
|---|---|---|
| 用户重复点击 | 下单、支付、领券按钮连点 | 前端可控,最低级 |
| 网络超时重试 | 客户端、网关、Nginx 自动重试 | 请求可能已到达服务端并执行成功 |
| MQ 重复消费 | 消费失败重投、分区 rebalance | "至少一次"投递语义决定 |
| RPC 框架重试 | Dubbo、Feign 的重试策略 | 中间件层面重发 |
| 定时任务重复触发 | 多节点同时跑批 | 分布式调度竞争 |
| 页面回退 | 浏览器回退后再次提交表单 | 浏览器行为 |

只要系统里存在"网络重试"或"至少一次投递",就必须在业务层做幂等,别指望调用方只发一次。这是分布式系统的铁律。
我之前就踩过这个坑:以为"前端按钮置灰就没事了",结果用户网络差,浏览器自己超时重试,照样产生重复订单。前端防重只是体验优化,幂等必须做在服务端。
三、黄金公式与四层防御体系
3.1 幂等的黄金公式
所有幂等方案,剥掉外壳之后都是三件事:
幂等 = 唯一标识 + 去重存储 + 原子操作

- 唯一标识 :怎么判定"这是同一个请求"?业务单号,或客户端生成的
requestId; - 去重存储:把"已处理"记录下来,Redis、MySQL 或状态字段都行;
- 原子操作:判断 + 写入必须在一个原子步骤里完成,否则并发下两个请求都会判断"没处理过",然后双双写入。
3.2 四层纵深防御:越靠前越快,越靠后越稳
生产上不会把宝押在单一方案上,而是分层布防:

- 前端层拦掉 80% 的低级重复,但只解决体验问题;
- 网关 / 框架层通用拦截,业务零侵入;
- 业务层用状态机、CAS 做业务语义级兜底;
- 数据层的唯一索引是最后一道防线,解决 100% 的正确性问题。
我的原则是:任何写接口,先问自己一句------这张表有没有能挡住重复的唯一约束? 如果没有,这个接口就是不安全的。
3.3 幂等键设计:这一步错了,全盘皆输
幂等的第一步是定义 key。幂等键必须是业务唯一标识------同一个业务意图,无论重试多少次,key 必须一模一样。
常见来源有三个:
http
Idempotency-Key: 7f1f6c2f-5b8a-4a7b-9d9f-9c5f2a5c0b1e
- 前端生成 :通过
Idempotency-Key请求头统一传递; - 业务生成:订单号、支付流水号、业务单号(最可靠);
- 网关生成 :全局
requestId,比如从X-Request-Id请求头统一提取,全链路透传。
哪些键能用、哪些键千万别用:
| 推荐 | 不推荐 |
|---|---|
bizType + requestId |
userId + 时间戳(会漂移,重试时对不上) |
订单号 + 操作类型 |
每次现场生成的 UUID(重试一次换一个,根本去不了重) |
外部支付流水号 externalTradeNo |
MQ 的 messageId(重投时可能变化) |
java
String idemKey = "pay:callback:" + externalTradeNo;
键设计原则 :
业务域:操作:业务唯一标识(+参数指纹),比如idem:pay:ORDER_20261008_001。key 太粗会误拦不同请求,太细又拦不住重复。
还有一点很多人忽略:对上游调用方要强制要求重试时携带同一个幂等键,最好由调用方生成,并贯穿整个调用链落到你的唯一索引上。如果调用方保证不了 key 一致,你这边做得再漂亮也形同虚设。
四、方案选型:一张对比表 + 一张决策图
主流方案一共 8 种,一张表讲清楚:
| 方案 | 核心原理 | 适用场景 | 优点 | 缺点 / 坑 |
|---|---|---|---|---|
| 数据库唯一索引 | 唯一约束拦截重复插入 | 插入型:下单、支付流水、消费兜底 | 简单粗暴、强一致、最终兜底 | 只防插入不防更新;冲突语义要区分 |
| Token 机制 | 预发令牌,提交时原子校验并删除 | 前端表单、下单页 | 业务执行前拦截,拦截率高 | 需前端配合,多一次交互 |
| 状态机 | 只允许合法状态流转,CAS 更新 | 订单、审批、支付回调 | 贴合业务、天然幂等、零额外组件 | 仅适用于有状态流转的业务 |
| 乐观锁 version | WHERE version = ? 条件更新 |
库存、余额等更新 | 无锁阻塞、性能好 | 仅适用更新类;失败要有重试策略 |
| 悲观锁 | SELECT ... FOR UPDATE 行锁 |
强一致要求极高的低并发场景 | 强一致性保证 | 性能差、有死锁风险 |
| 分布式锁 | Redis / Redisson 抢占串行化 | 多步复杂业务编排 | 通用性强 | 锁过期、误删风险;只防并发,不是幂等 |
| 幂等表 | 专用去重表 + 唯一索引 + 状态 + 结果快照 | 通用场景、资金类核心链路 | 业务表无侵入、可回放结果 | 多一张表,需定期清理 |
| MQ 去重表 | 消费端按消息业务唯一键去重 | MQ 消费 | 简单有效 | 必须与业务同事务 |
选型口诀可以直接背:
插入用唯一索引,更新用乐观锁,流转用状态机,通用用幂等表 + Redis 注解,前端加 Token / 置灰兜底。
拿到一个写接口,按这张决策图走:

不同场景的推荐组合也列一下:
| 场景 | 推荐组合 |
|---|---|
| 简单插入(创建订单) | 唯一索引 |
| 前端表单提交 | Token + 按钮置灰 |
| 支付回调 | 状态机 CAS + 唯一索引,并发高再加分布式锁 |
| 库存 / 余额更新 | 乐观锁 + 条件更新 |
| MQ 消费 | 业务唯一键去重表(与业务同事务) |
| 复杂分布式业务 | 全局唯一 ID + 幂等表 + 全链路透传幂等键 |
举个真实项目里的组合拳例子------支付回调三重保险:
- 数据库对
out_trade_no做唯一约束,兜底; - 业务入口用 Redis 分布式锁以
out_trade_no加锁,防并发回调; - 更新订单状态时用状态机约束,确保只能从"待支付"变"已支付"。
三层叠在一起,万无一失。
觉得这张选型表有用的话,建议先收藏。顺手点个关注,后面我会持续更新这类一线面试场景题和落地方案,少走弯路。
五、核心代码实现(可直接抄)
下面按方案逐个给代码。如果你时间有限只看一节,务必看 5.7 的通用幂等组件------那是最能体现架构能力的一段。
5.1 数据库唯一索引:最硬的兜底
订单表建表时,订单号上加唯一索引:
sql
CREATE TABLE `t_order` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`order_no` VARCHAR(64) NOT NULL COMMENT '业务订单号',
`user_id` BIGINT NOT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`) -- 幂等的最后一道防线
) ENGINE=InnoDB;
业务代码直接插入,冲突了就当重复请求处理:
java
public Order createOrder(CreateOrderCmd cmd) {
try {
Order order = buildOrder(cmd);
orderMapper.insert(order); // 唯一索引兜底
return order;
} catch (DuplicateKeyException e) {
// 不是报错,而是"这次是重复请求,直接返回已存在的订单"
return orderMapper.selectByOrderNo(cmd.getOrderNo());
}
}
面试官最爱追问 :为什么不先
select再insert?因为 check-then-act 在并发下不安全------两个线程可能都查到"不存在",然后双双写入。正确姿势叫乐观插入:直接插,冲突了再查。唯一索引把"判断"下沉到了 DB 的原子操作里,这才是关键。
还有一个细节:唯一键冲突 ≠ 一定是幂等场景 。DuplicateKeyException 要区分是"重复请求"还是"真实业务冲突"(比如两个人抢同一个单号),需要结合业务语义判断,不能无脑吞异常返回旧数据,否则会掩盖真正的数据问题。
5.2 Token 防重提交:前后端配合的经典方案
流程是这样的:

先看不加思索的写法错在哪:
java
// 错误写法:两步不是原子的,并发下两个请求都会通过校验
if (redisTemplate.hasKey(tokenKey)) {
redisTemplate.delete(tokenKey);
// 执行业务...
}
两个请求同时 hasKey 都返回 true,然后各自删除、各自执行业务------防重完全失效。正确做法是用 Lua 脚本把"校验 + 删除"合二为一:
java
@Service
public class TokenService {
// Lua:校验值一致才删除,Redis 单线程执行,天然原子
private static final DefaultRedisScript<Long> CHECK_AND_DEL = new DefaultRedisScript<>(
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
"return redis.call('DEL', KEYS[1]) else return 0 end", Long.class);
@Resource
private StringRedisTemplate redisTemplate;
/** 申请 Token:SET 带过期时间一条命令完成,避免先 SET 后 EXPIRE 的非原子问题 */
public String createToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("idem:token:" + token, token, 5, TimeUnit.MINUTES);
return token;
}
/** 校验并删除 Token,返回 true 才放行业务 */
public boolean checkToken(String token) {
Long r = redisTemplate.execute(CHECK_AND_DEL, List.of("idem:token:" + token), token);
return r != null && r > 0;
}
}
技术亮点 :
GET + DEL用 Lua 脚本合成一次原子操作,彻底杜绝"两个并发请求都校验通过"的经典坑。同理,发 Token 时也必须SET带过期时间一条命令搞定,如果先SET再EXPIRE,中间宕机就会留下一个永不过期的僵尸 Token。
Token 为什么放 Redis 而不是数据库?高频校验、自动 TTL、性能好;真正的一致性由 DB 唯一索引兜底。
5.3 状态机 CAS:订单、支付回调的灵魂
有状态流转的业务,状态机是最优雅的幂等方案。订单状态只能单向流转:

规则很简单:只允许"未走过"的边再走一次,重复走同一条边直接忽略。
核心 SQL 是把"判断状态"和"更新状态"合并成一条原子语句:
sql
UPDATE t_order
SET status = 'PAID', update_time = NOW()
WHERE order_no = #{orderNo}
AND status = 'WAIT_PAY'; -- 带期望状态的 CAS
支付回调是幂等的重灾区------第三方可能重复推 N 次。完整写法:
java
@Transactional(rollbackFor = Exception.class)
public void payCallback(PayNotify notify) {
// 1. 唯一索引兜底:pay_no 唯一,重复回调直接键冲突
payRecordMapper.insertIgnore(PayRecord.of(notify));
// 2. CAS 更新,影响行数 = 0 说明已处理或状态非法
int rows = orderMapper.casToPaid(notify.getOrderNo(), notify.getPayNo());
if (rows == 0) {
log.warn("订单已支付或状态异常,忽略回调: {}", notify.getOrderNo());
return;
}
// 3. 后续派生动作(发券 / 积分 / 通知)走 MQ 可靠投递
eventPublisher.publish(new OrderPaidEvent(notify.getOrderNo()));
}
为什么不先
SELECT判断再UPDATE? 因为两步之间有时间窗:两个回调线程同时看到WAIT_PAY,就会各执行一次更新,导致积分、库存被扣两次。CAS 把判断下沉到 DB 行锁,天然原子。支付平台就算回调 10 次,也只处理一次。
5.4 乐观锁:库存、余额的更新姿势
更新类操作用版本号或余量条件做原子更新:
java
// 带 version 防 ABA,带 num >= delta 防超卖
@Update("UPDATE t_stock SET num = num - #{delta}, version = version + 1 " +
"WHERE sku_id = #{skuId} AND num >= #{delta} AND version = #{version}")
int deductStock(@Param("skuId") Long skuId, @Param("delta") int delta,
@Param("version") int version);
返回影响行数 0,说明要么库存不足、要么并发被抢,交由上层重试或返回"库存不足",绝不超卖。
有个进阶细节:rows == 0 其实有两种含义,要区分开------
java
if (rows == 0) {
Order latest = orderMapper.selectByOrderId(orderId);
if ("PAID".equals(latest.getStatus())) {
return; // 已支付过:幂等,直接返回成功
}
throw new BizException("并发冲突,请重试"); // 版本冲突:可重试
}
WHERE 条件里 status 做业务幂等、version 做并发控制,一次数据库交互解决两个问题。
5.5 分布式锁:保护复杂业务块
涉及多步查询、计算、多表写入的复杂操作,唯一索引罩不住时,用分布式锁把整个业务块串行化:
java
RLock lock = redissonClient.getLock("lock:pay:" + orderNo);
try {
if (!lock.tryLock(0, 10, TimeUnit.SECONDS)) {
return Result.error("请求处理中,请稍后再试");
}
// 执行业务逻辑
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
用 Redisson 有两个好处:看门狗机制自动续期(锁获取成功后每隔 1/3 过期时间续一次,业务没跑完锁不会失效);释放时校验持有者身份,避免误删别人的锁。
但请时刻记住:锁只解决"同时",幂等解决"重复"。锁释放之后,隔几秒来的重试请求照样会重复执行,所以锁必须和唯一索引、状态机搭配使用,它自己扛不起幂等。
5.6 幂等表:结果兜底,让重复请求拿到第一次的结果
资金、支付、营销发奖这类核心链路,建议加一张幂等记录表,把状态和结果都存下来:
sql
CREATE TABLE t_idempotent (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
biz_type VARCHAR(64) NOT NULL COMMENT '业务类型',
idempotent_key VARCHAR(128) NOT NULL COMMENT '幂等键',
status VARCHAR(16) NOT NULL COMMENT 'PROCESSING / SUCCESS / FAIL',
response TEXT COMMENT '业务结果快照',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz_key (biz_type, idempotent_key),
KEY idx_create_time (create_time) -- 用于定时清理
) COMMENT '幂等记录表';
处理流程:

java
public Object executeWithIdempotent(String bizType, String key, Supplier<Object> biz) {
IdempotentDO record = new IdempotentDO(bizType, key, "PROCESSING");
try {
idempotentMapper.insert(record); // 原子占位,唯一索引保证
} catch (DuplicateKeyException e) {
IdempotentDO old = idempotentMapper.selectByKey(bizType, key);
if (old.isSuccess()) {
return JSON.parse(old.getResponse()); // 回放历史结果
}
if (old.isProcessing()) {
throw new BizException("请求正在处理中,请勿重复提交");
}
throw new BizException("上次请求失败,请稍后重试");
}
try {
Object result = biz.get();
idempotentMapper.markSuccess(bizType, key, JSON.toJSONString(result));
return result;
} catch (Throwable e) {
idempotentMapper.markFail(bizType, key);
throw e;
}
}
技术亮点 :Redis 负责快速拦截,幂等表负责结果留存。真正的幂等不只是"不重复执行",而是重复请求也返回第一次的结果 ------第一次下单成功了,第二次重复请求不应该报错,而应该原样返回那笔订单。这才是
f(f(x)) = f(x)的完整语义:连返回值都要一致。
5.7 最大亮点:@Idempotent 注解 + AOP 通用组件
把"唯一标识 + Redis 去重 + Lua 原子 + 结果回放"封装成一个注解,业务方一行代码接入------这是简历上最值得写的一笔。
注解定义:
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/** 幂等键,支持 SpEL,如 "#req.orderNo" */
String key() default "";
/** 业务前缀 */
String prefix() default "idem";
/** 去重窗口(秒) */
int expire() default 30;
/** 重复请求提示语 */
String message() default "请勿重复提交";
/** 重复请求是否回放首次结果 */
boolean replay() default true;
}
切面实现,注意 Lua 脚本的三态返回值:
java
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {
private final StringRedisTemplate redis;
/**
* KEYS[1]=幂等key ARGV[1]=参数指纹 ARGV[2]=过期时间(秒)
* 返回: 1=首次请求放行 | 2=重复请求 | 0=同key不同参数
*/
private static final DefaultRedisScript<Long> SCRIPT = new DefaultRedisScript<>(
"if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then return 1 " +
"elseif redis.call('GET', KEYS[1]) == ARGV[1] then return 2 " +
"else return 0 end", Long.class);
@Around("@annotation(idem)")
public Object around(ProceedingJoinPoint pjp, Idempotent idem) throws Throwable {
// 1. SpEL 解析业务幂等键,拼上用户 + 方法维度,防止不同用户互相影响
String bizKey = SpELUtil.parse(idem.key(), pjp);
String redisKey = idem.prefix() + ":" + userId() + ":"
+ pjp.getSignature().toShortString() + ":" + bizKey;
// 2. 参数指纹:同一个订单号换了金额,就是不同请求
String fingerprint = DigestUtils.md5DigestAsHex(JSON.toJSONBytes(pjp.getArgs()));
Long r = redis.execute(SCRIPT, List.of(redisKey),
fingerprint, String.valueOf(idem.expire()));
if (r == null || r == 0) {
throw new BizException("相同幂等键的参数不一致,请检查请求");
}
if (r == 2) {
// 重复请求:回放首次结果,而不是简单报错
return idem.replay() ? replayResult(redisKey) : Result.fail(idem.message());
}
try {
Object result = pjp.proceed();
cacheResult(redisKey, result, idem.expire()); // 结果快照,供回放
return result;
} catch (Throwable e) {
// 3. 只有"明确失败且可重试"的异常才释放占位
if (isRetryableException(e)) {
redis.delete(redisKey);
}
throw e;
}
}
private boolean isRetryableException(Throwable e) {
// 参数校验、普通业务异常可重试;网络超时、结果未知类异常不能轻易释放
return e instanceof BizException;
}
}
业务方使用,一行注解搞定:
java
@Idempotent(prefix = "order:create", key = "#req.orderNo", expire = 600,
message = "订单正在处理中,请稍候")
public Order createOrder(CreateOrderRequest req) {
return orderService.create(req);
}
这段代码有四个值得拿出来讲的亮点:
SET key value NX EX一条命令完成占位 ,杜绝了SETNX之后再EXPIRE之间宕机导致的死锁;- 参数指纹防"伪幂等":同一个订单号换了金额再提交,直接判异常,而不是被当成重复请求静默放过;
- 结果快照回放:重复请求返回首次的成功结果,这才是幂等的正确语义;
- 业务失败删除占位 key:让真正失败的请求能被用户重试,而不是被永久拦截------这是很多人的盲区。
5.8 MQ 消费幂等:至少一次投递的救命稻草
MQ 的"至少一次"投递语义决定了消费端必须自己做幂等。去重表 + 同事务是最稳的写法:
sql
CREATE TABLE mq_consume_log (
msg_id VARCHAR(64) NOT NULL COMMENT '消息业务唯一键',
biz_key VARCHAR(64) NOT NULL COMMENT '业务主键',
gmt_create DATETIME NOT NULL,
PRIMARY KEY (msg_id)
) ENGINE=InnoDB;
java
@Transactional(rollbackFor = Exception.class)
public void onMessage(Message msg) {
try {
consumeLogMapper.insert(msg.getBizKey()); // 与业务同库同事务
} catch (DuplicateKeyException e) {
log.warn("重复消息,已消费过: {}", msg.getBizKey());
return; // 直接 ACK,实现幂等
}
doBiz(msg);
}
关键点:去重记录必须和业务操作放在同一个本地事务里。 否则插入成功、业务失败回滚,消息再投一次就被误判为"已消费",直接丢数据。
如果用 RabbitMQ 手动 ACK,模板是这样:
java
@RabbitListener(queues = "order.pay.queue")
public void onMessage(PayMessage msg, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
// 幂等键 = 业务唯一 ID,不要依赖 MQ 的 messageId(重投时可能变化)
String bizNo = msg.getOrderNo() + ":" + msg.getPayType();
if (!tryInsertDedup(bizNo)) {
channel.basicAck(tag, false); // 重复消息,直接 ACK
return;
}
try {
payService.handlePaySuccess(msg);
channel.basicAck(tag, false);
} catch (Exception e) {
channel.basicNack(tag, false, !isFatal(e)); // 只有可重试的才重回队列
}
}
代码可以直接抄,但踩过的坑更值钱------下一章是全文最硬核的部分。
六、技术难点与踩坑指南(面试官最爱追问区)
幂等的坑基本都在细节里。我按类别整理成四张表,再单独拆解几个高频难点。
6.1 并发与原子性类
| 难点 | 现象 | 解决方案 |
|---|---|---|
| check-then-act 竞态 | 两个请求都查到"不存在",双双写入 | 把判断下沉到原子操作:唯一索引 / SET NX / 条件更新 |
| Token 先查后删非原子 | 两个请求都 GET 成功,都放行 |
Lua 脚本合并 GET + DEL |
SETNX 后再 EXPIRE 非原子 |
中间宕机,锁永不过期 | SET key val NX EX 一条命令,或 Lua |
| 锁误删 | A 删了 B 的锁 | value 存 UUID,删除时用 Lua 校验值 |
| 多实例本地去重失效 | JVM 内存、本地缓存多实例不共享 | 统一用 Redis / DB 等全局存储 |
6.2 过期与释放类
| 难点 | 现象 | 解决方案 |
|---|---|---|
| 锁过期但业务没跑完 | 占位失效,重复请求进入 | 合理评估 TTL;Redisson 看门狗自动续期 |
| 业务失败后占位不释放 | 用户重试被永远拦截 | 异常时删除 key,或幂等表标记 FAIL |
| 释放不是无条件的 | 超时结果未知,释放后可能重复执行 | 只有"明确失败且可重试"才释放;支付、扣款类结果未知时不释放,靠 CAS + 唯一索引保证重试安全 |
| 先删占位还是后删 | 先删:失败无法重试;后删:并发重复进入 | 业务执行前占位,失败时补偿删除,最终一致性靠唯一索引兜底 |
| 幂等键过期 | 窗口外的重复请求穿透到 DB | 有效期 ≥ 最大重试窗口(网关重试 + MQ 重投总时长);DB 唯一索引兜底 |
6.3 存储与性能类
| 难点 | 现象 | 解决方案 |
|---|---|---|
| Redis 宕机 | 拦截全部失效 | DB 唯一索引兜底;Sentinel / 集群保高可用 |
| 幂等表膨胀 | 历史数据无限增长 | 设 TTL + 定时归档(如保留 7~30 天)+ 冷热分离、分表 |
| 唯一索引热点 | 高并发大量冲突,DB CPU 飙升 | 前置 Redis 校验;INSERT ... ON DUPLICATE KEY UPDATE |
| 分库分表后唯一约束失效 | 唯一性只在单库单表内成立 | 幂等键必须包含分片键;全局发号器;入口按分片键路由到固定库表 |
| 极致高并发性能 | 唯一索引成为写入瓶颈 | 唯一键尾号分片打散热点;布隆过滤器前置过滤;非金融业务可降级为 Redis 判重(最终一致) |
6.4 链路与语义类
| 难点 | 现象 | 解决方案 |
|---|---|---|
| 重复请求返回值不一致 | 第一次成功但响应超时,重试却收到"重复提交"报错 | 结果快照:缓存首次结果,重复请求直接回放 |
| MQ 至少一次投递 | 消息被重复消费 | 业务唯一键去重表 + 与业务同事务;状态机兜底 |
| 上下游幂等键不一致 | 调用方重试换了个 key,去重形同虚设 | 接口协议强制 Idempotency-Key;网关无键直接拒绝;全链路透传同一事务 ID |
| 跨系统 / 跨库幂等 | 本地事务管不住外部系统 | 本地消息表 / 事务消息 + 下游幂等,走最终一致 |
| 批量操作部分成功 | 批量请求无法整体幂等 | 拆解为单条记录粒度去重,记录每条处理状态,支持部分失败重试 |
| 唯一键冲突 ≠ 一定幂等 | 两人抢同一单号是业务冲突,不是重复请求 | 结合业务语义判断,别无脑吞异常 |
| 非幂等 SQL | num = num - 1 重复执行结果不同 |
改为绝对值写入 num = #{newValue};或流水表 + 唯一索引(account_id + biz_no) |
| 异步处理中用户又重试 | 请求已返回但处理慢,用户再点一次 | 先落库返回"处理中",前端轮询 / 回调拿最终状态 |
6.5 四个高频难点拆解
难点一:重复请求要返回相同结果(真幂等)
这是高频追问。场景还原:前端提交下单 → 后端处理成功写库 → 网络超时前端没收到响应 → 用户点重试 → 后端返回"重复请求" → 前端显示"下单失败",但订单其实已经建好了。
解法是把第一次的结果缓存下来,重试时原样返回:
java
public Result createOrder(String requestId, OrderDTO dto) {
// 1. 先查是否已有结果
String cacheKey = "result:" + requestId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, Result.class); // 直接返回相同结果
}
// 2. 幂等占位、执行业务
Order order = orderService.create(dto);
Result result = Result.success(order);
// 3. 缓存结果,过期时间覆盖最大重试窗口
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result), 1, TimeUnit.HOURS);
return result;
}
难点二:失败释放占位,要小心事务边界
"业务异常就删 key"听起来没问题,但如果切面在事务外层,可能出现:事务已提交、部分数据已落库,这时异常删了 key,重试就会在已有数据上再执行一遍。两个对策:一是用 TransactionSynchronization 在事务提交后再决定释放与否;二是接受"释放可能带来重试",由唯一索引和状态机保证重试安全------这也是为什么我反复强调 DB 兜底不能省。
难点三:全局唯一 ID 的时钟回拨
幂等键经常依赖雪花算法,而雪花算法依赖机器时钟。时钟回拨的三种处理思路:小幅度回拨直接抛异常等时钟追上;用历史最大时间戳继续发号;干脆换美团 Leaf 的号段模式,从数据库取号段,完全不依赖时间。
难点四:非幂等 SQL 的改造
UPDATE t_account SET balance = balance + 10 这类累加语句天然不幂等,执行两次就是加两次。两条改造路线:写成绝对值 SET balance = #{newValue};或者引入流水表,每笔变更插一条记录并对 account_id + biz_no 建唯一索引,累加变成"插流水 + 汇总",天然可去重。
七、面试追问预案
把最常被追问的 8 个问题整理成表,答案背熟:
| 追问 | 回答要点 |
|---|---|
| 幂等键过期了怎么办? | 过期时间 ≥ 最大重试窗口;核心业务不依赖 Redis,唯一索引做最终兜底 |
| Redis 挂了幂等就失效? | Redis 是"性能层",唯一索引是"正确性层",两层职责分开 |
| MQ 消费幂等怎么做? | 业务唯一 ID 做幂等键 + 去重表同事务;或用状态机判断是否已处理 |
| 高并发下 Redis 会成为瓶颈吗? | SET NX 单命令 + 分片;核心链路仍由 DB 唯一约束兜底 |
| Token 为什么放 Redis 不放数据库? | 高频校验、自动 TTL、性能高;一致性由 DB 唯一索引兜底 |
| 先查后插做幂等行不行? | 不行,check-then-act 有竞态;正确做法是乐观插入,冲突了再查 |
| 雪花算法时钟回拨怎么办? | 抛异常等时钟追上 / 用历史最大时间戳 / 换 Leaf 号段模式 |
| 怎么衡量幂等方案的效果? | 上监控:重复请求拦截率、重复扣款事故数(必须为 0)、去重表命中率 |
八、总结:一锁、二唯一、三状态、四结果
全文浓缩成一句话:
幂等不是某个组件的事,而是"前端防呆 → 网关拦截 → 业务 CAS → 数据库唯一约束"的四层纵深防御。前三层解决 99% 的性能和体验问题,最后一层唯一索引解决 100% 的正确性问题。
落地四件套:入口防重 用 Redis Token / SETNX,存储兜底 用唯一索引,业务兜底 用状态机 CAS,结果兜底用幂等表存快照。没有银弹,按业务选型组合使用。
最后送一段 30 秒速记口诀:
text
表单重复点,token 先申请
接口要通用,指纹 + SETNX
兜底靠唯一,冲突即成功
状态用 CAS,别查了再更
消息必去重,同事务落地
锁只防并发,幂等另算账
给你的落地建议,按优先级排:
- 盘点存量写接口:所有创建类数据的表,先检查有没有唯一约束,没有的立刻补上;
- 核心链路上幂等表:资金、支付、营销发奖,结果快照 + 三状态一个都不能少;
- 封装团队统一的
@Idempotent组件:统一标准,别让每个人各写一套; - 加监控:拦截率、事故工单数、去重表命中率------幂等不是上线就完事,要能被度量。
最后留个互动问题:你们公司现在主要用哪种幂等方案?或者你在重复提交上踩过什么印象深刻的坑? 评论区聊聊,我看到都会回。觉得这篇有用的话,点赞、在看、转发走一波,你的支持是我持续更新的最大动力。