Hola a todos Mi nombre es Alexander, soy desarrollador de Java en el grupo de empresas Tinkoff.
En este artículo quiero compartir mi experiencia en la resolución de problemas asociados con la sincronización del estado de las memorias caché en sistemas distribuidos. Nos encontramos con ellos, dividiendo nuestra aplicación monolítica en
micro servicios. Obviamente, hablaremos sobre el almacenamiento en caché de datos en el nivel JVM, porque con los cachés externos, los problemas de sincronización se resuelven fuera del contexto de la aplicación.
En este artículo, hablaré sobre nuestra experiencia de cambiar a una arquitectura orientada a servicios, acompañada de un cambio a Kubernetes, y sobre la resolución de problemas relacionados. Consideraremos el enfoque para organizar el sistema de almacenamiento en caché distribuido de la cuadrícula de datos en memoria (IMDG), sus ventajas y desventajas, por lo que decidimos escribir nuestra propia solución.
Este artículo analiza un proyecto cuyo backend está escrito en Java. Por lo tanto, también hablaremos sobre estándares en el campo del almacenamiento en caché temporal en memoria. Discutimos la especificación JSR-107, la especificación JSR-347 fallida y las características de almacenamiento en caché en Spring. ¡Bienvenido a cat!
Y cortemos la aplicación en servicios ...
Pasaremos a la arquitectura orientada a servicios y nos trasladaremos a Kubernetes, eso es lo que decidimos hace poco más de 6 meses. Durante mucho tiempo, nuestro proyecto fue un monolito, muchos problemas relacionados con la deuda técnica acumulada, y escribimos nuevos módulos de aplicaciones como servicios separados. Como resultado, la transición a una arquitectura orientada a servicios y un corte monolítico fue inevitable.
Nuestra aplicación está cargada, en promedio 500 rps llega a los servicios web (en pico alcanza los 900 rps). Para recopilar el modelo de datos completo en respuesta a cada solicitud, debe ir a los diversos cachés varios cientos de veces.
Intentamos ir a la memoria caché remota no más de tres veces por solicitud, según el conjunto de datos requerido, y en las memorias caché JVM internas, la carga alcanza 90,000 rps por memoria caché. Tenemos alrededor de 30 cachés de este tipo para una variedad de entidades y DTO-shki. En algunos cachés cargados, ni siquiera podemos permitirnos eliminar el valor, ya que esto puede conducir a un aumento en el tiempo de respuesta de los servicios web y a un bloqueo en la aplicación.
Así es como se ve el monitoreo de carga, eliminado de las cachés internas en cada nodo durante el día. Según el perfil de carga, es fácil ver que la mayoría de las solicitudes son datos leídos. Una carga de escritura uniforme se debe a la actualización de valores en cachés a una frecuencia dada.
El tiempo de inactividad no es válido para nuestra aplicación. Por lo tanto, con el propósito de una implementación sin interrupciones, siempre equilibramos todo el tráfico entrante a dos nodos e implementamos la aplicación utilizando el método de actualización continua. Kubernetes se convirtió en nuestra solución de infraestructura ideal al cambiar a los servicios. Por lo tanto, resolvimos varios problemas a la vez.
El problema de ordenar y establecer constantemente infraestructura para nuevos servicios
Se nos dio un espacio de nombres en el clúster para cada circuito, que tenemos tres: dev - para desarrolladores, qa - para probadores, prod - para clientes.
Con el espacio de nombres resaltado, agregar un nuevo servicio o aplicación se reduce a escribir cuatro manifiestos: Implementación, Servicio, Ingress y ConfigMap.
Alta tolerancia de carga
El negocio se expande y crece constantemente: hace un año, la carga promedio era dos veces menor que la actual.
El escalado horizontal en Kubernetes le permite nivelar las economías de escala al aumentar la carga de trabajo del proyecto desarrollado.
Mantenimiento, recopilación de registros y monitoreo.
La vida se vuelve mucho más fácil cuando no hay necesidad de agregar registros al sistema de registro al agregar cada nodo, configurar la valla de métricas (a menos que tenga un sistema de monitoreo de inserción), realizar configuraciones de red y simplemente instalar el software necesario para la operación.
Por supuesto, todo esto se puede automatizar usando Ansible o Terraform, pero al final, escribir manifiestos múltiples para cada servicio es mucho más fácil.
Alta fiabilidad
El mecanismo incorporado k8s de Liveness- and Readiness-samples le permite no preocuparse de que la aplicación comenzó a ralentizarse o dejó de responder por completo.
Kubernetes ahora controla el ciclo de vida de los módulos de hogar que contienen contenedores de aplicaciones y el tráfico que se dirige a ellos.
Junto con las comodidades descritas, necesitábamos resolver una serie de problemas para que los servicios sean adecuados para el escalado horizontal y el uso de un modelo de datos común para muchos servicios. Era necesario resolver dos problemas:
- El estado de la aplicación. Cuando el proyecto se implementa en el clúster k8s, comienzan a crearse vainas con contenedores de la nueva versión de la aplicación que no están relacionadas con el estado de las vainas de la versión anterior. Se pueden generar nuevos pods de aplicación en servidores de clúster arbitrarios que satisfagan las restricciones especificadas. Además, ahora todos los contenedores de aplicaciones que se ejecutan dentro del pod Kubernetes pueden destruirse en cualquier momento si la sonda Liveness dice que debe reiniciarse.
- Consistencia de datos. Es necesario mantener la coherencia y la integridad de los datos entre sí en todos los nodos. Esto es especialmente cierto si varios nodos funcionan dentro de un solo modelo de datos. Es inaceptable que cuando las solicitudes a diferentes nodos de la aplicación en la respuesta, los datos inconsistentes lleguen al cliente.
En el desarrollo moderno de sistemas escalables, la arquitectura sin estado es la solución a los problemas anteriores. Eliminamos el primer problema moviendo todas las estadísticas al almacenamiento en la nube S3.
Sin embargo, debido a la necesidad de agregar un modelo de datos complejo y ahorrar tiempo de respuesta de nuestros servicios web, no podríamos negarnos a almacenar datos en cachés en memoria. Para resolver el segundo problema, escribieron una biblioteca para sincronizar el estado de los cachés internos de los nodos individuales.
Sincronizamos cachés en nodos separados
Como datos iniciales tenemos un sistema distribuido que consta de N nodos. Cada nodo tiene aproximadamente 20 memorias caché en memoria, cuyos datos se actualizan varias veces por hora.
La mayoría de los cachés tienen una política de actualización de datos TTL (tiempo de vida), algunos datos se actualizan con una operación CRON cada 20 minutos debido a la alta carga. La carga de trabajo en cachés varía de varios miles de rps por la noche a varias decenas de miles durante el día. La carga máxima, como regla, no excede 100,000 rps. El número de registros en el almacenamiento temporal no excede varios cientos de miles y se coloca en el montón de un nodo.
Nuestra tarea es lograr la consistencia de datos entre la misma caché en diferentes nodos, así como el menor tiempo de respuesta posible. Considere lo que generalmente hay formas de resolver este problema.
La primera y más simple solución que viene a la mente es poner toda la información en un caché remoto. En este caso, puede deshacerse por completo del estado de la aplicación, no pensar en los problemas de lograr la coherencia y tener un único punto de acceso a un almacén de datos temporal.
Este método de almacenamiento temporal de datos es bastante simple, y lo usamos. Guardamos en caché parte de los datos en
Redis , que es un almacenamiento de datos NoSQL en RAM. En Redis, generalmente registramos un marco de respuesta de servicio web, y para cada solicitud necesitamos enriquecer estos datos con información relevante, para lo cual tenemos que enviar varios cientos de solicitudes al caché local.
Obviamente, no podemos extraer los datos de las cachés internas para el almacenamiento remoto, ya que el costo de transmitir ese volumen de tráfico a través de la red no nos permitirá cumplir con el tiempo de respuesta requerido.
La segunda opción es usar una
cuadrícula de datos en memoria (IMDG), que es un caché distribuido en memoria. El esquema de tal solución es el siguiente:
La arquitectura IMDG se basa en el principio de Particionamiento de datos de cachés internos de nodos individuales. De hecho, esto se puede llamar una tabla hash distribuida en un grupo de nodos. IMDG se considera una de las implementaciones más rápidas de almacenamiento distribuido temporal.
Hay muchas implementaciones de IMDG, la más popular es
Hazelcast . El caché distribuido le permite almacenar datos en RAM en varios nodos de aplicación con un nivel aceptable de confiabilidad y preservación de la consistencia, que se logra mediante la replicación de datos.
La tarea de construir una memoria caché distribuida de este tipo no es fácil, pero usar una solución IMDG ya preparada para nosotros podría convertirse en un buen reemplazo para las memorias caché JVM y eliminar los problemas de replicación, consistencia y distribución de datos entre todos los nodos de la aplicación.
La mayoría de los proveedores de IMDG para aplicaciones Java implementan
JSR-107 , la API Java estándar para trabajar con cachés internos. En general, este estándar tiene una historia bastante grande, que analizaré con más detalle a continuación.
Había una vez ideas para implementar su interfaz para interactuar con IMDG -
JSR 347 . Pero la implementación de tal API no recibió suficiente soporte de la comunidad Java, y ahora tenemos una única interfaz para interactuar con cachés en memoria, independientemente de la arquitectura de nuestra aplicación. Buena o mala es otra cuestión, pero nos permite ignorar por completo todas las dificultades de implementar un caché distribuido en memoria y trabajar con él como caché de una aplicación monolítica.
A pesar de las ventajas obvias de usar IMDG, esta solución es aún más lenta que la caché JVM estándar, debido a la sobrecarga de garantizar la replicación continua de los datos distribuidos entre varios nodos JVM, así como a la copia de seguridad de estos datos. En nuestro caso, la cantidad de datos para el almacenamiento temporal no era tan grande, los datos con un margen cabían en la memoria de una aplicación, por lo que su asignación a varias JVM parecía una solución innecesaria. Y el tráfico de red adicional entre nodos de aplicaciones bajo cargas pesadas puede afectar en gran medida el rendimiento y aumentar el tiempo de respuesta de los servicios web. Al final, decidimos escribir nuestra propia solución para este problema.
Dejamos cachés en memoria como almacenamiento temporal de datos y, para mantener la coherencia, utilizamos el gestor de colas RabbitMQ. Adoptamos el patrón de diseño de comportamiento
"Editor - Suscriptor" y mantuvimos la relevancia de los datos al eliminar el registro modificado de la memoria caché de cada nodo. El esquema de solución es el siguiente:
El diagrama muestra un grupo de N nodos, cada uno de los cuales tiene un caché estándar en memoria. Todos los nodos usan un modelo de datos común y deben ser consistentes. En el primer acceso al caché por una clave arbitraria, el valor en el caché está ausente y colocamos el valor real de la base de datos en él. Con cualquier cambio, elimine el registro.
Aquí se proporciona información real en la respuesta de caché sincronizando la eliminación de una entrada cuando se cambia en cualquiera de los nodos. Cada nodo en el sistema tiene una cola en el gestor de colas RabbitMQ. La grabación en todas las colas se realiza a través de un punto de acceso de tipo Tema común. Esto significa que los mensajes enviados al tema se incluyen en todas las colas asociadas a él. Entonces, al cambiar el valor en cualquier nodo del sistema, este valor se eliminará del almacenamiento temporal de cada nodo, y el acceso posterior iniciará la escritura del valor actual en la memoria caché desde la base de datos.
Por cierto, existe un mecanismo similar de PUB / SUB en Redis. Pero, en mi opinión, aún es mejor usar el gestor de colas para trabajar con colas, y RabbitMQ fue perfecto para nuestra tarea.
JSR 107 estándar y su implementación
La API de caché Java estándar para el almacenamiento temporal de datos en memoria (especificación
JSR-107 ) tiene una historia bastante larga; se ha desarrollado durante 12 años.
Durante tanto tiempo, los enfoques para el desarrollo de software han cambiado, los monolitos han sido reemplazados por la arquitectura de microservicios. Debido a una falta tan larga de especificaciones para la API de caché, incluso ha habido solicitudes para desarrollar cachés de API para sistemas distribuidos
JSR-347 (Cuadrículas de datos para la plataforma Java). Pero después del tan esperado lanzamiento de JSR-107 y el lanzamiento de JCache, se retiró la solicitud de crear una especificación separada para sistemas distribuidos.
Durante los largos 12 años en el mercado, el lugar para el almacenamiento temporal de datos ha cambiado de HashMap a ConcurrentHashMap con el lanzamiento de Java 1.5, y más tarde aparecieron muchas implementaciones de código abierto preparadas de almacenamiento en caché en memoria.
Después del lanzamiento de JSR-107, las soluciones de los proveedores comenzaron a implementar gradualmente la nueva especificación. Para JCache, incluso hay proveedores especializados en almacenamiento en caché distribuido: las mismas cuadrículas de datos, cuya especificación nunca se ha implementado.
Considere en qué
consiste el paquete
javax.cache y cómo obtener una instancia de caché para nuestra aplicación:
CachingProvider provider = Caching.getCachingProvider("org.cache2k.jcache.provider.JCacheProvider"); CacheManager cacheManager = provider.getCacheManager(); CacheConfiguration<Integer, String> config = new MutableConfiguration<Integer, String>() .setTypes(Integer.class, String.class) .setReadThrough(true) . . .; Cache<Integer, String> cache = cacheManager.createCache(cacheName, config);
Aquí el almacenamiento en caché es un cargador de arranque para CachingProvider.
En nuestro caso, JCacheProvider, que es la implementación de cache2k del
SPI del proveedor JSR-107, se cargará desde ClassLoader. Para el cargador, es posible que no tenga que especificar la implementación del proveedor, pero luego intentará cargar la implementación que se encuentra en
META-INF / services / javax.cache.spi.CachingProvider
En cualquier caso, en ClassLoader debería haber una implementación única de CachingProvider.
Si utiliza la biblioteca javax.cache sin ninguna implementación, se generará una excepción cuando intente crear JCache. El propósito del proveedor es crear y administrar el ciclo de vida de CacheManager, que, a su vez, es responsable de administrar y configurar los cachés. Por lo tanto, para crear un caché, debe ir de la siguiente manera:
Los cachés estándar creados con CacheManager deben tener una configuración compatible con la implementación. La configuración de caché parametrizada estándar proporcionada por javax.cache se puede extender a una implementación específica de CacheProvider.
Hoy en día, hay docenas de implementaciones diferentes de la especificación JSR-107:
Ehcache ,
Guava ,
cafeína ,
cache2k . Muchas implementaciones son Grid de datos en memoria en sistemas distribuidos:
Hazelcast ,
Oracle Coherence .
También hay muchas implementaciones de almacenamiento temporal que no son compatibles con la API estándar. Durante mucho tiempo en nuestro proyecto, utilizamos Ehcache 2, que no es compatible con JCache (la implementación de la especificación apareció con Ehcache 3). La necesidad de una transición a una implementación compatible con JCache apareció con la necesidad de monitorear el estado de las memorias caché en memoria. Usando el MetricRegistry estándar, fue posible ajustar el monitoreo solo con la ayuda de la implementación JCacheGaugeSet, que recopila métricas de JCache estándar.
¿Cómo elegir la implementación de caché en memoria adecuada para su proyecto? Quizás deberías prestar atención a lo siguiente:
- ¿Necesita soporte para la especificación JSR-107?
- También vale la pena prestar atención a la velocidad de la implementación seleccionada. Bajo cargas pesadas, el rendimiento de las memorias caché internas puede tener un impacto significativo en el tiempo de respuesta de su sistema.
- Apoyo en primavera. Si usa el marco conocido en su proyecto, vale la pena considerar el hecho de que no todas las implementaciones de caché JVM tienen un CacheManager compatible en Spring.
Si usted, como nosotros, utiliza activamente Spring en su proyecto, entonces para el almacenamiento en caché de datos, probablemente se adhiera al enfoque orientado a aspectos (AOP) y use la anotación @Cacheable. Spring utiliza su propio SPI de CacheManager para que los aspectos funcionen. Se necesita el siguiente bean para que funcionen los cachés de primavera:
@Bean public org.springframework.cache.CacheManager cacheManager() { CachingProvider provider = Caching.getCachingProvider(); CacheManager cacheManager = provider.getCacheManager(); return new JCacheCacheManager(cacheManager); }
Para trabajar con cachés en el paradigma AOP, también deben considerarse las consideraciones transaccionales. El caché de primavera debe necesariamente admitir la gestión de transacciones. Con este fin, Spring CacheManager hereda las propiedades AbstractTransactionSupportingCacheManager, que se pueden usar para sincronizar operaciones de poner / desalojar realizadas dentro de una transacción y solo ejecutarlas después de que se haya confirmado una transacción exitosa.
El ejemplo anterior muestra el uso del contenedor JCacheCacheManager para el administrador de especificaciones de caché. Esto significa que cualquier implementación de JSR-107 también tiene compatibilidad con Spring CacheManager. Esta es otra razón para elegir un caché en memoria con soporte para la especificación JSR para su proyecto. Pero si todavía no se necesita este soporte, pero realmente quiero usar @Cacheable, entonces tiene soporte para dos soluciones de caché internas más: EhCacheCacheManager y CaffeineCacheManager.
Al elegir la implementación del caché en memoria, no tomamos en cuenta el soporte de IMDG para sistemas distribuidos, como se mencionó anteriormente. Para mantener el rendimiento de los cachés JVM en nuestro sistema, escribimos nuestra propia solución.
Borrar cachés en un sistema distribuido
Los IMDG modernos utilizados en proyectos con arquitectura de microservicio le permiten distribuir datos en la memoria entre todos los nodos de trabajo del sistema utilizando particiones de datos escalables con el nivel requerido de redundancia.
En este caso, existen muchos problemas asociados con la sincronización, la consistencia de los datos, etc., sin mencionar el aumento en el tiempo de acceso al almacenamiento temporal. Tal esquema es redundante si la cantidad de datos utilizados se ajusta en la RAM de un nodo, y para mantener la consistencia de los datos, es suficiente eliminar esta entrada en todos los nodos para cualquier cambio en el valor de caché.
Al implementar una solución de este tipo, la primera idea que viene a la mente es usar EventListener, en JCache hay un CacheEntryRemovedListener para el evento de eliminar una entrada del caché. Parece que es suficiente agregar su propia implementación de escucha, que enviará mensajes al tema cuando se elimine el registro, y la caché eutéctica en todos los nodos esté lista, siempre que cada nodo escuche los eventos de la cola asociada con el tema general, como se muestra en el diagrama arriba
Cuando se utiliza dicha solución, los datos en diferentes nodos serán inconsistentes debido al hecho de que EventLists en cualquier proceso de implementación de JCache después de que ocurra el evento. Es decir, si no hay un registro en la memoria caché local para la clave dada, pero hay un registro para la misma clave en cualquier otro nodo, el evento no se enviará al tema.
Considere qué otras formas hay para detectar el evento de un valor que se elimina de la memoria caché local.
En el paquete javax.cache.event, junto a EventListeners, también hay un CacheEntryEventFilter, que, de acuerdo con JavaDoc, se usa para verificar cualquier evento CacheEntryEvent antes de enviar este evento a CacheEntryListener, ya sea un registro, eliminación, actualización o un evento relacionado con la caducidad del registro en caché Al usar el filtro, nuestro problema permanecerá, porque la lógica se ejecutará después de que el evento CacheEntryEvent se registre y después de que la operación CRUD se realice en la memoria caché.
Sin embargo, es posible detectar el inicio de un evento para eliminar un registro de la memoria caché. Para hacer esto, use la herramienta incorporada en JCache que le permite usar especificaciones de API para escribir y cargar datos desde una fuente externa, si no están en la caché. Hay dos interfaces para esto en el paquete javax.cache.integration:
- CacheLoader: para cargar los datos solicitados por la clave, si no hay entradas en el caché.
- CacheWriter: para utilizar la escritura, eliminación y actualización de datos en un recurso externo al invocar las operaciones de caché correspondientes.
Para garantizar la coherencia, los métodos CacheWriter son atómicos con respecto a la operación de caché correspondiente. Parece que hemos encontrado una solución a nuestro problema.
Ahora podemos mantener la coherencia de la respuesta de las memorias caché en memoria en los nodos cuando utilizamos nuestra implementación de CacheWriter, que envía eventos al tema RabbitMQ cada vez que hay algún cambio en el registro en la memoria caché local.
Conclusión
En el desarrollo de cualquier proyecto, cuando se busca una solución adecuada para problemas emergentes, es necesario tener en cuenta su especificidad. En nuestro caso, las características del modelo de datos del proyecto, el código heredado heredado y la naturaleza de la carga no permitieron usar ninguna de las soluciones existentes para el problema de almacenamiento en caché distribuido.
Es muy difícil hacer que una implementación universal sea aplicable a cualquier sistema desarrollado. Para cada implementación de este tipo, existen condiciones óptimas para su uso. En nuestro caso, los detalles del proyecto llevaron a la solución descrita en este artículo. Si alguien tiene un problema similar, estaremos encantados de compartir nuestra solución y publicarla en GitHub.