java每日精进 5.28【幂等性】
一、引言
在分布式系统中,由于网络延迟、重试机制等原因,可能会出现重复请求的情况。幂等性的实现可以确保相同的请求多次执行产生的效果与一次执行相同,避免重复操作带来的数据不一致等问题。本文深入探讨幂等性在 Spring Boot 项目中的实现方式。
二、幂等性实现原理
(一)核心思路
yudao - spring - boot - starter - protection 组件的 idempotent 包提供的幂等特性,本质上是基于 Redis 实现的分布式锁。针对相同参数的方法,在一段时间内,有且仅能执行一次。
(二)执行流程
- 方法执行前检查:在方法执行前,根据参数对应的 Key 查询是否存在于 Redis 中。Key 的计算规则默认由
DefaultIdempotentKeyResolver
实现,使用MD5(方法名 + 方法参数)
,以避免 Redis Key 过长。如果存在,说明正在执行中,则抛出业务异常;如果不存在,则计算参数对应的 Key,存储到 Redis 中,并设置过期时间,标记正在执行中。 - 方法执行:方法正常执行,在这个过程中,不会主动删除参数对应的 Key。如果希望在方法执行完成后主动删除 Key,可以使用
@Lock
来实现幂等性。 - 过期处理:如果方法执行时间较长,超过 Key 的过期时间,Redis 会自动删除对应的 Key。因此,需要合理评估方法执行时间,避免执行时间超过设置的过期时间。
- 异常处理:如果方法执行发生 Exception 异常时,默认会删除 Key,避免下次请求无法正常执行。这是因为发生异常时,说明业务发生错误,此时删除 Key 可以保证后续请求能正常执行。
2.幂等性的实现方式
项目使用 @Idempotent 注解和 Redis 实现声明式幂等性,通过 AOP 切面拦截请求,基于 Redis 分布式锁防止重复提交。以下以 /admin-api/infra/test-demo/get 接口为例,详细解析实现过程。
实现步骤
步骤 1:引入依赖
在模块的 pom.xml 中添加 yudao spring-boot-starter-protection 依赖:
<dependency><groupId>cn.iocoder.boot</groupId><artifactId>yudao-spring-boot-starter-protection</artifactId>
</dependency>
解释:
- yudao spring-boot-starter-protection 提供幂等性和限流功能,包含 @Idempotent 注解、AOP 切面和 Redis 操作。
- 依赖封装了 Redis 集成,简化配置。
步骤 2:配置 Redis
在 application.yml 中配置 Redis 连接:
spring:redis:host: 127.0.0.1port: 6379database: 0
解释:
- 幂等性依赖 Redis 存储唯一键,需确保 Redis 服务器可用。
- Yudao 使用 StringRedisTemplate 操作 Redis
步骤 3:定义 @Idempotent 注解
@Idempotent 注解用于标记需要防重复提交的方法,定义如下:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {int timeout() default 1;TimeUnit timeUnit() default TimeUnit.SECONDS;String message() default "重复请求,请稍后重试";Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;String keyArg() default "";boolean deleteKeyWhenException() default true;
}
解释:
- 属性:
- timeout 和 timeUnit:键的过期时间,默认 1 秒。
- message:重复请求的错误提示。
- keyResolver:键解析器,支持 DefaultIdempotentKeyResolver(全局)、UserIdempotentKeyResolver(用户级别)、ExpressionIdempotentKeyResolver(自定义 SpEL)。
- keyArg:配合 ExpressionIdempotentKeyResolver 的 SpEL 表达式。
- deleteKeyWhenException:异常时删除键,允许重试。
- 作用:标记方法,触发 AOP 切面处理。
步骤 4:实现键解析器
Yudao 提供三种键解析器,DefaultIdempotentKeyResolver 是默认实现:
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {@Overridepublic String resolve(ProceedingJoinPoint joinPoint, Idempotent idempotent) {String methodName = joinPoint.getSignature().toString();String argsStr = Arrays.toString(joinPoint.getArgs());return DigestUtils.md5DigestAsHex((methodName + argsStr).getBytes());}
}
解释:
- 生成键:基于方法名和参数的 MD5 哈希,确保全局唯一。
- 其他解析器:
- UserIdempotentKeyResolver:基于用户 ID(如从 Security 上下文获取),限制每个用户。
- ExpressionIdempotentKeyResolver:通过 SpEL 表达式自定义键(如基于 IP)。
步骤 5:实现 Redis 操作
IdempotentRedisDAO 封装 Redis 操作:
@AllArgsConstructor
public class IdempotentRedisDAO {private static final String IDEMPOTENT = "idempotent:%s";private final StringRedisTemplate redisTemplate;public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) {String redisKey = formatKey(key);return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit);}public void delete(String key) {String redisKey = formatKey(key);redisTemplate.delete(redisKey);}private static String formatKey(String key) {return String.format(IDEMPOTENT, key);}
}
解释:
- 键格式:idempotent:{key},如 idempotent:md5(method+args)。
- setIfAbsent:设置键,带过期时间,若键存在返回 false。
- delete:删除键,用于异常处理。
步骤 6:实现 AOP 切面
IdempotentAspect 拦截 @Idempotent 方法:
@Aspect
@Slf4j
@Component
public class IdempotentAspect {private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;private final IdempotentRedisDAO idempotentRedisDAO;public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {this.keyResolvers = keyResolvers.stream().collect(Collectors.toMap(resolver -> resolver.getClass(), resolver -> resolver));this.idempotentRedisDAO = idempotentRedisDAO;}@Around("@annotation(idempotent)")public Object beforePointCut(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {// 解析键IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());assert keyResolver != null : "找不到对应的 IdempotentKeyResolver";String key = keyResolver.resolve(joinPoint, idempotent);// 锁定键boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());if (!success) {log.info("[beforePointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs());throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), idempotent.message());}// 执行方法try {return joinPoint.proceed();} catch (Throwable throwable) {// 异常时删除键if (idempotent.deleteKeyWhenException()) {idempotentRedisDAO.delete(key);}throw throwable;}}
}
- 解释:
- 解析键:使用指定的 keyResolver 生成键。
- 锁定:通过 setIfAbsent 检查和设置键,失败抛出 ServiceException(code: 900)。
- 执行:调用 proceed() 执行目标方法。
- 异常处理:异常时根据配置删除键。
步骤 7:应用 @Idempotent
在 TestDemoController 的 /get 接口上使用:
@RestController
@RequestMapping("/infra/test-demo")
public class TestDemoController {@GetMapping("/get")@Idempotent(timeout = 10, message = "重复请求,请稍后重试")public CommonResult<TestDemoRespVO> getTestDemo(@RequestParam("id") Long id) {TestDemoRespVO resp = new TestDemoRespVO().setId(id);return CommonResult.success(resp);}
}
解释:
- 效果:10 秒内相同请求被拦截,返回 {"code": 900, "msg": "重复请求,请稍后重试"}。
- 键:默认使用 DefaultIdempotentKeyResolver,基于方法名和 id 参数的 MD5。
步骤 8:按用户或 IP 限制请求
如何按每个用户或 IP 限制请求?
-
按用户限制: 使用 UserIdempotentKeyResolver,需确保用户 ID 可用(如通过 Spring Security 的 SecurityContextHolder 获取):
@PostMapping("/create")
@Idempotent(timeout = 10, keyResolver = UserIdempotentKeyResolver.class, message = "正在添加用户中,请勿重复提交")
public String createUser(@RequestBody User user) {userService.createUser(user);return "添加成功";
}
实现(假设 UserIdempotentKeyResolver):
public class UserIdempotentKeyResolver implements IdempotentKeyResolver {@Overridepublic String resolve(ProceedingJoinPoint joinPoint, Idempotent idempotent) {Authentication auth = SecurityContextHolder.getContext().getAuthentication();String userId = auth != null ? auth.getName() : "anonymous";return DigestUtils.md5DigestAsHex(userId.getBytes());}
}
效果:每个用户独立限制,键为 idempotent:md5(userId)。
按 IP 限制: 使用 ExpressionIdempotentKeyResolver 和 SpEL 表达式,从 HttpServletRequest 获取 IP:
@PostMapping("/create")
@Idempotent(timeout = 10, keyResolver = ExpressionIdempotentKeyResolver.class, keyArg = "#request.getRemoteAddr()", message = "正在添加用户中,请勿重复提交")
public String createUser(@RequestBody User user, HttpServletRequest request) {userService.createUser(user);return "添加成功";
}
实现(假设 ExpressionIdempotentKeyResolver):
public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {@Overridepublic String resolve(ProceedingJoinPoint joinPoint, Idempotent idempotent) {ExpressionParser parser = new SpelExpressionParser();Expression exp = parser.parseExpression(idempotent.keyArg());EvaluationContext context = new StandardEvaluationContext();for (int i = 0; i < joinPoint.getArgs().length; i++) {context.setVariable("p" + i, joinPoint.getArgs()[i]);}return DigestUtils.md5DigestAsHex(exp.getValue(context, String.class).getBytes());}
}
效果:键为 idempotent:md5(ip),每个 IP 独立限制。
操作 | 输出 | 详细说明 | 最终输出 | |
---|---|---|---|---|
1 | TestDemoController | id=123 (HTTP 请求 GET /infra/test-demo/get?id=123) | 解析查询参数 id,绑定到方法参数,触发 AOP 切面(@Idempotent) | id=123 (via ProceedingJoinPoint.getArgs()) |
2 | IdempotentAspect | ProceedingJoinPoint joinPoint(args=[123],signature=getTestDemo (Long)),Idempotent idempotent(timeout=10,timeUnit=SECONDS,message="重复请求,请稍后重试",keyResolver=DefaultIdempotentKeyResolver.class) | 获取 DefaultIdempotentKeyResolver,调用 resolve 生成键,调用 IdempotentRedisDAO.setIfAbsent 检查 Redis | key(to DefaultIdempotentKeyResolver) |
3 | DefaultIdempotentKeyResolver | joinPoint(args=[123],signature=getTestDemo(Long)),idempotent | 拼接 methodName + Arrays.toString (args),生成 MD5 哈希(如 a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6) | a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6(to IdempotentAspect) |
4 | IdempotentRedisDAO | key=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6,timeout=10,timeUnit=SECONDS | 格式化 Redis 键为 idempotent:a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6,调用 setIfAbsent 设置键(值为空字符串,过期 10 秒) | true/false(to IdempotentAspect) |
5 | IdempotentAspect | success(true/false),joinPoint,idempotent | 若 success=false,抛出 ServiceException (900, "重复请求,请稍后重试");若 true,调用 joinPoint.proceed ()。若异常发生且 deleteKeyWhenException=true,调用 IdempotentRedisDAO.delete | id=123(to TestDemoController if success=true) |
6 | TestDemoController | id=123(via joinPoint.proceed()) | 创建 TestDemoRespVO,设置 id=123,返回 CommonResult.success (resp) | CommonResult(HTTP 响应,{"code": 0, "data": {"id": 123}, "msg": "成功"}) |