Spring Boot et Spring Cloud

Les fondamentaux de Spring Batch 5 : jobs, steps, chunks, reprise et montée en charge

Intermédiaire11 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Vous saurez concevoir et exécuter un job Spring Batch 5 sur Spring Boot 3 : découper le travail en jobs et en steps, construire des steps orientés chunks à partir de readers, processors et writers, transmettre des paramètres en toute sécurité grâce au step scope, rendre les échecs ignorables ou rejouables, reprendre un job en échec là où il s'est arrêté et choisir la bonne stratégie de montée en charge pour les gros volumes.

Ce que vous allez apprendre

  • Expliquer le modèle de domaine de Spring Batch : Job, JobInstance, JobExecution, Step, StepExecution et JobRepository
  • Construire un step orienté chunks avec les API JobBuilder et StepBuilder de Spring Batch 5
  • Lire des fichiers plats et des bases de données, puis écrire par lots avec les item readers et writers fournis
  • Configurer le skip, le retry et la reprise, et comprendre ce qu'enregistrent les tables du job repository
  • Injecter des paramètres de job dans des beans en step scope et piloter le flux par des transitions conditionnelles
  • Choisir entre steps multithreadés, flux parallèles, partitionnement et remote chunking

Certains traitements ne rentrent pas dans le modèle requête-réponse : importer chaque nuit un fichier de deux millions de lignes, recalculer le solde de tous les clients, archiver les événements du trimestre écoulé, produire les relevés mensuels. Ces jobs doivent traiter de gros volumes avec une mémoire bornée, valider leur progression au fil de l'eau, survivre à un enregistrement défectueux sans jeter les bons, et reprendre après un crash au lieu de tout recommencer. Spring Batch est le framework qui intègre ces exigences, pour que vous n'ayez pas à les réinventer avec une boucle for et une entrée cron. Cette page traite de Spring Batch 5 tel que Spring Boot 3 l'auto-configure, avec l'API de builders Java qui a remplacé les anciennes factories et le XML.

Le modèle de domaine

Le vocabulaire de Spring Batch correspond directement aux métadonnées qu'il stocke :

ConceptSignification
JobUn traitement batch complet, composé de steps ordonnés ; identifié par son nom
JobInstanceUne exécution logique d'un job pour un jeu donné de paramètres identifiants, par exemple « import-orders du 2026-09-01 »
JobExecutionUne tentative physique d'exécuter une JobInstance ; une instance en échec peut en avoir plusieurs
JobParametersDes paires clé-valeur typées fournies au lancement, qui font partie de l'identité de l'instance sauf si elles sont déclarées non identifiantes
StepUne phase indépendante du job, avec sa propre gestion transactionnelle et son propre statut
StepExecutionUne tentative d'exécution d'un step, avec ses compteurs : lus, écrits, filtrés, ignorés, commits, rollbacks
ExecutionContextUne map persistante rattachée à l'exécution d'un job ou d'un step, qui sert à enregistrer la progression pour la reprise
JobRepositoryLe stockage de tout ce qui précède : un ensemble de tables BATCH_* dans une base relationnelle
JobLauncher / JobOperatorLes points d'entrée pour démarrer, arrêter et relancer des jobs

C'est le repository qui distingue Spring Batch d'un simple ordonnanceur couplé à une boucle. Chaque exécution de step enregistre jusqu'où elle est allée : la reprise d'une exécution en échec commence donc au step qui a échoué, et un reader capable de reprise repart du dernier élément commité.

Boot crée automatiquement les tables BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION, BATCH_JOB_EXECUTION_PARAMS, BATCH_STEP_EXECUTION et les deux tables *_EXECUTION_CONTEXT pour les bases embarquées ; pour une vraie base, positionnez une fois spring.batch.jdbc.initialize-schema=always, ou mieux, copiez le script de schéma correspondant à votre SGBD depuis le jar spring-batch-core dans une migration Flyway, afin que ces tables soient versionnées avec tout le reste.

Steps tasklet et steps orientés chunks

Un step est soit une tasklet (une unité de travail exécutée une seule fois), soit orienté chunks (lire les éléments un par un, éventuellement traiter chacun d'eux, puis les écrire par groupes). Les tasklets conviennent aux tâches d'intendance : décompresser un fichier, appeler une procédure stockée, supprimer un répertoire. Les chunks conviennent à tout ce qui traite un flux d'enregistrements.

@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 et StepBuilder prennent directement le JobRepository ; les JobBuilderFactory et StepBuilderFactory de Batch 4 sont dépréciées depuis la 5.0 et n'ont plus leur place dans du code neuf. Le transaction manager est désormais un argument explicite de chunk() et de tasklet(), ce qui montre clairement lequel un step utilise quand l'application a plusieurs sources de données.

La boucle de chunk fonctionne ainsi : le reader est appelé jusqu'à ce que 500 éléments aient été lus (ou qu'il renvoie null, qui signale la fin des données), chaque élément passe par le processor (qui peut renvoyer null pour l'écarter), le writer reçoit le Chunk obtenu en un seul appel, puis la transaction est commitée. Batch 5 a modifié ItemWriter.write pour qu'elle accepte un Chunk<? extends T> au lieu d'une List ; pour le reste, les interfaces n'ont pas changé :

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; }

La taille du chunk est le principal paramètre de réglage. Des chunks plus gros signifient moins de commits et un meilleur débit, mais davantage de travail perdu en cas de rollback et davantage de mémoire occupée. De quelques centaines à quelques milliers d'éléments, tel est l'ordre de grandeur habituel pour des écritures en base ; mesurez au lieu de deviner.

Readers et writers

Spring Batch fournit des readers et des writers pour les sources courantes, tous construits avec des builders fluides dans Batch 5 :

Source ou cibleReader / writer
Fichiers délimités ou à largeur fixeFlatFileItemReader, FlatFileItemWriter
JDBCJdbcCursorItemReader (un seul curseur ouvert, en streaming), JdbcPagingItemReader (requêtes par page, capable de reprise et thread-safe), JdbcBatchItemWriter
JPAJpaPagingItemReader, JpaCursorItemReader, JpaItemWriter
Spring DataRepositoryItemReader, RepositoryItemWriter
XMLStaxEventItemReader, StaxEventItemWriter
JSONJsonItemReader, JsonFileItemWriter
MessagerieJmsItemReader/Writer, KafkaItemReader/Writer, AmqpItemReader/Writer
CompositionCompositeItemProcessor, CompositeItemWriter, ClassifierCompositeItemWriter

Lire un CSV dans un record et écrire avec le batching 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 envoie tout le chunk sous la forme d'un seul batch JDBC, et c'est de là que vient l'essentiel du débit d'un job batch. beanMapped() s'appuie sur des propriétés de type getter ; pour des records ou des colonnes calculées, fournissez plutôt un ItemSqlParameterSourceProvider qui construit une MapSqlParameterSource à partir de l'élément. Si vous écrivez via JPA, JpaItemWriter fait un flush par chunk ; activez le batching JDBC d'Hibernate (hibernate.jdbc.batch_size) et utilisez des identifiants SEQUENCE, comme l'explique le guide Spring Data JPA, faute de quoi votre « batch » restera une instruction par ligne.

Choisissez JdbcCursorItemReader pour une lecture simple et monothread d'une grosse table : une requête, un result set ouvert, une mémoire constante. Choisissez JdbcPagingItemReader quand le step sera multithreadé ou partitionné, parce qu'un curseur ne peut pas être partagé, et parce que la position d'un reader paginé est une clé de tri qu'on peut persister pour la reprise. La pagination exige une clé de tri déterministe (une colonne unique), sinon les pages se chevauchent.

Paramètres de job et step scope

Les beans sont normalement créés au démarrage du contexte, bien avant que quiconque sache quel fichier l'exécution de ce soir doit importer. @StepScope (et @JobScope) reporte la création au démarrage du step, moment où les expressions #{jobParameters['...']}, #{jobExecutionContext['...']} et #{stepExecutionContext['...']} peuvent être résolues. Tout bean qui lit un paramètre de job doit être en step scope ; une erreur fréquente consiste à oublier l'annotation et à recevoir null.

Les paramètres sont typés et, depuis Batch 5, peuvent être de n'importe quel type pour lequel un convertisseur est enregistré ; en ligne de commande, la forme texte par défaut est name=value,type,identifying, par exemple inputFile=orders-2026-09-01.csv,java.lang.String,true. Boot transmet les arguments de la ligne de commande au job quand spring.batch.job.enabled vaut true (la valeur par défaut) : java -jar import.jar inputFile=file:/data/orders.csv. Quand une application contient plusieurs jobs, spring.batch.job.name choisit celui qui s'exécute au démarrage, et spring.batch.job.enabled=false vous permet de lancer plutôt les jobs via le JobLauncher, depuis un ordonnanceur ou un endpoint HTTP.

Réservez les paramètres identifiants à ce qui définit l'exécution (date métier, nom de fichier) et les non identifiants aux réglages techniques (surcharge de la taille de chunk, mode dry-run), pour qu'une nouvelle tentative avec un réglage différent reste la même instance et puisse reprendre.

Échecs : skip, retry, reprise

Un batch qui s'arrête à la première ligne mal formée ne sert pas à grand-chose. La tolérance aux pannes se configure sur le 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();
}

Ignorer un élément pendant la phase d'écriture coûte plus cher qu'il n'y paraît : comme le writer a reçu le chunk entier, Spring Batch ne peut pas savoir quel élément a provoqué l'échec. Il fait donc un rollback, puis retraite le chunk élément par élément, dans des transactions d'un seul élément, pour isoler le coupable. Gardez des limites de skip modestes et faites la validation dans le processor, où un skip ne coûte rien.

La reprise est ce que vous apporte le repository. Si une exécution échoue, relancer le job avec les mêmes paramètres identifiants crée une nouvelle JobExecution pour la même JobInstance, saute les steps déjà terminés et relance le step en échec. Les readers qui implémentent ItemStream (c'est le cas de tous les readers de fichiers et paginés, dès qu'on leur donne un name) stockent leur position dans l'execution context du step à chaque commit et repartent de là. Deux options modifient ce comportement : .allowStartIfComplete(true) sur un step le réexécute même après un succès, et .preventRestart() sur le job interdit toute reprise pour les jobs qui ne sont pas idempotents. Le startLimit d'un step plafonne le nombre de tentatives autorisées.

Piloter le flux

Par défaut, les steps s'exécutent en séquence. Les transitions conditionnelles aiguillent selon l'ExitStatus d'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 est distinct de BatchStatus : le batch status (COMPLETED, FAILED, STOPPED…) est ce qu'enregistre le repository, tandis que l'exit status est une chaîne qu'un listener ou un JobExecutionDecider peut positionner librement pour piloter l'aiguillage. Un decider est un bean qui implémente decide(JobExecution, StepExecution) et renvoie un FlowExecutionStatus ; c'est l'outil adapté quand la décision dépend des données plutôt que du résultat d'un step. Les listeners (JobExecutionListener, StepExecutionListener, ChunkListener, ItemReadListener, ItemProcessListener, ItemWriteListener, SkipListener) s'accrochent à chaque niveau du cycle de vie, et c'est là que doivent se trouver la journalisation, l'audit et les fichiers de rejets.

Montée en charge

Quand un step orienté chunks monothread n'est pas assez rapide, Spring Batch propose quatre options, à peu près par ordre de complexité :

  1. Step multithreadé. Donnez au step un TaskExecutor ; chaque thread traite ses propres chunks. Readers et writers doivent être thread-safe, ce qui exclut les curseurs et FlatFileItemReader (enveloppez ce dernier dans un SynchronizedItemStreamReader), et la reprise n'est plus fiable puisque l'ordre des éléments n'est pas défini.
  2. Steps parallèles. Des steps indépendants s'exécutent en même temps dans un split doté d'un TaskExecutor. Aucune contrainte de thread-safety à l'intérieur de chaque step : le parallélisme se situe entre les steps.
  3. Partitionnement. Un Partitioner découpe l'entrée en plages (plages d'identifiants, un fichier par partition, une région par partition), et un PartitionHandler exécute une copie du step worker par partition, avec sa propre StepExecution, localement sur des threads ou à distance sur d'autres JVM. Chaque partition peut reprendre indépendamment des autres. C'est la solution de référence pour les grosses tables.
  4. Remote chunking. Le manager lit les éléments et les envoie par messagerie ; les workers les traitent et les écrivent. Utile quand le goulot d'étranglement est le traitement, pas la lecture, et que l'entrée ne peut pas être partitionnée.

Voici à quoi ressemble un step partitionné 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();
}

Le reader du worker est en step scope et lit #{stepExecutionContext['minId']} et ['maxId'] pour construire sa requête. Avant tout cela, vérifiez que c'est bien la base de données, et non la JVM, qui limite le débit : un writer sans batching JDBC, ou un processor qui fait une requête par élément, est généralement plus lent que n'importe quel manque de threads.

Exécution et tests

spring-batch-test fournit @SpringBatchTest, qui enregistre un JobLauncherTestUtils et un JobRepositoryTestUtils dans un contexte @SpringBootTest. Lancez le job complet ou un seul step avec des paramètres, puis vérifiez la JobExecution obtenue :

@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);
    }
}

Positionnez spring.batch.job.enabled=false dans les propriétés de test pour que Boot ne lance pas aussi le job au démarrage du contexte. Côté exploitation, Spring Batch n'a plus de console web intégrée depuis l'abandon de Spring Batch Admin ; Spring Cloud Data Flow, un petit endpoint Actuator au-dessus du JobExplorer ou de simples requêtes SQL sur les tables BATCH_* sont les moyens habituels de voir ce qui s'est exécuté.

Erreurs courantes

Ajouter @EnableBatchProcessing en suivant un vieux tutoriel. Sur Boot 3, cela désactive l'auto-configuration ; les symptômes sont des beans JobRepository manquants ou des jobs qui ne se lancent jamais.

