Spring Boot y Spring Cloud
Fundamentos de Spring Batch 5: jobs, steps, chunks, reinicio y escalado
Intermedio11 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Sabrás diseñar y ejecutar un job de Spring Batch 5 con Spring Boot 3: modelar el trabajo como jobs y steps, construir steps orientados a chunks a partir de readers, processors y writers, pasar parámetros de forma segura con el step scope, hacer que los fallos se puedan omitir o reintentar, reiniciar un job fallido donde se quedó y elegir la estrategia de escalado adecuada para grandes volúmenes.
Lo que aprenderás
- Explicar el modelo de dominio de Spring Batch: Job, JobInstance, JobExecution, Step, StepExecution y el JobRepository
- Construir un step orientado a chunks con las API JobBuilder y StepBuilder de Spring Batch 5
- Leer ficheros planos y bases de datos y escribir por lotes con los item readers y writers que trae el framework
- Configurar skip, retry y reinicio, y entender qué registran las tablas del job repository
- Pasar parámetros del job a beans con step scope y controlar el flujo con transiciones condicionales
- Elegir entre steps multihilo, flujos paralelos, particionado y remote chunking
Hay trabajo que no encaja en el modelo petición-respuesta: importar cada noche un fichero de dos millones de filas, recalcular el saldo de todos los clientes, archivar los eventos del trimestre pasado o generar los extractos mensuales. Estos jobs tienen que procesar grandes volúmenes con memoria acotada, confirmar el progreso de forma incremental, sobrevivir a un registro defectuoso sin descartar los buenos y reanudarse tras una caída en lugar de empezar de cero. Spring Batch es el framework que recoge esos requisitos para que no tengas que reinventarlos con un bucle for y una entrada de cron. Esta página trata Spring Batch 5 tal como lo autoconfigura Spring Boot 3, con la API de builders de Java que sustituyó a las antiguas factorías y al XML.
El modelo de dominio
El vocabulario de Spring Batch se corresponde directamente con los metadatos que almacena:
| Concepto | Significado |
|---|---|
| Job | Un proceso batch completo, formado por steps ordenados; se identifica por su nombre |
| JobInstance | Una ejecución lógica de un job para un conjunto concreto de parámetros identificativos, p. ej. «import-orders del 2026-09-01» |
| JobExecution | Un intento físico de ejecutar una JobInstance; una instancia fallida puede tener varias ejecuciones |
| JobParameters | Pares clave-valor tipados que se pasan al lanzar el job; forman parte de la identidad de la instancia salvo que se marquen como no identificativos |
| Step | Una fase independiente del job con su propia gestión transaccional y su propio estado |
| StepExecution | Un intento de ejecutar un step, con contadores: leídos, escritos, filtrados, omitidos, commits, rollbacks |
| ExecutionContext | Un mapa persistente asociado a la ejecución de un job o de un step, que se usa para registrar el progreso de cara al reinicio |
| JobRepository | El almacén de todo lo anterior: un conjunto de tablas BATCH_* en una base de datos relacional |
| JobLauncher / JobOperator | Los puntos de entrada para iniciar, detener y reiniciar jobs |
El repositorio es lo que distingue a Spring Batch de un planificador con un bucle. Cada ejecución de step registra hasta dónde llegó, de modo que el reinicio de una ejecución fallida empieza en el step que falló y un reader reiniciable retoma la lectura en el último elemento confirmado.
Boot crea automáticamente, en las bases embebidas, las tablas BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION, BATCH_JOB_EXECUTION_PARAMS, BATCH_STEP_EXECUTION y las dos *_EXECUTION_CONTEXT; con una base de datos real, pon spring.batch.jdbc.initialize-schema=always una vez o, mejor, copia el script de esquema correspondiente a tu motor de base de datos desde el jar spring-batch-core a una migración de Flyway, para que las tablas se versionen junto con todo lo demás.
Steps de tipo tasklet y steps orientados a chunks
Un step es o bien una tasklet (una unidad de trabajo que se ejecuta una vez) o bien orientado a chunks (lee los elementos de uno en uno, opcionalmente procesa cada uno y los escribe en grupos). Las tasklets sirven para tareas de mantenimiento: descomprimir un fichero, llamar a un procedimiento almacenado, borrar un directorio. Los chunks sirven para todo lo que procese un flujo de registros.
@Configuration
class ImportOrdersJobConfig {
@Bean
Job importOrdersJob(JobRepository jobRepository, Step unpackStep, Step importStep) {
return new JobBuilder("importOrders", jobRepository)
.incrementer(new RunIdIncrementer()) // adds run.id: a new instance per launch, unless the last one failed
.start(unpackStep)
.next(importStep)
.build();
}
@Bean
Step unpackStep(JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder("unpack", jobRepository)
.tasklet((contribution, chunkContext) -> {
Files.createDirectories(Path.of("work"));
return RepeatStatus.FINISHED;
}, tx)
.build();
}
@Bean
Step importStep(JobRepository jobRepository, PlatformTransactionManager tx,
FlatFileItemReader<OrderCsvRow> reader,
ItemProcessor<OrderCsvRow, Order> processor,
JdbcBatchItemWriter<Order> writer) {
return new StepBuilder("import", jobRepository)
.<OrderCsvRow, Order>chunk(500, tx) // 500 items per transaction
.reader(reader)
.processor(processor)
.writer(writer)
.build();
}
}
JobBuilder y StepBuilder reciben directamente el JobRepository; JobBuilderFactory y StepBuilderFactory, de Batch 4, están deprecadas desde la 5.0 y no deberían aparecer en código nuevo. El transaction manager es ahora un argumento explícito de chunk() y tasklet(), lo que deja claro cuál usa cada step cuando la aplicación tiene varios orígenes de datos.
El bucle de chunk funciona así: se llama al reader hasta leer 500 elementos (o hasta que devuelve null, que indica el fin de la entrada), cada elemento pasa por el processor (que puede devolver null para filtrarlo), el writer recibe el Chunk resultante en una sola llamada y se hace commit de la transacción. Batch 5 cambió ItemWriter.write para que acepte un Chunk<? extends T> en lugar de una List; por lo demás, las interfaces son las de siempre:
public interface ItemReader<T> { T read() throws Exception; } // null = end of data
public interface ItemProcessor<I, O> { O process(I item) throws Exception; } // null = filter out
public interface ItemWriter<T> { void write(Chunk<? extends T> chunk) throws Exception; }
El tamaño del chunk es el parámetro de ajuste principal. Chunks más grandes implican menos commits y más rendimiento, pero también más trabajo perdido en un rollback y más memoria ocupada. Lo habitual para escrituras en base de datos es entre cientos y unos pocos miles; mide en lugar de adivinar.
Readers y writers
Spring Batch incluye readers y writers para los orígenes más comunes, todos construidos con builders fluidos en Batch 5:
| Origen o destino | Reader / writer |
|---|---|
| Ficheros delimitados o de ancho fijo | FlatFileItemReader, FlatFileItemWriter |
| JDBC | JdbcCursorItemReader (un cursor abierto, en streaming), JdbcPagingItemReader (consultas por páginas, reiniciable y thread-safe), JdbcBatchItemWriter |
| JPA | JpaPagingItemReader, JpaCursorItemReader, JpaItemWriter |
| Spring Data | RepositoryItemReader, RepositoryItemWriter |
| XML | StaxEventItemReader, StaxEventItemWriter |
| JSON | JsonItemReader, JsonFileItemWriter |
| Mensajería | JmsItemReader/Writer, KafkaItemReader/Writer, AmqpItemReader/Writer |
| Composición | CompositeItemProcessor, CompositeItemWriter, ClassifierCompositeItemWriter |
Leer un CSV en un record y escribir con batching de JDBC:
@Bean
@StepScope // created per step execution so the file name can come from job parameters
FlatFileItemReader<OrderCsvRow> orderReader(@Value("#{jobParameters['inputFile']}") Resource inputFile) {
return new FlatFileItemReaderBuilder<OrderCsvRow>()
.name("orderReader") // required: key for restart state in the execution context
.resource(inputFile)
.linesToSkip(1) // header
.delimited().delimiter(';')
.names("orderNumber", "customerId", "sku", "quantity", "unitPrice")
.targetType(OrderCsvRow.class) // maps columns onto a record's components by name
.build();
}
@Bean
JdbcBatchItemWriter<Order> orderWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<Order>()
.dataSource(dataSource)
.sql("""
insert into orders (order_number, customer_id, total_cents, created_at)
values (:orderNumber, :customerId, :totalCents, :createdAt)
""")
.beanMapped() // :name placeholders resolved from the item's JavaBean getters
.build();
}
JdbcBatchItemWriter envía todo el chunk como un único batch de JDBC, y de ahí sale la mayor parte del rendimiento de un job batch. beanMapped() depende de propiedades con getters; para records o columnas calculadas, proporciona en su lugar un ItemSqlParameterSourceProvider que construya un MapSqlParameterSource a partir del elemento. Si escribes con JPA, JpaItemWriter hace flush por chunk; activa el batching JDBC de Hibernate (hibernate.jdbc.batch_size) y usa identificadores SEQUENCE, como se explica en la guía de Spring Data JPA, o el «batch» seguirá siendo una sentencia por fila.
Elige JdbcCursorItemReader para leer una tabla grande de forma sencilla y en un solo hilo: una consulta, un result set abierto y memoria constante. Elige JdbcPagingItemReader cuando el step vaya a ser multihilo o particionado, porque un cursor no se puede compartir y porque la posición de un reader paginado es una clave de ordenación que se puede persistir para el reinicio. La paginación exige una clave de ordenación determinista (una columna única); si no, las páginas se solapan.
Parámetros del job y step scope
Normalmente los beans se crean al arrancar el contexto, mucho antes de que nadie sepa qué fichero tiene que importar la ejecución de esta noche. @StepScope (y @JobScope) retrasa la creación hasta el momento en que arranca el step, que es cuando se pueden resolver las expresiones #{jobParameters['...']}, #{jobExecutionContext['...']} y #{stepExecutionContext['...']}. Todo bean que lea un parámetro del job debe tener step scope; un error habitual es olvidar la anotación y recibir null.
Los parámetros son tipados y, desde Batch 5, pueden ser de cualquier tipo que tenga un conversor registrado; en la línea de comandos, la forma textual por defecto es name=value,type,identifying, por ejemplo inputFile=orders-2026-09-01.csv,java.lang.String,true. Boot pasa los argumentos de la línea de comandos al job cuando spring.batch.job.enabled vale true (el valor por defecto): java -jar import.jar inputFile=file:/data/orders.csv. Si una aplicación tiene varios jobs, spring.batch.job.name elige cuál se ejecuta al arrancar, y con spring.batch.job.enabled=false puedes lanzarlos a través del JobLauncher desde un planificador o un endpoint HTTP.
Usa parámetros identificativos para lo que define la ejecución (fecha de negocio, nombre de fichero) y no identificativos para los ajustes técnicos (tamaño de chunk alternativo, modo dry-run), de modo que un reintento con otro ajuste siga siendo la misma instancia y pueda reiniciarse.
Fallos: skip, retry y reinicio
Un batch que se detiene en la primera línea mal formada no sirve de mucho. La tolerancia a fallos se configura en el step:
@Bean
Step importStep(/* ... */) {
return new StepBuilder("import", jobRepository)
.<OrderCsvRow, Order>chunk(500, tx)
.reader(reader).processor(processor).writer(writer)
.faultTolerant()
.skip(FlatFileParseException.class) // bad line: drop it and continue
.skip(UnknownCustomerException.class)
.skipLimit(100) // more than 100 skips fails the step
.listener(rejectedLinesListener) // SkipListener: write rejects to a file
.retry(DeadlockLoserDataAccessException.class)
.retryLimit(3) // transient database error: try again
.noRollback(ValidationWarning.class) // exception that should not roll back the chunk
.build();
}
Omitir elementos durante la fase de escritura sale más caro de lo que parece: como el writer recibió el chunk entero, Spring Batch no puede saber qué elemento provocó el fallo, así que hace rollback y vuelve a procesar el chunk elemento a elemento, en transacciones de uno, para aislar al culpable. Mantén límites de skip moderados y haz la validación en el processor, donde un skip no cuesta nada.
El reinicio es lo que te da el repositorio. Si una ejecución falla, volver a lanzar el job con los mismos parámetros identificativos crea una nueva JobExecution para la misma JobInstance, se salta los steps que terminaron y reinicia el step fallido. Los readers que implementan ItemStream (todos los de ficheros y los paginados, siempre que se les dé un name) guardan su posición en el execution context del step en cada commit y continúan desde ahí. Hay dos opciones que cambian este comportamiento: .allowStartIfComplete(true) en un step lo vuelve a ejecutar aunque haya terminado bien, y .preventRestart() en el job prohíbe los reinicios en jobs que no son idempotentes. El startLimit de un step limita cuántas veces se puede intentar.
Controlar el flujo
Por defecto, los steps se ejecutan en secuencia. Las transiciones condicionales ramifican según el ExitStatus de un step:
return new JobBuilder("importOrders", jobRepository)
.start(importStep)
.on("FAILED").to(notifyOpsStep) // exit code pattern matching, "*" is a wildcard
.from(importStep).on("*").to(reconcileStep)
.from(reconcileStep)
.on("NOTHING_TO_DO").end() // custom exit status set by a StepExecutionListener
.from(reconcileStep).on("*").to(reportStep)
.end()
.build();
ExitStatus no es lo mismo que BatchStatus: el batch status (COMPLETED, FAILED, STOPPED…) es lo que registra el repositorio, mientras que el exit status es una cadena que un listener o un JobExecutionDecider puede fijar libremente para dirigir el enrutamiento. Un decider es un bean que implementa decide(JobExecution, StepExecution) y devuelve un FlowExecutionStatus; es la herramienta adecuada cuando la decisión depende de los datos y no del resultado de un step. Los listeners (JobExecutionListener, StepExecutionListener, ChunkListener, ItemReadListener, ItemProcessListener, ItemWriteListener, SkipListener) se enganchan a cada nivel del ciclo de vida y son el sitio para el logging, la auditoría y los ficheros de rechazos.
Escalado
Cuando un step de chunks en un solo hilo no es lo bastante rápido, Spring Batch ofrece cuatro opciones, más o menos por orden de complejidad:
- Step multihilo. Dale al step un
TaskExecutor; cada hilo procesa sus propios chunks. Readers y writers deben ser thread-safe, lo que descarta los cursores yFlatFileItemReader(envuelve este último en unSynchronizedItemStreamReader), y el reinicio deja de ser fiable porque el orden de los elementos no está definido. - Steps paralelos. Steps independientes se ejecutan a la vez en un
splitcon unTaskExecutor. No hay problemas de thread-safety dentro de cada step; el paralelismo se da entre steps. - Particionado. Un
Partitionerdivide la entrada en rangos (rangos de ids, un fichero por partición, una región por partición) y unPartitionHandlerejecuta una copia del step worker por partición, con su propiaStepExecution, en local sobre hilos o en remoto sobre otras JVM. Cada partición se puede reiniciar por separado. Es la opción de referencia para tablas grandes. - Remote chunking. El manager lee los elementos y los envía por mensajería; los workers los procesan y escriben. Útil cuando el cuello de botella es el procesamiento, no la lectura, y la entrada no se puede particionar.
Así queda un step particionado en local:
@Bean
Step partitionedImport(JobRepository jobRepository, Step importWorkerStep, TaskExecutor executor) {
return new StepBuilder("partitionedImport", jobRepository)
.partitioner("importWorker", new IdRangePartitioner(dataSource)) // yields minId/maxId per partition
.step(importWorkerStep)
.gridSize(8)
.taskExecutor(executor)
.build();
}
El reader del worker tiene step scope y lee #{stepExecutionContext['minId']} y ['maxId'] para construir su consulta. Antes de todo esto, confirma que el cuello de botella es la base de datos y no la JVM: un writer sin batching de JDBC, o un processor que lanza una consulta por elemento, suele ser más lento que cualquier falta de hilos.
Ejecución y pruebas
spring-batch-test aporta @SpringBatchTest, que registra un JobLauncherTestUtils y un JobRepositoryTestUtils en un contexto @SpringBootTest. Lanza el job completo o un solo step con parámetros y comprueba la JobExecution resultante:
@SpringBootTest
@SpringBatchTest
class ImportOrdersJobTest {
@Autowired JobLauncherTestUtils jobLauncherTestUtils;
@Test
void importsValidRowsAndSkipsBadOnes() throws Exception {
JobParameters params = new JobParametersBuilder()
.addString("inputFile", "classpath:orders-sample.csv")
.addLong("run.id", System.currentTimeMillis())
.toJobParameters();
JobExecution execution = jobLauncherTestUtils.launchJob(params);
assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
StepExecution step = execution.getStepExecutions().stream()
.filter(s -> s.getStepName().equals("import")).findFirst().orElseThrow();
assertThat(step.getWriteCount()).isEqualTo(998);
assertThat(step.getSkipCount()).isEqualTo(2);
}
}
Pon spring.batch.job.enabled=false en las propiedades de test para que Boot no lance además el job al arrancar el contexto. En cuanto a la operación, Spring Batch no tiene consola web integrada desde que se retiró Spring Batch Admin; Spring Cloud Data Flow, un pequeño endpoint de Actuator sobre el JobExplorer o SQL directo contra las tablas BATCH_* son las formas habituales de ver qué se ha ejecutado.
Errores comunes
Añadir @EnableBatchProcessing porque aparece en un tutorial antiguo. En Boot 3 desactiva la autoconfiguración; los síntomas son beans JobRepository que faltan o jobs que nunca se lanzan.
Olvidar @StepScope en los beans que usan parámetros del job. La expresión se resuelve a null al arrancar el contexto, y el fallo aparece lejos de su causa.
Hacer commit por elemento. Un tamaño de chunk de 1, o un writer que abre su propia transacción por elemento, tira por la borda la razón de usar un framework batch.
Leer con JPA sin limpiar nunca el contexto de persistencia. JpaPagingItemReader y JpaItemWriter lo gestionan por chunk, pero un reader escrito a mano que carga entidades en un bucle hace crecer la caché de primer nivel hasta agotar la memoria.
Paginación no determinista. Un reader paginado ordenado por una columna no única se salta o duplica filas entre páginas. Ordena por la clave primaria.
Hacer que todo se pueda omitir. skip(Exception.class) con un límite alto convierte la corrupción de datos en un aviso que nadie lee. Omite solo excepciones concretas y entendidas, y envía los rechazos a un lugar visible.
Lanzar dos veces la misma instancia. Sin incrementer ni un parámetro que la distinga, el segundo lanzamiento de un job completado falla; con un run id en todo, reiniciar una instancia fallida se vuelve imposible porque cada lanzamiento es nuevo. Usa parámetros de negocio para la identidad y reserva run.id para los jobs que realmente no tienen clave natural.
Puntos clave
- Spring Batch modela el trabajo como jobs compuestos de steps y registra cada ejecución en un
JobRepositoryrespaldado por una base de datos, que es lo que hace posibles el reinicio, la auditoría y el recuento de skips. - En Spring Boot 3, usa
JobBuilderyStepBuildercon unJobRepositoryy un transaction manager explícitos; no pongas@EnableBatchProcessing. - Los steps orientados a chunks leen los elementos de uno en uno, los procesan y los escriben en chunks transaccionales; el tamaño del chunk es el principal parámetro de ajuste y el batching de JDBC, la principal fuente de velocidad.
- Los beans con step scope reciben los parámetros del job y los valores del execution context lo bastante tarde como para que ya existan; los parámetros identificativos definen la
JobInstance. - Skip y retry hacen que los steps toleren registros defectuosos y errores transitorios; el reinicio reanuda una ejecución fallida desde el último commit.
- Escala con particionado para tablas grandes, steps multihilo para readers thread-safe, flujos paralelos para steps independientes y remote chunking cuando domina el procesamiento.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Sigue haciendo falta @EnableBatchProcessing con Spring Boot 3?
- No, y añadirla cambia el comportamiento. Spring Boot 3 autoconfigura el JobRepository, el JobLauncher y los beans relacionados a partir de tu DataSource; @EnableBatchProcessing le indica a Boot que se aparte por completo para que tú asumas la infraestructura. No la pongas salvo que quieras configurar Spring Batch a mano de forma deliberada, por ejemplo para usar un DataSource dedicado a las tablas de metadatos.
- ¿Por qué mi job dice «A job instance already exists and is complete»?
- Una JobInstance se identifica por el nombre del job más sus parámetros identificativos. Cuando una instancia ha terminado con éxito, Spring Batch se niega a ejecutarla de nuevo con los mismos parámetros para protegerte de reprocesar datos. Pasa un parámetro que cambie en cada ejecución (un run id de RunIdIncrementer, el nombre del fichero de entrada, una fecha de negocio) o marca un parámetro como no identificativo si no debe contar.
- ¿Se puede ejecutar Spring Batch sin base de datos?
- Spring Batch 5 eliminó el antiguo job repository en memoria basado en maps, así que el JobRepository estándar necesita una base de datos relacional para sus tablas de metadatos, que es además lo que hace posible el reinicio. Para desarrollo basta con una base H2 embebida. Las versiones 5.x recientes añaden un repositorio sin recursos para los casos en que no se necesita reiniciar; lee las notas de la versión que uses antes de confiar en él.
- ¿Qué diferencia hay entre commit interval y page size?
- El commit interval (el tamaño del chunk) es cuántos elementos se procesan antes de llamar al writer y hacer commit de la transacción. El page size pertenece a los readers paginados como JdbcPagingItemReader y controla cuántas filas trae cada consulta a la base de datos. Son independientes; un error habitual es darles valores distintos y luego no entender por qué las consultas no cuadran con los commits. Igualarlos es un valor por defecto sensato.