CodeGym /Cursos /Hibernate deep-dive /@Transactional ( readOnly = true )

@Transactional ( readOnly = true )

Hibernate deep-dive
Nivel 17, Lección 2
Disponible

1. La transacción como marco para la lectura en JPA/Hibernate

Con el caso de uso de escritura, el panorama ya está claro: una operación de servicio, un contexto de persistencia, y Hibernate lleva tranquilamente los cambios a flush/commit. En las lecturas surge una pregunta simétrica: si no escribimos nada, ¿realmente necesitamos una transacción de servicio y para qué sirve entonces readOnly=true? La intuición sugiere: «no estoy cambiando nada, entonces no necesito una transacción». Suena lógico, pero Hibernate no está diseñado como una “consola SQL con SELECT”, sino como un sistema en tiempo de ejecución que gestiona el ciclo de vida de los objetos. Incluso la lectura a menudo requiere un marco: para la carga perezosa, para la consistencia de la lectura, para un comportamiento predecible del contexto de persistencia y para que su código no se rompa cuando OSIV está desactivado (open-in-view=false), que en nuestro curso es una disciplina básica.

En una aplicación típica de Spring Boot, la lectura puede funcionar incluso sin @Transactional explícito en el servicio… pero esto generalmente significa que la transacción se creará en algún lugar «dentro», por ejemplo, a nivel de la llamada al repositorio. Entonces, cada método del repositorio se convierte en una pequeña historia separada con su propio contexto de persistencia, su propia sesión y sus propias posibilidades de problemas de carga perezosa.

Imagine que quiere leer un pedido y sus posiciones, y luego verificar el stock de los productos. Si llama a tres repositorios uno tras otro sin un límite de servicio común, casi con toda seguridad obtendrá tres minitransacciones independientes. En ese momento, Hibernate deja de ser su «ayudante de unidad de trabajo» y se convierte en un «conjunto de llamadas separadas». Y si después de la primera llamada intenta recorrer una colección perezosa order.getItems(), entonces con open-in-view=false se encontrará con el clásico del género: LazyInitializationException. No porque Hibernate sea malvado, sino porque le pide que cargue datos cuando la sesión ya está cerrada.

En este punto surge una idea útil: la lectura también es un caso de uso, y es útil formalizarla con la misma rigurosidad que la escritura. Y aquí readOnly=true comienza a desempeñar el papel no de un acelerador «para todo», sino de un marcador de significado: «hemos entrado en un escenario de lectura, no modificamos nada, simplemente tomamos los datos y salimos».

2. readOnly=true como contrato de escenario

El flag readOnly=true en @Transactional a menudo se intenta usar como una optimización mágica: «pondré readOnly y todo será más rápido». A veces realmente será un poco más ligero (pronto analizaremos por qué), pero esto es un efecto secundario. El valor principal de una transacción de solo lectura es la semántica y la disciplina: se dice a sí mismo (y a sus colegas) que el método no debe cambiar el estado del sistema. Es como una pegatina «NO TOCAR, PIEZA DE EXPOSICIÓN» en un museo: la pegatina no atornilla la pieza al suelo con pernos de hierro, pero aumenta drásticamente las posibilidades de que nadie comience a moverla.

En nuestro proyecto Commerce Persistence Lab, los escenarios de solo lectura son, por ejemplo, «mostrar lista de pedidos», «leer estado del pedido», «mostrar ficha del producto», «devolver saldo por SKU», «generar resumen del pedido». Son operaciones que deben ser predecibles: no deben provocar accidentalmente UPDATE, no deben mezclar cambios en el contexto de persistencia y no deben convertirse imperceptiblemente en un caso de uso de escritura.

Existe un hábito de ingeniería útil: tan pronto como un método se declara como @Transactional(readOnly=true), lo lee mentalmente así: «dentro tenemos derecho a leer, tener acceso perezoso (mientras estemos dentro del límite), construir DTO, pero no tenemos derecho a cambiar las entidades». A nivel de sintaxis de Java, nadie le prohíbe llamar a un setter: la mano aún puede temblar. Pero precisamente por eso, una transacción de solo lectura es un acuerdo que debe mantenerse con el estilo de código: separar los métodos de consulta de los métodos de comando, no mezclar «miré» y «cambié» en un mismo lugar.

Para fijar las expectativas, es útil tener en mente una pequeña tabla de «qué hace readOnly y qué no hace»:

Pregunta Qué suele pasar con readOnly=true Qué no se debe esperar
Semántica del código El método se lee como un caso de uso de lectura Que es «solo por velocidad»
Vaciado / comprobación sucia Hibernate a menudo hace menos trabajo innecesario Que la escritura es «imposible» en principio
Objetos Java Siguen siendo objetos mutables normales Que el objeto se vuelve inmutable
Protección contra errores Ayuda a no convertir la lectura en escritura Que esto reemplaza la revisión de código y la disciplina

Aquí es importante mantener precisamente el significado: readOnly es un marcado honesto del escenario y una señal útil para Hibernate. Las limitaciones técnicas de la anotación por sí mismas no reemplazan este significado.

3. Solo lectura por defecto en Spring Data JPA

Si abre las fuentes de Spring Data JPA (no es necesario ahora, es solo un hecho de su estructura), verá que la implementación básica del repositorio está organizada más o menos así: la clase tiene transaccionalidad «para lectura», y los métodos de escritura se anulan como transacciones de escritura normales. Por lo tanto, la sensación de «no anoté nada, pero funciona» no es magia. Las lecturas del repositorio a menudo se ejecutan en una transacción de solo lectura.

Pero aquí hay una sutileza que causa confusión y errores en aplicaciones reales. La transaccionalidad de solo lectura en el repositorio es una transacción a nivel de una sola llamada. Es decir, si su servicio hace:

  1. orderRepository.findById(...)
  2. inventoryRepository.findByProductId(...)
  3. customerRepository.findById(...)

entonces sin un límite de servicio común, son tres transacciones separadas. Son cortas, y para consultas individuales esto suele ser suficiente, pero pierde:

  • primero, un contexto de persistencia único y la caché de primer nivel para todo el escenario, lo que significa que puede volver a leer las mismas filas de la base de datos;
  • segundo, una zona perezosa predecible donde puede recorrer las relaciones dentro de una sola sesión;
  • tercero, claridad a nivel de arquitectura: no es obvio dónde termina el escenario de lectura y dónde comienza el escenario de escritura.

Por lo tanto, un buen estilo es tener servicios de lectura (a menudo se llaman QueryService, ReadService, *QueryFacade) y marcar sus métodos con @Transactional(readOnly=true). Entonces aparece un marco honesto: «leí todo lo necesario para la respuesta y salí».

Un mini ejemplo para nuestro proyecto. Queremos simplemente leer el estado de un pedido, sin tocar nada más:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderQueryService {

    private final PurchaseOrderRepository orderRepository; // repositorio usado solo para lectura

    public OrderQueryService(PurchaseOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true) // fijamos el límite del caso de uso de lectura (lazy interno posible)
    public String loadOrderStatus(Long orderId) {
        return orderRepository.findById(orderId).orElseThrow().getStatus().name();
    }
}

Este código es bueno porque es «honesto»: por la anotación se ve que es una lectura. Y si dentro de seis meses alguien decide «cambiemos también el estado aquí», tendrá que hacerlo explícitamente, y verá que el marco de solo lectura se vuelve metodológicamente incorrecto.

Para contrastar, una variante típica «mala». Puede que incluso «no explote» de inmediato, pero es metodológicamente peligrosa: lee como un caso de uso de lectura, pero hace escritura:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    @Transactional(readOnly = true)
    public void hideProductWrong(Long productId) {
        // productRepository aquí se entiende como una dependencia inyectada (omitido por brevedad)
        Product product = productRepository.findById(productId).orElseThrow();
        product.setStatus(ProductStatus.HIDDEN); // dentro de un método readOnly cambiamos una entidad gestionada (violamos el contrato)
    }
}

La corrección correcta no es «configurar Hibernate», sino simplemente nombrar honestamente el escenario como escritura:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    @Transactional // esto es un caso de uso de escritura: los cambios son esperados y deben guardarse
    public void hideProduct(Long productId) {
        Product product = productRepository.findById(productId).orElseThrow();
        product.setStatus(ProductStatus.HIDDEN);
    }
}

Y aquí es importante no confundirse: readOnly=true no es «por decoración». Es parte del contrato de su método.

4. Solo lectura y Hibernate: comprobación sucia y vaciado

Cuando escucha por primera vez «transacción de solo lectura», quiere preguntar: «¿Y Hibernate realmente deja de rastrear los cambios?». Y en la mayoría de las configuraciones prácticas, la respuesta será: sí, Spring realmente intenta optimizar el comportamiento de Hibernate en transacciones de solo lectura. Pero esto sigue sin ser una protección de hormigón armado contra la escritura, sino más bien una traducción de Hibernate al modo «no espero cambios, trabajo de manera más económica».

Vamos a desglosarlo en términos familiares del curso. Ya sabemos que Hibernate para entidades gestionadas hace una instantánea y en el flush realiza la comprobación sucia. En una transacción de escritura normal, esto es lógico: esperamos que cambie las entidades. En un escenario de solo lectura, este es un trabajo innecesario: leyó los datos, los transformó en DTO y se fue. Por lo tanto, las instantáneas y la comparación «antes/después» pueden ser redundantes.

Precisamente por eso, Spring con @Transactional(readOnly=true) para Hibernate suele hacer dos cosas: reduce la probabilidad de un vaciado automático y pone la sesión/consulta en expectativas de solo lectura. Como resultado, Hibernate se comporta más a menudo como si hubiera dicho: «no gastes energía buscando cambios, solo estoy leyendo».

Mostremos esto en un pequeño fragmento de diagnóstico. A veces es útil, con fines educativos, ver directamente qué modo de vaciado cree Hibernate dentro de una transacción:

import jakarta.persistence.EntityManager;
import org.hibernate.Session;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public void debugReadOnlyMode() {
    // entityManager aquí se entiende como un EntityManager inyectado (campo/constructor omitidos para centrarse en la idea)
    Session session = entityManager.unwrap(Session.class); // obtenemos la sesión nativa de Hibernate
    System.out.println("defaultReadOnly = " + session.isDefaultReadOnly()); // a menudo: true
}

Si activa el perfil sql-trace y observa atentamente el registro, notará algo característico: en los escenarios de solo lectura, a menudo solo verá SELECT, sin UPDATE repentinos que a veces aparecen debido a cambios accidentales en el grafo gestionado. Pero aquí debemos mencionar directamente un punto peligroso.

Solo lectura — no es una licencia para escribir «código sucio». Si dentro de un método de solo lectura accidentalmente llama a un setter, puede encontrarse con una de las situaciones más desagradables en la práctica de la ingeniería: en la memoria el objeto ha cambiado, pero en la base de datos, no. Y puede salir del método con la sensación de que «todo ha cambiado», porque estaba mirando el objeto Java. Pero en la siguiente consulta a la base de datos, resultará que no escribió nada. Y esto no es que «Hibernate se haya roto», es que ha mezclado dos mundos: el caso de uso de lectura y el caso de uso de escritura.

Por separado, vale la pena hablar sobre el flush. Discutimos que el vaciado y la confirmación son cosas diferentes. En una transacción de solo lectura, Hibernate y Spring generalmente se comportan de manera que el vaciado ocurra con menos frecuencia. Esto es útil, pero nuevamente no hay que convertirlo en una religión. Si llama manualmente a entityManager.flush() o hace saveAndFlush() (no lo haga en un escenario de solo lectura, por favor), puede conseguir SQL incluso en modo «solo lectura», porque usted mismo ha solicitado la sincronización. Hibernate no es un vidente: si explícitamente pide un vaciado, intentará hacerlo.

Finalmente, readOnly puede propagarse al nivel de JDBC como un flag «connection read-only». Algunos controladores y bases de datos pueden usarlo para optimizaciones, pero esto no siempre es una prohibición estricta de INSERT/UPDATE/DELETE. En PostgreSQL existe el modo «transaction read only», pero Spring «de fábrica» trata más a menudo readOnly como una sugerencia. Por lo tanto, la posición de ingeniería correcta es esta: readOnly=true ayuda tanto en el significado como en el coste de ejecución, pero no es su «barrera de seguridad» contra la escritura.

Una transacción de solo lectura debe ser de solo lectura por su significado, y las optimizaciones de Hibernate son una ventaja agradable.

5. Solo lectura y carga perezosa sin OSIV

Con la carga perezosa aquí todo es extremadamente claro: los proxies y las colecciones viven perfectamente mientras hay una sesión activa, y de repente se convierten en LazyInitializationException cuando la sesión no está. Este es el caso en el que la «lectura» de repente se vuelve muy «sobre la transacción»: para leer correctamente los campos necesarios, debe estar dentro del límite donde el contexto de persistencia sigue vivo.

