Diseñando una estrategia de caché

Gabriel Martínez
Bancolombia Tech
Published in
8 min readJun 16, 2021
Foto de Janko Ferlic en Pexels
Foto de Janko Ferlic en Pexels

Desde sus inicios, la caché es conocida en el mundo del hardware y el software. Existe por una razón: nos permite disminuir la latencia e incrementar el rendimiento de cualquier operación de cómputo “costosa”, cuyo resultado permita ser almacenado para materializar una
ganancia de tiempo
.

Ante la explosión de tecnologías cloud y la omnipresencia de sistemas y componentes altamente distribuidos, tal ganancia en tiempo es crítica, especialmente para la experiencia de los usuarios de nuestras soluciones.

¿Cuándo debemos usar una caché?

Muchas veces notamos que nuestro sistema no tiene el rendimiento esperado o empieza a degradarse a partir de cierta tasa de peticiones. Es mas, observamos que la afectación tiene que ver con una o varias dependencias de datos para los cuales el sistema proveedor no es capaz de mantener la demanda.

Esos datos deberían almacenarse en una caché, siempre que tengamos una buena relación hit/miss ratio. Es decir, si el resultado de la consulta con una llave específica es aprovechable por múltiples procesos que pueden usar esa información (muchos hits); o si, por el contrario, la consulta con una llave específica resulta ser aprovechable solo por uno o pocos de los procesos invocadores (demasiados miss). Debemos encontrar el
equilibrio entre ambos.

Otro factor a tener en cuenta es la consistencia eventual de los datos y la tolerancia de nuestro sistema a que la información en caché diste de la almacenada en la dependencia de datos. La información muta naturalmente por las operaciones llevadas a cabo en nuestro sistema o los sistemas relacionados.

Por lo tanto, antes de optar por este tipo de memoria debemos conocer si nuestro sistema tolera esa consistencia eventual. Adicionalmente debemos tener en cuenta el tiempo que la información va a permanecer en la caché, y los procesos para mantener consistente dicha información respecto a la dependencia de datos.

Tipos de caché

Hay básicamente tres tipos de caché y sus variaciones:

  • Caché local.
  • Caché centralizado.
  • Caché distribuido.

Caché local

Este tipo de caché es implementado como una capacidad de almacenamiento en memoria (el mismo espacio de memoria del proceso que lo usa). Es relativamente fácil de implementar y de integrar en una solución existente. Podemos usar esta caché cuando la cantidad de información a almacenar es pequeña, predecible y no muta mucho en el tiempo.

Aplicación con cache local

Este tipo de caché viene con algunos trade-offs. El primero de estos es que el estado de caché entre réplicas de un proceso será siempre inconsistente. Por lo tanto, si un cliente de nuestro servicio hace múltiples peticiones y cada una de ellas es atendida por un nodo diferente, existe la posibilidad de que obtengamos distintas respuestas.

Aplicaciones con caché local, cada una con una copia de datos. Ante una consulta al caché, ambas obtienen versiones diferentes de B.

El segundo, es el peso que le ponemos al proceso de Garbage Collection. Recordemos que la información está almacenada en memoria y esta será sujeta al constante trabajo de un proceso que la escanea para determinar qué puede ser reclamado. Este impacto dependerá del tamaño de la información y el tiempo de expiración que definimos a los datos en el caché. Adicionalmente debemos tener cuidado de no sobrepasar el heap de memoria pre-establecido, así que debemos definir un limite a la cantidad de elementos a almacenar.

Implementando caché local con Reactor

La función cache() de un flujo reactivo permite convertir el mono en un hot source, de manera que cualquier suscriptor va a recibir la ultima señal emitida, incluidas las señales “Completion y “ Error.

Cache del resultado emitido por el flujo reactivo

Incluso podemos configurar el tiempo en que el mono será un hot source, pasando como argumento al método cache un parámetro duration ttl.

Caché local con la utilidad reactor-extra

Tenemos otra opción de manejo de caché que podemos lograr mediante el uso de un add-on de Reactor llamado reactor-extra.

Con reactor-extra tenemos un mayor control, ya que podemos usar funciones separadas para la lectura del caché (lookup), obtener información de la dependencia externa de datos (onCacheMissResume) y la escritura en el caché (andWriteWith).

Cabe anotar que requerimos proporcionar alguna estructura para el almacenamiento de los datos. En el ejemplo usamos un AtomicReference para almacenar un solo valor, pero una solución real requerirá de algo más sofisticado, por ejemplo: Caffeine.

Caché centralizado

En este tipo de caché la información es almacenada por fuera de nuestro proceso y ofrece una vista unificada de los datos para todos los nodos que dependan del caché, de esta manera, reducimos la ocurrencia de la inconsistencia de la información entre nodos.

Cache centralizado

Los trade-offs de este tipo de caché son la latencia de red y la disponibilidad del servicio. Debemos tener en cuenta que es un elemento más en nuestra solución que deberemos monitorear y administrar. Adicionalmente es necesario que incluyamos código en la solución para solventar errores en los flujos de lectura/escritura de la caché.

Caché centralizada con Redis

Una caché centralizada muy popular es Redis. Este spring-data nos ofrece acceso de manera reactiva usando Lettuce. Con el siguiente ejemplo podemos ver una configuración y uso muy básico:

Conexión a Redis con spring-data

Igualmente, aquí también podemos usar del add-on de reactor-extra para manejar la abstracción de caché e interactuar con nuestro cliente redis reactivo:

