MQ专题:消费幂等性

一、提要

1.1 通过本文将获得

  1. 消息投递的通用代码
    • 非事务消息的投递
    • 事务消息的投递
    • 任意延迟消息的投递,不依赖于任何MQ
    • 上面这些投递都支持批量的方式
    • 投递失败自动重试的代码
  2. 幂等消费的通用代码
  3. 消费失败,衰减式自动重试的通用代码

1.2本文涉及到的主要技术点

  1. SpringBoot2.7
  2. MyBatisPlus
  3. MySQL
  4. 线程池
  5. java中的延迟队列:DelayQueue
  6. 分布式锁
  7. RabbitMQ

二、消费者幂等性概念

2.1 消费者如何确保消息一定会被消费?

消费者这边可以采用下面的过程,可以确保消息一定会被消费。

  • step1:从MQ中拉取消息,此时不要从mq中删除消息

  • step2:执行业务逻辑(需要做幂等)

  • step3:通知MQ删除这条消息

若上面过程失败了,则采用衰减式的方式进行自动重试,比如第一次消费失败后,延迟10秒后,将消息再次丢入队列,进行消费重试,若还是失败,再延迟20秒后丢入队列,继续重试,但是得有个上限,比如最多50次,达到上限需要进行告警人工干预。

这里的关键技术点就是:幂等+重试+开启消费者手动ack

什么是消费失败后衰减式重试?

失败后,会过一会,再次重试,若还是失败,则过一会,再次重试。

比如累计失败次数在1-5次内,每次失败后会间隔10秒进行重试,在6-10次内,间隔20秒,在11-20次内,间隔30秒,但是有个次数上限,比如50次,达到最大次数,将不再重试,报警,人工干预

衰减重试是如何实现的?

通过延迟消息实现的,消费失败后,会投递一条延迟消息,消息的内容和原本消息的内容是一样的,延迟时间到了后,这个消息会进入消息原本的队列,会触发再次消费。

2.2 什么是消费者手动ack(acknowledgemenet)?

消费者从mq中拉取消息后,mq需要将消息从mq中删除,这个删除有2种方式

方式1:MQ自动删除

消费者从mq中拉取消息后,mq立即就把消息删掉了,此时消费者还未消费。

这种可能会有问题,比如消费者拿到消息后,消费失败了,但是此时消息已经被mq删除了,结果会导致消息未被成功消费。

方式2:消费者通知MQ删除(也叫手动ack)

消费者从mq拉取消息后,做业务处理,业务处理完成之后,通知mq删除消息,这种就叫做消费者手动ack

这种会存在通知mq删除消息失败的情况,会导致同一条消息会被消费者消费多次,消费端需要避免重复消费。

本文中用的是这种ack的方式。

2.3 什么是幂等消费?

同一条消息,即使出现了重复的消息,被同一个消费者消费,也只会成功消费一次。

为什么要考虑幂等消费?

先看下上面这个图,消息从发送到消费的整个过程,中间涉及到网络通信,网络存在不稳定的因素,这就可能导致下面2个问题

重复投递的情况

生产者投递消息到MQ,由于网络问题,未收到回执,生产者以为消息投递失败了,会重试,这就可能会导致同一条消息被投递多次

消费者ACK失败,消息会被再次消费

消费者拉取消息消费后,会通知MQ中删除此消息,通知MQ删除消息这个过程又涉及网络通信,可能会失败,此时会导致消息被消费者消费了,但是却未从mq中删除,这样消息就会被再次拉取进行消费。

上面2种情况,会导致同一条消息,会被消费者处理多次,消费端若未考虑幂等性,可能导致严重的事故。

三、幂等消费问题的解决方案

如何解决幂等消费的问题?

搞定下面2个问题,幂等消费的问题就解决了。

  1. 如何确定MQ中的多条消息是同一条业务消息?
  2. 消费者如何确保同一条消息只被成功消费一次?

3.1 生产者:如何确定MQ中的多条消息是同一条业务消息?

