JdbcItemReader 详解
本章定位:深入掌握从数据库读取数据的两种模式——JdbcCursorItemReader 和 JdbcPagingItemReader,理解它们的原理、配置、线程安全性和选型标准。
定义与作用
Spring Batch 提供了两种从 JDBC 数据源读取数据的内置 Reader:
| Reader | 读取模式 | 核心机制 |
|---|---|---|
JdbcCursorItemReader | 游标(Cursor) | 打开 PreparedStatement,逐行 ResultSet.next() |
JdbcPagingItemReader | 分页(Paging) | 每页执行 SELECT ... LIMIT ? OFFSET ? |
飞翔科技架构师白歌的区分:
"游标模式像用吸管喝饮料——一根吸管插到底,一口一口吸。分页模式像用小杯子舀——每次舀一杯喝完,放下杯子,再拿新杯子舀下一杯。"
核心原理
两种模式对比
性能特征对比
| 维度 | Cursor 模式 | Paging 模式 |
|---|---|---|
| 数据库连接 | 全程占用 1 个连接 | 每页获取/释放 |
| 数据库端开销 | 低(游标保持打开) | 略高(每页 SQL 解析) |
| 内存占用 | 低(逐行) | 低到中(每页缓存) |
| 线程安全 | 否 | 是(分页参数隔离) |
| 适合数据量 | < 10 万 | 任意大小 |
| 分区支持 | 不支持 | 支持 |
| 必须排序 | 否 | 是(sortKeys 必须设置) |
完整示例
场景一:游标模式——中小数据量的学生查询
@Bean
public JdbcCursorItemReader<Student> studentCursorReader(DataSource dataSource) {
return new JdbcCursorItemReaderBuilder<Student>()
.name("studentCursorReader")
.dataSource(dataSource)
.sql("SELECT id, name, email, age FROM students WHERE status = 'ACTIVE'")
.rowMapper(new BeanPropertyRowMapper<>(Student.class))
.fetchSize(100) // JDBC fetchSize 提示
.driverSupportsAbsolute(false)
.saveState(true) // 支持断点续传
.maxItemCount(10000) // 最多读取 1 万条
.build();
}
场景二:分页模式——千万级订单数据
@Bean
@StepScope
public JdbcPagingItemReader<Order> orderPagingReader(
DataSource dataSource,
@Value("#{jobParameters['orderDate']}") String orderDate) {
Map<String, Object> parameters = new HashMap<>();
parameters.put("orderDate", orderDate);
parameters.put("status", "PENDING");
return new JdbcPagingItemReaderBuilder<Order>()
.name("orderPagingReader")
.dataSource(dataSource)
.selectClause("SELECT order_id, customer_name, amount, created_at")
.fromClause("FROM orders")
.whereClause("WHERE order_date = :orderDate AND status = :status")
.parameterValues(parameters)
.sortKeys(Map.of("order_id", Order.ASCENDING)) // ⚠️ 必须排序
.rowMapper((rs, rowNum) -> {
Order order = new Order();
order.setOrderId(rs.getLong("order_id"));
order.setCustomerName(rs.getString("customer_name"));
order.setAmount(rs.getBigDecimal("amount"));
order.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
return order;
})
.pageSize(500) // 每页 500 条
.saveState(true)
.build();
}
操作前后对比:
操作前:手动 JDBC
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders");
ResultSet rs = ps.executeQuery();
// 500 万条数据 → 内存暴增?逐条处理 → 连接长时间占用?
while (rs.next()) { ... }
操作后:JdbcPagingItemReader
Page 1: SELECT ... LIMIT 500 OFFSET 0 → 连接释放
Page 2: SELECT ... LIMIT 500 OFFSET 500 → 连接释放
...
Page 10000: SELECT ... LIMIT 500 OFFSET 4999500 → 连接释放
崩溃后自动从上次页码恢复
易错场景与避坑
反例一:Paging 模式忘记设置 sortKeys
// ❌ 不设置 sortKeys → IllegalArgumentException
return new JdbcPagingItemReaderBuilder<Order>()
.name("reader")
.dataSource(dataSource)
.selectClause("SELECT * FROM orders")
.fromClause("FROM orders")
.pageSize(100)
// 缺少 .sortKeys() → 启动报错!
.build();
// IllegalArgumentException: sortKey is required for paging
原因:分页依赖 ORDER BY 保证每次分页的数据范围一致。没有排序 → 同一条数据可能出现在两个分页中(重复处理)或漏掉。
反例二:Cursor 模式用于分区 Step
// ❌ Partition 场景下 JdbcCursorItemReader 非线程安全
@Bean
@StepScope
public JdbcCursorItemReader<Order> reader(DataSource ds) {
return new JdbcCursorItemReaderBuilder<Order>()
.name("reader")
.dataSource(ds)
.sql("SELECT * FROM orders")
// ... 分区时 4 个线程共享同一个 ResultSet → 数据错乱
.build();
}
正确做法:分区场景使用 JdbcPagingItemReader,或使用 JdbcCursorItemReader + @StepScope 为每个分区提供不同的 SQL 条件。
面试高频考点
Q1:为什么 Paging 模式必须设置 sortKeys?
分页通过
ORDER BY保证每次查询结果的确定性和连续性。如果没有排序,数据库可能返回不同顺序的结果,导致同一行出现在两个分页中(重复处理)或被跳过(遗漏)。sortKeys确保 RESTART 后分页位置的计算基于确定的数据顺序。
Q2:多数据源场景如何为 Reader 指定特定的事务管理器?
通过
StepBuilder.transactionManager(tx)为整个 Step 指定事务管理器,或通过JdbcCursorItemReaderBuilder/JdbcPagingItemReaderBuilder的dataSource()指定不同数据源。注意事务管理器需要和 Reader 的数据源一致,否则事务不会覆盖 Reader 的读操作。