Volver a artículos
15 min de lectura

Elegir la solución correcta para LazyInitializationException

Alejandro Alonso Noguerales

Alejandro Alonso Noguerales

13 abr 2026

En la primera parte desactivamos open-in-view y resolvimos un deadlock silencioso. La aplicación dejó de colgarse. Bien.

Lo que vino después fue menos dramático pero más largo: LazyInitializationException por todas partes. Cada entidad con una relación lazy que se accedía fuera de transacción explotaba. Y en una API con decenas de entidades y relaciones en todos los niveles, eso significaba tocar mucho código.

Lo fácil habría sido poner @Transactional en todos los servicios y pasar al siguiente ticket. Pero queríamos entender qué estábamos haciendo. Así que montamos un laboratorio con Hibernate Statistics sobre PostgreSQL real para medir cada estrategia antes de elegir. Todos los tests del lab pasan en verde y respaldan cada número que aparece aquí.


01 — El primer instinto: @EntityGraph en todo

La primera LazyInitializationException nos saltó en Store. Tiene 3 relaciones ManyToOne: storeType, region y timezone. El mapper las necesita para construir el DTO.

El fix fue limpio:

@EntityGraph(attributePaths = {"storeType", "region", "timezone"})
List<Store> findAllWithAllRelationsBy();

Hibernate genera un solo SELECT con LEFT JOIN. 1 query, 10 filas, sin vueltas. El servicio no necesita @Transactional para nada. Nos gustó.

Y para ser justos, no todo son problemas con @EntityGraph. Si la entidad tiene pocas ManyToOne y como mucho una colección, funciona perfecto: en el lab, Store con 3 ManyToOne + 1 storeEmployees se resuelve en 1 query, sin cartesiano, sin excepciones. El caso feliz existe y es bastante común.

Así que hicimos lo mismo con Department. 6 colecciones: employees, projects, budgets, equipment, policies, documents. Pusimos el @EntityGraph con todos los attributePaths y…

MultipleBagFetchException: cannot simultaneously fetch multiple bags

Hibernate no puede hacer fetch de múltiples List<> en una sola query. Primer muro.

El workaround de StackOverflow que no es workaround

Lo primero que encuentras buscando ese error es “cambia List a Set”. Lo probamos. La excepción desapareció. Los tests pasaban. Todo bien.

Hasta que miramos el SQL. Con 3 colecciones Set de 5 items cada una:

5 × 5 × 5 = 125 filas transferidas para 15 items reales

Un cartesiano silencioso. Hibernate deduplica en memoria, así que el resultado parece correcto. Pero con datos de producción — 50 items por colección — serían 125.000 filas para 150 items. Y nadie se enteraría hasta que el DBA preguntara por qué la base de datos está sufriendo.

Y si paginamos?

También probamos combinar @EntityGraph con Pageable sobre entidades con colecciones. Hibernate lanza un warning (HHH90003004) y pagina en memoria: carga TODAS las filas y recorta en Java. El SQL no tiene LIMIT/OFFSET. Con 100K registros, esto mata la aplicación sin aviso.

Quedó claro: @EntityGraph es perfecto para entidades simples con pocas ManyToOne y como mucho una colección. Para cualquier cosa con múltiples colecciones, necesitábamos otra cosa.


02 — El fallback: @Transactional(readOnly = true)

Para Department y las entidades con árboles de relaciones profundos, la solución fue envolver el método del servicio:

@Transactional(readOnly = true)
public List<StoreDto> getAllStores() {
    return storeRepository.findAll().stream()
            .map(storeMapper::toDto)
            .toList();
}

La sesión de Hibernate se mantiene abierta durante todo el método. El mapper puede recorrer cualquier cadena de relaciones sin explotar. Funcionaba con todo. Pero nos preocupaba el N+1.

El N+1 no es lo que pensábamos

Esperábamos que 10 stores con 3 ManyToOne generaran 31 queries (1 + 3×10). Pero al medirlo encontramos algo que no habíamos visto en ningún artículo: el N+1 depende de la cardinalidad de las entidades referenciadas, no del número de padres.

EscenarioQueries
10 stores, compartiendo 2 tipos, 2 regiones, 2 tz7
10 stores, cada uno con refs únicas31

