Sincronização de cache Redis para o serviço Go


1. Introdução


Durante o refinamento de um projeto, tornou-se necessário armazenar em cache os dados solicitados com freqüência. A implementação do cache é possível de diferentes maneiras, mas eu queria implementá-lo com alterações mínimas no projeto original. O resultado, seus prós e contras estão descritos abaixo.


Como foi tudo


Inicialmente, para cada consulta que contém o identificador do objeto solicitado, uma consulta foi executada no banco de dados PostgreSQL (DB). Mais precisamente, várias consultas, uma vez que, para formar uma resposta completa, era necessário aplicar a várias tabelas do banco de dados. Como resultado do processamento de solicitações, um objeto bastante complexo foi formado, alguns dos campos representados por interfaces. Na memória, esse objeto ocupa cerca de 250 kB.


O desempenho com esta implementação não foi ótimo, não mais que 3500 RPS (solicitação por segundo) ao solicitar os mesmos dados com 1000 threads concorrentes.


A questão surgiu imediatamente, mas como aumentar o RPS: mudar o roteador, otimizar o banco de dados, armazenar em cache os dados? O roteador foi usado muito bem ( github.com/julienschmidt/httprouter ), e a substituição do roteador em um projeto grande levará muito tempo e há um alto risco de algo quebrar. Para otimizar o trabalho com o banco de dados, você também precisará reescrever uma parte substancial do código (agora github.com/jmoiron/sqlx é usado). Obviamente, o cache é a maneira mais ideal de aumentar o RPS.


Solução simples


A coisa mais simples que vem à mente é o uso de um cache na memória. Ao usar esse cache, foram obtidos cerca de 20.000 RPS. O desempenho do cache na memória é excelente, mas você não pode usá-lo com muitas instâncias de serviço. Você nunca sabe para qual instância do serviço uma solicitação será direcionada e pode haver solicitações não apenas para recebimento de dados, mas também para exclusão / atualização.


O desempenho obtido com o cache na memória foi tomado como padrão em uma busca adicional por uma solução.


Idéia, má ideia


É possível colocar o resultado da consulta no banco de dados NoSQL Redis? Essa é uma solução típica para armazenar em cache solicitações de resposta. Os dados são armazenados na memória. Ao usar várias instâncias do serviço, todos eles podem usar um cache comum. Esta solução foi implementada rapidamente. E os testes mostraram ... E os testes mostraram que o desempenho não aumentou muito.
Pesquisas posteriores mostraram que as principais perdas de desempenho estão associadas ao empacotamento e descompactação. A conversão de uma estrutura para JSON e vice-versa requer o uso de reflexão, que é extremamente caro em desempenho. É impossível recusar empacotamento / desempacotamento, pois é necessário obter um objeto completo do cache com a capacidade de chamar métodos de estruturas e não apenas obter os valores de campos individuais. O uso de várias bibliotecas com otimização de empacotamento / desempacotamento também não salvou, houve crescimento, mas o cache na memória estava muito distante. Portanto, foi decidido não fazer amizade com o "ouriço e cobra" e fazer um cache híbrido.


Híbrido "cobra e ouriço"


Você não pode chamá-lo de híbrido completo (consulte a Fig.). Na verdade, ele resultou em um cache na memória, mas com sincronização através do Redis (a biblioteca github.com/go-redis/redis foi usada ). Somente o identificador exclusivo do objeto solicitado no banco de dados (objeto de ID) será armazenado no Redis. Ele será adicionado ao Redis durante o processamento de uma solicitação para criar um objeto ou uma solicitação para obter um objeto existente no banco de dados. O ID do objeto servirá como a chave para o valor em Redis, e o valor será o UUID gerado (identificador universalmente exclusivo, identificador exclusivo universal ”). O UUID será gerado apenas quando o objeto for adicionado ao Redis. Por que esse UUID é necessário, será descrito mais adiante.



Diagrama de blocos da interação de componentes para sincronização de cache através do Redis


O cache da memória é implementado com base no sync.Map. Para itens de cache híbrido, TTL (tempo de vida útil, vida útil) é definido e, se o Redis limpar itens "sujos", o cache na memória será limpo pelo timer (time.AfterFunc). Ele passa por todos os elementos do cache e verifica se o elemento está "podre". Se um elemento de cache for acessado, sua vida útil será estendida; uma operação semelhante será executada com as chaves no Redis.


Então, agora de acordo com o algoritmo. Se uma solicitação chegar e precisarmos extrair o objeto, a seguinte sequência de ações será executada:


  1. Examinamos se há um objeto com um determinado objeto de ID no Redis; se houver, podemos pegar o cache da instância de serviço da memória:
    1. Se o objeto não estiver no cache da memória, nós o retiramos do banco de dados e adicionamos o cache com o UUID do Redis ao cache da memória e atualizamos o TTL da chave no Redis.
    2. Se o objeto estiver no cache da memória, nós o retiramos do cache, verificamos se o UUID no cache e no Redis corresponde e, nesse caso, atualize o TTL no cache e no Redis. Se o UUID não corresponder, exclua o objeto do cache da memória, retire-o do banco de dados, adicione o cache com o UUID do Redis à memória.
  2. Se o objeto não estiver no Redis, se o objeto estiver no cache, remova-o do cache. Pegue um objeto do banco de dados e adicione-o ao cache e ao Redis. Para eliminar a situação em que a atualização / exclusão de uma entrada é mais rápida que a adição ao cache ( comentário andreyverbin ), adicione um objeto com um UUID zero ao cache. Então, no primeiro acesso ao cache, a diferença de UUID com Redis será revelada e os dados do banco de dados serão solicitados novamente.

Se uma solicitação para excluir um objeto chegar, ela será imediatamente excluída do banco de dados e, em seguida, operações de cache:


  1. Exclua o objeto no Redis.
  2. Exclua o objeto no cache da memória.

Agora, se uma solicitação semelhante chegar em outra instância do serviço, embora o objeto ainda possa estar no cache da memória, ele não será usado.


Atualização do objeto, após a atualização no banco de dados:


  1. Exclua o objeto no Redis.
  2. Exclua o objeto no cache da memória.

Quando você solicita um objeto em outra instância do serviço, será revelado que ele não está no Redis, portanto, é necessário retirá-lo do banco de dados. Se houver outra instância do serviço, e a solicitação voou para ele após atualizar o objeto e depois de adicioná-lo pela segunda instância no Redis, então, ao verificar o UUID, uma diferença será revelada e a terceira instância do serviço também pegará o objeto do banco de dados.


I.e. de fato, em qualquer situação incompreensível, acreditamos que nosso cache está incorreto e precisamos coletar dados do banco de dados.


Conclusão


A solução desenvolvida tem prós e contras.


Prós


  • O esquema de cache desenvolvido tornou possível obter cerca de 19000 RPS, o que é quase equivalente a testes com cache na memória.
  • O código do projeto original tem um número mínimo de alterações.

Contras


  • Se o Redis travar, o serviço diminuirá drasticamente o desempenho e dependerá do trabalho com o banco de dados.
  • Cada instância do serviço exigirá mais memória porque possui seu próprio cache na memória.

Como o alto desempenho era mais importante, não considero os menos críticos. No futuro, existe uma idéia para escrever uma biblioteca para simplificar a implementação do cache híbrido, pois é necessário usar o cache semelhante em outros projetos.

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


All Articles