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
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:
- A thread (ou o worker) que processa ordem volte rápido pro estado de espera / próximo evento.
- 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ário | Por que faz sentido |
|---|---|
| Audit trail de trading | Muitos eventos de execução ou mensagens FIX indo pro ledger de uma vez. |
| Clearance & settlement | Posições e fechamentos entrando em massa após o pregão. |
| Risk management | Atualizaçã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 reservada | Unitário (uma operação por vez) | |
|---|---|---|
| Throughput | Muito alto — combina com ingestão em lote | Baixo sob o mesmo volume |
| Tempo total na transação | Tende a ser menor | Tende a ser maior |
| Memória | Segura mais entidades no flush — troca explícita | Menor 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
allocationSizealinhado aobatch_size(ou múltiplo) — senão você pode tomar ida extra à sequência no meio do processamento e estragar o ritmo do lote.order_inserts— sem isso, se você intercala insert em entidades/tabelas diferentes (ex.:Tradee depoisTaxLog), o Hibernate quebra o batch a cada troca de “tipo” de persistência.reWriteBatchedInserts(PostgreSQL) — costuma fazer diferença real no driver quando o objetivo é throughput de insert.- 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.