当前灵感、部分资源来自于知识星球 :拿个offer
如果侵权请联系删除
业务背景
当使用消息队列时,客户端重复消费可能会造成严重问题
因为消息队列具有持久性和可靠性的特性,确保消息能够倍成功传递给消费者。但是这也会导致客户端在一些情况下重复消费消息,EX:
-
网络故障/延迟(例如:导致ack对视,mq没有收到ack,认为消费者挂了或者消息处理失败,过一段时间重新发送消息)
-
客户端崩溃(例如:执行完了业务逻辑,在执行return 或者提交ack处理前的一瞬间,客户端崩溃了,同样是没有收到ack)
-
消息处理失败(例如:onMessage方法上面没有加上事务,代码执行出现异常,数据库不会回滚(脏数据保留)同时消息队列触发重试,两者结合导致严重的重复处理问题)
为了避免上述情况发生,需要在客户端实现一些机制保证消息不会被重复消费:
EX:
-
记录消费者已经处理的消息ID
-
使用分布式锁控制消费进程的唯一性
这些机制保证了消息能够被陈宫处理,同时也能够提高系统的可靠性、稳定性。
消息幂等性
消息队列当中RocketMQ实现异步、削峰填谷、解耦等功能的情况下,我们认为消息中间件是一个可靠的组件。
这里可靠性指的是:只要消息被成功投递到了消息中间件,就不会丢失,至少会被消费者消费一次。这是消息中间件的最基本特性之一,也就是我们常说的"AT LEAST ONCE",也就是消息至少被成功消费一次。
然而,这种可靠性特性也会导致消息被多次投递的情况。举个例子,仍然以之前的例子为例,如果消费程序 A 接收并完成消息 M 的消费逻辑后,正准备通知消息中间件"我已经消费成功了",但在此之前程序A又重启了,那么对于消息中间件来说,这个消息 M 并没有被成功消费过,因此消息中间件会继续投递这个消息。而对于消费程序A来说,尽管它已经成功消费了这个消息,但由于程序重启导致消息中间件继续投递,看起来就好像这个消息还没有被成功消费过一样。
在 RockectMQ 的场景中,这意味着同一个 messageId 的消息会被重复投递。由于消息的可靠投递是更重要的,所以避免消息重复投递的任务转移给了应用程序自身来实现。这也是 RocketMQ 文档强调消费逻辑需要自行实现幂等性的原因。实际上,这背后的逻辑是:在分布式场景下,保证消息不丢和避免消息重复投递是矛盾的,但是消息重复投递是可以解决的,而消息丢失则非常麻烦。
幂等设计
当前方案的优点在于:使用了redis消息去重表,不依赖事务,针对消息表本身做了状态的区分:消费中,消费完成
-
消息已经在消费中,抛出异常,消息触发延迟消费
-
rocketmq场景下消费失败,间隔时间后再次发起消费流程

通过当前方案可以解决什么问题:
-
消息已经消费成功,第二条重复的消息将被直接幂等处理掉
-
并发场景下,依旧能满足不出现消息重复,即穿透幂等挡板的问题
-
支持上游业务生产者重发的重复业务 消息幂等问题
为什么要给初始化的幂等标识新增10分钟过期时间?
并发场景下,我们使用消息状态实现并发控制,让第二条消息不断被延迟消费(重试)。但是如果在此期间第一条消息也因为一些异常(机器重启/外部异常)导致未被成功消费,如何解决?因为每次查询时都会显示 "消费中" 的状态,延迟消费/重试会一直进行下去,直至最终被视为消费失败并被投递到死信Topic当中。(RocketMQ当中消息最多被消费16次)
解决方案:
插入消息表时,必须为每条消息设置一个最畅销费时间,EX:10min。这意味着,如果某个消息在消费过程当中执行时间超过了10分钟,被视为消费失败并从消息表中进行删除
抽象通用幂等组件
在我们的消息消费当中,防重复消费幂等组件是通用的
将通用的防止幂等组件同样在framework模块当中开发

