幂等性设计的几种解决方案

122 阅读10分钟

幂等性是程序设计中保障系统可靠性的核心特性,指同一操作无论执行多少次,结果都与执行一次完全一致,不会重复创建数据、重复扣减金额、重复发送消息等。其核心解决的是网络重试、消息重发、用户重复提交等场景下的异常问题。以下是工业界主流的幂等性解决方案,按「适用场景 + 实现原理 + 优缺点 + 代码示例」分层讲解:

一、核心设计原则

在选择方案前,需明确幂等性设计的核心原则:

  1. 唯一性标识:为每次操作生成全局唯一的幂等号(Idempotent ID),作为去重依据;
  2. 原子性校验:校验 + 执行业务逻辑必须是原子操作(避免并发下重复执行);
  3. 无副作用:重复执行时,除 “返回成功” 外,不产生任何业务副作用;
  4. 性能平衡:避免过度设计(如全链路分布式锁)导致性能损耗。

二、主流解决方案(按场景优先级排序)

方案 1:基于唯一索引 / 主键(数据库层)

适用场景:新增数据场景(如创建订单、用户注册、商品入库),核心是避免重复插入。实现原理:利用数据库「唯一索引 / 主键约束」,确保重复插入时触发约束异常,业务层捕获异常并判定为 “操作已执行”。核心步骤

  1. 为业务关键字段(如订单号、用户 ID + 业务类型)创建唯一索引;
  2. 插入数据时,若抛出 DuplicateKeyException,则判定为重复操作,直接返回成功。

代码示例(Java + MySQL)

// 1. 数据库表设计:订单表,订单号order_no创建唯一索引
// CREATE UNIQUE INDEX uk_order_no ON t_order(order_no);

// 2. 业务代码
@Service
public class OrderService {
    @Autowired
    private OrderMapper orderMapper;
    
    @Transactional(rollbackFor = Exception.class)
    public String createOrder(OrderDTO orderDTO) {
        String orderNo = orderDTO.getOrderNo(); // 全局唯一订单号(如雪花算法生成)
        try {
            OrderDO orderDO = new OrderDO();
            orderDO.setOrderNo(orderNo);
            orderDO.setUserId(orderDTO.getUserId());
            orderDO.setAmount(orderDTO.getAmount());
            orderMapper.insert(orderDO); // 插入数据
            return "订单创建成功";
        } catch (DuplicateKeyException e) {
            // 捕获唯一索引冲突,判定为重复创建
            log.warn("订单{}已存在,无需重复创建", orderNo);
            return "订单创建成功"; // 幂等返回,结果与首次一致
        }
    }
}

优缺点

  • ✅ 优点:实现简单、性能高(数据库索引天然高效)、无需额外存储;
  • ❌ 缺点:仅适用于 “新增场景”,无法解决更新 / 删除的幂等性;需提前规划唯一索引字段。

方案 2:基于幂等号 + 状态机(业务层 + 数据库)

适用场景:有状态流转的操作(如订单支付、退款、物流状态更新),核心是避免状态重复变更。实现原理

  1. 为每次操作生成全局唯一的幂等号(如 reqIdbizId);
  2. 业务表新增「幂等号字段 + 状态字段」,并为幂等号创建唯一索引;
  3. 执行操作时,先校验 “幂等号是否存在 + 当前状态是否允许执行”,仅当两者满足时才执行业务逻辑。

核心步骤

  1. 生成幂等号(如 UUID、雪花 ID、用户 ID + 时间戳 + 业务类型);
  2. 数据库操作:INSERT ... ON DUPLICATE KEY UPDATE 或 SELECT FOR UPDATE 校验状态;
  3. 状态机约束:仅允许从 “初始状态”→“目标状态”(如订单仅能从 “待支付”→“已支付”)。

代码示例(订单支付场景)

// 1. 数据库表设计:订单表新增idempotent_id(唯一索引)、status字段
// CREATE UNIQUE INDEX uk_idempotent_id ON t_order(idempotent_id);

// 2. 业务代码
@Service
public class PayService {
    @Autowired
    private OrderMapper orderMapper;
    
    @Transactional(rollbackFor = Exception.class)
    public String payOrder(String idempotentId, String orderNo) {
        // 1. 加行锁查询订单,确保原子性
        OrderDO orderDO = orderMapper.selectByOrderNoForUpdate(orderNo);
        if (orderDO == null) {
            return "订单不存在";
        }
        
        // 2. 校验幂等号和状态:幂等号已存在 → 操作已执行;状态非待支付 → 拒绝执行
        if (idempotentId.equals(orderDO.getIdempotentId())) {
            log.warn("订单{}支付操作已执行,幂等号{}", orderNo, idempotentId);
            return "支付成功";
        }
        if (!"WAIT_PAY".equals(orderDO.getStatus())) {
            return "订单状态非待支付,无法支付";
        }
        
        // 3. 执行业务逻辑:扣减库存、更新订单状态、记录幂等号
        orderDO.setStatus("PAID");
        orderDO.setIdempotentId(idempotentId);
        orderMapper.updateById(orderDO);
        // 其他业务:扣减库存、生成支付记录...
        
        return "支付成功";
    }
}

