Redis sincronización de caché para el servicio Go


Introduccion


Durante el refinamiento de un proyecto, se hizo necesario almacenar en caché los datos solicitados con frecuencia. La implementación del almacenamiento en caché es posible de diferentes maneras, pero quería implementarla con cambios mínimos en el proyecto original. El resultado, sus ventajas y desventajas se describen a continuación.


Como estuvo todo


Inicialmente, para cada consulta que contenía el identificador del objeto solicitado, se ejecutó una consulta en la base de datos PostgreSQL (DB). Más precisamente, varias consultas, ya que para formar una respuesta completa, era necesario aplicar a varias tablas de la base de datos. Como resultado del procesamiento de solicitudes, se formó un objeto bastante complejo, algunos de cuyos campos están representados por interfaces. En la memoria, este objeto ocupa unos 250 kB.


El rendimiento con esta implementación no fue excelente, no más de 3500 RPS (solicitud por segundo) al solicitar los mismos datos con 1000 hilos competidores.


La pregunta surgió de inmediato, pero ¿cómo aumentar RPS: cambiar el enrutador, optimizar la base de datos, almacenar en caché los datos? El enrutador se usó bastante bien ( github.com/julienschmidt/httprouter ), y reemplazar el enrutador en un proyecto grande requerirá mucho tiempo y existe un alto riesgo de que algo se rompa. Para optimizar el trabajo con la base de datos, también deberá volver a escribir una parte sustancial del código (ahora usando github.com/jmoiron/sqlx ). Obviamente, el almacenamiento en caché es la forma más óptima de aumentar RPS.


Solución simple


Lo más simple que viene a la mente es el uso de un caché en memoria. Cuando se usó tal caché, se obtuvieron aproximadamente 20,000 RPS. El rendimiento de la memoria caché en memoria es excelente, pero no puede utilizar dicha memoria caché con muchas instancias de servicio. Nunca se sabe a qué instancia del servicio volará una solicitud, y puede haber solicitudes no solo para recibir datos, sino también para eliminar / actualizar.


El rendimiento obtenido con el caché en memoria se tomó como estándar en una búsqueda adicional de una solución.


Idea, mala idea


¿Es posible poner el resultado de la consulta como está en la base de datos NoSQL Redis? Esta es una solución típica para almacenar en caché las solicitudes de respuesta. Los datos se almacenan en la memoria, cuando se usan varias instancias del servicio, todos pueden usar una memoria caché común. Esta solución se implementó rápidamente. Y las pruebas mostraron ... Y las pruebas mostraron que el rendimiento no aumentó mucho.
La investigación adicional mostró que las principales pérdidas de rendimiento están asociadas con la clasificación y la desorganización. La conversión de una estructura a JSON y viceversa requiere el uso de la reflexión, que es extremadamente costosa en rendimiento. Es imposible rechazar la clasificación / desorganización, ya que es necesario obtener un objeto completo del caché con la capacidad de llamar a métodos de estructuras, y no solo obtener los valores de los campos individuales. El uso de varias bibliotecas con la optimización de clasificación / descompresión tampoco ahorró, hubo crecimiento, pero el caché en memoria estaba muy lejos. Por lo tanto, se decidió no hacer amigos con el "erizo y la serpiente" y hacer un caché híbrido.


Híbrido "serpiente y erizo"


