Aller au contenu

Performance Tuning Guide

Ce contenu n’est pas encore disponible dans votre langue.

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):

  • Lower memory usage
  • More frequent I/O operations
  • Better for debugging
  • More transaction overhead

Large Chunks (500-1000):

  • Higher memory usage
  • Fewer I/O operations
  • Better throughput
  • Fewer transaction commits
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 development
let step = StepBuilder::new("dev-process")
.chunk::<Input, Output>(10) // Small for debugging
.reader(&reader)
.writer(&writer)
.build();
// Large chunks for production
let 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?;

Pool Size Guidelines:

  • Single reader + single writer: 5-10 connections
  • Multiple concurrent jobs: 10-20 connections
  • High concurrency: 20-50 connections
  • Don’t exceed database max_connections limit
// Use appropriate page size
let 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 clause
let 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 size
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())
.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();
Terminal window
cargo run --example benchmark_csv_postgres_xml --features csv,xml,rdbc-postgres

Scope. 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.

Breaking Change in 0.5.0 — a Failing close() Fails the Step

Section titled “Breaking Change in 0.5.0 — a Failing close() Fails the Step”

Before 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 columns
CREATE INDEX idx_records_created_id ON records(created_at, id);
-- Query uses index effectively
SELECT * FROM records WHERE created_at > '2024-01-01' ORDER BY id;

use std::io::BufReader;
use std::fs::File;
// Inefficient: No buffering
let file = File::open("large.csv")?;
let reader = CsvItemReaderBuilder::new()
.from_reader(file); // Direct file access
// Efficient: Buffered I/O
let file = File::open("large.csv")?;
let buffered = BufReader::with_capacity(64 * 1024, file); // 64KB buffer
let reader = CsvItemReaderBuilder::new()
.from_reader(buffered);
use std::io::BufWriter;
// Efficient: Buffered writing
let 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 value
impl 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 move
impl 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 buffering
let 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:

reading step metrics
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);
Terminal window
# Install cargo-flamegraph
cargo install flamegraph
# Run with profiling
cargo flamegraph --bin my-batch-job
# Open flamegraph.svg in browser

// Slow: Small chunks, no buffering, unnecessary clones
let 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/sec
use std::io::BufReader;
// Fast: Large chunks, buffered I/O, optimized pool
let 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:

  1. Chunk size too small
  2. Missing database indexes
  3. Network latency (database/FTP)
  4. Expensive processor operations
  5. Small connection pool

Check:

  1. Chunk size too large
  2. Processor accumulating state
  3. Memory leaks in custom implementations
  4. Large strings not using Cow
  5. Unbounded collections

Check:

  1. Not using bulk inserts
  2. Individual INSERT statements
  3. Transaction overhead (too frequent commits)
  4. Missing indexes on target table
  5. Constraints/triggers slowing inserts