Cómo mejorar el rendimiento de la aplicación ASP.NET en Web Farm con almacenamiento en caché

Publicado: 2022-03-11

Solo hay dos cosas difíciles en Ciencias de la Computación: invalidación de caché y nombrar cosas.

  • Autor: Phil Karlton

Una breve introducción al almacenamiento en caché

El almacenamiento en caché es una técnica poderosa para aumentar el rendimiento a través de un truco simple: en lugar de hacer un trabajo costoso (como un cálculo complicado o una consulta de base de datos compleja) cada vez que necesitamos un resultado, el sistema puede almacenar, o almacenar en caché, el resultado de ese trabajo y simplemente proporcionarlo la próxima vez que se solicite sin necesidad de volver a realizar ese trabajo (y puede, por lo tanto, responder tremendamente más rápido).

Por supuesto, toda la idea detrás del almacenamiento en caché funciona solo mientras el resultado que almacenamos en caché siga siendo válido. Y aquí llegamos a la parte difícil real del problema: ¿Cómo determinamos cuándo un elemento almacenado en caché se vuelve inválido y necesita ser recreado?

El almacenamiento en caché es una técnica poderosa para aumentar el rendimiento

El caché en memoria de ASP.NET es extremadamente rápido
y perfecto para resolver el problema de almacenamiento en caché de la granja web distribuida.
Pío

Por lo general, una aplicación web típica tiene que lidiar con un volumen mucho mayor de solicitudes de lectura que de solicitudes de escritura. Es por eso que una aplicación web típica que está diseñada para manejar una gran carga está diseñada para ser escalable y distribuida, implementada como un conjunto de nodos de nivel web, generalmente llamado granja. Todos estos hechos tienen un impacto en la aplicabilidad del almacenamiento en caché.

En este artículo, nos centramos en el papel que puede desempeñar el almacenamiento en caché para garantizar un alto rendimiento y rendimiento de las aplicaciones web diseñadas para manejar una gran carga, y voy a utilizar la experiencia de uno de mis proyectos y proporcionar una solución basada en ASP.NET. como una ilustracion.

El problema de manejar una carga alta

El problema real que tenía que resolver no era original. Mi tarea era hacer que un prototipo de aplicación web monolítica ASP.NET MVC fuera capaz de manejar una gran carga.

Los pasos necesarios para mejorar las capacidades de rendimiento de una aplicación web monolítica son:

  • Permita que ejecute varias copias de la aplicación web en paralelo, detrás de un balanceador de carga, y atienda todas las solicitudes concurrentes de manera efectiva (es decir, hágala escalable).
  • Perfile la aplicación para revelar los cuellos de botella de rendimiento actuales y optimizarlos.
  • Utilice el almacenamiento en caché para aumentar el rendimiento de las solicitudes de lectura, ya que normalmente constituye una parte importante de la carga general de las aplicaciones.

Las estrategias de almacenamiento en caché a menudo implican el uso de algún servidor de almacenamiento en caché de middleware, como Memcached o Redis, para almacenar los valores almacenados en caché. A pesar de su alta adopción y aplicabilidad comprobada, estos enfoques tienen algunas desventajas, que incluyen:

  • Las latencias de red introducidas al acceder a los servidores de caché separados pueden ser comparables a las latencias de acceder a la propia base de datos.
  • Las estructuras de datos del nivel web pueden no ser adecuadas para la serialización y la deserialización listas para usar. Para usar servidores de caché, esas estructuras de datos deben admitir la serialización y la deserialización, lo que requiere un esfuerzo de desarrollo adicional continuo.
  • La serialización y la deserialización agregan una sobrecarga de tiempo de ejecución con un efecto adverso en el rendimiento.

Todos estos temas eran relevantes en mi caso, por lo que tuve que explorar opciones alternativas.

Cómo funciona el almacenamiento en caché

El caché en memoria ASP.NET integrado ( System.Web.Caching.Cache ) es extremadamente rápido y se puede usar sin sobrecarga de serialización y deserialización, tanto durante el desarrollo como en el tiempo de ejecución. Sin embargo, la caché en memoria de ASP.NET también tiene sus propios inconvenientes:

  • Cada nodo de nivel web necesita su propia copia de los valores en caché. Esto podría resultar en un mayor consumo de nivel de base de datos en el arranque en frío o el reciclaje del nodo.
  • Cada nodo de nivel web debe recibir una notificación cuando otro nodo invalide cualquier parte de la memoria caché al escribir valores actualizados. Dado que la memoria caché está distribuida y sin la sincronización adecuada, la mayoría de los nodos devolverán valores antiguos, lo que suele ser inaceptable.

