Respuesta "casi" instantánea

21 de Febrero de 2023 · 11 min de lectura

Imagen_Stock_Blog_Cache

¿Por qué una caché?

En nuestro día a día, como usuarios de todo tipo de aplicaciones y soluciones tecnológicas lo que no queremos es perder el tiempo. Buscamos obtener la respuesta a nuestras preguntas con la máxima brevedad posible. Necesitamos buscar múltiples opciones de vuelos y hotel para comparar tarifas sin esperar minutos tediosos, además está demostrado que menores tiempos de espera llevan a mejor conversión y ventas.

Para minimizar este tiempo de respuesta, en informática, se puede intentar optimizar al máximo el sistema o, además, añadir una caché.

Seguro que todos hemos oído hablar de caché en algún momento de nuestra vida laboral, de algo abstracto que nos permite reducir el tiempo de acceso. La realidad es mucho más compleja pero, para no saturar, introduciré, los que para mí, son los tres tipos básicos:

  • Low Level: Cachés capaces de almacenar instrucciones o cálculos redundantes de CPU. Suele estar ubicada dentro de la propia CPU.
  • Server Side: Cachés capaces de almacenar información específica de una aplicación. Suelen estar ubicadas en la memoria RAM de los servidores que ejecutan las aplicaciones.
  • Client Side: Cachés capaces de almacenar información específica de una aplicación cliente. Un claro ejemplo de esta podría ser la sesión que utilizan muchas páginas web para almacenar información referente al usuario que ha iniciado sesión o a datos estáticos que no cambiarán en el futuro.

Únicamente me centraré en las dos últimas ya que son las que más nos incumben y son las que realmente podemos controlar e implementar. Antes de nada, voy a responder a la pregunta que probablemente te hagas y no entiendas cómo es que aún no he respondido… ¿Qué es una caché?

Una memoria caché es una capa de almacenamiento de alta velocidad que almacena un subconjunto de datos, de modo que el acceso a estos sea más rápido que si se tuviera que acceder a través de la memoria principal.

Básicamente, nos podemos quedar con los siguientes puntos:

  • Memoria intermedia que almacena datos para su posterior acceso.
  • Permite minimizar el tiempo de acceso a dichos datos. En caso de hit, la respuesta podría ser casi instantánea.
  • Permite minimizar la carga de procesamiento tanto del servidor como del motor de base de datos.
  • Su propósito principal es reducir el tiempo de acceso a datos frecuentes.

Tipos Básicos

Ahora que tenemos una idea de qué es una caché y para qué sirve, me centraré en describir algunos de los tipos más comunes dividiéndolos en dos bloques principales:

  • Por cómo se carga.
  • Por qué se almacena.

Tipos por forma de carga

Introduciré y utilizaré el mismo ejemplo en todos los tipos de carga.

Nuestra caché será un carrito de la compra que se utiliza para almacenar productos. Puede estar vacío, lleno, con algún producto, etc.

Lo primero es saber a qué me refiero con cargar una caché. Cargar una caché (también conocido como caché warming en Inglés) es, básicamente, llenarla de datos. [Se llena el carrito de compra con todos los productos que se desee]. Esto se puede hacer de diferentes maneras, pero me centraré únicamente en cuatro de ellas:

  • COMPLETE ON START: Se leen, procesan y guardan todos los datos que almacenará la caché durante el arranque del servidor. [Se llena el carrito de compra con todos los productos que se comprará nada más cogerlo].
  • COMPLETE LAZY: Se leen, procesan y guardan todos los datos que almacenará la caché durante el primer acceso a esta. [Se tiene el carrito de compra vacío, pero cuando se va a pagar y se intenta acceder a los productos almacenados, antes se llena con todos estos].
  • INCREMENTAL ON START: Se realiza una carga parcial de los datos en el arranque del servidor. El resto se van añadiendo de manera incremental dependiendo de las necesidades.
  • INCREMENTAL: No se realiza una carga inicial. Es 100% incremental.

Tipos por datos almacenados

No me quiero extender mucho, por lo que introduciré los tres tipos de caché que más he utilizado en mi vida laboral.

Function Caching: Es una de las cachés más sencillas y consiste en almacenar el resultado de una función dados unos parámetros concretos (Ej: Servidores GraphQL como Apollo Server cachean el resultado de sus funciones para evitar llamadas innecesarias a los servicios de datos).