我们可以定义一种通用的消息的格式,格式如下,生产者发送的所有消息,都必须采用这个格式。

java 复制代码
public class Msg<T> {
    /**
     * 生产者名称
     */
    private String producer;
    /**
     * 生产者这边消息的唯一标识
     */
    private String producerBusId;
    /**
     * 消息体,主要是消息的业务数据
     */
    private T body;
}

对于多条消息,通过(producer、producerBusId)这两个字段来判断是否是同一条消息,若他们的这两个字段的值是一样的,则表示他们是同一条消息。

  • producer:可以使用服务名称
  • producerBusId:生产者这边消息的唯一标识,比如可以使用UUID

3.2 消费者:如何确保同一条消息只被成功消费一次?

3.2.1 使用辅助表建立唯一约束

需要一个幂等辅助表,如下,idempotent_key 添加了唯一约束,多个线程同时向这个表写入数据,若idempotent_key是一样的,则只有一个会成功,其他的会违反唯一约束触发异常,导致失败。

sql 复制代码
create table if not exists t_idempotent_lesson033
(
    id             varchar(50) primary key comment 'id,主键',
    idempotent_key varchar(500) not null comment '需要确保幂等的key',
    unique key uq_idempotent_key (idempotent_key)
) comment '幂等辅助表';

3.2.2 消费端幂等消费实现代码块

用上面的幂等辅助表,便可实现幂等消费,过程如下

java 复制代码
// 这里的幂等key,由消息里面的(producer,producerBusId)加上消费者完整类名组成,也就是同一条消息只能被同一个消费者消费一次
String idempotentKey = (producer,producerBusId,消费者完整类名);

// 幂等表是否存在记录,如果存在说明处理过,直接返回成功
IdempotentPO idempotentPO = select * from t_idempotent_lesson033 where idempotent_key = #{idempotentKey};
if(idempotentPO!=null){
	return "SUCCESS";
}
 --以下是关键理解--
开启Spring事务(这里千万不要漏掉,一定要有事务)

这里放入消息消费的实际业务逻辑,最好是db操作的代码。。。。。

