Spring Batch: procesamiento de datos en lotes con Spring Boot
Procesar millones de registros de una base de datos, transformar archivos CSV de gran tamaño, generar reportes nocturnos, migrar datos entre sistemas: estas tareas tienen algo en común. No se ejecutan en respuesta a una petición HTTP, no necesitan respuesta inmediata, y fallar a mitad del proceso sin poder reiniciar desde el punto de falla es inaceptable.
Spring Batch resuelve exactamente esto. Es el framework del ecosistema Spring para construir pipelines de procesamiento por lotes: confiables, reiniciables y con soporte nativo para el manejo de errores, reintentos y skip de registros inválidos.
Conceptos fundamentales
Antes del código, conviene tener claros los bloques que Spring Batch ensambla:
- Job: la unidad de trabajo completa. Puede tener uno o varios pasos.
- Step: una fase del Job. Puede ser chunk-oriented (lee, procesa y escribe en lotes) o tasklet (una operación personalizada).
- ItemReader: lee los datos de la fuente (DB, CSV, XML, API...).
- ItemProcessor: transforma o filtra cada ítem. Es opcional.
- ItemWriter: escribe el resultado en el destino.
- JobRepository: persiste el estado de ejecución (Jobs, Steps, parámetros) en base de datos.
- JobLauncher: dispara la ejecución de un Job.
El flujo de un Step chunk-oriented es: leer N ítems → procesarlos uno a uno → escribirlos todos juntos. Ese grupo de N ítems es el chunk.
Dependencias
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-batch</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>Spring Batch requiere una base de datos para el JobRepository. En desarrollo H2 en memoria es suficiente; en producción usa PostgreSQL o MySQL. Spring Boot autoconfigura las tablas de metadatos automáticamente con spring.batch.jdbc.initialize-schema=always.
Configuración mínima en application.yml:
spring:
batch:
job:
enabled: false # evita que los Jobs arranquen al iniciar la app
jdbc:
initialize-schema: always
datasource:
url: jdbc:h2:mem:batchdb
driver-class-name: org.h2.Driverjob.enabled: false es importante: por defecto Spring Boot lanza todos los Jobs al arrancar. En producción querrás controlar cuándo se ejecutan.
Un Job completo: importar un CSV de usuarios
El ejemplo clásico de Spring Batch es leer un CSV, transformar los datos y guardarlos en base de datos.
El modelo de datos
public record UserRecord(String firstName, String lastName, String email) {}
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
private String email;
private LocalDateTime importedAt;
// constructores, getters, setters
}El ItemReader
Para leer CSV, Spring Batch incluye FlatFileItemReader:
@Bean
public FlatFileItemReader<UserRecord> userCsvReader() {
return new FlatFileItemReaderBuilder<UserRecord>()
.name("userCsvReader")
.resource(new ClassPathResource("data/users.csv"))
.delimited()
.names("firstName", "lastName", "email")
.targetType(UserRecord.class)
.linesToSkip(1) // salta el encabezado
.build();
}El archivo users.csv tiene este aspecto:
firstName,lastName,email
Ana,García,ana@example.com
Carlos,López,carlos@example.com
María,Martínez,maria@example.comEl ItemProcessor
El processor transforma UserRecord en la entidad User y puede filtrar registros (retornar null descarta el ítem):
@Component
public class UserProcessor implements ItemProcessor<UserRecord, User> {
@Override
public User process(UserRecord record) {
// filtra emails inválidos
if (record.email() == null || !record.email().contains("@")) {
return null;
}
User user = new User();
user.setFirstName(record.firstName().trim());
user.setLastName(record.lastName().trim());
user.setEmail(record.email().toLowerCase());
user.setImportedAt(LocalDateTime.now());
return user;
}
}El ItemWriter
Para persistir en JPA, RepositoryItemWriter delega directamente en un Spring Data repository:
@Bean
public RepositoryItemWriter<User> userWriter(UserRepository userRepository) {
RepositoryItemWriter<User> writer = new RepositoryItemWriter<>();
writer.setRepository(userRepository);
writer.setMethodName("save");
return writer;
}El Step y el Job
@Configuration
@EnableBatchProcessing
public class UserImportJobConfig {
@Bean
public Step importUsersStep(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
FlatFileItemReader<UserRecord> reader,
UserProcessor processor,
RepositoryItemWriter<User> writer
) {
return new StepBuilder("importUsersStep", jobRepository)
.<UserRecord, User>chunk(100, transactionManager)
.reader(reader)
.processor(processor)
.writer(writer)
.build();
}
@Bean
public Job importUsersJob(JobRepository jobRepository, Step importUsersStep) {
return new JobBuilder("importUsersJob", jobRepository)
.start(importUsersStep)
.build();
}
}chunk(100, transactionManager) significa: lee 100 registros, procésalos y escríbelos en una sola transacción. Si falla alguno de los 100, toda la transacción se revierte y Spring Batch registra el fallo.
Leer desde base de datos con JdbcPagingItemReader
Cuando la fuente es una tabla de base de datos, JdbcPagingItemReader pagina automáticamente la consulta para no cargar todo en memoria:
@Bean
public JdbcPagingItemReader<OrderDto> orderReader(DataSource dataSource) {
return new JdbcPagingItemReaderBuilder<OrderDto>()
.name("orderReader")
.dataSource(dataSource)
.selectClause("SELECT id, customer_id, total, status")
.fromClause("FROM orders")
.whereClause("WHERE status = 'PENDING' AND created_at < :cutoff")
.sortKeys(Map.of("id", Order.ASCENDING))
.parameterValues(Map.of("cutoff", LocalDate.now().minusDays(30)))
.rowMapper(new BeanPropertyRowMapper<>(OrderDto.class))
.pageSize(500)
.build();
}Cada página lanza una consulta con LIMIT/OFFSET (o el equivalente del dialecto SQL), así el heap de la JVM no crece con el tamaño de la tabla.
Manejo de errores: skip y retry
En un lote real, no todos los registros son perfectos. Spring Batch permite saltar registros fallidos o reintentarlos antes de descartar:
@Bean
public Step robustStep(JobRepository jobRepository, PlatformTransactionManager txManager, ...) {
return new StepBuilder("robustStep", jobRepository)
.<UserRecord, User>chunk(100, txManager)
.reader(reader)
.processor(processor)
.writer(writer)
// reintenta hasta 3 veces ante errores transitorios de red o DB
.faultTolerant()
.retryLimit(3)
.retry(TransientDataAccessException.class)
// salta registros con datos inválidos (máximo 10 skips por Step)
.skipLimit(10)
.skip(ValidationException.class)
.build();
}Los registros saltados se registran en el JobRepository con su índice, lo que permite auditarlos después.
Tasklet: para operaciones que no son chunk
No todo en un Job es leer-procesar-escribir. A veces necesitas ejecutar una sola operación: limpiar una tabla antes de importar, enviar un email al final, comprimir un archivo de salida. Para eso existe el Tasklet:
@Component
public class CleanupTasklet implements Tasklet {
private final UserRepository userRepository;
public CleanupTasklet(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
userRepository.deleteByImportedAtBefore(LocalDateTime.now().minusDays(90));
log.info("Usuarios antiguos eliminados");
return RepeatStatus.FINISHED;
}
}
@Bean
public Step cleanupStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
CleanupTasklet cleanupTasklet) {
return new StepBuilder("cleanupStep", jobRepository)
.tasklet(cleanupTasklet, txManager)
.build();
}Jobs con múltiples Steps
Un Job puede encadenar varios Steps. Por defecto se ejecutan secuencialmente:
@Bean
public Job fullImportJob(JobRepository jobRepository,
Step cleanupStep,
Step importUsersStep,
Step notifyStep) {
return new JobBuilder("fullImportJob", jobRepository)
.start(cleanupStep)
.next(importUsersStep)
.next(notifyStep)
.build();
}Si un Step falla, los Steps siguientes no se ejecutan. Spring Batch registra en qué Step falló, lo que permite reiniciar el Job desde ese punto en la siguiente ejecución en lugar de comenzar desde cero.
Lanzar un Job manualmente
Con job.enabled: false en la configuración, necesitas disparar el Job explícitamente. El mecanismo habitual es un endpoint o un scheduler:
@RestController
@RequestMapping("/batch")
public class BatchController {
private final JobLauncher jobLauncher;
private final Job importUsersJob;
public BatchController(JobLauncher jobLauncher, Job importUsersJob) {
this.jobLauncher = jobLauncher;
this.importUsersJob = importUsersJob;
}
@PostMapping("/import-users")
public ResponseEntity<String> triggerImport() throws Exception {
JobParameters params = new JobParametersBuilder()
.addLocalDateTime("startedAt", LocalDateTime.now())
.toJobParameters();
JobExecution execution = jobLauncher.run(importUsersJob, params);
return ResponseEntity.ok("Job lanzado con ID: " + execution.getJobId());
}
}Los JobParameters son importantes: Spring Batch identifica una instancia de Job por la combinación de nombre + parámetros. Para poder ejecutar el mismo Job varias veces, al menos un parámetro debe cambiar entre ejecuciones (por eso se incluye el timestamp).
Listeners para observabilidad
Spring Batch tiene listeners para interceptar el ciclo de vida de Jobs y Steps:
@Component
public class JobCompletionListener implements JobExecutionListener {
@Override
public void afterJob(JobExecution jobExecution) {
if (jobExecution.getStatus() == BatchStatus.COMPLETED) {
long written = jobExecution.getStepExecutions().stream()
.mapToLong(StepExecution::getWriteCount)
.sum();
log.info("Job completado. Registros escritos: {}", written);
} else {
log.error("Job terminó con estado: {}", jobExecution.getStatus());
}
}
}
// Se registra en el Job:
@Bean
public Job importUsersJob(JobRepository jobRepository, Step importStep,
JobCompletionListener listener) {
return new JobBuilder("importUsersJob", jobRepository)
.listener(listener)
.start(importStep)
.build();
}Con StepExecutionListener se puede hacer lo mismo a nivel de Step, lo que es útil para métricas granulares.
Cuándo usar Spring Batch
Spring Batch es la elección correcta cuando:
- El volumen de datos hace inviable procesarlos en una sola transacción.
- El proceso debe ser reiniciable: si falla a mitad, debe continuar desde donde se quedó.
- Necesitas auditoría detallada de cada ejecución (qué se procesó, cuánto tardó, cuántos registros fallaron).
- El procesamiento puede paralelizarse (Spring Batch tiene soporte para Steps paralelos y particionamiento).
Para tareas simples y pequeñas, un @Scheduled con lógica directa en el servicio es más que suficiente. La complejidad de Spring Batch se justifica cuando la confiabilidad y trazabilidad del proceso son requisitos reales, no opcionales.
