O êxtase e a agonia dos caches

Ao longo dos anos desenvolvendo serviços na Amazon, experimentamos várias versões do seguinte cenário: desenvolvemos um serviço novo e ele precisa fazer algumas chamadas de rede para atender às suas solicitações. Essas chamadas podem ser para um banco de dados relacional ou para um serviço da AWS, como o Amazon DynamoDB, ou para outro serviço interno. Em testes simples ou com taxas baixas de solicitação, o serviço funciona muito bem, porém percebemos a possibilidade de um problema. O problema poderia ser a lentidão das chamadas para o serviço ou o gasto alto com o aumento de escala do banco de dados para acompanhar o maior volume de chamadas. Também percebemos que muitas solicitações usam o mesmo recurso downstream ou os mesmos resultados de consulta. Acreditamos que o armazenamento desses dados em cache poderia ser a resposta para os nossos problemas. Adicionamos um cache e nosso serviço parece ter melhorado. Observamos que a latência da solicitação é baixa, os custos estão menores e pequenas quedas da disponibilidade de downstream podem ser facilmente resolvidas. Depois de um tempo, ninguém se lembra mais de como era viver sem o cache. As dependências reduzem seus tamanhos de frota e a escala do banco de dados diminui. Quando tudo parece ir bem, o serviço pode estar à beira de um desastre. Podem ser alterações no padrão de tráfego, falha da frota de cache ou outras circunstâncias inesperadas que podem causar um travamento ou, de alguma outra forma, indisponibilizar o cache. Por outro lado, isso pode causar um pico nos serviços downstream e gerar interrupções nas dependências e no serviço.

Acabamos de descrever um serviço que ficou viciado em usar cache. Inadvertidamente, o cache deixou de ser um complemento útil ao serviço e passou a ser um elemento necessário e essencial para suas operações. No cerne desse problema está o comportamento modal introduzido pelo cache. Um comportamento diverso que só surge se determinado objeto está armazenado no cache ou não. Uma mudança não prevista na distribuição desse comportamento modal pode levar a um desastre.

Temos presenciado os benefícios e os desafios do uso do cache durante a criação e a operação dos serviços na Amazon. Daqui para frente, o artigo descreve as lições aprendidas, as melhores práticas e as considerações para o uso do cache.

Quando usar cache

Diversos fatores nos fazem considerar incluir um cache no sistema. Muitas vezes, isso começa com uma observação sobre a latência ou a eficiência de uma dependência com uma taxa de solicitação específica. Por exemplo, pode ser quando determinamos que uma dependência pode começar a controlar a utilização ou, de outra forma, não conseguir lidar com a carga prevista. Achamos útil considerar o uso do cache diante de padrões de solicitação irregulares que causam o controle da utilização de hot-key/hot-partition. Os dados dessa dependência são um bom candidato ao armazenamento em cache se esse cache tiver uma boa taxa de acertos com as solicitações. Ou seja, os resultados das chamadas à dependência podem ser usados em várias solicitações ou operações. Se cada solicitação normalmente precisa realizar uma consulta exclusiva no serviço dependente, com resultados exclusivos por solicitação, o cache teria uma taxa de acertos negligenciável e não seria bom. Uma segunda consideração é o nível de tolerância do serviço de uma equipe e de seus clientes a uma consistência eventual. O crescimento dos dados em cache ao longo do tempo necessariamente gera inconsistência com a fonte. O armazenamento em cache só pode ter êxito se o serviço e seus clientes conseguirem compensar essa questão. A taxa de alteração dos dados em relação à fonte e a política de cache para atualização dos dados determinarão o nível de inconsistência dos dados. Esses dois aspectos estão relacionados entre si. Por exemplo, dados relativamente estáticos ou que mudam devagar podem ser armazenados em cache por períodos mais longos.

Caches locais