Un escenario de nuestro dominio: necesitamos mostrar los detalles de un pedido, incluidas las posiciones (OrderItem) y algunas instantáneas. No queremos hacerlo a través de OSIV (está desactivado), y no queremos extender una transacción de escritura para la lectura. Por lo tanto, hacemos un caso de uso de consulta separado y lo formalizamos con una transacción de solo lectura.

Por ejemplo, queremos construir una salida DTO simple para la «ficha del pedido»:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderQueryService {

    @Transactional(readOnly = true) // mantenemos la sesión abierta exactamente durante la lectura + construcción del DTO
    public OrderCardDto loadOrderCard(Long orderId) {
        PurchaseOrder order = orderRepository.findById(orderId).orElseThrow(); // lectura de la entidad dentro de una transacción
        return OrderCardDto.from(order); // dentro se puede recorrer con cuidado las relaciones perezosas mientras la transacción está activa
    }
}

Observe que devolvemos un DTO, no una entidad. Esto no es una «regla obligatoria del mundo», pero es un estilo muy saludable: el DTO fija qué datos estamos leyendo realmente y no se lleva el grafo gestionado fuera.

Si dentro de OrderCardDto.from(order) accede a order.getItems() y son LAZY, esta lectura ocurrirá de manera segura porque está dentro de la transacción, la sesión está viva y Hibernate puede ejecutar SQL. Pero después de salir del método, ya debe considerar que «las puertas perezosas se han cerrado». Precisamente por eso, un caso de uso de lectura debe «leer todo lo necesario e inmediatamente construir el resultado», en lugar de devolver un objeto con el que luego se deambulará por las capas.

Aquí es fácil caer en un error de concepto: «como es de solo lectura, puedo mantener la transacción más tiempo, total no bloqueo». No se debe hacer. Incluso una transacción de solo lectura retiene recursos: conexión, contexto, a veces estructuras de instantáneas. Debe ser corta y comprensible: entramos → leímos → formamos el resultado → salimos.

6. Práctica de readOnly=true en Commerce Persistence Lab

En la práctica, un buen estilo para proyectos donde se desea un comportamiento predecible de ORM se ve así: tiene servicios de comandos (casos de uso de escritura) y servicios de consultas (casos de uso de lectura). No es necesario hacerlos como módulos separados, pero es útil al menos a nivel de paquetes y nomenclatura.

En nuestro proyecto, esto se expresa naturalmente así: CheckoutService formaliza el pedido y mueve el estado — esto es una transacción de escritura normal. OrderQueryService devuelve el estado, la ficha, la lista — son transacciones de solo lectura. Esto ayuda tanto al cerebro como a Hibernate: el cerebro lee el código como «en este lugar hay cambios», Hibernate trabaja en modo «espero cambios»; el cerebro lee el código como «en este lugar hay lectura», Hibernate trabaja de manera más económica.

Ejemplo: lista de productos para el panel de administración. No queremos cambiar nada, pero queremos que la lectura sea predecible, y que si es necesario, se pueda acceder a los puntos perezosos dentro del método (o, mejor aún, usar una lectura diseñada previamente). Incluso si ahora devolvemos un DTO, podemos formalizar un límite de solo lectura:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductQueryService {

    @Transactional(readOnly = true) // toda la construcción de la lista va dentro de una sola transacción de solo lectura
    public ProductListDto loadList() {
        return new ProductListDto(productQueryRepository.findListItems());
    }
}

Aquí es importante que readOnly no esté «en el repositorio», sino en el caso de uso. Como resultado, puede recopilar datos de múltiples fuentes (varios repositorios) dentro de este método sin convertir cada lectura en una minitransacción separada.

Otra ventaja es un «freno para futuros errores». Supongamos que alguien añade una línea: «y al leer la lista de productos, también actualicemos el campo lastViewedAt». Esto ya es un comportamiento de escritura. Cuando el método es de solo lectura, ve inmediatamente que la acción «va en la dirección equivocada». Y esta es una señal muy útil en la revisión de código.

7. Cuando readOnly=true interfiere

Hay dos clases de problemas particularmente desagradables que a menudo parecen «Hibernate se comporta de manera extraña», pero que en realidad son consecuencia de mezclar significados.

La primera clase es cuando dentro de un método de solo lectura, sin embargo, cambia una entidad gestionada. Dependiendo de la configuración, el modo de vaciado y las condiciones específicas, es posible que no vea un UPDATE. Parece que «cambió el estado», pero en la base de datos no ha cambiado nada. Después de salir de la transacción, pierde el contexto de persistencia, luego vuelve a leer de la base de datos y ve el valor anterior. Aparece la sensación de que «el sistema falla», pero en realidad simplemente violó el contrato.