Hibernate dispara 1 query por entidad distinta referenciada. Si 10 stores apuntan a los mismos 2 StoreType, solo carga esos 2 — no 10. En producción, donde los catálogos son tablas pequeñas compartidas, el N+1 real es mucho menor del teórico. Pero si cada entidad referencia algo único (como createdBy apuntando a usuarios distintos), pega con fuerza total.

Por qué readOnly=true y no solo @Transactional

El readOnly=true no es decorativo. Hibernate pone FlushMode.MANUAL, lo que significa que si accidentalmente modificas una entidad dentro del método, no se persiste. También puede omitir los snapshots de estado — menos memoria por entidad. Lo comprobamos en el lab: modificar stores sin save() en una transacción readOnly=true no persiste nada. Sin readOnly, el dirty checking detecta el cambio y lo persiste al commit.


03 — La línea que cambió todo: batch_fetch_size=16

Teníamos @EntityGraph para las entidades simples y @Transactional(readOnly=true) para las complejas. Pero los números del N+1 en algunos casos seguían siendo feos. Entonces encontramos esto:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 16

Una línea. En vez de que Hibernate haga 1 query por cada proxy no inicializado, agrupa hasta 16 del mismo tipo en una sola query con WHERE id IN (?, ?, ..., ?).

Los resultados en el lab fueron brutales:

EscenarioSin batchCon batch=16
10 stores, refs únicas314
25 departments, 6 colecciones15214
5.000 departments, 6 colecciones30.0021.880

5.000 departments sin batch: 30.002 queries en 64 segundos. Con batch: 1.880 queries en 8 segundos.

No toca código. No produce cartesiano. Mejora todo el proyecto de golpe. Si solo puedes hacer una cosa de toda esta lista, es esta. Debería ser el baseline de todo proyecto Hibernate y no entendemos por qué no viene activado por defecto.

Si necesitas control más fino, también puedes poner @BatchSize(size = 16) directamente en una colección específica — mismo efecto, pero granular. Ojo en Hibernate 7: @BatchSize solo funciona en colecciones @OneToMany, no en @ManyToOne. Para eso necesitas el default_batch_fetch_size global.


04 — @Fetch(SUBSELECT): la que mejor escala con colecciones

El batch_fetch_size nos estaba funcionando bien, pero al escalar los tests de Department a muchos registros notamos algo: con 100 departamentos, batch seguía haciendo 44 queries (los chunks de 16 se iban acumulando). Buscando alternativas encontramos @Fetch(FetchMode.SUBSELECT):

@OneToMany
@JoinColumn(name = "department_id")
@Fetch(FetchMode.SUBSELECT)
private List<Employee> employees = new ArrayList<>();

En vez de agrupar en chunks de 16, SUBSELECT carga TODAS las colecciones de un tipo en 1 sola query usando una subquery que repite la query original:

SELECT * FROM employees WHERE department_id IN (SELECT id FROM departments)

Los números nos dejaron claros que para findAll con colecciones, esto era lo mejor:

DepartamentosSUBSELECT@BatchSize(16)Sin nada
257 queries, 11ms13 queries, 26ms152 queries, 96ms
1007 queries, 15ms43 queries, 49ms602 queries, 336ms
5007 queries, 53ms193 queries, 210ms3.002 queries, 2.121ms
1.0007 queries, 84ms379 queries, 436ms6.002 queries, 4.214ms
5.0007 queries, 439ms1.879 queries, 3.696ms30.002 queries, 45.603ms

A 5.000 departamentos: SUBSELECT hace 7 queries en 439ms vs 30.002 queries en 45,6 segundos sin batch — 100x más rápido. Incluso vs @BatchSize(16), SUBSELECT es 8x más rápido (439ms vs 3,7s) con 270x menos queries. No crece con N. Siempre es 1 query base + 1 por tipo de colección.

El trade-off: repite la query original como subquery, así que si tu query base es compleja o costosa, puede no ser ideal. Y no se configura globalmente — es una anotación por colección. Pero para entidades con múltiples colecciones donde cargas muchos padres a la vez, es la mejor opción que encontramos.


05 — Split Queries para un solo padre

SUBSELECT nos resolvía el caso de findAll con colecciones. Pero para cargar un solo Department por ID con sus 6 colecciones, el patrón que recomienda Vlad Mihalcea seguía siendo más limpio: una query JOIN FETCH por colección, dentro de la misma transacción.