Redis con Spring-data y Reactor Extra

Caché Distribuido

En este tipo de cachés la información no está consolidada en una sola unidad de cómputo, sino particionada y balanceada en diferentes nodos. Su ubicación es transparente para el proceso que requiere consultar información de la caché.

Para este tipo de cachés existen soluciones de mercado como Apache Ignite, EHcache, Hazelcast, entre otros.

Tomando cartas en el asunto

En el marco del proyecto para la modernización de canales digitales, nos propusimos definir e implementar una librería (llamada Bin-stash) que nos permitirá implementar rápidamente el uso de una caché local o centralizado. Con un pequeño ‘twist’: Podemos usarla en un modelo híbrido, algo parecido a un Near Cache. En en este tipo de cachés, se usa un ‘front cache’ para el local, y un ‘back cache’ para centralizados, como Redis.

Con este tipo de caché tenemos las siguientes ventajas:

  • Ofrece tiempos de respuesta muy rápidos en el caché local para la información que se accede de manera repetitiva.
  • Incrementamos la concurrencia al no convertir el caché centralizado en un cuello de botella.
  • Los procesos solo recurren al caché centralizado cuando la información no se encuentra localmente, además podemos definir reglas para hacer ‘pull’ de esa información al caché local.
  • También es factible hacer ‘push’ de información que almacenamos en el caché local y llevarlo al cache centralizado en una sola operación.

Es importante recalcar que con este modelo tenemos la responsabilidad de balancear muy bien el tiempo de expiración local y las reglas para hacer push y pull para incrementar la coherencia de la información almacenada en los diferentes nodos.

Usando bin-stash en modo local solamente

Lo primero es incluir la dependencia bin-stash-local, para esta demostración también incluiremos la dependencia a reactor-extra.

build.gradle

Ahora configuramos el caché:

Hay dos parámetros que la librería buscará en la configuración de nuestra aplicación:

stash:
memory:
expireTime: 10
maxSize: 10_000
  • expireTime: es el tiempo que los valores van a permanecer almacenados en el caché antes de ser eliminados.
  • maxSize: se refiere a la cantidad máxima de memoria que vamos a destinar para el almacenamiento.

Por último usamos la caché en combinación con reactor-extra así:

QueryHandler.java

Usando bin-stash en modo centralizado solamente

En este modo usamos Redis como caché centralizado, con acceso reactivo vía Lettuce como cliente. La implementación cambia muy poco desde la perspectiva del desarrollador. La dependencia a incluir es:

compile 'com.github.bancolombia:bin-stash-centralized:1.0.2'

Para declarar el ObjectCache en nuestra clase de configuración, usamos el CentralizedCacheFactory<Person> en lugar del LocalCacheFactory.

@Bean
public ObjectCache<Person> objectCache(CentralizedCacheFactory<Person> centralizedCacheFactory) {
return centralizedCacheFactory.newObjectCache();
}

La configuración a nivel de propiedades es:

stash:
redis:
expireTime: 60
host: localhost
port: 6379
  • expireTime: es el tiempo que los valores van a permanecer almacenados en el cache antes de ser eliminados.
  • host, port, database y password: los parámetros para establecer la conexión a Redis.

Y la implementación del QueryHandler es exactamente la misma que la definida en el ejemplo con caché local.

Usando bin-stash en modo híbrido (Near Cache)

Ahora veamos cómo podemos usar bin-stash en modo híbrido, es decir, caché local y cache centralizado (con Redis) al mismo tiempo.

La dependencia que debemos usar es:

compile 'com.github.bancolombia:bin-stash-hybrid:1.0.2'

Ahora la configuración luce así:

Configuración de reglas y del ObjectCache para el modo híbrido
stash:
memory:
expireTime: 10
maxSize: 10_000
redis:
expireTime: 60
host: localhost
port: 6379

Podemos notar que en esta implementación de caché se requiere una colección de SyncRules para indicarle a bin-stash cuándo debe mover datos del caché local hacia el cache centralizado (push/upstream), y viceversa (pull/ downstream).

SyncRule es una interfaz funcional con una función apply, la cual recibe dos argumentos.

  • keyArg: el valor de la llave almacenada en el cache.
  • syncType: el tipo de actualización para la cual aplica la regla (UPSTREAM o DOWNSTREAM).
@FunctionalInterface
public interface SyncRule {
boolean apply(String keyArg, SyncType syncType);
}

Y, de nuevo, la implementación del QueryHandler es exactamente la misma que la definida en el ejemplo con caché local.

Miremos en detalle como sería uno de los flujos, en este caso el de consulta:

Flujo consulta en bin-stash

La llave será buscada siempre en el caché local; en caso de no encontrarla, se evaluará una regla que haga match con esa llave y bin-stash determinará si se debe buscar en la caché centralizada.

Si se encuentra la llave en la caché centralizada, se evaluará una regla para determinar si el valor se descarga a la caché local, la cual existe en el espacio de memoria del proceso que inició la consulta. De esta manera las futuras búsquedas sobre la misma llave resolverán el valor muy rápido de manera local.

Cuando el flujo retorne, Mono.empty(), y usemos reactor-extra; utilizaríamos la función onCacheMissResume() para buscar la información en la dependencia externa de datos; buscando que pueda ser almacenada
en la caché.

La librería la encontrarás en el siguiente enlace, los aportes a la misma son bienvenidos.

Para conocer más sobre nuestros proyectos Open Source en Bancolombia, visítanos dando clic en este enlace de github.

--

--