🔄 分布式幂等性设计:让重复操作"不重复"!

60 阅读10分钟

副标题:从理论到实践,彻底搞懂幂等性!🎯


🎬 开场:可怕的重复执行

真实故障案例

某支付系统的灾难:

用户操作:
10:00:00  用户点击"支付"按钮
          服务器处理中...
10:00:05  用户等不及,再次点击"支付"
          服务器又收到一次请求...

结果:
订单ID:20231212001
应扣款:100元
实际扣款:200元!❌

用户投诉:
"我只买了100块钱的东西,
 为什么扣了我200?!"

原因:
支付接口没有做幂等性处理!

幂等性的定义

幂等性(Idempotence):
同一个操作执行多次,结果和执行一次相同

数学定义:
f(f(x)) = f(x)

例子:
幂等操作 ✅:
- 查询操作:SELECT * FROM users
- 删除操作:DELETE FROM users WHERE id = 1
- 设置操作:UPDATE users SET status = 1 WHERE id = 1

非幂等操作 ❌:
- 增加操作:UPDATE users SET balance = balance + 100
- 创建操作:INSERT INTO orders VALUES (...)
- 扣减操作:UPDATE products SET stock = stock - 1

📚 为什么需要幂等性?

场景1:网络重试

客户端 → 服务器:创建订单请求
           ↓
       网络超时(实际已创建)
           ↓
客户端 → 服务器:重试,再次创建订单
           ↓
       创建了两个订单!❌

场景2:消息队列重复消费

生产者 → MQ → 消费者:处理订单
              ↓
         消费者处理完成
         但ACK消息丢失
              ↓
         MQ → 消费者:重新投递
              ↓
         订单被重复处理!❌

场景3:用户重复操作

用户快速点击"提交"按钮3次
    ↓
  3个请求到达服务器
    ↓
  创建了3条相同数据!❌

🛠️ 幂等性实现方案

方案1:数据库唯一约束 ⭐⭐⭐⭐⭐

最简单可靠的方案!

-- 订单表添加唯一约束
CREATE TABLE `orders` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
  `order_no` VARCHAR(64) NOT NULL UNIQUE,  -- 订单号唯一约束
  `user_id` BIGINT NOT NULL,
  `amount` DECIMAL(10,2) NOT NULL,
  `status` TINYINT NOT NULL,
  `create_time` DATETIME NOT NULL,
  UNIQUE KEY `uk_order_no` (`order_no`)  -- 唯一索引
);

-- 支付流水表
CREATE TABLE `payment_record` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
  `order_no` VARCHAR(64) NOT NULL UNIQUE,  -- 订单号唯一约束
  `transaction_id` VARCHAR(64) NOT NULL UNIQUE,  -- 交易流水号唯一约束
  `amount` DECIMAL(10,2) NOT NULL,
  `status` TINYINT NOT NULL,
  `create_time` DATETIME NOT NULL,
  UNIQUE KEY `uk_order_no` (`order_no`),
  UNIQUE KEY `uk_transaction_id` (`transaction_id`)
);

代码实现

/**
 * 使用数据库唯一约束保证幂等性
 */
@Service
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 创建订单(幂等)
     */
    public Order createOrder(CreateOrderRequest request) {
        String orderNo = request.getOrderNo();  // 客户端生成唯一订单号
        
        // 1. 先查询订单是否已存在
        Order existingOrder = orderMapper.selectByOrderNo(orderNo);
        if (existingOrder != null) {
            log.info("订单已存在,返回已有订单: {}", orderNo);
            return existingOrder;  // 幂等:返回已有订单
        }
        
        // 2. 创建新订单
        Order order = new Order();
        order.setOrderNo(orderNo);
        order.setUserId(request.getUserId());
        order.setAmount(request.getAmount());
        order.setStatus(OrderStatus.CREATED);
        order.setCreateTime(new Date());
        
        try {
            orderMapper.insert(order);
            log.info("订单创建成功: {}", orderNo);
            return order;
            
        } catch (DuplicateKeyException e) {
            // 3. 唯一约束冲突,说明并发创建了
            log.warn("订单号重复,返回已有订单: {}", orderNo);
            return orderMapper.selectByOrderNo(orderNo);
        }
    }
}

优缺点

优点 ✅:
- 实现简单
- 性能好
- 可靠性高
- 数据库层保证

缺点 ❌:
- 依赖数据库
- 需要业务唯一标识

适用场景:
- 订单创建
- 支付请求
- 用户注册
- 推荐方案 ⭐⭐⭐⭐⭐