@Transactional(readOnly = true)
public DepartmentDto getDepartmentSplitQueries(Long id) {
    Department dept = departmentRepository.findWithEmployeesById(id).orElseThrow();
    departmentRepository.findWithProjectsById(id);   // L1 cache merge
    departmentRepository.findWithBudgetsById(id);     // L1 cache merge
    departmentRepository.findWithEquipmentById(id);   // L1 cache merge
    departmentRepository.findWithPoliciesById(id);    // L1 cache merge
    departmentRepository.findWithDocumentsById(id);   // L1 cache merge
    return departmentMapper.toDto(dept);
}

El truco está en que todas las queries se ejecutan dentro del mismo EntityManager. La caché L1 fusiona los resultados: cuando la segunda query carga el mismo Department con sus projects, Hibernate actualiza la entidad que ya tenía en caché. Al final, dept tiene todas sus colecciones cargadas.

7 queries. Sin cartesiano. Sin N+1 descontrolado. Y esas 7 queries se mantienen constantes desde 5 hasta 5.000 items por colección — lo que crece es el volumen de datos, no los round-trips.

Más código que las otras opciones, sí. Pero para cargar una entidad concreta con múltiples colecciones grandes, es la solución más controlada.


06 — DTO Projection: para cuando no necesitas la entidad

Mientras revisábamos los servicios, nos dimos cuenta de que muchos endpoints solo leían datos para devolverlos como JSON. No modificaban nada. No necesitaban la entidad managed. Para esos casos:

@Transactional(readOnly = true)
public List<StoreProjection> getAllStoresProjection() {
    return entityManager.createQuery(
        "SELECT new com.example.osivlab.dto.StoreProjection(" +
            "s.id, s.name, s.address, st.name, r.name, tz.zoneId) " +
        "FROM Store s " +
        "LEFT JOIN s.storeType st " +
        "LEFT JOIN s.region r " +
        "LEFT JOIN s.timezone tz",
        StoreProjection.class)
        .getResultList();
}

No carga entidades. No hay persistence context, ni dirty checking, ni lazy loading. 1 query que devuelve exactamente los campos que necesitas mapeados a un record.

La diferencia de memoria es real

Medimos el consumo con 100K stores, todas las soluciones head-to-head:

SoluciónQueriesTiempoMemoria
JdbcClient1137ms56 MB
DTO Projection1127ms59 MB
@Transactional7242ms108 MB
@EntityGraph1369ms118 MB
Interface Projection1277ms130 MB

DTO Projection y JdbcClient son los más eficientes: 59 MB y 56 MB frente a 108-130 MB de las alternativas. El overhead de las entidades managed viene del persistence context más una copia snapshot de cada campo para dirty checking. A 1M registros serían ~600 MB de overhead solo por usar entidades en vez de DTOs.

La sorpresa fue Interface Projection: aunque hace 1 sola query y no carga entidades en el persistence context, consume 130 MB — más que @EntityGraph (118 MB). Spring Data genera proxies dinámicos (java.lang.reflect.Proxy) con un Map<String, Object> por instancia, que resulta más pesado que un record plano. Si el consumo de memoria importa, usa DTO Projection con record en vez de Interface Projection.

// DTO Projection — record plano, 59 MB a 100K
record StoreProjection(Long id, String name, String address,
                       String storeTypeName, String regionName, String timezoneZoneId) {}

// Interface Projection — proxy dinámico, 130 MB a 100K
public interface StoreView {
    Long getId();
    String getName();
    String getStoreTypeName();
}

07 — Los números a escala

Una cosa es medir con 10 registros y otra es ver qué pasa cuando llegas a producción. Escalamos los tests desde 100 hasta 1M registros:

Queries

Registros@EntityGraph@Transactional (refs compartidas)@Transactional (refs únicas)batch=16 (únicas)DTO Projection
10017301221
1K173.0011901
10K1730.0011.8761
100K17300.00118.7511
1M17------1

@EntityGraph y DTO Projection se mantienen en 1 query hasta 1M. @Transactional con refs compartidas — que es el caso típico de producción con catálogos — queda en 7 queries constantes independientemente del volumen. Con refs únicas sin batch, se vuelve impracticable a partir de 10K.

Tiempos (ms)

Registros@EntityGraphDTO Projection@Transactional (compartidas)batch=16 (compartidas)
1K2061099635
10K136269135
100K434122366459
500K2.6461.5511.5901.892
1M5.5093.2763.1534.655
10MOOMOOMOOMOOM

