1. 从一次批处理翻车说起:为什么 JdbcPagingItemReader 值得单独讲
Spring Batch 里读数据库,很多人第一反应是JdbcCursorItemReader,因为它写起来最省事:一条 SQL、一个 RowMapper 就完事。我最早做用户数据同步任务时也是这么干的,本地跑 5 条数据一切正常,上线后表里 80 万行,任务跑了十几分钟内存直接顶到 OOM。原因不复杂——游标方式本质上是把整个ResultSet保持打开状态,一条一条往下读,数据库连接和结果集在整段 chunk 处理期间都不能释放。数据量一大,连接池被占满,堆内存也跟着涨。
JdbcPagingItemReader解决的就是这个场景:它不维持长连接游标,而是按pageSize一批一批地发分页 SQL,每批读完就释放,内存占用稳定在单页数据量级别。适合谁?适合做订单对账、用户画像批量刷新、日志归档这类「表大、单条处理慢、不能一次性全捞」的批处理任务。它和游标方式的核心差异在于:游标是「一次查询、逐条消费」,分页是「多次查询、按页消费」,前者省数据库往返但吃内存,后者多几次查询但内存可控。
这篇我会交付一套可直接复制的JdbcPagingItemReaderBean 配置骨架,把分页 SQL、sortKey、parameterValues这几个最容易踩坑的点讲透,再补上 TaoToken 统一 Key/API 通道的接入配置和本地验证动作,目标是一次跑通分页读取并确认数据条数正确。
2. TaoToken 前置准备:统一 Key 与 API 通道
批处理任务里经常要调用模型做数据清洗、字段补全或者结果校验,如果每个任务各自维护一套 Key,配置散落、轮换麻烦。TaoToken 在这里的角色是提供一个统一的 API 通道,把模型调用收敛到一个入口,批处理代码里只需要读一个环境变量。
你需要先拿到一个可用的 Key。登录官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,进入控制台后创建 API Key,具体入口在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。Key 的管理页面在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,建议按任务维度建多个 Key,方便单独吊销。
接入地址统一用 https://taotoken.net/api ,注意这个地址不带任何查询参数。配置上我习惯用环境变量注入,避免硬编码进代码仓库:
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"如果你用的是 Spring Boot,可以在application.yml里这样引用:
taotoken: base-url: ${TAOTOKEN_BASE_URL:https://taotoken.net/api} api-key: ${TAOTOKEN_API_KEY}注意:Key 只放在环境变量或配置中心,不要提交到 Git。批处理任务通常跑在服务器上,环境变量是最省事的注入方式。
模型对话的调试入口在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。如果你后面要做长期编码或 Agent 类任务,可以看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。
3. 可复制配置:JdbcPagingItemReader Bean 骨架与分页 SQL
先建表和数据,方便你本地直接跑:
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `name` varchar(255) DEFAULT NULL COMMENT '用户名', `age` int DEFAULT NULL COMMENT '年龄', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; INSERT INTO `user` VALUES (1, 'dafei', 18); INSERT INTO `user` VALUES (2, 'xiaofei', 17); INSERT INTO `user` VALUES (3, 'zhongfei', 16); INSERT INTO `user` VALUES (4, 'laofei', 15); INSERT INTO `user` VALUES (5, 'feifei', 14);实体和 RowMapper 是基础件,先备好:
@Getter @Setter @ToString public class User { private Long id; private String name; private int age; } public class UserRowMapper implements RowMapper<User> { @Override public User mapRow(ResultSet rs, int rowNum) throws SQLException { User user = new User(); user.setId(rs.getLong("id")); user.setName(rs.getString("name")); user.setAge(rs.getInt("age")); return user; } }核心是PagingQueryProvider和JdbcPagingItemReader两个 Bean。分页 SQL 不是让你手写limit,而是拆成selectClause、fromClause、whereClause、sortKey四段交给框架拼:
@Configuration @EnableBatchProcessing public class PageDBReaderJob { @Autowired private JobBuilderFactory jobBuilderFactory; @Autowired private StepBuilderFactory stepBuilderFactory; @Autowired private DataSource dataSource; @Bean public UserRowMapper userRowMapper() { return new UserRowMapper(); } @Bean public PagingQueryProvider pagingQueryProvider() throws Exception { SqlPagingQueryProviderFactoryBean factoryBean = new SqlPagingQueryProviderFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setSelectClause("select id, name, age"); factoryBean.setFromClause("from user"); factoryBean.setWhereClause("where age > :age"); factoryBean.setSortKey("id"); return factoryBean.getObject(); } @Bean public JdbcPagingItemReader<User> userItemReader() throws Exception { Map<String, Object> param = new HashMap<>(); param.put("age", 16); return new JdbcPagingItemReaderBuilder<User>() .name("userPagingItemReader") .dataSource(dataSource) .queryProvider(pagingQueryProvider()) .parameterValues(param) .pageSize(2) .rowMapper(userRowMapper()) .build(); } @Bean public ItemWriter<User> itemWriter() { return items -> items.forEach(System.err::println); } @Bean public Step step() throws Exception { return stepBuilderFactory.get("step1") .<User, User>chunk(2) .reader(userItemReader()) .writer(itemWriter()) .build(); } @Bean public Job job() throws Exception { return jobBuilderFactory.get("page-db-reader-job") .start(step()) .build(); } public static void main(String[] args) { SpringApplication.run(PageDBReaderJob.class, args); } }几个参数必须说清楚。sortKey是分页的锚点,框架靠它生成where id > ? order by id这类翻页条件,所以它必须是唯一且稳定的列,用主键最稳。pageSize是每页条数,不是 chunk 大小,两者可以不同:pageSize控制单次查询量,chunk控制事务提交粒度。parameterValues里的 key 要和whereClause里的:age占位符名字对上,对不上会直接报参数缺失。
selectClause建议显式列出字段而不是select *,一是减少网络传输,二是避免表结构变更导致 RowMapper 映射错位。SqlPagingQueryProviderFactoryBean会根据 DataSource 自动识别数据库类型,MySQL 生成limit,Oracle 生成rownum,你不用手写方言。
4. 验证请求:跑通分页读取并确认条数
配置写完,直接运行main方法。上面数据里age > 16的有 3 条(18、17、16 对应的三条),pageSize=2,所以应该分两页读:第一页 2 条,第二页 1 条。控制台输出类似:
User(id=1, name=dafei, age=18) User(id=2, name=xiaofei, age=17) User(id=3, name=zhongfei, age=16)如果你在 writer 里加了计数,最终write被调用两次,累计 3 条,说明分页逻辑正确。想更直观地看分页 SQL,把日志级别调到 DEBUG:
logging: level: org.springframework.jdbc.core.JdbcTemplate: DEBUG你会看到框架实际执行的两条 SQL,第一条带limit 2,第二条带id > 2和limit 2,这就是sortKey在起作用。
批处理任务里如果还要调模型做数据校验,可以在 writer 里加一段调用,用前面配好的 TaoToken 通道:
@Bean public ItemWriter<User> itemWriter(RestTemplate restTemplate, @Value("${taotoken.api-key}") String apiKey) { return items -> { for (User user : items) { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(apiKey); headers.setContentType(MediaType.APPLICATION_JSON); // 构造请求体,调用 https://taotoken.net/api 下的对话接口 // 这里只演示通道接入,具体模型名按文档填 } }; }验证模型通道是否通,可以直接用模型对话页面发一条测试消息:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。批处理里调用失败不要影响主流程,建议加 try-catch 并记录日志,避免一条数据校验失败导致整个 chunk 回滚。
5. 本篇常见错排查
报错一:sortKey未设置或不是唯一列。现象是任务启动就抛IllegalArgumentException,提示 sort key 相关。原因是分页翻页依赖排序锚点,如果sortKey有重复值,翻页会漏数据或重复读。解决:用主键或唯一索引列做sortKey。
报错二:parameterValues的 key 和whereClause占位符不匹配。现象是运行时报参数绑定异常。检查whereClause里写的是:age,param.put的 key 也必须是age,大小写敏感。
报错三:selectClause用了select *但 RowMapper 按列名取值。表结构一变就映射错位。解决:显式列出字段,和 RowMapper 里的列名一一对应。
报错四:pageSize设得比 chunk 大很多。现象是内存又涨上去了。pageSize决定单次查询返回量,设太大等于把分页优势抵消了。一般pageSize和chunk保持同量级,或者pageSize略大。
报错五:DataSource 没注入或指向了错误的库。现象是查不到数据但不报错。检查SqlPagingQueryProviderFactoryBean和JdbcPagingItemReaderBuilder用的是同一个dataSource。
报错六:TaoToken 调用返回 401。检查环境变量TAOTOKEN_API_KEY是否生效,Key 是否被吊销。Key 管理入口:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。
6. 接入与排障的分流入口
如果你卡在 Key 申请、通道配置或者批处理里调用模型报错,优先看 API Keys 页面和接入文档:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 、https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。想先确认模型本身能不能正常返回,用模型对话页面发一条消息最快:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。如果你要做的是长期跑的编码或 Agent 类批处理任务,Coding Plan 的配额和通道更适合:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。
最后补一个我踩过的坑:JdbcPagingItemReader默认不是线程安全的,如果你开了多线程 Step,每个线程需要独立的 reader 实例,别共用一个 Bean。分页读取本身是顺序翻页的,多线程加速要靠分区(Partitioner)把数据按范围切开,每个分区各自分页,这个后面单独讲。