Parallelisierung klingt verführerisch: mehr Threads, mehr Durchsatz. In Spring Batch ist das aber kein Schalter, sondern eine Architektur-Entscheidung. Viele Reader/Writer sind stateful (sie merken sich Positionen) und nicht thread-safe (sie können nicht gleichzeitig von mehreren Threads benutzt werden). Wenn du das ignorierst, bekommst du Race-Conditions (konkurrierende Zugriffe mit Zufallseffekten), doppelte Daten oder unsichtbare Verluste.
In diesem Teil zeigen wir, wie Multi-threaded Steps funktionieren, wann sie sinnvoll sind und welche Risiken du aktiv managen musst.
Zielbild
Nach diesem Kapitel kannst du Multi-threaded Steps korrekt konfigurieren, thread-safe Komponenten erkennen und entscheiden, wann Parallelisierung sinnvoll ist und wann nicht.
Das Prinzip
Ein Multi-threaded Step nutzt einen TaskExecutor (Thread-Pool), um mehrere
Chunks parallel zu verarbeiten. Der Step bleibt ein Step, aber die
Chunk-Verarbeitung läuft parallel. Beispiel: 4 Threads verarbeiten
gleichzeitig unterschiedliche Chunks desselben Steps.
Wichtig: Es gibt nur eine StepExecution. Alle Threads schreiben in
dieselben Zähler und teilen sich dieselbe Step-Logik. Das heißt:
@StepScope erzeugt keine getrennten Reader-Instanzen pro Thread.
Wenn du echte Isolation brauchst, ist Partitioning die richtige Wahl.
Minimal-Konfiguration
Ausschnitt: Bean-Methoden in einer @Configuration-Klasse.
import org.springframework.batch.core.Step;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.transaction.PlatformTransactionManager;
@Bean
public TaskExecutor batchTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("spring-batch-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.initialize();
return executor;
}
@Bean
public Step importStep(TaskExecutor batchTaskExecutor,
JobRepository jobRepository,
PlatformTransactionManager transactionManager) {
return new StepBuilder("importStep", jobRepository)
.<RawCustomerRow, Customer>chunk(200)
.transactionManager(transactionManager)
.reader(reader())
.processor(processor())
.writer(writer())
.taskExecutor(batchTaskExecutor)
.build();
}
Das ist die technische Aktivierung. Die eigentliche Arbeit ist aber: Thread-Safety garantieren.
Executor-Design und Backpressure
Der TaskExecutor bestimmt, wie viele Chunks parallel laufen. Ein
ThreadPoolTaskExecutor mit begrenzter Queue verhindert unkontrolliertes
Wachstum. Wenn die Queue voll ist, musst du entscheiden: blockieren,
verwerfen oder den Pool vergrößern. Das ist Backpressure und sollte
bewusst konfiguriert sein.
Einfach gesagt: Mehr Threads ohne ausreichend DB-Connections oder I/O bringen keinen Durchsatz, sondern nur Warteschlangen.
Thread-Safety: was schiefgeht
Typische Probleme sind ein Reader, der die falsche Position liest und Daten doppelt verarbeitet, ein Writer, der denselben Chunk doppelt schreibt und Duplikate erzeugt, sowie Shared State im Processor, der zu nicht deterministischen Ergebnissen führt. Beispiel: Zwei Threads lesen dieselbe Zeile, weil der Reader den Fortschritt nicht sauber teilt.
Viele Standard-Reader (z. B. FlatFileItemReader) sind stateful und
für Single-Thread gedacht. In Multi-threaded Steps teilen sich alle Threads
eine Reader-Instanz. @StepScope hilft hier nicht, weil es nur eine
Step-Execution gibt. Lösungen sind ein SynchronizedItemStreamReader-Wrapper
oder der Wechsel zu Partitioning (Teil 11), wo jeder Thread seine eigene
Step-Execution und damit eigene Komponenten bekommt.
SynchronizedItemStreamReader: pragmatischer Fix
Wenn du einen stateful Reader verwenden musst, kannst du ihn synchronisieren. Das macht ihn thread-safe, aber serialisiert das Lesen:
@Bean
public SynchronizedItemStreamReader<Customer> synchronizedReader(
FlatFileItemReader<Customer> delegate) {
return new SynchronizedItemStreamReaderBuilder<Customer>()
.delegate(delegate)
.build();
}
Das ist ein Sicherheitsnetz, aber keine Skalierungsstrategie. Der Durchsatz steigt dann oft nur über parallelisierte Processing- und Write-Phasen.
Wann Multi-threaded Steps sinnvoll sind
Sinnvoll ist Parallelisierung, wenn der Processor der Engpass ist, wenn Reader und Writer thread-safe oder idempotent sind und wenn die Datenquelle paralleles Lesen unterstützt.
Nicht sinnvoll ist sie bei File-Readern ohne Partitioning, wenn die DB der Engpass ist oder wenn du nicht sauber messen kannst.
Ressourcen-Pooling ist Pflicht
Mehr Threads bedeuten mehr Bedarf an DB-Connections, Thread-Pool-Size und Memory. Beispiel: 8 Threads ohne passenden Connection-Pool blockieren sich gegenseitig und werden langsamer statt schneller.
Faustregel: DB-Pool-Größe ≥ Thread-Anzahl. Wenn du das nicht skalierst, erzeugst du mehr Contention statt mehr Durchsatz. Prüfe außerdem die Queue-Tiefe des Thread-Pools im Monitoring.
Fehlerbilder und Diagnose
Typische Symptome falscher Parallelisierung sind:
- Duplikate oder Lücken bei identischen Inputs
- Nicht-deterministische Counts (Read/Write schwankt pro Run)
- Steigende Laufzeit trotz mehr Threads
Wenn diese Muster auftreten, prüfe zuerst Reader/Writer-Thread-Safety und den Connection-Pool. Danach erst Parameter drehen.
Artefakt: Thread-Safety-Checkliste
- [ ] Reader thread-safe oder pro Thread isoliert?
- [ ] Writer idempotent oder mit eindeutigen Keys abgesichert?
- [ ] Processor ohne shared mutable state?
- [ ] DB-Pool-Größe passend zur Threadzahl?
- [ ] Monitoring pro Thread/Step aktiv?
Anti-Patterns
Ein TaskExecutor ohne Thread-Safety-Prüfung führt zu klassischen Race-Conditions. Parallelisierung bei DB-Engpass verschlechtert den Durchsatz. Unbegrenzte Executor (z. B. SimpleAsyncTaskExecutor ohne Limit) erzeugen unkontrolliert Threads.
Kurzfazit
Parallelisierung ist mächtig, aber riskant. Ohne Thread-Safety hast du unzuverlässige Ergebnisse. Für File-Imports ist Partitioning oft die bessere Wahl und kommt als nächstes.
Im nächsten Teil geht es um Partitioning: Wie du die Arbeit deterministisch aufteilst und Step-Scoped Reader/Writer sauber einsetzt.
Alle Teile der Serie: Serie: Spring Batch