Spring Boot en producción real: lo que mi codebase de Lakaut me enseñó que la documentación oficial omite
Un datasource pool es básicamente como la boletería de un recital de Soda Stereo. Cuando hay poca gente, funciona perfecto — cada uno llega, saca su lugar, entra. Pero cuando el estadio se llena de golpe y hay 300 personas querando entrar a la vez, el sistema colapsa. No porque esté roto. Porque nunca fue diseñado para ese momento de pico. Y la documentación oficial de Spring Boot te muestra la boletería vacía. Nunca te muestra el recital.
Eso es exactamente lo que encontré en Lakaut Hub — el sistema core de Lakaut AC, la autoridad de certificación digital donde trabajo como arquitecto. Producción real. Carga real. Logs que no mienten.
Mi tesis es incómoda: Spring Boot está documentado para un entorno idealizado que no existe en plataformas PaaS como Railway. Los defaults están pensados para desarrollo local con recursos infinitos, y en producción con JVM tuning real y conexiones PostgreSQL bajo carga, esos defaults te van a quemar. Lo sé porque tengo los logs.
El problema con spring.jpa.open-in-view que nadie te explica en serio
Cuando arrancé con Lakaut Hub, la app arrancaba, funcionaba, y en el log había una advertencia que ignoré durante semanas:
WARN o.s.b.autoconfigure.orm.jpa.JpaBaseConfiguration$JpaWebConfiguration
- spring.jpa.open-in-view is enabled by default.
Therefore, database queries may be performed during view rendering.
Explicitly configure spring.jpa.open-in-view to disable this warning
open-in-view=true es el default. Lo que eso significa en la práctica: la sesión de Hibernate queda abierta durante todo el ciclo de vida del request HTTP, desde que entra el pedido hasta que se termina de renderizar la respuesta. La doc lo menciona. Lo que no te dice es cuánto te cuesta eso en términos de conexiones de datasource pool retenidas bajo carga.
Medí esto directamente en Lakaut Hub con Actuator habilitado:
# application.yml — antes del fix
spring:
jpa:
open-in-view: true # default silencioso que te come conexiones
Con un endpoint que hacía múltiples consultas JPA, cada request retenía una conexión del pool desde que entraba hasta que salía la respuesta JSON — incluyendo cualquier lógica de negocio, validaciones y serializaciones que no necesitaban la base de datos para nada. Con 50 requests concurrentes en momentos de pico en Lakaut Hub, empezamos a ver timeouts de adquisición de conexión del pool. No era un bug de la app. Era el default.
El fix es una línea, pero el entendimiento es lo que importa:
# application.yml — después del fix
spring:
jpa:
open-in-view: false # liberás la conexión al pool apenas terminás con la DB
datasource:
hikari:
maximum-pool-size: 10 # para Railway: no sobrepasar lo que el plan soporta
minimum-idle: 5 # no arrancar desde cero en cada pico
connection-timeout: 20000 # 20s antes de tirar HikariTimeoutException
idle-timeout: 300000 # liberar conexiones ociosas a los 5 min
max-lifetime: 1200000 # 20 min máximo de vida por conexión
El tiempo de respuesta p95 del endpoint más crítico de Lakaut Hub bajó notoriamente después de este cambio. No tengo un número mágico para mostrarte porque las condiciones de carga varían, pero la tendencia en los logs de Actuator fue clara e inmediata. Si trabajan con JPA en Railway, apaguen open-in-view desde el día uno.
JVM tuning en Railway: los defaults te matan en contenedores
Acá está el gotcha más peligroso y el que más tiempo me llevó entender.
Railway corre la JVM dentro de un contenedor. La JVM, por default, lee los recursos del host físico, no del contenedor. En 2026 esto está mayormente resuelto con las container-aware flags, pero el problema es más sutil: Spring Boot no te dice explícitamente qué flags pasarle a la JVM, y los defaults del garbage collector no están pensados para un contenedor con 512MB o 1GB de RAM.
Cuando desplegué Lakaut Hub por primera vez en Railway, el startup time era errático:
# Log de Railway — startup sin tuning
Started LakautHubApplication in 18.432 seconds (process running for 19.1)
Dieciocho segundos. Para un servicio que tiene que estar disponible y responder certificaciones digitales. Inaceptable.
El problema era doble: heap sizing automático que no respetaba los límites del contenedor, y el GC default (G1GC) con configuración pensada para heaps grandes. Ajusté el Dockerfile y el JAVA_OPTS de Railway:
# Dockerfile — Lakaut Hub
FROM eclipse-temurin:21-jre-alpine
# Copiamos el jar del stage de build
COPY --from=builder /app/target/lakaut-hub.jar app.jar
# Flags explícitas para contenedor: le decimos a la JVM que lea los límites del contenedor
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:InitialRAMPercentage=50.0", \
"-XX:+UseZGC", \
"-XX:+ZGenerational", \
"-Dspring.profiles.active=production", \
"-jar", "app.jar"]
Por qué ZGC y no G1GC: en un contenedor con memoria limitada, las pausas de G1GC se vuelven impredecibles bajo carga. ZGC con generational mode (disponible desde Java 21) tiene pausas sub-milisegundo y funciona mejor en ambientes donde el heap está acotado. No es teoría — lo medí en Railway con logs de startup:
# Log de Railway — después del tuning
Started LakautHubApplication in 6.891 seconds (process running for 7.4)
De 18 segundos a 7. Sin tocar una línea de código de negocio. Solo flags de JVM y un cambio de GC.
La documentación oficial de Spring Boot no habla de esto. Hay una sección de "Optimizing Startup Time" que menciona lazy initialization, pero el tuning de JVM para contenedores en PaaS específicos no está. Estás solo, con los logs y el trial and error.
El gotcha de @Transactional con proxies que me costó un incidente
Este es el que más vergüenza da documentar, pero también el más útil.
Spring Boot implementa @Transactional mediante proxies de AOP. La regla básica es que si llamás un método @Transactional desde dentro de la misma clase, el proxy se bypasea y la transacción no existe. Lo dice la documentación. Lo que no dice es en qué escenarios reales esto explota silenciosamente.
En Lakaut Hub tenemos un servicio de emisión de certificados digitales. Simplificado, se veía así:
@Service
public class CertificadoService {
// Este método SÍ tiene transacción — lo llaman desde afuera
@Transactional
public void emitirCertificado(EmisionRequest request) {
validarRequest(request);
persistirCertificado(request);
// ERROR SILENCIOSO: esto llama a un método de la misma clase
notificarEmision(request);
}
// Este método también tiene @Transactional, pero NUNCA va a participar
// en una transacción separada porque Spring no puede interceptarlo —
// se llama directamente (this.notificarEmision), no a través del proxy
@Transactional(propagation = Propagation.REQUIRES_NEW)
private void notificarEmision(EmisionRequest request) {
// Queríamos que esto corriera en su propia transacción
// para que un fallo acá no rollbackeara la emisión del certificado.
// No pasaba. Nunca. Y no había error — simplemente corría en la misma tx.
logNotificacionService.registrar(request.getCertificadoId());
}
}
El resultado: cuando notificarEmision fallaba, rollbackeaba toda la transacción de emitirCertificado. El certificado se perdía. El incidente duró dos horas diagnosticando por qué había emisiones que aparecían en los logs de negocio pero no en la base de datos.
El fix requiere romper la auto-invocación. Hay varias formas — la más limpia en nuestro caso fue separar el servicio:
@Service
public class CertificadoService {
private final NotificacionService notificacionService; // servicio separado
@Transactional
public void emitirCertificado(EmisionRequest request) {
validarRequest(request);
persistirCertificado(request);
// Ahora sí pasa por el proxy de Spring — transacción separada garantizada
notificacionService.notificarEmision(request);
}
}
@Service
public class NotificacionService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void notificarEmision(EmisionRequest request) {
// Esta sí corre en su propia transacción
logNotificacionService.registrar(request.getCertificadoId());
}
}
Lo interesante es que este gotcha tiene décadas. Está en la documentación, en Stack Overflow, en libros de Spring. Y aun así me lo encontré en producción en 2026, en un codebase que escribí yo mismo. Porque en el contexto del dominio, la separación de responsabilidades no era obvia hasta que el incidente la hizo obvia.
El debugging de concurrencia en producción es similar a lo que describí en el post sobre mutex deadlock en Rust y patrones de diagnóstico en codebase real — la lección es la misma: los problemas de concurrencia y transacciones son silenciosos hasta que no lo son.
El contexto de aplicación bajo restart y el gap con Railway
Último gotcha, y el más específico a PaaS.
Railway hace deployments sin downtime usando rolling restarts. Cuando subís una nueva versión, hay un período donde la instancia vieja y la nueva corren simultáneamente. Con Spring Boot y estado en el ApplicationContext, esto puede generar condiciones raras si tenés beans con estado (stateful beans) o caches en memoria que se inicializan al arrancar.
En Lakaut Hub tenemos un cache de CRLs (Certificate Revocation Lists) que se inicializa al startup desde la base de datos. Durante el rolling restart, la instancia nueva arrancaba con el cache vacío y empezaba a servir requests antes de que el cache estuviera caliente. Los primeros 30-60 segundos de una instancia nueva tenían latencias notoriamente más altas.
El fix fue implementar un health check real que Railway usa para determinar cuándo la instancia está lista:
@Component
public class CrlCacheHealthIndicator implements HealthIndicator {
private final CrlCacheService crlCacheService;
@Override
public Health health() {
// Railway no manda tráfico hasta que esto devuelva UP
if (!crlCacheService.isWarmedUp()) {
return Health.down()
.withDetail("razon", "CRL cache todavia cargando")
.withDetail("entriesLoaded", crlCacheService.getLoadedCount())
.build();
}
return Health.up()
.withDetail("crlEntries", crlCacheService.getLoadedCount())
.build();
}
}
# application.yml — configuración de health checks para Railway
management:
endpoints:
web:
exposure:
include: health, metrics, info
endpoint:
health:
show-details: always
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
Y en el railway.toml:
[deploy]
healthcheckPath = "/actuator/health/readiness"
healthcheckTimeout = 60 # segundos que Railway espera antes de considerar el deploy fallido
Sin esto, Railway asume que la instancia está lista apenas el puerto está abierto. Y el puerto de Spring Boot está abierto antes de que el ApplicationContext termine de inicializarse completamente. La doc de Railway no menciona esto para Java. La doc de Spring Boot no habla de Railway. Estás en el gap.
Este tipo de gap entre lo que un proveedor promete y lo que pasa en producción real me recuerda al análisis que hice sobre supply chain attacks en npm donde el scanner no ve todo — la promesa oficial y la realidad tienen siempre una distancia que solo cierra con evidencia propia.
Errores comunes que no vienen en la doc oficial
1. Confiar en spring.datasource.url sin ?sslmode=require en Railway PostgreSQL
Railway PostgreSQL requiere SSL. Sin el parámetro explícito, algunas versiones del driver JDBC conectan sin SSL y la conexión falla silenciosamente o con mensajes crípticos. Siempre: ?sslmode=require&sslrootcert=system.
2. Usar spring.jpa.hibernate.ddl-auto=update en producción
La doc lo desaconseja. La gente igual lo usa. En Lakaut Hub lo encontré en un branch de un PR que casi llega a main. update puede perder datos en migraciones no triviales. En producción: validate + Flyway o Liquibase, siempre.
3. Ignorar los warnings de startup
open-in-view, lazy initialization desactivada sin justificación, beans con nombres duplicados — Spring Boot los loguea como WARN y la gente los ignora. Yo los ignoré. Me costó semanas de diagnóstico que se hubieran evitado con diez minutos de leer los logs del primer deploy.
4. No separar profiles por ambiente
application.properties único para todo. En Lakaut Hub arrancamos así. El problema es que los values de dev (heap pequeño, pool mínimo, logging verbose) llegan a producción por default. La separación application-production.yml con los valores correctos es obligatoria desde el día uno, no cuando el problema ya está.
FAQ — Spring Boot en producción real
¿Qué tamaño de datasource pool recomendás para Railway con PostgreSQL?
Depende del plan de Railway y de los límites de conexiones del servidor Postgres. Como punto de partida: maximum-pool-size entre 5 y 10, minimum-idle en la mitad. La fórmula de HikariCP sugiere (núcleos * 2) + spindle_disks, pero en Railway tenés que medir qué límite de conexiones tiene el plan contratado y no superarlo entre todas las instancias.
¿ZGC o G1GC para Spring Boot en contenedores?
Para Java 21+ en contenedores con heap acotado (512MB - 2GB), ZGC Generational es mi elección actual. G1GC funciona bien con heaps grandes (4GB+). Con memoria limitada en Railway, las pausas de G1GC se vuelven impredecibles. Medí el cambio en Lakaut Hub y la diferencia fue clara en startup time y latencia p99.
¿Cómo diagnosticás un problema de pool de conexiones en producción sin acceso directo a la base?
Spring Boot Actuator con el endpoint /actuator/metrics/hikaricp.connections te da el estado del pool en tiempo real: activas, ociosas, pendientes, timeouts. Si hikaricp.connections.pending sube, el pool está saturado. Si hikaricp.connections.timeout tiene valores distintos de cero, ya tuviste timeouts reales.
¿@Transactional en la capa de Controller o solo en Service?
Solo en Service. Nunca en Controller. El Controller no debería saber nada sobre transacciones — mezclar concerns ahí rompe la separación de capas y hace más difícil testear la lógica de negocio de forma aislada. En Lakaut Hub tenemos esta regla como parte del code review checklist.
¿Qué diferencia hay entre liveness y readiness en Spring Boot Actuator?
liveness responde si la app está viva (si falla, Railway/Kubernetes la reinicia). readiness responde si está lista para recibir tráfico (si falla, Railway le saca el tráfico pero no la reinicia). Para el warmup de caches y el gap de startup, readiness es el que te importa. Configurar solo health genérico sin separar estos dos estados es perder la mitad del valor del health check.
¿Vale la pena Spring Boot para proyectos pequeños o es overkill?
Depende del contexto. Para Lakaut Hub, donde el dominio es complejo (PKI, certificados X.509, CRLs, TSA), el ecosistema de Spring Security, Spring Data JPA y la integración con Bouncy Castle justifican el overhead. Para un CRUD simple con tres endpoints, probablemente Quarkus o Micronaut arrancan más rápido y consumen menos memoria. La pregunta no es "¿es Spring Boot bueno?" sino "¿qué necesita este problema específico?".
Conclusión: la doc es el punto de partida, no el destino
Tres años trabajando con Spring Boot en producción real en Lakaut AC me dejaron una convicción: la documentación oficial es una guía de inicio, no un manual de operaciones. Está escrita para que la app arranque. No para que sobreviva un recital de Soda Stereo.
Los cuatro gotchas que documenté acá — open-in-view y el pool bajo carga, JVM tuning para contenedores en Railway, proxies de @Transactional y auto-invocación, y el gap de readiness en rolling restarts — no son bugs de Spring Boot. Son decisiones de diseño que tienen sentido en el contexto para el que fueron tomadas. El problema es que ese contexto no es el de producción real en una plataforma PaaS con recursos limitados.
Lo que no compro del ecosistema Spring en 2026 es la tendencia a esconder complejidad debajo de defaults que parecen mágicos. open-in-view=true por default es un diseño pensado para que los tutoriales funcionen sin configuración extra. En producción real, ese default cobra. El disclaimer en el log es útil, pero insuficiente.
Lo que sí acepto: cuando Spring Boot está bien configurado y bien entendido, es un stack sólido para dominios complejos. Lakaut Hub corre en Railway con JVM 21, PostgreSQL, y los gotchas documentados acá están resueltos. El sistema emite certificados digitales en producción todos los días. La doc no me llevó ahí — los logs sí.
Si trabajás con Java en producción y encontraste otros gotchas que no están acá, me interesa saberlo. Estoy construyendo categoría Java en el blog desde la evidencia, no desde los tutoriales. Hay mucho más por documentar.
Si el tema de diagnóstico en producción con logs reales te resulta útil, también documenté el análisis de guardrails reales para agentes autónomos después de un incidente concreto — el approach de evidencia primero aplica igual para sistemas distribuidos complejos.
Este artículo fue publicado originalmente en juanchi.dev
Top comments (0)