1.自定义幂等注解
java
/**
* 幂等注解:防止雄安锡独立额消费者重复消费消息
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoMQDuplicateConsume {
/**
* 设置防重令牌Key前缀
*/
String keyPrefix() default "";
/**
* 通过SpEL表岛是生成的唯一key
*/
String key();
/**
*设置防重令牌Key过期时间,单位为秒,默认1小时
*/
long keyTimeout()default 3600L;
}
详细解释Retention注解以及参数作用
核心作用
指定该自定义注解在运行时的生命周期范围
也就是:该注解在编译之后是否保留、能否被JVM读取、能否通过反射获取
写上RUNTIME表示:
注解不仅会存在于源码、字节码当中,还会再运行时保留,JVM允许通过反射读取到它
这就是为什么aop切面、拦截器、spring等框架能够通过反射判断方法上是否有你的注解
三种参数取值以及区别
|-----------|-----------------------|------------|------------------------------------|
| 枚举值 | 中文含义 | JVM/反射可否读取 | 典型使用场景 |
| SOURCE | 只保留在源码中,编译后丢弃 | 不可读取 | Lombok、@Override、@SuppressWarnings |
| CLASS(默认) | 源码 → class 文件,但运行时不可见 | 不可读取 | 一般很少自定义使用,编译器/框架内部 |
| RUNTIME | 源码 → class → 运行时都存在 | 可读取(可反射) | Spring 注解、AOP 注解、校验注解 |
为什么幂等注解上面必须使用@Retention(RetentionPolicy.RUNTIME)
当前我们的注解使用类似:
java
@NoDuplicateSubmit
public void saveOrder() {}
SpringAOP通过反射判断方法是否包含该注解:(我们之前后管平台的防幂等注解aop切面就用到了这个方法)
java
method.getAnnotation(NoDuplicateSubmit.class);
如果你的注解不是 RUNTIME:
-
SOURCE→ 编译后信息消失,AOP 永远检测不到 -
CLASS→ class 文件有,但 JVM 运行时不加载注解信息 -
只有
RUNTIME→ JVM 才能读取注解
所以:
不使用 RUNTIME,你的幂等注解就无法在切面中生效。
2.SpEL表达式解析工具类
java
package com.ycy.onecoupon.framework.idempotent.utils;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.util.ArrayUtil;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Optional;
/**
* SpEL表达式解析工具
* 用于将字符串形式的SpEL解析为实际值,常用于缓存Key、幂等Key等场景
*/
public final class SpELUtil {
/**
* 校验并返回实际使用的SpEL表达式
* 如果过SpEL包含SpEL标识符,调用parse方法解析实际值并返回
* 否则直接返回原始字符串
*
* @param spEL SpEL表达式
* @param method 方法
* @param contextObj 上下文对象
* @return 实际使用的SpEL表达式
*/
public static Object parseKey(String spEL, Method method,Object[] contextObj){
//定义需要识别的SpEL标记符(#:变量引用:T(java.lang.Math),"T(" :类型引用开头)
List<String> spELFlag = ListUtil.of("#", "T(");
//Optional:处理null出现的空指针
//findFirst可能找到匹配的标识符------非空
// 找不到------null空指针,异常
Optional<String> optional = spELFlag.stream().
filter(spEL::contains).//对每个元素检查SpEL表达式是否包含它
findFirst();//拿到第一个标识符
if(optional.isPresent())//optional当中是否有值
return parse(spEL,method,contextObj);
//不包含SpEL标记符,原样返回
return spEL;
}
/**
* 解析SpEL表达式并返回表达式执行结果
* @param spEL SpEL表达式
* @param contextObj 上下文对象
* @return 解析的字符串值
*/
public static Object parse(String spEL, Method method ,Object[]contextObj){
//默认参数名解析工具类,通过反射和字节码信息解析方法参数名
DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
//SpEL语句解析类
SpelExpressionParser parser = new SpelExpressionParser();
//得到SpEL对应的表达式对象
Expression exp = parser.parseExpression(spEL);
//得到当前方法参数名
String[] params = discoverer.getParameterNames(method);
//SpEL执行所需的上下文,用于存储表达式可访问的变量
StandardEvaluationContext context = new StandardEvaluationContext();
//参数不为空
if(ArrayUtil.isNotEmpty(params)){
//将对象存到上下文当中(将方法参数名和对应的实参参数绑定为SpEl上下文变量,例如context.setVariable("id", 1001))
for(int len=0;len<params.length;len++){
context.setVariable(params[len],contextObj[len]);
}
}
//返回SpEL表达式解析执行出来的对象值
return exp.getValue(context);
}
}
代码思路:
-
识别当前字符串是否是SpEL语句:包含 # 、T( 作为前缀
-
如果不是SpEL字符串,直接返回
-
是SpEL字符串,进行执行:
-
声明参数名解析类、SpEL解析类、表达式类
-
通过参数名解析类解析当前方法得到参数名称
-
声明上下文用于存储当前能够得到的所有参数以及对应的实参,用于后续SpEL解析
-
解析所有参数,对应其实参一起存入上下文
-
表达式对象调用上下文调用对应参数执行,返回结果
-
(如果需要给SpEL提供参数的话需要使用上下文(当前我遇到的情况都是的))
3.状态枚举类
java
/**
* MQ消费幂等状态枚举
*/
@Getter
@RequiredArgsConstructor
public enum IdempotentMQConsumeStatusEnum {
/**
*消费中
*/
CONSUMING("0"),
/**
* 已经消费
*/
CONSUMED("1");
private final String code;
/**
* 消费状态等于消费中,返回失败
* @param consumeStatus 消费状态
* @return 是否消费失败
*/
public static boolean isError(String consumeStatus){
return ObjectUtil.equals(CONSUMING.code,consumeStatus);
}
}
4.自定义切面控制器
java
/**
* 防止消息队列消费者重复消费消息切面控制器
*/
@Slf4j
@Aspect
@RequiredArgsConstructor
public class NoMQDuplicateConsumeAspect {
private final StringRedisTemplate stringRedisTemplate;
//java文本块,使用""""定义多行字符串而不需要手动\n,手动转义引号,手动拼接字符串,增强文本可读性
//好处:较短的lua脚本无需写在resources当中、无需加载脚本,直接调用
//缺点:语法错误难以检查
private static final String LUA_SCRIPT = """
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)
""";
/**
* 脚本解析:
* set key value :写入键值对
* nx :仅当key不存在时执行写入
* get:redis 6.2新增的选项,作用:返回key的旧值(set之前的)
* key不存在:返回nil key存在:返回旧值(字符串)
* px:设置过期时间,单位:毫秒 (ex:设置过期时间为秒)
*/
@Around("@annotation(com.ycy.onecoupon.framework.idempotent.annotations.NoMQDuplicateConsume)")
public Object noMQRepeatConsume(ProceedingJoinPoint joinPoint)throws Throwable{
NoMQDuplicateConsume noMQDuplicateConsume = getNoMQDuplicateConsumeAnnotation(joinPoint);
String uniqueKey = noMQDuplicateConsume.keyPrefix() + SpELUtil.parseKey(noMQDuplicateConsume.key(), ((MethodSignature) joinPoint.getSignature()).getMethod(), joinPoint.getArgs());
String absentAndGet = stringRedisTemplate.execute(
RedisScript.of(LUA_SCRIPT, String.class),
List.of(uniqueKey),
IdempotentMQConsumeStatusEnum.CONSUMING.getCode(),
String.valueOf(TimeUnit.SECONDS.toMillis(noMQDuplicateConsume.keyTimeout()))
);
//返回值不为空,证明已经有
if (ObjectUtil.isNotEmpty(absentAndGet)) {
//检查状态是不是正在执行
boolean errorFlag = IdempotentMQConsumeStatusEnum.isError(absentAndGet);
//可以肯定的是当前消息先前一定是存在的,但是有两种情况:
// 1.当前相同消息在执行 ,抛出异常原因:防止当前执行出现异常(预防)
// 2.相同消息执行完毕(执行完毕后续无需再次执行,直接结束即可,无需重试
log.warn("[{}] MQ repeated consumption, {}.", uniqueKey, errorFlag ? "Wait for the client to delay consumption" : "Status is completed");
if(errorFlag){
throw new ServiceException(String.format("消息消费幂等异常,幂等标识:%s",uniqueKey));
}
return null;
}
//正常执行
Object result;
try{
//执行注解标记方法原逻辑
result = joinPoint.proceed();
//防重复key,调整状态为消费完成,设置新的过期时间.过期时间内防止重复执行
stringRedisTemplate.opsForValue().set(uniqueKey,IdempotentMQConsumeStatusEnum.CONSUMED.getCode(),noMQDuplicateConsume.keyTimeout(),TimeUnit.SECONDS);
} catch (Throwable ex) {
//删除幂等key,让消息队列的重试能够正常执行
stringRedisTemplate.delete(uniqueKey);
//抛出异常,让消息队列重试(正常情况)
// 能执行这里代码是正常执行,这和上面的抛出异常是两种情况,不会同时被触发
throw ex;
}
return result;
}
public static NoMQDuplicateConsume getNoMQDuplicateConsumeAnnotation(ProceedingJoinPoint joinPoint)throws NoSuchMethodException{
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
//第二个参数:拿到对应的方法签名,拿到被代理对象的原始方法,之后拿到jvm反射层面实际定义的方法参数类型
//直接使用signature去拿参数类型是aop对方法签名的解析结果可能和上面出现差异
Method targetMethod = joinPoint.getTarget().getClass().getMethod(methodSignature.getName(), methodSignature.getMethod().getParameterTypes());
return targetMethod.getAnnotation(NoMQDuplicateConsume.class);
}
}
在消息消费端实现幂等控制时,当前方案不再复用之前"防前端重复提交" 所采用的Redisson分布式锁模式,而是构建一套基于Redis的幂等状态表。本方案贴合消息队列处理特性,对消息在不同的生命周期进行更加细化的管理。
为什么使用幂等状态表
常规分布式锁仅有"被占用"、"未占用"两种不同状态。而我们的幂等状态表允许我们自定义更加丰富、适配业务的转台,例如我们当前方案当中的"正在消费"、"消费完成"等。对于MQ场景,这些状态是必要的:
-
消费过程中可能失败,需要告知 MQ 进行重试。
-
消费完成后同一条消息无需再被执行,应直接跳过。
分布式锁在MQ场景当中有着天然的限制:
-
锁状态无法表达MQ需要的多种状态
-
锁续期适合长耗时业务,但是对于短流程的MQ消费并不必要
下面介绍我们当前切面处理类对幂等状态表的处理逻辑
两种消费状态处理策略
两种核心状态:
-
正在执行
-
执行完成
执行完毕
若状态表中已记录该消息为"执行完成",说明业务逻辑已经成功处理过该消息。此时重新收到相同消息时,可以直接跳过,不再重复执行对应方法。
这避免了重复消费,也避免了业务侧产生重复数据。
执行当中
相较于执行完毕状态,我们需要进一步判断:
- 当前任务正在执行,但是一定成功吗?
不一定。消费逻辑可能出现异常、业务问题
应对策略:
-
当前消息和正在执行的消息冲突,aop主动抛出异常,提示MQ进行后续重试当前消息执行,避免多个消费者并发处理内容相同消息。下次重试时:
-
消费完成,return
-
之前相同消息失败,删除了key,当前消息能够执行
-
-
当前执行确实发生异常时,删除当前幂等key,释放执行资格,抛出异常,MQ触发后续重试。
两个抛出异常是否冲突?
不冲突
-
第一个抛出异常是为了防止影响当前同任务执行+同任务的兜底 延迟重试;作用场景:并发重复冲突
-
第二个抛出异常则是当前执行异常,正常重试;作用场景:消费失败场景
即使出现了当前消息重新尝试+后续相同消息,也会再次被当前aop拦截、处理,无需担心重复执行两遍
为什么使用SpEL构建幂等表key?
MQ 幂等的本质是:
为每条消息生成一个唯一业务标识(幂等 key),用来判断消息是否已被消费。
不同消息的 couponTaskId、userId、orderId、messageId 都可能不同,所以幂等 key 必须能够从方法参数中动态提取业务字段。
为什么注释上不直接编写java代码
注解的参数要求:
-
只能是编译期常量
-
不能执行代码
-
不能引用对象属性
无法在注释代码当中写:
java
@NoMQDuplicateConsume(key = msg.couponTaskId)
使用SpEL
使用SpEL能够满足我们的需求
通过aop环绕拿到当前方法的joinPoint,解析SpEL
java
SpELUtil.parseKey(noMQDuplicateConsume.key(), method, joinPoint.getArgs());
基于如下机制:
-
AOP 拦截方法时能拿到方法签名与参数值
-
SpEL 引擎能基于参数名、方法、参数值解析表达式
优点:
-
支持任意字段、多层级对象和方法的调用
-
支持表达式拼接,灵活度更高
-
运行时动态解析,适配aop和注解机制,适配当前业务
-
不侵入业务
-
通用性、可复用性强
5.幂等配置类添加配置
java
/**
* 幂等组件相关配置类
*/
//虽然没有添加@Configuration注解但是因为使用了meta-inf.spring包下面的自动配置文件,bean方法能够生效
public class IdempotentConfiguration {
/**
* 防止用户重复提交表单信息切面控制器
*/
//模块当中应入依赖,提供有效的redis配置,触发自动装配
@Bean
public NoDuplicateSubmitAspect noDuplicateSubmitAspect(RedissonClient redissonClient){
return new NoDuplicateSubmitAspect(redissonClient);
}
/**
* 防止消息队列消费者重复消费消息切面控制器
*/
@Bean
public NoMQDuplicateConsumeAspect noMQDuplicateConsumeAspect(StringRedisTemplate stringRedisTemplate) {
return new NoMQDuplicateConsumeAspect(stringRedisTemplate);
}
}
为什么需要:
因为幂等注解aop控制器当中需要用到RedissonClient、StringRedisTemplate这些基础组件
但是作为framework模块,不可能、没必要添加pom文件用于配置
通过自动装配机制提供迷瞪组件配置类
-
业务模块存在对应的Redis Bean前提下自动注入
-
模块保持可移植性、独立性
-
简化框架依赖管理