spring boot / cloud (十九) 并发消费消息,如何保证入库的数据是最新的?
spring boot / cloud (十九) 并发消费消息,如何保证入库的数据是最新的?
消息中间件在解决异步处理,模块间解耦和,和高流量场景的削峰,等情况下有着很广泛的应用 .
本文将跟大家一起讨论以下其中的异常场景,如题.
场景
在实际工作中,大家可能也都遇到过这样的需求 :
如 : 系统A中的某些重要的数据,想在每次数据变更的时候,将当前最新的数据备份下来,当然,这个备份的动作不能影响当前数据变更的进程.
也更不期望因为备份的操作,影响当前进程的性能.
分析
这是一个比较常见的,可以异步处理的需求,业务数据变更 和 数据备份 之间并没有强一致性的要求,大致的架构如下:
producer作为消息产生者,会通过指定的交换机(exchange)和路由键(routingkey),将消息传输到指定的队列(queue)中,通常producer也会有多个节点
consume作为消息的消费者,会依次从队列(queue)中拿到消息,进行业务处理,最终将数据写入数据库中,并且为了更快速的消费消息,consume通常会部署多个节点,并且每个节点中也会有多个线程同时消费消息
queue作为消息队列,保证了消息被消费的时序性,以及唯一性(一条消息只能被消费一次)
dlxQueue作为死信队列,当queue中的的消息无法被正常消费时,当重处理N次后,将会被放入死信队列,并有专门的consume来消费和处理,比如:通知相关人员进行人工干预,等.
问题
producer会源源不断的产生消息,有新的数据,也有更新老的数据,
而consume则是拿到消息,做insert或者update的操作.
但是由于consume是多线程并发消费消息的,那么就会出现当前线程拿到的消息并非最新版本的消息,如果这个时候进行了update操作的话,很有可能会覆盖掉已经是最新版本的数据了
如 : 当前数据库里的数据为1,业务操作先是将1改为了2,然后马上的又将2改为了3,这两个操作时间非常接近,几乎是同时,然后产生的消息也几乎同时的进入了消息中间件,
但是在queue里依然有先后,2在前3在后(queue机制保证),那么这个时候,consume来消费了,由于consume是多线程的,所以,2和3会被分配到两条线程中同时被处理
这时,如果2的这条线程先结束,3的这条线程后结束,那么则数据正常,最终数据被更新成3
但是,如果3的这条线程先结束了,2的这条线程是后结束的,那么,最新的数据就会被老数据覆盖掉
这种情况显然是不满足需求记录当前最新的数据的,
并且这种情况很容易发生,虽然queue里保证了消息的先后,以及唯一性,但是消息被consume在线程中消费确实同时处理的
脏读的问题
通常以上这种情况,网络上的一些解决方案,都是在数据中加入版本(version)的概念来解决,本文也是(上文提及的1,2,3,其实就是版本的概念).
通常网络上的描述是,在update的时候,根据数据库中的最新版本号,如果当前消息的版本号小于数据库最新的版本号,则放弃更新,大于,则更新
这一段逻辑很简单,但是也很容易产生误解,最大的问题在于获得最新版本号,在多线程环境下,数据库的脏读也是蛮严重的,脏读的存在,导致你查询出来的数据并非是最新的数据
如 : 上面的一个场景,数据库中最新的版本号是1,有版本号2和3两个消息是即将要消费的,按照上面的逻辑,处理程序应该先查数据库,拿到当前最新的版本.
这个时候,两条线程查询到的结果有可能都是1,这时2>1,并且3>1,两条线程依然都会执行,同样的 :
如果2的这条线程先结束,3的这条线程后结束,那么则数据正常,最终数据被更新成3
如果3的这条线程先结束了,2的这条线程是后结束的,那么,最新的数据就会被老数据覆盖掉
同样达到想要的效果
如何保证入库的数据是最新的?
其实要实现很简单,首先,要知道,对于同一行数据,sql的执行也是有先后顺序的,其实到底更新为2的sql语句先执行,还是更新为3的sql语句先执行,并不重要
重要的是,将判断版本号的语句放入更新条件中进行判断.
例子 : 同样是上面的场景,数据库中的版本为1,这时2和3同时更新,谁先结束,谁也不知道,也无法控制(其实有办法,但是损失性能,当前场景需要的是高效)
但是我们可以在条件中加入"version < 2"这样的条件
SQL语句样例 :
UPDATE TEST_MSG
SET
VERSION = #{version},
DATA = #{data},
LAST_UPDATE_DATE = #{lastUpdatedDate}
WHERE BUSINESS_KEY = #{businessKey} AND VERSION < #{version}
这样的话,无论那条线程先结束,都不会影响最终的结果
如果,2先结束,3线程的条件为2 < 3,条件成立,数据将会被更新为3
如果,3先结束,2线程的条件为3 < 2,条件不成立,数据则不会更新(由于是在sql执行过程中判断,所以这里不存在脏读的情况)
这样就能满足记录当前最新的数据的需求了
实现 (springboot使用rabbitmq的例子)
spring.rabbitmq.host=itkk.org
spring.rabbitmq.port=5672
spring.rabbitmq.username=dev_udf-sample
spring.rabbitmq.password=1qazxsw2
spring.rabbitmq.virtual-host=/dev_udf-sample
spring.rabbitmq.template.retry.enabled=true
spring.rabbitmq.listener.simple.retry.enabled=true
spring.rabbitmq.listener.simple.concurrency=5
spring.rabbitmq.listener.simple.max-concurrency=10
以上配置文件配置了rabbitmq的连接,也指定了消费者监听器的并发数量(5)和最大并发数量(10),并且开启了重试,重试失败的消息会被流转到死信队里里
@Configuration
public class UdfServiceADemoConfig {
public static final String EXCHANGE_ADEMO_TEST1 = "exchange.ademo.test1";
public static final String QUEUE_ADEMO_TEST1_CONSUME1 = "queue.ademo.test1.consume1";
public static final String ROUTINGKEY_ADEMO_TEST1_TESTMSG = "routingkey.ademo.test1.testmsg";
@Bean
public DirectExchange exchangeAdemoTest1() {
return new DirectExchange(EXCHANGE_ADEMO_TEST1, true, true);
}
@Bean
public Queue queueAdemoTest1Consume1() {
return new Queue(QUEUE_ADEMO_TEST1_CONSUME1, true, false, true);
}
@Bean
public Binding queueAdemoTest1Consume1Binding() {
return new Binding(QUEUE_ADEMO_TEST1_CONSUME1,
Binding.DestinationType.QUEUE, EXCHANGE_ADEMO_TEST1,
ROUTINGKEY_ADEMO_TEST1_TESTMSG, null);
}
}
exchangeAdemoTest1方法定义了一个交换机,并且是自动删除的
queueAdemoTest1Consume1定义了一个消费者队列,也是自动删除的
queueAdemoTest1Consume1Binding将上面定义的交换机和消费者绑定起来,并设定了路由键(routingkey)
public class TestMsg implements Serializable {
/**
* 描述 : id
*/
private static final long serialVersionUID = 1L;
/**
* msgId
*/
private String msgId = UUID.randomUUID().toString();
/**
* businessKey
*/
private String businessKey;
/**
* version
*/
private long version;
/**
* data
*/
private String data;
/**
* lastUpdatedDate
*/
private Date lastUpdatedDate;
}
以上定义了消息的格式,主要的字段就是businessKey和version,分别用来确定唯一的业务数据和版本的判断
@Autowired
private AmqpTemplate amqpTemplate;
@Scheduled(fixedRate = SCHEDULED_FIXEDRATE)
public void send1() {
this.send();
}
public void send2() {
.....
}
/**
* send
*/
private void send() {
final int numA = 1000;
int a = (int) (Math.random() * numA);
long b = (long) (Math.random() * numA);
TestMsg testMsg = new TestMsg();
testMsg.setBusinessKey(Integer.toString(a));
testMsg.setVersion(b);
testMsg.setData(UUID.randomUUID().toString());
testMsg.setLastUpdatedDate(new Date());
amqpTemplate.convertAndSend(UdfServiceADemoConfig.EXCHANGE_ADEMO_TEST1,
UdfServiceADemoConfig.ROUTINGKEY_ADEMO_TEST1_TESTMSG, testMsg);
}
以上定义了用于做测试的消息发送方,使用计划任务,定期的向交换机中写入数据,可以定义多个计划任务,增加同一时间消息产生的数量
@RabbitListener(queues = UdfServiceADemoConfig.QUEUE_ADEMO_TEST1_CONSUME1)
public void consume1(TestMsg testMsg) {
if (testMsgRespository.count(testMsg.getBusinessKey()) > 0) {
int row = testMsgRespository.update(testMsg);
log.info("update row = {}", row);
} else {
try {
int row = testMsgRespository.insert(testMsg);
log.info("insert row = {}", row);
} catch (Exception e) {
//进行异常判断,确定是主键冲突错误
int row = testMsgRespository.update(testMsg);
log.info("update row = {}", row);
}
}
try {
final long time = 5L;
Thread.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
以上定义了消息消费的方法,此方法是多线程执行的,大概逻辑是,先count数据库,判断数据是否存在,如果存在,则update数据,如果不存在,则insert数据
但是count也有可能存在脏读的情况,所以insert操作有可能会因为主键重复而失败,这时,会捕获到异常,通过异常判断,确定是主键冲突错误后(样例代码中省略了),在进行update操作
这里的update操作,则是上文提到的采用"version < #{updateVersion}"的方法进行更新,保证了将最新的数据更新到数据库中
最后的线程休眠,是为了模拟处理时间,以便造成更多的并发情况
int count(@Param("businessKey") String businessKey);
int insert(TestMsg testMsg);
int update(TestMsg testMsg);
<select id="count" resultType="int">
SELECT COUNT(*)
FROM TEST_MSG
WHERE BUSINESS_KEY = #{businessKey}
</select>
<insert id="insert">
INSERT INTO TEST_MSG
(
BUSINESS_KEY,
VERSION,
DATA,
LAST_UPDATE_DATE
)
VALUES
(
#{businessKey},
#{version},
#{data},
#{lastUpdatedDate}
)
</insert>
<update id="update">
UPDATE TEST_MSG
SET
VERSION = #{version},
DATA = #{data},
LAST_UPDATE_DATE = #{lastUpdatedDate}
WHERE BUSINESS_KEY = #{businessKey} AND VERSION <![CDATA[ < ]]> #{version}
</update>
以上为相关的sqlmap定义以及mapper接口的定义
CREATE TABLE TEST_MSG
(
BUSINESS_KEY VARCHAR(100) NOT NULL
PRIMARY KEY,
VERSION BIGINT NOT NULL,
DATA VARCHAR(100) NOT NULL,
LAST_UPDATE_DATE DATETIME NOT NULL
)
COMMENT 'TEST_MSG';
以上为表结构的定义
结束
这样,启动应用,观察数据库表更新情况,会发现,数据的版本只会有增长的,不会存在降低的
那么,我们这实现了本文中开头提到的需求了.
并且也了解了在springboot中如何使用rabbitmq发送和消费消息了.
关于本文内容 , 欢迎大家的意见跟建议
代码仓库 (博客配套代码)
想获得最快更新,请关注公众号
spring boot / cloud (十九) 并发消费消息,如何保证入库的数据是最新的?的更多相关文章
- spring boot / cloud (十五) 分布式调度中心进阶
spring boot / cloud (十五) 分布式调度中心进阶 在<spring boot / cloud (十) 使用quartz搭建调度中心>这篇文章中介绍了如何在spring ...
- spring boot / cloud (十六) 分布式ID生成服务
spring boot / cloud (十六) 分布式ID生成服务 在几乎所有的分布式系统或者采用了分库/分表设计的系统中,几乎都会需要生成数据的唯一标识ID的需求, 常规做法,是使用数据库中的自动 ...
- spring boot / cloud (十四) 微服务间远程服务调用的认证和鉴权的思考和设计,以及restFul风格的url匹配拦截方法
spring boot / cloud (十四) 微服务间远程服务调用的认证和鉴权的思考和设计,以及restFul风格的url匹配拦截方法 前言 本篇接着<spring boot / cloud ...
- spring boot / cloud (十二) 异常统一处理进阶
spring boot / cloud (十二) 异常统一处理进阶 前言 在spring boot / cloud (二) 规范响应格式以及统一异常处理这篇博客中已经提到了使用@ExceptionHa ...
- spring boot / cloud (十八) 使用docker快速搭建本地环境
spring boot / cloud (十八) 使用docker快速搭建本地环境 在平时的开发中工作中,环境的搭建其实一直都是一个很麻烦的事情 特别是现在,系统越来越复杂,所需要连接的一些中间件也越 ...
- spring boot / cloud (九) 使用rabbitmq消息中间件
spring boot / cloud (九) 使用rabbitmq消息中间件 前言 rabbitmq介绍: RabbitMQ是一个在AMQP基础上完整的,可复用的企业消息系统.它可以用于大型软件系统 ...
- spring boot / cloud (二十) 相同服务,发布不同版本,支撑并行的业务需求
spring boot / cloud (二十) 相同服务,发布不同版本,支撑并行的业务需求 有半年多没有更新了,按照常规剧本,应该会说项目很忙,工作很忙,没空更新,吧啦吧啦,相关的话吧, 但是细想想 ...
- 使用Intellij中的Spring Initializr来快速构建Spring Boot/Cloud工程(十五)
在之前的所有Spring Boot和Spring Cloud相关博文中,都会涉及Spring Boot工程的创建.而创建的方式多种多样,我们可以通过Maven来手工构建或是通过脚手架等方式快速搭建,也 ...
- Spring Boot教程(十五)使用Intellij中的Spring Initializr来快速构建Spring Boot/Cloud工程
在之前的所有Spring Boot和Spring Cloud相关博文中,都会涉及Spring Boot工程的创建.而创建的方式多种多样,我们可以通过Maven来手工构建或是通过脚手架等方式快速搭建,也 ...
随机推荐
- 201521123121 《Java程序设计》第5周学习总结
1. 本周学习总结 1.1 尝试使用思维导图总结有关多态与接口的知识点. 2. 书面作业 代码阅读:Child压缩包内源代码 1.1 com.parent包中Child.java文件能否编译通过?哪句 ...
- 201521123098 《Java程序设计》第2周学习总结
1. 本周学习总结 1. 熟悉了一些码云中储存eclipse中代码的操作,利于随时储存代码,避免U盘丢失导致代码丢失的问题: 2. 了解了如何从码云中提取已储存的代码: 3. 学会了如何创建动态数组, ...
- 201521123097《Java程序设计》第二周学习总结
1.本周学习总结 (1)学习了java的一些类型和变量. (2)学习了码云的部分功能的使用. 2.书面作业 使用Eclipse关联jdk源代码,并查看String对象的源代码. 为什么要尽量频繁的对字 ...
- Java:java中BufferedReader的read()及readLine()方法的使用心得
BufferedReader的readLine()方法是阻塞式的, 如果到达流末尾, 就返回null, 但如果client的socket末经关闭就销毁, 则会产生IO异常. 正常的方法就是使用sock ...
- python基础之字典、赋值补充
字典常用操作: 存/取info_dic={'name':'egon','age':18,'sex':'male'} print(info_dic['name11111111']) print(info ...
- Spring第二篇【Core模块之快速入门、bean创建细节、创建对象】
前言 上篇Spring博文主要引出了为啥我们需要使用Spring框架,以及大致了解了Spring是分为六大模块的-.本博文主要讲解Spring的core模块! 搭建配置环境 引入jar包 本博文主要是 ...
- Python学习笔记010_迭代器_生成器
迭代器 迭代就类似于循环,每次重复的过程被称为迭代的过程,每次迭代的结果将被用来作为下一次迭代的初始值,提供迭代方法的容器被称为迭代器. 常见的迭代器有 (列表.元祖.字典.字符串.文件 等),通常 ...
- js 第一课
什么是JavaScript JavaScript是一种脚本语言,运行在网页上.无需安装编译器.只要在网页浏览器上就能运行 一般JavaScript与HTML合作使用. 例如 <html> ...
- <c:forEach>+<c:if>
<c:forEach>:用来做循环<c:if>:相当于if语句用于判断执行,如果表达式的值为 true 则执行其主体内容. <c:forEach var="每个 ...
- 【实验吧】Reverse400
在网上下载,pyinstxtractor.py,对Reverse400.exe进行反汇编 得到其源代码为 $ cat Revesre03 data = \ "\x1c\x7a\x16\x77 ...