Having worked with MyBatis Mapper classes is one thing, but diving into Spring Batch’s Chunk processing often means wading through endless JPA- or CSV-focused examples. In practice, you can reuse your existing Mapper XML or annotations directly in an ItemReader or ItemWriter. In this article, we’ll build out a Job/Step/Chunk configuration using cursor-based reads with MyBatisCursorItemReader and batch updates with MyBatisBatchItemWriter.
For an explanation of what MyBatis is and how to implement a basic Mapper, see the article explaining what MyBatis is and the MyBatis Mapper implementation guide; here we’ll focus specifically on the integration with Spring Batch. CSV ingestion using FlatFileItemReader is covered in the CSV import article, so check that out as well.
What MyBatisCursorItemReader and MyBatisBatchItemWriter Solve
MyBatisCursorItemReader is an ItemReader that reads results one at a time using a cursor. This lets you process large volumes of data without loading everything into memory at once.
MyBatisBatchItemWriter, on the other hand, is an ItemWriter that writes all the items in a chunk together in a single batch operation. The major advantage of both is that they can reference your existing Mapper interfaces and XML SQL directly — there’s no need to write new SQL specifically for the ItemReader/ItemWriter.
Preparing Dependencies
You only need two dependencies: mybatis-spring-boot-starter and spring-boot-starter-batch. Since MyBatisCursorItemReader and MyBatisBatchItemWriter are included in mybatis-spring (the org.mybatis.spring.batch package), adding the starter makes them available automatically.
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-batch</artifactId>
</dependency>
There’s no need to change your usual MyBatis configuration, such as mapper-locations in application.yml.
Configuring MyBatisCursorItemReader
In the ItemReader bean definition, specify the fully namespaced Mapper ID as queryId. There’s no need to add a new Mapper method — you can reuse an existing select statement as-is.
@Bean
public MyBatisCursorItemReader<User> userItemReader(SqlSessionFactory sqlSessionFactory) {
return new MyBatisCursorItemReaderBuilder<User>()
.sqlSessionFactory(sqlSessionFactory)
.queryId("com.example.mapper.UserMapper.selectAllForBatch")
.parameterValues(Map.of("status", "ACTIVE"))
.build();
}
The queryId references the same select statement you’d normally write in your Mapper XML.
<select id="selectAllForBatch" resultMap="userResultMap">
SELECT id, name, email, status
FROM users
WHERE status = #{status}
ORDER BY id
</select>
You can use the existing resultMap as-is, with no changes needed on the entity side. The map passed to parameterValues is expanded into #{status}.
Configuring MyBatisBatchItemWriter and ExecutorType.BATCH
The ItemWriter configuration follows almost the same pattern — specify the Mapper ID for the update statement as statementId.
@Bean
public MyBatisBatchItemWriter<User> userItemWriter(SqlSessionFactory sqlSessionFactory) {
return new MyBatisBatchItemWriterBuilder<User>()
.sqlSessionFactory(sqlSessionFactory)
.statementId("com.example.mapper.UserMapper.updateStatus")
.build();
}
If you don’t specify an itemToParameterConverter, each object in the chunk is passed directly as the parameter to the Mapper statement.
Internally, a SqlSession is opened with ExecutorType.BATCH, and the statement calls for the entire chunk are executed together in a single flush — this is what differs from a normal Mapper call (SIMPLE). In BATCH mode, you can’t reliably get the return value (affected row count) of individual updates, so it’s safer to check counts using the read/write counts instead.
Assembling the Job/Step/Chunk Configuration
Simply connecting the Reader and Writer above via a chunk completes the Job/Step configuration.
@Bean
public Step userUpdateStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager,
MyBatisCursorItemReader<User> userItemReader,
MyBatisBatchItemWriter<User> userItemWriter) {
return new StepBuilder("userUpdateStep", jobRepository)
.<User, User>chunk(100, transactionManager)
.reader(userItemReader)
.writer(userItemWriter)
.listener(loggingStepExecutionListener())
.build();
}
@Bean
public Job userUpdateJob(JobRepository jobRepository, Step userUpdateStep) {
return new JobBuilder("userUpdateJob", jobRepository)
.start(userUpdateStep)
.build();
}
The count specified in chunk(100, ...) serves as both the commit unit and the number of items MyBatisBatchItemWriter flushes at once. Increasing this count reduces write overhead, but also widens the scope of rollback caused by a single error, so it’s best to start with somewhere around 100–500 items.
Things to Watch Out for at the Transaction Boundary
The SqlSession opened with ExecutorType.BATCH is managed within the same scope as the chunk-level transaction. If an exception occurs in the Writer, all updates for that chunk are rolled back. It’s reassuring to verify, using the Listener described below, that the read count and write count don’t diverge by the end.
Notes on Cursors, Connections, and fetchSize
Since MyBatisCursorItemReader keeps the cursor open and reads one row at a time, it holds onto the DB connection for the entire duration of the Step’s execution. Increasing fetchSize fetches more rows at once, creating a trade-off between memory usage and read speed. If you plan to hold the cursor open for a long time, also check your DB-side connection timeout settings.
Verifying Processing Counts with StepExecutionListener
Simply logging the read and write counts is enough to confirm that your configuration is working correctly.
@Bean
public StepExecutionListener loggingStepExecutionListener() {
return new StepExecutionListener() {
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
log.info("read={}, write={}",
stepExecution.getReadCount(), stepExecution.getWriteCount());
return stepExecution.getExitStatus();
}
};
}
If the read count and write count match, that’s evidence that everything is flowing as expected from cursor reading through to batch update.
Common Pitfalls
- Specify
queryIdandstatementIdwith the Mapper’s namespace included. Writing just the class name often results in a NotFound error. - If you have multiple data sources configured, be careful not to mix up which
SqlSessionFactoryis passed to the ItemReader versus the ItemWriter. - Since BATCH mode doesn’t let you rely on individual update return values, check counts using the StepExecution statistics instead.
Summary
Using MyBatisCursorItemReader and MyBatisBatchItemWriter, you can build Chunk processing that reuses your existing Mapper implementations as-is. Before deploying to production, make sure to check two things: the transaction boundary created by ExecutorType.BATCH, and the fetchSize/connection usage while the cursor is held open. Mapper implementation patterns are covered in detail in the MyBatis Mapper implementation guide, and when to use MyBatis versus JPA is covered in the MyBatis vs JPA comparison guide.