No puede llamarlo un híbrido completo (ver. Fig.). De hecho, resultó un caché en memoria, pero con sincronización a través de Redis ( se utilizó la biblioteca github.com/go-redis/redis ). Solo el identificador único del objeto solicitado de la base de datos (objeto ID) se almacenará en Redis. Se agregará a Redis durante el procesamiento de una solicitud para crear un objeto, o una solicitud para obtener un objeto existente de la base de datos. La ID del objeto servirá como la clave para el valor en Redis, y el valor será el UUID generado (identificador universalmente único, identificador único universal "). El UUID se generará solo cuando el objeto se agregue a Redis. Por qué se necesita este UUID se describirá más adelante.



Diagrama de bloques de la interacción de componentes para la sincronización de caché a través de Redis


El caché en memoria se implementa en base a sync.Map. Para los elementos de caché híbridos, se establece TTL (tiempo de vida, vida útil), y si Redis limpia los elementos "sucios", el temporizador limpia el caché en memoria (time.AfterFunc). Pasa a través de todos los elementos del caché y comprueba si el elemento está "podrido". Si se accede a un elemento de caché, su vida útil se extiende; una operación similar se realiza con claves en Redis.


Entonces, ahora de acuerdo con el algoritmo. Si llega una solicitud y necesitamos extraer el objeto, se realiza la siguiente secuencia de acciones:


  1. Observamos si hay un objeto con un objeto de ID dado en Redis, si es así, podemos tomar el caché de instancia de servicio de la memoria:
    1. Si el objeto no está en el caché en memoria, lo tomamos de la base de datos y agregamos el caché con el UUID de Redis al caché en memoria y actualizamos el TTL de la clave en Redis.
    2. Si el objeto está en la memoria caché en memoria, lo tomamos de la memoria caché, verificamos si el UUID en la memoria caché y en Redis coinciden, y si es así, actualizamos el TTL en la memoria caché y en Redis. Si el UUID no coincide, elimine el objeto de la memoria caché en memoria, tómelo de la base de datos, agregue la memoria caché con el UUID de Redis a la memoria.
  2. Si el objeto no está en Redis, entonces si el objeto está en el caché, retírelo del caché. Tome un objeto de la base de datos y agréguelo al caché y a Redis. Para eliminar la situación cuando actualizar / eliminar una entrada es más rápido que agregar al caché ( comentario de andreyverbin ), agregue un objeto con un UUID cero al caché. Luego, en el primer acceso al caché, se revelará la diferencia en UUID con Redis, y se solicitarán nuevamente los datos de la base de datos.

Si llega una solicitud para eliminar un objeto, se elimina inmediatamente de la base de datos y luego las operaciones de caché:


  1. Eliminar el objeto en Redis.
  2. Elimine el objeto en la memoria caché en memoria.

Ahora, si llega una solicitud similar en otra instancia del servicio, aunque el objeto todavía puede estar en la memoria caché en memoria, no se utilizará.


Actualización de objeto, después de actualizar en la base de datos:


  1. Eliminar el objeto en Redis.
  2. Elimine el objeto en la memoria caché en memoria.

Cuando solicite un objeto en otra instancia del servicio, se revelará que no está en Redis, por lo que debe tomarlo de la base de datos. Si hay otra instancia del servicio, y la solicitud voló hacia ella después de actualizar el objeto y después de agregarla por segunda instancia en Redis, entonces, al verificar el UUID, se revelará una diferencia, y la tercera instancia del servicio también tomará el objeto de la base de datos.


Es decir de hecho, en cualquier situación incomprensible, creemos que nuestro caché es incorrecto y necesitamos tomar datos de la base de datos.


Conclusión


La solución desarrollada tiene ventajas y desventajas.


Pros


  • El esquema de almacenamiento en caché desarrollado permitió alcanzar aproximadamente 19000 RPS, que es casi equivalente a las pruebas con caché en memoria.
  • El código original del proyecto tiene un número mínimo de cambios.

Contras


  • Si Redis falla, el servicio disminuye drásticamente el rendimiento y se basa en trabajar con la base de datos.
  • Cada instancia del servicio requerirá más memoria porque tiene su propia caché en memoria.

Como el alto rendimiento era más importante, no considero que las desventajas sean críticas. En el futuro, existe la idea de escribir una biblioteca para simplificar la implementación de la memoria caché híbrida, ya que es necesario utilizar el almacenamiento en caché similar en otros proyectos.

Source: https://habr.com/ru/post/482704/


All Articles