miércoles, 24 de octubre de 2012

Comparadores, igualdad y error difícil de detectar

Hola,

Después de leer por los internetes la enésima entrada sobre comparar objetos en Java y encontrar de nuevo el error que tienen el 90% de ellas, voy a aportar mi granito de arena para que quede constancia y alguno no se pille los dedos.

Para los que se aburren de leer pronto: Mucho ojo al devolver 0 en el método compare(Object o1, Object o2), tanto al implementar la interfaz Comparable como al crear un objeto Comparator.

La documentación dice que devolver 0 significa que los objetos son iguales, pero eso no es lo mismo que decir que nos da igual como estén ordenados, que eso es lo que entiende mucha gente instintivamente. Así que la pregunta es... ¿que ocurre cuando tenemos una Collection que no permite objetos repetidos ordenada por ese criterio?
La demostración, aquí:
import java.util.Arrays;
import java.util.Comparator;
import java.util.Set;
import java.util.TreeSet;

import lombok.Data;

public class App
{
  @Data
  static class ObjetoOrdenado{
    private final String nombre;
    private final Integer edad;    
  }
  
  static Comparator<objetoordenado> COMPARADOR_POR_EDAD =
     new Comparator<objetoordenado>()
  {
    @Override
    public int compare(ObjetoOrdenado o1, ObjetoOrdenado o2)
    {
      // Haciendo esto tendremos un problema, ya que
      // si devolvemos 0, uno de los elementos desaparecerá
      return o1.getEdad().compareTo(o2.getEdad());
    }
  };
  
  public static void main(String[] args)
  {
    ObjetoOrdenado[] aOrdenar = new ObjetoOrdenado[]{
        new ObjetoOrdenado("John",24)
        ,new ObjetoOrdenado("Jane",29)
        ,new ObjetoOrdenado("Bob",30)
        ,new ObjetoOrdenado("Susan",27)
        ,new ObjetoOrdenado("Kenny",24)
    };
    Set<objetoordenado> ordenados = new TreeSet(COMPARADOR_POR_EDAD);    
    ordenados.addAll(Arrays.asList(aOrdenar));
    for (ObjetoOrdenado o : ordenados)
    {
      System.err.println(o.getEdad() + " - " + o.getNombre());
    }
    System.err.println("They killed Kenny!");
  }
}
->

24 - John
27 - Susan
29 - Jane
30 - Bob


El resultado es que el pobre Kenny desaparece, eliminado por ser considerado "igual" que John al ordenar. La solución es no devolver 0 excepto cuando estemos seguros de que efectivamente son iguales, y si no pues devolvemos un 1 o un -1, lo que sea, pero distinto de 0.

Ésta implementación de compare sería una solución
import java.util.Arrays;
...
    @Override
    public int compare(ObjetoOrdenado o1, ObjetoOrdenado o2)
    {
      // Primero ordenamos por edad
      int temp = o1.getEdad().compareTo(o2.getEdad());
      if(temp==0)
      {
        // Si la edad es la misma, ordenamos por nombre
        temp = o1.getNombre().compareTo(o2.getNombre());
        // Si la edad y el nombre son iguales, ordenamos al azar.
        // Si realmente nombre y edad iguales significa que solo
        // queremos UN objeto, entonces podríamos saltar este paso y devolver 0
        if(temp==0)
        {
         temp = -1;  
        }
      }
      return temp;
    }
  };
...

Es un error difícil de detectar por que podemos tener una colección con todos los objetos, todo va bien, los ordenamos y ¡zas! mágicamente tenemos menos, sin excepciones, sin avisos.

Así que espero que a alguien le sirva para no caer en ello, siguiendo los típicos ejemplos de Internet que suelen explicarlo mal. Por dejarlo claro, yo caí en ello y por eso lo tengo bien aprendido :D.

Happy coding! EJ

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

jueves, 30 de agosto de 2012

Cluster de Tomcats con balanceo y "session failover"

Al hilo del artículo de David González Múltiples Tomcats con Apache y conector HTTP o AJP y el problema mencionado de tener que reiniciar un Tomcat al actualizar aplicaciones y perder servicio, vamos a añadir este articulillo rápido para explicar como montar un cluster de Tomcats con varias aplicaciones intentando no perder servicio al reiniciar. En principio la idea es tener todas las aplicaciones en 1 Tomcat y hacer cluster para que podamos pararlo y re-arrancarlo sin perder servicio. También serviría, con ligeras modificaciones, para tener varios Tomcat, dividiendo las aplicaciones entre ellos, y luego "clusterizárlos". Con eso conseguiríamos las ventajas que menciona David, más las de este artículo.

