学习记录-基于分布式锁注解防重复提交
1.什么是幂等性?
在计算机中编程中,一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。
2.什么是接口幂等性?
在HTTP/1.1
中,对幂等性进行了定义。它描述了一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外),即第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用。
这里的副作用是不会对结果产生破坏或者产生不可预料的结果。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。
3.为什么需要实现幂等性?
在接口调用时一般情况下都能正常返回信息不会重复提交,不过在遇见以下情况时可以就会出现问题,如:
- 前端重复提交表单: 在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。
- 用户恶意进行刷单: 例如在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
- 接口超时重复提交:很多时候 HTTP 客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
- 消息进行重复消费: 当使用 MQ 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。
4.引入幂等性后对系统有什么影响?
1. 系统稳定性提升
- 防止重复操作:幂等性可以避免因网络重试、客户端重复请求或消息队列重复消费等导致的操作重复,从而防止数据不一致。
- 提高系统容错性:当发生故障或异常时,系统能够安全地重试操作而不会产生额外副作用。
2. 代码复杂性增加
- 引入幂等性需要对业务逻辑进行改造,例如:
- 设计唯一请求标识(如请求 ID 或流水号)。
- 维护操作状态记录(如数据库记录或缓存记录)。
- 某些操作(如扣减库存)可能需要较复杂的逻辑以确保幂等性,这会增加开发和维护成本。
3. 性能开销
- 状态存储:为确保幂等性,系统可能需要额外存储请求状态或结果(Redis 缓存或数据库)。
- 校验逻辑:每次请求都需要进行幂等性校验,这可能带来额外的性能开销。
5.基于分布式锁注解的实现
5.1自定义幂等注解
自定义幂等注解用于标记需要实现幂等性的接口方法。结合分布式锁机制,确保即使在分布式环境中,接口的某次调用只会被成功执行一次,从而避免重复提交、重复消费等问题。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoDuplicateSubmit {
/**
* 提示信息:当请求被重复时的提示。
*/
String message() default "您操作的太快请稍后重试";
/**
* 锁的唯一标识,默认为空时可动态生成。
*/
String key() default "";
}
5.2实现切面
5.2.1锁的key
在实现幂等性切面中,锁的 Key 是核心设计,直接关系到幂等逻辑的正确性。一个合理的锁 Key 需要具备以下特点:
一个典型的分布式锁 Key 通常由以下部分组成:
1.1 接口唯一标识
- 使用 请求路径(如
/order/create
)来唯一标识当前操作的接口。 - 作用:区分不同的接口,避免同一用户对不同接口的请求相互影响。
1.2 用户唯一标识
- 使用 用户 ID(如
userId
)区分不同用户的操作。 - 作用:确保锁仅对同一用户生效,不影响其他用户。
1.3 请求参数唯一标识
- 将方法参数进行唯一性标识,通常采用 MD5 或 SHA256 对参数进行加密。
- 作用:确保同一用户对同一接口的不同参数不会误认为重复请求。
所以key默认为:幂等锁前缀+用户id+入参+请求路径
5.2如何获取注解信息,获取请求入参
在切面中,获取方法的注解信息和请求入参是关键步骤。这可以通过反射和 ProceedingJoinPoint
提供的 API 实现。
/**
* 获取目标方法上的 @NoDuplicateSubmit 注解实例。
*
* @param joinPoint AOP 切点,包含了目标方法的信息。
* @return 目标方法的 @NoDuplicateSubmit 注解实例;如果没有此注解,则返回 null。
* @throws NoSuchMethodException 当目标方法不存在时抛出。
*/
public static NoDuplicateSubmit getNoDuplicateSubmitAnnotation(ProceedingJoinPoint joinPoint) throws NoSuchMethodException {
// 获取方法签名
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
// 获取目标方法
Method targetMethod = joinPoint.getTarget().getClass()
.getDeclaredMethod(methodSignature.getName(), methodSignature.getMethod().getParameterTypes());
// 返回注解实例
return targetMethod.getAnnotation(NoDuplicateSubmit.class);
}
/**
* 获取目标方法的入参列表。
*
* @param joinPoint AOP 切点,包含了目标方法的信息。
* @return 方法的入参数组,按传递顺序排列。
*/
public static Object[] getMethodArgs(ProceedingJoinPoint joinPoint) {
// 从切点中获取方法参数
return joinPoint.getArgs();
}
/**
* 根据方法的入参计算 MD5 值,用于生成唯一标识。
*
* @param joinPoint AOP 切点,包含了目标方法的信息。
* @return 参数的 MD5 哈希值,作为幂等性的标识。
*/
public static String calcArgsMD5(ProceedingJoinPoint joinPoint) {
// 使用 FastJSON 将方法参数序列化为 JSON 字节数组,并计算其 MD5 值
return DigestUtil.md5Hex(JSON.toJSONBytes(joinPoint.getArgs()));
}
/**
* @return 获取当前线程上下文 ServletPath
*/
private String getServletPath() {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return sra.getRequest().getServletPath();
}
用户id一般是在Usercontext内
5.3使用 MD5 加密的意义
为什么要对对目标方法的参数计算出一个唯一的 MD5 哈希值,用于生成分布式锁的 lockKey
,而不是直接用入参,原因如下。
1.减少锁键长度
请求参数可能很长,直接使用参数构造锁键会导致锁键长度过大,影响性能和存储效率。MD5 将输入参数压缩为固定长度(通常是 32 位的十六进制字符串)。
2.生成唯一标识
即使请求参数的结构复杂(例如嵌套对象、数组等),计算后的 MD5 值能确保唯一性。不同的参数组合会生成不同的哈希值,防止锁冲突。
3.安全性
MD5 加密是一种单向哈希算法,保证了参数不会直接暴露在锁键中,增加了数据安全性。
5.4环绕通知方法实现
@Around("@annotation(com.nageoffer.onecoupon.framework.idempotent.NoDuplicateSubmit)")
public Object noDuplicateSubmit(ProceedingJoinPoint joinPoint) throws Throwable {
NoDuplicateSubmit noDuplicateSubmit = getNoDuplicateSubmitAnnotation(joinPoint);
// 获取分布式锁标识
String lockKey = String.format("no-duplicate-submit:path:%s:currentUserId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(joinPoint));
RLock lock = redissonClient.getLock(lockKey);
// 尝试获取锁,获取锁失败就意味着已经重复提交,直接抛出异常
if (!lock.tryLock()) {
throw new ClientException(noDuplicateSubmit.message());
}
Object result;
try {
// 执行标记了防重复提交注解的方法原逻辑
result = joinPoint.proceed();
} finally {
lock.unlock();
}
return result;
}
5.5配置 Bean:单模块与组件化开发的差异
在实现幂等性功能时,根据项目的规模和结构不同,配置方式也会有所不同:
- 单模块项目:直接在切面类上使用
@Component
注解。 - 组件化开发:通过配置类注册 Bean,并结合 Spring 自动装配机制,使组件能够灵活集成到不同项目中。
1.单模块项目的实现
在单模块项目中,可以直接将切面类通过 @Component
注解标记为 Spring Bean,这样 Spring 容器会自动扫描并管理该切面。
2.组件化开发的实现
在组件化开发中,为了让幂等性功能在多个项目中复用,需将其封装为一个独立的模块(如 starter
),并通过配置类动态注册相关 Bean。
1.切面类去除 @Component
注解
2.通过配置类注册 Bean
@Configuration
@RequiredArgsConstructor
public class IdempotentAutoConfiguration {
private final RedissonClient redissonClient;
/**
* 注册幂等性切面到 Spring 容器。
*
* @return NoDuplicateSubmitAspect 幂等性切面
*/
@Bean
public NoDuplicateSubmitAspect noDuplicateSubmitAspect() {
// 将 RedissonClient 注入到切面中,用于实现分布式锁
NoDuplicateSubmitAspect aspect = new NoDuplicateSubmitAspect();
aspect.setRedissonClient(redissonClient);
return aspect;
}
}
3.配置 Spring 自动装配文件
在 resources/META-INF/spring.factories
中添加自动装配配置:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=yourPackage.idepotent.IdempotentAutoConfiguration
Spring Boot 2.x 及之前版本
在 Spring Boot 2.x 及之前版本,自动装配通过 META-INF/spring.factories
文件实现,格式如下:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=yourPackage.idepotent.IdempotentAutoConfiguration
特点:
- 文件路径必须是
META-INF/spring.factories
。 - 配置的类需要使用
@Configuration
注解。 spring.factories
文件会在 Spring 启动时被扫描并加载。
Spring Boot 3.x 及更高版本
从 Spring Boot 3.x 开始,推荐使用 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件代替 spring.factories
。
原因:
- 优化了自动装配的处理机制,支持原生配置(如 GraalVM 的 native-image)。
- 更加轻量化,专注于自动配置逻辑。
文件内容格式
在 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
中配置:
yourPackage.idepotent.IdempotentAutoConfiguration
标签:return,请求,重复,joinPoint,提交,注解,分布式
From: https://blog.csdn.net/sjsjsbbsbsn/article/details/145131305