方案2:全局唯一ID(Token机制)⭐⭐⭐⭐⭐

防止重复提交的标准方案!

原理

流程:
1. 客户端请求获取Token
2. 服务器生成Token并存入Redis
3. 客户端提交业务请求时携带Token
4. 服务器验证Token并删除(保证只能用一次)
5. 执行业务逻辑

┌──────────┐
│  客户端  │
└────┬─────┘
     │ ① 获取Token
     ↓
┌────────────┐      ┌─────────┐
│  服务器    │─────▶│  Redis  │
│            │  ②   │ Token   │
└────┬───────┘◀─────└─────────┘
     │ ③ 返回Token
     ↓
┌──────────┐
│  客户端  │
│ 保存Token│
└────┬─────┘
     │ ④ 提交请求 + Token
     ↓
┌────────────┐      ┌─────────┐
│  服务器    │      │  Redis  │
│  验证Token │─────▶│检查删除 │
│            │  ⑤   │         │
└────┬───────┘      └─────────┘
     │ ⑥ 执行业务
     ↓
   完成

代码实现

/**
 * Token幂等性服务
 */
@Service
public class IdempotentTokenService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String TOKEN_PREFIX = "idempotent:token:";
    private static final long TOKEN_EXPIRE_TIME = 5 * 60;  // 5分钟
    
    /**
     * 生成Token
     */
    public String generateToken(Long userId) {
        // 生成唯一Token
        String token = UUID.randomUUID().toString().replace("-", "");
        
        // 存入Redis
        String key = TOKEN_PREFIX + token;
        redisTemplate.opsForValue().set(key, String.valueOf(userId), 
            TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
        
        log.info("生成幂等Token: userId={}, token={}", userId, token);
        
        return token;
    }
    
    /**
     * 验证并消费Token(原子操作)
     */
    public boolean validateAndConsumeToken(String token, Long userId) {
        if (token == null || token.isEmpty()) {
            return false;
        }
        
        String key = TOKEN_PREFIX + token;
        
        // 使用Lua脚本保证原子性:检查 + 删除
        String luaScript = 
            "if redis.call('get', KEYS[1]) == ARGV[1] then\n" +
            "    return redis.call('del', KEYS[1])\n" +
            "else\n" +
            "    return 0\n" +
            "end";
        
        RedisScript<Long> script = RedisScript.of(luaScript, Long.class);
        
        Long result = redisTemplate.execute(
            script, 
            Collections.singletonList(key), 
            String.valueOf(userId)
        );
        
        boolean success = result != null && result == 1;
        
        if (success) {
            log.info("Token验证成功: token={}", token);
        } else {
            log.warn("Token验证失败(重复或过期): token={}", token);
        }
        
        return success;
    }
}

/**
 * 订单Controller
 */
@RestController
@RequestMapping("/order")
public class OrderController {
    
    @Autowired
    private IdempotentTokenService tokenService;
    
    @Autowired
    private OrderService orderService;
    
    /**
     * 获取幂等Token
     */
    @GetMapping("/token")
    public Result<String> getToken(@RequestParam Long userId) {
        String token = tokenService.generateToken(userId);
        return Result.success(token);
    }
    
    /**
     * 创建订单(幂等)
     */
    @PostMapping("/create")
    public Result<Order> createOrder(
        @RequestBody CreateOrderRequest request,
        @RequestHeader("X-Idempotent-Token") String token) {
        
        Long userId = request.getUserId();
        
        // 验证并消费Token
        if (!tokenService.validateAndConsumeToken(token, userId)) {
            return Result.fail("请勿重复提交");
        }
        
        // 执行业务逻辑
        Order order = orderService.createOrder(request);
        
        return Result.success(order);
    }
}

前端使用

/**
 * 前端Token使用
 */
class OrderForm {
    
    constructor() {
        this.token = null;
    }
    
    /**
     * 页面加载时获取Token
     */
    async onPageLoad() {
        const userId = this.getUserId();
        
        const response = await fetch(`/order/token?userId=${userId}`);
        const result = await response.json();
        
        if (result.code === 200) {
            this.token = result.data;
            console.log('获取Token成功:', this.token);
        }
    }
    
    /**
     * 提交订单
     */
    async submitOrder() {
        if (!this.token) {
            alert('Token未获取,请刷新页面');
            return;
        }
        
        const orderData = this.getOrderData();
        
        const response = await fetch('/order/create', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-Idempotent-Token': this.token  // 携带Token
            },
            body: JSON.stringify(orderData)
        });
        
        const result = await response.json();
        
        if (result.code === 200) {
            alert('订单创建成功');
            // Token已消费,重新获取
            await this.onPageLoad();
        } else {
            alert(result.message);
        }
    }
}

方案3:状态机(State Machine)⭐⭐⭐⭐

适合有明确状态流转的场景!

/**
 * 订单状态机
 */
public enum OrderStatus {
    CREATED(1, "已创建"),
    PAID(2, "已支付"),
    SHIPPED(3, "已发货"),
    COMPLETED(4, "已完成"),
    CANCELLED(5, "已取消");
    
    private final int code;
    private final String desc;
    
    OrderStatus(int code, String desc) {
        this.code = code;
        this.desc = desc;
    }
}

/**
 * 订单服务(使用状态机保证幂等性)
 */
@Service
public class OrderStateMachineService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 支付订单(幂等)
     */
    @Transactional
    public boolean payOrder(String orderNo) {
        // 1. 查询订单
        Order order = orderMapper.selectByOrderNo(orderNo);
        
        if (order == null) {
            throw new BusinessException("订单不存在");
        }
        
        // 2. 检查当前状态
        if (order.getStatus() == OrderStatus.PAID) {
            log.info("订单已支付,幂等返回成功: {}", orderNo);
            return true;  // 幂等:已支付,直接返回成功
        }
        
        if (order.getStatus() != OrderStatus.CREATED) {
            throw new BusinessException("订单状态不允许支付: " + order.getStatus());
        }
        
        // 3. 更新订单状态(使用乐观锁)
        int rows = orderMapper.updateStatusWithVersion(
            orderNo, 
            OrderStatus.CREATED,  // 期望当前状态
            OrderStatus.PAID,     // 目标状态
            order.getVersion()    // 版本号
        );
        
        if (rows == 0) {
            // 更新失败,可能是并发修改
            log.warn("订单状态更新失败,可能已被处理: {}", orderNo);
            // 重新查询确认状态
            order = orderMapper.selectByOrderNo(orderNo);
            return order.getStatus() == OrderStatus.PAID;
        }
        
        log.info("订单支付成功: {}", orderNo);
        return true;
    }
}

SQL实现(乐观锁)

<!-- OrderMapper.xml -->
<update id="updateStatusWithVersion">
    UPDATE orders
    SET status = #{newStatus},
        version = version + 1,
        update_time = NOW()
    WHERE order_no = #{orderNo}
      AND status = #{oldStatus}
      AND version = #{version}
</update>

状态流转规则

订单状态流转:

CREATED(已创建)
    ↓ pay()
PAID(已支付)
    ↓ ship()
SHIPPED(已发货)
    ↓ complete()
COMPLETED(已完成)

不允许的流转:
PAID → CREATED  ❌
SHIPPED → PAID  ❌
COMPLETED → *   ❌

幂等性:
当前状态已是目标状态,直接返回成功

方案4:去重表 ⭐⭐⭐⭐

专门用于记录已处理的请求!

-- 幂等去重表
CREATE TABLE `idempotent_record` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
  `business_type` VARCHAR(32) NOT NULL COMMENT '业务类型',
  `business_key` VARCHAR(128) NOT NULL COMMENT '业务唯一标识',
  `request_id` VARCHAR(64) NOT NULL COMMENT '请求ID',
  `request_data` TEXT COMMENT '请求数据',
  `response_data` TEXT COMMENT '响应数据',
  `status` TINYINT NOT NULL COMMENT '状态:1-处理中,2-成功,3-失败',
  `create_time` DATETIME NOT NULL,
  `update_time` DATETIME NOT NULL,
  UNIQUE KEY `uk_business` (`business_type`, `business_key`)
);

代码实现

/**
 * 幂等记录服务
 */
@Service
public class IdempotentRecordService {
    
    @Autowired
    private IdempotentRecordMapper recordMapper;
    
