10.2 Esto no se puede optimizar más (III)
- 5 min de lectura
¡Atención! Esta es la tercera entrega del capítulo 10.2 Esto no se puede optimizar más (I).
Caché a varios niveles en queries repetitivas
Imagina que tenemos un método que hace una query a la base de datos y que lo usamos en todo el sistema de forma intensiva. Lo que acaba pasando es que hacemos muchísimas consultas repetidas a la base de datos. Cada una de ellas es una operación lenta que puede implicar joins entre tablas, ordenaciones, etc. Por lo tanto, esta es una oportunidad de oro para aplicar una caché multinivel.
En primer lugar, no tiene sentido que si llamamos este método sobre el mismo objeto (sin que haya modificaciones en los datos), tenga que acceder a la base de datos cada vez. Por lo tanto, una primera optimización sería guardar el resultado de la primera query como un atributo en el objeto para que en las siguientes consultas se devuelva directamente el valor de este atributo, en vez de acceder a la base de datos para obtener la misma información.
No obstante, con este enfoque seguimos haciendo consultas repetidas a la base de datos en la primera llamada al método porque el objeto aún no tiene el atributo (caché) inicializado. Si le damos una vuelta más, podemos aplicar una segunda caché, por ejemplo, usando Redis o Memcached, donde también se guarde el dato devuelto por la base de datos. De esta forma, cuando se llame al método sobre el objeto, en primera instancia se mirará si el propio objeto tiene el dato, si no lo tiene, entonces se mirará en la caché externa (Redis o Memcached), se recuperará el dato, se guardará como atributo del objeto y se devolverá. Evidentemente, si el dato no está ni en el objeto ni en la caché externa, entonces se hará la consulta en la base de datos y el dato se guardará en la caché externa y en el propio objeto.
A esta técnica la podríamos llamar «cached property on asteroids» porque es una propiedad doblemente cacheada. Hay que ir con mucho cuidado con este tipo de técnicas a la hora de tratar las modificaciones de datos porque nos tendremos que encargar de invalidar las cachés de forma adecuada o tendremos problemas de inconsistencias de datos. Usando esta técnica con cabeza, me he ahorrado miles y miles de consultas en bases de datos y muchos minutos (incluso horas) de espera de los usuarios. En el caso de «LODMV» la apliqué en un par de métodos muy usados en todo el sistema y la mejora de rendimiento fue simplemente brutal.
Hay otra técnica similar llamada «lazy method», que consiste básicamente en no hacer los cálculos costosos hasta que realmente se pide el dato, ya que puede ser que no se necesite. En tales casos, nos habremos ahorrado calcularlo, con la consiguiente mejora del rendimiento.
Desnormalizar la base de datos
Espero que te suene el concepto «normalización de bases de datos», donde, entre otros objetivos, se busca «evitar la redundancia de los datos». Partiendo de la base de que esto es vital, también es cierto que hay casos en los que, por motivos de eficiencia, desnormalizamos las bases de datos expresamente para ahorrarnos queries complejas, donde probablemente tendríamos que hacer joins entre varias tablas. Desnormalizar una base de datos es una técnica más avanzada y no es lo primero que tenemos que hacer para optimizar una aplicación que va lenta, aunque está bien tenerla como otro recurso en nuestra caja de herramientas.
En tales casos, hay que saber muy bien qué se está haciendo y tener en cuenta que la integridad de los datos corre a nuestra cuenta, ya que los podemos estar duplicando (de forma similar a cuando utilizamos cachés). Por lo tanto, a la hora de crear, actualizar y eliminar estos datos tendremos que sincronizarlos para que sigan siendo consistentes. En caso contrario, podemos llegar a tener casos perversos de corrupción de datos, que luego no nos van a dejar dormir porque no estaremos cumpliendo las características ACID de Atomicidad, Consistencia, Aislamiento y Durabilidad.
En ocasiones, usamos técnicas que rompen el concepto de «atomicidad» requerido por la primera forma normal (1NF), por ejemplo, guardando datos (listas y diccionarios) en formato JSON directamente como valores de campos de tablas en la base de datos, por razones de eficiencia y de simplicidad del código. Cuando se recurre a este tipo de técnicas, hay que aplicarlas de forma ordenada y con coherencia. Es decir, hay tener claro que los campos X e Y de la tabla T siempre contendrán los datos usando una estructura consistente y en un mismo formato concreto (JSON, XML o el que corresponda en cada caso), o bien serán valores nulos (si aplica). De hecho, existen ORM que permiten definir atributos de los modelos de tipo ArrayField o JSONField (siempre que la base de datos relacional que estemos usando lo soporte), lo cual nos ahorrará trabajo y será más eficiente que una implementación propia, sobre todo a la hora de hacer búsquedas de valores en estos campos. Por supuesto, otra opción es la de usar bases de datos no relacionales (NoSQL) como MongoDB, Redis o Elasticsearch, entre muchas otras.
A veces no hace falta llegar a desnormalizar las bases de datos y podemos usar otras técnicas que nos pueden ser muy útiles, como las llamadas «vistas materializadas». Estas se parecen mucho a las vistas «normales», pero se diferencian en que son cacheadas, con lo cual pueden ofrecer una mejora de rendimiento importante. Sin embargo, no están soportadas por todos los sistemas de gestión de bases de datos.
Usar timeouts y reintentos finitos al acceder a recursos externos
Tal y como ya he explicado en más profundidad anteriormente, es un error confiar en que los recursos externos (Internet) siempre van a estar disponibles. Por este motivo, es muy importante especificar timeouts (tiempo máximo de espera) cuando se pueda y tratar estos errores de timeout cuando se produzcan. Me he encontrado con casos en los que se quería acceder a un recurso que no estaba disponible y, como el servidor no contestaba, entonces el proceso se quedaba bloqueado esperando indefinidamente. En cambio, si especificamos un tiempo límite, nos aseguramos de que no se va a quedar bloqueado esperando eternamente.
Asimismo, permíteme recordarte nuevamente que es muy conveniente implementar un sistema de reintentos hasta un número definido de veces. Si no ponemos un límite, podríamos acabar en un bucle infinito de peticiones a un recurso que ya no está operativo. También es muy recomendable esperar un tiempo entre reintentos, e incluso dejar un lapso de tiempo cada vez mayor entre ellos.
Comprobar eficientemente si existen recursos en Internet
Si solo queremos comprobar la existencia de un fichero en un servidor remoto en Internet no hace falta que hagamos una petición GET para descargarlo, simplemente podemos hacer una petición HEAD que ya nos sirve para saber si está o no. Imagínate la ganancia cuando estamos hablando de ficheros que pesan varios megabytes. En el caso de «LODMV», una llamada que tardaba diez minutos pasó a tardar cinco, solamente cambiando unas peticiones GET por HEAD que comprobaban si existían unos documentos. En ese caso concreto, después de evaluar si esas validaciones eran realmente necesarias, finalmente decidimos eliminarlas completamente porque la penalización de tiempo que introducían no justificaba su utilidad, ¿es este tu caso, también?
Continuará...
${ commentsData.total } comentario comentarios
Todavía no hay comentarios. ¡Sé el primero!
Inicia sesión para publicar, responder o reaccionar a los comentarios.
Inicia sesión para publicar, responder o reaccionar a los comentarios.
Respuesta para ${ replyToComment?.user.full_name }