1. 从一次分片读写翻车说起:Spring Batch 数据库批数据读写到底难在哪
Spring Batch 做数据库批数据读写,听起来就是「读一张表、写另一张表」,但真到分片加事务的场景,坑比扁平文件多得多。我见过太多项目在单机小数据量下跑得好好的,一上分片就出现重复写入、回滚不干净、重试后数据翻倍的问题。核心检索词先摆出来:Spring Batch 数据库批数据读写,指的是用JdbcPagingItemReader分页读库、用JdbcBatchItemWriter批量写库,并通过 chunk 事务边界控制提交与回滚的一整套机制。它能帮你把百万级数据的迁移、对账、清洗任务拆成可控的小批次,适合谁?适合已经在用 Spring Boot 做业务、需要定时跑批或做数据同步的后端同学。
为什么数据库读写比文件读写更微妙?文件写入时,框架要维持文件句柄打开、异常时擦除已写内容,所以 Spring Batch 提供了完整的FlatFileItemWriter。但数据库不一样:连接池本身保证「连接-写入-释放」的高效,数据库驱动自带事务能力,异常时自动回滚,不存在「擦除半截文件」的问题。所以 Spring Batch 干脆不提供数据库写入实现类,把ItemWriter交给开发者自己写。这既是自由,也是责任——你得自己保证批量写入和 chunk 事务对齐。
分页读取这边,JdbcPagingItemReader每次从库里捞一整页,但对外仍然一行一行返回。框架根据运行情况决定何时执行下一页查询。这里有个关键点:分页查询依赖sortKey排序键,如果排序键不唯一,翻页时可能漏读或重读。我踩过的坑就是拿一个可重复的字段当 sortKey,结果第 2 页和第 1 页出现同一条记录。所以 sortKey 一定要选主键或唯一索引列。
再叠加一个现实问题:很多团队的批处理任务需要调用外部模型服务做数据补全或校验,比如给每条记录打标签、做语义去重。这时候鉴权散落在各个 Job 里,Key 管理混乱。本篇会把 TaoToken 统一 Key 接入作为鉴权通道,让批处理任务在读取和写入之外,多一步可控的外部调用,同时用最小数据集验证分片读写和回滚行为。下面从环境准备开始,一步步跑通。
2. TaoToken 统一 Key 前置准备:批处理任务鉴权通道怎么接
在动手写 Job 之前,先把鉴权通道理清楚。批处理任务里如果每条记录都要调一次外部接口,Key 的注入方式直接决定你后面排障的难度。TaoToken 在这里扮演的角色是统一 Key 和 API 通道:你不需要在代码里硬编码多个服务的凭证,而是通过一个 Base URL 加一个 Key,走 OpenAI 兼容协议完成调用。官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数。
先说清楚它不是什么:它不是数据库代理,也不替代你的 DataSource。它只负责外部模型调用的鉴权与转发。你的 Spring Batch 任务依然直连自己的 MySQL/PostgreSQL,读写逻辑不变,只是在 Processor 阶段需要调用模型时,走 TaoToken 的通道。
第一步,拿到 Key。进入控制台创建 API Key,地址是 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。创建后复制保存,Key 只显示一次。这里建议给批处理任务单独建一个 Key,方便按任务维度排查调用量,也方便出问题时单独吊销,不影响其他业务。
第二步,确认你要用的模型 ID。批处理里做文本处理,常用的是对话类模型。你可以先在模型对话页面试一下,地址是 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,确认模型可用、返回格式符合预期,再写进配置。不要凭记忆填模型名,模型 ID 写错会直接报 404 或 model not found。
第三步,把配置写进 Spring Boot 的application.yml。这里给出可复制的片段,路径和字段名按你项目实际情况调整:
taotoken: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} model-id: your-model-id connect-timeout: 5000 read-timeout: 30000注意api-key用环境变量注入,不要明文写进仓库。批处理任务通常在服务器上跑,环境变量在启动脚本里 export 即可。read-timeout给 30 秒,因为批处理里模型调用可能比在线请求慢,超时太短会导致 chunk 频繁失败重试。
第四步,如果你用的是 Claude Code 这类编码工具来辅助写批处理代码,可以走 coding-plan 通道,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。它适合长期编码场景,和本篇的运行时鉴权是两回事,别混用。运行时批处理任务用的是 API Key,编码辅助用的是另一套额度。
前置准备的核心就一句话:Base URL、Key、Model ID 三件套齐全,且 Key 通过环境变量注入。后面所有配置都围绕这三件套展开。如果你在接入文档里看到字段名和这里不一致,以文档为准,地址是 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
3. 可复制配置:JdbcPagingItemReader 分片读取与 JdbcBatchItemWriter 批量写入
这一节是全文的技术核心,给出能直接抄的配置。先建两张表,源表和目标表结构一致,方便核对行数。DDL 如下:
CREATE TABLE `src_weather` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `siteid` varchar(64) NOT NULL, `month` varchar(64) NOT NULL, `type` varchar(64) NOT NULL, `value` int(11) NOT NULL, `ext` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ); CREATE TABLE `dst_weather` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `siteid` varchar(64) NOT NULL, `month` varchar(64) NOT NULL, `type` varchar(64) NOT NULL, `value` int(11) NOT NULL, `ext` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) );插入 1000 条测试数据到src_weather,用存储过程或批量 insert 都行。数据量不用大,1000 条足够验证分片和回滚。
接下来是JdbcPagingItemReader的配置。分页读取的关键是PagingQueryProvider,不同数据库分页语法不同,用SqlPagingQueryProviderFactoryBean自动识别:
@Bean public SqlPagingQueryProviderFactoryBean queryProvider(DataSource dataSource) { SqlPagingQueryProviderFactoryBean provider = new SqlPagingQueryProviderFactoryBean(); provider.setDataSource(dataSource); provider.setSelectClause("select id, siteid, month, type, value, ext"); provider.setFromClause("from src_weather"); provider.setWhereClause("where id > :startId"); provider.setSortKey("id"); return provider; } @Bean public ItemReader<WeatherEntity> jdbcPagingItemReader( DataSource dataSource, PagingQueryProvider queryProvider, RowMapper<WeatherEntity> rowMapper) { Map<String, Object> parameterValues = new HashMap<>(); parameterValues.put("startId", 0); return new JdbcPagingItemReaderBuilder<WeatherEntity>() .name("weatherPagingReader") .dataSource(dataSource) .queryProvider(queryProvider) .parameterValues(parameterValues) .rowMapper(rowMapper) .pageSize(200) .saveState(true) .build(); }pageSize设 200,配合 chunk 的 50,意味着每读 4 个 chunk 才翻一页。saveState(true)让读取位置写入ExecutionContext,任务重启时能从上次位置继续,而不是从头再来。sortKey必须是唯一列,这里用主键id。
然后是JdbcBatchItemWriter。Spring Batch 不提供数据库写入实现,但JdbcBatchItemWriter是官方提供的批量写入类,底层用JdbcTemplate.batchUpdate:
@Bean public ItemWriter<WeatherEntity> jdbcBatchItemWriter(DataSource dataSource) { return new JdbcBatchItemWriterBuilder<WeatherEntity>() .dataSource(dataSource) .sql("INSERT INTO dst_weather(siteid, month, type, value, ext) VALUES (:siteId, :month, :type, :value, :ext)") .beanMapped() .build(); }注意这里用的是命名参数:siteId而不是问号占位符,配合beanMapped()自动从实体属性取值。如果你用问号占位符,需要改成itemPreparedStatementSetter。两种方式都行,命名参数可读性更好。
Step 和 Job 的配置,重点是 chunk 大小和事务边界:
@Bean public Step dbToDbStep(StepBuilderFactory builder, ItemReader<WeatherEntity> reader, ItemWriter<WeatherEntity> writer) { return builder.get("dbToDbStep") .<WeatherEntity, WeatherEntity>chunk(50) .reader(reader) .writer(writer) .faultTolerant() .skipLimit(10) .skip(FlatFileParseException.class) .retryLimit(3) .retry(TransientDataAccessException.class) .build(); } @Bean public Job dbToDbJob(JobBuilderFactory builder, Step dbToDbStep) { return builder.get("dbToDbJob") .start(dbToDbStep) .build(); }chunk(50)是事务边界:每 50 条提交一次。retryLimit(3)配合retry(TransientDataAccessException.class),让瞬时数据库异常自动重试,重试时整个 chunk 回滚重来。这里有个细节:重试是针对 chunk 的,不是针对单条记录,所以 Writer 必须保证幂等,否则重试会导致重复写入。JdbcBatchItemWriter的 insert 本身不幂等,如果你的业务要求幂等,需要在 SQL 里加ON DUPLICATE KEY UPDATE或先删后插。
如果你在 Processor 阶段要调 TaoToken 做数据补全,配置如下:
@Bean public ItemProcessor<WeatherEntity, WeatherEntity> enrichProcessor( @Value("${taotoken.base-url}") String baseUrl, @Value("${taotoken.api-key}") String apiKey, @Value("${taotoken.model-id}") String modelId) { return item -> { // 调用 TaoToken 通道做数据补全 String enriched = callModel(baseUrl, apiKey, modelId, item.getExt()); item.setExt(enriched); return item; }; }callModel用 RestTemplate 或 WebClient 发 OpenAI 兼容请求,Header 里带Authorization: Bearer ${apiKey}。注意 Processor 在 chunk 事务内执行,如果模型调用超时抛异常,整个 chunk 回滚。所以模型调用的超时和重试策略要和 chunk 的 retry 配置对齐,避免一个慢请求拖垮整个批次。
4. 验证请求与成功结果:最小数据集跑通分片读写并核对行数
配置写完,跑一次最小数据集验证。启动 Job 的方式有两种:命令行--spring.batch.job.names=dbToDbJob,或者写个CommandLineRunner手动触发。这里用命令行,方便观察日志。
启动后,日志里会看到几个关键节点。第一,JdbcPagingItemReader初始化,打印pageSize=200。第二,chunk 开始执行,每 50 条一次Transaction committed。第三,Step 结束时打印StepExecution的读写计数。
跑完后核对行数,执行:
SELECT COUNT(*) FROM src_weather; SELECT COUNT(*) FROM dst_weather;如果源表 1000 条,目标表也应该是 1000 条。如果目标表少于 1000,说明有 chunk 失败被跳过或回滚了。如果多于 1000,说明重试导致重复写入,需要检查 Writer 幂等性。
再验证分片读取的翻页行为。把日志级别调到 DEBUG,搜索JdbcPagingItemReader的 SQL 执行记录,应该看到类似:
Executing SQL: SELECT id, siteid, month, type, value, ext FROM src_weather WHERE id > ? ORDER BY id ASC LIMIT 200每次翻页,id > ?的参数会变成上一页最后一条的 id。如果参数没变,说明saveState或sortKey配置有问题,会导致死循环或漏读。
验证回滚行为,故意制造异常。在 Writer 里加一段逻辑:当id == 500时抛RuntimeException。重新跑 Job,观察日志。你会看到第 500 条所在的 chunk(假设是第 10 个 chunk,覆盖 451-500)整体回滚,dst_weather里不会有 451-500 这 50 条。同时,由于skipLimit(10)和skip(FlatFileParseException.class)只跳过特定异常,RuntimeException不在跳过列表里,所以 Job 会失败退出。这正好验证了事务边界:chunk 内要么全成功,要么全回滚。
如果你想验证重试,把异常改成TransientDataAccessException的子类,比如QueryTimeoutException。配置了retryLimit(3)后,框架会重试这个 chunk 最多 3 次。如果 3 次都失败,Job 失败;如果第 2 次成功,Job 继续。重试时整个 chunk 回滚重来,所以 Writer 的幂等性在这里至关重要。
最后验证 TaoToken 鉴权通道。在 Processor 里调一次模型,观察返回。如果 Key 正确、模型 ID 正确,会拿到正常响应。如果 Key 错误,会收到 401。如果 Base URL 写错,会收到连接超时或 404。这一步的验证结果直接决定后面排障的方向。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth 报错对照
批处理任务跑起来后,报错集中在几个地方。这一节按真实报错对照排查。
401 Unauthorized。这是 TaoToken 鉴权失败。检查三件事:Key 是否通过环境变量正确注入,Header 里是否带了Authorization: Bearer <key>,Key 是否被吊销。常见错误是把 Key 写进了application.yml但没加${},或者环境变量名拼错。排查方法:在启动日志里打印 Key 的前 6 位和后 4 位,确认注入成功。不要打印完整 Key。
local proxy failed / connection refused。这是网络层问题。检查 Base URL 是否写成了https://taotoken.net/api,注意结尾没有斜杠。如果你在容器里跑,检查容器是否能访问外网。如果你配置了 HTTP 代理,检查代理是否放行了taotoken.net。注意:这里说的代理是网络代理配置,不是让你去搞什么特殊通道,企业内网出口代理是正常运维配置。
reading choices 报错 / choices 字段为空。这是模型返回格式解析失败。常见原因是模型 ID 写错,返回了非预期结构;或者请求体里messages格式不对。检查请求体是否符合 OpenAI 兼容格式:
{ "model": "your-model-id", "messages": [{"role": "user", "content": "hello"}] }如果返回体里没有choices字段,先打印完整响应体看结构。不要盲目按choices[0].message.content取值,先确认字段存在。
OAuth 相关报错。如果你用的是 Claude Code 或 Codex 这类工具,可能会遇到 OAuth 认证问题。注意区分:运行时批处理任务用的是 API Key,不是 OAuth。OAuth 是编码工具登录用的。如果你在批处理代码里误用了 OAuth token,会报 401 或 invalid token。排查方法:确认批处理任务用的是api-key配置项,不是oauth-token。
Codex auth.json 配置问题。如果你用 Codex 辅助写代码,auth.json里需要填 Base URL、Key、Model ID 三件套。路径通常在~/.codex/auth.json。配置片段:
{ "base_url": "https://taotoken.net/api", "api_key": "your-key", "model": "your-model-id" }注意base_url不要带/v1,除非文档明确要求。三件套缺一不可,缺 Key 报 401,缺 Model ID 报 404。
CC Switch / Cline MCP 配置问题。如果你用 CC Switch 或 Cline 的 MCP 功能,同样需要 Base URL、Key、Model ID 三件套。MCP 配置里通常写在mcp.json或工具设置里。常见错误是把 MCP 直连到生产数据库,这是禁止的。MCP 只用于编码辅助,不要让它碰生产库。
分页读取漏读或重读。检查sortKey是否唯一。如果 sortKey 有重复值,翻页时id > ?的条件会跳过或重复记录。解决方法:sortKey 用主键,或者用「主键 + 唯一列」组合排序。
chunk 重试导致重复写入。检查 Writer 是否幂等。JdbcBatchItemWriter的 insert 不幂等,重试会重复插入。解决方法:SQL 改成INSERT ... ON DUPLICATE KEY UPDATE,或者在 Writer 里先按业务键删除再插入。
事务边界不清导致部分提交。检查 chunk 大小和faultTolerant配置。如果skip配置了宽泛的异常类型,被跳过的记录不会回滚,会导致数据不一致。建议skip只配置明确的业务异常,不要用Exception.class一把梭。
6. 继续把批处理跑稳:从验证到长期运行的接入建议
跑通一次最小数据集只是开始,长期运行还要考虑几件事。第一,Key 的轮换。批处理任务通常长期跑,Key 泄露风险高。建议定期在控制台轮换 Key,地址是 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,轮换后更新环境变量并重启任务。第二,调用量监控。批处理任务调用量大,建议在 TaoToken 控制台按 Key 维度看用量,避免超额。第三,失败告警。Job 失败时要有告警,不要等第二天才发现数据没同步。
如果你还在选型阶段,想先验证模型返回是否符合预期,可以去模型对话页面试几条,地址是 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。确认没问题再写进批处理配置。如果你需要长期编码辅助来维护这套批处理代码,可以看 coding-plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,字段名和参数以文档为准。
最后给一个实用技巧:批处理任务的日志里,把 chunk 的读写计数、翻页 SQL、模型调用耗时都打出来。出问题时,先看计数对不对,再看翻页参数变没变,最后看模型调用有没有超时。这三步能定位 80% 的问题。剩下的 20%,多半是事务边界和幂等性,回到第 3 节的配置逐项核对即可。