viernes, 14 de septiembre de 2012

El problema de N+1 consultas: explicación, caso real y solución propuesta


La entrada de hoy trata sobre un problema recurrente al acceder a bases de datos y especialmente al usar abstracciones por encima, como JPA o Hibernate, sin leer en detalle el funcionamiento interno de estas herramientas.

El problema se da al querer acceder a un objeto y sus N objetos relacionados, normalmente a través de claves foráneas, ya que la estrategia simple implica hacer una consulta para obtener el objeto principal y las claves de los objetos relacionados, y luego hacer N consultas, cada una para obtener un objeto relacionado, usando las claves obtenidas en la primera consulta. De ahí el nombre “N+1 consultas”. La trampa de este problema está en que con pocos datos o en ejemplos simples, el deterioro del rendimiento puede no ser significativo, pero cuando el número de objetos relacionados crece y los objetos relacionados están relacionados a su vez con otros objetos, que a su vez... En fin, la cosa crece exponencialmente y es la causa principal de que la gente se queje de que los ORM son lentos, ya que por defecto éste es su comportamiento y la gente no suele ir más allá.

Para ilustrar el problema usaremos un caso real con el que tuve que lidiar hace relativamente poco: mostrar en detalle los exámenes de un plan de estudios (sí, ya sé que suena a problema de examen de la carrera :) ). La cuestión es que eso que a principio suena tan simple se transforma en:
  • Los planes de estudios se ofrecen en varios campus (es lo que tiene una universidad con varios campus).
  • Para cada plan/campus, hay unas asignaturas que se ofrecen.
  • Cada asignatura, se divide a su vez en grupos-asignatura (por turnos, por letras, por plan ya que una asignatura puede ofrecerse en varios planes).
  • Para cada grupo-asignatura, se definen una serie de exámenes con sus datos.
  • Además, cada examen puede tener lugar en varias localizaciones (normalmente aulas de un mismo edificio).
  • Para componer más la cosa, los grupos-asignatura se juntan en entidades llamadas grupo-horario para poder distribuir los alumnos sin problemas de forma ordenada sin que haya solapamientos de horarios/exámenes etc.
  • Todo esto, filtrado por año académico.
Si queremos obtener estos datos para poder mostrarlos y lo hacemos “al tun-tun”, lo que ocurre es que, partiendo del código de plan de estudios, campus y año académico:
  • Obtenemos los datos del plan de estudios a partir de su código.
  • Obtenemos los datos del campus a partir su código.
  • Obtenemos la lista de códigos de los grupos-horario que se ofrecen para ese año académico filtrando por plan, campus y año.
  • Obtenemos los datos del grupo-horario a partir de su código.
  • Obtenemos los códigos de los grupos-asignatura de cada grupo-horario según el código de grupo-horario y el año académico.
  • Obtenemos los datos del grupo-asignatura por código
  • Obtenemos los datos de la asignatura por la clave foránea que tiene grupo-asignatura.
  • Obtenemos los códigos de los exámenes de cada grupo-asignatura.
  • Obtenemos los datos de cada examen por código
  • Obtenemos los códigos de las localizaciones de cada examen.
  • Obtenemos los datos de cada localización por código.

Cada una de esas lineas implica una consulta de 1 o N elementos, con el retardo extra que ello implica. Y eso si solo nos traemos los elementos necesarios, si usamos un  ORM y no tenemos cuidado con las relaciones que tenemos marcadas como “eager fetching”, podemos hacer muchas más consultas y traernos además multitud de objetos que, encima, no vamos a usar. La solución eficiente en cuanto a minimizar las consultas es, en este caso, sencilla: podemos usar una sola consulta para obtener todos esos datos. Una señora consulta, sí, y con datos repetidos, también, pero en un único viaje por la red, que en muchas veces será lo que más nos importe. La consulta, sin entrar en detalle, sería algo así

  • Plan de estudios
  • ∟ Datos del plan de estudios
  • ∟ Datos del campus
  • ∟ Lista de grupos-horario
    • Datos de grupo-horario
    •  Lista de grupos-asignatura
      • Datos de grupo-asignatura
      •  Datos de asignatura
      •  Lista de exámenes
        • Datos de examen
        •  Lista de localizaciones
          • Datos de localización
Ahora bien, si tenemos los datos de plan de estudio y campus repetidos en cada fila del resultado, los de cada grupo-horario para n filas etc. ¿Cómo hacemos para reconstruir el árbol de objetos sin que sea una pesadilla? Ahí es donde entra en marcha algo como Ibatis/MyBatis, que no sólo nos permite definir libremente las consultas si no que se encarga de “separar” los datos adecuadamente una vez se lo indiquemos en el fichero de “mapeo”. En este caso, al definir la consulta le diríamos que el resultado es un objeto de la clase PlanEstudios que contiene los datos del plan, y una referencia a un objeto de la clase Campus, que contendrá los datos del campus. Además, la clase PlanEstudios contiene una lista de objetos GrupoHorario, que se puede discriminar con la columna correspondiente al código de grupo-horario, el cual contiene los datos del mismo y una lista de objetos GrupoAsignatura el cual...