El acceso a los datos se hace de la siguiente manera:

  • HIT: Existe un resultado almacenado relacionado con los parámetros de entrada. Se devuelve directamente.
  • MISS: No existe un resultado almacenado relacionado con los parámetros de entrada. Por lo tanto, se ejecuta la función y, al obtener la respuesta, se almacena para su posterior acceso.

DB Cache: El objetivo principal es almacenar datos en crudo tras ejecutar una/s consulta/s sobre la base de datos. Muy parecido a Function Caching pero el primero no tiene por qué acceder a datos, puede únicamente almacenar el resultado de un algoritmo. Además, para cachear datos en crudo, podemos tener índices a nivel de arquitectura (dependiendo de la implementación, para un acceso directo, se podría tener un índice por id, external_id, email, etc.) y diferentes funciones de acceso.

El acceso a los datos se hace de la siguiente manera:

  • HIT: Existe un registro para un valor del índice concreto. Se devuelve directamente.
  • MISS: Debe acceder a base de datos para recuperar los registros. Si existen índices, se deberá realizar la indexación y luego devolver el registro.

Business Cache: El objetivo principal es almacenar datos complejos que necesitan de un procesamiento previo. Estos, a priori, suelen ser objetos de negocio que relacionan y dependen de varios modelos/funcionalidades además de necesitar un procesamiento o una gestión costosa en tiempo y recursos.

El acceso a los datos se hace de la siguiente manera:

  • HIT: Se devuelve el registro.
  • MISS: Se debe lanzar una carga individual (definida por arquitectura), procesar, indexar y devolver el registro.

Distributed Hybrid Cache

Estamos listos para entrar en el gran mundo de las cachés navegando a través de una versión de caché distribuida. Ya sé que he explicado diferentes tipos de caché, tanto por tipo de acceso como por tipo de dato almacenado, pero quiero añadir también información breve sobre el lugar de almacenamiento.

  • Client Side: Se suele utilizar la sesión del cliente, para almacenar los datos para su uso posterior. Evita realizar llamadas innecesarias al servidor.
  • Server Side: En este punto, teniendo en cuenta un Framework .NET, tendríamos dos posibilidades:
    • Context Cache: El servidor permite crear y almacenar cachés en su sesión propia compartida por todos los hilos y accesible desde cualquier petición. (Doc. oficial Microsoft)
    • Shared Cache: Utilizando el patrón singleton, se genera una instancia de un gestor común de cachés compartido por toda la instancia del servidor (esta será la solución que describiré a continuación).
  • También se puede tener caché a nivel de Base de Datos, Redis, Mongo, etc.

Ahora que sabemos más o menos dónde se almacenarán nuestros datos, vamos a empezar introduciendo algunos conceptos básicos que se utilizarán para llevar a cabo la implementación.

Singleton

Se asegura de que exista una única instancia de una clase, además de devolver un puntero global a dicha instancia.

Management Thread

Hilo gestor de cachés encargado de cargar, distribuir, recargar las instancias de caché individuales. Es un hilo activo en paralelo al hilo principal del servidor y permanece siempre activo (obviamente seguirá el mismo flujo que el servidor, por lo que si este se recicla, el hilo también lo hará).

Shared Access

Las instancias individuales de caché, que también llamaremos Gestores Individuales o Cache Managers también hacen uso del patrón singleton. Gracias a esto nos aseguramos de que no tengamos datos duplicados en memoria y nos evitamos la inconsistencia de datos, entre otros.

Cache Type

Dependiendo del tipo de caché configurada en el Cache Manager el hilo gestor es capaz de lanzar cargas automáticas al inicio de su ciclo de vida.

Access Time

En nuestra implementación, como ya se comentó, se hace uso de los punteros Shared del Framework ya que nos permiten el acceso compartido para todos los hilos del sistema. Es infinitamente más rápido utilizar un puntero directo que pasar por la arquitectura definida por Microsoft. El inconveniente principal de utilizar un Cache Engine Custom es la gestión de las instancias CacheManager, que se deberá realizar manualmente por el propio Engine.

Teniendo en cuenta estos conceptos básicos, vamos a ver un esquema de una posible arquitectura.

Supongamos que nuestro sistema está formado por tres servidores balanceados y no sabemos cuál de ellos podría recibir una petición concreta. En realidad, nos da igual ya que en teoría, todos ellos deberían tener el mismo código y la misma configuración.

