Backend

Low-Latency Trading Ledger: Optimizing Bulk Order Persistence

✍️ Taylson Martinez
10 min read
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

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

  1. The order-processing thread (or worker) must return quickly to the next event.
  2. 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

ScenarioWhy it fits
Trading audit trailMany execution events or FIX messages written to ledger in bursts.
Clearance & settlementPositions and close events arriving in bulk after market close.
Risk managementBulk 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 sequenceUnitary (one operation at a time)
ThroughputVery high — good fit for bulk ingestionLower under same volume
Total transaction timeUsually lowerUsually higher
Memory usageMore entities held until flushLower 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

  1. allocationSize aligned with batch_size (or a multiple) — otherwise sequence fetches can happen mid-batch and hurt throughput.
  2. order_inserts — without it, alternating inserts across entities/tables can break batching frequently.
  3. reWriteBatchedInserts (PostgreSQL) — often a real, practical gain on insert throughput.
  4. 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.