Low-Latency Trading Ledger: Optimizing Bulk Order Persistence
How to reduce write latency in a financial ledger using JDBC batching and well-tuned Hibernate sequences, with a practical production mindset.
This article is also available in Portuguese
Level: Senior — high-throughput persistence in financial systems with Hibernate. The focus here is the ledger/audit path (not the matching engine hot path).
Imagine you are building the persistence module of an Order Management System (OMS). During a market volatility spike, the execution engine sends a batch of 10,000 executed orders that must be stored for compliance, settlement, and audit.
If everything is handled as one insert per network round-trip, time disappears quickly. In high-volume scenarios, spending hundreds of milliseconds just to persist one batch is enough to pressure event buffers and start dropping real-time market updates.
The challenge
I want two things from this design:
- The order-processing thread (or worker) must return quickly to the next event.
- The transaction must avoid holding resources longer than necessary — less lock contention and lower chance of connection pool starvation when other parts of the system also need the database.
So it is not just “437 ms vs 77 ms” on paper; it is about keeping the whole system healthy under load.
JDBC batching for high throughput
In trading systems, database writes cannot become the strategy bottleneck. With JDBC batching, Hibernate groups multiple INSERTs (or compatible updates) and sends them in packs instead of one round-trip per row. Fewer network trips, less latency overhead.
Sequences and allocationSize (why I avoid IDENTITY here)
GenerationType.IDENTITY is convenient in regular CRUD flows, but for large batches it usually hurts: depending on driver/dialect behavior, Hibernate increases per-row cost to obtain generated IDs, which scales poorly when you push thousands of records.
What I usually do in this pattern is GenerationType.SEQUENCE with a higher allocationSize (in this example, 1000): Hibernate reserves an ID block from the sequence and distributes IDs in memory. Combined with batching, this changes the load profile significantly.
(In more formal material this often appears as HiLo/range reservation; same practical point: fewer sequence round-trips.)
Where this applies — and where I would not force it
| Scenario | Why it fits |
|---|---|
| Trading audit trail | Many execution events or FIX messages written to ledger in bursts. |
| Clearance & settlement | Positions and close events arriving in bulk after market close. |
| Risk management | Bulk updates of account exposure/limits. |
Where I do not use this as a one-size-fits-all pattern: flows where UI or upstream systems need immediate per-order persistence confirmation in the same request. In that case, transaction and ID strategy are usually different.
Implementation: TradeExecution ledger in Kotlin
application.yml
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 1000
batch_versioned_data: true
order_inserts: true
# In trading, updates (balances/positions) often interleave with inserts
# Ordering helps reduce deadlock surprises in some patterns
order_updates: true
datasource:
# PostgreSQL: often improves batch insert efficiency at driver level
url: jdbc:postgresql://localhost:5432/trading?reWriteBatchedInserts=true
Entity, DTO, repository, and service
import jakarta.persistence.*
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.time.Instant
@Entity
@Table(name = "trade_executions")
class TradeExecution(
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "trade_seq")
@SequenceGenerator(
name = "trade_seq",
sequenceName = "seq_trade_exec",
allocationSize = 1000
)
val id: Long? = null,
val symbol: String,
val price: BigDecimal,
val quantity: Long,
val side: String, // BUY / SELL
val timestamp: Instant = Instant.now()
)
data class TradeDTO(
val symbol: String,
val price: BigDecimal,
val quantity: Long,
val side: String
)
interface TradeExecutionRepository : org.springframework.data.jpa.repository.JpaRepository<TradeExecution, Long>
@Service
class TradePersistenceService(
private val repository: TradeExecutionRepository
) {
@Transactional
fun persistExecutions(executions: List<TradeDTO>) {
val entities = executions.map { dto ->
TradeExecution(
symbol = dto.symbol,
price = dto.price,
quantity = dto.quantity,
side = dto.side
)
}
// With batch_size=1000, Hibernate tends to pack inserts in JDBC batches
repository.saveAll(entities)
}
}
What logs showed (batch off vs on)
Without batch — “one row, one conversation”
[DEBUG] org.hibernate.SQL - insert into trade_executions (price, qty, side, symbol, id) values (?, ?, ?, ?, ?)
-- Network latency per execution (1–2 ms per row is common in many environments)
[DEBUG] org.hibernate.SQL - insert into trade_executions (price, qty, side, symbol, id) values (?, ?, ?, ?, ?)
...
Total: ~437 ms for 10k records.
With batch enabled
[DEBUG] org.hibernate.SQL - insert into trade_executions (price, qty, side, symbol, id) values (?, ?, ?, ?, ?)
[INFO] o.h.e.j.batch.internal.AbstractBatchImpl - Executing batch of 1000 statements
...
Total: ~77 ms for 10k records.
Numbers will vary with network, disk, driver, and payload size — the key point is the order-of-magnitude gain: batching + sequence tuning is often a strong lever before changing stack.
In my case, numbers came from the same environment, same payload, same database. To reproduce in your setup, run several iterations and compare median/p95.
Tradeoffs: batch vs unitary writes
| Batch + pre-allocated sequence | Unitary (one operation at a time) | |
|---|---|---|
| Throughput | Very high — good fit for bulk ingestion | Lower under same volume |
| Total transaction time | Usually lower | Usually higher |
| Memory usage | More entities held until flush | Lower peak memory per transaction |
Why this matters at scale
At 100k orders/s scale, gains are not only linear in elapsed time: you reduce connection pool starvation risk, so other routes can still acquire DB connections instead of waiting behind oversized transactions.
From a maintainability perspective, I still like Kotlin + JPA in this domain because code stays readable and auditable (important in regulated systems) — as long as Hibernate config is not left at tutorial defaults.
What I always check in code reviews
allocationSizealigned withbatch_size(or a multiple) — otherwise sequence fetches can happen mid-batch and hurt throughput.order_inserts— without it, alternating inserts across entities/tables can break batching frequently.reWriteBatchedInserts(PostgreSQL) — often a real, practical gain on insert throughput.- Practical verdict: in financial systems, persistence latency becomes a bottleneck quickly. Tuning Spring Data JPA + Hibernate for batch is often one of the best technical ROIs before moving to more complex architectures.
If you have seen similar behavior in your setup (different DB, different batch_size, driver quirks), feel free to reach out — real-world comparisons are where this topic gets really interesting.