    /**
     * 检查并记录请求
     */
    public IdempotentResult checkAndRecord(
        String businessType, 
        String businessKey, 
        String requestData) {
        
        // 1. 查询是否已处理
        IdempotentRecord record = recordMapper.selectByBusinessKey(
            businessType, businessKey
        );
        
        if (record != null) {
            // 已处理过
            if (record.getStatus() == IdempotentStatus.SUCCESS) {
                log.info("请求已处理成功,幂等返回: {}-{}", businessType, businessKey);
                return IdempotentResult.alreadyProcessed(record.getResponseData());
            } else if (record.getStatus() == IdempotentStatus.PROCESSING) {
                log.warn("请求正在处理中: {}-{}", businessType, businessKey);
                return IdempotentResult.processing();
            } else {
                log.warn("请求之前处理失败: {}-{}", businessType, businessKey);
                // 允许重试
            }
        }
        
        // 2. 记录请求(状态:处理中)
        record = new IdempotentRecord();
        record.setBusinessType(businessType);
        record.setBusinessKey(businessKey);
        record.setRequestId(UUID.randomUUID().toString());
        record.setRequestData(requestData);
        record.setStatus(IdempotentStatus.PROCESSING);
        record.setCreateTime(new Date());
        record.setUpdateTime(new Date());
        
        try {
            recordMapper.insert(record);
            return IdempotentResult.newRequest(record.getId());
            
        } catch (DuplicateKeyException e) {
            // 并发插入,说明正在处理
            log.warn("并发请求,已有其他线程处理: {}-{}", businessType, businessKey);
            return IdempotentResult.processing();
        }
    }
    
    /**
     * 更新处理结果
     */
    public void updateResult(Long recordId, boolean success, String responseData) {
        IdempotentStatus status = success ? 
            IdempotentStatus.SUCCESS : IdempotentStatus.FAILED;
        
        recordMapper.updateStatus(recordId, status, responseData);
    }
}

/**
 * 订单服务(使用去重表)
 */
@Service
public class OrderIdempotentService {
    
    @Autowired
    private IdempotentRecordService recordService;
    
    @Autowired
    private OrderService orderService;
    
    /**
     * 创建订单(幂等)
     */
    public Order createOrderIdempotent(CreateOrderRequest request) {
        String businessType = "ORDER_CREATE";
        String businessKey = request.getOrderNo();
        String requestData = JSON.toJSONString(request);
        
        // 1. 检查并记录
        IdempotentResult result = recordService.checkAndRecord(
            businessType, businessKey, requestData
        );
        
        if (result.isAlreadyProcessed()) {
            // 已处理,返回之前的结果
            return JSON.parseObject(result.getResponseData(), Order.class);
        }
        
        if (result.isProcessing()) {
            // 正在处理,返回提示
            throw new BusinessException("请求正在处理中,请稍后查询");
        }
        
        // 2. 执行业务逻辑
        Order order = null;
        try {
            order = orderService.createOrder(request);
            
            // 3. 记录成功结果
            recordService.updateResult(
                result.getRecordId(), 
                true, 
                JSON.toJSONString(order)
            );
            
            return order;
            
        } catch (Exception e) {
            // 4. 记录失败结果
            recordService.updateResult(
                result.getRecordId(), 
                false, 
                e.getMessage()
            );
            throw e;
        }
    }
}

方案5:分布式锁 ⭐⭐⭐

适合高并发场景!

/**
 * 使用分布式锁保证幂等性
 */
