Streaming reactivo con Oracle SQL y Jakarta EE: Cómo procesar 1M de registros sin agotar el Heap
En sistemas bancarios con bases de datos heredadas, migrar procesos monolíticos batch a microservicios requiere replantear tanto el acceso a datos como la concurrencia en la máquina virtual Java.
1. El problema: el reporteador en Visual Basic y el cuello de botella
En COOPAC Los Andes, el reporteador financiero operaba sobre una aplicación monolítica en Visual Basic. El proceso ejecutaba consultas SQL de más de 300 líneas con múltiples JOINs y agregaciones complejas sobre tablas con millones de registros de cuentas y transacciones.
- Tiempos de ejecución superiores a 4 horas y 15 minutos.
- Bloqueos de filas y contención en la base de datos Oracle durante horas hábiles.
- Caídas intempestivas por desborde de memoria (
OutOfMemoryError) cuando el volumen superaba los 800,000 registros.
2. Por qué la paginación con OFFSET falla catastróficamente
La primera alternativa que muchos desarrolladores consideran es la paginación tradicional vía OFFSET / FETCH FIRST n ROWS ONLY. Sin embargo, en bases de datos relacionales, OFFSET n tiene complejidad algorítmica $O(n)$:
Para saltarse 900,000 filas en una página posterior, el motor de Oracle debe leer 900,000 bloques lógicos del disco y descartarlos. Conforme avanza la paginación, el tiempo por página se degrada exponencialmente.
La única solución escalable con memoria constante era el cursor streaming con un único puntero de lectura continuo.
3. La solución: Sintonización de JDBC FetchSize y Reactive Streams
Por defecto, el driver JDBC de Oracle utiliza un defaultRowPrefetch de tan solo 10 filas. Para 1,000,000 de registros, esto representa 100,000 viajes de red (roundtrips) entre la base de datos y el servidor de aplicaciones Jakarta EE.
// Configuración del Cursor en Jakarta EE JDBC
preparedStatement.setFetchSize(2500);
preparedStatement.setFetchDirection(ResultSet.FETCH_FORWARD);
// Conversión a Stream reactivo con Backpressure
try (Stream<MovimientoDTO> stream = resultSetMapper.stream(resultSet)) {
stream.gather(Gatherers.windowFixed(2500))
.forEach(chunk -> microserviceClient.processBatch(chunk));
}
Al elevar el FetchSize a 2,500 filas, los roundtrips de red se redujeron de 100,000 a tan solo 400. Al mismo tiempo, el búfer de 2,500 objetos DTO en la memoria de la JVM ocupaba menos de 25 MB en cualquier instante, manteniendo el Heap en un régimen perfectamente estable de 380 MB.
4. Optimización de Oracle SQL: de Cost 42,850 a Cost 124
La consulta original carecía de índices compuestos apropiados, forzando un TABLE ACCESS FULL. Mediante trazas AWR y SQL Plan Baselines:
- Se introdujo el índice compuesto
IDX_MOV_FECHA_ESTADO (FECHA_CORTE, ESTADO_CONCIL). - Se forzó el acceso por
INDEX RANGE SCANlimitando las lecturas lógicas de 1,840,200 bloques a solo 2,500 por lote. - El costo del optimizador de Oracle CBO disminuyó en un 99.7%.
5. Resultados medidos y conclusiones
[ MÉTRICAS DE PRODUCCIÓN VERIFICADAS ]
- ⏱️ Tiempo total: De 4h 15m a 14m 20s (94.4% de reducción).
- 🧠 Memoria Heap: De 3.8 GB a 380 MB estables.
- 🚀 Throughput: De ~65 registros/segundo a >1,200 registros/segundo.