当前位置: 首页 > news >正文

java每日精进 5.28【幂等性】

一、引言

在分布式系统中,由于网络延迟、重试机制等原因,可能会出现重复请求的情况。幂等性的实现可以确保相同的请求多次执行产生的效果与一次执行相同,避免重复操作带来的数据不一致等问题。本文深入探讨幂等性在 Spring Boot 项目中的实现方式。

二、幂等性实现原理

(一)核心思路

yudao - spring - boot - starter - protection 组件的 idempotent 包提供的幂等特性,本质上是基于 Redis 实现的分布式锁。针对相同参数的方法,在一段时间内,有且仅能执行一次。

(二)执行流程

  1. 方法执行前检查:在方法执行前,根据参数对应的 Key 查询是否存在于 Redis 中。Key 的计算规则默认由DefaultIdempotentKeyResolver实现,使用MD5(方法名 + 方法参数),以避免 Redis Key 过长。如果存在,说明正在执行中,则抛出业务异常;如果不存在,则计算参数对应的 Key,存储到 Redis 中,并设置过期时间,标记正在执行中。
  2. 方法执行:方法正常执行,在这个过程中,不会主动删除参数对应的 Key。如果希望在方法执行完成后主动删除 Key,可以使用@Lock来实现幂等性。
  3. 过期处理:如果方法执行时间较长,超过 Key 的过期时间,Redis 会自动删除对应的 Key。因此,需要合理评估方法执行时间,避免执行时间超过设置的过期时间。
  4. 异常处理:如果方法执行发生 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 独立限制。

操作输出详细说明最终输出
1TestDemoControllerid=123(HTTP 请求 GET /infra/test-demo/get?id=123)解析查询参数 id,绑定到方法参数,触发 AOP 切面(@Idempotent)id=123(via ProceedingJoinPoint.getArgs())
2IdempotentAspectProceedingJoinPoint joinPoint(args=[123],signature=getTestDemo (Long)),Idempotent idempotent(timeout=10,timeUnit=SECONDS,message="重复请求,请稍后重试",keyResolver=DefaultIdempotentKeyResolver.class)获取 DefaultIdempotentKeyResolver,调用 resolve 生成键,调用 IdempotentRedisDAO.setIfAbsent 检查 Rediskey(to DefaultIdempotentKeyResolver)
3DefaultIdempotentKeyResolverjoinPoint(args=[123],signature=getTestDemo(Long)),idempotent拼接 methodName + Arrays.toString (args),生成 MD5 哈希(如 a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6)a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6(to IdempotentAspect)
4IdempotentRedisDAOkey=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6,timeout=10,timeUnit=SECONDS格式化 Redis 键为 idempotent:a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6,调用 setIfAbsent 设置键(值为空字符串,过期 10 秒)true/false(to IdempotentAspect)
5IdempotentAspectsuccess(true/false),joinPoint,idempotent若 success=false,抛出 ServiceException (900, "重复请求,请稍后重试");若 true,调用 joinPoint.proceed ()。若异常发生且 deleteKeyWhenException=true,调用 IdempotentRedisDAO.deleteid=123(to TestDemoController if success=true)
6TestDemoControllerid=123(via joinPoint.proceed())创建 TestDemoRespVO,设置 id=123,返回 CommonResult.success (resp)CommonResult(HTTP 响应,{"code": 0, "data": {"id": 123}, "msg": "成功"})

http://www.xdnf.cn/news/687475.html

相关文章:

  • 2025年05月28日Github流行趋势
  • uniapp-商城-74-shop(7-商品列表,选规格 添加商品到购物车)
  • 前端面试准备-1
  • Linux中的权限概念
  • Java SE Cloneable接口和深/浅拷贝
  • 水域应急救援可视化平台
  • 【前端】Vue3+elementui+ts,TypeScript Promise<string>转string错误解析,习惯性请出DeepSeek来解答
  • 国产SOC有哪些?
  • 即插即用的全新算法改进策略——引导学习策略:一种用于元启发式算法设计和改进的新型更新机制
  • Unity对象池插件Lean Pool学习笔记
  • android 图片背景毛玻璃效果实现
  • Flutter 与 Android 原生布局组件对照表(完整版)
  • TencentOSTiny
  • 【模型显著性分析】配对样本 t 检验
  • 虚拟与现实时空认知同步的核心指标
  • maven中的maven-resources-plugin插件详解
  • 部署LVS-DR群集
  • Docker部署Spark大数据组件:配置log4j日志
  • Vue开发系列——零基础HTML引入 Vue.js 实现页面之间传参
  • Kotlin 中的数据类型有隐式转换吗?为什么?
  • 天津工作机会:技术文档工程师 - 华海清科股份有限公司
  • 【Linux】分页式存储管理:深刻理解页表映射
  • 【Doris基础】Apache Doris 基本架构深度解析:从存储到查询的完整技术演进
  • 金砖国家人工智能高级别论坛在巴西召开,华院计算应邀出席并发表主题演讲
  • 960g轻薄本,把科技塞进巧克力盒子
  • 从零开始学安全:服务器被入侵后的自救指南
  • 第二章 1.5 数据采集安全风险防范之数据采集安全管理
  • git和gitee的常用语句命令
  • JS语言基础
  • LiveNVR 直播流拉转:Onvif/RTSP/RTMP/FLV/HLS 支持海康宇视天地 SDK 接入-视频广场页面集成与视频播放说明