Cómo evitar el Cache Stampede

API7.ai

December 29, 2022

OpenResty (NGINX + Lua)

En el artículo anterior, aprendimos algunas técnicas de optimización de alto rendimiento con shared dict y lru cache. Sin embargo, dejamos atrás un problema importante que merece su propio artículo hoy: "Cache Stampede".

¿Qué es un Cache Stampede?

Imaginemos un escenario.

La fuente de datos está en una base de datos MySQL, los datos en caché están en un shared dict, y el tiempo de expiración es de 60 segundos. Durante los 60 segundos en que los datos están en la caché, todas las solicitudes obtienen los datos de la caché en lugar de MySQL. Pero una vez que pasan los 60 segundos, los datos en la caché expiran. Si hay una gran cantidad de solicitudes concurrentes, no se podrá consultar ningún dato en la caché. Entonces, se activará la función de consulta de la fuente de datos, y todas estas solicitudes irán a la base de datos MySQL, lo que provocará directamente que el servidor de la base de datos se bloquee o incluso se caiga.

Este fenómeno se puede llamar "Cache Stampede", y a veces se le conoce como Dog-Piling. Ninguno de los códigos relacionados con la caché que aparecieron en las secciones anteriores tiene un tratamiento correspondiente. A continuación, se muestra un ejemplo de pseudocódigo que tiene el potencial de provocar un cache stampede.

local value = get_from_cache(key)
if not value then
    value = query_db(sql)
    set_to_cache(value, timeout = 60)
end
return value

El pseudocódigo parece tener una lógica correcta, y no se desencadenará un cache stampede utilizando pruebas unitarias o pruebas de extremo a extremo. Solo una prueba de estrés prolongada revelará el problema. Cada 60 segundos, la base de datos tendrá un pico regular de consultas. Pero si se establece un tiempo de expiración de la caché más largo aquí, las posibilidades de detectar el problema de la tormenta de caché se reducen.

¿Cómo evitarlo?

Dividamos la discusión en varios casos diferentes.

1. Actualización proactiva de la caché

En el pseudocódigo anterior, la caché se actualiza de manera pasiva y solo se consulta la base de datos para obtener nuevos datos cuando se solicita pero se encuentra un fallo en la caché. Por lo tanto, cambiar la forma en que se actualiza la caché de pasiva a activa puede evitar el problema del cache stampede.

En OpenResty, podemos implementarlo de la siguiente manera.

Primero, usamos ngx.timer.every para crear una tarea de temporizador que se ejecute cada minuto para obtener los últimos datos de la base de datos MySQL y colocarlos en el shared dict:

local function query_db(premature, sql)
    local value = query_db(sql)
    set_to_cache(value, timeout = 60)
end

local ok, err = ngx.timer.every(60, query_db, sql)

Luego, en la lógica del código que maneja la solicitud, necesitamos eliminar la parte que consulta MySQL y mantener solo la parte del código que obtiene la caché del shared dict.

local value = get_from_cache(key)
return value

Los dos fragmentos de pseudocódigo anteriores pueden ayudarnos a evitar el problema del cache stampede. Pero este enfoque no es perfecto, cada caché debe corresponder a una tarea periódica (hay un límite superior en el número de temporizadores en OpenResty), y el tiempo de expiración de la caché y el tiempo de ciclo de la tarea programada deben coincidir bien. Si hay algún error durante este período, la solicitud puede seguir obteniendo datos vacíos.

Por lo tanto, en proyectos reales, generalmente usamos bloqueos para resolver el problema del cache stampede. A continuación, se presentan algunos métodos diferentes de bloqueo, puedes elegir el que mejor se adapte a tus necesidades.

2. lua-resty-lock

Cuando se trata de agregar bloqueos, puede parecer difícil, pensando que es una operación pesada, y qué pasa si ocurre un bloqueo y tienes que manejar bastantes excepciones.

Podemos aliviar esta preocupación utilizando la biblioteca lua-resty-lock en OpenResty para agregar bloqueos. lua-resty-lock es una biblioteca resty de OpenResty, que se basa en un shared dict y proporciona una API de bloqueo no bloqueante. Veamos un ejemplo simple.

resty --shdict='locks 1m' -e 'local resty_lock = require "resty.lock"
                            local lock, err = resty_lock:new("locks")
                            local elapsed, err = lock:lock("my_key")
                            -- query db and update cache
                            local ok, err = lock:unlock()
                            ngx.say("unlock: ", ok)'