Ahora vamos a suponer también que queremos cachear información referente a usuarios únicos del sistema.

Nuestra caché tendrá almacenados todos los usuarios con sus datos básicos identificativos (id, nombre, apellidos, documento_identidad, email, telefono, direccion_1, codigo_postal…). En este punto he supuesto muchas cosas que en la vida real se deberían tener en cuenta (qué datos cargar, activos/inactivos, validaciones, etc.).

Posteriormente entraré más en detalle, pero a grandes rasgos, el esquema anterior se podría explicar como:

  • Cada servidor debería tener su propio Cache Manager (gestionado por su propio hilo paralelo al sistema). Internamente, cada gestor tiene sus propias instancias de caché independientes. Con nuestro ejemplo y para facilitar el entendimiento, cada servidor tendrá su instancia de caché de usuarios.
  • El Cache Manager es el encargado de lanzar las cargas (si son completas) de sus cachés internas. Estas podrán o no acceder a base de datos, dependiendo de su naturaleza.
  • También existe la posibilidad de tener cachés 100% distribuídas, eso significa que no tendríamos registros almacenados a nivel de servidor. Todos los datos estarían en Redis y los gestores individuales se encargarían únicamente de realizar las peticiones y parsear los datos.

Arquitectura de Managers

Hasta este punto he estado hablando de Instancias de caché individuales, Cache Managers, gestores… Pero ¿qué son realmente?

Un Cache Manager es una instancia encargada de gestionar todo lo relacionado con un tipo de datos ya sea básico o complejo. Cuando digo todo lo relacionado me refiero a carga, acceso y actualización. Además, al utilizar el patrón singleton para su creación, nos aseguramos de que únicamente tengamos una única instancia.

Las principales características serían:

  • Clase Base: Todos nuestros gestores deberán heredar de una clase base, así nos aseguraremos de que todos implementen ciertas funciones y tengan acceso a ciertas propiedades. Además nos ayudará a tener mayor control sobre qué instancias almacena el Gestor Principal de la caché (el encargado de tener apuntadas todas las CacheManager del sistema) permitiendo comprobar el tipo base a la hora de apuntar la instancia.
  • Función de carga privada: Todos los gestores deberán tener una función de carga privada. Únicamente será accesible por el gestor principal de la caché por medio de reflection. Nos interesa que sea privada ya que nos aseguramos de que nadie pueda instanciar a nuestro manager por fuera del flujo base del Engine.
  • Configuración: Tendremos la opción de establecer valores de configuración por cada gestor individual para que el principal pueda tomar decisiones a la hora de cargar, distribuir, identificar, etc.
  • Distribución: Puesto que nuestro Engine permitiría distribuir los datos (ya sea cargas iniciales o actualizaciones) deberemos tener también funciones que permitan dicho flujo.
  • Funciones públicas de acceso: La única manera de acceder a los datos almacenados dentro de nuestros gestores será a través de funciones públicas.

Ahora que sabemos qué es un Cache Manager vamos a explicar cómo podemos cargar y cómo acceder a los datos almacenados.

Tenemos dos opciones principales: el Cacheo de Datos Genérico y el Cacheo de Datos Custom.

Cacheo de Datos Genérico

Es importante tener en cuenta que para el cacheo genérico no es necesario que el programador defina ningún tipo de estructura de datos, funciones de acceso o de indexado. Únicamente deberá definir una consulta a base de datos.

El Engine proporciona:

  • Carga automática de datos dada una consulta a base de datos.
  • Mapeado e indexado automático de los registros tras su carga. Existencia de índices únicos (cada índice apunta a un único registro) e índices múltiples (cada índice apunta a una lista de registros).
  • Clases definidas para la gestión y apuntado de datos.
  • Funciones de acceso por índice. (Get, GetList, etc.)

Cuando se llame a la función privada de carga, el manager pasará su consulta de base de datos a las funciones internas del Engine y este cargará, mapeará e indexará todos los registros.

Dado el ejemplo de Caché de Usuarios, una posible implementación tendría:

  • La consulta a base de datos en SQL plano.
  • La instancia que contiene todos los registros e índices (pertenece a una clase proporcionada por el propio Engine).
  • Funciones de acceso como GetUser (haría uso del acceso por índice único a través del ID), GetUserByEmail (igual que GetUser devolvería el valor a través de un índice único por Email), GetUsersByPostalCode (devolvería una lista de usuarios a través de un índice múltiple por codigo_postal), etc.

