总结一下之前的开发实践,参考网上开源项目实现的幂等组件开发😎😎😎
幂等问题
先说下什么是幂等,幂等性是数学和计算机科学中的概念,用于描述操作无论执行多少次,都产生相同结果的特性。在软件行业中,广泛应用该概念。当我们说一个接口支持幂等性时,无论调用多少次,系统的结果都保持一致。
开发中主要有两种场景:接口幂等、消息消费幂等
方案:分布式锁、Token令牌、去重表
幂等组件设计
首先定义幂等注解:
作用在方法上、运行时有效
less
/**
* 幂等注解
*
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* 幂等Key,只有在 {@link Idempotent#type()} 为 {@link IdempotentTypeEnum#SPEL} 时生效
*/
String key() default "";
/**
* 触发幂等失败逻辑时,返回的错误提示信息
*/
String message() default "您操作太快,请稍后再试";
/**
* 验证幂等场景,支持多种 {@link IdempotentSceneEnum}
*/
IdempotentSceneEnum scene() default IdempotentSceneEnum.RESTAPI;
/**
* 验证幂等类型,支持多种幂等方式
* RestAPI 建议使用 {@link IdempotentTypeEnum#TOKEN} 或 {@link IdempotentTypeEnum#PARAM}
* 其它类型幂等验证,使用 {@link IdempotentTypeEnum#SPEL}
*/
IdempotentTypeEnum type() default IdempotentTypeEnum.PARAM;
/**
* 设置防重令牌 Key 前缀,MQ 幂等去重可选设置
* {@link IdempotentSceneEnum#MQ} and {@link IdempotentTypeEnum#SPEL} 时生效
*/
String uniqueKeyPrefix() default "";
/**
* 设置防重令牌 Key 过期时间,单位秒,默认 1 小时,MQ 幂等去重可选设置
* {@link IdempotentSceneEnum#MQ} and {@link IdempotentTypeEnum#SPEL} 时生效
*/
long keyTimeout() default 3600L;
}
幂等AOP
需要AOP就是拿到幂等注解修饰的方法,进行前置和后置处理
就是拿到方法上的注解实例,然后在工厂中返回具体的策略模式处理器
基于分布式锁
基于分布式锁的实现,就是在方法前加分布式锁具体实现就是把获取到的分布式锁放到幂等上下文中,方法执行结束后释放分布式锁资源
为什么要使用 IdempotentContext?
幂等组件中把一部分内容放到幂等上下文类,并在不同方法中进行使用,为什么这么使用?
因为如果不这么用,会有大量的参数需要在方法传参中声明以及传递,使用上下文形式可以很好规避该问题,编码较为优雅。比如释放锁资源时需要判断当前线程是否持有该锁
基于Token的实现
思路就是客户端在第一次调用业务请求之前会发送一个获取 Token 的请求。服务端会生成一个全局唯一的 ID 作为 Token,并将其保存在 Redis 中,同时将该 ID 返回给客户端。
在客户端进行第二次业务请求时,必须携带这个 Token。
服务端会验证这个 Token,如果验证成功,则执行业务逻辑并从 Redis 中删除该 Token。
如果验证失败,说明 Redis 中已经没有对应的 Token,表示重复操作,服务端会直接返回指定的结果给客户端
基于Token的实现就是拿到请求头或者请求体看看是否有Token,如果有则删除缓存
基于Spel方法验证请求幂等性
就是在消费者的类的onMessage
方法上添加注解:
ini
@Idempotent(
uniqueKeyPrefix = "index12306-order:pay_result_callback:",
key = "#message.getKeys()+'_'+#message.hashCode()",
type = IdempotentTypeEnum.SPEL,
scene = IdempotentSceneEnum.MQ,
keyTimeout = 7200L
)
通过请求入参 message 对象,获取属性 keys 值,然后再获取 message 对象的 hashCode 值,通过 _ 的方式拼接在一起,就得到了本次请求的唯一幂等 Key
然后执行器中:
scss
@Override
public void handler(IdempotentParamWrapper wrapper) {
// 拼接前缀和 SpEL 表达式对应的 Key 生成最终放到 Redis 中的唯一标识
String uniqueKey = wrapper.getIdempotent().uniqueKeyPrefix() + wrapper.getLockKey();
// 向 Redis 触发命令,如果值不存在则存储返回 True,值存在返回 False
Boolean setIfAbsent = ((StringRedisTemplate) distributedCache.getInstance())
.opsForValue()
.setIfAbsent(uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMING.getCode(), TIMEOUT, TimeUnit.SECONDS);
// 如果值为 False,那么就代表要么消息已经执行完成了或者执行中
// 两个不同的状态需要执行不同的逻辑
// 为此,需要再进行判断
if (setIfAbsent != null && !setIfAbsent) {
// 获取幂等标识对应的值,判断是否为已执行成功
String consumeStatus = distributedCache.get(uniqueKey, String.class);
// 如果已经执行成功了,那么 error 为 false;执行中 error 为 true
boolean error = IdempotentMQConsumeStatusEnum.isError(consumeStatus);
LogUtil.getLog(wrapper.getJoinPoint()).warn("[{}] MQ repeated consumption, {}.",
uniqueKey,
error ? "Wait for the client to delay consumption" : "Status is completed");
// 将异常抛出到上层
throw new RepeatConsumptionException(error);
}
IdempotentContext.put(WRAPPER, wrapper);
}
获取StringRedisTemplate
的实例,使用setIfAbsent
是否能设置成功,可以设置成功则之前没有消费过,将key放到幂等的上下文中,如果不能设置成功,那么就代表要么消息已经执行完成了或者执行中,两个不同的状态需要执行不同的逻辑
在StringRedisTemplate
中获取幂等标识对应的值,根据注解的状态判断是否消费成功,并向上抛出,根据抛出异常的error变量来决定让 RocketMQ 重试,还是将该消息吞掉,不执行具体的消费流程
这种本质上还是基于缓存