A 10M registros, todas las soluciones revientan con OutOfMemoryError (4GB heap). Ninguna solución basada en findAll() está pensada para millones de objetos en una List<>. A ese volumen no hay atajos: paginación o streaming.

DTO Projection es consistentemente la más rápida: a 1M registros, 3,2 segundos frente a 5,5s de @EntityGraph. La diferencia es todo el overhead del persistence context que te ahorras — los 118 MB vs 59 MB que medimos a 100K, multiplicados por 10.

Limitaciones de este benchmark

Estos números dan para comparar soluciones entre sí, no para predecir tiempos en producción:

Lo que SÍ es fiable: el conteo de queries es exacto e independiente del entorno, las fórmulas de escalado (1+7N, 1+ceil(N/16)*7, constante 7) son universales, la proporción de memoria entre soluciones se mantiene, y los fallos (MultipleBagFetchException, OOM, paginación en memoria) son reproducibles en cualquier entorno.


08 — Lo que encontramos en las escrituras

Hasta aquí todo era lectura. Pero al auditar las escrituras encontramos cosas que no esperábamos.

El dirty checking que enmascara bugs

Ya contamos en la primera parte el bug del servicio que cambiaba un campo a través del pipeline genérico de update, con un mapper que lo ignoraba. Con open-in-view=true “funcionaba” por dirty checking. Con open-in-view=false el cambio se perdía.

Pero la lección va más allá de ese bug concreto: con OSIV activado, cualquier entidad que se modifique accidentalmente se persiste sin que nadie haya llamado save(). El readOnly=true en transacciones de lectura previene esto. Y en las de escritura, la regla es simple: siempre save() o saveAndFlush() explícito. Nunca dependas de que “Hibernate ya se entera”.

Para entidades que sabes que nunca vas a modificar — catálogos, vistas, datos históricos — Hibernate ofrece @Immutable. Marca la entidad como solo lectura a nivel de Hibernate: cero dirty checking, sin snapshots, las modificaciones se ignoran silenciosamente. Es lo más ligero que puedes tener sin salir de JPA.

jdbc.batch_size no funciona con IDENTITY

También descubrimos que configurar hibernate.jdbc.batch_size=50 no reduce los statements cuando usas @GeneratedValue(strategy = GenerationType.IDENTITY) — que es lo que usábamos en todo el proyecto. Hibernate necesita el ID generado de vuelta inmediatamente después de cada INSERT, así que no puede agrupar. No solo no ayuda — en cascade persist, batch=50 es más lento que sin batch porque añade overhead de coordinación sin beneficio real.

Bulk insert con mediana de 5 ejecuciones tras 2 warmup:

VolumenSin batchCon batch=50
1K orders953ms975ms
5K orders4.738ms4.718ms

Tiempos prácticamente idénticos. Para que el batching funcione de verdad necesitas GenerationType.SEQUENCE con @SequenceGenerator(allocationSize = 50).

También probamos StatelessSession — la API de Hibernate sin persistence context. Con IDENTITY la diferencia es prácticamente nula (982ms vs 1.007ms a 1K orders) porque el bottleneck es el round-trip por INSERT, no el persistence context. StatelessSession brilla con SEQUENCE donde puede pre-alocar IDs y agrupar inserts.


09 — ¿Es un problema de JPA/Hibernate?

Esta fue la pregunta que nos hicimos al final de todo el proceso. Y la respuesta corta es: sí. El problema de OSIV solo existe porque Hibernate gestiona la sesión y el lazy loading por ti. Si usas JdbcClient, jOOQ o MyBatis, nada de esto pasa — no hay proxies, no hay sesión persistente, no hay lazy loading, no hay dirty checking accidental.

HerramientaLazy loading?OSIV aplica?N+1 posible?Dirty checking?
JPA/Hibernate
JdbcClientNoNoNoNo
jOOQNoNoNoNo
MyBatisConfigurableNoConfigurableNo
Native query vía JPANoLa sesión sigue abiertaNoSí (si tocas entidades)

Ojo con las native queries vía JPA: aunque el SQL sea nativo, sigues dentro del EntityManager. La sesión sigue abierta y si tocas entidades, el dirty checking sigue activo.

Para curiosidad, medimos la misma query devolviendo el mismo DTO con JPA y con JdbcClient:

RegistrosJPA DTO ProjectionJdbcClient
1K96ms9ms
100K159ms93ms
500K1.437ms1.430ms