Os caches de serviço podem ser implementados na memória ou externamente em relação ao dispositivo. Os caches on-box, comumente implementados na memória de processo, são relativamente rápidos e fáceis de implementar.Eles podem fornecer melhorias significativas com o mínimo de trabalho. Os caches on-box normalmente são a primeira abordagem implementada e avaliada assim que a necessidade de cache é identificada. Em contraste com caches externos, eles não vêm com overhead operacional adicional, portanto apresentam risco muito baixo na integração a um dispositivo existente. Geralmente, implementamos um cache on-box como uma tabela de hash na memória que é gerenciada pela lógica do aplicativo (por exemplo, colocando explicitamente os resultados no cache após a conclusão das chamadas de serviço) ou incorporados no cliente de serviço (por exemplo, por usando um cliente HTTP em cache).

Apesar dos benefícios e da simplicidade sedutora dos caches na memória, eles vêm com várias desvantagens. Uma é que os dados armazenados em cache serão inconsistentes de servidor para servidor em toda a sua frota, manifestando um problema de coerência de cache. Se um cliente fizer chamadas repetidas para o serviço, ele poderá obter dados mais novos usados na primeira chamada e dados mais antigos na segunda chamada, dependendo de qual servidor manipula a solicitação.

Outra falha é que a carga a jusante agora é proporcional ao tamanho da frota do serviço, portanto, à medida que o número de servidores aumenta, ainda é possível sobrecarregar os serviços dependentes. Descobrimos que uma maneira eficaz de monitorar isso é emitir métricas sobre ocorrências/falhas de cache e o número de solicitações feitas aos serviços downstream.

Os caches na memória também são suscetíveis a problemas de "inicialização a frio". Esses problemas ocorrem quando um novo servidor é iniciado com um cache completamente vazio, o que pode causar uma intermitência de solicitações ao serviço dependente à medida que ele preenche seu cache. Esse pode ser um problema significativo durante implantações ou em outras circunstâncias nas quais o cache é liberado em toda a frota. A coerência do cache e os problemas de cache vazio geralmente podem ser resolvidos usando a coalescência de solicitações, descrita em detalhes posteriormente neste artigo.

Caches externos

Os caches externos podem solucionar muitos dos problemas que acabamos de citar. Um cache externo armazena os dados em uma frota à parte; por exemplo, usando Memcached ou Redis. Problemas de coerência de cache são reduzidos, porque o cache externo retém o valor usado por todos os servidores da frota. (Note que esses problemas não são totalmente eliminados porque podem surgir casos de falha durante a atualização do cache.) A carga geral nos serviços downstream diminui em comparação aos caches na memória e não é proporcional ao tamanho da frota. Não há problemas de início a frio durante eventos como implantações, porque o cache externo permanece preenchido em todo o processo de implantação. Por fim, os caches externos oferecem mais espaço de armazenamento disponível do que os caches na memória, pois reduzem as ocorrências de remoção de cache causadas por limitações de espaço.

Contudo, os caches externos têm suas desvantagens a serem consideradas. A primeira é o aumento da complexidade geral do sistema e da carga operacional, pois existe uma carga adicional que precisa de monitoramento, gerenciamento e escalabilidade. As características de disponibilidade da frota de cache serão diferentes do serviço dependente para o qual ele atua como cache. A frota de cache pode frequentemente ficar menos disponível. Por exemplo, se ela não aceitar upgrades com zero tempo de inatividade e precisar de janelas de manutenção.

Para que a disponibilidade do serviço não seja prejudicada pelo cache externo, precisávamos adicionar código de serviço para lidar com a indisponibilidade da frota de cache, falha do nó de cache ou falhas de put/get do cache. Uma opção é fazer fallback e chamar o serviço dependente. No entanto, sabemos que é preciso ter cuidado com essa abordagem. Se ocorrer uma longa interrupção do cache, haverá um pico de tráfego incomum no serviço downstream, gerando controle da utilização ou redução de carga desse serviço dependente e, por fim, menor disponibilidade. Preferimos usar o cache externo junto com um cache na memória para ser usado como fallback se o cache externo ficar indisponível, ou para reduzir a carga de uso e limitar a taxa máxima de solicitações enviadas ao serviço downstream. Testamos o comportamento do serviço com o cache desabilitado para confirmar se as medidas de segurança para evitar a redução das dependências estavam funcionando como esperado.

Uma segunda consideração é a escalabilidade e a elasticidade da frota de cache. Conforme a frota de cache começa a atingir sua taxa de solicitação ou o limite da memória, será preciso adicionar nós. Determinamos quais métricas são os principais indicadores desses limites, e podemos assim configurar monitores e alarmes. Por exemplo, trabalhei recentemente em um serviço, no qual nossa equipe percebeu que a utilização da CPU ficava muito alta quando a taxa de solicitação de Redis atingia seu limite. Fizemos o teste de carga com padrões de tráfego realistas para determinar o limite e encontrar o limiar certo do alarme.

À medida que adicionamos capacidade à frota de cache, tomamos cuidado para não causar uma interrupção ou uma perda maciça de dados em cache. As diferentes tecnologias de cache têm suas próprias considerações. Por exemplo, alguns servidores de cache não permitem adicionar cache a um cluster sem tempo de inatividade. Além disso, nem todas as bibliotecas de cliente de cache oferecem hash consistente, que é necessário para adicionar nós à frota de cache e redistribuir os dados em cache. Devido à variabilidade das implementações do cliente de hash consistente e à descoberta de nós na frota de cache, testamos inteiramente a inclusão e a remoção de servidores de cache antes de irem para a produção.

Com um cache externo, tomamos mais cuidado para assegurar a robustez, já que o formato do armazenamento mudou. Os dados em cache são tratados como se estivessem em um armazenamento persistente. Garantimos que o software atualizado sempre possa ler dados de uma versão anterior e que as versões antigas possam lidar com novos formatos/campos (por exemplo, durante implantações quando a frota tem uma combinação de códigos antigos e novos). É importante que exceções não passem despercebidas diante de formatos inesperados, a fim de evitar táticas de envenenamento (poison pills). Entretanto, isso não é suficiente para evitar todos os problemas relacionados a formato. Detectar uma incompatibilidade de formato de versão e descartar os dados em cache pode gerar atualizações maciças dos caches, e causar controle da utilização ou redução da carga do serviço dependente. Os problemas no formato de serialização são abordados em maior profundidade no artigo Como garantir a segurança da reversão durante as implantações.

Uma consideração final sobre os caches externos é que eles são atualizados por nós individuais na frota de serviços. Normalmente, os caches não têm recursos como transações e put condicional. Portanto, temos o cuidado de assegurar que o código de atualização do cache esteja correto e nunca deixe o cache em um estado inválido ou inconsistente.

Caches em linha (inline) x caches auxiliares (side-cache)

Outra decisão a ser tomada ao avaliarmos diferentes abordagens de cache é sobre caches em linha e caches auxiliares (side-cache). Os caches de linha ou caches de leitura/gravação, incorporam o gerenciamento de cache à principal API de acesso aos dados, tornando o gerenciamento de cache um detalhe na implementação dessa API. Os exemplos incluem implementações específicas a aplicativos, como Amazon DynamoDB Accelerator (DAX), e implementações baseadas em padrões, como o cache HTTP (com um cliente de cache local ou um servidor de cache externo, como Nginx ou Varnish). Caches auxiliares (side-cache), são armazenamentos de objetos genéricos, como os fornecidos pelo Amazon ElastiCache (Memcached e Redis), ou bibliotecas, como Ehcache e Google Guava, para os caches na memória. Com os caches auxiliares (side-cache), o código do aplicativo manipula diretamente o cache antes e após chamadas à fonte de dados: busca objetos em cache antes de realizar chamadas downstream e coloca os objetos em cache após as chamadas.