优缺点

  • ✅ 优点:适配绝大多数业务场景(新增 / 更新 / 状态变更)、状态机约束更安全;
  • ❌ 缺点:需额外维护幂等号和状态字段,开发成本略高。

方案 3:基于分布式锁(Redis/ZooKeeper)

适用场景:高并发下的核心操作(如秒杀下单、库存扣减),核心是避免并发重复执行。实现原理

  1. 以 “业务唯一标识”(如订单号、商品 ID + 用户 ID)为锁 Key;
  2. 抢占分布式锁(如 Redis 的 SET NX EX),只有抢到锁的线程能执行操作;
  3. 操作完成后释放锁,重复请求因抢不到锁直接返回成功。

代码示例(Redis 分布式锁 + Lua 脚本,保证原子性)

@Service
public class SeckillService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private SeckillMapper seckillMapper;
    
    // Lua脚本:抢锁+扣减库存原子操作
    private static final String LUA_SCRIPT = """
        local key = KEYS[1]
        local stockKey = KEYS[2]
        local userId = ARGV[1]
        -- 1. 检查是否已抢过锁(避免重复下单)
        if redis.call('exists', key) == 1 then
            return 1 -- 已抢锁,重复操作
        end
        -- 2. 检查库存
        local stock = tonumber(redis.call('get', stockKey))
        if stock <= 0 then
            return 0 -- 库存不足
        end
        -- 3. 扣减库存+加锁(设置过期时间,避免死锁)
        redis.call('decr', stockKey)
        redis.call('setex', key, 60, userId)
        return 2 -- 操作成功
        """;
    
    public String seckill(Long goodsId, Long userId) {
        // 锁Key:秒杀_商品ID_用户ID(保证唯一)
        String lockKey = "seckill:lock:" + goodsId + ":" + userId;
        String stockKey = "seckill:stock:" + goodsId;
        
        DefaultRedisScript<Long> script = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
        Long result = redisTemplate.execute(script, Arrays.asList(lockKey, stockKey), userId.toString());
        
        if (result == 1) {
            return "您已参与过秒杀,无需重复操作"; // 幂等返回
        } else if (result == 0) {
            return "秒杀库存不足";
        } else {
            // 执行业务入库(兜底,即使Redis锁失效,数据库唯一索引仍能保证幂等)
            seckillMapper.insertSeckillRecord(goodsId, userId);
            return "秒杀成功";
        }
    }
}

优缺点

  • ✅ 优点:适配高并发场景、支持所有操作类型(增删改查)、实时性高;
  • ❌ 缺点:需维护分布式锁(过期时间难设置)、有性能损耗(网络 IO)、可能出现死锁(需设置过期时间)。

方案 4:基于 Token 机制(接口层)

适用场景:前端重复提交(如表单提交、按钮多次点击)、API 接口防重放。实现原理

  1. 前端请求 “获取 Token” 接口,后端生成唯一 Token(如 UUID),存储到 Redis(设置过期时间),并返回给前端;
  2. 前端提交业务请求时,携带该 Token;
  3. 后端校验 Token:若 Redis 中存在则删除(原子操作),执行业务逻辑;若不存在则判定为重复请求。

核心步骤

前端:获取Token → 携带Token提交表单 → 后端:校验Token(存在则删除+执行业务)/(不存在则拒绝)

代码示例

// 1. 获取Token接口
@RestController
@RequestMapping("/token")
public class TokenController {
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @GetMapping("/get")
    public String getToken() {
        String token = UUID.randomUUID().toString();
        // 存储Token,过期时间5分钟(避免Redis堆积)
        redisTemplate.opsForValue().set("token:" + token, "valid", 5, TimeUnit.MINUTES);
        return token;
    }
}

// 2. 业务接口(校验Token)
@RestController
@RequestMapping("/order")
public class OrderController {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private OrderService orderService;
    
    @PostMapping("/create")
    public String createOrder(@RequestParam String token, @RequestBody OrderDTO orderDTO) {
        // 原子操作:删除Token(避免并发重复提交)
        Boolean exists = redisTemplate.delete("token:" + token);
        if (!exists) {
            return "重复提交,请稍后再试";
        }
        // 执行业务逻辑
        return orderService.createOrder(orderDTO);
    }
}

优缺点

  • ✅ 优点:适配前端重复提交场景、实现简单、无数据库侵入;
  • ❌ 缺点:需额外的 Token 分发流程、依赖 Redis、无法解决服务端重试场景(如消息重发)。

方案 5:基于消息幂等(消息队列层)

