Un @Transactional sin readOnly duplicó la espera del pool de conexiones
Unos endpoints de solo lectura sin el atributo readOnly = true mantenían las conexiones de Hibernate más tiempo del necesario. Al corregirlo, la espera en el pool cayó a la mitad.

El equipo tenía un pool de conexiones que aguantaba sus picos de tráfico hasta que dejó de hacerlo. No habían tocado el código ni la infraestructura en toda la semana. Lo único que había subido era el volumen de peticiones, y el pool se atragantaba mucho antes de lo que decían sus cálculos de capacidad. Nada fallaba de forma abierta: las peticiones solo hacían cola, esperando cada vez más por una conexión libre.
El dashboard no lo veía
La investigación arrancó donde suelen arrancar estas cosas: mirando gráficas y lanzando conjeturas. CPU bien. Carga de base de datos bien. El dimensionado del pool era el correcto, siempre que el ratio de lecturas y escrituras fuera el que creían tener. Ese era justo el error de partida.
Un puñado de endpoints —listados por categoría, consultas de producto, resúmenes de panel— estaban anotados con un @Transactional pelado, sin el atributo readOnly = true. Funcionaban y devolvían los datos correctos. Ningún test funcional se quejaba. Pero sin ese atributo, Hibernate trataba cada llamada como una posible escritura: rastreaba cada entidad cargada para el dirty checking y hacía un flush al cerrar la transacción por si había algo que persistir. Ese trabajo no sale gratis.
En una sola petición son unos milisegundos que no nota nadie. Multiplícalo por el volumen de tráfico de lectura de un pico y las conexiones se quedaban retenidas más tiempo del necesario, así que menos de ellas volvían al pool para el siguiente de la cola.
El arreglo y lo que queda
Añadir readOnly = true a esos métodos llevó una tarde. Con el atributo, Hibernate se salta el dirty checking, se salta el flush y cierra la transacción en cuanto lee. La espera en el pool durante el pico siguiente cayó a la mitad, lo que encaja con qué porción del tráfico pasaba por esos endpoints.
Lo llamativo no es el arreglo, es lo invisible que era el coste hasta que el volumen lo hizo visible. readOnly = true parece una pista decorativa, de las que uno se salta cuando va con prisa y los tests salen en verde. No lo es: le dice a la capa de persistencia que deje de hacer trabajo que nunca iba a necesitar, y a escala ese trabajo se convierte en contención real sobre un recurso por el que compite cada petición del sistema.
La auditoría es rápida: un grep de @Transactional sin readOnly y después comprobar si el método escribe algo. El atributo cuesta unas teclas; su ausencia cuesta conexiones, y se van a echar en falta justo cuando peor viene.