String idempotentId = "";
// 这里是关键一步,向 t_idempotent 插入记录,如果有并发过来,只会有一个成功,其他的会报异常导致事务回滚
insert into t_idempotent_lesson033 (id, idempotent_key) values (#{idempotentId}, #{idempotentKey});

提交spring事务

四、案例

4.1 下面先看案例

会模拟电商中下单后,投递一条订单消息,然后会搞一个消费者来消费这个消息。

本案例会用到RabbitMQ,大家先安装rabbitmq,然后修改lesson033/src/main/resources/application.yml中rabbitmq相关配置。

RabbitMQ的安装可以参考:https://blog.csdn.net/qq_30166465/article/details/139612362

4.2 会有3个案例代码

  • 投递普通订单消息,模拟消费
  • 投递延迟订单消息,延迟5秒,模拟消费
  • 投递普通消息,模拟消费失败,自动重试的情况

4.3 案例中会用到5个表

先不用记,大概有个印象,知道每个表是干什么用的就行了

sql 复制代码
-- 创建订单表,业务相关
drop table if exists t_order_lesson033;
create table if not exists t_order_lesson033
(
    id    varchar(32)    not null primary key comment '订单id',
    goods varchar(100)   not null comment '商品',
    price decimal(12, 2) comment '订单金额'
) comment '订单表';

-- 创建本地消息表,用来存储事务消息和延迟消息
drop table if exists t_msg_lesson033;
create table if not exists t_msg_lesson033
(
    id               varchar(32) not null primary key comment '消息id',
    exchange         varchar(100) comment '交换机',
    routing_key      varchar(100) comment '路由key',
    body_json        text        not null comment '消息体,json格式',
    status           smallint    not null default 0 comment '消息状态,0:待投递到mq,1:投递成功,2:投递失败',
    expect_send_time datetime    not null comment '消息期望投递时间,大于当前时间,则为延迟消息,否则会立即投递',
    actual_send_time datetime comment '消息实际投递时间',
    create_time      datetime comment '创建时间',
    fail_msg         text comment 'status=2 时,记录消息投递失败的原因',
    fail_count       int         not null default 0 comment '已投递失败次数',
    send_retry       smallint    not null default 1 comment '投递MQ失败了,是否还需要重试?1:是,0:否',
    next_retry_time  datetime comment '投递失败后,下次重试时间',
    update_time      datetime comment '最近更新时间',
    key idx_status (status)
) comment '本地消息表';


-- 创建消息和消费者关联表,(producer, producer_bus_id, consumer_class_name)相同时,此表只会产生一条记录,就是同一条消息被同一个消费者消费,此表只会产生一条记录
drop table if exists t_msg_consume_lesson033;
create table if not exists t_msg_consume_lesson033
(
    id              varchar(32)  not null primary key comment '消息id',
    producer        varchar(100) not null comment '生产者名称',
    producer_bus_id varchar(100) not null comment '生产者这边消息的唯一标识',
    consumer_class_name        varchar(300) not null comment '消费者完整类名',
    queue_name      varchar(100) not null comment '队列名称',
    body_json       text         not null comment '消息体,json格式',
    status          smallint     not null default 0 comment '消息状态,0:待消费,1:消费成功,2:消费失败',
    create_time     datetime comment '创建时间',
    fail_msg        text comment 'status=2 时,记录消息消费失败的原因',
    fail_count      int          not null default 0 comment '已投递失败次数',
    consume_retry   smallint     not null default 1 comment '消费失败后,是否还需要重试?1:是,0:否',
    next_retry_time datetime comment '投递失败后,下次重试时间',
    update_time     datetime comment '最近更新时间',
    key idx_status (status),
    unique uq_msg (producer, producer_bus_id, consumer_class_name)
) comment '消息和消费者关联表';

drop table if exists t_msg_consume_log_lesson033;
-- 消息消费的日志
create table if not exists t_msg_consume_log_lesson033
(
    id              varchar(32)  not null primary key comment '消息id',
    msg_consume_id        varchar(32) not null comment '消息和消费者关联记录',
    status          smallint     not null default 0 comment '消费状态,1:消费成功,2:消费失败',
    create_time     datetime comment '创建时间',
    fail_msg        text comment 'status=2 时,记录消息消费失败的原因',
    key idx_msg_consume_id (msg_consume_id)
) comment '消息消费日志';

-- 幂等辅助表
drop table if exists t_idempotent_lesson033;
create table if not exists t_idempotent_lesson033
(
    id             varchar(50) primary key comment 'id,主键',
    idempotent_key varchar(500) not null comment '需要确保幂等的key',
    unique key uq_idempotent_key (idempotent_key)
) comment '幂等辅助表';

4.4 代码

关键消费代码

继承了AbstractIdempotentConsumer则拥有了幂等消费的功能

消费成功或失败时,对t_msg_consume和t_msg_consume_log进行更新

消费失败时,会重新投递一条相同的延迟消息,触发消费重试

相关推荐
customer087 分钟前
【开源免费】基于SpringBoot+Vue.JS医院管理系统(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·开源·intellij-idea
2402_8575893617 分钟前
SpringBoot框架:作业管理技术新解
java·spring boot·后端
HBryce2421 分钟前
缓存-基础概念
java·缓存
一只爱打拳的程序猿36 分钟前
【Spring】更加简单的将对象存入Spring中并使用
java·后端·spring
杨荧37 分钟前
【JAVA毕业设计】基于Vue和SpringBoot的服装商城系统学科竞赛管理系统
java·开发语言·vue.js·spring boot·spring cloud·java-ee·kafka
minDuck39 分钟前
ruoyi-vue集成tianai-captcha验证码
java·前端·vue.js
为将者,自当识天晓地。1 小时前
c++多线程
java·开发语言
daqinzl1 小时前
java获取机器ip、mac
java·mac·ip
激流丶1 小时前
【Kafka 实战】如何解决Kafka Topic数量过多带来的性能问题?
java·大数据·kafka·topic