同一条消息被 comsumer 消费两次
中间件本身原因
人为因素
不可抗力
中间件本身
人为因素(通常造成问题最多的场景)
不可抗力
rabbitMq(指定消费者组)
rabbitMq 通过对 comsumer 进行分组,从 rabbit 的 web 管理端可以查看各个 exchange 的分组情况
web 管理页面会显示为 bindings,这里就是我们说的分组(分组的称呼是根据配置字段来的)
点击可以查看详情,比如消费者数量
涉及到的配置
spring:
application:
name: rabbit-stream-consumer
rabbitmq:
host: 192.168.3.10
port: 5672
username: guest
password: guest
cloud:
stream:
binders:
defaultRabbit:
type: rabbit
bindings:
input:
destination: studyExchange
content-type: application/json
binder: defaultRabbit
group: studyExchange_main # 关键是这个配置项,我们说的分组也是按这个配置项叫的
kafka(操作 offset)
//TODO start 待迁移
kafka 是一个上限很高但下限很低的消息中间件,从实践中体会,它的易用性相对不高。
这里说的易用性不高是指,相对其他消息中间件,它更需要一定的学习成本,需要了解一些概念,否则很容易错误使用。
kafka 判断一个消息是否被消费,是基于 偏移量(offset)的
kafka 在 springboot 中使用时,是有一个 @KafkaListener 的,用法如下
@KafkaListener(topics = {"${kafka.topic.alarm.topic-name}"}, containerFactory = "generalKafkaConsumerFactory")
@Override
public void listen(ConsumerRecord<String, String> consumerRecord, Acknowledgment ack) {
super.listen(consumerRecord, ack);
}
它可以让被注解的方法按 每次消费单条消息并自动提交 offset 的方式消费 topic
但这里的自动提交 offset 是只要方法(比如上面的 listen())执行完就提交,那消费失败靠什么区分呢,靠抛异常。
于是作者所见不止一个团队,远远不止一个研发,各种 throw new Exception(),接着就各种消息丢失
因为只有抛 RunTimeException 时,才会进入重试机制
//TODO end 待迁移
默认的 @KafkaListener
kafka 的防止重复消费主要依靠正确提交 offset
kafka 结合 springboot 时,提供了一个相对易用的注解 @KafkaListener
需要注意此注解默认单条消费、自动提交,只有消费过程中抛出 RunTimeException ,才会进入重试机制
配合自定义模板或其他二次开发
同时,在某些时候,默认注解不能满足业务场景(毕竟是个百万吞吐量的消息中间件,业务场景花样很多),此时我们可以通过一些简单模板封装或二次开发以满足不同的需要
模板1,要求研发人员提供一个 boolean 的返回,除非消费完全正常,否则一律不提交 offset,根据细节有不同处理
此模板实践中可以有效防止研发人员只考虑业务处理而不考虑考虑处理结果(毕竟处理成什么样得返回)
public abstract class KafkaLisenterPrototype<T> {
protected static Logger logger= LoggerFactory.getLogger(KafkaLisenterPrototype.class);
public void listen(ConsumerRecord<String, T> record, Acknowledgment ack) {
logger.info("<<>> load msg:" );
boolean processed = false;
try {
logger.info("<<>> topic:{}, partition:{} loaded" ,record.topic(), record.partition());
T message = record.value();
processed = onMessages(message);//实际处理,会返回一个 boolean 的结果
logger.info("<<>> processed [{}]" ,processed);
if(processed){
ack.acknowledge();//手动提交偏移量
logger.info("<<>> offset commited:\n{}" ,message);
}else{
logger.info("<<>> offset not commited:\n{}" ,message);
throw new RuntimeException("for retry");
}
} catch (Exception e ) {
logger.error("<<>> exception:" ,e);
if(e instanceof RuntimeException) throw e;
}finally{
logger.info("<<>> done" );
}
}
public abstract boolean onMessages(T message);
}
模板2,再更复杂的业务中,我们可能需要对 offset 进行更多的操作,以适应更多消费场景
/**
* 目的:
* 原始的kafkaListener,只支持正常执行完成和异常两种处理结果,分别对应提交offset和重试
* 但是,在复杂场景,需要按具体需求自定义行为时,可能并不友好
* 因此需要更多的对消息执行完之后的操作的指示信息
*
* 此类中,使用String类型的值代表这些信息,并预设若干指令,并给出默认的处理方式 postActionHandle
*
* 扩展思路:
* 允许扩展指示信息,并提供扩展宏的指示信息-指示信息执行器容器和指示信息执行器接口
* 优化现有默认处理方式为执行器组(链)
* 提供注册指示信息执行器的注解
*/
public abstract class PostActionKafkaLisenterPrototype<T> {
protected static Logger logger= LoggerFactory.getLogger(PostActionKafkaLisenterPrototype.class);
@Resource
private KafkaConfig kafkaConfig;
@Resource
protected KafkaTemplate<String, String> kafkaTemplate;
@Resource
private AdminClient adminClient;
/* *******************************
* PROCESSED 处理成功,提交offset
* FAILED 失败,重试,不提交offset
* RETRY 重试,自己控制重试时才使用,比如使用重试队列
* IGNORE 忽略,不处理,提交offset,重复消费等场景使用
* DELAY 延时,不满足处理条件时使用,可以直接丢回队列,也可以自定义重试队列,变更重试次数,推荐钩子
* DEAD 死信,直接进入死信队列,没有重试价值时使用
* POSTACTION 钩子,完全自定义,推荐使用简易的命令模式
* 后面会支持自注册的后置处理
******************************* */
protected static final String IGNORE = "IGNORE";
protected static final String PROCESSED = "PROCESSED";
protected static final String FAILED = "FAILED";
protected static final String DELAY = "DELAY";
protected static final String DEAD = "DEAD";
protected static final String POSTACTION = "POSTACTION";
public void listen(ConsumerRecord<String, T> record, Acknowledgment ack) {
logger.info("<<>> load msg:" );
String processed ;
logger.info("<<>> {} loaded" ,record.topic());
processed = (null==record.value())?DEAD:onMessages(record.value());
logger.info("<<>> processed [{}]" ,processed);
postActionHandle(processed, record, ack);
logger.info("<<>> done" );
}
protected void postActionHandle(String processed, ConsumerRecord<String, T> record, Acknowledgment ack) {
switch (processed){
case IGNORE:
ack.acknowledge();//手动提交偏移量
logger.info("post action:offset commit(ignore) {}({},{})",
record.topic(),record.partition(),record.offset());
break;
case PROCESSED:
ack.acknowledge();//手动提交偏移量
logger.info("post action:offset commit(process) {}({},{})",record.topic(),record.partition(),record.offset());
break;
case FAILED:
logger.info("post action:offset not commited(failed):\n{}",record.value());
throw new KafkaConsumeFailedException();
case DELAY:break;
case DEAD:
try {
if(!adminClient.listTopics().namesToListings().get().containsKey(record.topic()+kafkaConfig.getDlqSuffix())){
logger.error("post action: create DLQ for topic: {}",record.topic());
adminClient.createTopics(
Arrays.asList(new NewTopic(record.topic()+kafkaConfig.getDlqSuffix(),record.partition(), (short) 1)));
//TODO 若出问题,改为while,直到拿到结果
TimeUnit.MILLISECONDS.sleep(500);
kafkaTemplate.send(record.topic()+kafkaConfig.getDlqSuffix(),record.value().toString());
}
ack.acknowledge();//手动提交偏移量
logger.info("post action:offset dead {}({},{})",record.topic(),record.partition(),record.offset());
break;
} catch (InterruptedException|ExecutionException e) {
logger.error("post action offset dead exctption to retry,{}:\n{}",record.topic(),record.value());
postActionHandle(FAILED,record,ack);
break;
}
case POSTACTION:break;
default:
}
}
public abstract String onMessages(T message) throws RuntimeException;
}
正确的配置通常可以保证消息中间件在正常条件下正常工作
但一些不可抗力会导致破功,比如
这些问题通常可以通过幂等解决
幂等解决有两种思路,
一种是真的将消息再完整处理一遍,但再次处理之后与处理了一次之后结果一模一样
另一种是再处理消息前判断消息的状态,跳过已经处理的过的并且结果符合预期的消息
第一种难度较大,操作性不强,通常实现第二种思路
第二种思路有两个要点:
大体流程如下
上面的流程其实可以更加复杂,一个简单的例子,红线处添加一个期待处理缓存校验,这可以适应执行条件比较复杂的消息(比如必须其他服务达成某些条件,此消息才有消费的基础)
传送门:
微服务架构 | 组件目录