@Service
public class OrderLockService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private OrderService orderService;
    
    /**
     * 创建订单(使用分布式锁)
     */
    public Order createOrderWithLock(CreateOrderRequest request) {
        String orderNo = request.getOrderNo();
        String lockKey = "order:create:lock:" + orderNo;
        
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 尝试加锁,最多等待10秒,锁30秒后自动释放
            boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
            
            if (!locked) {
                throw new BusinessException("系统繁忙,请稍后重试");
            }
            
            // 双重检查:订单是否已存在
            Order existingOrder = orderService.getByOrderNo(orderNo);
            if (existingOrder != null) {
                log.info("订单已存在,幂等返回: {}", orderNo);
                return existingOrder;
            }
            
            // 创建订单
            Order order = orderService.createOrder(request);
            log.info("订单创建成功: {}", orderNo);
            
            return order;
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BusinessException("创建订单失败", e);
            
        } finally {
            // 释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

🎯 实战场景

场景1:支付回调幂等

/**
 * 支付回调处理(幂等)
 */
@RestController
@RequestMapping("/payment/callback")
public class PaymentCallbackController {
    
    @Autowired
    private IdempotentRecordService recordService;
    
    @Autowired
    private PaymentService paymentService;
    
    /**
     * 支付宝回调
     */
    @PostMapping("/alipay")
    public String alipayCallback(@RequestBody String requestBody) {
        // 解析回调参数
        Map<String, String> params = parseParams(requestBody);
        String outTradeNo = params.get("out_trade_no");  // 商户订单号
        String tradeNo = params.get("trade_no");         // 支付宝交易号
        
        // 幂等处理
        String businessType = "ALIPAY_CALLBACK";
        String businessKey = tradeNo;  // 使用支付宝交易号作为唯一标识
        
        IdempotentResult result = recordService.checkAndRecord(
            businessType, businessKey, requestBody
        );
        
        if (result.isAlreadyProcessed()) {
            log.info("支付回调已处理: {}", tradeNo);
            return "success";  // 返回成功,避免支付宝重复回调
        }
        
        if (result.isProcessing()) {
            log.warn("支付回调正在处理: {}", tradeNo);
            return "processing";
        }
        
        try {
            // 验证签名
            boolean signVerified = verifySign(params);
            if (!signVerified) {
                throw new BusinessException("签名验证失败");
            }
            
            // 处理支付结果
            paymentService.handlePaymentCallback(outTradeNo, tradeNo, params);
            
            // 记录成功
            recordService.updateResult(result.getRecordId(), true, "success");
            
            return "success";
            
        } catch (Exception e) {
            log.error("处理支付回调失败: {}", tradeNo, e);
            recordService.updateResult(result.getRecordId(), false, e.getMessage());
            return "fail";
        }
    }
}

场景2:MQ消息幂等消费

/**
 * 订单消息消费者(幂等)
 */
@Component
public class OrderMessageConsumer {
    
    @Autowired
    private IdempotentRecordService recordService;
    
    @Autowired
    private OrderService orderService;
    
    /**
     * 消费订单创建消息
     */
    @RabbitListener(queues = "order.create.queue")
    public void handleOrderCreateMessage(Message message) {
        String messageId = message.getMessageProperties().getMessageId();
        String body = new String(message.getBody());
        
        // 幂等检查
        String businessType = "ORDER_CREATE_MQ";
        String businessKey = messageId;  // 使用消息ID作为唯一标识
        
        IdempotentResult result = recordService.checkAndRecord(
            businessType, businessKey, body
        );
        
        if (result.isAlreadyProcessed()) {
            log.info("消息已处理: {}", messageId);
            return;  // 幂等:直接返回,不重复处理
        }
        
        if (result.isProcessing()) {
            log.warn("消息正在处理: {}", messageId);
            // 暂时不ACK,等待下次重试
            throw new AmqpRejectAndDontRequeueException("消息正在处理中");
        }
        
        try {
            // 解析消息
            CreateOrderRequest request = JSON.parseObject(body, CreateOrderRequest.class);
            
            // 创建订单
            Order order = orderService.createOrder(request);
            
            // 记录成功
            recordService.updateResult(
                result.getRecordId(), 
                true, 
                JSON.toJSONString(order)
            );
            
            log.info("订单创建成功: messageId={}, orderNo={}", 
                messageId, order.getOrderNo());
            
        } catch (Exception e) {
            log.error("处理订单消息失败: {}", messageId, e);
            recordService.updateResult(result.getRecordId(), false, e.getMessage());
            throw e;
        }
    }
}

📊 方案对比

方案实现难度性能可靠性适用场景推荐度
数据库唯一约束⭐⭐⭐⭐⭐⭐⭐⭐⭐有业务唯一标识⭐⭐⭐⭐⭐
Token机制⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐表单提交⭐⭐⭐⭐⭐
状态机⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐状态流转⭐⭐⭐⭐
去重表⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐通用⭐⭐⭐⭐
分布式锁⭐⭐⭐⭐⭐⭐⭐⭐⭐高并发⭐⭐⭐

🎉 总结

选择建议

场景1:订单创建
方案:数据库唯一约束(订单号)
理由:简单可靠

场景2:表单提交
方案:Token机制
理由:防止重复提交

场景3:订单支付
方案:状态机 + 乐观锁
理由:明确的状态流转

场景4:支付回调
方案:去重表(交易流水号)
理由:需要记录处理结果

场景5:MQ消费
方案:去重表(消息ID)
理由:消息可能重复投递

场景6:秒杀扣库存
方案:分布式锁 + Redis
理由:高并发场景

记忆口诀

幂等设计很重要,
重复执行不出错。
五种方案要记牢,
场景不同方案选。

数据库唯一约束强,
订单创建最常用。
业务标识要唯一,
简单可靠又高效。

Token机制防重提,
获取令牌再操作。
一次使用就删除,
表单提交必须用。

状态机思想好,
有序流转不混乱。
乐观锁来保证,
并发更新不出错。

去重表专门用,
记录请求和结果。
支付回调MQ消费,
都可以用去重表。

分布式锁要谨慎,
性能开销要考虑。
高并发短事务,
才适合用分布式锁!

愿你的系统幂等性完美,重复请求从此无忧! 🔄✨