Chunk Size
Increase chunk size to 500-1000 for large datasets

This guide covers performance optimization strategies for Spring Batch RS applications.
Chunk Size
Increase chunk size to 500-1000 for large datasets
Connection Pooling
Configure appropriate pool sizes for database operations
Buffering
Use buffered I/O for file operations
Batch Operations
Leverage bulk inserts and batch APIs
Chunk size determines how many items are processed before writing to the destination.
Small Chunks (10-50):
Large Chunks (500-1000):
| Use Case | Recommended Size | Reason |
|---|---|---|
| File to File | 500-1000 | Minimize I/O overhead |
| File to Database | 500-1000 | Batch inserts are efficient |
| Database to File | 100-500 | Balance query overhead and memory |
| Database to Database | 500-1000 | Bulk operations on both sides |
| Complex Processing | 100-200 | Manage memory for transformations |
| Testing/Development | 10-50 | Easier debugging |
// Small chunks for developmentlet step = StepBuilder::new("dev-process") .chunk::<Input, Output>(10) // Small for debugging .reader(&reader) .writer(&writer) .build();
// Large chunks for productionlet step = StepBuilder::new("prod-process") .chunk::<Input, Output>(1000) // Large for throughput .reader(&reader) .writer(&writer) .build();use sqlx::postgres::PgPoolOptions;
let pool = PgPoolOptions::new() .max_connections(10) // Pool size .min_connections(2) // Minimum idle connections .acquire_timeout(Duration::from_secs(30)) .idle_timeout(Duration::from_secs(600)) .max_lifetime(Duration::from_secs(1800)) .connect("postgres://...").await?;use sqlx::mysql::MySqlPoolOptions;
let pool = MySqlPoolOptions::new() .max_connections(10) .min_connections(2) .connect("mysql://...").await?;use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new() .max_connections(5) // SQLite has lower concurrency .connect("sqlite://...").await?;Pool Size Guidelines:
// Use appropriate page sizelet reader = RdbcItemReaderBuilder::<Record>::new() .postgres(pool) .query("SELECT * FROM large_table ORDER BY id") // ORDER BY for consistency .with_page_size(500) // Match or exceed chunk size .build_postgres();
// For very large datasets, add WHERE clauselet reader = RdbcItemReaderBuilder::<Record>::new() .postgres(pool) .query("SELECT * FROM large_table WHERE created_at > '2024-01-01' ORDER BY id") .with_page_size(1000) .build_postgres();// Use bulk inserts with appropriate chunk sizelet writer = RdbcItemWriterBuilder::<Record>::new() .postgres(&pool) .table("target_table") .column("field1", |r: &Record| r.field1.as_str().into()) .column("field2", |r: &Record| r.field2.as_str().into()) .column("field3", |r: &Record| r.field3.into()) .build_postgres();
let step = StepBuilder::new("bulk-insert") .chunk::<Record, Record>(1000) // Large chunks for bulk inserts .reader(&reader) .writer(&writer) .build();By default each write call completes its INSERT before returning, so the write
phase is a queue of one. with_concurrency(N) lets up to N chunk INSERTs be in
flight at once over the connection pool.
let writer = RdbcItemWriterBuilder::<Record>::new() .postgres(&pool) .table("target_table") .column("field1", |r: &Record| r.field1.as_str().into()) .column("field2", |r: &Record| r.field2.as_str().into()) .with_concurrency(4) // up to 4 chunk INSERTs in flight .build_postgres();cargo run --example benchmark_csv_postgres_xml --features csv,xml,rdbc-postgresScope. Only PostgreSQL and MySQL honour the setting. SQLite serialises writes
behind a database-level lock, so build_sqlite() logs a warning and ignores it.
SeaORM and MongoDB writers are unaffected.
1 000 000 rows, chunk 1 000, 8 columns, PostgreSQL 15, pool of 10 connections. The pipeline is the 3-step CSV → PostgreSQL → XML → PostgreSQL benchmark; concurrency applies to the two PostgreSQL write steps.
with_concurrency |
step 1 write | step 3 write | wall-clock | throughput |
|---|---|---|---|---|
| 1 (default) | 4.7 s | 5.0 s | 16.7 s | 59 934 rec/s |
| 2 | 1.2 s | 0.3 s | 7.3 s | 136 460 rec/s |
| 4 | 0.8 s | 0.1 s | 6.5 s | 154 776 rec/s |
| 8 | 0.7 s | 0.1 s | 6.2 s | 160 075 rec/s |
The write phase is what collapses — 4.7 s → 0.7 s on step 1, 5.0 s → 0.1 s on step 3. End-to-end that is 2.7× on the full pipeline (16.7 s → 6.2 s).
Note that the bottleneck moves rather than disappearing: at concurrency ≥ 2 both write steps become read-bound (step 3’s read share goes from 32 % to 92 %). Once writes are concurrent, further tuning belongs on the read side.
close() Fails the StepBefore 0.5.0, an error from ItemWriter::close() was logged as a warning and
discarded, so a step could report Success after its final flush had failed.
As of 0.5.0 a failing close() sets StepStatus::WriteError and the step
returns Err.
This is what makes concurrent writes safe: the last in-flight batch learns its
fate only at close(), and a swallowed error there would mean silent data loss.
open() is unchanged and keeps its non-fatal handling.
If a job of yours previously passed with a close error, it will now fail — that failure was always real, it was just not reported.
-- Good: Index on filter and order columnsCREATE INDEX idx_records_created_id ON records(created_at, id);
-- Query uses index effectivelySELECT * FROM records WHERE created_at > '2024-01-01' ORDER BY id;use std::io::BufReader;use std::fs::File;
// Inefficient: No bufferinglet file = File::open("large.csv")?;let reader = CsvItemReaderBuilder::new() .from_reader(file); // Direct file access
// Efficient: Buffered I/Olet file = File::open("large.csv")?;let buffered = BufReader::with_capacity(64 * 1024, file); // 64KB bufferlet reader = CsvItemReaderBuilder::new() .from_reader(buffered);use std::io::BufWriter;
// Efficient: Buffered writinglet file = File::create("output.csv")?;let buffered = BufWriter::with_capacity(64 * 1024, file);let writer = CsvItemWriterBuilder::new() .from_writer(buffered);For very large files, consider memory-mapped I/O:
use memmap2::Mmap;use std::fs::File;
let file = File::open("huge.csv")?;let mmap = unsafe { Mmap::map(&file)? };let reader = CsvItemReaderBuilder::new() .from_reader(&mmap[..]);use sysinfo::{System, SystemExt};
fn log_memory_usage() { let mut system = System::new_all(); system.refresh_all();
let used = system.used_memory(); let total = system.total_memory();
println!("Memory: {:.2} GB / {:.2} GB ({:.1}%)", used as f64 / 1_000_000_000.0, total as f64 / 1_000_000_000.0, (used as f64 / total as f64) * 100.0 );}1. Clone Only When Necessary:
// Inefficient: Cloning after taking by valueimpl ItemProcessor<Record, Record> for MyProcessor { fn process(&self, item: Record) -> ItemProcessorResult<Record> { let mut owned = item; owned.field = transform(owned.field); Ok(Some(owned)) }}
// Efficient: Transform and moveimpl ItemProcessor<Record, Record> for MyProcessor { fn process(&self, item: Record) -> ItemProcessorResult<Record> { Ok(Some(Record { field: transform(item.field), ..item })) }}2. Use References for Large Strings:
use std::borrow::Cow;
struct Processor;
impl ItemProcessor<String, String> for Processor { fn process(&self, item: String) -> ItemProcessorResult<String> { // Use Cow to avoid unnecessary cloning let result: Cow<str> = if item.contains("special") { Cow::Owned(item.to_uppercase()) } else { Cow::Owned(item) };
Ok(Some(result.into_owned())) }}3. Stream Processing:
// Process items one at a time without bufferinglet step = StepBuilder::new("stream-process") .chunk::<Input, Output>(1) // Process immediately .reader(&reader) .processor(&processor) .writer(&writer) .build();Spring Batch RS uses a single-threaded model per step execution. For parallel processing:
Option 1: Multiple Job Instances
use std::thread;
fn main() -> Result<(), Box<dyn std::error::Error>> { let handles: Vec<_> = (0..4).map(|i| { thread::spawn(move || { process_partition(i) }) }).collect();
for handle in handles { handle.join().unwrap()?; }
Ok(())}
fn process_partition(partition_id: usize) -> Result<(), Box<dyn std::error::Error>> { // Each thread processes a partition let reader = CsvItemReaderBuilder::new() .from_path(format!("data_part_{}.csv", partition_id))?;
// ... process partition Ok(())}Option 2: Async Steps with Tokio
use tokio::task;
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let tasks = (0..4).map(|i| { task::spawn(async move { process_partition(i).await }) });
futures::future::try_join_all(tasks).await?;
Ok(())}use std::time::Instant;
let start = Instant::now();
let mut execution = StepExecution::new("process");step.execute(&mut execution)?;
let duration = start.elapsed();let items = execution.read_count();let throughput = items as f64 / duration.as_secs_f64();
println!("Processed {} items in {:?}", items, duration);println!("Throughput: {:.2} items/sec", throughput);println!("Write count: {}", execution.write_count());println!("Skip count: {}", execution.skip_count());Every chunk-oriented step logs where its time went. This is an info! record, so it
only appears if a logger is initialised at info level or below (for example
RUST_LOG=info with env_logger):
Step 'load-postgres' 42.3s — read 18.1s (43%) | process 2.4s (6%) | write 21.1s (50%) | flush 0.7s (2%)The same numbers are available programmatically:
let job = JobBuilder::new().start(&step).build();job.run()?;
let metrics = job.get_step_execution("load-postgres").unwrap();println!("{}", metrics.phase_summary());println!("read: {:?}", metrics.read_duration);Percentages are relative to the step’s total duration. The four phases do not sum to
100%. The remainder covers the writer’s open() and close() — which is where the CSV,
JSON and XML writers perform their final flush — the teardown of each processed chunk, and
the framework’s own bookkeeping. A large remainder is itself worth investigating.
Only chunk-oriented steps populate these fields. On a tasklet step every phase reads
0.0s (0%).
Timing is recorded once per chunk, not once per item, so the overhead stays negligible even on multi-million-row jobs.
[dev-dependencies]criterion = "0.5"use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn benchmark_processor(c: &mut Criterion) { let processor = MyProcessor::new();
c.bench_function("process item", |b| { b.iter(|| { let item = black_box(create_test_item()); processor.process(&item) }) });}
criterion_group!(benches, benchmark_processor);criterion_main!(benches);# Install cargo-flamegraphcargo install flamegraph
# Run with profilingcargo flamegraph --bin my-batch-job
# Open flamegraph.svg in browser// Slow: Small chunks, no buffering, unnecessary cloneslet reader = CsvItemReaderBuilder::new() .from_path("data.csv")?; // No buffering
let processor = SlowProcessor; // Clones everything
let writer = RdbcItemWriterBuilder::<Record>::new() .postgres(&small_pool) // 2 connections only .table("target") .column("field1", |r: &Record| r.field1.as_str().into()) .build_postgres();
let step = StepBuilder::new("slow") .chunk::<Record, Record>(10) // Tiny chunks .reader(&reader) .processor(&processor) .writer(&writer) .build();
// Result: 100 items/secuse std::io::BufReader;
// Fast: Large chunks, buffered I/O, optimized poollet file = File::open("data.csv")?;let buffered = BufReader::with_capacity(64 * 1024, file);let reader = CsvItemReaderBuilder::new() .from_reader(buffered);
let processor = FastProcessor; // Minimal cloning
let pool = PgPoolOptions::new() .max_connections(20) // Larger pool .connect("...").await?;
let writer = RdbcItemWriterBuilder::<Record>::new() .postgres(&pool) .table("target") .column("field1", |r: &Record| r.field1.as_str().into()) .column("field2", |r: &Record| r.field2.into()) .build_postgres();
let step = StepBuilder::new("fast") .chunk::<Record, Record>(1000) // Large chunks .reader(&reader) .processor(&processor) .writer(&writer) .build();
// Result: 10,000 items/sec (100x improvement!)Check:
Check:
Check: