Redis预扣减失败时如何设计幂等重试机制?

3 阅读6分钟

Redis预扣减失败时如何设计幂等重试机制?

在电影购票选座系统中,Redis预扣减失败时的幂等重试机制设计需要确保在高并发场景下,即使多次重试也不会导致座位被重复扣减或产生不一致状态。该机制的核心在于通过唯一业务标识实现操作的幂等性,并结合退避策略和状态机来管理重试流程,最终保障系统数据的最终一致性。

一、 幂等重试机制的核心设计原则

  1. 幂等性保证:无论同一个扣减请求被处理多少次,最终结果都应与第一次成功执行的结果一致。这是防止重复扣减的关键。
  2. 状态可追踪:每一次扣减尝试都必须有明确的状态记录,以便区分新请求与重试请求。
  3. 优雅退避:重试不应以固定频率进行,而应采用递增延迟(如指数退避),避免对下游服务造成雪崩效应。
  4. 最终一致性:通过异步重试和补偿机制,确保Redis与数据库(如MySQL)中的库存/座位状态最终一致。

二、 具体实现方案与代码示例

1. 幂等键与请求标识

为每个座位扣减请求生成全局唯一的业务标识(如 orderId:seatIdrequestId),并将其作为Redis操作的幂等键。

    

      

        
Java

      

      

        
// 生成唯一请求ID(示例)
public String generateRequestId(Long userId, Long scheduleId, String seatNo) {
    // 组合业务字段并哈希,或直接使用UUID
    String uniqueKey = userId + ":" + scheduleId + ":" + seatNo + ":" + System.currentTimeMillis();
    return DigestUtils.md5DigestAsHex(uniqueKey.getBytes());
    // 或使用分布式ID生成器,如Snowflake算法
}


        
        
      

      
    

  

2. Redis预扣减的幂等操作(Lua脚本)

在Redis中,通过原子性Lua脚本执行扣减,并记录幂等状态。

    

      

        
lua

      

      

        
-- KEYS[1]: 座位库存键 (如: seat_stock:schedule_1001)
-- KEYS[2]: 幂等记录键 (如: idempotent:req_123456)
-- ARGV[1]: 扣减数量 (通常为1)
-- ARGV[2]: 请求ID
-- ARGV[3]: 过期时间(秒)

-- 1. 检查幂等性:若已处理过,直接返回之前的结果
local processed = redis.call('GET', KEYS[2])
if processed then
    return {tonumber(processed), 'DUPLICATE_REQUEST'}
end

-- 2. 检查并扣减库存
local currentStock = tonumber(redis.call('GET', KEYS[1]))
if not currentStock then
    -- 库存键不存在,可能未初始化,返回错误码-1
    return {-1, 'STOCK_KEY_NOT_EXIST'}
end

if currentStock >= tonumber(ARGV[1]) then
    redis.call('DECRBY', KEYS[1], ARGV[1])
    -- 3. 扣减成功,记录幂等状态,值为1表示成功
    redis.call('SETEX', KEYS[2], ARGV[3], '1')
    return {1, 'SUCCESS'}
else
    -- 4. 库存不足,记录幂等状态,值为0表示失败
    redis.call('SETEX', KEYS[2], ARGV[3], '0')
    return {0, 'INSUFFICIENT_STOCK'}
end


        
        
      

      
    

  

3. 服务层重试控制器

在Spring Boot服务中,通过注解或组件实现带有退避策略的幂等重试。

    

      

        
Java

      

      

        
@Service
public class SeatDeductService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private RetryTemplate retryTemplate; // Spring Retry 或自定义
    
    private static final String LUA_SCRIPT = "..."; // 上述Lua脚本
    
    /**
     * 幂等预扣减座位
     * @param scheduleId 场次ID
     * @param seatNo 座位编号
     * @param requestId 请求ID
     * @return 扣减结果
     */
    public DeductResult idempotentDeductSeat(Long scheduleId, String seatNo, String requestId) {
        // 构造Redis键
        String stockKey = "seat_stock:schedule_" + scheduleId;
        String idempotentKey = "idempotent:req_" + requestId;
        
        // 执行Lua脚本
        DefaultRedisScript<List> script = new DefaultRedisScript<>(LUA_SCRIPT, List.class);
        List result = redisTemplate.execute(script, 
            Arrays.asList(stockKey, idempotentKey), 
            "1", requestId, "300" // ARGV: 扣减1个,请求ID,过期300秒
        );
        
        Long status = (Long) result.get(0);
        String msg = (String) result.get(1);
        
        if (status == 1) {
            return DeductResult.success(requestId);
        } else if (status == 0) {
            return DeductResult.fail("座位已售罄");
        } else if (status == -1) {
            // 库存键不存在,触发初始化或降级
            return DeductResult.fail("场次库存未初始化");
        } else {
            // 其他未知状态,触发重试
            throw new RetryableException("Redis扣减异常,需重试");
        }
    }
    
    /**
     * 带退避策略的重试方法
     */
    public DeductResult deductWithRetry(Long scheduleId, String seatNo, String requestId) {
        return retryTemplate.execute(context -> {
            // 重试上下文可获取重试次数
            int retryCount = context.getRetryCount();
            log.info("尝试扣减座位,第{}次重试,requestId: {}", retryCount + 1, requestId);
            
            DeductResult result = idempotentDeductSeat(scheduleId, seatNo, requestId);
            
            if (result.isSuccess()) {
                return result;
            } else if ("座位已售罄".equals(result.getMsg())) {
                // 业务明确失败,不应重试
                throw new NonRetryableException(result.getMsg());
            } else {
                // 可重试的异常
                throw new RetryableException(result.getMsg());
            }
        }, recoveryContext -> {
            // 重试耗尽后的恢复回调
            log.error("座位扣减重试耗尽,scheduleId: {}, seatNo: {}, requestId: {}", 
                     scheduleId, seatNo, requestId);
            return DeductResult.fail("系统繁忙,请稍后重试");
        });
    }
}