A principal vantagem de um cache em linha é um modelo de API uniforme para os clientes. O cache pode ser adicionado, removido ou ajustado sem qualquer alteração na lógica do cliente. Um cache em linha também retira a lógica de gerenciamento de cache do código do aplicativo, o que elimina uma fonte de possíveis bugs. Os caches HTTP são especialmente interessantes devido às inúmeras opções imediatamente disponíveis, como bibliotecas na memória, proxies HTTP autônomos, como os mencionados anteriormente, e serviços gerenciados, como as redes de entrega de conteúdo (CDNs).

No entanto, a transparência dos caches em linha também pode ser uma desvantagem para a disponibilidade. Os caches externos agora fazem parte da equação de disponibilidade dessa dependência. O cliente não tem a oportunidade de compensar a indisponibilidade temporária de um cache. Por exemplo, se você tem uma frota de Varnish que armazena em cache as solicitações de um serviço REST externo, se essa frota de cache fica inativa, da perspectiva do seu serviço, é como se a dependência fosse desativada. A outra desvantagem do cache em linha é que ele precisa ser incorporado ao protocolo ou ao serviço para o qual atua. Se não houver um cache em linha disponível para o protocolo, esse cache em linha não será uma opção, a menos que você queira criar um cliente integrado ou um serviço de proxy por sua conta.

Expiração do cache

Alguns dos detalhes de implementação de cache mais complexos são a escolha do tamanho certo do cache, a política de expiração e a política de remoção. A política de expiração determina o prazo de retenção de um item no cache. A política mais comum usa uma expiração absoluta baseada em tempo (ou seja, associa um tempo de vida útil, ou TTL, quando cada objeto é carregado). O TTL é escolhido de acordo com os requisitos do cliente, como o nível de tolerância do cliente a dados desatualizados e o quão estáticos são os dados, porque a alteração lenta dos dados pode afetar o cache de forma mais agressiva. O tamanho de cache ideal baseia-se em um modelo do volume previsto de solicitações e a distribuição dos objetos em cache nessas solicitações. A partir daí, estimamos um tamanho de cache que garanta uma alta taxa de acertos do cache com esses padrões de tráfego. A política de remoção controla como os itens são removidos do cache quando ele atinge sua capacidade. A política de remoção mais comum é a do menos usado ou Least Recently Used (LRU).

Até agora, esse é apenas um exercício de pensamento. Os padrões de tráfego reais podem diferir do que projetamos. Por isso, acompanhamos a performance real do nosso cache. Para esse fim, preferimos emitir métricas de serviço sobre os acertos e erros do cache, o tamanho total do cache e o número de solicitações aos serviços downstreams.

Aprendemos que é necessário deliberar sobre a escolha do tamanho do cache e os valores da política de expiração. Queremos evitar a situação em que um desenvolvedor escolhe arbitrariamente algum tamanho de cache e valores de TTL na implementação inicial, e nunca confere sua validade posteriormente. Vimos exemplos reais dessa falta de acompanhamento causar interrupções temporárias de serviço e exacerbação das interrupções em andamento.

Outro padrão para aumentar a resiliência quando serviços downstream ficam indisponíveis é usar dois TTLs: soft TTL e hard TTL. O cliente tenta atualizar os itens em cache com base no soft TTL, mas se o serviço downstream não estiver disponível ou, de alguma maneira, não responder à solicitação, os atuais dados em cache continuarão sendo usados até o hard TTL ser atingido. Um exemplo desse padrão é usado no cliente AWS Identity and Access Management (IAM).

Também usamos a abordagem de soft e hard TTL com contrapressão para reduzir o impacto das reduções de carga do serviço downstream. O serviço downstream pode responder com um evento de contrapressão se sofrer uma redução de carga. Isso sinaliza que o serviço chamador deve usar os dados em cache até o hard TTL e somente fazer solicitações de dados que não estão em seu cache. Continuamos assim até o serviço downstream remover a contrapressão. Esse padrão permite que o serviço downstream se recupere de uma redução de carga, sem perder sua disponibilidade.