El primer paso es tener tus aplicaciones montadas en un Tomcat normal y corriente. Si no sabes hacerlo, empieza por ahí ya que éste artículo te será muy complejo sin una buena base.

Vamos a suponer que las maquinas donde quieres desplegar las aplicaciones se llaman app_host1.com y app_host2.com (nombres no publicos pero accesibles desde el servidor donde se encuentra el Apache) y que en ambas escucha el Tomcat con el protocolo AJP por el puerto 8009 o con el protocolo HTTP por el puerto 8080. Podemos usar el protocolo que más nos convenga (a AJP se le supone mejor rendimiento pero cada uno...)

Una vez tenemos claro esto, para dividir la carga entre las dos máquinas y enviarla a los Tomcats, y con "sticky sessions", usaremos el mod_proxy_balancer de Apache, añadiendo este bloque a la configuración en el lugar adecuado (depende de lo que queramos hacer y la versión de Apache, por defecto httpd.conf):
 # Definición del cluster con 2 nodos, usando el protocolo AJP
 <Proxy balancer://app_ajp_cluster>
  BalancerMember ajp://app_hos1.com:8009 route=Tomcat-All.1  
  BalancerMember ajp://app_hos2.com:8009 route=Tomcat-All.2  
  ProxySet stickysession=JSESSIONID|jsessionid scolonpathdelim=On
 </Proxy> 
 # Definición del cluster con 2 nodos, usando el protocolo HTTP
 <Proxy balancer://app_http_cluster>
  BalancerMember http://app_hos1.com:8080 route=Tomcat-All.1  
  BalancerMember http://app_hos2.com:8080 route=Tomcat-All.2  
  ProxySet stickysession=JSESSIONID|jsessionid scolonpathdelim=On
 </Proxy> 
 # Descomenta la siguiente parte para administrar el cluster, si sabes lo que haces.
 # Esta parte sirve para poder consultar/gestionar el estado del cluster
 # ¡¡No debe dejarse abierto al público!!
 #<Location /balancer-manager>
 # SetHandler balancer-manager
 # Order Deny,Allow
 # Deny from all
 # Allow from W.X.Y.Z   #Host del administrator
 # #... otras medidas de proteccion
 #</Location>
 #...
 # Para enviar un contexto al cluster de Tomcats, podemos hacerlo así
 ProxyPass ^/my1app/ balancer://app_ajp_cluster/myapp/ [P,QSA,L]
 # o si queremos afinar más...
 RewriteRule ^/my2app/ balancer://app_ajp_cluster%{REQUEST_URI} [P,QSA,L]
 # Podemos hacer virguerías como deshabilitar acceso a aplicaciones si no es por SSL
 RewriteCond %{SERVER_PORT} =443
 RewriteRule ^/mysslapp/ balancer://app_ajp_cluster%{REQUEST_URI} [P,QSA,L]
 # Y otras magias que permite RewriteCond

* Conviene asegurarse que en el Apache que estemos utilizando están cargados los modulos proxy_module, proxy_ajp_module, proxy_balancer_module, proxy_http_module, rewrite_module, headers_module y status_module.
** NO hay que olvidarse de proteger el balancer-manager si lo abrimos. YOU HAVE BEEN WARNED!!!

Ahora necesitamos definir en la configuración del Tomcat los nombres de las rutas, para lo cual modificaremos el fichero server.xml (o equivalente) y en el nodo  Engine añadiremos el atributo jvmRoute con el valor adecuado en cada nodo. Así pues, en el host app_host1.com el nodo quedará como:

    <Engine
      name="..."
      defaultHost="localhost"
      jvmRoute="Tomcat-All.1"
    >

en el host app_host2.com el nodo quedará como:

    <Engine
      name="..."
      defaultHost="localhost"
      jvmRoute="Tomcat-All.2"
    >


Una vez hecho esto, tenemos un cluster con "sticky sessions", lo cual nos permite tener aplicaciones con sesión y donde todas las peticiones de la misma sesión van al mismo Tomcat. Sin embargo, ¿Qué ocurre si uno de los Tomcat se cae? Pues que las sesiones se perderán y aunque los usuarios podrán seguir accediendo de forma transparente, perderán lo que estaban haciendo. En ciertos casos, como aplicaciones públicas sin identificación, eso no nos importará y el servicio se mantendrá sin incidencias, pero en los casos donde, como mínimo, se guarda el usuario identificado como atributo de la sesión como prueba de login, el servicio se interrumpirá y "echará" a todos los usuarios obligándoles a identificarse de nuevo, perdiendo lo que tuvieran en la sesión.

Como ese resultado no es muy agradable para los usuarios, ¿cómo añadimos "session failover" a nuestra configuración para que eso no pase? Pues existen multitud de opciones (Terracota, Hazelcast...) para hacerlo, pero la más sencilla y barata (aunque no tan fiable) es usar el clustering que viene con el Tomcat.

Para usarlo, dentro del nodo Engine de la configuración del Tomcat (server.xml o equivalente) añadiremos el elemento Cluster, configurado adecuadamente. Antes de mostrar la configuración en sí, analicemos los datos que vamos a necesitar definir:
  • Necesitamos una dirección y un puerto de multicast, para que los nodos se encuentren (en el ejemplo definidas como ${tomcat.mcast.addr} y ${tomcat.mcast.port}). Deben ser los mismos valores para todos los nodos del cluster.
  • Necesitamos el nombre público del host particular para que los otros miembros del cluster se puedan comunicar (en el ejemplo ${tomcat.host}). Esto que parece una obviedad no lo es si añadimos DNS internos, firewalls, etc. Hay que definirlo por que si no, lo más normal es que la JVM asuma por defecto que nuestro host se llama... "localhost" y cuando otro miembro del cluster quiera comunicarse con "localhost"... podeis suponer lo que pasa. Así que hay que asegurarse que ese nombre es correcto y se le pasa correctamente al Tomcat, ya que es la fuente #1 de problemas de configuración del cluster. Avisados quedais.
  • * Hace falta además un puerto para las comunicaciones entre Tomcats, en el ejemplo definido como ${tomcat.nio.port}.
  • Si estos últimos campos son iguales o diferentes para todos los nodos, depende de si los nodos están en la misma máquina o no.
  • En el ejemplo los valores esán definidos como ${xxx} por que el script de arranque ha sido modificado para pasar los valores adecuados en cada nodo del cluster, pero el Tomcat por defecto no rellena estos valores, así que modifica los valores, o el script, o no te te funcionará.

Con todos estos datos, así es como queda el trozo de configuración:

<Engine
 ...
    >
      <Cluster
        className="org.apache.catalina.ha.tcp.SimpleTcpCluster"
      >
        <Manager
          className="org.apache.catalina.ha.session.DeltaManager" />
        <Channel
          className="org.apache.catalina.tribes.group.GroupChannel"
        >
          <Membership
            className="org.apache.catalina.tribes.membership.McastService"
            address="${tomcat.mcast.addr}"
            port="${tomcat.mcast.port}" />
          <Sender
            className="org.apache.catalina.tribes.transport.ReplicationTransmitter"
          >
            <Transport
              className="org.apache.catalina.tribes.transport.nio.PooledParallelSender" />
          </Sender>
          <Receiver
            className="org.apache.catalina.tribes.transport.nio.NioReceiver"
            address="${tomcat.host}"
            port="${tomcat.nio.port}" />
          <Interceptor
            className="org.apache.catalina.tribes.group.interceptors.TcpFailureDetector" />
          <Interceptor
            className="org.apache.catalina.tribes.group.interceptors.MessageDispatch15Interceptor" />
          <Interceptor
            className="org.apache.catalina.tribes.group.interceptors.ThroughputInterceptor" />
        </Channel>
        <ClusterListener
          className="org.apache.catalina.ha.session.ClusterSessionListener" />
      </Cluster>
      <Host
   ...
    </Engine>
* Nota: Esta es la configuración mínima que he conseguido que me funcione. Algunos atributos tienen los valores por defecto, según la documentación, y algunos nodos deberían estar incluidos por defecto, de nuevo según la documentación, pero o la documentación miente o el Tomcat se pasa por el forro de la boina los valores por defecto en algunos casos, escoge la que más te guste. De todas formas, esta no es la única configuración posible y lo único que puedo decir de ella es que se ajusta a los que nos interesa y nos funciona ;).

En caso de querer hacer pruebas para ver si funciona en una sola maquina, lo que habría que hacer es usar dos Tomcat cambiando los puertos y ajustando la configuración adecuadamente.

Se podrían contar más cosas, como el script que arranca los Tomcat pasando los valores adecuados, usar un solo binario para multiples Tomcats detrás de un único Apache... pero como dice un gurú... eso es otra historia.

Happy coding!
EJ.

Disclaimer: Este no es un tema sencillo y no es recomendable para los no-iniciados, así que tratarlo con prudencia ;).