Optimiza tu capa de peristencia: Sacando todo el provecho a JPA con Hibernate – Parte 1

UN FANTASMA MUY COMÚN

Si no te has parado a pensar en la estrategia que quieres usar para acceder a tus datos, es muy probable que estés causando que tu aplicación se ralentice terriblemente sin saberlo.

Quizás no lo notes ahora, pero el problema del que hablo puede agravarse conforme la base de datos empiece a llenarse (tan solo un poco). Una base de datos incluso pequeña puede convertir una aplicación no muy compleja en un desastre, causando que sus peticiones tarden una cantidad inaceptable de tiempo en ser procesadas.

Y lo peor de todo es que de esto no te vas a dar cuenta durante la fase de desarrollo: no van a haber warnings, no van a haber logs por defecto que te vayan a hacer sospechar, Y no va a haber una ralentización del servicio si tu base de datos no tiene la envergadura de la de producción. En general, nada te va a hacer sospechar de que no lo estás haciendo de la mejor forma.

QUE NO CUNDA EL PÁNICO

Por suerte, JPA tiene las herramientas necesarias para solucionar este problema, y en este artículo intentaré exponer todo lo que sé acerca de identificar, y solucionar con buenas prácticas los terribles cuellos de botella de la persistencia de datos.

Voy a intentar explicar los conceptos clave de las estrategias de acceso a entidades paso a paso, con el objetivo de comprender la la raíz del problema, entender porqué y cómo se puede solucionar y ser capaz de resolver hasta los casos más complejos.

Comprender el problema está a mitad de camino de la solución

ENTENDIENDO AL ENEMIGO

Nota: Este artículo tiene en cuenta que usas Hibernate como implementación de JPA. Si usas otro ORM, muchos conceptos se corresponden, pero otros puede que no se apliquen de la misma forma.

Nuestra némesis se llama el problema N+1, y no es algo específico de Hibernate ni mucho menos, sino que puede ocurrir con cualquier tecnología en la que accedas a una base de datos relacional. Antes que nada, vamos a entenderlo bien:

El problema N+1

El problema N+1 es algo que muy probablemente hayamos provocado en nuestros inicios sin saberlo, cuando para acceder a la base de datos desde el código usábamos queries escritas a mano, y quizá de esa forma era más visual y más fácil percatarse de que algo no se está haciendo bien. Por ello, durante este ejemplo, imaginemos que lo estamos haciendo así.

La situación es la siguiente: Queremos obtener n filas de una tabla que tiene m hijas en otra tabla. Por ejemplo, queremos sacar una lista de las multas de las personas que se llamen Carlos.

Supongamos que hay 1000 Carlos en la base de datos y cada uno tiene 5 multas.

Para ello, un programador principiante, haría lo más intuitivo en un principio; primero, una query donde sacase una lista de las personas que se llaman Carlos, y después, por cada persona p, una llamada a la tabla de multas, filtrando por ese Carlos en cuestión.

El novato programador piensa: no creo que pueda hacer esto de mejor forma, lo haga como lo haga, tengo que ‘descargar’ 5000 filas de todas formas. Craso error.

Hacer esto supone hacer una llamada a la base de datos para obtener los Carlos, y luego 1000 llamadas más para obtener las multas. Por cada una de esas llamadas estamos abriendo una transacción, filtrando los resultados (estén indexados o no) y cerrando la transacción.

Si un sistema tiene un cuello de botella, asegúrate de que ahí el liquido pasa a presión.

Visto así es obvio, pero quizás usando un ORM donde «tan solo» estamos haciendo un p.getMultas() en un bucle, estamos dando por sentado que la entidad de Persona ya contiene las multas…

Si tan solo hubiera una forma de unir ambas tablas y obtener los Carlos y las multas en una sola llamada…

Evidentemente las hay, y la más común de ellas (estrategias de fetching) es el join. Y hibernate lo puede hacer nativamente, pero hay que indicarle cuándo y cómo con los tipos de fetching.