Generar Cache de Datos Custom

La principal diferencia entre el Custom y el Genérico es que en este caso el programador debe definir la estructura de datos, el indexado, el mapeo y las funciones de acceso a los datos. Además, al no hacer uso de las clases proporcionadas por el Engine, es él el que debe gestionar el origen de los datos (consultas a Base de Datos, funciones de cálculo, etc.).

Personalmente, esta es la más utilizada ya que da mucha más libertad a la hora de implementar las cachés. Además, suele ser más rápida al ahorrarnos clases intermedias que lo único que hacen es añadir más saltos entre punteros. Somos nosotros los que definimos los índices que queremos y somos nosotros los que elegimos la estructura de datos que queremos para nuestra caché.

Otro punto en el que es superior a la genérica es que no estamos obligados a almacenar únicamente datos planos con la misma estructura que la base de datos, podemos generar y guardar objetos de negocio complejos. El límite es nuestra imaginación.

Distribución y actualización de datos entre servidores

Sabemos que podemos guardar datos en memoria del servidor, pero ¿es necesario que todos ellos realicen la carga inicial y tengan que procesar todos los datos si son compartidos entre servidores? ¿Qué pasa cuando se actualiza un registro? ¿Podemos tener ese dato actualizado sin tener que refrescar toda la información de la caché?

Depende de cómo se configure la caché, no sería necesario que todas las instancias de los diferentes servidores cargaran y mapearan los datos. Se podría tener un servidor que haga todo el trabajo de procesamiento (Master) y luego compartiera su estructura de datos ya creada con el resto para así ahorrarnos procesamiento innecesario y redundante.

Así en cuanto se inicien todos los servidores, el primero que obtenga el token de Master será el que cargue los datos y los distribuya al resto.

Una vez tenemos los datos cargados, si queremos actualizar los registros y que esta actualización esté presente en todos los servidores, podríamos hacer uso de redis para propagar los valores.

Es sencillo si seguimos el flujo que veremos a continuación:

  • Cualquier cliente de nuestro sistema puede realizar un cambio. Si seguimos con el ejemplo de usuarios, alguien actualiza el nombre del usuario con ID=1. Esta modificación se notifica a Redis especificando la caché, el ID actualizado y podríamos también enviar el valor, aunque no sea necesario ya que en general nunca cargamos las cachés propiedad a propiedad, siempre suele ser el registro relacionado al ID entero.
  • Redis siempre notifica al servidor master, que como hemos dicho será único que procese datos, para que cargue el nuevo registro con los nuevos datos y los envíe a Redis.
  • En cuanto le llega un aviso de cambio, Redis notifica a todos los servidores slave con su caché específica, el ID y el registro entero ya mapeado y actualizado con la nueva información. Estos al recibir dicha notificación únicamente cambian el puntero para el ID=1 (en nuestro ejemplo) apuntando a la instancia del nuevo registro.

Ahora sabemos cargar y actualizar, pero ¿qué pasa si queremos invalidar una caché? En este caso tendríamos dos opciones:

  1. El hilo principal es el encargado de cada X tiempo configurado refrescar todas las caché. Esto es sencillo ya que él tiene apuntados todos los gestores y puede realizar la llamada a la carga por detrás y cuando está todo cargado, solo reapuntar la instancia. Así no se pierden datos y nadie tiene que esperar, ya como mucho habrá lecturas sucias.
  2. Tener cachés infinitas. No necesitamos invalidarla si sus datos están siempre actualizados con la última información.

Conclusiones

Ha sido un viaje rápido y superficial sobre el mundo de las cachés y más concretamente sobre una implementación interesante de caché distribuida. Hemos visto algunos ejemplos y sobre todo hemos entendido el concepto básico que podríamos extrapolar a cualquier sistema.

Con esto no quiero decir que sea necesario implementarlas siempre. Únicamente si el sistema lo requiere y tenemos la posibilidad de invertir. Además, su uso depende de otros muchos factores igual de importantes como los usuarios concurrentes, tiempos de acceso deseados, datos que queremos almacenar, carga del sistema, etc.

Comparte este artículo
Etiquetas
Artículos recientes