一、问题背景

在微服务架构中,服务间调用的网络抖动、超时、重试风暴等问题是生产环境的常见痛点。最近我们团队在上线订单服务时,遇到了一个典型问题:当库存服务响应变慢时,订单服务的重试机制导致了重复扣库存,最终造成超卖问题。

典型场景

  • 用户提交订单 → 订单服务调用库存服务扣库存
  • 库存服务因数据库压力响应超时(超过3s)
  • 订单服务触发重试机制(最多重试3次)
  • 库存服务实际已完成扣库存,但响应超时导致重试
  • 最终同一订单被重复扣库存3次

本文将从实战角度,详细讲解如何在Spring Boot中优雅地处理超时重试与幂等性设计。

二、环境准备

2.1 Maven依赖

<!-- Spring Cloud OpenFeign -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
    <version>4.1.2</version>
</dependency>

<!-- Spring Retry -->
<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
    <version>2.0.6</version>
</dependency>

<!-- Resilience4j Retry -->
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-retry</artifactId>
    <version>2.2.0</version>
</dependency>

<!-- Spring AOP(Retry依赖) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2.2 基础配置

# application.yml
feign:
  client:
    config:
      default:
        connectTimeout: 2000    # 连接超时2s
        readTimeout: 3000       # 读取超时3s
        loggerLevel: full
  compression:
    request:
      enabled: true
    response:
      enabled: true

resilience4j:
  retry:
    configs:
      default:
        maxRetryAttempts: 3
        waitDuration: 500ms
        retryExceptions:
          - java.net.SocketTimeoutException
          - java.io.IOException
        ignoreExceptions:
          - com.example.exception.BusinessException

三、超时重试机制实现

3.1 使用Spring Retry注解方式

@Service
@Slf4j
public class InventoryService {

    @Autowired
    private InventoryFeignClient inventoryFeignClient;

    /**
     * 扣库存(带重试机制)
     * @param orderNo 订单号(幂等键)
     * @param productId 商品ID
     * @param quantity 数量
     */
    @Retryable(
        retryFor = {SocketTimeoutException.class, IOException.class},
        noRetryFor = {BusinessException.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 500, multiplier = 2)
    )
    public void deductStock(String orderNo, Long productId, Integer quantity) {
        log.info("尝试扣库存,订单号:{},商品ID:{},数量:{}", orderNo, productId, quantity);
        
        try {
            inventoryFeignClient.deductStock(orderNo, productId, quantity);
            log.info("扣库存成功,订单号:{}", orderNo);
        } catch (FeignException e) {
            log.error("扣库存失败,订单号:{},状态码:{}", orderNo, e.status(), e);
            throw e;
        }
    }

    /**
     * 重试耗尽后的回调
     */
    @Recover
    public void recoverDeductStock(Exception e, String orderNo, 
                                    Long productId, Integer quantity) {
        log.error("扣库存重试耗尽,订单号:{},进入降级处理", orderNo, e);
        // 记录失败订单,后续通过补偿机制处理
        recordFailedOrder(orderNo, productId, quantity);
    }
}

3.2 使用Resilience4j编程式重试

@Configuration
public class RetryConfig {

    @Bean
    public Retry inventoryDeductRetry() {
        RetryConfig config = RetryConfig.custom()
                .maxAttempts(3)
                .waitDuration(Duration.ofMillis(500))
                .retryExceptions(SocketTimeoutException.class, IOException.class)
                .failAfterMaxAttempts(true)
                .build();

        RetryRegistry registry = RetryRegistry.of(config);
        Retry retry = registry.retry("inventoryDeduct");

        // 添加重试监听
        retry.getEventPublisher()
                .onRetry(event -> log.info("库存扣减第{}次重试,异常:{}", 
                    event.getNumberOfRetryAttempts(), 
                    event.getLastThrowable().getMessage()));

        return retry;
    }
}

@Service
@Slf4j
public class InventoryServiceV2 {

    @Autowired
    private InventoryFeignClient inventoryFeignClient;
    
    @Autowired
    private Retry inventoryDeductRetry;

    public void deductStock(String orderNo, Long productId, Integer quantity) {
        CheckedRunnable retryableTask = Retry.decorateCheckedRunnable(
            inventoryDeductRetry,
            () -> inventoryFeignClient.deductStock(orderNo, productId, quantity)
        );

        try {
            retryableTask.run();
        } catch (Throwable e) {
            log.error("扣库存最终失败,订单号:{}", orderNo, e);
            throw new BusinessException("库存扣减失败,请稍后重试");
        }
    }
}

四、幂等性设计核心方案

4.1 基于唯一请求ID的幂等表方案

-- 创建幂等记录表
CREATE TABLE idempotent_record (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    request_key VARCHAR(128) NOT NULL COMMENT '幂等键(如订单号)',
    business_type VARCHAR(64) NOT NULL COMMENT '业务类型',
    request_data TEXT COMMENT '请求数据',
    response_data TEXT COMMENT '响应数据',
    status TINYINT NOT NULL DEFAULT 0 COMMENT '0-处理中,1-成功,2-失败',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_request_key (request_key, business_type),
    KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幂等记录表';

4.2 幂等注解与AOP实现

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    
    /**
     * 幂等键前缀
     */
    String keyPrefix() default "";
    
    /**
     * 幂等键表达式(SpEL)
     */
    String keyExpression();
    
    /**
     * 业务类型
     */
    String businessType();
    
    /**
     * 超时时间(秒),默认24小时
     */
    int expireTime() default 86400;
}

@Aspect
@Component
@Slf4j
public class IdempotentAspect {

    @Autowired
    private IdempotentRecordMapper idempotentRecordMapper;
    
    @Autowired
    private RedissonClient redissonClient;

    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint point, Idempotent idempotent) throws Throwable {
        // 1. 解析幂等键
        String key = generateIdempotentKey(point, idempotent);
        log.info("幂等校验开始,key:{}", key);

        // 2. 尝试获取分布式锁
        RLock lock = redissonClient.getLock("idempotent:lock:" + key);
        if (!lock.tryLock(5, 30, TimeUnit.SECONDS)) {
            throw new BusinessException("请求处理中,请稍后重试");
        }

        try {
            // 3. 查询幂等记录
            IdempotentRecord record = idempotentRecordMapper.selectByKey(
                key, idempotent.businessType());
            
            if (record != null) {
                if (record.getStatus() == 1) {
                    log.info("请求已成功处理,直接返回缓存结果");
                    return JSON.parseObject(record.getResponseData(), Object.class);
                } else if (record.getStatus() == 0) {
                    throw new BusinessException("请求处理中,请稍后重试");
                }
            }

            // 4. 插入幂等记录(状态:处理中)
            if (record == null) {
                IdempotentRecord newRecord = new IdempotentRecord();
                newRecord.setRequestKey(key);
                newRecord.setBusinessType(idempotent.businessType());
                newRecord.setRequestData(JSON.toJSONString(point.getArgs()));
                newRecord.setStatus(0);
                idempotentRecordMapper.insert(newRecord);
            }

            // 5. 执行业务逻辑
            Object result = point.proceed();

            // 6. 更新幂等记录为成功
            idempotentRecordMapper.updateStatus(key, idempotent.businessType(), 
                1, JSON.toJSONString(result));

            return result;

        } catch (BusinessException e) {
            // 业务异常,更新为失败状态
            idempotentRecordMapper.updateStatus(key, idempotent.businessType(), 
                2, e.getMessage());
            throw e;
        } finally {
            lock.unlock();
        }
    }

    private String generateIdempotentKey(ProceedingJoinPoint point, Idempotent idempotent) {
        // 使用SpEL解析表达式生成幂等键
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();
        
        Object[] args = point.getArgs();
        String[] paramNames = ((MethodSignature) point.getSignature())
            .getParameterNames();
        
        for (int i = 0; i < args.length; i++) {
            context.setVariable(paramNames[i], args[i]);
        }
        
        String keySuffix = parser.parseExpression(idempotent.keyExpression())
            .getValue(context, String.class);
        
        return idempotent.keyPrefix() + ":" + keySuffix;
    }
}

4.3 实际使用示例

@RestController
@RequestMapping("/api/inventory")
@Slf4j
public class InventoryController {

    @PostMapping("/deduct")
    @Idempotent(
        keyPrefix = "inventory",
        keyExpression = "#orderNo",
        businessType = "DEDUCT_STOCK",
        expireTime = 3600
    )
    public Result<Void> deductStock(
            @RequestParam String orderNo,
            @RequestParam Long productId,
            @RequestParam Integer quantity) {
        
        log.info("开始扣库存,订单号:{},商品ID:{},数量:{}", 
            orderNo, productId, quantity);
        
        // 业务逻辑:扣减库存
        inventoryService.doDeductStock(productId, quantity);
        
        return Result.success();
    }
}

五、生产环境最佳实践

5.1 重试策略优化建议

  1. 区分可重试异常:只对网络异常、超时异常等可恢复错误进行重试,业务异常(如参数错误)绝不重试
  2. 指数退避:重试间隔采用指数增长,避免请求风暴
  3. 设置最大重试次数:建议不超过3次,避免无限重试
  4. 断路器配合:当失败率超过阈值时触发熔断,保护下游服务

5.2 幂等性设计要点

  1. 幂等键选择:使用业务唯一标识(如订单号、支付流水号)而非随机ID
  2. 超时时间设置:幂等记录的过期时间应大于业务最长处理时间
  3. 数据一致性:幂等记录更新应与业务操作在同一事务中
  4. 定期清理:定期清理过期的幂等记录,避免表膨胀

六、实战踩坑案例分析

6.1 案例一:重试风暴导致服务雪崩

问题现象
某电商大促期间,订单服务QPS达到5000+,由于库存服务数据库主从延迟,导致读取超时率从0.1%飙升到15%。订单服务的重试机制(3次重试)被触发,实际请求量放大到4倍,最终库存服务被打垮,整个下单链路瘫痪。

根因分析

  • 没有配置重试的断路器阈值
  • 所有超时请求一律重试,没有区分是读超时还是写超时
  • 缺少请求级别的限流保护

优化方案