La segunda clase es cuando hace «un poco de escritura» en un método de solo lectura y luego, dentro del mismo método, ejecuta otra consulta y espera que la consulta vea los cambios. Recordamos del día sobre el vaciado que Hibernate puede hacer un vaciado antes de una consulta para mantener la corrección. En una transacción de solo lectura, el vaciado a menudo está suprimido porque «no debería haber escritura». Como resultado, la consulta lee datos antiguos de la base de datos, mientras usted mira los objetos Java modificados. Y dentro de un solo método aparecen dos mundos que no están sincronizados: la base de datos y la memoria.

Esto es muy similar a la situación en la que edita un documento en Word pero olvida presionar «Guardar», y luego abre el archivo de nuevo y se sorprende. Solo que aquí es peor: el botón «guardar» de Hibernate se llama flush, y a usted «como que se lo han desactivado» porque usted mismo dijo «readOnly».

Por lo tanto, la principal conclusión práctica de esta sección es simple y aburrida (y por lo tanto, correcta): un método de solo lectura debe ser de solo lectura por su significado. Si siente la tentación de «leer y actualizar un poco», hágalo como una transacción de escritura normal, y saque la lectura/recopilación de datos a un caso de uso de lectura separado si es necesario.

8. Errores típicos

Error nº 1: considerar readOnly=true como un «acelerador» que se puede poner en cualquier parte.
A veces lo ponen en cualquier método que «parece que lee mucho», incluso si dentro hay un cambio de estado, creación de entidad o llamada a save(). Como resultado, el código comienza a contradecirse a sí mismo: por la anotación es de solo lectura, por su significado es de escritura. Esto rompe la previsibilidad y hace que el comportamiento dependa de los matices del modo de vaciado, no de la lógica de negocio.

Error nº 2: pensar que readOnly hace que los objetos Java sean inmutables.
En Java, todavía puede llamar a un setter, cambiar un campo, añadir un elemento a una colección. ReadOnly no es magia del lenguaje, es un modo de transacción y una expectativa de la infraestructura. Por lo tanto, no se puede «proteger» con readOnly contra errores de programación; hay que escribir el código de manera que en un caso de uso de lectura simplemente no haya lugares donde se cambie algo.

Error nº 3: mezclar lectura y escritura en un mismo método «porque es más cómodo».
La versión más peligrosa: leyó una entidad, cambió un campo, luego hizo otra consulta y espera que la consulta vea los cambios. En un escenario de solo lectura, el vaciado puede no funcionar automáticamente, y obtiene una desincronización entre la base de datos y la memoria. Incluso si «a veces funciona», en otro entorno o con otra versión de las dependencias puede comportarse de manera diferente.

Error nº 4: esperar que readOnly sea una prohibición estricta de UPDATE/INSERT/DELETE a nivel de base de datos.
En la mayoría de las configuraciones de Spring, readOnly es una sugerencia, y aunque Hibernate y JDBC pueden comportarse de manera más económica, esto no se convierte necesariamente en «SET TRANSACTION READ ONLY» en PostgreSQL. Si necesita una protección estricta, se resuelve con una configuración separada y una disciplina separada, pero en el marco de nuestro curso, la protección principal es el diseño correcto de los límites y escenarios.

Error nº 5: no poner un límite de servicio de solo lectura donde la lectura consta de varios pasos.
Si su caso de uso de lectura hace varias consultas, recopila un DTO, verifica datos relacionados y no tiene @Transactional(readOnly=true) en el método de servicio, la lectura puede fragmentarse en varias minitransacciones a nivel de repositorio. Esto empeora la previsibilidad del contexto de persistencia, aumenta el número de lecturas repetidas y conduce más a menudo a problemas de carga perezosa cuando open-in-view=false.

1
Tarea
Hibernate deep-dive, nivel 17, lección 2
Bloqueada
Servicio de solo lectura para consultar el estado de un pedido
Servicio de solo lectura para consultar el estado de un pedido
1
Tarea
Hibernate deep-dive, nivel 17, lección 2
Bloqueada
Tarjeta de producto con relaci�n LAZY dentro de una transacci�n de solo lectura
Tarjeta de producto con relaci�n LAZY dentro de una transacci�n de solo lectura
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION