高并发与分布式系统中的幂等处理

一、为什么高并发系统一定会"被幂等教育"?

先说一句可能不太好听的大实话:

绝大多数高并发事故,不是代码写错,而是请求被执行了不止一次。

很多新手工程师在本地测试时,接口逻辑跑得顺风顺水,但一上生产环境,就容易栽在"重复请求"这个坑里。不是代码逻辑有问题,而是真实的线上环境,根本不存在"请求只来一次"的理想状态。

你一定会遇到这些无法避免的场景:

  • 网络抖动:客户端发完请求后,因网络延迟没及时收到响应,误以为请求失败

  • 客户端重试:前端按钮重复点击、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 标记请求,用数据库唯一索引兜底",从根源上防止重复数据插入。

核心思路

  1. 客户端发起请求时,携带一个全局唯一的业务 ID(比如订单号 orderNo、业务流水号 bizId)------这个 ID 可以由客户端生成,也可以由服务端生成后返回给客户端

  2. 服务端处理请求时,将这个唯一业务 ID 存入业务表(比如订单表)

  3. 在业务表上给这个唯一业务 ID 加"唯一索引",利用数据库的约束机制,防止重复插入数据

  4. 当重复请求到来时,数据库会抛出唯一约束冲突异常,服务端捕获异常后,直接返回"处理成功"(因为第一次请求已经处理完成)

实战代码(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️⃣ 状态机 + 乐观锁(订单系统标配)

这个方案主要适用于"有明确状态流转"的业务场景,比如订单、支付、退款。核心是"通过状态机约束业务状态的流转,并用乐观锁保证并发安全",避免重复处理。

核心思路

  1. 给业务实体定义清晰的状态机,比如订单状态:待支付(CREATED)→ 已支付(PAID)→ 已发货(SHIPPED)→ 已完成(COMPLETED)

  2. 状态只能"向前流转",不能"向后流转"(比如已支付的订单,不能再回到待支付状态)

  3. 更新状态时,通过"乐观锁"(比如版本号 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 有效性",从而防止重复提交。

核心流程

  1. 客户端先向服务端请求"获取 Token"(比如用户进入下单页面时,前端调用接口获取 Token)

  2. 服务端生成一个全局唯一的 Token(比如用 UUID),存入 Redis(设置过期时间),然后返回给客户端

  3. 客户端发起业务请求时,将 Token 放在请求头或请求参数中一起提交

  4. 服务端收到请求后,先校验 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(比如订单号)做去重。

核心流程:

  1. 消费消息前,先检查该消息的 ID(msgId 或 bizId)是否已经处理过

  2. 如果已经处理过,则直接返回成功,不执行后续逻辑

  3. 如果未处理过,则执行消费逻辑,执行完成后,记录该消息 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 去重"


九、总结(核心观点再强调)

最后用几句话总结全文,帮大家梳理核心知识点:

  1. 幂等不是高级技巧,而是分布式高并发系统的"入场券"------你可以暂时没遇到幂等问题,但高并发一定会帮你遇到

  2. 幂等的本质是"判断请求是否已处理",核心思路是"唯一标识、状态不可逆、去重记录"

  3. 4 种主流方案各有适用场景:唯一索引最稳,状态机适合订单,Token 适合表单,MQ 去重分核心/非核心

  4. 避坑重点:别用"先查后写",幂等逻辑要在事务里,别把幂等当成吞异常的遮羞布

  5. 技术组合原则:核心链路靠 DB 保证强一致,外围链路用 Redis 提升性能

最后再送大家一句实战感悟:

做幂等不是为了"解决问题",而是为了"避免问题"------在高并发的世界里,预防永远比救火更重要。

相关推荐
JZC_xiaozhong17 小时前
主数据同步失效引发的业务风险与集成架构治理
大数据·架构·数据一致性·mdm·主数据管理·数据孤岛解决方案·数据集成与应用集成
沛沛老爹18 小时前
Web开发者进阶AI:Agent Skills-深度迭代处理架构——从递归函数到智能决策引擎
java·开发语言·人工智能·科技·架构·企业开发·发展趋势
小雨青年18 小时前
鸿蒙 HarmonyOS 6 | ArkUI (07):导航架构 Navigation 组件 (V2) 与路由栈管理最佳实践
华为·架构·harmonyos
IT 行者18 小时前
微服务架构选型指南:中小型软件公司的理性思考
微服务·云原生·架构
喜欢吃豆19 小时前
深度解析:FFmpeg 远程流式解复用原理与工程实践
人工智能·架构·ffmpeg·大模型·音视频·多模态
oMcLin19 小时前
如何在 Manjaro Linux 上通过配置systemd服务管理,提升微服务架构的启动速度与资源效率
linux·微服务·架构
Chan1619 小时前
微服务 - Higress网关
java·spring boot·微服务·云原生·面试·架构·intellij-idea
tle_sammy19 小时前
【架构的本质 07】数据架构:在 AI 时代,数据是流动的资产,不是静态的表格
人工智能·架构
没有bug.的程序员19 小时前
Serverless 架构深度解析:FaaS/BaaS、冷启动困境与场景适配指南
云原生·架构·serverless·架构设计·冷启动·baas·faas