// 配置指数退避重试策略(Spring Retry示例)
@Configuration
@EnableRetry
public class RetryConfig {
    @Bean
    public RetryTemplate retryTemplate() {
        RetryTemplate template = new RetryTemplate();
        
        // 指数退避策略:初始间隔100ms,倍数2,最大间隔2s
        ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
        backOffPolicy.setInitialInterval(100);
        backOffPolicy.setMultiplier(2);
        backOffPolicy.setMaxInterval(2000);
        
        // 简单重试策略:最多重试3次
        SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
        retryPolicy.setMaxAttempts(3);
        
        template.setBackOffPolicy(backOffPolicy);
        template.setRetryPolicy(retryPolicy);
        
        return template;
    }
}


        
        
      

      
    

  

4. 状态机与异步重试队列

对于需要跨服务或长时间重试的场景,可引入消息队列(如RabbitMQ、RocketMQ)实现异步重试。

    

      

        
Java

      

      

        
@Component
public class SeatDeductRetryHandler {
    
    @Autowired
    private AmqpTemplate amqpTemplate;
    
    // 初始扣减失败,发送至延迟队列
    public void sendToRetryQueue(DeductRequest request, String reason) {
        SeatDeductMessage message = new SeatDeductMessage();
        message.setRequestId(request.getRequestId());
        message.setScheduleId(request.getScheduleId());
        message.setSeatNo(request.getSeatNo());
        message.setRetryCount(0);
        message.setFailReason(reason);
        
        // 发送到延迟队列,10秒后重试
        amqpTemplate.convertAndSend("seat.deduct.exchange", 
                                   "seat.deduct.retry", 
                                   message,
                                   msg -> {
                                       msg.getMessageProperties().setDelay(10000); // 延迟10秒
                                       return msg;
                                   });
    }
    
    // 重试消费者
    @RabbitListener(queues = "seat.deduct.retry.queue")
    public void handleRetryMessage(SeatDeductMessage message) {
        try {
            DeductResult result = deductService.idempotentDeductSeat(
                message.getScheduleId(), 
                message.getSeatNo(), 
                message.getRequestId()
            );
            
            if (!result.isSuccess() && message.getRetryCount() < 3) {
                // 重试次数未超限,再次发送到延迟队列,延迟时间递增
                message.setRetryCount(message.getRetryCount() + 1);
                long delay = 10000 * (message.getRetryCount() + 1); // 10s, 20s, 30s
                sendToRetryQueueWithDelay(message, delay);
            } else if (message.getRetryCount() >= 3) {
                // 重试耗尽,进入死信队列,人工干预或补偿
                log.error("座位扣减重试耗尽,进入死信队列: {}", message);
                sendToDlq(message);
            }
        } catch (Exception e) {
            log.error("处理重试消息异常: {}", message, e);
        }
    }
}


        
        
      

      
    

  

三、 异常场景与补偿处理

场景可能原因幂等重试策略补偿措施
Redis网络超时网络抖动、Redis负载高立即重试1-2次,随后指数退避记录日志,监控Redis健康状态
Redis键不存在库存未预热、Key过期不重试,直接返回错误,触发库存初始化从数据库加载库存到Redis
库存不足座位已被其他请求扣减不重试,返回“已售罄”更新前端座位状态,无需补偿
Lua脚本执行错误脚本语法错误、参数错误不重试,记录告警立即修复脚本,手动检查数据一致性
服务重启导致请求丢失请求在内存中未持久化依赖客户端重发,通过幂等键去重客户端应有重试机制,服务端幂等处理

四、 与博客系统的关联与扩展

在博客描述的基于Spring Boot的电影购票选座系统中,数据库设计包含了订票信息表(存储订单与座位关系)和电影信息表(可能隐含场次信息)。当引入Redis预扣减和幂等重试机制时,需注意:

  1. 数据同步:Redis中的座位库存需与数据库的场次座位总数保持同步。系统启动或场次变更时,需将数据库数据预热至Redis。
  2. 最终一致性:Redis预扣减成功后,需通过异步消息确保数据库的订票信息表被更新。若异步更新失败,应有补偿任务将Redis库存加回。
  3. 监控与对账:需定期比对Redis库存总和与数据库中被预订座位数,防止因消息丢失、程序Bug导致的数据不一致。
  4. 压力测试:在类似博客系统测试中“订票操作”高并发场景下,此幂等重试机制能有效防止同一座位被重复销售,同时保障系统在高并发下的稳定性。

该方案通过幂等键 + 原子Lua脚本 + 指数退避重试 + 异步队列的多层保障,构建了一个健壮的Redis预扣减失败处理体系,既能提升系统吞吐量,又能确保在分布式环境下座位扣减操作的准确性与一致性。