Ampliación: tal y como habrás razonado, en la definición anterior el parámetro importante es n (1000 en el ejemplo). Por otra parte m (5 en el ejemplo) es irrelevante: que sea mayor o menor no afecta al rendimiento, ya que no hace variar el número de llamadas.

Tipos de fetching

Vamos a ver el mismo caso, pero con JPA. Para este ejemplo tendríamos dos entidades:

Persona.java

 @Table(name = "Personas")
 @Entity
 @Getter
 public class Persona {

    @Id
    private String nombe;

    @OneToMany(mappedBy = "persona")
    private List<Multa> multas;

}

Multa.java

 @Table(name = "Multas")
 @Entity
 @Getter
 public class Multa {

   @GeneratedValue
   private String codigoMulta;

   @ManyToOne()
   private Persona persona;

 }

Entonces, la pregunta es:

Cuando hacemos un p.getMultas(), ¿están ya cargadas o no? ¿y al hacer m.getPersona()? Depende, (por ahora) del tipo de fetching que tengan las relaciones. Existen dos tipos: Lazy y Eager.

En cada momento, la decisión correcta es una diferente

Eager fetching

Las relaciones que estén anotadas de forma eager, se leerán de la base de datos en el momento en el que la entidad que las contiene se crea. Es decir, una entidad con variables eager, desde el primer momento (nada más ser obtenida), contiene las entidades relacionadas a esas variables accesibles sin necesitad de una llamada (y a su vez, si esas tuvieran otras eager, también estarían ya cargadas).

Lazy fetching

Las relaciones que estén anotadas de forma lazy, se leerán de la base de datos en el momento en el que son accedidas. Es decir, una entidad con variables lazy, en un primer momento (nada más ser obtenida), tiene dichas variables vacías hasta el momento en el que se accedieran, momento el cual mediante una llamada se obtienen los datos necesarios para rellenar esa variable.

¿Cuál ocurre por defecto?

En el caso de arriba, no hemos indicado el tipo de fetching que va a tener en ninguna de las relaciones, ni en el @OneToMany ni en el @ManyToOne, por lo tanto, se usan los tipos de fetching por defecto de cada anotación, que son:

@OneToOne → Eager
@ManyToOne → Eager
@OneToMany → Lazy
@ManyToMany → Lazy

Por defecto, se asume que en una relación hijo-padre, es ventajoso tener por defecto cargado el padre, y no es costoso llamarlo por defecto, ya que, al ser solo uno, en una relación hijo-padre-abuelo-bisabuelo no corremos el riesgo de cargar un número de filas exponencial, como sí ocurriría con una relación padre-hijos-nietos-bisnietos. En este caso, la API asume que es mejor cargar los hijos de forma lazy.

En resumen: Los padres se cargan de forma eager, las listas de hijos se cargan de forma lazy. O, visto de otra forma: las entidades únicas se cargan por defecto, las listas de entidades, no.

Es decir, tal y como hemos especificado las relaciones, puedes llamar a .getPersona() desde una multa sin que internamente se llame a la base de datos, pero al llamar al método .getMultas() desde una persona implica que internamente se llame a la base de datos. Llamar a este método dentro de un bucle, provocará el problema N+1 comentado antes.

¿Cuál debo usar para evitar el N+1?

La solución es compleja, pero con unas claves claras, podrás no solamente evitar el N+1, sino además evitar hacer llamadas innecesarias a la base de datos. Podrás conocer estas claves en la parte 2 de este artículo.

En el próximo artículo hablaré de cómo detectar los N+1 que podrían ya existir en tu aplicación, de cómo indicar el tipo de fetching deseado en cada momento (¡incluso en tiempo de ejecución!) y de cómo elegir hasta qué punto de la cadena de herencia de entidades (abuelo-padres-hijos-etc) queremos llegar (de forma personalizada para cada una de las llamadas).

Espero y estoy seguro de que nos veremos pronto.

Sé el primero en comentar

Deja un comentario

Tu dirección de correo electrónico no será publicada.