Dado que lua-resty-lock se implementa utilizando un shared dict, primero necesitamos declarar el nombre y el tamaño del shdict y luego usar el método new para crear un nuevo objeto lock. En el fragmento de código anterior, solo pasamos el primer parámetro, el nombre del shdict. El método new tiene un segundo parámetro, que se puede usar para especificar el tiempo de expiración, el tiempo de espera para el bloqueo y muchos otros parámetros. Aquí mantenemos los valores predeterminados. Estos parámetros se utilizan para evitar bloqueos y otras excepciones.

Luego podemos llamar al método lock para intentar obtener un bloqueo. Si logramos adquirir el bloqueo, podemos asegurarnos de que solo una solicitud vaya a la fuente de datos para actualizar los datos en el mismo momento. Pero si el bloqueo falla debido a la prelación, el tiempo de espera, etc., entonces los datos se obtienen de la caché obsoleta y se devuelven al solicitante. Esto nos lleva a la API get_stale introducida en la lección anterior.

local elapsed, err = lock:lock("my_key")
# elapsed to nil significa que el bloqueo falló. El valor de retorno de err es uno de timeout, locked
if not elapsed and err then
    dict:get_stale("my_key")
end

Si lock tiene éxito, entonces es seguro consultar la base de datos y actualizar los resultados en la caché, y finalmente llamamos a la interfaz unlock para liberar el bloqueo.

Combinando lua-resty-lock y get_stale, tenemos la solución perfecta al problema del cache stampede. La documentación de lua-resty-lock proporciona un código muy completo para manejarlo. Si estás interesado, puedes consultarlo aquí.

Profundicemos un poco más y veamos cómo la interfaz lock implementa el bloqueo. Cuando nos encontramos con alguna implementación interesante, siempre queremos ver cómo se implementa en el código fuente, que es una de las ventajas del código abierto.

local ok, err = dict:add(key, true, exptime)
if ok then
    cdata.key_id = ref_obj(key)
    self.key = key
    return 0
end

Como se mencionó en el artículo sobre shared dict, todas las API de shared dict son operaciones atómicas y no hay necesidad de preocuparse por la contención. Por lo tanto, es una buena idea usar shared dict para marcar el estado de los bloqueos.

La implementación de lock anterior usa dict:add para intentar establecer la clave: si la clave no existe en la memoria compartida, add devolverá éxito, lo que indica que el bloqueo fue exitoso; otras solicitudes concurrentes devolverán fallo cuando lleguen a la lógica de la línea de código dict:add, y luego el código puede elegir si devolver directamente o reintentar varias veces basándose en la información de err devuelta.

3. lua-resty-shcache

En la implementación anterior de lua-resty-lock, necesitas manejar el bloqueo, desbloqueo, obtención de datos obsoletos, reintentos, manejo de excepciones y otros problemas, lo cual sigue siendo bastante tedioso.

Aquí tienes un envoltorio simple: lua-resty-shcache, que es una biblioteca lua-resty de Cloudflare, hace una capa de encapsulación sobre los diccionarios compartidos y el almacenamiento externo y proporciona funciones adicionales para la serialización y deserialización, por lo que no tienes que preocuparte por los detalles anteriores:

local shcache = require("shcache")

local my_cache_table = shcache:new(
        ngx.shared.cache_dict
        { external_lookup = lookup,
          encode = cmsgpack.pack,
          decode = cmsgpack.decode,
        },
        { positive_ttl = 10,           -- cache good data for 10s
          negative_ttl = 3,            -- cache failed lookup for 3s
          name = 'my_cache',     -- "named" cache, useful for debug / report
        }
    )

local my_table, from_cache = my_cache_table:load(key)

Este código de muestra está extraído del ejemplo oficial y ha ocultado todos los detalles. Esta biblioteca de encapsulación de caché no es la mejor opción, pero es un buen material de aprendizaje para principiantes. El siguiente artículo presentará algunas otras encapsulaciones mejores y más utilizadas.

4. Directivas de NGINX

Si no estás utilizando la biblioteca lua-resty de OpenResty, también puedes usar las directivas de configuración de NGINX para el bloqueo y la obtención de datos obsoletos: proxy_cache_lock y proxy_cache_use_stale. Sin embargo, no recomendamos usar la directiva de NGINX aquí, ya que no es lo suficientemente flexible y su rendimiento no es tan bueno como el código Lua.

Resumen

Los cache stampedes, como el problema de carrera que hemos mencionado repetidamente antes, son difíciles de detectar a través de revisiones de código y pruebas. La mejor manera de resolverlos es mejorar tu codificación o usar una biblioteca de encapsulación.

Una última pregunta: ¿Cómo manejas el cache stampede y similares en los lenguajes y plataformas con los que estás familiarizado? ¿Hay una mejor manera que OpenResty? No dudes en compartirlo conmigo en los comentarios.