适用场景:消息队列消费场景(如 RocketMQ/Kafka 消费消息),核心是避免重复消费。实现原理

  1. 生产者发送消息时,携带全局唯一的消息 ID(如 msgId 或业务唯一键);
  2. 消费者消费前,先校验该消息 ID 是否已消费(存储到 Redis / 数据库);
  3. 消费完成后,标记消息为 “已消费”(校验 + 标记需原子操作)。

代码示例(RocketMQ 消费幂等)

@Component
public class OrderMsgConsumer implements RocketMQListener<MessageExt> {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private OrderService orderService;
    
    @Override
    public void onMessage(MessageExt messageExt) {
        // 1. 获取消息唯一标识(msgId或业务键)
        String msgId = messageExt.getMsgId();
        String bizKey = new String(messageExt.getBody()).split(",")[0]; // 如订单号
        
        // 2. 幂等校验:Redis SET NX 原子操作
        String key = "msg:consumed:" + bizKey;
        Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "consumed", 24, TimeUnit.HOURS);
        if (!success) {
            log.warn("消息{}已消费,幂等返回", msgId);
            return;
        }
        
        // 3. 执行业务消费逻辑
        orderService.handleMsg(bizKey);
    }
}

进阶优化

  • RocketMQ 内置 MSG_ID 但可能重复(如消息重发),建议使用「业务唯一键」(如订单号)作为幂等号;
  • 消费完成后手动提交 offset(避免自动提交导致重复消费)。

方案 6:基于版本号 / 乐观锁(更新场景)

适用场景:数据更新场景(如商品库存扣减、用户余额修改),核心是避免并发更新导致数据不一致。实现原理

  1. 业务表新增「版本号(version)」字段,初始值为 0;
  2. 更新数据时,SQL 中携带版本号:UPDATE table SET ..., version = version + 1 WHERE id = ? AND version = ?
  3. 若更新行数为 0,说明版本号不匹配(已被其他线程更新),判定为重复操作。

代码示例

@Service
public class StockService {
    @Autowired
    private StockMapper stockMapper;
    
    @Transactional(rollbackFor = Exception.class)
    public String deductStock(Long goodsId, Integer num, Integer version) {
        // 乐观锁更新:仅当版本号匹配时才扣减库存
        int rows = stockMapper.deductStock(goodsId, num, version);
        if (rows == 0) {
            log.warn("商品{}库存扣减失败,版本号{}已过期", goodsId, version);
            return "操作失败(重复请求/并发更新)";
        }
        return "库存扣减成功";
    }
}

// Mapper XML
<update id="deductStock">
    UPDATE t_stock 
    SET stock = stock - #{num}, version = version + 1 
    WHERE goods_id = #{goodsId} AND version = #{version}
</update>

优缺点

  • ✅ 优点:无锁设计、性能高、适配更新场景;
  • ❌ 缺点:需额外维护版本号字段、重复请求会返回失败(需业务层重试)。

三、方案选型指南(按场景匹配)

业务场景推荐方案补充说明
新增数据(订单 / 用户)唯一索引 + 幂等号优先数据库层方案,性能最高
状态变更(支付 / 退款)幂等号 + 状态机 + 分布式锁结合状态机避免重复变更
前端重复提交(表单)Token 机制简单高效,无侵入
消息消费(MQ)消息 ID + Redis / 数据库标记优先业务唯一键,避免 MSG_ID 重复
并发更新(库存 / 余额)版本号 / 乐观锁无锁设计,适配高并发
高并发核心操作(秒杀)分布式锁 + 唯一索引(双重保障)分布式锁防并发,唯一索引兜底

四、避坑要点

  1. 幂等号设计:必须是全局唯一(如雪花 ID),避免 “用户 ID + 时间戳” 因并发重复;
  2. 原子性保障:校验 + 执行业务必须原子(如数据库事务、Redis Lua 脚本、分布式锁),否则并发下仍会重复;
  3. 过期清理:Redis 存储的幂等号 / Token 需设置过期时间,避免数据堆积;
  4. 异常处理:捕获幂等异常时,需返回 “成功”(而非 “重复操作”),保证结果一致性;
  5. 兜底方案:核心场景建议 “双重幂等”(如分布式锁 + 唯一索引),避免单一方案失效。

五、面试高频考点

  1. **幂等性的核心是什么?**答:核心是 “唯一性标识 + 原子性校验”,确保重复操作无业务副作用。
  2. **分布式锁和乐观锁的选型区别?**答:分布式锁适合高并发写、强一致性场景(如秒杀),但有性能损耗;乐观锁适合读多写少、并发更新场景,无锁设计性能更高。
  3. **消息消费幂等为什么不用 MSG_ID?**答:MQ 的 MSG_ID 可能因消息重发(如 Broker 重启)重复,建议用业务唯一键(如订单号)作为幂等号。