一、为什么高并发系统一定会"被幂等教育"?
先说一句可能不太好听的大实话:
绝大多数高并发事故,不是代码写错,而是请求被执行了不止一次。
很多新手工程师在本地测试时,接口逻辑跑得顺风顺水,但一上生产环境,就容易栽在"重复请求"这个坑里。不是代码逻辑有问题,而是真实的线上环境,根本不存在"请求只来一次"的理想状态。
你一定会遇到这些无法避免的场景:
-
网络抖动:客户端发完请求后,因网络延迟没及时收到响应,误以为请求失败
-
客户端重试:前端按钮重复点击、APP 离线重连后重试、第三方调用方配置了自动重试
-
网关重发:API 网关因超时等原因,主动重发请求到后端服务
-
MQ 至少一次投递:为保证消息不丢失,Kafka、RocketMQ 等消息中间件默认都是"至少一次"投递策略,消息必然可能重复
用一段真实的交互场景,就能明白这种无奈:
text
客户端:我刚刚是不是没成功?(因网络延迟没收到响应)
系统:成功了。(其实已经处理完并返回,但响应丢了)
客户端:那我再来一次。(触发重试机制)
系统:???(我到底该再处理一次还是直接返回?)
结论先行,记死这句话:
在高并发 + 分布式系统中,请求"只来一次"是奢望,"重复请求"才是常态。
这就是为什么所有成熟的高并发系统,都必须过"幂等"这一关------不是你想做,而是环境逼着你必须做。
二、什么是幂等?(工程师版本,拒绝废话)
别去背教科书上的抽象定义,记住这一句人话就够了:
同一个请求,不管来多少次,系统最终的状态和结果都完全一样。
举个简单的例子:查询接口(GET /api/order?orderNo=123)天生就是幂等的,不管你调用1次还是100次,返回的都是同一个订单信息;但下单接口(POST /api/order/create)如果不做处理,调用2次就会生成2个订单,这就是非幂等的。
不做幂等,会发生什么?(真实事故案例)
很多人觉得"幂等是高级优化",直到踩了坑才明白这是"基础必备"。非幂等接口在高并发下,必然会出现这些致命问题:
-
重复下单:用户点击"提交订单"后因网络卡顿时重复点击,生成多笔订单,导致库存被多扣、用户收到多个发货通知
-
重复扣款:支付接口重试导致用户账户被多次扣款,直接引发用户投诉,甚至监管介入
-
MQ 重复消费:消息重复投递后被重复处理,导致库存扣为负数、优惠券被重复核销
-
重复退款:退款接口被重试,用户收到多笔退款,企业直接产生资金损失
这里有个判断标准,记下来直接用:
只要"同一个请求多执行一次会出事",这个接口就必须做幂等处理。
三、哪些场景是幂等"重灾区"?(优先处理这些)
不是所有接口都需要做幂等,我们要把精力放在高风险场景上。直接列重点,这些场景必须优先保证幂等:
-
交易相关接口:下单、支付、退款、转账------核心是涉及资金变动,一旦重复必然出问题
-
创建型接口(POST):POST 接口的语义是"创建资源",重复调用会生成多个资源,而 GET 接口是"查询资源",天生幂等(这里要注意:RESTful 规范中,POST 是非幂等的,PUT 是幂等的,设计接口时要遵循这个语义)
-
MQ 消费逻辑:如前所述,MQ 默认"至少一次"投递,消费端必须能处理重复消息
-
分布式事务场景:Saga 模式的补偿接口、TCC 模式的 Confirm/Cancel 接口,都可能因重试触发重复执行
-
对外提供的接口:给合作方、第三方调用的接口,无法控制对方的重试策略,必须自己兜底幂等
-
用户高频操作接口:如优惠券领取、活动报名、秒杀下单------用户可能因操作失误或网络问题重复触发
用一句扎心的话总结:
你不主动做幂等,流量会帮你做事故。
很多团队的线上故障复盘,最后结论都是"某个接口未做幂等,导致高并发下重复执行"。与其事后救火,不如事前预防。
四、幂等的本质是什么?(看透问题核心)
很多人在设计幂等方案时会陷入"各种技巧的堆砌",其实只要抓住本质,所有方案都能看懂。
所有幂等方案,本质上都在解决同一个核心问题:
"我怎么判断这个请求是不是已经处理过了?"
只要能准确回答这个问题,幂等就解决了一半。从工程实践的角度看,解决这个问题无非三种核心思路:
-
给请求加"唯一标识":通过一个全局唯一的 ID 标记请求,系统处理前先检查这个 ID 是否已经处理过------这是最常用的思路
-
让状态"不可逆":通过状态机约束,让业务状态只能从"未处理"向"已处理"流转,无法回退,从而避免重复处理------比如订单状态从"待支付"到"已支付",再收到支付请求时就无法再次变更
-
记录"去重日志":专门维护一个去重表或缓存,记录已经处理过的请求,处理新请求时先查去重记录------本质是"唯一标识"思路的延伸
理解了这三种思路,下面的实战方案就很容易看懂了------所有方案都是这三种思路的具体落地。
五、4 种主流幂等方案(Java 实战,附代码+注意事项)
下面介绍的 4 种方案,覆盖了 90% 以上的业务场景。从"稳定性""实现成本""适用场景"三个维度对比,大家可以根据自己的业务选择。
1️⃣ 唯一业务 ID + 唯一索引(最稳、最推荐)⭐
这是工业界最常用、最可靠的方案,核心是"用业务唯一 ID 标记请求,用数据库唯一索引兜底",从根源上防止重复数据插入。
核心思路
-
客户端发起请求时,携带一个全局唯一的业务 ID(比如订单号 orderNo、业务流水号 bizId)------这个 ID 可以由客户端生成,也可以由服务端生成后返回给客户端
-
服务端处理请求时,将这个唯一业务 ID 存入业务表(比如订单表)
-
在业务表上给这个唯一业务 ID 加"唯一索引",利用数据库的约束机制,防止重复插入数据
-
当重复请求到来时,数据库会抛出唯一约束冲突异常,服务端捕获异常后,直接返回"处理成功"(因为第一次请求已经处理完成)
实战代码(Java + MySQL)
第一步:给业务表加唯一索引(以订单表为例)
sql
-- 订单表:order_no 是全局唯一的业务 ID
ALTER TABLE orders
ADD COLUMN order_no VARCHAR(64) NOT NULL COMMENT '订单号(全局唯一)',
ADD UNIQUE KEY uk_order_no(order_no); -- 唯一索引兜底
第二步:Java 代码处理逻辑(结合 MyBatis)
java
/**
* 下单接口(幂等版)
* @param createOrderDTO 下单请求DTO,包含唯一订单号 orderNo
*/
@Transactional(rollbackFor = Exception.class)
public OrderVO createOrder(CreateOrderDTO createOrderDTO) {
String orderNo = createOrderDTO.getOrderNo();
if (StringUtils.isBlank(orderNo)) {
throw new BizException("订单号不能为空");
}
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(createOrderDTO.getUserId());
order.setProductId(createOrderDTO.getProductId());
order.setStatus("CREATED"); // 初始状态:待支付
try {
// 尝试插入订单
orderMapper.insert(order);
// 后续业务逻辑:扣减库存、锁定优惠券等
deductStock(createOrderDTO.getProductId(), createOrderDTO.getQuantity());
lockCoupon(createOrderDTO.getUserId(), createOrderDTO.getCouponId());
// 返回成功结果
OrderVO orderVO = convertToVO(order);
return orderVO;
} catch (DuplicateKeyException e) {
// 捕获唯一索引冲突异常,说明是重复请求
log.info("重复下单,订单号:{}", orderNo, e);
// 直接查询已存在的订单,返回成功结果
Order existOrder = orderMapper.selectByOrderNo(orderNo);
return convertToVO(existOrder);
}
}
优点 & 注意事项
✅ 优点:
-
实现简单,开发成本低
-
稳定性高,数据库唯一索引是"物理兜底",不会出现漏判的情况
-
强一致性,能保证业务数据的准确性
⚠️ 注意事项(关键!):
唯一索引是最后一道防线,而不是第一道。
很多人会犯一个错误:每次请求都直接往数据库插数据,靠唯一索引挡重复请求。这样会导致数据库频繁抛出异常,增加数据库压力。
优化建议:在插入数据库之前,先查缓存(比如 Redis)判断订单号是否已存在------缓存命中则直接返回,未命中再执行插入逻辑。这样能减少数据库的异常抛出次数,提升性能。优化后的代码片段:
java
// 优化:先查缓存,减少数据库压力
String redisKey = "order:idempotent:" + orderNo;
Boolean isExist = redisTemplate.hasKey(redisKey);
if (Boolean.TRUE.equals(isExist)) {
Order existOrder = orderMapper.selectByOrderNo(orderNo);
return convertToVO(existOrder);
}
try {
orderMapper.insert(order);
// 插入成功后,往缓存放一份,过期时间设置为业务合理时间(比如30分钟)
redisTemplate.opsForValue().set(redisKey, "1", 30, TimeUnit.MINUTES);
// 后续业务逻辑...
} catch (DuplicateKeyException e) {
// 重复请求处理...
}
2️⃣ 状态机 + 乐观锁(订单系统标配)
这个方案主要适用于"有明确状态流转"的业务场景,比如订单、支付、退款。核心是"通过状态机约束业务状态的流转,并用乐观锁保证并发安全",避免重复处理。
核心思路
-
给业务实体定义清晰的状态机,比如订单状态:待支付(CREATED)→ 已支付(PAID)→ 已发货(SHIPPED)→ 已完成(COMPLETED)
-
状态只能"向前流转",不能"向后流转"(比如已支付的订单,不能再回到待支付状态)
-
更新状态时,通过"乐观锁"(比如版本号 version 或状态字段本身)做条件判断,只有条件满足时才更新成功
一句话理解:重复请求到来时,因为状态已经流转到下一个阶段,更新条件不满足,所以不会重复处理。
实战代码(Java + MySQL)
第一步:订单表增加状态字段和版本号字段(乐观锁)
sql
ALTER TABLE orders
ADD COLUMN status VARCHAR(32) NOT NULL COMMENT '订单状态:CREATED-待支付,PAID-已支付,SHIPPED-已发货,COMPLETED-已完成',
ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '版本号(乐观锁)';
第二步:更新状态的 SQL(以"订单支付"为例)
sql
-- 方式1:用状态作为条件(简单场景)
UPDATE orders
SET status = 'PAID', pay_time = NOW()
WHERE order_no = ? AND status = 'CREATED'; -- 只有状态是"待支付"时才能更新
-- 方式2:用版本号作为条件(并发更高的场景)
UPDATE orders
SET status = 'PAID', pay_time = NOW(), version = version + 1
WHERE order_no = ? AND version = ?;
第三步:Java 代码处理逻辑
java
/**
* 订单支付接口(幂等版)
* @param payDTO 支付请求DTO,包含订单号、支付金额等
*/
@Transactional(rollbackFor = Exception.class)
public Boolean payOrder(PayDTO payDTO) {
String orderNo = payDTO.getOrderNo();
Order order = orderMapper.selectByOrderNo(orderNo);
if (order == null) {
throw new BizException("订单不存在");
}
// 方式1:用状态作为条件更新
int updateCount = orderMapper.updateOrderStatusToPaid(orderNo, "CREATED");
if (updateCount == 0) {
// 更新失败,说明订单已支付或状态异常(重复请求)
log.info("订单已支付或状态异常,订单号:{}", orderNo);
return true; // 重复请求,返回成功
}
// 方式2:用版本号作为条件更新(推荐高并发场景)
// int updateCount = orderMapper.updateOrderStatusToPaidWithVersion(orderNo, order.getVersion());
// if (updateCount == 0) {
// log.info("订单已支付或状态异常,订单号:{}", orderNo);
// return true;
// }
// 后续业务逻辑:扣减用户余额、记录支付日志等
deductUserBalance(payDTO.getUserId(), payDTO.getAmount());
recordPayLog(payDTO);
return true;
}
优点 & 适用场景
✅ 优点:
-
贴合业务场景,状态机本身就是业务逻辑的一部分,不需要额外引入过多概念
-
并发性能好,乐观锁不会阻塞线程,适合高并发场景
-
能防止"状态乱序"问题(比如同时收到"支付"和"取消"请求,保证只有一个能执行)
📌 适用场景:
-
订单状态流转(支付、发货、取消)
-
退款流程(申请退款 → 退款中 → 退款成功/失败)
-
任何有明确状态流转的业务实体
核心原则:
状态只往前走,本身就是幂等。
3️⃣ Token / Redis 去重(防重复提交首选)
这个方案主要用于"用户交互型接口",比如表单提交、按钮点击、秒杀下单。核心是"在请求执行前,先获取一个唯一 Token,执行时校验 Token 有效性",从而防止重复提交。
核心流程
-
客户端先向服务端请求"获取 Token"(比如用户进入下单页面时,前端调用接口获取 Token)
-
服务端生成一个全局唯一的 Token(比如用 UUID),存入 Redis(设置过期时间),然后返回给客户端
-
客户端发起业务请求时,将 Token 放在请求头或请求参数中一起提交
-
服务端收到请求后,先校验 Token:如果 Redis 中存在该 Token,则删除 Token 并执行后续业务逻辑;如果不存在,则说明是重复请求,直接返回错误
流程示意图:
Server Client Server Client 进入下单页面,请求获取Token 生成UUID作为Token,存入Redis(设置过期时间) 返回Token 提交订单(携带Token) 校验Redis中是否存在该Token,存在则删除 执行下单逻辑 返回下单结果 重复提交订单(携带相同Token) 校验Redis中已无该Token 返回"重复提交"错误
实战代码(Java + Redis)
第一步:获取 Token 接口
java
/**
* 获取防重复提交Token
*/
@GetMapping("/api/idempotent/token")
public Result<String> getIdempotentToken() {
// 生成唯一Token(UUID)
String token = UUID.randomUUID().toString().replace("-", "");
// 存入Redis,设置过期时间5分钟(根据业务调整,比如表单提交设置10分钟)
String redisKey = "idempotent:token:" + token;
redisTemplate.opsForValue().set(redisKey, "1", 5, TimeUnit.MINUTES);
return Result.success(token);
}
第二步:业务接口(结合 Spring AOP 实现更优雅)
先定义一个自定义注解,用于标记需要防重复提交的接口:
java
/**
* 防重复提交注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
// Token参数的名称(默认从请求头获取)
String tokenName() default "Idempotent-Token";
}
// AOP切面实现
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 1. 获取请求中的Token
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String token = request.getHeader(idempotent.tokenName());
if (StringUtils.isBlank(token)) {
throw new BizException("Token不能为空");
}
// 2. 校验Token是否存在于Redis
String redisKey = "idempotent:token:" + token;
Boolean isExist = redisTemplate.hasKey(redisKey);
if (Boolean.FALSE.equals(isExist)) {
throw new BizException("重复提交,请稍后再试");
}
// 3. 原子性删除Token(防止并发问题)
Boolean deleteSuccess = redisTemplate.delete(redisKey);
if (Boolean.FALSE.equals(deleteSuccess)) {
throw new BizException("重复提交,请稍后再试");
}
// 4. 执行原业务逻辑
return joinPoint.proceed();
}
}
第三步:使用注解标记业务接口
java
/**
* 表单提交接口(防重复提交)
*/
@PostMapping("/api/form/submit")
@Idempotent(tokenName = "Idempotent-Token")
public Result<String> submitForm(@RequestBody FormDTO formDTO) {
// 执行表单提交逻辑
formService.submit(formDTO);
return Result.success("提交成功");
}
优点 & 注意事项
✅ 优点:
-
实现优雅,通过 AOP 可以全局复用,开发成本低
-
性能好,基于 Redis 操作,响应速度快
-
适合前端交互场景,能有效防止用户重复点击
⚠️ 注意事项:
-
不适合核心交易链路:Redis 是缓存,存在宕机或数据丢失的风险,无法像数据库那样提供强一致性保证
-
Token 过期时间要合理:太短会导致正常请求失败,太长会占用 Redis 资源
-
必须保证"删除 Token"是原子操作:如果用"先查后删"的方式,会出现并发问题(两个请求同时查到 Token 存在,然后都执行删除,导致重复处理)
核心结论:这个方案适合"非核心链路的防重复提交",比如表单提交、评论发布、活动报名等;核心交易链路(下单、支付)不推荐单独使用,建议结合方案一(唯一索引)兜底。
4️⃣ MQ 消费幂等(面试必考,必须掌握)
MQ 消费的幂等是高并发系统中的高频问题,因为所有 MQ 中间件都无法保证"恰好一次"投递(除非用事务消息,但复杂度高),默认都是"至少一次"投递。所以,消费端必须自己处理重复消息。
核心思路
MQ 消息本身会携带一个全局唯一的消息 ID(比如 RocketMQ 的 msgId、Kafka 的 offset + partition),我们可以利用这个消息 ID 做去重;如果消息 ID 不可靠(比如有些 MQ 会重复生成 msgId),则可以用业务唯一 ID(比如订单号)做去重。
核心流程:
-
消费消息前,先检查该消息的 ID(msgId 或 bizId)是否已经处理过
-
如果已经处理过,则直接返回成功,不执行后续逻辑
-
如果未处理过,则执行消费逻辑,执行完成后,记录该消息 ID 已处理
一句话点破:
MQ 一定会重复,业务必须能扛住。
实战代码(Java + RocketMQ)
这里提供两种实现方式:基于 Redis 去重(适合非核心消息)和基于数据库去重表(适合核心消息)。
方式一:基于 Redis 去重(简单场景)
java
/**
* MQ 消费者(基于Redis去重)
*/
@Component
@RocketMQMessageListener(topic = "order_topic", consumerGroup = "order_consumer_group")
public class OrderConsumer implements RocketMQListener<MessageExt> {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private OrderService orderService;
@Override
public void onMessage(MessageExt messageExt) {
String msgId = messageExt.getMsgId(); // MQ自带的唯一消息ID
String body = new String(messageExt.getBody(), StandardCharsets.UTF_8);
OrderMessage orderMessage = JSON.parseObject(body, OrderMessage.class);
String bizId = orderMessage.getOrderNo(); // 业务唯一ID(订单号)
// 1. 先查Redis,判断是否已处理(用bizId更可靠,msgId可能重复)
String redisKey = "mq:idempotent:order:" + bizId;
Boolean isProcessed = redisTemplate.hasKey(redisKey);
if (Boolean.TRUE.equals(isProcessed)) {
log.info("消息已处理,msgId:{},bizId:{}", msgId, bizId);
return;
}
try {
// 2. 执行消费逻辑(比如更新订单状态)
orderService.processOrderMessage(orderMessage);
// 3. 标记为已处理(存入Redis,设置过期时间,比如24小时)
redisTemplate.opsForValue().set(redisKey, "1", 24, TimeUnit.HOURS);
log.info("消息处理成功,msgId:{},bizId:{}", msgId, bizId);
} catch (Exception e) {
log.error("消息处理失败,msgId:{},bizId:{}", msgId, bizId, e);
// 抛出异常,让MQ重试(根据业务调整重试策略)
throw new RuntimeException("消息处理失败,触发重试", e);
}
}
}
方式二:基于数据库去重表(核心消息,强一致)
第一步:创建 MQ 消息去重表
sql
CREATE TABLE mq_message_idempotent (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
msg_id VARCHAR(64) NOT NULL COMMENT 'MQ消息ID',
biz_id VARCHAR(64) NOT NULL COMMENT '业务唯一ID',
status TINYINT NOT NULL DEFAULT 0 COMMENT '处理状态:0-未处理,1-已处理',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_biz_id (biz_id) COMMENT '业务ID唯一约束'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT 'MQ消息幂等表';
第二步:消费逻辑代码
java
/**
* MQ 消费者(基于数据库去重表)
*/
@Component
@RocketMQMessageListener(topic = "payment_topic", consumerGroup = "payment_consumer_group")
public class PaymentConsumer implements RocketMQListener<MessageExt> {
@Autowired
private MqMessageIdempotentMapper idempotentMapper;
@Autowired
private PaymentService paymentService;
@Override
@Transactional(rollbackFor = Exception.class)
public void onMessage(MessageExt messageExt) {
String msgId = messageExt.getMsgId();
String body = new String(messageExt.getBody(), StandardCharsets.UTF_8);
PaymentMessage paymentMessage = JSON.parseObject(body, PaymentMessage.class);
String bizId = paymentMessage.getPayNo(); // 支付流水号(业务唯一ID)
// 1. 尝试插入去重表(唯一约束兜底)
MqMessageIdempotent idempotent = new MqMessageIdempotent();
idempotent.setMsgId(msgId);
idempotent.setBizId(bizId);
idempotent.setStatus(0); // 未处理
try {
idempotentMapper.insert(idempotent);
} catch (DuplicateKeyException e) {
// 重复消息,查询处理状态
MqMessageIdempotent exist = idempotentMapper.selectByBizId(bizId);
if (exist.getStatus() == 1) {
log.info("消息已处理,msgId:{},bizId:{}", msgId, bizId);
return;
} else {
// 消息正在处理中,抛出异常让MQ重试(或根据业务处理)
throw new RuntimeException("消息处理中,请稍后重试");
}
}
try {
// 2. 执行消费逻辑(比如处理支付结果)
paymentService.processPaymentResult(paymentMessage);
// 3. 更新去重表状态为"已处理"
idempotentMapper.updateStatusByBizId(bizId, 1);
log.info("消息处理成功,msgId:{},bizId:{}", msgId, bizId);
} catch (Exception e) {
log.error("消息处理失败,msgId:{},bizId:{}", msgId, bizId, e);
// 可以更新状态为"处理失败",后续人工介入
idempotentMapper.updateStatusByBizId(bizId, 2);
throw new RuntimeException("消息处理失败,触发重试", e);
}
}
}
优点 & 适用场景
✅ 优点:
-
可靠性高,尤其是基于数据库去重表的方式,能保证强一致性
-
适配所有 MQ 场景,通用性强
📌 适用场景:
-
基于 Redis :非核心消息,比如日志同步、通知推送、数据统计
-
基于数据库:核心消息,比如订单支付结果通知、库存变更消息、退款消息
面试小贴士:被问到"MQ 消费如何保证幂等"时,要先说明"MQ 无法避免重复投递",然后分场景给出方案,最后强调"核心消息必须用数据库去重表兜底",这样回答才够全面。
六、3 个最容易踩的幂等大坑(避坑指南)
很多人做了幂等处理,但还是出了问题,原因是踩了一些"隐性坑"。下面这 3 个坑,一定要避开:
❌ 1. 先查再插/先查再更(高并发下必炸)
这是最常见的错误做法:处理请求时,先查询数据库"这个请求是否已处理",如果没处理再执行插入/更新操作。代码示例:
java
// 错误代码:先查再插
public void createOrder(CreateOrderDTO dto) {
// 先查询订单是否存在
Order existOrder = orderMapper.selectByOrderNo(dto.getOrderNo());
if (existOrder != null) {
return;
}
// 插入订单
Order order = new Order();
order.setOrderNo(dto.getOrderNo());
orderMapper.insert(order);
}
问题出在"查询"和"插入"之间存在"时间窗口":高并发下,两个相同的请求可能同时查询到"订单不存在",然后同时插入订单,导致唯一索引冲突,或者直接生成两个订单(如果没加唯一索引)。
正确做法:
直接插/直接更,交给唯一约束或乐观锁兜底。
比如方案一中的"直接插入,捕获唯一索引冲突",方案二中的"直接更新,判断影响行数"------用数据库的原子操作代替"先查后写",避免并发问题。
❌ 2. 幂等逻辑不在事务里(数据灵异)
这个坑的场景:标记"请求已处理"的操作,和核心业务逻辑不在同一个事务里。比如:
java
// 错误代码:幂等标记不在事务中
@Transactional(rollbackFor = Exception.class)
public void processOrder(OrderMessage message) {
String bizId = message.getOrderNo();
// 1. 执行核心业务逻辑(更新订单状态)
orderMapper.updateStatus(bizId, "PAID");
// 2. 标记为已处理(存入Redis,不在事务中)
redisTemplate.opsForValue().set("order:processed:" + bizId, "1", 24, TimeUnit.HOURS);
}
问题:如果步骤 1 执行成功,但步骤 2 执行失败(比如 Redis 宕机),此时事务会回滚吗?不会!因为步骤 2 是 Redis 操作,不在数据库事务的管辖范围内。最终结果是:订单状态已经更新为"已支付",但 Redis 中没有标记"已处理"------当下次重复消息到来时,会再次执行步骤 1,导致订单状态被重复更新(虽然状态机可能阻止,但如果是其他业务可能出问题)。
更严重的情况:如果步骤 2 先执行(标记已处理),步骤 1 执行失败(事务回滚),会导致"标记已处理,但业务没执行"------后续请求都会被当成重复请求,直接返回,导致业务丢失。
正确做法:
-
如果用数据库去重(比如方案一、方案四的数据库去重表),一定要把"标记已处理"和核心业务逻辑放在同一个事务里------用数据库事务保证两者的原子性
-
如果用 Redis 去重,要采用"先执行业务,再标记已处理"的顺序,并且接受"极端情况下可能重复处理"的风险(因为 Redis 不支持事务回滚);如果是核心业务,建议用数据库去重表兜底
❌ 3. 幂等 = 吞异常(掩盖问题)
这个坑的错误认知:"只要是重复请求,不管什么情况都返回成功"------把幂等当成了"吞异常"的遮羞布。比如:
java
// 错误代码:幂等 = 吞异常
public void createOrder(CreateOrderDTO dto) {
try {
orderMapper.insert(dto);
} catch (Exception e) {
// 不管什么异常,都当成重复请求返回成功
log.error("创建订单失败", e);
return;
}
}
问题:如果是真正的业务异常(比如库存不足、参数错误),也会被当成重复请求,直接返回成功------用户以为订单创建成功,但实际上并没有,导致用户投诉;同时,开发人员无法及时发现业务问题,因为异常被吞掉了。
正确做法:
幂等只处理"重复请求"的异常,不处理"业务逻辑"的异常。
比如方案一中,只捕获"DuplicateKeyException"(唯一索引冲突,代表重复请求),其他异常(比如库存不足的 BizException)要正常抛出,让上层处理(比如返回错误信息给用户)。
核心原则:幂等是"保证重复请求的结果一致",而不是"掩盖所有异常"。
七、幂等与分布式事务(相辅相成)
很多人会把"幂等"和"分布式事务"分开看,但实际上,两者是相辅相成的------没有幂等,分布式事务就跑不起来。
为什么?因为分布式事务的核心是"跨服务的协调",而协调过程中必然会出现重试(比如 Saga 模式的补偿重试、TCC 模式的 Confirm/Cancel 重试)。如果这些重试的接口没有做幂等,就会导致重复处理,引发数据不一致。
下面是常见分布式事务场景中,幂等的作用:
| 分布式事务场景 | 幂等的作用 |
|---|---|
| Saga 模式 | Saga 分为"正向流程"和"补偿流程",当正向流程某一步失败时,会重试补偿流程;如果补偿接口没有做幂等,多次补偿会导致数据错误(比如重复退款) |
| TCC 模式 | TCC 的 Confirm(确认)和 Cancel(取消)接口可能因网络问题被重试;如果这些接口没有做幂等,会导致重复扣减/释放资源(比如重复扣库存) |
| MQ 事务消息 | 事务消息的"提交"或"回滚"可能被重试,消费端也会收到重复消息;幂等能保证重试和重复消息不会导致数据不一致 |
一句话总结:
没有幂等,分布式事务跑不起来;有了幂等,分布式事务才能更稳定。
八、推荐的 Java 技术组合(落地参考)
结合前面的方案,这里给出一套工业界常用的 Java 技术组合,覆盖大部分业务场景:
-
核心框架:Spring Boot / Spring Cloud(成熟稳定,生态完善)
-
数据存储:MySQL(唯一索引 + 状态机 + 去重表,保证强一致性)
-
缓存:Redis(Token 防重复提交 + 非核心消息去重,提升性能)
-
消息中间件:RocketMQ / Kafka(支持事务消息,适配高并发场景)
-
ORM 框架:MyBatis-Plus(简化数据库操作,支持乐观锁插件)
经验总结,记下来直接用:
核心链路靠 DB,外围防抖用 Redis。
-
核心链路(下单、支付、退款):用"唯一业务 ID + 唯一索引"兜底,结合"状态机 + 乐观锁"优化并发性能
-
外围链路(表单提交、通知推送、活动报名):用"Token + Redis"防重复提交,简单高效
-
MQ 消费:核心消息用"数据库去重表",非核心消息用"Redis 去重"
九、总结(核心观点再强调)
最后用几句话总结全文,帮大家梳理核心知识点:
-
幂等不是高级技巧,而是分布式高并发系统的"入场券"------你可以暂时没遇到幂等问题,但高并发一定会帮你遇到
-
幂等的本质是"判断请求是否已处理",核心思路是"唯一标识、状态不可逆、去重记录"
-
4 种主流方案各有适用场景:唯一索引最稳,状态机适合订单,Token 适合表单,MQ 去重分核心/非核心
-
避坑重点:别用"先查后写",幂等逻辑要在事务里,别把幂等当成吞异常的遮羞布
-
技术组合原则:核心链路靠 DB 保证强一致,外围链路用 Redis 提升性能
最后再送大家一句实战感悟:
做幂等不是为了"解决问题",而是为了"避免问题"------在高并发的世界里,预防永远比救火更重要。