Si la carga adicional del nivel de la base de datos no genera un cuello de botella por sí misma, entonces la implementación de una memoria caché distribuida correctamente parece una tarea fácil de manejar, ¿verdad? Bueno, no es una tarea fácil , pero es posible . En mi caso, los puntos de referencia mostraron que el nivel de la base de datos no debería ser un problema, ya que la mayor parte del trabajo se realizó en el nivel web. Por lo tanto, decidí optar por el caché en memoria de ASP.NET y centrarme en implementar la sincronización adecuada.

Presentamos una solución basada en ASP.NET

Como se explicó, mi solución fue usar el caché en memoria de ASP.NET en lugar del servidor de almacenamiento en caché dedicado. Esto implica que cada nodo de la granja web tenga su propio caché, consulte la base de datos directamente, realice los cálculos necesarios y almacene los resultados en un caché. De esta forma, todas las operaciones de la memoria caché serán muy rápidas gracias a la naturaleza en memoria de la memoria caché. Por lo general, los elementos almacenados en caché tienen una vida útil clara y se vuelven obsoletos con algún cambio o escritura de nuevos datos. Entonces, desde la lógica de la aplicación web, generalmente está claro cuándo se debe invalidar el elemento de caché.

El único problema que queda aquí es que cuando uno de los nodos invalida un elemento de caché en su propio caché, ningún otro nodo sabrá acerca de esta actualización. Por lo tanto, las solicitudes posteriores atendidas por otros nodos generarán resultados obsoletos. Para abordar esto, cada nodo debe compartir sus invalidaciones de caché con los otros nodos. Al recibir dicha invalidación, otros nodos podrían simplemente eliminar su valor almacenado en caché y obtener uno nuevo en la próxima solicitud.

Aquí, Redis puede entrar en juego. El poder de Redis, en comparación con otras soluciones, proviene de sus capacidades Pub/Sub. Cada cliente de un servidor Redis puede crear un canal y publicar algunos datos en él. Cualquier otro cliente puede escuchar ese canal y recibir los datos relacionados, de manera muy similar a cualquier sistema controlado por eventos. Esta funcionalidad se puede utilizar para intercambiar mensajes de invalidación de caché entre los nodos, por lo que todos los nodos podrán invalidar su caché cuando sea necesario.

Un grupo de nodos de nivel web de ASP.NET que utilizan un backplane de Redis

El caché en memoria de ASP.NET es sencillo en algunos aspectos y complejo en otros. En particular, es sencillo en el sentido de que funciona como un mapa de pares clave/valor, pero hay mucha complejidad relacionada con sus estrategias de invalidación y dependencias.

Afortunadamente, los casos de uso típicos son lo suficientemente simples y es posible usar una estrategia de invalidación predeterminada para todos los elementos, lo que permite que cada elemento de caché tenga una sola dependencia como máximo. En mi caso, terminé con el siguiente código ASP.NET para la interfaz del servicio de almacenamiento en caché. (Tenga en cuenta que este no es el código real, ya que omití algunos detalles en aras de la simplicidad y la licencia propietaria).

 public interface ICacheKey { string Value { get; } } public interface IDataCacheKey : ICacheKey { } public interface ITouchableCacheKey : ICacheKey { } public interface ICacheService { int ItemsCount { get; } T Get<T>(IDataCacheKey key, Func<T> valueGetter); T Get<T>(IDataCacheKey key, Func<T> valueGetter, ICacheKey dependencyKey); }

Aquí, el servicio de caché básicamente permite dos cosas. En primer lugar, permite almacenar el resultado de alguna función de captación de valor de forma segura para subprocesos. En segundo lugar, garantiza que el valor actual en ese momento siempre se devuelva cuando se solicite. Una vez que el elemento de la memoria caché se vuelve obsoleto o se expulsa explícitamente de la memoria caché, se vuelve a llamar al captador de valor para recuperar un valor actual. La clave de caché fue abstraída por la interfaz ICacheKey , principalmente para evitar la codificación rígida de las cadenas de claves de caché en toda la aplicación.

Para invalidar elementos de caché, introduje un servicio separado, que se veía así:

 public interface ICacheInvalidator { bool IsSessionOpen { get; } void OpenSession(); void CloseSession(); void Drop(IDataCacheKey key); void Touch(ITouchableCacheKey key); void Purge(); }

Además de los métodos básicos para colocar elementos con datos y tocar teclas, que solo tenían elementos de datos dependientes, existen algunos métodos relacionados con algún tipo de "sesión".

Nuestra aplicación web usó Autofac para la inyección de dependencias, que es una implementación del patrón de diseño de inversión de control (IoC) para la administración de dependencias. Esta función permite a los desarrolladores crear sus clases sin tener que preocuparse por las dependencias, ya que el contenedor IoC gestiona esa carga por ellos.

El servicio de caché y el invalidador de caché tienen ciclos de vida drásticamente diferentes con respecto a IoC. El servicio de caché se registró como singleton (una instancia, compartida entre todos los clientes), mientras que el invalidador de caché se registró como una instancia por solicitud (se creó una instancia separada para cada solicitud entrante). ¿Por qué?

La respuesta tiene que ver con una sutileza adicional que necesitábamos manejar. La aplicación web utiliza una arquitectura Modelo-Vista-Controlador (MVC), que ayuda principalmente en la separación de la interfaz de usuario y las preocupaciones lógicas. Por lo tanto, una acción de controlador típica se incluye en una subclase de ActionFilterAttribute . En el marco ASP.NET MVC, dichos atributos de C# se utilizan para decorar la lógica de acción del controlador de alguna manera. Ese atributo en particular fue responsable de abrir una nueva conexión de base de datos e iniciar una transacción al comienzo de la acción. Además, al final de la acción, la subclase de atributo de filtro era responsable de confirmar la transacción en caso de éxito y revertirla en caso de falla.

Si la invalidación de la memoria caché ocurrió justo en el medio de la transacción, podría haber una condición de carrera por la cual la siguiente solicitud a ese nodo colocaría con éxito el valor anterior (todavía visible para otras transacciones) nuevamente en la memoria caché. Para evitar esto, todas las invalidaciones se posponen hasta que se confirme la transacción. Después de eso, los elementos del caché son seguros para desalojar y, en el caso de una falla en la transacción, no hay necesidad de modificar el caché en absoluto.

Ese fue el propósito exacto de las partes relacionadas con la "sesión" en el invalidador de caché. Además, ese es el propósito de que su vida útil esté vinculada a la solicitud. El código ASP.NET se veía así:

 class HybridCacheInvalidator : ICacheInvalidator { ... public void Drop(IDataCacheKey key) { if (key == null) throw new ArgumentNullException("key"); if (!IsSessionOpen) throw new InvalidOperationException("Session must be opened first."); _postponedRedisMessages.Add(new Tuple<string, string>("drop", key.Value)); } ... public void CloseSession() { if (!IsSessionOpen) return; _postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2)); _postponedRedisMessages = null; } ... }

El método PublishRedisMessageSafe aquí es responsable de enviar el mensaje (segundo argumento) a un canal en particular (primer argumento). De hecho, hay canales separados para soltar y tocar, por lo que el controlador de mensajes de cada uno de ellos sabía exactamente qué hacer: soltar/tocar la tecla igual a la carga útil del mensaje recibido.

Una de las partes difíciles fue administrar correctamente la conexión al servidor Redis. En el caso de que el servidor se caiga por cualquier motivo, la aplicación debería continuar funcionando correctamente. Cuando Redis vuelva a estar en línea, la aplicación debería comenzar a usarlo nuevamente sin problemas e intercambiar mensajes con otros nodos nuevamente. Para lograr esto, utilicé la biblioteca StackExchange.Redis y la lógica de administración de conexión resultante se implementó de la siguiente manera:

 class HybridCacheService : ... { ... public void Initialize() { try { Multiplexer = ConnectionMultiplexer.Connect(_configService.Caching.BackendServerAddress); ... Multiplexer.ConnectionFailed += (sender, args) => UpdateConnectedState(); Multiplexer.ConnectionRestored += (sender, args) => UpdateConnectedState(); ... } catch (Exception ex) { ... } } private void UpdateConnectedState() { if (Multiplexer.IsConnected && _currentCacheService is NoCacheServiceStub) { _inProcCacheInvalidator.Purge(); _currentCacheService = _inProcCacheService; _logger.Debug("Connection to remote Redis server restored, switched to in-proc mode."); } else if (!Multiplexer.IsConnected && _currentCacheService is InProcCacheService) { _currentCacheService = _noCacheStub; _logger.Debug("Connection to remote Redis server lost, switched to no-cache mode."); } } }

Aquí, ConnectionMultiplexer es un tipo de la biblioteca StackExchange.Redis, que es responsable del trabajo transparente con Redis subyacente. La parte importante aquí es que, cuando un nodo en particular pierde la conexión con Redis, vuelve al modo sin caché para asegurarse de que ninguna solicitud reciba datos obsoletos. Una vez que se restaura la conexión, el nodo comienza a usar la memoria caché en memoria nuevamente.

Estos son ejemplos de acciones sin uso del servicio de caché ( SomeActionWithoutCaching ) y una operación idéntica que lo usa ( SomeActionUsingCache ):

 class SomeController : Controller { public ISomeService SomeService { get; set; } public ICacheService CacheService { get; set; } ... public ActionResult SomeActionWithoutCaching() { return View( SomeService.GetModelData() ); } ... public ActionResult SomeActionUsingCache() { return View( CacheService.Get( /* Cache key creation omitted */, () => SomeService.GetModelData() ); ); } }

Un fragmento de código de una implementación de ISomeService podría verse así:

 class DefaultSomeService : ISomeService { public ICacheInvalidator _cacheInvalidator; ... public SomeModel GetModelData() { return /* Do something to get model data. */; } ... public void SetModelData(SomeModel model) { /* Do something to set model data. */ _cacheInvalidator.Drop(/* Cache key creation omitted */); } }

Benchmarking y Resultados

Después de configurar el código ASP.NET de almacenamiento en caché, llegó el momento de usarlo en la lógica de la aplicación web existente, y la evaluación comparativa puede ser útil para decidir dónde poner la mayor parte de los esfuerzos para reescribir el código para usar el almacenamiento en caché. Es fundamental seleccionar algunos de los casos de uso más comunes o críticos desde el punto de vista operativo para compararlos. Después de eso, una herramienta como Apache jMeter podría usarse para dos cosas:

  • Para comparar estos casos de uso clave a través de solicitudes HTTP.
  • Para simular alta carga para el nodo web bajo prueba.

Para obtener un perfil de rendimiento, se puede usar cualquier generador de perfiles que sea capaz de adjuntarse al proceso de trabajo de IIS. En mi caso, utilicé JetBrains dotTrace Performance. Después de pasar un tiempo experimentando para determinar los parámetros correctos de jMeter (como el conteo de solicitudes simultáneas), es posible comenzar a recopilar instantáneas de rendimiento, que son muy útiles para identificar los puntos críticos y los cuellos de botella.

En mi caso, algunos casos de uso mostraron que alrededor del 15 % al 45 % del tiempo total de ejecución del código se gastó en las lecturas de la base de datos con los cuellos de botella obvios. Después de aplicar el almacenamiento en caché, el rendimiento casi se duplicó (es decir, fue el doble de rápido) para la mayoría de ellos.

Relacionado: Ocho razones por las que Microsoft Stack sigue siendo una opción viable

Conclusión

Como puede ver, mi caso podría parecer un ejemplo de lo que se suele llamar “reinventar la rueda”: ¿Por qué molestarse en intentar crear algo nuevo, cuando ya existen buenas prácticas ampliamente aplicadas? Simplemente configure Memcached o Redis y déjelo funcionar.

Definitivamente estoy de acuerdo en que el uso de las mejores prácticas suele ser la mejor opción. Pero antes de aplicar ciegamente cualquier mejor práctica, uno debe preguntarse: ¿Qué tan aplicable es esta “mejor práctica”? ¿Se ajusta bien a mi caso?

Desde mi punto de vista, las opciones adecuadas y el análisis de compensaciones son imprescindibles al tomar cualquier decisión importante, y ese fue el enfoque que elegí porque el problema no era tan fácil. En mi caso, hubo muchos factores a considerar, y no quería tomar una solución única para todos cuando podría no ser el enfoque correcto para el problema en cuestión.

Al final, con el almacenamiento en caché adecuado, obtuve un aumento de rendimiento de casi el 50 % con respecto a la solución inicial.