Backend

Low-Latency Trading Ledger: Otimizando a Persistência de Ordens em Massa

✍️ Taylson Martinez
10 min read
Low-Latency Trading Ledger: Otimizando a Persistência de Ordens em Massa

Como reduzir latência de escrita em um ledger financeiro com JDBC batching e sequência bem configurada no Hibernate, com foco prático em produção.

🌐

Este artigo também está disponível em inglês

Ler em Inglês →

Nível: Senior — persistência em alto throughput, domínio financeiro e Hibernate. O foco aqui é o caminho de ledger/auditoria (não o hot path do matching engine).

Imagine que você está no módulo de persistência de um Order Management System (OMS). Num pico de volatilidade, o motor de execução manda um lote de 10.000 ordens executadas que precisam ir pro banco — conformidade, liquidação, auditoria.

Se tudo for tratado como um insert isolado por viagem de rede, o tempo some rápido. Em cenário de alto volume, ficar na casa de centenas de milissegundos só pra persistir o lote é receita pra pressionar buffer de eventos e começar a perder atualização de mercado em tempo real.

O desafio

O que eu quero nesse desenho é duplo:

  1. A thread (ou o worker) que processa ordem volte rápido pro estado de espera / próximo evento.
  2. A transação não fique segurando recurso à toa — menos lock contention e menos chance de starvation no connection pool quando outras partes do sistema também precisam do banco.

Ou seja: não é só “437 ms vs 77 ms” no papel; é liberar o sistema pra continuar respirando sob carga.

JDBC batching em alto throughput

Em trading, escrita no banco não pode ser o gargalo da estratégia. Com JDBC batching, o Hibernate agrupa vários INSERT (ou updates compatíveis) e envia em pacotes, em vez de um round-trip por linha. Menos ida e volta, menos tempo dominado por latência de rede.

Sequências e allocationSize (por que eu evito IDENTITY aqui)

GenerationType.IDENTITY é prático no CRUD comum, mas em lote grande ele me incomoda: em muitos cenários de driver/dialeto, o Hibernate acaba aumentando o custo por linha para obter ID gerado, e isso escala mal quando você quer empurrar milhares de registros.

O que eu costumo fazer nesse padrão é GenerationType.SEQUENCE com allocationSize alto (no exemplo, 1000): o Hibernate reserva um bloco de IDs na sequência e vai distribuindo em memória. É o tipo de ajuste que, junto com o batch, muda o perfil de carga no banco.

(Em materiais mais formais isso aparece ligado a ideia de HiLo / reserva de range — o ponto prático é o mesmo: menos round-trip pra sequência.)

Onde isso se aplica — e onde eu não forçaria

CenárioPor que faz sentido
Audit trail de tradingMuitos eventos de execução ou mensagens FIX indo pro ledger de uma vez.
Clearance & settlementPosições e fechamentos entrando em massa após o pregão.
Risk managementAtualização em lote de limites / exposição por conta.

Onde eu não uso esse modelo como “padrão único”: fluxo em que a UI ou outro sistema precisa do “OK, essa ordem específica já está persistida” na mesma requisição, com confirmação síncrona em uma linha — aí o desenho de transação e de ID costuma ser outro.

Implementação: ledger de TradeExecution em Kotlin

application.yml

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 1000
          batch_versioned_data: true
        order_inserts: true
        # Em trading costuma ter update de saldo / posição misturado com insert — ordenar ajuda a evitar surpresa com deadlock
        order_updates: true
  datasource:
    # PostgreSQL: ajuda o driver a reescrever inserts em lote de forma mais eficiente
    url: jdbc:postgresql://localhost:5432/trading?reWriteBatchedInserts=true

Entidade, DTO, repositório e serviço

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
            )
        }
        // Com batch_size=1000, o Hibernate tende a empacotar inserts em lotes JDBC em vez de um statement por linha
        repository.saveAll(entities)
    }
}

O que os logs mostraram (com batch desligado vs ligado)

Sem batch — padrão “uma linha, uma conversa”

[DEBUG] org.hibernate.SQL - insert into trade_executions (price, qty, side, symbol, id) values (?, ?, ?, ?, ?)
-- Latência de rede por execução (ordem de 1–2 ms por linha não é incomum)
[DEBUG] org.hibernate.SQL - insert into trade_executions (price, qty, side, symbol, id) values (?, ?, ?, ?, ?)
...
Total: ~437 ms para 10k registros.

Com batch ativo

[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 para 10k registros.

Os números mudam com rede, disco, driver e tamanho real do registro — o que eu quis registrar aqui é a ordem de grandeza: batch + sequência bem dimensionada costuma ser uma alavanca forte antes mesmo de trocar de stack.

No meu caso, os números vieram no mesmo ambiente, com mesmo payload e mesma base. Se você quiser reproduzir no seu contexto, rode algumas iterações e use mediana/p95 para comparar.

Tradeoffs: batch vs gravação “unitária”

Batch + sequência reservadaUnitário (uma operação por vez)
ThroughputMuito alto — combina com ingestão em loteBaixo sob o mesmo volume
Tempo total na transaçãoTende a ser menorTende a ser maior
MemóriaSegura mais entidades no flush — troca explícitaMenor pico de memória por transação

Por que isso importa em escala

Quando alguém descreve 100k ordens/s, o ganho de performance não é só linear no relógio: você reduz chance de connection pool starvation — ou seja, outras rotas do sistema ainda conseguem pegar conexão e não ficam na fila atrás de um monstro de transação mal dimensionada.

Do lado de manutenibilidade, eu ainda gosto de Kotlin + JPA nesse tipo de sistema porque o código continua legível e auditável (coisa que pesa em domínio regulado) — desde que a configuração do Hibernate não seja “default do tutorial”.

Pontos que eu não deixo passar em revisão de código

  1. allocationSize alinhado ao batch_size (ou múltiplo) — senão você pode tomar ida extra à sequência no meio do processamento e estragar o ritmo do lote.
  2. order_inserts — sem isso, se você intercala insert em entidades/tabelas diferentes (ex.: Trade e depois TaxLog), o Hibernate quebra o batch a cada troca de “tipo” de persistência.
  3. reWriteBatchedInserts (PostgreSQL) — costuma fazer diferença real no driver quando o objetivo é throughput de insert.
  4. Veredito prático: em sistemas financeiros, latência de persistência vira gargalo rápido. Ajustar Spring Data JPA + Hibernate pra batching costuma ser um dos melhores ROIs técnicos antes de partir para soluções mais complexas.

Se você já passou por cenário parecido (outro SGBD, outro batch_size, pegadinha de driver), comenta ou me chama — eu curto ver como isso se comporta fora do exemplo de laboratório.