一、幂等性核心概念与问题分析
1.1 幂等性定义与重要性
幂等性是分布式系统中的核心概念,指同一个操作无论执行一次还是多次,结果都保持一致。在软件系统中,幂等性主要体现在两个方面:
接口幂等:防止客户端重复提交请求
消息队列幂等:防止消息被重复消费
1.2 非幂等性带来的问题
数据重复:重复下单、重复扣款
资源浪费:重复计算、重复发送消息
业务逻辑错误:多次发放优惠券、多次触发业务流程
数据不一致:余额多次扣除、库存多次减少
二、幂等性解决方案对比
2.1 三种核心解决方案
| 方案 | 实现方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 分布式锁 | Redis/Redisson/Zookeeper | 并发控制,短时间防重 | 实现简单,性能好 | 锁释放异常可能导致死锁 |
| Token令牌 | Redis存储唯一Token | 表单提交,支付请求 | 完全防止重复提交 | 需要额外获取Token的请求 |
| 去重表 | Redis/MySQL记录已处理请求 | 消息队列消费,异步任务 | 支持长时间幂等 | 存储成本,需要清理策略 |
2.2 方案选择建议
text
业务场景 → 选择方案: ├── 用户快速点击重复提交 → Token令牌 ├── 高并发秒杀场景 → 分布式锁 + 去重表 ├── 消息队列消费 → Redis去重表 └── 定时任务防重 → 分布式锁 + 状态标记
三、幂等组件架构设计
3.1 整体架构
text
┌─────────────────────────────────────────┐ │ 应用层 (Application) │ │ ┌────────────────────────────────────┐ │ │ │ @Idempotent注解标记方法 │ │ │ └────────────────────────────────────┘ │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ AOP切面层 (IdempotentAspect) │ │ ┌────────────────────────────────────┐ │ │ │ 1. 解析注解 │ │ │ │ 2. 选择处理器 │ │ │ │ 3. 执行幂等逻辑 │ │ │ │ 4. 异常处理 │ │ │ └────────────────────────────────────┘ │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 处理器工厂 (IdempotentExecuteHandler)│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │Token │ │Param │ │SpEL │ │ │ │处理器 │ │处理器 │ │处理器 │ │ │ └─────────┘ └─────────┘ └─────────┘ │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 存储层 (Redis/MySQL) │ │ ┌────────────────────────────────────┐ │ │ │ 幂等键值存储 │ │ │ │ 分布式锁 │ │ │ │ Token管理 │ │ │ └────────────────────────────────────┘ │ └─────────────────────────────────────────┘
3.2 核心注解设计
java
@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Idempotent { /** * 场景枚举 * RESTAPI: HTTP接口防重 * MQ: 消息队列消费防重 */ IdempotentSceneEnum scene() default IdempotentSceneEnum.RESTAPI; /** * 幂等类型 * TOKEN: 基于Token令牌 * PARAM: 基于方法参数 * SPEL: 基于SpEL表达式 */ IdempotentTypeEnum type() default IdempotentTypeEnum.PARAM; /** * SpEL表达式,用于生成唯一Key */ String key() default ""; /** * 错误提示信息 */ String message() default "请求重复,请稍后重试"; /** * 幂等Key前缀(仅MQ场景) */ String uniqueKeyPrefix() default ""; /** * Key过期时间(秒) */ long keyTimeout() default 3600L; /** * 是否在异常时删除幂等标记 */ boolean deleteWhenException() default true; } // 场景枚举 public enum IdempotentSceneEnum { RESTAPI, // REST API场景 MQ // 消息队列场景 } // 类型枚举 public enum IdempotentTypeEnum { TOKEN, // Token令牌方式 PARAM, // 参数方式 SPEL // SpEL表达式方式 }四、核心实现详解
4.1 AOP切面实现
java
@Aspect @Component @Slf4j public class IdempotentAspect { @Autowired private IdempotentService idempotentService; @Around("@annotation(idempotent)") public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { // 1. 构建幂等上下文 IdempotentContext context = buildContext(joinPoint, idempotent); // 2. 执行幂等校验 boolean passed = idempotentService.checkIdempotent(context); if (!passed) { // 3. 幂等校验失败,返回错误或空结果 return handleIdempotentFailure(context); } try { // 4. 执行业务逻辑 Object result = joinPoint.proceed(); // 5. 处理成功后的逻辑 idempotentService.handleSuccess(context); return result; } catch (Throwable e) { // 6. 异常处理 idempotentService.handleException(context, e); throw e; } finally { // 7. 清理上下文 IdempotentContextHolder.clear(); } } private IdempotentContext buildContext(ProceedingJoinPoint joinPoint, Idempotent idempotent) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); return IdempotentContext.builder() .method(method) .args(joinPoint.getArgs()) .annotation(idempotent) .targetClass(joinPoint.getTarget().getClass()) .build(); } private Object handleIdempotentFailure(IdempotentContext context) { // 根据不同场景返回不同结果 if (context.getAnnotation().scene() == IdempotentSceneEnum.MQ) { // MQ场景:返回null,让消息不重试 return null; } else { // REST API场景:抛出业务异常 throw new BusinessException(context.getAnnotation().message()); } } }4.2 幂等服务实现
java
@Service @Slf4j public class IdempotentServiceImpl implements IdempotentService { @Autowired private RedisTemplate<String, String> redisTemplate; @Autowired private IdempotentKeyGenerator keyGenerator; @Override public boolean checkIdempotent(IdempotentContext context) { Idempotent annotation = context.getAnnotation(); // 1. 生成幂等Key String idempotentKey = generateIdempotentKey(context); context.setIdempotentKey(idempotentKey); // 2. 根据不同类型执行校验 switch (annotation.type()) { case TOKEN: return checkByToken(idempotentKey); case PARAM: return checkByParam(idempotentKey); case SPEL: return checkBySpEL(idempotentKey, context); default: throw new IllegalArgumentException("不支持的幂等类型"); } } private String generateIdempotentKey(IdempotentContext context) { Idempotent annotation = context.getAnnotation(); // 组合键:prefix + scene + type + 业务键 StringBuilder keyBuilder = new StringBuilder(); keyBuilder.append("idempotent:") .append(annotation.scene().name().toLowerCase()) .append(":") .append(annotation.type().name().toLowerCase()) .append(":"); // 根据类型生成业务键部分 switch (annotation.type()) { case TOKEN: keyBuilder.append(getTokenFromRequest(context)); break; case PARAM: keyBuilder.append(keyGenerator.generateParamKey(context)); break; case SPEL: keyBuilder.append(keyGenerator.generateSpELKey(context)); break; } return keyBuilder.toString(); } private boolean checkByToken(String key) { // Token方式:检查并删除Token return Boolean.TRUE.equals(redisTemplate.delete(key)); } private boolean checkByParam(String key) { // 参数方式:使用SETNX实现分布式锁 return Boolean.TRUE.equals( redisTemplate.opsForValue() .setIfAbsent(key, "processing", Duration.ofSeconds(30)) ); } private boolean checkBySpEL(String key, IdempotentContext context) { // MQ场景的特殊处理 if (context.getAnnotation().scene() == IdempotentSceneEnum.MQ) { String status = redisTemplate.opsForValue().get(key); if (status == null) { // 首次消费 redisTemplate.opsForValue().set(key, "processing", Duration.ofSeconds(600)); return true; } else if ("consumed".equals(status)) { // 已经消费成功,跳过 return false; } else if ("processing".equals(status)) { // 正在处理中,需要重试 throw new RetryLaterException("消息正在处理中,请稍后重试"); } } // REST API的SpEL处理 return checkByParam(key); } @Override public void handleSuccess(IdempotentContext context) { String key = context.getIdempotentKey(); if (context.getAnnotation().scene() == IdempotentSceneEnum.MQ) { // MQ场景:标记为已消费 redisTemplate.opsForValue().set(key, "consumed", Duration.ofSeconds(context.getAnnotation().keyTimeout())); } else { // REST场景:删除或保留Key(根据配置) if (context.getAnnotation().type() == IdempotentTypeEnum.PARAM) { redisTemplate.delete(key); } // Token类型在check时已经删除 } } @Override public void handleException(IdempotentContext context, Throwable e) { if (context.getAnnotation().deleteWhenException()) { redisTemplate.delete(context.getIdempotentKey()); } } }4.3 Key生成器实现
java
@Component public class IdempotentKeyGenerator { /** * 基于方法参数生成Key */ public String generateParamKey(IdempotentContext context) { Object[] args = context.getArgs(); // 1. 获取方法的参数名 Method method = context.getMethod(); Parameter[] parameters = method.getParameters(); // 2. 构建参数指纹 StringBuilder fingerprint = new StringBuilder(); for (int i = 0; i < args.length; i++) { if (args[i] != null) { fingerprint.append(parameters[i].getName()) .append("=") .append(calculateHash(args[i])) .append("&"); } } // 3. 返回MD5摘要 return DigestUtils.md5DigestAsHex(fingerprint.toString().getBytes()); } /** * 基于SpEL表达式生成Key */ public String generateSpELKey(IdempotentContext context) { String spEL = context.getAnnotation().key(); if (StringUtils.isEmpty(spEL)) { // 默认使用全参数 return generateParamKey(context); } // 创建SpEL表达式上下文 EvaluationContext evaluationContext = new StandardEvaluationContext(); // 设置变量 evaluationContext.setVariable("args", context.getArgs()); evaluationContext.setVariable("method", context.getMethod()); // 解析表达式 ExpressionParser parser = new SpelExpressionParser(); Expression expression = parser.parseExpression(spEL); Object result = expression.getValue(evaluationContext); return result != null ? result.toString() : ""; } private String calculateHash(Object obj) { if (obj == null) { return "null"; } try { return HashUtil.md5(JSONUtil.toJsonStr(obj)); } catch (Exception e) { return Integer.toHexString(obj.hashCode()); } } }五、使用示例
5.1 REST API防重提交
java
@RestController @RequestMapping("/order") @Slf4j public class OrderController { @PostMapping("/create") @Idempotent( type = IdempotentTypeEnum.TOKEN, scene = IdempotentSceneEnum.RESTAPI, message = "请勿重复提交订单" ) public ApiResponse<OrderDTO> createOrder(@RequestBody CreateOrderRequest request, @RequestHeader("X-Idempotent-Token") String token) { // 业务逻辑 OrderDTO order = orderService.createOrder(request); return ApiResponse.success(order); } @PostMapping("/update") @Idempotent( type = IdempotentTypeEnum.PARAM, scene = IdempotentSceneEnum.RESTAPI, keyTimeout = 30 ) public ApiResponse<Void> updateOrder(@RequestBody UpdateOrderRequest request) { // 基于请求参数的幂等 orderService.updateOrder(request); return ApiResponse.success(); } @GetMapping("/token") public ApiResponse<String> getIdempotentToken() { // 生成并返回Token String token = UUID.randomUUID().toString(); redisTemplate.opsForValue().set( "idempotent:token:" + token, "1", Duration.ofMinutes(5) ); return ApiResponse.success(token); } }5.2 MQ消息消费幂等
java
@Component @RocketMQMessageListener( topic = "ORDER_PAY_TOPIC", consumerGroup = "ORDER_PAY_CONSUMER_GROUP" ) @Slf4j public class OrderPayConsumer implements RocketMQListener<OrderPayMessage> { @Idempotent( type = IdempotentTypeEnum.SPEL, scene = IdempotentSceneEnum.MQ, key = "#message.orderNo + '_' + #message.payTime", uniqueKeyPrefix = "mq:order:pay:", keyTimeout = 7200, deleteWhenException = false ) @Override @Transactional(rollbackFor = Exception.class) public void onMessage(OrderPayMessage message) { log.info("收到支付成功消息: {}", message); // 处理支付成功逻辑 orderService.handlePaySuccess(message); // 发送相关通知 notifyService.sendPaySuccessNotify(message); } }六、高级特性与优化
6.1 性能优化
java
@Component public class IdempotentCacheManager { @Autowired private RedisTemplate<String, String> redisTemplate; // 本地缓存,减少Redis访问 private final Cache<String, Boolean> localCache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.SECONDS) .build(); /** * 二级缓存检查 */ public boolean checkWithCache(String key) { // 1. 检查本地缓存 Boolean cached = localCache.getIfPresent(key); if (Boolean.TRUE.equals(cached)) { return false; // 已处理 } // 2. 检查Redis String value = redisTemplate.opsForValue().get(key); if (value != null) { // 更新本地缓存 localCache.put(key, true); return "consumed".equals(value); } return true; } }6.2 监控与告警
java
@Component @Slf4j public class IdempotentMetrics { private final MeterRegistry meterRegistry; // 计数器 private final Counter totalRequests; private final Counter repeatedRequests; private final Counter failedRequests; public IdempotentMetrics(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; this.totalRequests = Counter.builder("idempotent.requests.total") .description("总请求数") .register(meterRegistry); this.repeatedRequests = Counter.builder("idempotent.requests.repeated") .description("重复请求数") .register(meterRegistry); this.failedRequests = Counter.builder("idempotent.requests.failed") .description("失败请求数") .register(meterRegistry); } public void recordRequest(boolean isRepeated, boolean success) { totalRequests.increment(); if (isRepeated) { repeatedRequests.increment(); } if (!success) { failedRequests.increment(); } } }6.3 配置化管理
yaml
# application-idempotent.yml idempotent: enabled: true redis: key-prefix: "idempotent:" default-timeout: 3600 mq-timeout: 7200 local-cache: enabled: true size: 1000 expire-seconds: 10 strategy: restapi-default: PARAM mq-default: SPEL metrics: enabled: true export-interval: 60s
七、最佳实践与注意事项
7.1 实践建议
合理选择方案
短时间防重用Token
高并发用分布式锁
消息队列用去重表
Key设计原则
包含业务标识
控制Key长度
添加环境前缀
过期时间设置
Token:5-30分钟
参数锁:30秒-5分钟
MQ去重:业务处理时间2-3倍
7.2 注意事项
Redis集群模式:使用Redlock或Redis集群确保分布式锁可靠性
数据库去重表:需要定期清理过期数据
Token泄露风险:Token需要足够随机,防止猜测
SpEL注入风险:避免使用用户输入作为SpEL表达式
监控告警:监控重复请求率,及时发现问题
7.3 常见问题解决
java
// 问题1:如何解决时钟回拨问题? public class SnowflakeIdGenerator { private long lastTimestamp = -1L; public synchronized long nextId() { long timestamp = timeGen(); if (timestamp < lastTimestamp) { // 时钟回拨,等待或抛出异常 throw new RuntimeException("时钟回拨异常"); } // ... 正常生成ID逻辑 } } // 问题2:如何避免本地缓存与Redis不一致? public class ConsistentCache { // 使用版本号或时间戳 public boolean checkConsistent(String key, long version) { String redisValue = redis.get(key); if (redisValue == null) { return true; } long redisVersion = parseVersion(redisValue); return version >= redisVersion; } }总结
幂等性组件是分布式系统中的基础设施,设计时需要综合考虑:
多种场景支持:REST API、MQ消息、定时任务等
灵活的策略:Token、参数锁、SpEL表达式
高性能实现:本地缓存、异步处理、批量操作
可观测性:监控指标、日志记录、链路追踪
容错能力:异常处理、降级策略、自动恢复
通过本文的组件设计,可以实现一个高可用、高性能、易扩展的幂等性解决方案,有效保障分布式系统数据一致性和业务稳定性。