Una vez hecho así, podemos ejecutar la consulta y ésta, suponiendo que los datos estén bien, devolverá un único objeto de la clase PlanEstudios, con la listade GrupoHorario rellenada, y para cada GrupoHorario sus datos rellenados y sus listas de GruposAsignatura rellenadas etc. Todo el árbol de objetos rellenado y con los datos en su lugar con una sola consulta, no sólo a nivel SQL si no también a nivel de lógica, lo cual es una ventaja importante.

Obviamente, todo tiene su precio y en este caso el precio es introducir el framework Ibatis/MyBatis, y definir el mapeo de las columnas a los campos de los objetos, pero el beneficio es claro en cuanto a claridad en el código y rendimiento. Por otro lado, mencionar que no siempre es posible convertir todas las consultas en una (por ejemplo cuando un objeto está relacionado con dos listas de objetos independientes), pero en este caso lo que se puede hacer es definir la consulta de forma que obtenga los mayores datos posibles de la forma más eficiente minimizando las, en este caso inevitables, “consultas N”.

Cabe también mencionar que las herramientas ORM decentes incluyen opciones (véase la especificación del tipo de fetch y la utilización de “inner join” en  JPA) para poder controlar este tipo de cosas y, si bien de forma más limitada, permiten evitar en algunos casos el problema de las N+1 consultas y/o de la carga de objetos innecesaria. Así que antes de culpar a la herramienta, hay que asegurarse de que una la está usando correctamente.

No incluyo en esta entrada detalles de implementación, para no hacerla aun más extensa, pero espero que sirva para ilustrar un problema que existe y os encontrareis en el mundo real, y una forma de solucionarlo. Si el tema interesa, se podría hacer otra entrada con detalles de ese tipo, pero esa es otra historia ;).


Happy coding! EJ

9 comentarios:

  1. Una verdad como un pino eso de que se use este "problema" para criticar los ORMs.

    Por otro lado, hacer una consulta tan compleja en una unica sentencia sql a mano es practicamente imposible (e ilegible!), por lo que con una herramienta como ibatis o algun ORM que haga esa clase de optimizaciones es posible incluso ganar velocidad!

    ResponderEliminar
  2. El problema de usar fetch=join en Hibernate es que no puedes elegir cuando inicializar y cuando no inicializar esa relación. Pasa a inicializarse siempre, con la consecuente carga innecesaria.

    Para entornos con grandes cargas de datos recomiendo carga explícita por demanda. Sí, definir a pelo que relaciones han de cargarse.



    ResponderEliminar
  3. En Hibernate no lo se, no lo he mirado pero supongo que también, pero en JPA eso lo puedes hacer con fetch a LAZY pero usando una clausula "inner join" en JPQL, que es lo que se menciona al final del artículo.

    El problema, al fin y al cabo, es especificar algo en tiempo de compilación para una relación y que no siempre se necesita la misma opción para esa relación. Por eso poder especificar el comportamiento en ejecución es tan importante.

    ResponderEliminar
  4. En Hibernate, fetch=join deshabilita la configuración lazy. Es decir, la pone a false para esa relación = DANGER.

    ResponderEliminar
  5. Lo que yo digo de JPA se hace por consulta, así que no cambia nada permanente. Sólo es para esa consulta en concreto.
    Por ejemplo, "select f from Factura f join fetch f.lineasFactura as l where..." se traería en una sola query la factura y sus lineas, pero solo para esa consulta.
    Por lo que veo de Hibernate, fetch=join sería lo mismo que poner FetchType a EAGER, y sí, coincido que no es buena idea en muchos muchos casos. Pero al menos en JPA puedes modificarlo por consulta.

    ResponderEliminar
  6. Es decir, la solución al problema N+1 es usar "JOIN" , es decir, delegar en el motor de base de datos toda la lógica.




    ResponderEliminar
  7. Para evitar hacer N+1 consultas, la solución pasa obviamente por hacer un JOIN y hacer solo una. Que eso sea delegar toda la logica en el motor de base de datos... yo ciertamente no opino lo mismo.

    La "lógica" de tú código es prácticamente la misma en ambos casos y considerar que cambiar la consulta SQL es "delegar toda la lógica en la BDD"... me parece estirar mucho el significado de la palabra lógica y menos si dices "toda la lógica".

    Es como decir que al usar keep-alive en http estás "delegando toda la lógica del navegador en el servidor web".

    ResponderEliminar
  8. Hola, no leí toda la nota (Disculpas ando buscando comprender el tema de lazy y eager) Pero viendo la consulta, me parece que un tipo de consulta relativamente grande lo mejor no sería usar JDBC puro (en el mejor de los casos un template como spring jdbc)?

    Usaría ORM para lo sencillo (crud) y consultas complicadas o grandes con muchas relaciones las pondría en un archivo. Al pasar de los años (por lo viejo que es el post)

    Que has aprendido y qué te parece lo que propongo?

    ResponderEliminar
    Respuestas
    1. Precisamente lo que comenta el artículo es usar Mybatis (una capa ligera sobre JDBC puro) para hacerlo. No recomendaría usar un ORM para este tipo de cosas.

      Eliminar