Cache: Modos de usar

Adriano Croco
5 min readMar 20, 2023

Olá!

No texto de hoje eu gostaria de explorar um pouco melhor algumas técnicas de Cache que são utilizadas na arquitetura de sistemas modernos, porém, que ainda sim geram muitas dúvidas.

Se você não conhece muito bem essa técnica, vamos para a definição:

Cache é um dispositivo de acesso rápido, interno a um sistema, que serve de intermediário entre um operador de um processo e o dispositivo de armazenamento ao qual esse operador incrementa. A principal vantagem na utilização de um cache consiste em evitar o acesso ao dispositivo de armazenamento — que pode ser demorado -, armazenando os dados em meios de acesso mais rápidos.

Só um detalhe: Pronuncia-se algo como Cash e não Cachê. Já vi a escrita da palavra confundir a pronúncia de algumas pessoas.

Dado que o propósito dessa técnica é simplesmente evitar o acesso a um dispositivo externo lento, quais são as formas possíveis de se fazer isso?

Existem 4 formas catalogadas que encontrei nas minhas pesquisas. Obviamente que as estou citando dessa forma simplesmente para definir um vocabulário comum. Mas tá tudo bem se você ver outros nomes por aí para os mesmos termos.

Irei utilizar os termos Cache Hit e Cache Miss em alguns pontos também. Basicamente, o Hit é quando o dado foi lido do cache com sucesso e o Miss ocorre quando isso não acontece, o que pode ser resumido na seguinte imagem, que representa a estratégia Cache-Aside (vou comentar detalhadamente adiante):

Exemplo de Cache-Aside em uma CDN

Com essa introdução feita, vamos as estratégias em si.

Cache-aside

Nessa técnica, o aplicativo lê os dados do cache primeiro. Após isso, ele busca os dados da fonte de dados do back-end e os replica no cache. Essa técnica é simples e eficiente, mas exige que o aplicativo gerencie o cache explicitamente. O exemplo mais direto dessa técnica talvez seja o mais onipresente na Web: CDNs.

No código abaixo, há um exemplo que usa essa técnica para ler um Post de uma fonte de dados:

async function getPost(postId: string): Promise<Post> {
//vai no cache primeiro
const post = await cache.get<Post>(`post_${postId}`);
if (post) {
return post;
}

//senão achou, vai no banco de dados e replica os dados no cache
const fetchedPost = await db.getPost(postId);
cache.set(`post_${postId}`, fetchedPost);
return fetchedPost;
}

Write-through

Nessa técnica, sempre que o aplicativo atualiza ou insere novos dados, ele grava os dados no cache e na fonte de dados simultaneamente. Essa técnica garante que os dados estejam sempre sincronizados nos dois lugares. Geralmente essa técnica é utilizada para dados que temos certeza que podem ser replicados em ambas as fontes sem maiores problemas na invalidação posteriormente.

Como exemplo de código, temos:

async function updatePost(postId: string, newData: PostData): Promise<void> {
//escrita sincrona nos dois lugares
await db.updatePost(postId, newData);
await cache.set(`post_${postId}`, { ...newData, id: postId });
}

Write-behind

Nessa técnica, os dados são gravados primeiro no cache e em seguida, gravados de forma assíncrona no banco de dados. Essa técnica reduz a latência das operações de gravação, mas pode resultar em inconsistências de dados entre o cache e a fonte de dados. Portanto, quando mais você souber do comportamento dos dados, melhor para decidir qual técnica usar. Inclusive, isso serve para arquitetura de uma maneira geral: Quanto mais você souber sobre o comportamento dos usuários e padrão de acessos, melhor para desenhar uma solução.

A principal diferença da técnica anterior é observável no código abaixo:

async function updatePost(postId: string, newData: PostData): Promise<void> {
await db.updatePost(postId, newData);
//aqui, a escrita é async após a atualização na fonte de dados
cache.set(`post_${postId}`, { ...newData, id: postId });
}

Refresh-ahead

Nessa técnica, o intuito é tentar antecipar o que vai ser solicitado de alguma forma. Isso pode ser feito com algum tipo de timer recorrente ou algo similar. Caso ocorra um excesso de Cache-Miss ao usar essa estratégia, a recomendação é rever esse tipo de técnica. Ela serve para melhorar o desempenho das operações de leitura, mas também pode sobrecarregar a fonte de dados (afinal, além da carga normal, há um outro componente pedindo dados dessa fonte com certa frequência).

No exemplo abaixo, a cada minuto, o cache é atualizado a partir da fonte de dados:

class Cache {
private data: Map<string, any> = new Map();
private lastRefresh: Date = new Date();
private refreshInterval: number = 60 * 1000; // 1 minuto

public async get(key: string): Promise<any> {
const now = new Date();
//a cada minuto
if (now.getTime() - this.lastRefresh.getTime() > this.refreshInterval) {
// chama a função de atualização
await this.refresh();
}
return this.data.get(key);
}

private async refresh(): Promise<void> {
// busca os dados na fonte de dados
const newData = await fetchData();
// atualiza o cache com os dados da fonte
for (const [key, value] of newData.entries()) {
this.data.set(key, value);
}
// atualiza a ultima data de atualização
this.lastRefresh = new Date();
}
}

E que ferramentas são usadas para conseguirmos utilizar tais técnicas?

Uma das mais populares é o Redis. Apesar de existirem outras opções que podem fazer o mesmo trabalho por aí, como o memcached e até algumas opções de bancos NoSQL (como o DynamoDB e o MongoDB), ao se avaliar facilidade de uso + performance + facilidade de gerenciamento, o Redis acaba se sobressaindo para casos de uso mais comuns de uso desse tipo de técnica.

Ele é um banco de dados em memória (que por sua vez, já torna os acessos ordens de magnitude mais rápidos que o uso de disco). Além disso, ao usar técnicas avançadas de concorrência como IO Multiplexing e Event Loops (similar ao NodeJs), consegue performance expressiva, mais detalhes aqui.

Na imagem abaixo, existe um resumo das principais features do Redis:

Overview do Redis

Para decidir como usar o Redis da melhor forma, o uso dos fundamentos (como estrutura de dados corretas) é importantissímo. Abaixo você encontra as principais estruturas que a ferramenta consegue trabalhar:

Exemplo de estruturas de dados que o Redis fornece suporte

A recomendação aqui é: Qualquer estrutura que não soe familiar para você, vá pesquisar para melhorar sua caixa de ferramentas de técnicas de código. Inclusive, sugiro implementar essas estruturas de dados na linguagem de sua preferência, para entendê-las melhor.

Depois de saber o básico de Cache, você provavelmente irá se deparar com a seguinte situação: Saber como invalidá-lo.

Existem apenas duas coisas difíceis em Ciência da Computação: invalidação de cache e nomeação de coisas. (Phil Karlton)

Explorar isso em profundidade é assunto para artigos futuros.

Até!

Você gostou do conteúdo e gostaria de fazer mentoria comigo? Clique aqui e descubra como.

--

--