El problema silencioso de open-in-view
Alejandro Alonso Noguerales
9 abr 2026
Seguro que conoces este warning. Sale cada vez que arrancas Spring Boot y todos lo ignoramos:
WARN: 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.
Nosotros también lo ignoramos. Estábamos montando una API nueva con Spring Boot 4 y virtual threads. Todo iba bien, los tests pasaban, el desarrollo avanzaba sin problemas.
Hasta que un día la suite de tests empezó a colgarse. Sin errores. Sin excepciones. Sin timeouts. Simplemente se quedaba ahí, en silencio, esperando algo que nunca iba a llegar.
01 — El contexto
Tenemos una API en Spring Boot 4 con una arquitectura CRUD basada en hooks. El flujo de un update tiene tres fases:
Fase 1 (sin transacción): findById() + pre-hooks (validaciones, llamadas externas)
Fase 2 (con transacción): TransactionTemplate { save + hooks transaccionales }
Fase 3 (sin transacción): post-hooks (notificaciones, eventos)
El motivo de este diseño es no mantener la transacción de base de datos abierta mientras se hacen validaciones o llamadas a servicios externos. Solo la escritura real va dentro del TransactionTemplate. Es un patrón que llevábamos usando meses sin problemas.
02 — Qué pasó
Lanzamos los más de 10.000 tests de la suite completa y la ejecución se quedó colgada en un test de update. Lo raro: los tests individuales pasaban sin problema. Filtrar por -Dgroups=e2e también funcionaba. Pero lanzar todo junto se colgaba siempre en el mismo punto.
03 — Buscando la causa
Primer intento: los contextos de Spring
Lo primero que vimos fue que teníamos dos clases base de tests (AbstractWebMvcTest y AbstractSecureWebMvcTest), cada una con su propio @DynamicPropertySource. En Spring Test, eso significa dos contextos Spring diferentes, cada uno con su propio pool de conexiones HikariCP, los dos pegando contra el mismo PostgreSQL de Testcontainers.
Unificamos los contextos para que todo pasara por un único HikariPool. Se seguía colgando. Pista falsa — aunque el fix mereció la pena igualmente, porque cada contexto extra de Spring en tests es un HikariPool extra, y con pools pequeños (maximum-pool-size: 5) te quedas sin conexiones enseguida.
Segundo intento: mirar qué estaba pasando de verdad
Hicimos un thread dump y miramos pg_stat_activity en PostgreSQL. Ahí estaba todo:
Main thread: BLOCKED en CompletableFuture.get()
Virtual thread: BLOCKED en PGStream.receiveChar() durante saveAndFlush()
PID 168 | idle in transaction | SELECT ... FROM communication WHERE ... | 8 min
PID 169 | active | UPDATE communication SET ... | 8 min | wait: Lock/transactionid
Dos conexiones del mismo pool. Del mismo request. Esperándose la una a la otra.
04 — El problema de fondo
Con open-in-view=true (el default de Spring Boot), Hibernate mantiene el EntityManager abierto durante todo el request HTTP. Las consecuencias no son obvias hasta que te explotan.
Cuando nuestro findById() se ejecuta en la Fase 1, fuera de cualquier transacción de Spring:
- Hibernate coge la Conexión A del pool
- Pone
autoCommit=falsey lanza el SELECT - La conexión queda en estado “idle in transaction” — con una transacción abierta que nadie ha commiteado
- Como el
EntityManagersigue vivo (gracias aopen-in-view), la Conexión A se queda retenida
Después, cuando llegamos a la Fase 2 y arranca el TransactionTemplate:
- Spring pide una conexión nueva al pool (no hay transacción gestionada por Spring, así que no reutiliza la A)
- HikariCP le da la Conexión B
- Los hooks transaccionales hacen escrituras (
saveAllAndFlush()) - La Conexión B intenta hacer UPDATE sobre filas que la Conexión A tiene bloqueadas con su transacción fantasma
Conexión A (idle in transaction): SELECT ... FROM entity WHERE ... [transacción implícita abierta]
Conexión B (activa): UPDATE entity SET ... [esperando a que A haga commit]
La B espera a que la A haga commit. La A no va a hacer commit hasta que termine el request. El request no va a terminar hasta que la B complete. Deadlock clásico.
¿Y los virtual threads?
Con platform threads te puedes salvar porque el pool de threads suele ser pequeño (200 hilos), el pool de conexiones se dimensiona en proporción, y la ventana para que se dé este escenario es muy estrecha.
Con virtual threads puedes tener miles de requests concurrentes, cada uno reteniendo su conexión a través del EntityManager, contra un pool de 10-50 conexiones. La probabilidad de que se agote el pool y se produzca el deadlock sube muchísimo. Nosotros lo detectamos en los tests. Si hubiese llegado a producción sin detectarse, habría sido mucho peor de diagnosticar.
05 — Cómo lo solucionamos
Lo obvio: desactivar open-in-view
spring:
jpa:
open-in-view: false
El EntityManager se cierra después de cada transacción en vez de al final del request. El findById() de la Fase 1 coge una conexión, ejecuta el SELECT, y la suelta inmediatamente. Cuando el TransactionTemplate arranca, coge una conexión limpia. Sin conflicto.
Lo no tan obvio: las LazyInitializationException
Al desactivar open-in-view, todas las entidades quedan detached en cuanto se cierra la transacción. Cualquier acceso a una relación lazy fuera de transacción te da un LazyInitializationException. Tuvimos que tocar todas las interfaces CRUD base — lecturas, escrituras, repositories. Cómo lo resolvimos y con qué criterio es lo que cubrimos en detalle en la segunda parte.
El bug que open-in-view escondía desde el primer día
Lo mejor vino al auditar los servicios. Encontramos un servicio que usaba el pipeline genérico de update para modificar un campo concreto de una entidad. El problema: el mapper de update tenía un @Mapping(target = "campo", ignore = true) para ese campo. El mapper lo descartaba silenciosamente.
Con open-in-view=true esto “funcionaba”. La entidad seguía managed durante todo el request, Hibernate detectaba el cambio por dirty checking y lo persistía igualmente. Con open-in-view=false, la entidad quedaba detached y el cambio se perdía.
Un bug escondido a plena vista gracias a un mecanismo implícito que nadie había pedido. La solución: un repository.saveAndFlush(entity) directo, sin pasar por todo el pipeline de update genérico.
Esto es lo realmente peligroso de open-in-view: no solo retiene conexiones, sino que enmascara errores. Código que parece correcto funciona por casualidad.
06 — Lo que sacamos en claro
open-in-view + virtual threads = deadlock. Spring Boot lo activa por defecto. Con virtual threads y transacciones programáticas (TransactionTemplate), se convierte en una bomba de relojería.
open-in-view=false te obliga a ser explícito. Y eso es bueno:
open-in-view=true | open-in-view=false | |
|---|---|---|
| Conexión | Retenida todo el request | Se libera con la transacción |
| Lazy loading fuera de tx | Funciona (N+1 silenciosos) | LazyInitializationException |
| Pool de conexiones | Mayor consumo | Menor consumo |
| Queries N+1 | Se generan sin que te enteres | Fallan con excepción |
07 — En resumen
Si usas Spring Boot con virtual threads, pon esto en tu application.yml ahora:
spring:
jpa:
open-in-view: false
Te van a saltar LazyInitializationException por varios sitios. No pasa nada. Cada una de esas excepciones es un sitio donde tu código estaba haciendo queries de más sin que lo supieras, o dependiendo de un mecanismo implícito que bajo carga te puede colgar la aplicación. Tu aplicación va a ser más predecible, va a consumir menos conexiones, y no se te va a colgar misteriosamente un martes a las 3 de la mañana.
Ahora queda la parte difícil: resolver cada LazyInitializationException. Hay varias estrategias, y elegir mal te puede generar problemas de rendimiento tan serios como los que acabas de resolver. Eso es exactamente lo que cubrimos en la segunda parte.