Outras considerações

Uma consideração importante é perceber o comportamento do cache quando são recebidos erros do serviço downstream. Uma opção é responder aos clientes usando o último valor válido do cache. Por exemplo, utilizar o padrão de soft TTL/hard TTL descrito anteriormente. Também podemos armazenar a resposta do erro em cache (ou seja, usar um “cache negativo”) com um TTL diferente das entradas de cache positivas e propagar o erro até o cliente. A abordagem escolhida em determinada situação depende das particularidades do serviço e da avaliação do que é melhor para os clientes: ver dados desatualizados ou erros. Independentemente do que escolhermos, é importante assegurar que haja algo no cache em casos de erro. Se a situação não for essa e se o serviço downstream estiver temporariamente indisponível ou de alguma forma incapaz de atender a certas solicitações (por exemplo, quando um recurso downstream é excluído), o serviço upstream continuará a bombardeá-lo com tráfego e possivelmente causará uma interrupção ou exacerbará alguma interrupção já existente. Vimos exemplos reais, em que uma falha no armazenamento das respostas negativas em cache causaram um aumento nas taxas de falha e erros.

Segurança é outro aspecto importante do uso do cache. Ao introduzirmos um cache em um serviço, avaliamos e reduzimos qualquer risco adicional à segurança que ele apresente. Por exemplo, as frotas de cache externo geralmente não têm criptografia para proteger o transporte e dados serializados. Isso é especialmente importante quando há informações confidenciais do usuário retidas no cache. O problema pode ser suavizado com algo como o Amazon ElastiCache for Redis, que oferece suporte à criptografia de dados em trânsito e em repouso. Os caches também são suscetíveis a ataques de envenenamento, em que uma vulnerabilidade no protocolo downstream permite que um invasor preencha o cache com um valor que ele tem sob seu controle. Isso amplia o impacto de um ataque, pois as solicitações efetuadas enquanto esse valor permanece no cache verão o valor malicioso. Como exemplo final, os caches também são suscetíveis a ataques de temporização a canais laterais (side-channel). Os valores em cache retornam mais rapidamente que os valores que não estão em cache. Os invasores podem usar o tempo de resposta para obter informações sobre solicitações de outros clientes ou princípios.

Uma consideração final é a situação de “thundering herd”, na qual vários clientes fazem solicitações que precisam do mesmo recurso downstream que não está em cache, mais ou menos simultaneamente. Isso também pode ocorrer quando um servidor é ativado e se integra à frota com um cache local vazio. Essa situação resulta no acesso de um grande número de solicitações de cada servidor à dependência downstream, o que pode causar controle da utilização/redução de carga. Para solucionar esse problema, usamos coalescência de solicitações, que é quando os servidores ou o cache externo garantem que apenas uma solicitação pendente busque recursos que não estão em cache. Algumas bibliotecas de cache oferecem suporte à coalescência de solicitações, bem como alguns caches externos em linha (como Nginx ou Varnish). Além disso, a coalescência de solicitações pode ser implementada junto com os caches já existentes. 

Melhores práticas e considerações da Amazon

Este artigo abordou várias melhores práticas da Amazon e as vantagens e os riscos associados ao uso do cache. Aqui vai um resumo das melhores práticas e considerações da Amazon usadas por nossas equipes ao inserirem um cache:

• Confirme se há necessidade legítima de usar um cache. A necessidade deve ser justificada em termos de custo, latência e/ou aumento de disponibilidade. Verifique se os dados podem ser armazenados em cache. Ou seja, se podem ser usados em várias solicitações do cliente. Duvide do valor que um cache pode oferecer e avalie cuidadosamente se as vantagens superam os riscos associados ao uso do cache.
• Planeje operar o cache com o mesmo rigor e os mesmos processos usados para o restante da frota de serviços e a infraestrutura. Não subestime esse esforço. Emita métricas sobre a utilização do cache e a taxa de acertos para se certificar de que o cache esteja corretamente ajustado. Monitore os principais indicadores (como CPU e memória) para verificar a integridade da frota de caches externos e se a escalabilidade está correta. Configure alarmes com essas métricas. Verifique se a frota de cache pode ser aumentada sem causar tempo de inatividade ou invalidação maciça do cache. Ou seja, confirme se o hash consistente está funcionando como o esperado.
• Seja seletivo e prático ao escolher o tamanho do cache, a política de expiração e a política de remoção. Realize testes e use as métricas mencionadas no ponto anterior a fim de validar e ajustar essas escolhas.
• Verifique se o seu serviço é resiliente diante de indisponibilidade do cache. Isso inclui várias circunstâncias que impedem as solicitações de serem atendidas com os dados em cache. Podem ser inícios a frio, interrupções da frota de cache, alterações nos padrões de tráfego ou interrupções estendidas de downstream. Em muitos casos, isso pode significar trocar um pouco da sua disponibilidade para garantir que os servidores e os serviços dependentes não sofram redução de carga (por exemplo, nivelando as solicitações com os serviços dependentes ou oferecendo dados desatualizados). Execute testes de carga com os caches desabilitados para conferir.
• Considere os aspectos de segurança dos dados em cache, incluindo criptografia, segurança do transporte durante comunicações com uma frota de caches externos e o impacto dos ataques de envenenamento de cache e dos ataques side-channel.
• Crie um formato de armazenamento para a evolução dos objetos em cache ao longo do tempo (por exemplo, use um número de versão) e desenvolva um código de serialização capaz de ler versões antigas. Fique atento às táticas de envenenamento (poison pills) da lógica de serialização do cache.
• Avalie como o cache tratará os erros de downstream e considere manter um cache negativo com um TTL distinto. Não provoque nem amplie uma interrupção com solicitações repetidas ao mesmo recurso downstream e descarte as respostas de erro.

Muitas equipes de serviços da Amazon usam técnicas de armazenamento em cache. Apesar dos benefícios dessas técnicas, não somos displicentes na hora de decidir sobre o uso de cache. É porque as desvantagens podem frequentemente superar as vantagens. Esperamos que este artigo ajude você a avaliar o uso do cache em seus serviços.


Sobre os autores

Matt é engenheiro-chefe em dispositivos emergentes na Amazon, onde trabalha no software e nos serviços referentes aos próximos dispositivos de consumo. Trabalhou anteriormente no AWS Elemental, à frente da equipe que lançou o MediaTailor, um serviço de inserção de anúncios personalizado do servidor para vídeos ao vivo e sob demanda. Ajudou a lançar a primeira temporada do PrimeVideo, com streaming do NFL Thursday Night Football. Antes da Amazon, Matt trabalhou 15 anos no setor de segurança, inclusive na McAfee, Intel e algumas start-ups, onde se dedicou ao gerenciamento da segurança na empresa, tecnologias antimalware e antiexploração, medidas de segurança assistidas por hardware e DRM.

Jas Chhabra é engenheiro sênior na AWS. Jas Chhabra ingressou na AWS em 2016 e trabalhou no AWS IAM por alguns anos antes de passar para a sua função atual no AWS Machine Learning. Antes da AWS, ele trabalhou na Intel em várias funções técnicas nas áreas de IoT, identidade e segurança. Seus atuais interesses são machine learning, segurança e sistemas distribuídos de grande escala. Antigos interesses incluem IoT, bitcoins, identidade e criptografia. Tem diploma de mestrado em Ciência da Computação.

Como evitar o fallback em sistemas distribuídos Como usar o descarte de carga para evitar sobrecarga Garantindo a segurança da reversão durante as implantações