写此博客的缘由:
题外话:暑假期间实习参与了公司一个SaaS项目,由于是一个新立的项目,因此使用较多的开源框架。在这两个月期间,接触了微服务框架、定时任务调度Quartz、Spring Batch批处理等多个开源框架。由于在某些任务中需要进行批处理作业,使用了Spring batch批处理框架。接触Spring Batch重点学习了Spring Batch Remote Partitioning,完成实现相关功能的Demo。首先,博主本来对java不是太熟悉的,刚来公司的时候连maven都没有用过,更不用说什么微服务、spring框架等,很感谢实习导师教我很多入门的基础,后来自己能够自主学习并参与项目工作。
在学习Spring Batch远程分区的过程中,有一点就是这方面的相关资料或者分享的学习经验很少,最主要的资料就是官方的参考文档,但是参考文档中对Remote Partitioning的介绍也是相当简单,对于初学Spring Batch的人来说很不友好。写这篇博客,总结了自己学习Spring Batch远程分区功能的内容,也希望能够方便大家对Spring Batch远程分区功能有更好的了解。
说明:(1)本博客是针对Spring Batch Remote Partitioning的介绍,读者需要有Spring Batch的基础知识,安装好eclipse、maven、MySQL等;(2)对于想要入门Spring Batch的初学者,推荐阅读刘相的《Spring Batch批处理框架》一书,博客中讲解的例子也是以书中的例子为基础。书中例子的源码地址:https://github.com/jxtaliu/SpringBatchSample 。(3)本文主要关注如何实现远程分区功能,其关键点在于如何通过消息队列实现此功能,具体分区功能请参考《Spring Batch批处理框架》一书。
Spring batch是一个轻量级的、完善的批处理框架,对于大数据量和高性能的批处理任务,Spring Batch提供不少高级功能和特性来支持,比如并行step、多线程step、分区step、远程step等功能。其中,远程分区功能是分区step和远程step两者的结合,对于处理大数据量的批处理任务有着重要的作用。这里的远程功能需要使用消息队列接受和发送信息,主要通过Spring Integration实现。所以为了能够了解Remote Partitioning功能,除了Spring Batch,也需要接触到Spring Integration的相关知识。如下面Spring Batch Remote Partitioning的示意图,Master对任务进行分区,把各个分区后的任务交给多个Slave节点执行:
本节会详细介绍如何配置xml文件实现远程分区功能。这里通过例子详解Spring Batch远程分区功能的实现,以《Spring Batch批处理框架》书中的关于Remote Partitioning的例子为基础。读者们可以从作者的github中下载源代码。
关于书中实现Remote Partitioning的例子不再详细说明,读者有兴趣的话可以尝试运行例子的源代码。但这里需要说明的是,书中的远程分区例子并不完全正确。对于书中例子的配置方法,Master把分区后的任务信息发送到了Slave节点上,Slave收到信息后会执行任务,只是Master不会接收Slave完成任务的信息。所以,当Slave执行任务出错的时候,Master却依旧显示任务completed。
这个例子的远程分区处理如下图所示(来自《Spring Batch批处理框架》书中):
该例子的任务为读取三个文件里的账单记录,并把记录写入数据库中。处理过程为:(1)对任务进行分区,一个文件作为一个分区任务;(2)通过消息队列把文件信息发送给各个Slave节点;(3)Slave收到信息后,处理对应的文件,把记录写入数据库,并返回任务完成的信息;(4)Master收到信息后,结束任务。
所以接下来会给出以不同的方式实现上面远程分区例子,首先是通过官方参考文档的方法实现远程分区,接着会根据我们的需求给出改进的方式实现远程分区。
官方参考文档地址:https://docs.spring.io/spring-batch/trunk/reference/html/springBatchIntegration.html。参考文档中提供了实现远程分区的方法,但由于该文档写的过于简洁,并且把Master与Slave的配置混在一起,对于初学者来说比较难理解。所以,这里会详细介绍文档的方法,并会清晰地区分Master与Slave的配置,方便用于分别配置为Master、Slave两种不同的程序。
在该例子中,我们使用ActiveMQ作为消息中间件(其它支持jms的MQ都没问题)。读者可以在官网下载ActiveMQ并在本地启动,在 http://localhost:8161/admin 可以观察远程分区发送消息的信息统计。
下图给出了该远程分区方法使用的jms消息队列的示意图:
具体的过程如下:
1. 首先,Master对批处理任务job中需要远程分区执行的step进行分区,并将分区后的多条任务消息通过MasterRequestChannel发送到消息中间件的RequestQueue中,并开始监听ReplystQueue;
2. 多个Slave节点相互竞争从RequestQueue获取任务消息,并在收到相应的消息后开始执行任务;
3. Slave完成任务后,把任务完成的信息发送到消息中间件的ReplystQueue中;
4. Master节点收到各个Slave节点完成任务的信息,并把信息放入消息整合器(aggregator)中,统计是否所有分区的任务已经完成,最后判断该step是否成功完成。
为了实现上面的功能,配置文件: job-partition-remote-MasterSlave.xml
将该xml文件保存在《Spring Batch批处理框架》书中的源代码文件夹src\main\resources\ch11\ 中。
(1)修改源代码maven项目pom文件的jar包版本
由于书中源代码使用的版本比较低,为了能够使用新版本的功能,我们需要在pom文件中改为更高的版本。这里的例子使用如下的版本:
.version>4.3.4.RELEASE .version>
.batch.version>3.0.7.RELEASE .batch.version>
.integration.version>4.3.4.RELEASE .integration.version>
其中: spring.version、spring.batch.version、spring.integration.version分别表示所有groupId为org.springframework、org.springframework.batch、org.springframework.integration的jar包版本。
(2)配置xml文件中的命名空间
"http://www.springframework.org/schema/batch"
xmlns:bean="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:task="http://www.springframework.org/schema/task"
xmlns:int="http://www.springframework.org/schema/integration"
xmlns:int-jms="http://www.springframework.org/schema/integration/jms"
xmlns:jms="http://www.springframework.org/schema/jms"
xmlns:amq="http://activemq.apache.org/schema/core"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/task
http://www.springframework.org/schema/task/spring-task.xsd
http://www.springframework.org/schema/batch
http://www.springframework.org/schema/batch/spring-batch.xsd
http://www.springframework.org/schema/integration
http://www.springframework.org/schema/integration/spring-integration-4.3.xsd
http://www.springframework.org/schema/integration/jms
http://www.springframework.org/schema/integration/jms/spring-integration-jms-4.3.xsd
http://www.springframework.org/schema/jms
http://www.springframework.org/schema/jms/spring-jms.xsd
http://activemq.apache.org/schema/core
http://activemq.apache.org/schema/core/activemq-core.xsd">
注意:在命名空间中,spring-integration-jms和spring-integration的xsd文件需要对应于我们在pom文件中配置的版本,否则容易编译错误。
(3)配置连接的ActiveMQ服务器
<amq:connectionFactory id="connectionFactory" brokerURL="tcp://localhost:61616" />
(4)配置xml中的Master部分
id="partitionRemoteJob">
id="partitionRemoteStep">
"partitioner" handler="partitionHandler" />
id="partitioner"
class="org.springframework.batch.core.partition.support.MultiResourcePartitioner">
property name="keyName" value="fileName"/>
property name="resources" value="classpath:/ch11/data/*.csv"/>
id="partitionHandler"
class="org.springframework.batch.integration.partition.MessageChannelPartitionHandler">
property name="messagingOperations">
class="org.springframework.integration.core.MessagingTemplate">
property name="defaultChannel" ref="MasterRequestChannel" />
property name="receiveTimeout" value="30000" />
property>
property name="replyChannel" ref="AggregatedChannel"/>
property name="stepName" value="remoteStep" />
property name="gridSize" value="3" />
在需要远程分区执行的step中,配置partition中使用的partitioner和handler。partitioner是自己定义的分区规则,这里使用书中的规则。
关于partitionHandler的配置:
defaultChannel: Master发送任务信息的Channel;
receiveTimeout: 接受Slave返回信息超时时间,超时直接返回任务失败。
replyChannel: 接受Slave返回信息的Channel,注意这是一个AggregatedChannel。因为Master需要把所有Slave返回的任务信息整合在一起,才能判断任务是否成功完成。
stepName: 需要在Slave上远程执行的step,Slave上需要有对应的step。
gridSize: 分区的个数,即把任务拆分为多少份。
配置Master使用的channel: MasterRequestChannel和MasterReplyChannel
<int:channel id="MasterRequestChannel">
<int:dispatcher task-executor="RequestPublishExecutor"/>
int:channel>
<task:executor id="RequestPublishExecutor" pool-size="5-10" queue-capacity="0"/>
<int:channel id="MasteReplyChannel"/>
MasterRequestChannel使用了task-executor,用多线程加快分区任务的分发速度。
配置outbound-channel-adapter和message-driven-channel-adapter:
<int-jms:outbound-channel-adapter
connection-factory="connectionFactory"
destination-name="RequestQueue"
channel="MasterRequestChannel"/>
<int-jms:message-driven-channel-adapter
connection-factory="connectionFactory"
destination-name="ReplyQueue"
channel="MasterReplyChannel"/>
分别使用outbound-channel-adapter和message-driven-channel-adapter向消息中间件发送和接受消息。其中,destination-name为消息中间件对应的队列名称。Master向RequestQueue发送消息,接受ReplyQueue的消息。
配置消息整合器
<int:channel id="AggregatedChannel">
<int:queue/>
int:channel>
<int:aggregator ref="partitionHandler"
input-channel="MasterReplyChannel"
output-channel="AggregatedChannel"/>
当Master收到多个Slave发送回来的信息,会通过消息整合器(aggregator)放入AggregatedChannel中,所以AggregatedChannel需要使用队列queue。当接收完所有Slave完成任务的信息,Master才会判断任务成功完成。
(5)配置xml的Slave部分
配置Slave使用的channel:SlaveRequestChannel和SlaveReplyChannel
<int:channel id="SlaveRequestChannel"/>
<int:channel id="SlaveReplyChannel"/>
配置outbound-channel-adapter和message-driven-channel-adapter:
<int-jms:message-driven-channel-adapter
connection-factory="connectionFactory"
destination-name="RequestQueue"
channel="SlaveRequestChannel"/>
<int-jms:outbound-channel-adapter
connection-factory="connectionFactory"
destination-name="ReplyQueue"
channel="SlaveReplyChannel"/>
Slave接受RequestQueue的消息,向ReplyQueue发送消息。
配置service-activator
<int:service-activator ref="stepExecutionRequestHandler"
input-channel="SlaveRequestChannel"
output-channel="SlaveReplyChannel"/>
Slave从SlaveRequestChannel获取信息,并使用stepExecutionRequestHandler处理信息,最后向SlaveReplyChannel返回结果。
配置Slave执行的step
<step id="remoteStep">
<tasklet>
<chunk reader="flatFileItemReader" writer="jdbcItemWriter" commit-interval="10"/>
<listeners>
<listener ref="partitionItemReadListener">listener>
listeners>
tasklet>
step>
<bean:bean id="stepExecutionRequestHandler"
class="org.springframework.batch.integration.partition.StepExecutionRequestHandler">
<bean:property name="jobExplorer" ref="jobExplorer"/>
<bean:property name="stepLocator" ref="stepLocator"/>
bean:bean>
注意: 这里step的id必须与Master中partitionHandler的stepName一致才能正常执行。
其余的flatFileItemReader、jdbcItemWriter、jobExplorer、stepLocator等等于原来源代码中的保持一致,这里不再说明。
根据上面配置完job-partition-remote-MasterSlave.xml后,根据书的源代码src\test\java\test\com\juxtapose\example\ch11中的例子自己写一个JobLaunch.java的文件就可以运行了。
因为这里已经清晰给出了Master和Slave的配置。所以读者很容易自己分别写出job-partition-remote-Master.xml和job-partition-remote-Slave.xml的配置文件,即使用不同的进程作为Master和Slave。(先启动一个或多个Slave,再启动Master分发任务)。读者可以自己尝试去实现。
上节提到官方参考文档使用的方法实现远程分区是可以正常执行的,但在一种情况下会出现问题,就是复用ActiveMQ的消息队列Queue。例如,上面的例子,如果同时启动两个以上的Master,读者会发现有Master显示任务失败,但实际上Slave已经完成所有的任务了。 为什么会有上面的情况呢?这是因为消息队列Queue中的信息只能被消费一次。当有多个Master同时监听ActiveMQ的ReplyQueue,Slave发送过来的信息只能被其中一个Master接收,所以Master就没有办法收到自己想要接受的信息了。
如何解决这个问题呢?第一种方法是使用多对不同的Queue,但是这并非好的方法。因为当我们需要执行不同的远程分区任务比较多,一般批处理任务都是定时每天或者每月执行的,每个任务使用不同的一对Queue,这是对资源的浪费。所以项目组希望能够找出实现消息队列复用的方式。
那么我们需要寻求另一种解决方法。远程分区的方法本质上是Spring Batch和Spring Integration的两者结合,当时为了解决这个问题,于是就开始学习Spring Integration方面关于jms的知识,也在那里找到的答案。官方参考文档地址:https://docs.spring.io/spring-integration/docs/4.3.12.RELEASE/reference/html/jms.html
为了实现消息队列复用,本质上是利用jms中的消息选择器(message selector)。下面给出了该远程分区方法使用的jms消息队列的示意图:
如上图所示,在Master和Slave上都取消了使用outbound-channel-adapter和message-driven-channel-adapter,改为了分别使用outbound-gateway和inbound-gateway。
<int-jms:outbound-gateway
connection-factory="connectionFactory"
correlation-key="JMSCorrelationID"
request-channel="MasterRequestChannel"
request-destination-name="RequestQueue"
receive-timeout="30000"
reply-channel="MasterReplyChannel"
reply-destination-name="ReplyQueue"
async="true">
<int-jms:reply-listener />
</int-jms:outbound-gateway>
correlation-key: 使Master发送出去的jms消息带有correlationId,并且在接受S回复信息时,会让消息中间件通过消息选择器进行筛选,只有带有与原来发送消息一致的correlationId的消息才会被接受。该值设置为JMSCorrelationID即可正常使用。
async: (注意该属性只有4.3以上的版本支持)是否使用异步的方式发送消息。如果设置为false,发送请求的线程在发送完消息后会挂起知道收到回复。当设置为true,发送请求的线程在发送完消息后会被释放,把监听回复的任务交给reply-listener处理,可以释放线程资源。
方式二:使用idle-reply-listener
<int-jms:outbound-gateway
connection-factory="connectionFactory"
correlation-key="JMSCorrelationID"
request-channel="MasterRequestChannel"
request-destination-name="RequestQueue"
receive-timeout="30000"
reply-channel="MasterReplyChannel"
reply-destination-name="ReplyQueue"
idle-reply-listener-timeout="5000">
</int-jms:outbound-gateway>
注意:idle-reply-listener是从4.2版本开始支持。
首先reply-listener的生命周期是与gateway一致的,所以使用reply-listener会到时Master一致在监听replyQueue。在已经收到回复的情况下,reply-listener此时就变成idle的状态,除了占用系统资源,对于broker来说也是一种负担。而idle-reply-listener只有在被需要的时候才会启动,并且在接受完信息后等待一段时间(idle-reply-listener-timeout)会自动释放。
<int-jms:inbound-gateway
connection-factory="connectionFactory"
correlation-key="JMSCorrelationID"
request-channel="SlaveRequestChannel"
request-destination-name="RequestQueue"
reply-channel="SlaveReplyChannel"
default-reply-queue-name="ReplyQueue"/>
在Slave中同时把correlation-key的值设置为JMSCorrelationID,那么Slave在接受到带有correlationId的消息,回复的时候也会把该correlationId复制到回复的消息里,从而使得Master能偶收到自己对应的消息。
job-partition-remote-MasterSlave.xml
<bean:beans xmlns="http://www.springframework.org/schema/batch"
xmlns:bean="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:task="http://www.springframework.org/schema/task"
xmlns:int="http://www.springframework.org/schema/integration"
xmlns:int-jms="http://www.springframework.org/schema/integration/jms"
xmlns:jms="http://www.springframework.org/schema/jms"
xmlns:amq="http://activemq.apache.org/schema/core"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/batch
http://www.springframework.org/schema/batch/spring-batch.xsd
http://www.springframework.org/schema/task
http://www.springframework.org/schema/task/spring-task.xsd
http://www.springframework.org/schema/integration
http://www.springframework.org/schema/integration/spring-integration-4.3.xsd
http://www.springframework.org/schema/integration/jms
http://www.springframework.org/schema/integration/jms/spring-integration-jms-4.3.xsd
http://www.springframework.org/schema/jms
http://www.springframework.org/schema/jms/spring-jms.xsd
http://activemq.apache.org/schema/core
http://activemq.apache.org/schema/core/activemq-core.xsd">
<bean:import resource="classpath:ch11/job-context.xml"/>
<bean:import resource="classpath:ch11/job-context-db.xml"/>
<job id="partitionRemoteJob">
<step id="partitionRemoteStep">
<partition partitioner="partitioner" handler="partitionHandler" />
step>
job>
<bean:bean id="partitioner"
class="org.springframework.batch.core.partition.support.MultiResourcePartitioner">
<bean:property name="keyName" value="fileName"/>
<bean:property name="resources" value="classpath:/ch11/data/*.csv"/>
bean:bean>
<bean:bean id="partitionHandler"
class="org.springframework.batch.integration.partition.MessageChannelPartitionHandler">
<bean:property name="messagingOperations">
<bean:bean class="org.springframework.integration.core.MessagingTemplate">
<bean:property name="defaultChannel" ref="MasterRequestChannel" />
<bean:property name="receiveTimeout" value="30000" />
bean:bean>
bean:property>
<bean:property name="replyChannel" ref="AggregatedChannel"/>
<bean:property name="stepName" value="remoteStep" />
<bean:property name="gridSize" value="3" />
bean:bean>
<int:channel id="MasterRequestChannel">
<int:dispatcher task-executor="RequestPublishExecutor"/>
int:channel>
<task:executor id="RequestPublishExecutor" pool-size="5-10" queue-capacity="0"/>
<int:channel id="MasterReplyChannel"/>
<int-jms:outbound-gateway
connection-factory="connectionFactory"
correlation-key="JMSCorrelationID"
request-channel="MasterRequestChannel"
request-destination-name="RequestQueue"
receive-timeout="30000"
reply-channel="MasterReplyChannel"
reply-destination-name="ReplyQueue"
async="true">
<int-jms:reply-listener />
int-jms:outbound-gateway>
<int:channel id="AggregatedChannel">
<int:queue/>
int:channel>
<int:aggregator ref="partitionHandler"
input-channel="MasterReplyChannel"
output-channel="AggregatedChannel"/>
<int:channel id="SlaveRequestChannel"/>
<int:channel id="SlaveReplyChannel"/>
<int-jms:inbound-gateway
connection-factory="connectionFactory"
correlation-key="JMSCorrelationID"
request-channel="SlaveRequestChannel"
request-destination-name="RequestQueue"
reply-channel="SlaveReplyChannel"
default-reply-queue-name="ReplyQueue"/>
<int:service-activator ref="stepExecutionRequestHandler"
input-channel="SlaveRequestChannel"
output-channel="SlaveReplyChannel"/>
<step id="remoteStep">
<tasklet>
<chunk reader="flatFileItemReader" writer="jdbcItemWriter" commit-interval="10"/>
<listeners>
<listener ref="partitionItemReadListener">listener>
listeners>
tasklet>
step>
<bean:bean id="stepExecutionRequestHandler"
class="org.springframework.batch.integration.partition.StepExecutionRequestHandler">
<bean:property name="jobExplorer" ref="jobExplorer"/>
<bean:property name="stepLocator" ref="stepLocator"/>
bean:bean>
<amq:broker useJmx="false" persistent="false" schedulerSupport="false">
<amq:transportConnectors>
<amq:transportConnector uri="tcp://localhost:61616"/>
amq:transportConnectors>
amq:broker>
<amq:connectionFactory id="connectionFactory" brokerURL="tcp://localhost:61616" trustAllPackages="true"/>
<bean:bean class="org.springframework.beans.factory.config.CustomScopeConfigurer">
<bean:property name="scopes">
<bean:map>
<bean:entry key="thread">
<bean:bean class="org.springframework.context.support.SimpleThreadScope" />
bean:entry>
bean:map>
bean:property>
bean:bean>
<bean:bean id="flatFileItemReader" scope="step"
class="org.springframework.batch.item.file.FlatFileItemReader">
<bean:property name="resource"
value="#{stepExecutionContext[fileName]}"/>
<bean:property name="lineMapper" ref="lineMapper" />
bean:bean>
<bean:bean id="lineMapper"
class="org.springframework.batch.item.file.mapping.DefaultLineMapper" >
<bean:property name="lineTokenizer" ref="delimitedLineTokenizer" />
<bean:property name="fieldSetMapper" ref="creditBillFieldSetMapper"/>
bean:bean>
<bean:bean id="delimitedLineTokenizer"
class="org.springframework.batch.item.file.transform.DelimitedLineTokenizer">
<bean:property name="delimiter" value=","/>
<bean:property name="names" value="id,accountID,name,amount,date,address" />
bean:bean>
<bean:bean id="creditBillFieldSetMapper"
class="com.juxtapose.example.ch11.partition.CreditBillFieldSetMapper">
bean:bean>
<bean:bean id="jdbcItemWriter"
class="org.springframework.batch.item.database.JdbcBatchItemWriter">
<bean:property name="dataSource" ref="dataSource"/>
<bean:property name="sql" value="insert into t_destcredit (ID,ACCOUNTID,NAME,AMOUNT,DATE,ADDRESS) values (:id,:accountID,:name,:amount,:date,:address)"/>
<bean:property name="itemSqlParameterSourceProvider">
<bean:bean class="org.springframework.batch.item.database.BeanPropertyItemSqlParameterSourceProvider"/>
bean:property>
bean:bean>
<bean:bean id="creditBillProcessor" scope="step"
class="com.juxtapose.example.ch11.partition.CreditBillProcessor">
bean:bean>
<bean:bean id="taskExecutor"
class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
<bean:property name="corePoolSize" value="5" />
<bean:property name="maxPoolSize" value="5" />
bean:bean>
<bean:bean id="partitionItemReadListener"
class="com.juxtapose.example.ch11.partition.PartitionStepExecutionListener">
bean:bean>
bean:beans>
注意:在classpath:ch11/job-context-db.xml中,simpleJdbcTemplate可以注释掉,高版本不兼容。