El éxtasis y la agonía de los cachés

En estos años de creación de servicios en Amazon hemos experimentado diversas versiones del siguiente escenario: construimos un nuevo servicio y este servicio requiere realizar ciertas llamadas de la red para completar sus solicitudes. Quizás estas llamadas son una base de datos relacionales, o un servicio de AWS como Amazon DynamoDB, u otro servicio interno. En pruebas simples o en índices bajos de solicitudes el servicio funciona genial, pero notamos que hay un problema en el horizonte. El problema podría ser que las llamadas a este otro servicio son lentas o que la base de datos es costosa como para escalar como aumentos en el volumen de llamadas. También notamos que muchas solicitudes utilizan el mismo recurso posterior o los mismos resultados de consultas, por lo que creemos que almacenar en caché estos datos podría ser la respuesta a nuestros problemas. Añadimos caché y nuestro servicio aparece haber mejorado. Observamos que la latencia de las solicitudes es baja, se redujo el costo y las pequeñas caídas de disponibilidad posterior se suavizan. Después de un tiempo, nadie puede recordar la vida antes del caché. Las dependencias reducen el tamaño de sus flotas en consecuencia, y la base de datos se reduce. Cuando todo parece estar yendo bien, el servicio se equilibra para el desastre. Podría haber cambios en los patrones del tráfico, fallas en la flota del caché o alguna otra circunstancia imprevista que podría llevar a un caché frío o no disponible de otro modo. A su vez puede provocar un aumento en el tráfico de servicios posteriores que puede llevar a interrupciones tanto en nuestras dependencias como en nuestro servicio.

Acabamos de describir un servicio que se ha vuelto adicto a su caché. El caché se ha elevado de manera inadvertida y pasó de ser una adición útil a ser un servicio y una parte necesaria y crítica de su capacidad de operar. En el centro de este problema se encuentra el comportamiento modal que introdujo el caché, con diferente comportamientos que dependen de si se almacena en caché un determinado objeto. Un cambio no anticipado en la distribución de este comportamiento modal puede llevar potencialmente al desastre.

Hemos experimentado tanto los beneficios como los desafíos del almacenamiento en caché en el transcurso de las creaciones y las operaciones de servicios en Amazon. El resto de este artículo describe nuestras lecciones aprendidas, las mejores prácticas y las consideraciones para utilizar cachés.

Cuando usamos el almacenamiento en caché

Diversos factores nos llevan a considerar añadir un caché a nuestro sistema. En muchas ocasiones, esto comienza con la observación de la latencia o la eficacia de una dependencia en un índice de solicitud determinado. Por ejemplo, podría ser cuando determinamos que una dependencia podría comenzar a limitarse o que podría no ser capaz de mantenerse al día con la carga anticipada. Hemos observado que sería útil considerar el almacenamiento en caché cuando encontramos patrones de solicitud no uniformes que conducen a la limitación de teclas de acceso rápido/particiones rápidas. Los datos de esta dependencia son buenos candidatos para el almacenamiento en caché si dicho caché tendría un buen índice de coincidencia de caché entre las solicitudes. Es decir, se pueden usar los resultados de las llamadas de la dependencia en múltiples solicitudes u operaciones. Si cada solicitud generalmente requiere una única consulta para el servicio dependiente con resultados únicos por solicitud, entonces un caché tendría un índice de coincidencia insignificante y el caché no ayudaría. Una segunda consideración es en qué medida son tolerantes un servicio de equipo y sus clientes con la consistencia final. Con el tiempo, los datos en caché necesariamente aumentan en forma inconsistente con el origen, por lo que el almacenamiento en caché solo puede funcionar correctamente si se compensan tanto el servicio como sus clientes. El índice de cambio de los datos de origen, así como la política de caché para actualizar datos, determinará qué tan inconsistentes tienden a ser los datos. Estos dos factores están relacionados entre sí. Por ejemplo, los datos de cambio lento o relativamente estáticos se pueden almacenar en caché durante períodos de tiempo más prolongados.

Cachés locales

Los cachés de servicio se pueden implementar tanto en la memoria como de manera externa al servicio. Los cachés en caja, que comúnmente se implementan en la memoria de proceso, son relativamente rápidos y fáciles de implementar y pueden proporcionar mejoras significativas con muy poco trabajo. Los cachés en caja suelen ser el primer enfoque en implementarse y evaluarse una vez que se identifica la necesidad de almacenamiento en caché. Al contrario de los cachés externos, no producen gastos operativos adicionales, por lo que prácticamente no hay riesgo al integrarlos con un servicio existente. Solemos implementar un caché en caja como una tabla hash en memoria que se administra mediante la lógica de la aplicación (por ejemplo, colocando explícitamente resultados en el caché una vez completadas las llamadas de servicio) o se integra en el cliente del servicio (por ejemplo, utilizando un cliente HTTP de almacenamiento en caché).

A pesar de los beneficios y de la atractiva simplicidad de los cachés en memoria, también presentan algunas desventajas. Una de ellas es que los datos almacenados no serán consistentes entre los servidores de la flota, lo que pone de manifiesto un problema de coherencia de caché. Si un cliente realiza llamadas repetidas al servicio, podría recibir datos más nuevos usados en la primera llamada y datos más antiguos en la segunda, dependiendo de qué servidor lidie con la solicitud.

Otra desventaja es que la carga posterior ahora es proporcionar al tamaño de la flota del servicio, por lo tanto, a medida que aumenta el número de servidores, sigue siendo posible que los servicios dependientes se vean abrumados. Hemos notado que un modo efectivo de controlar esto es emitiendo métricas sobre las coincidencias/pérdidas de caché y la cantidad de solicitudes realizadas en los servicios posteriores.

Los cachés en memoria también son susceptibles a problemas de “arranque en frío”. Estos problemas ocurren cuando se lanza un nuevo servidor con un caché completamente vacío, lo que podría ocasionar un aumento de solicitudes al servicio dependiente a medida que llena su caché. Esto puede ser un problema significativo durante las implementaciones o en otras circunstancias en las que el caché se carga en toda la flota. Los problemas de caché vacío y de coherencia en caché se pueden abordar mediante el uso de fusión de solicitudes, que se describe en mayor detalle más adelante en este artículo.

Cachés externos

Los cachés externos pueden abordar muchos de los problemas que acabamos de analizar. Un caché externo almacena datos de caché en una flota separada, por ejemplo utilizando Memcached o Redis. Los problemas de coherencia del caché se reducen debido a que el caché externo contiene el valor que utilizan todos los servidores de la flota. (Tenga en cuenta que estos problemas no se eliminan por completo ya que pueden existir casos de falla cuando se actualiza el caché). La carga general de los servicios posteriores se reduce en comparación con los cachés en memoria y no es proporcionar al tamaño de la flota. Los problemas de arranque en frío durante eventos como implementaciones no están presentes dado que el caché externo permanece sin completar durante la implementación. Por último, los cachés externos proporcionan más espacio de almacenamiento disponible que los cachés en memoria, lo que reduce las ocurrencias de expulsión de caché debido a limitaciones de espacio.

Sin embargo, los cachés externos vienen con su propio conjunto de deficiencias a tener en cuenta. La primera es una mayor carga operativa y complejidad del sistema en general, dado que hay una flota adicional que controlar, manejar y escalar. Las características de disponibilidad de la flota de caché serán diferentes del servicio dependiente para el que actúan como caché. La flota de caché suele estar menos disponible, por ejemplo, si no tiene soporte para actualizaciones sin tiempo de inactividad y si requiere ventanas de mantenimiento.

Para evitar que la disponibilidad del servicio se vea degradado por el caché externo, notamos que debemos añadir un código de servicio para lidiar con la falta de disponibilidad de la flota de caché, la falla del nodo del caché o las fallas para poner/recibir caché. Una opción es retroceder para llamar al servicio dependiente, pero hemos aprendido que debemos ser cuidadosos cuando tomamos este enfoque. Durante una interrupción de caché extendida, esto provocará un pico atípico en el tráfico para el servicio posterior, lo que lleva a la limitación o la disminución de ese servicio dependiente y, al final, se reduce la disponibilidad. Preferimos usar el caché externo junto con un caché en memoria al que podemos recurrir en caso de que el caché externo no esté disponible, o usar desbordamiento de carga y limitar el índice máximo de solicitudes que se envían al servicio posterior. Probamos el comportamiento del servicio con el almacenamiento en caché deshabilitado para validar que las protecciones que implementamos para evitar la caída de las dependencias están funcionando realmente como esperábamos.

Una segunda consideración es la escalación y la elasticidad de la flota de caché. A medida que la flota de caché comienza a alcanzar su índice de solicitudes o límites de memoria, se deberán añadir nodos. Determinamos qué métricas son los principales indicadores de estos límites para poder establecer controles y alarmas en consecuencia. Por ejemplo, en un servicio en el que trabajé recientemente, nuestro equipo notó que el uso del CPU llegó a un nivel muy alto cuando el índice de solicitud Redis llegó a su límite. Utilizamos las pruebas de carga con patrones de tráfico realistas a fin de determinar el límite y hallar el umbral correcto de la alarma.

A medida que añadimos capacidad a la flota de caché, nos ocupamos de hacerle de un modo que no provoque interrupciones o una pérdida masiva de datos de caché. Las diferentes tecnologías de almacenamiento en caché tienen consideraciones únicas. Por ejemplo, ciertos servidores de caché no admiten la adición de nodos a un clúster sin tiempo de inactividad, y no todas las bibliotecas de clientes de caché brindan un hash consistente, lo cual es necesario para agregar nodos a la flota de caché y redistribuir los datos almacenados. Debido a la variabilidad de las implementaciones del cliente del hash consistente y el descubrimiento de nodos en la flota de caché, probamos cuidadosamente agregar y eliminar servidores de caché antes de entrar en producción.

Con un caché externo, tenemos un cuidado adicional para asegurar la solidez a medida que el formato de almacenamiento cambia. Los datos en caché se tratan como si estuvieran en un almacén persistente. Nos aseguramos de que el software actualizado siempre pueda leer los datos que escribió una versión anterior del software, y que las versiones anteriores puedan fácilmente ver nuevos formatos/campos (por ejemplo, durante las implementaciones cuando la flota tiene una mezcla de código antiguo y nuevo). Es necesario evitar las excepciones no capturadas cuando se encuentran formatos imprevistos a fin de evitar daños. Sin embargo, esto no es suficiente para evitar todos los problemas relacionados con el formato. Detectar una incoherencia en el formato de la versión y descartar los datos almacenados en caché pueden conducir a una actualización masiva de los cachés, que a su vez puede llevar a una limitación o una caída en el servicio dependiente. Los problemas de formato de serialización se describen con mayor profundidad en el artículo Asegurar la seguridad en las reversiones durante las implementaciones.

Una consideración final para los cachés externos es que se actualizan mediante nodos individuales en la flota de servicio. Los cachés generalmente no tienen características como funciones condicionales put y de transacciones, por lo que nos aseguramos de garantizar que el caché que actualiza el código sea correcto y que nunca deje el caché en estado no válido o inconsistente.

Caché en línea en comparación con caché de un lado

Otra decisión que debemos tomar cuando evaluamos diferentes enfoques de almacenamiento en caché es la opción de usar cachés en línea o de un lado. Los cachés en línea, o cachés de lectura/escritura, integran el manejo de caché a la principal API de acceso de datos, lo que hace que el manejo de caché sea un detalle de implementación de esa API. Algunos ejemplos incluyen implementaciones específicas de la aplicación como Amazon DynamoDB Accelerator (DAX) e implementaciones basadas en estándares, como el almacenamiento en caché de HTTP (ya sea con un cliente de caché local o un servidor de caché externo como Nginx o Varnish). Los cachés de lado, por el contrario, son tiendas de objetos genéricos como las proporcionadas por Amazon ElastiCache (Memcached y Redis) o bibliotecas como Ehcache y Google Guava para los cachés en memoria. Con los cachés de lado, el código de la aplicación manipula directamente el caché antes y después de las llamadas al origen de datos, para verificar los objetos almacenados antes de realizar las llamadas posteriores, y colocar los objetos en caché una vez completadas las llamadas.

El principal beneficio de un caché en línea es un modelo de API uniforme para los clientes. El almacenamiento en caché se puede agregar, eliminar o ajustar sin cambiar la lógica del cliente. Un caché en línea también extrae la lógica de manejo de caché del código de la aplicación, eliminando así una fuente de potenciales errores. Los cachés HTTP son particularmente atractivos ya que existen numerosas opciones disponibles listas para usar, tales como las bibliotecas en memoria, los proxies HTTP independientes como los que mencionamos anteriormente, y servicios gestionados como las redes de entrega de contenido (CDN).

Sin embargo, la transparencia de los cachés en línea también puede ser una desventaja para la disponibilidad. Los cachés externos ahora son parte de la ecuación de disponibilidad para esta dependencia. No hay oportunidad para que el cliente compense un caché no disponible en forma temporal. Por ejemplo, si tiene una flota Varnish que los cachés solicitan desde un servicio REST externo, si esa flota de caché disminuye, desde la perspectiva de su servicio es como si la dependencia misma disminuyera. La otra desventaja del caché en línea es que se debe integrar al protocolo o al servicio para el cual se está almacenando. Si no hay un caché en línea disponible para el protocolo, entonces este almacenamiento en línea no es una opción a menos que desee crear un cliente integrado o servicio proxy usted mismo.

Vencimiento del caché

Algunos de los detalles de implementación de caché más desafiantes son elegir el tamaño de caché, la política de vencimiento y la política de expulsión adecuados. La política de expulsión determina cuánto tiempo se retiene un elemento en el caché. La política más común utiliza una expulsión absoluta basada en el tiempo (es decir, se asocia con un período de vida (TTL) con cada objeto mientras se carga). El TTL se elige en función de los requerimientos del cliente, tales como qué tan tolerante puede ser el cliente con los datos obsoletos, y qué tan estáticos son los datos, ya que los datos que cambian lentamente pueden almacenarse de manera más agresiva. El tamaño de caché ideal se basa en un modelo del volumen anticipado de solicitudes y la distribución de objetos en caché entre dichas solicitudes. A partir de eso, calculamos el tamaño de caché que garantiza un alto índice de coincidencia de caché con estos patrones de tráfico. La política de expulsión controla cómo se eliminan los elementos del caché cuando alcanza su capacidad. La política de expulsión más común es la Menos usada recientemente (LRU).

Hasta ahora, es solo un ejercicio difícil. Los patrones de tráfico del mundo real puede diferir de nuestro modelo, por eso rastreamos el rendimiento real de nuestro caché. Nuestro modo preferido para hacer esto es emitir métricas de servicio sobre coincidencias y pérdidas de caché, tamaño total del caché y cantidad de solicitudes a servicios posteriores.

Hemos aprendido que debemos reflexionar sobre la elección de los valores de la política de vencimiento y el tamaño del caché. Deseamos evitar la situación en la que un desarrollador elige en forma arbitraria ciertos valores de TTL y de tamaño de caché durante la implementación inicial y nunca vuelve ni valida su adecuación en el futuro. Hemos visto ejemplos del mundo real de esta falta de seguimiento que llevan a interrupciones temporales del servicio y a la exacerbación de interrupciones en curso.

Otro patrón que usamos para mejorar la resiliencia cuando no hay servicios posteriores disponibles es usar dos TTL: un TTL blando y un TTL duro. El cliente intentará actualizar los elementos almacenados en caché de conformidad con el TTL blando, pero si el servicio posterior no está disponible o si no responde de otro modo con la solicitud, se seguirán usando los datos de caché existentes hasta alcanzar el TTL duro. Se utiliza un ejemplo de este patrón en el cliente AWS Identity and Access Management (IAM).

También usamos el enfoque de TTL blando y duro con contrapresión para reducir el impacto de las caídas en el servicio posterior. El servicio posterior puede responder con un evento de contrapresión cuando está cayendo, lo que indica que el servicio de llamada debería usar datos almacenados hasta el TTL duro y solo debería realizar solicitudes de datos que no están en su caché. Continuamos con esto hasta que el servicio posterior elimine la contrapresión. Este patrón permite que el servicio posterior se recupere de una caída mientras mantiene la disponibilidad de los servicios posteriores.

Otras consideraciones

Una importante consideración es cuál es el comportamiento del caché cuando se reciben errores del servicio posterior. Una opción para lidiar con esto es responder a los clientes utilizando el último valor bueno almacenado, por ejemplo aprovechando el patrón de TTL blando /TTL duro que se describió anteriormente. Otra opción que empleamos es almacenar la respuesta de error (es decir, usamos un “caché negativo”) utilizando un TTL diferente a las entradas de caché positivas, y propagamos el error al cliente. El enfoque que elegimos en una determinada situación depende de las particularidades del servicio y de evaluar cuándo es mejor para los clientes ver los datos obsoletos en comparación con los errores. Independientemente de qué enfoque tomemos, es importante asegurarnos de que algo está en el caché en casos de error. Si no es el caso y el servicio posterior está temporalmente no disponible o por algún otro motivo no puede cumplir con ciertos requerimientos (por ejemplo cuando se elimina un recurso posterior), el servicio anterior continuará bombardeándolo con tráfico y potencialmente le provocará una interrupción o exacerbará una existente. Hemos visto ejemplos del mundo real en los que por no poder almacenar respuestas negativas en caché, se aumentaron los índices de fallas y los errores.

La seguridad es otro aspecto importante del almacenamiento en caché. Cuando introducimos un caché a un servicio, evaluamos y mitigamos cualquier riesgo de seguridad adicional que introduce. Por ejemplo, las flotas de caché externas suelen no contar con cifrado para datos serializados y para la seguridad a nivel de transporte. Esto es muy importante especialmente si se retiene información confidencial del usuario en el caché. El problema se puede mitigar si usamos algo como Amazon ElastiCache for Redis, que admite el cifrado en movimiento y en reposo. Los cachés también son susceptibles a los ataques dañinos, en los que una vulnerabilidad en el protocolo posterior permite que un atacante complete un caché con un valor bajo su control. Esto amplifica el impacto de un ataque, dado que todas las solicitudes que se hagan mientras este valor está en el caché verán el valor malintencionado. Para dar un ejemplo final, los cachés también son susceptibles a los ataques de tiempo de canal lateral. Los valores en caché regresan más rápidamente que los valores no almacenados, por lo que un atacante puede usar tiempo de respuesta para obtener información sobre las solicitudes que otros clientes están realizando.

Una última cosa a tener en cuenta es la situación de “rebaño estruendoso”, en la que muchos clientes realizan solicitudes que requieren el miso recurso posterior no almacenado aproximadamente al mismo tiempo. Esto puede ocurrir cuando surge un servidor y se une a la flota con un caché local vacío. Esto resulta en una gran cantidad de solicitudes de cada servidor que va a la dependencia posterior, que puede llevar a limitación/caída. A fin de remediar este problema, usamos la fusión de solicitudes, en la que los servidores o el caché externo se aseguran de que solo quede una solicitud pendiente para los recursos no almacenados. Algunas bibliotecas de caché así como algunos caché en línea externos (como Nginx o Varnish) admiten la fusión de solicitudes. Además, la fusión de solicitudes se puede implementar sobre los cachés existentes. 

Mejores prácticas y consideraciones de Amazon

Este artículo ha tocado varias de las mejores prácticas y compensaciones de Amazon asociados con el almacenamiento en caché. Este es un resumen de las mejores prácticas y las consideraciones de Amazon que nuestros equipos utilizan cuando introducen un caché:

• Asegúrese de que haya una necesidad legítima de caché que se justifique en términos de mejoras en costo, latencia o disponibilidad. Asegúrese de que los datos se puedan almacenar en caché, lo que significa que se puedan usar en múltiples solicitudes de cliente. Sea escéptico del valor que le brinda un caché, y evalúe cuidadosamente que los beneficios superen los riesgos adicionales que introduce el caché.
• Planee operar el caché con el mismo rigor y los mismos procesos utilizados para el resto de la flota y la infraestructura de servicio. No subestime este esfuerzo. Emita métricas sobre el uso del caché y el índice ce coincidencias a fin de asegurarse de que el caché esté correctamente ajustado. Controle los indicadores clave (como el CPU y la memoria) para asegurarse de que la flota de almacenamiento en caché externo está en buenas condiciones y que funciona de manera correcta. Configure alarmas para estas métricas. Asegúrese de que la flota de almacenamiento pueda escalar sin tiempo de inactividad o invalidación de caché masiva (es decir, valide que haya hash consistente funcionando tal como se espera).
• Sea deliberado y empírico en la elección del tamaño de caché, política de vencimiento y política de expulsión. Realice pruebas y use las métricas mencionadas en la viñeta anterior para validar y ajustar estas opciones.
• Asegúrese de que sus servicios sean resilientes frente a la no disponibilidad de caché, que incluye una variedad de circunstancias que llevan a la incapacidad de servir solicitudes utilizando datos en caché. Estos incluyen arranques en frío, interrupciones de flota de caché, cambios en los patrones de tráfico o interrupciones posteriores extendidas. En muchos casos, esto podría significar compensar cierta parte de su disponibilidad para asegurarse de que sus servidores y sus servicios dependientes no caigan (por ejemplo, desbordando la carga, limitando las solicitudes a los servicios dependientes o sirviendo datos obsoletos). Ejecute pruebas de carga con cachés deshabilitados para validar esto.
• Tenga en cuenta los aspectos de seguridad de mantener datos almacenados en caché, que incluyen cifrado, seguridad de transporte cuando se comunica con una flota de caché externa y el impacto de los ataques dañinos de caché y de ataques de canal lateral.
• Diseñe el formato de almacenamiento para que los objetos en caché evolucionen con el tiempo (por ejemplo, use un número de versión) y escriba un código de serialización capaz de leer versiones más antiguas. Esté atento a los daños en su lógica de serialización de caché.
• Evalúe cómo el caché manejará los errores posteriores, y piense en cómo mantener un caché negativo con un TTL diferente. No provoque o amplifique una interrupción al pedir repetidas veces por el mismo recurso posterior y desechar las respuestas de error.

Muchos equipos de servicio de Amazon usan técnicas de caché. A pesar de los beneficios de estas técnicas, no tomamos a la ligera la decisión de incorporar almacenar en caché ya que las desventajas pueden superar las ventajas. Esperamos que este artículo le ayude en el momento de evaluar el almacenamiento en caché en sus propios servicios.


Acerca de los autores

Matt es ingeniero jefe de dispositivos emergentes en Amazon, donde trabaja en el software y los servicios de los próximos dispositivos para clientes. Anteriormente, trabajó en AWS Elemental, a cargo del equipo que lanzó Media Tailor, un servicio de inserción de anuncios personalizados del lado del servidor para los vídeos en vivo y bajo demanda. Asimismo, ayudó a lanzar la primera temporada de PrimeVideo con la transmisión de NFL Thrusday Night Football. Antes de Amazon, Matt pasó 15 años en la industria de la seguridad, en empresas como McAfee, Intel y algunas empresas emergentes, trabajando en gestión de seguridad empresarial, anti-malware y tecnologías contra la vulnerabilidad en la seguridad, medidas de seguridad asistidas por hardware y DRM.

Jas Chhabra es el principal ingeniero de AWS. Se unió a AWS en 2016 y trabajó en AWS IAM durante un par de años antes de cambiarse a su puesto actual en AWS Machine Learning. Antes de AWS, trabajó en Intel en diversos puestos técnicos en las áreas de IoT, identidad y seguridad. Actualmente está interesado en el aprendizaje automático, la seguridad y los sistemas distribuidos a gran escala. Antes se especializó en temas como IoT, bitcoins, identidad y criptografía. Cuenta con un máster en ciencias informáticas.

Evitar los planes alternativos en los sistemas distribuidos Uso de la eliminación de carga para evitar la sobrecarga Asegurar la seguridad en las reversiones durante las implementaciones