Oublier @StepScope sur les beans qui utilisent des paramètres de job. L'expression est résolue à null au démarrage du contexte, et l'échec se manifeste loin de sa cause.

Commiter à chaque élément. Une taille de chunk de 1, ou un writer qui ouvre sa propre transaction pour chaque élément, fait perdre toute raison d'utiliser un framework batch.

Lire avec JPA sans jamais vider le contexte de persistance. JpaPagingItemReader et JpaItemWriter s'en chargent à chaque chunk, mais un reader écrit à la main qui charge des entités dans une boucle fait grossir le cache de premier niveau jusqu'à épuisement de la mémoire.

Pagination non déterministe. Un reader paginé trié sur une colonne non unique saute ou duplique des lignes d'une page à l'autre. Triez sur la clé primaire.

Tout rendre ignorable. skip(Exception.class) avec une limite élevée transforme une corruption de données en un avertissement que personne ne lit. N'ignorez que des exceptions précises et comprises, et envoyez les rejets dans un endroit visible.

Lancer deux fois la même instance. Sans incrementer ni paramètre distinctif, le second lancement d'un job terminé échoue ; avec un run id partout, reprendre une instance en échec devient impossible puisque chaque lancement est nouveau. Utilisez des paramètres métier pour l'identité et réservez run.id aux jobs qui n'ont réellement aucune clé naturelle.

À retenir

  • Spring Batch organise le travail en jobs composés de steps et enregistre chaque exécution dans un JobRepository adossé à une base de données, ce qui rend possibles la reprise, l'audit et le décompte des skips.
  • Sur Spring Boot 3, utilisez JobBuilder et StepBuilder avec un JobRepository et un transaction manager explicites ; laissez @EnableBatchProcessing de côté.
  • Les steps orientés chunks lisent les éléments un par un, les traitent, puis les écrivent par chunks transactionnels ; la taille du chunk est le premier levier de réglage, et le batching JDBC la première source de vitesse.
  • Les beans en step scope reçoivent les paramètres de job et les valeurs de l'execution context suffisamment tard pour que ceux-ci existent ; les paramètres identifiants définissent la JobInstance.
  • Le skip et le retry rendent les steps tolérants aux enregistrements défectueux et aux erreurs transitoires ; la reprise relance une exécution en échec à partir du dernier commit.
  • Montez en charge avec le partitionnement pour les grosses tables, les steps multithreadés pour les readers thread-safe, les flux parallèles pour les steps indépendants, et le remote chunking quand le traitement domine.

Sources et lectures complémentaires

Questions fréquentes

Faut-il encore @EnableBatchProcessing avec Spring Boot 3 ?
Non, et l'ajouter change le comportement. Spring Boot 3 auto-configure le JobRepository, le JobLauncher et les beans associés à partir de votre DataSource ; @EnableBatchProcessing demande à Boot de se retirer complètement pour que vous preniez vous-même l'infrastructure en main. Ne le mettez pas, sauf si vous voulez délibérément configurer Spring Batch à la main, par exemple pour utiliser une DataSource dédiée aux tables de métadonnées.
Pourquoi mon job affiche-t-il « A job instance already exists and is complete » ?
Une JobInstance est identifiée par le nom du job et ses paramètres identifiants. Dès qu'une instance s'est terminée avec succès, Spring Batch refuse de la relancer avec les mêmes paramètres, pour vous protéger d'un double traitement. Passez un paramètre qui change à chaque exécution (un run id fourni par RunIdIncrementer, le nom du fichier en entrée, une date métier), ou déclarez un paramètre non identifiant s'il ne doit pas compter.
Peut-on faire tourner Spring Batch sans base de données ?
Spring Batch 5 a supprimé l'ancien job repository en mémoire à base de maps : le JobRepository standard a donc besoin d'une base relationnelle pour ses tables de métadonnées, et c'est aussi ce qui rend la reprise possible. En développement, une base H2 embarquée suffit. Les versions 5.x récentes ajoutent un repository sans ressource pour les cas où la reprise n'est pas nécessaire ; lisez les notes de version de celle que vous utilisez avant de vous y fier.
Quelle différence entre commit interval et page size ?
Le commit interval (la taille du chunk) est le nombre d'éléments traités avant l'appel au writer et le commit de la transaction. La page size appartient aux readers paginés comme JdbcPagingItemReader et fixe le nombre de lignes que ramène chaque requête. Les deux sont indépendants ; une erreur courante consiste à leur donner des valeurs différentes puis à s'étonner que les requêtes ne s'alignent pas sur les commits. Les égaler est une valeur par défaut raisonnable.