一、业务背景
支付对账是金融系统每日核心定时任务:
- 数据源:业务库订单支付流水、第三方支付渠道回调流水;
- 对账逻辑:以渠道流水为准,匹配本地支付订单,区分三类结果:
- 平账:本地订单与渠道流水金额、订单号完全匹配;
- 本地长款:渠道有记录,本地无对应订单;
- 本地短款:本地有订单,渠道无对应流水;
3.需求约束:
- 每日凌晨自动执行,处理前一日全量支付数据;
- 百万级流水不 OOM,流式分页读取数据库;
- 批量入库对账结果,支持断点续跑;
- 失败分片单独重试,不重复处理完整数据;
- 技术选型:
Spring Batch + MySQL + JdbcCursorItemReader(流式游标读)+ JdbcBatchItemWriter(批量写)
二、整体架构流程
- Job :支付对账任务 payReconciliationJob
- Step :单步分片处理 reconciliationStep
- Reader :JdbcCursorItemReader 读取前一日第三方渠道流水(流式游标,避免全量加载)
- Processor:对账核心处理器,匹配本地订单,生成对账结果
- Writer:批量写入对账结果表,Chunk 批量事务提交
- 参数:通过 Job 参数传入对账日期,动态筛选当日流水
三、数据库表设计
1. 第三方渠道流水表(source_channel_pay)
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| sql CREATE TABLE `source_channel_pay` ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', out_trade_no VARCHAR(64) NOT NULL COMMENT '商户订单号', channel_trade_no VARCHAR(64) NOT NULL COMMENT '渠道交易号', pay_amount DECIMAL(12,2) NOT NULL COMMENT '支付金额', pay_time DATETIME NOT NULL COMMENT '支付时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_pay_time (pay_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方支付渠道流水'; |
2. 本地业务支付订单表(biz_pay_order)
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| sql CREATE TABLE `biz_pay_order` ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT '商户订单号', pay_amount DECIMAL(12,2) NOT NULL COMMENT '支付金额', pay_status TINYINT NOT NULL COMMENT '1待支付 2已支付', pay_time DATETIME COMMENT '本地支付完成时间', INDEX idx_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地支付订单'; |
3. 对账结果表(pay_reconciliation_result)
|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| sql CREATE TABLE `pay_reconciliation_result` ( id BIGINT PRIMARY KEY AUTO_INCREMENT, recon_date DATE NOT NULL COMMENT '对账日期', out_trade_no VARCHAR(64) NOT NULL COMMENT '商户订单号', channel_trade_no VARCHAR(64) NOT NULL COMMENT '渠道交易号', channel_amount DECIMAL(12,2) NOT NULL COMMENT '渠道金额', local_amount DECIMAL(12,2) DEFAULT NULL COMMENT '本地订单金额', recon_type TINYINT NOT NULL COMMENT '1平账 2长款 3短款', recon_desc VARCHAR(256) COMMENT '对账说明', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_recon_date (recon_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付对账结果表'; |
四、依赖引入(Maven)
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| xml <!-- Spring Batch 核心 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-batch</artifactId> </dependency> <!-- JDBC 数据库 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> |
五、实体对象封装
1. 渠道流水实体 ChannelPayDO
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; @Data public class ChannelPayDO { private Long id; private String outTradeNo; private String channelTradeNo; private BigDecimal payAmount; private LocalDateTime payTime; } |
2. 本地支付订单 BizPayOrderDO
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; @Data public class BizPayOrderDO { private Long id; private String orderNo; private BigDecimal payAmount; private Integer payStatus; private LocalDateTime payTime; } |
3. 对账结果 PayReconciliationResultDO
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import lombok.Data; import java.math.BigDecimal; import java.time.LocalDate; @Data public class PayReconciliationResultDO { private LocalDate reconDate; private String outTradeNo; private String channelTradeNo; private BigDecimal channelAmount; private BigDecimal localAmount; /** 1平账 2长款 3短款 */ private Integer reconType; private String reconDesc; } |
六、Spring Batch 完整配置类
配置说明
- JdbcCursorItemReader:流式游标读取渠道流水,百万级数据不 OOM;
- Chunk 1000:每 1000 条一批次读取、处理、批量写入,单事务提交;
- Job 参数reconDate:动态传入对账日期,筛选当日数据;
- 自动启用 Spring Batch 内置元数据表(job 执行日志、断点续跑)。
|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import lombok.RequiredArgsConstructor; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; import org.springframework.batch.core.configuration.annotation.JobBuilderFactory; import org.springframework.batch.core.configuration.annotation.StepBuilderFactory; import org.springframework.batch.core.job.builder.JobBuilder; import org.springframework.batch.core.step.builder.StepBuilder; import org.springframework.batch.item.database.BeanPropertyItemSqlParameterSourceProvider; import org.springframework.batch.item.database.JdbcBatchItemWriter; import org.springframework.batch.item.database.JdbcCursorItemReader; import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.core.BeanPropertyRowMapper; import javax.sql.DataSource; import java.time.LocalDate; @Configuration @EnableBatchProcessing @RequiredArgsConstructor public class PayReconciliationBatchConfig { private final JobBuilderFactory jobBuilderFactory; private final StepBuilderFactory stepBuilderFactory; private final DataSource dataSource; private final PayReconciliationProcessor reconciliationProcessor; // ===================== Reader:读取渠道流水 ===================== @Bean public JdbcCursorItemReader<ChannelPayDO> channelPayReader() { // 从Job参数获取对账日期reconDate,筛选前一日渠道流水 String sql = "SELECT id, out_trade_no, channel_trade_no, pay_amount, pay_time " + "FROM source_channel_pay WHERE DATE(pay_time) = :reconDate"; return new JdbcCursorItemReaderBuilder<ChannelPayDO>() .dataSource(dataSource) .sql(sql) .rowMapper(new BeanPropertyRowMapper<>(ChannelPayDO.class)) .fetchSize(1000) // 游标批量拉取,优化网络IO .name("channelPayCursorReader") .build(); } // ===================== Writer:批量写入对账结果 ===================== @Bean public JdbcBatchItemWriter<PayReconciliationResultDO> reconResultWriter() { String insertSql = "INSERT INTO pay_reconciliation_result " + "(recon_date, out_trade_no, channel_trade_no, channel_amount, local_amount, recon_type, recon_desc) " + "VALUES (:reconDate, :outTradeNo, :channelTradeNo, :channelAmount, :localAmount, :reconType, :reconDesc)"; return new JdbcBatchItemWriterBuilder<PayReconciliationResultDO>() .dataSource(dataSource) .sql(insertSql) .itemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider<>()) .build(); } // ===================== Step:对账处理步骤 ===================== @Bean public Step reconciliationStep() { return stepBuilderFactory.get("reconciliationStep") // chunk块大小1000,每1000条提交一次事务 .<ChannelPayDO, PayReconciliationResultDO>chunk(1000) .reader(channelPayReader()) .processor(reconciliationProcessor) .writer(reconResultWriter()) // 容错:单条数据异常跳过,记录日志不中断整个任务 .skip(Exception.class) .skipLimit(100) .build(); } // ===================== Job:支付对账主任务 ===================== @Bean public Job payReconciliationJob() { return jobBuilderFactory.get("payReconciliationJob") .start(reconciliationStep()) .build(); } } |
七、对账核心 ItemProcessor(业务逻辑层)
核心逻辑
- 根据渠道订单号查询本地支付订单;
- 匹配判定平账 / 长款 / 短款;
- 组装对账结果实体返回,交由 Writer 批量入库。
|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.item.ItemProcessor; import org.springframework.jdbc.core.BeanPropertyRowMapper; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.time.LocalDate; @Slf4j @Component @RequiredArgsConstructor public class PayReconciliationProcessor implements ItemProcessor<ChannelPayDO, PayReconciliationResultDO> { private final JdbcTemplate jdbcTemplate; private static final String QUERY_ORDER_SQL = "SELECT id, order_no, pay_amount, pay_status, pay_time " + "FROM biz_pay_order WHERE order_no = ?"; @Override public PayReconciliationResultDO process(ChannelPayDO channelPay) throws Exception { String outTradeNo = channelPay.getOutTradeNo(); BigDecimal channelAmount = channelPay.getPayAmount(); LocalDate reconDate = channelPay.getPayTime().toLocalDate(); // 查询本地订单 BizPayOrderDO localOrder = null; try { localOrder = jdbcTemplate.queryForObject(QUERY_ORDER_SQL, new Object\[\]{outTradeNo}, new BeanPropertyRowMapper<>(BizPayOrderDO.class)); } catch (Exception e) { // 无本地订单,判定长款 log.warn("订单{}本地无支付记录,长款", outTradeNo); } PayReconciliationResultDO result = new PayReconciliationResultDO(); result.setReconDate(reconDate); result.setOutTradeNo(outTradeNo); result.setChannelTradeNo(channelPay.getChannelTradeNo()); result.setChannelAmount(channelAmount); if (localOrder == null) { // 长款:渠道有、本地无 result.setReconType(2); result.setReconDesc("长款:渠道存在流水,本地无对应支付订单"); result.setLocalAmount(null); } else { result.setLocalAmount(localOrder.getPayAmount()); if (channelAmount.compareTo(localOrder.getPayAmount()) == 0) { // 平账:金额一致 result.setReconType(1); result.setReconDesc("平账:渠道与本地订单金额匹配"); } else { // 短款:订单存在但金额不一致 result.setReconType(3); result.setReconDesc(String.format("短款:渠道金额%s,本地订单金额%s", channelAmount, localOrder.getPayAmount())); } } return result; } } |
八、启动任务测试类(手动执行 / 定时调度)
1. 手动启动单元测试
|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import org.junit.jupiter.api.Test; import org.springframework.batch.core.Job; import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.core.launch.JobLauncher; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.time.LocalDate; @SpringBootTest public class PayReconJobTest { @Autowired private JobLauncher jobLauncher; @Autowired private Job payReconciliationJob; @Test void runReconJob() throws Exception { // 传入对账日期,例如2026-06-14 LocalDate reconDate = LocalDate.of(2026, 6, 14); JobParameters params = new JobParametersBuilder() .addLocalDate("reconDate", reconDate) .addLong("runTime", System.currentTimeMillis()) // 每次执行参数唯一,避免任务重复跳过 .toJobParameters(); jobLauncher.run(payReconciliationJob, params); } } |
2. 定时自动执行(每日凌晨 1 点对账)
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import lombok.RequiredArgsConstructor; import org.springframework.batch.core.Job; import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.core.launch.JobLauncher; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.LocalDate; @Component @RequiredArgsConstructor public class ReconScheduleTask { private final JobLauncher jobLauncher; private final Job payReconciliationJob; /** * 每日凌晨1点执行前一日对账 */ @Scheduled(cron = "0 0 1 * * ?") public void executeReconTask() throws Exception { // 对账日期为昨日 LocalDate reconDate = LocalDate.now().minusDays(1); JobParameters params = new JobParametersBuilder() .addLocalDate("reconDate", reconDate) .addLong("runTime", System.currentTimeMillis()) .toJobParameters(); jobLauncher.run(payReconciliationJob, params); } } |
九、MySQL 性能优化配置(application.yml)
|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| yaml spring: batch: jdbc: initialize-schema: always # 自动创建Batch元数据表,首次运行开启 datasource: url: jdbc:mysql://127.0.0.1:3306/pay_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai # 批量写入核心优化:合并多条insert为单条SQL,性能提升5~10倍 &rewriteBatchedStatements=true &useServerPrepStmts=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver |
十、方案核心优势(博客重点总结)
1. 大数据量内存可控
使用JdbcCursorItemReader游标流式读取,不会一次性加载全量渠道流水,百万 / 千万级数据无 OOM 风险,对比分页limit offset深度分页性能断崖下跌问题完美规避。
2. 数据库写入高效
JdbcBatchItemWriter底层 JDBC 批量addBatch,配合rewriteBatchedStatements=true,1000 条单次事务提交,网络 IO、事务日志开销大幅降低,速度是单条循环写入 10 倍以上。
3. 金融级可靠特性
- 断点续跑:Batch 内置元数据表记录每一个 chunk 执行状态,任务中途崩溃重启后,仅重跑失败分片,无需全量重跑;
- 异常容错:配置 skip 限制,单条脏数据跳过不中断整体对账任务;
- 事务隔离:每个 Chunk 独立事务,成功批量入库,失败整块回滚,对账数据不会半残。
4. 定时运维友好
支持 Cron 定时每日自动执行,通过 Job 参数动态指定对账日期,支持手动重跑历史日期对账,适配对账差错补跑场景。
5. 业务解耦易扩展
- 新增对账规则仅修改ItemProcessor,读写层完全不动;
- 可扩展分区并行 Partition,多线程分片读取渠道流水,千万级数据同步提速;
- 可扩展文件导出、对账告警、差错推送等后置 Step。
十一、生产调优建议(博客拓展阅读)
- Chunk 大小:对账流水推荐 chunk=1000,平衡吞吐量与回滚成本;数据量超大可调整至 2000~5000;
- 并行提速 :千万级流水接入Partitioner分区,按渠道流水 ID 分片多线程并行读取;
- 索引优化:渠道流水 pay_time、订单表 order_no 必须建立索引,避免全表扫描;
- 资源隔离:对账任务夜间执行,避开业务高峰,可单独配置读写分离从库读取渠道流水;
- 监控告警:监听 Job 执行完成事件,对账失败、长款短款数量超标推送钉钉 / 邮件告警。
十二、适用场景拓展
除支付对账外,该架构可无缝复用:
- 资金流水核对、商户结算批处理;
- 订单数据统计、历史数据迁移;
- 第三方 API 数据落地、数据清洗批任务。