随着互联网的飞速发展,系统架构的不断演变,分布式系统已经被使用在大多数场景当中。那么如何处理分布式系统下幂等性问题(接口、方法以及消息队列等)成为一个挑战,面对日益复杂的业务逻辑,怎么简单高效且减少代码冗余的处理幂等性,本文将基于12306开源框架中的幂等性处理模块,深入探讨如何优雅地解决分布式环境下的幂等性问题,我们基于方法参数和SPEL的幂等校验举例。
1、自定义注解、枚举
注解属性包括必要的幂等key,幂等key的超时时间,支持验证幂等类型。 幂等注解
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* 幂等Key
*/
String key() default "";
/**
* 触发幂等失败逻辑时,返回的错误提示信息
*/
String message() default "您操作太快,请稍后再试";
/**
* 验证幂等类型,支持多种幂等方式
* RestAPI 建议使用 {@link IdempotentTypeEnum#TOKEN} 或 {@link IdempotentTypeEnum#PARAM}
* 其它类型幂等验证,使用 {@link IdempotentTypeEnum#SPEL}
*/
IdempotentTypeEnum type() default IdempotentTypeEnum.PARAM;
/**
* 验证幂等场景,支持多种 {@link IdempotentSceneEnum}
*/
IdempotentSceneEnum scene() default IdempotentSceneEnum.RESTAPI;
/**
* 设置防重令牌 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;
}
幂等验证类型
public enum IdempotentTypeEnum {
/**
* 基于 Token 方式验证
*/
TOKEN,
/**
* 基于方法参数方式验证
*/
PARAM,
/**
* 基于 SpEL 表达式方式验证
*/
SPEL
}
幂等验证场景枚举
public enum IdempotentSceneEnum {
/**
* 基于 RestAPI 场景验证
*/
RESTAPI,
/**
* 基于 MQ 场景验证
*/
MQ
}
幂等参数包装类
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public final class IdempotentParamWrapper {
/**
* 幂等注解
*/
private Idempotent idempotent;
/**
* AOP 处理连接点
*/
private ProceedingJoinPoint joinPoint;
/**
* 锁标识,{@link IdempotentTypeEnum#PARAM}
*/
private String lockKey;
}
2、定义抽象类及接口
这里定义了幂等性处理顶级接口。
public interface IdempotentExecuteHandler {
/**
* 幂等处理逻辑
*
* @param wrapper 幂等参数包装器
*/
void handler(IdempotentParamWrapper wrapper);
/**
* 执行幂等处理逻辑
*
* @param joinPoint AOP 方法处理
* @param idempotent 幂等注解
*/
void execute(ProceedingJoinPoint joinPoint, Idempotent idempotent);
/**
* 异常流程处理
*/
default void exceptionProcessing() {
}
/**
* 后置处理
*/
default void postProcessing() {
}
}
定义抽象幂等执行处理器,这里使用模板方法模式。
public abstract class AbstractIdempotentExecuteHandler implements IdempotentExecuteHandler {
/**
* 构建幂等验证过程中所需要的参数包装器
*
* @param joinPoint AOP 方法处理
* @return 幂等参数包装器
*/
protected abstract IdempotentParamWrapper buildWrapper(ProceedingJoinPoint joinPoint);
/**
* 执行幂等处理逻辑
*
* @param joinPoint AOP 方法处理
* @param idempotent 幂等注解
*/
public void execute(ProceedingJoinPoint joinPoint, Idempotent idempotent) {
// 模板方法模式:构建幂等参数包装器
IdempotentParamWrapper idempotentParamWrapper = buildWrapper(joinPoint).setIdempotent(idempotent);
// 执行幂等处理
handler(idempotentParamWrapper);
}
}
3、基于方法参数、SPEL实现幂等逻辑
基于方法参数的幂等性处理主要采用redis加锁的方式防止重复提交,先通过builderWrapper方法包装幂等参数IdempotentParamWrapper,lockKey拼接了请求url,当前用户id,请求参数MD5加密后的字符串,所以这种方式不适用于处理请求接口参数中带有时间戳字段的请求。 执行幂等处理时通过redisson对拼接好的字符串lockKey加锁,失败抛出错误提示信息。
@RequiredArgsConstructor
public final class IdempotentParamExecuteHandler extends AbstractIdempotentExecuteHandler {
private final RedissonClient redissonClient;
private final static String LOCK = "lock:param:restAPI";
@Override
protected IdempotentParamWrapper buildWrapper(ProceedingJoinPoint joinPoint) {
String lockKey = String.format("idempotent:path:%s:currentUserId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(joinPoint));
return IdempotentParamWrapper.builder().lockKey(lockKey).joinPoint(joinPoint).build();
}
/**
* @return 获取当前线程上下文 ServletPath
*/
private String getServletPath() {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return sra.getRequest().getServletPath();
}
/**
* @return 当前操作用户 ID
*/
private String getCurrentUserId() {
String userId = UserContext.getUserId();
if(StrUtil.isBlank(userId)){
throw new ClientException("用户ID获取失败,请登录");
}
return userId;
}
/**
* @return joinPoint md5
*/
private String calcArgsMD5(ProceedingJoinPoint joinPoint) {
return DigestUtil.md5Hex(JSON.toJSONBytes(joinPoint.getArgs()));
}
@Override
public void handler(IdempotentParamWrapper wrapper) {
String lockKey = wrapper.getLockKey();
RLock lock = redissonClient.getLock(lockKey);
if (!lock.tryLock()) {
throw new ClientException(wrapper.getIdempotent().message());
}
IdempotentContext.put(LOCK, lock);
}
@Override
public void postProcessing() {
RLock lock = null;
try {
lock = (RLock) IdempotentContext.getKey(LOCK);
} finally {
if (lock != null) {
lock.unlock();
}
}
}
@Override
public void exceptionProcessing() {
postProcessing();
}
}
基于SPEL表达式的幂等性处理比较常用,需要灵活指定幂等键的场景,比方法参数更灵活。实现起来和基于方法参数的幂等性处理比较类似,并没有通过redisson显式加锁,而是通过执行lua脚本的返回值判断消息是否成功消费。
@RequiredArgsConstructor
public final class IdempotentSpELByMQExecuteHandler extends AbstractIdempotentExecuteHandler {
private final static int TIMEOUT = 600;
private final static String WRAPPER = "wrapper:spEL:MQ";
private final static String LUA_SCRIPT_SET_IF_ABSENT_AND_GET_PATH = "lua/set_if_absent_and_get.lua";
private final DistributedCache distributedCache;
@SneakyThrows
@Override
protected IdempotentParamWrapper buildWrapper(ProceedingJoinPoint joinPoint) {
Idempotent idempotent = IdempotentAspect.getIdempotent(joinPoint);
String key = (String) SpELUtil.parseKey(idempotent.key(), ((MethodSignature) joinPoint.getSignature()).getMethod(), joinPoint.getArgs());
return IdempotentParamWrapper.builder().lockKey(key).joinPoint(joinPoint).build();
}
@Override
public void handler(IdempotentParamWrapper wrapper) {
String uniqueKey = wrapper.getIdempotent().uniqueKeyPrefix() + wrapper.getLockKey();
String absentAndGet = this.setIfAbsentAndGet(uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMING.getCode(), TIMEOUT, TimeUnit.SECONDS);
// redis已经存在记录,判断是否消费成功
if (Objects.nonNull(absentAndGet)) {
// 消息已成果消费
boolean error = IdempotentMQConsumeStatusEnum.isError(absentAndGet);
LogUtil.getLog(wrapper.getJoinPoint()).warn("[{}] MQ repeated consumption, {}.", uniqueKey, error ? "Wait for the client to delay consumption" : "Status is completed");
throw new RepeatConsumptionException(error);
}
// 放在幂等处理器上下文中,用ThreadLocal封装
IdempotentContext.put(WRAPPER, wrapper);
}
public String setIfAbsentAndGet(String key, String value, long timeout, TimeUnit timeUnit) {
DefaultRedisScript<String> redisScript = new DefaultRedisScript<>();
ClassPathResource resource = new ClassPathResource(LUA_SCRIPT_SET_IF_ABSENT_AND_GET_PATH);
redisScript.setScriptSource(new ResourceScriptSource(resource));
redisScript.setResultType(String.class);
long millis = timeUnit.toMillis(timeout);
return ((StringRedisTemplate) distributedCache.getInstance()).execute(redisScript, List.of(key), value, String.valueOf(millis));
}
/*
* 异常情况redis删除消费记录,RocketMq重试
*/
@Override
public void exceptionProcessing() {
IdempotentParamWrapper wrapper = (IdempotentParamWrapper) IdempotentContext.getKey(WRAPPER);
if (wrapper != null) {
Idempotent idempotent = wrapper.getIdempotent();
String uniqueKey = idempotent.uniqueKeyPrefix() + wrapper.getLockKey();
try {
distributedCache.delete(uniqueKey);
} catch (Throwable ex) {
LogUtil.getLog(wrapper.getJoinPoint()).error("[{}] Failed to del MQ anti-heavy token.", uniqueKey);
}
}
}
@Override
public void postProcessing() {
IdempotentParamWrapper wrapper = (IdempotentParamWrapper) IdempotentContext.getKey(WRAPPER);
if (wrapper != null) {
Idempotent idempotent = wrapper.getIdempotent();
String uniqueKey = idempotent.uniqueKeyPrefix() + wrapper.getLockKey();
try {
distributedCache.put(uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMED.getCode(), idempotent.keyTimeout(), TimeUnit.SECONDS);
} catch (Throwable ex) {
LogUtil.getLog(wrapper.getJoinPoint()).error("[{}] Failed to set MQ anti-heavy token.", uniqueKey);
}
}
}
}
Lua脚本
-- 原子性获取给定key,若key存在返回其值,若key不存在则设置key并返回null
local key = KEYS[1]
local value = ARGV[1]
local expire_time_ms = ARGV[2]
return redis.call('SET', key, value, 'NX', 'GET', 'PX', expire_time_ms)
4、AOP拦截幂等方法,执行幂等处理逻辑
@Aspect
public final class IdempotentAspect {
/**
* 增强方法标记 {@link Idempotent} 注解逻辑
*/
@Around("@annotation(xxx.xxx.xxx.idempotent.annotation.Idempotent)")
public Object idempotentHandler(ProceedingJoinPoint joinPoint) throws Throwable {
Idempotent idempotent = getIdempotent(joinPoint);
// 利用简单工厂方法获取幂等处理执行器,我们可以实现AbstractIdempotentExecuteHandler扩展
IdempotentExecuteHandler instance = IdempotentExecuteHandlerFactory.getInstance(idempotent.scene(), idempotent.type());
Object resultObj;
try {
instance.execute(joinPoint, idempotent);
resultObj = joinPoint.proceed();
instance.postProcessing();
} catch (RepeatConsumptionException ex) {
/**
* 触发幂等逻辑时可能有两种情况:
* * 1. 消息还在处理,但是不确定是否执行成功,那么需要返回错误,方便 RocketMQ 再次通过重试队列投递
* * 2. 消息处理成功了,该消息直接返回成功即可
*/
if (!ex.getError()) {
return null;
}
throw ex;
} catch (Throwable ex) {
// 客户端消费存在异常,需要删除幂等标识方便下次 RocketMQ 再次通过重试队列投递
instance.exceptionProcessing();
throw ex;
} finally {
IdempotentContext.clean();
}
return resultObj;
}
public static Idempotent getIdempotent(ProceedingJoinPoint joinPoint) throws NoSuchMethodException {
// 获取方法签名
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
// 获取目标方法
Method targetMethod = joinPoint.getTarget().getClass().getDeclaredMethod(methodSignature.getName(), methodSignature.getMethod().getParameterTypes());
// 获取目标注解
return targetMethod.getAnnotation(Idempotent.class);
}
}
5、实际场景应用
以往我们对接口或者消息做幂等性处理时,随时业务逐渐复杂,项目中出现大量冗余代码,可读性和可维护性极差,通过自定义注解+AOP+简单工厂模式+模板方法等就可以很简单的处理幂等性问题,极大提高我们日常工厂中的开发效率。
@Component
@RequiredArgsConstructor
@RocketMQMessageListener(
topic = xxx,
selectorExpression = xxx,
consumerGroup = xxx
)
public class XxxConsumer implements RocketMQListener<XXX>> {
private final XxxService xxxService;
@Idempotent(
uniqueKeyPrefix = "index12306-xxx:xxxxxx:",
key = "#message.getKeys()+'_'+#message.hashCode()",
type = IdempotentTypeEnum.SPEL,
scene = IdempotentSceneEnum.MQ,
keyTimeout = 7200L
)
@Override
public void onMessage(MessageWrapper<xxx> message) {
// 消息监听,执行业务操作
}
}
链接: 12306开源代码地址.