Ir al contenido
10215

Blog


Cómo DoorDash estandarizó y mejoró el almacenamiento en caché de microservicios

19 de octubre de 2023

|
Lev Neiman

Lev Neiman

Jason Fan

Jason Fan

A medida que la arquitectura de microservicios de DoorDash ha ido creciendo, también lo ha hecho el volumen de tráfico entre servicios. Cada equipo gestiona sus propios datos y expone el acceso a través de servicios gRPC, un marco de llamadas a procedimientos remotos de código abierto utilizado para crear API escalables. La mayor parte de la lógica empresarial está ligada a la E/S debido a las llamadas a servicios posteriores. El almacenamiento en caché es desde hace tiempo una estrategia para mejorar el rendimiento y reducir costes. Sin embargo, la falta de un enfoque uniforme del almacenamiento en caché ha generado complicaciones. Aquí explicamos cómo hemos racionalizado el almacenamiento en caché a través de una biblioteca Kotlin, ofreciendo a los desarrolladores backend una forma rápida, segura y eficiente de introducir nuevos cachés.

Aumentar el rendimiento al tiempo que se respalda la lógica empresarial

En el mundo de los microservicios de DoorDash, la atención se centra más en la implementación de la lógica empresarial que en la optimización del rendimiento. Si bien la optimización de los patrones de E/S en el código podría mejorar el rendimiento, reescribir la lógica empresarial para hacerlo llevaría mucho tiempo y consumiría muchos recursos. El problema, por tanto, es cómo aumentar el rendimiento sin modificar el código existente. 

Una solución ortodoxa es el almacenamiento en caché, que consiste en guardar copias de los datos a los que se accede con frecuencia cerca del lugar donde se necesitan para mejorar la velocidad y el rendimiento de las solicitudes posteriores. El almacenamiento en caché puede añadirse de forma transparente al código de lógica empresarial simplemente sobrecargando los métodos utilizados para recuperar datos. 

Las cachés más comunes en DoorDash son Caffeine para la caché local y Redis Lettuce para la caché distribuida. La mayoría de los equipos utilizan clientes Caffeine y Redis Lettuce directamente en su código.

Dado que existen problemas comunes con el almacenamiento en caché, muchos equipos se encontraban con problemas similares al aplicar sus propios enfoques independientes.

Problemas:

  1. Caducidad de la caché: Aunque implementar el almacenamiento en caché de un método es sencillo, resulta complicado garantizar que la caché permanezca actualizada con la fuente de datos original. Resolver los problemas que surgen de las entradas obsoletas de la caché puede ser complejo y llevar mucho tiempo.
  2. Gran dependencia de Redis: con frecuencia, los servicios sufrían un alto índice de fallos cuando Redis no funcionaba o experimentaba problemas.
  3. Sin control en tiempo de ejecución: Introducir una nueva caché puede ser arriesgado debido a la falta de ajustes en tiempo real. Si la caché tiene problemas o necesita ajustes, los cambios requieren una nueva implantación o una reversión, lo que consume tiempo y recursos de desarrollo. Además, para ajustar parámetros de la caché como TTL
  4. Esquema de claves incoherente: La ausencia de un enfoque estandarizado para las claves de caché complica los esfuerzos de depuración. En concreto, es difícil rastrear cómo se corresponde una clave de la caché de Redis con su uso en el código de Kotlin.
  5. Métricas y observabilidad inadecuadas: La ausencia de métricas uniformes en todos los equipos provocó una falta de datos críticos, como las tasas de aciertos de la caché, los recuentos de solicitudes y las tasas de error. 

Dificultad para implantar la caché multicapa: la configuración anterior no permitía fácilmente el uso de varias capas de caché para el mismo método. Combinando una caché local y una caché Redis, que consume más recursos, se podrían optimizar los resultados antes de recurrir al fallback.

Sueña a lo grande, empieza poco a poco

Aunque al final creamos una biblioteca de almacenamiento en caché compartida para todo DoorDash, empezamos con un programa piloto para solucionar los problemas de almacenamiento en caché de un solo servicio: el backend de DashPass. Queríamos probar e iterar sobre nuestra solución antes de adoptarla en otros lugares.

En aquel momento, DashPass estaba experimentando problemas de escalado y frecuentes caídas de tensión. DoorDash crecía rápidamente y aumentaba el tráfico cada semana. DashPass era uno de los mayores usuarios de nuestra base de datos Postgres compartida; una base de datos de la que dependía casi todo DoorDash; si se caía, los clientes no podrían hacer pedidos.

Al mismo tiempo, estábamos desarrollando rápidamente nuevas funciones y casos de uso para DashPass, por lo que el ancho de banda de los desarrolladores para ajustar el rendimiento era escaso. 

Con toda esta actividad crítica en paralelo a la presión por estabilizar el servicio -incluso cuando la mayoría de los ingenieros estaban ocupados gestionando funciones relacionadas con el negocio-, decidimos desarrollar una sencilla biblioteca de almacenamiento en caché que pudiera integrarse de forma transparente y con una interrupción mínima.

Una única interfaz para gobernarlos a todos

Cada equipo utilizaba distintos clientes de caché, como Caffeine, Redis Lettuce o HashMaps, por lo que había poca coherencia en las firmas de las funciones y las API. Para estandarizar esto, introdujimos una interfaz simplificada para que los desarrolladores de aplicaciones la utilizaran al configurar nuevas cachés, como se muestra en el siguiente fragmento de código:

interface CacheManager {
    /**
   * Wraps fallback in Cache.  
   * key: Instance of CacheKey.  
   *      Subclasses of CacheKey define a unique cache with a unique 
   *      name, which can be configured via runtime.
   * fallback: Invoked on a cache miss. The return value is then cached and 
   *           returned to the caller.
   */
    suspend fun <V> withCache(
        key: CacheKey<V>,
        fallback: suspend () -> V?
    ): Result<V?>
}
/**
 * Each unique cache is tied to a particular implementation of the key.
 *
 * CacheKey controls the cache name and the type of unique ID.
 *
 * Name of the cache is the class name of the implementing class.
 * all implementations should use a unique class name.
 */
abstract class CacheKey<V>(
    val cacheKeyType: String,
    val id: String,
    val config: CacheKeyConfig<V>
)
/**
 * Cache specific config.
 */
class CacheKeyConfig<V>(   /**
     * Kotlin serializer for the return value. This is used to store values in Redis.
     */
    val serializer: KSerializer<V>
)

Esto nos permite utilizar la inyección de dependencias y el polimorfismo para inyectar lógica arbitraria entre bastidores, manteniendo al mismo tiempo las llamadas de caché uniformes de la lógica de negocio.

Cachés por capas

Queríamos adoptar una interfaz simplificada para la gestión de la caché a fin de facilitar a los equipos que antes utilizaban una sola capa la mejora del rendimiento mediante un sistema de caché de varias capas. A diferencia de una sola capa, las capas múltiples pueden aumentar el rendimiento porque algunas capas, como la caché local, son mucho más rápidas que las capas que implican llamadas a la red -por ejemplo, Redis-, que ya es más rápida que la mayoría de las llamadas a servicios.

En una caché multicapa, una solicitud de clave avanza por las capas hasta que se encuentra la clave o hasta que llega a la última función de reserva de la fuente de la verdad (SoT). Si el valor se recupera de una capa posterior, se almacena en capas anteriores para un acceso más rápido en posteriores solicitudes de la misma clave. Este mecanismo de recuperación y almacenamiento por capas optimiza el rendimiento al reducir la necesidad de llegar a la SoT.

Hemos implementado tres capas detrás de una interfaz común, como se muestra en la Figura 1:

  1. Caché local de peticiones: Vive sólo durante el tiempo de vida de la solicitud; utiliza un simple HashMap.
  2. Caché local: Visible para todos los trabajadores dentro de una única máquina virtual Java; utiliza una caché Caffeine para el trabajo pesado.
  3. Caché Redis: Visible para todos los pods que comparten el mismo cluster Redis; utiliza cliente Lettuce.

Figura 1: Flujo de peticiones de caché multicapa

Control de la bandera de características en tiempo de ejecución

Diversos casos de uso pueden requerir diferentes configuraciones o la desactivación de capas enteras de almacenamiento en caché. Para que esto sea mucho más rápido y sencillo, hemos añadido el control en tiempo de ejecución. Esto nos permite incorporar nuevos casos de uso de la caché una vez en el código y, a continuación, realizar un seguimiento en tiempo de ejecución para su despliegue y ajuste.

Cada caché único puede ser controlado individualmente a través del sistema de ejecución de DoorDash. Cada caché puede ser:

  • Activado o desactivado. Esto puede ser útil si una estrategia de caché recién introducida tiene un error. En lugar de hacer un despliegue rollback, podemos simplemente desactivar la caché. En modo off, la librería invoca fallback, saltándose todas las capas de caché por completo.
  • Reconfigurado para un tiempo de vida (TTL) individual. Si se establece el TTL de una capa en cero, se omitirá por completo. 
  • Sombreado en un porcentaje especificado. En modo sombra, un porcentaje de las peticiones a la caché también comparará el valor almacenado en caché con el SoT.

Observabilidad y sombra de caché

Para medir el rendimiento de la caché, recopilamos métricas sobre cuántas veces se solicita una caché y cuántas veces las solicitudes resultan en un acierto o un fallo. La proporción de aciertos de la caché es la principal métrica de rendimiento; nuestra biblioteca recopila métricas de proporción de aciertos para cada caché y capa únicas.

Otra métrica importante es la frescura de las entradas de la caché en comparación con el SoT. Nuestra biblioteca proporciona un mecanismo de sombra para medir esto. Si el shadowing está activado, un porcentaje de las lecturas de la caché también invocarán el fallback y compararán los valores de la caché y del fallback en busca de igualdad. Las métricas sobre coincidencias exitosas y fallidas pueden ser graficadas y alertadas. También podemos medir el estancamiento de la caché, es decir, la latencia entre la creación de la entrada de la caché y el momento en que se actualiza el SoT. Medir la caducidad de la caché es fundamental porque cada caso de uso tendrá una tolerancia diferente a la caducidad.

Además de las métricas, cualquier fallo también genera registros de errores, que detallan la ruta en los objetos que difiere entre los valores almacenados en caché y los originales. Esto puede ser útil para depurar cachés obsoletas.

Para validar empíricamente una estrategia de invalidación de caché, es fundamental poder observar el estancamiento de la caché.

Ejemplo de uso

Veamos un ejemplo y profundicemos en la API de la biblioteca.

Cada clave de caché tiene tres componentes principales:

  1. Nombre único de la caché, que se utiliza como referencia en los controles en tiempo de ejecución. 
  2. Tipo de clave de caché, una cadena que representa el tipo de entidad de la clave para permitir la categorización de las claves de caché.
  3. ID, una cadena que hace referencia a alguna entidad única de tipo clave de caché.
  4. Configuración, que incluye TTLs por defecto y un serializador Kotlin.

Para estandarizar el esquema de claves, elegimos el formato de nombre uniforme de recursos(URN):

urn:doordash:<cache key type>:<id>#<cache name>

La librería proporciona una instancia de CacheManager, que se inyecta y tiene un método `withCache` que envuelve un fallback u otra función de suspensión de Kotlin para ser cacheada.

Por ejemplo, si tenemos un repositorio UserProfileRepository con un método GetUserProfile que queremos almacenar en caché, podríamos añadir la siguiente clave:

class UserProfileRepositoryGetUserProfileKey(userId: String): CacheKey<UserProfile>(
cacheKeyType = "user",
id = userId,
config = CacheKeyConfig(serializer = UserProfile.serializer())
)
...
suspend fun getUserProfile(userId: String): UserProfile = CacheManager.withCache(UserProfileRepositoryGetUserProfileKey(userId)) {
... <Fetch user profile> ...
}.getOrThrow()

Una clave para el usuario con id "123" se representaría como un URN de la siguiente manera: 

urn:doordash:user:123#UserProfileRepositoryGetUserProfileKey

Tenga en cuenta que cualquier otra CacheKey que utilice "user" como tipo de clave de caché compartirá el mismo prefijo que UserProfileRepositoryGetUserProfileKey. 

La estandarización de la representación de las claves es ideal para depurar la observabilidad y abre oportunidades únicas para la concordancia de patrones de claves.

Guía de casos prácticos

Una vez creada y probada la biblioteca en DashPass, el siguiente paso era hacerla llegar a los desarrolladores y ayudarles a integrarla en su trabajo de la forma más fluida posible. Para ello, ofrecimos una guía de alto nivel sobre cuándo y cómo usar la caché y, lo que es igual de importante, cuándo no usarla.

Cuándo utilizar la caché

Podemos dividir los casos de uso de la memoria caché por eventuales restricciones de coherencia. 

Categoría 1: tolera la caché obsoleta

En ciertos casos de uso, es aceptable tener unos minutos de retraso para que las actualizaciones surtan efecto. En estas situaciones, es seguro utilizar las tres capas de caché: caché local de petición, caché local y capa Redis. Puedes configurar el TTL de cada capa para que expire en varios minutos. El ajuste TTL más largo en todas las capas determinará el tiempo máximo para que la caché sea consistente con la fuente de datos. 

Controlar la tasa de aciertos de la caché es crucial para optimizar el rendimiento; ajustar la configuración TTL puede ayudar a mejorar esta métrica. 

En este escenario, no hay necesidad de implementar shadowing para controlar la precisión de la caché.

Categoría 2: No tolera la memoria caché obsoleta

Cuando los datos están sujetos a cambios frecuentes, la información obsoleta podría afectar negativamente a las métricas empresariales o a la experiencia del usuario. Resulta crucial limitar la caducidad máxima tolerable a unos pocos segundos o incluso milisegundos. 

Por lo general, el almacenamiento local en caché debe evitarse en este tipo de situaciones, ya que no puede invalidarse fácilmente. Sin embargo, la caché a nivel de petición puede ser adecuada para el almacenamiento temporal. 

Aunque es posible establecer un TTL más largo para la capa Redis, es esencial invalidar la caché tan pronto como cambien los datos subyacentes. La invalidación de la caché puede implementarse de varias formas, como borrando las claves Redis relevantes al actualizar los datos o utilizando un enfoque de etiquetado para eliminar cachés cuando la coincidencia de patrones es difícil. 

Existen dos opciones principales para los disparadores de invalidación. El método preferido es utilizar eventos de captura de datos de cambios emitidos cuando se actualizan las tablas de la base de datos, aunque este enfoque puede implicar cierta latencia. Alternativamente, la caché podría ser invalidada directamente dentro del código de la aplicación cuando los datos cambian. Esto es más rápido pero potencialmente más complejo porque múltiples ubicaciones de código pueden potencialmente introducir nuevos cambios. 

Es crucial habilitar la sombra de caché para monitorizar el estancamiento porque esta visibilidad es vital para verificar la efectividad de la estrategia de invalidación de caché.

Cuándo no utilizar la caché

Flujos de escritura o mutación

Es una buena idea reutilizar el código tanto como sea posible para que su punto final de escritura pueda reutilizar la misma función en caché que sus puntos finales de lectura. Pero esto presenta un problema potencial de estancamiento cuando escribes en la base de datos y luego lees el valor de vuelta. La lectura de un valor obsoleto puede romper la lógica de negocio. En su lugar, es seguro desactivar el almacenamiento en caché por completo para estos flujos mientras se reutiliza la misma función almacenada en caché fuera del CacheContext.

Como fuente de verdad

No utilices la caché como base de datos ni confíes en ella como fuente de verdad. Tenga siempre en cuenta que las capas de caché caducan y disponga de un sistema alternativo que consulte la fuente correcta de la verdad.

Conclusión

Los microservicios de DoorDash se enfrentaban a importantes retos como resultado de prácticas de almacenamiento en caché fragmentadas. Al centralizar estas prácticas en una biblioteca integral, hemos racionalizado drásticamente nuestra escalabilidad y reforzado la seguridad en todos nuestros servicios. Con la introducción de una interfaz estandarizada, métricas coherentes, un esquema de claves unificado y configuraciones adaptables, hemos fortalecido el proceso de introducción de nuevas cachés. Además, al ofrecer orientaciones claras sobre cuándo y cómo utilizar la caché, hemos evitado posibles escollos e ineficiencias. Esta revisión estratégica nos ha permitido aprovechar al máximo el potencial de la caché y evitar los errores más comunes.

Manténgase informado con las actualizaciones semanales

Suscríbase a nuestro blog de ingeniería para estar al día de los proyectos más interesantes en los que trabaja nuestro equipo.

About the Authors

  • Lev Neiman

    Lev Neiman is a Software Engineer at DoorDash, since June 2021, working for DashPass platform team where he helps scale DashPass in volume and complexity.

  • Jason Fan

    Jason Fan is a Software Engineer at DoorDash, since June 2021, working for DashPass Partnerships team to unlock and accelerate sign-ups through partner integrations.

Trabajos relacionados

Ubicación
Nueva York, NY
Departamento
Ingeniería
Ubicación
Sunnyvale, CA; San Francisco, CA
Departamento
Ingeniería
Ubicación
Toronto, ON
Departamento
Ingeniería
Ubicación
New York, NY; San Francisco, CA; Sunnyvale, CA; Los Angeles, CA; Seattle, WA
Departamento
Ingeniería
Ubicación
San Francisco, CA; Sunnyvale, CA
Departamento
Ingeniería