A pocos registros, JPA tiene overhead del query parser. A gran volumen, el bottleneck es la transferencia de datos y la diferencia se diluye. Pero @EntityGraph (entidades completas con persistence context) vs JdbcClient (DTO mínimo) es otra historia: entre 2x y 18x más lento por los snapshots de dirty checking.

Esto no quiere decir “abandona JPA”. El cascade, el dirty checking para escrituras, la caché L1, los repositories de Spring Data — todo eso tiene valor cuando necesitas escribir y manejar relaciones complejas. Pero para endpoints de solo lectura con alto tráfico, saber que tienes alternativas más ligeras es útil.


10 — El árbol de decisión con el que nos quedamos

Después de todo este proceso, esta es la foto completa de cada solución y sus trade-offs:

SoluciónN+1?LazyInit?Cartesiano?Paginación real?Memoria (100K)Retiene conexión?Esfuerzo
open-in-view=trueSí (silencioso)Lo ocultaNoRetiene todoSí (todo el request)0 (default)
@EntityGraphNo (1 query)NoSí (múltiples cols)En memoria con cols118 MBNoAnotación
@Transactional(readOnly)Sí (depende cardinalidad)NoNo108 MBSí (todo el método)Anotación
batch_fetch_size=16Reducido 16xNoNo~108 MB1 línea yml
@Fetch(SUBSELECT)No (7q constantes)NoNo~108 MBAnotación/col
Split QueriesNo (controlado)NoNo~108 MBCódigo manual
DTO Projection (record)NoNoNo59 MBSí (breve)Query + record
Interface ProjectionNoNoNo130 MBSí (breve)Interface + @Query
JdbcClientNoNo aplicaNo56 MBNoSQL manual

Y este es el camino que seguimos para elegir en cada caso:

0. SIEMPRE: configura batch_fetch_size=16 como baseline global

1. ¿Es operación de ESCRITURA?
   SÍ  → @Transactional + save()/saveAndFlush() explícito
         Para batch inserts: usa SEQUENCE, no IDENTITY
   NO  → (continúa)

2. ¿Necesitas la entidad JPA o solo datos planos?
   SOLO DATOS → DTO Projection con record (1 query, 0 entidades, mitad de memoria)
                Evita Interface Projection (proxies dinámicos, más memoria que @EntityGraph)
   NECESITO ENTIDAD → ¿Es de solo lectura?
                       SÍ  → Considera @Immutable (sin dirty checking)
                       NO  → (continúa)

3. ¿La entidad tiene múltiples colecciones (>1 List/Set)?
   SÍ  → ¿Cargas 1 padre o muchos?
         1 PADRE  → Split Queries (1 JOIN FETCH por colección)
         MUCHOS   → @Fetch(FetchMode.SUBSELECT)
                    (7 queries constantes sin importar N)
         NUNCA @EntityGraph aquí
   NO  → (continúa)

4. ¿Cuántas relaciones ManyToOne tiene?
   1-3 ManyToOne → ¿Paginas sobre colecciones?
                    SÍ  → @Transactional(readOnly=true) + batch_fetch
                          (@EntityGraph pagina en memoria con colecciones)
                    NO  → @EntityGraph (1 JOIN query)
   Muchas o profundas → @Transactional(readOnly=true) + batch_fetch

SIEMPRE en métodos de lectura: @Transactional(readOnly=true)

11 — En resumen

Lo primero que hicimos al terminar todo esto fue añadir una línea al application.yml que debería haber estado ahí desde el día uno:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 16

Después fuimos entidad por entidad. @EntityGraph para las simples con pocas ManyToOne. @Fetch(SUBSELECT) en las colecciones que se cargaban en listados. Split Queries para cargar una entidad concreta con todas sus colecciones. DTO Projection con record para los listados de solo lectura. @Immutable en las entidades de catálogo. @Transactional(readOnly=true) en todo método de lectura. Y save() explícito en toda escritura — nunca más confiar en el dirty checking.

No fue un cambio de un día. Pero cada LazyInitializationException que resolvimos era un sitio donde la aplicación estaba haciendo queries de más sin que lo supiéramos, o dependiendo de un mecanismo implícito que bajo carga nos habría tumbado.

El warning de Spring Boot llevaba años intentando decírnoslo. Ahora tenemos los números para justificarlo.

Todo el código del laboratorio con los tests que respaldan cada número de este artículo está en open-in-view-lab.

Spring BootHibernateJPAPerformanceVirtual Threads