@Configuration
public class Resilience4jConfig {
    
    @Bean
    public CircuitBreaker inventoryCircuitBreaker() {
        CircuitBreakerConfig config = CircuitBreakerConfig.custom()
                .failureRateThreshold(50)  // 失败率超过50%触发熔断
                .slowCallRateThreshold(30)  // 慢调用率超过30%触发熔断
                .slowCallDurationThreshold(Duration.ofSeconds(2))
                .permittedNumberOfCallsInHalfOpenState(10)
                .maxWaitDurationInHalfOpenState(Duration.ofSeconds(30))
                .slidingWindowType(SlidingWindowType.COUNT_BASED)
                .slidingWindowSize(100)
                .minimumNumberOfCalls(20)
                .build();
        
        CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);
        return registry.circuitBreaker("inventoryService");
    }
}

@Service
@Slf4j
public class InventoryServiceV3 {

    @Autowired
    private InventoryFeignClient inventoryFeignClient;
    
    @Autowired
    private CircuitBreaker inventoryCircuitBreaker;

    @CircuitBreaker(name = "inventoryService", fallbackMethod = "deductFallback")
    @Retry(name = "inventoryDeduct")
    public void deductStock(String orderNo, Long productId, Integer quantity) {
        // 只有在断路器关闭状态下才执行重试
        inventoryFeignClient.deductStock(orderNo, productId, quantity);
    }

    private void deductFallback(String orderNo, Long productId, 
                                Integer quantity, Exception e) {
        log.warn("库存服务熔断,订单号:{},进入降级流程", orderNo);
        // 发送到消息队列,异步处理
        kafkaTemplate.send("inventory-deduct-fallback", 
            orderNo, JSON.toJSONString(Map.of(
                "productId", productId,
                "quantity", quantity
            )));
    }
}

6.2 案例二:幂等键设计不当导致重复处理

问题现象
某支付系统使用UUID作为幂等键,每次重试都会生成新的UUID,导致同一笔支付被重复处理多次,造成资金损失。

错误写法

// ❌ 错误:每次调用都生成新的幂等键
public void processPayment(PaymentRequest request) {
    String idempotentKey = UUID.randomUUID().toString();  // 这是错误的!
    idempotentProcessor.process(idempotentKey, () -> doPayment(request));
}

正确方案

// ✅ 正确:使用业务唯一标识作为幂等键
public void processPayment(PaymentRequest request) {
    // 使用支付流水号作为幂等键,这是业务层面的唯一标识
    String idempotentKey = request.getPaymentSerialNo();
    idempotentProcessor.process(idempotentKey, () -> doPayment(request));
}

// ✅ 更好的方案:幂等键生成策略
public enum IdempotentKeyStrategy {
    // 单一字段
    ORDER_NO("#orderNo"),
    PAYMENT_SERIAL("#paymentSerial"),
    // 组合字段
    ORDER_AND_PRODUCT("#orderNo + '_' + #productId");
    
    private final String expression;
}

七、监控与告警

7.1 关键指标监控

@Component
public class RetryMetricsCollector {

    @Autowired
    private MeterRegistry meterRegistry;

    @EventListener
    public void onRetryEvent(RetryOnRetryEvent event) {
        // 统计重试次数
        Counter.builder("retry.attempts.total")
                .tag("service", event.getName())
                .tag("exception", event.getLastThrowable().getClass().getSimpleName())
                .register(meterRegistry)
                .increment();
    }

    @EventListener
    public void onRetrySuccess(RetryOnSuccessEvent event) {
        // 统计重试成功次数
        Timer.builder("retry.success.duration")
                .tag("service", event.getName())
                .register(meterRegistry)
                .record(event.getProcessingDuration());
    }

    @EventListener
    public void onRetryError(RetryOnErrorEvent event) {
        // 统计重试失败次数(重试耗尽)
        Counter.builder("retry.exhausted.total")
                .tag("service", event.getName())
                .register(meterRegistry)
                .increment();
    }
}

7.2 告警规则建议

指标 阈值 告警级别 说明
重试率 > 10% WARN 重试比例过高,下游服务可能有问题
重试耗尽率 > 1% ERROR 大量重试失败,需要人工介入
断路器打开 - CRITICAL 服务熔断,业务受影响
幂等重复请求 > 5% WARN 可能存在客户端重试风暴

八、总结与展望

本文系统地讲解了Spring Boot微服务架构中超时重试和幂等性的设计实现。从基础配置到高级特性,从实战踩坑到监控告警,覆盖了生产环境的各个方面。

核心要点回顾

  1. ✅ 重试必须有节制:最大重试次数 + 指数退避 + 断路器
  2. ✅ 幂等是重试的前提:没有幂等的重试就是灾难
  3. ✅ 区分异常类型:只重试可恢复的异常
  4. ✅ 业务唯一键:使用业务标识而非随机ID作为幂等键
  5. ✅ 监控告警:关键指标可视化,及时发现问题

未来演进方向

  • 基于服务网格(Istio)的全局重试策略统一管理
  • 结合Chaos Engineering的故障注入测试
  • AI驱动的自适应重试策略(根据系统负载动态调整)
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