Padrões de Cache Distribuído: Estratégias para Escala e Consistência
Cache é a diferença entre um sistema que escala e um que colapsa sob carga. Aprenda padrões write-through, write-behind, read-through e invalidação de cache para sistemas distribuídos.
Resumo executivo
Cache é a diferença entre um sistema que escala e um que colapsa sob carga. Aprenda padrões write-through, write-behind, read-through e invalidação de cache para sistemas distribuídos.
Ultima atualizacao: 11/03/2026
O problema de cache
Em escala, cada query de banco de dados se torna um gargalo. A história é familiar: aplicação lança, tráfego cresce, pools de conexão de banco esgotam, tempos de resposta disparam, e o sistema colapsa sob carga. Cache resolve isso armazenando dados frequentemente acessados em memória, reduzindo drasticamente a carga do banco de dados.
O desafio é implementar cache corretamente. Caches mal projetados introduzem dados obsoletos, estado inconsistente e complexidade que anula ganhos de performance. A diferença entre um sistema que escala e um que introduz bugs está em entender padrões de cache: quando escrever, o que invalidar, e como lidar com misses.
Cache efetivo em sistemas distribuídos requer escolher o padrão certo para seu caso de uso: cache-aside para workloads leitura-intensivas, write-through para forte consistência, write-behind para throughput de escrita, e read-through para lógica de aplicação simplificada.
Padrão cache-aside
O padrão cache-aside (também chamado de lazy loading) verifica o cache primeiro, então retorna ao banco de dados em miss, populando o cache para requisições subsequentes.
Quando usar cache-aside
- Workloads leitura-intensivas com acesso frequente aos mesmos dados
- Dados que mudam infrequentemente
- Quando você quer cache populado apenas sob demanda
- Quando redução de carga do banco de dados é o objetivo principal
Implementação
typescriptclass ServicoCacheAside {
constructor(
private cache: RedisClient,
private database: Database
) {}
async getUsuario(usuarioId: number): Promise<Usuario> {
// Passo 1: Verificar cache
const cacheKey = `usuario:${usuarioId}`;
const cached = await this.cache.get(cacheKey);
if (cached) {
this.metrics.record('cache_hit', { key: cacheKey });
return JSON.parse(cached);
}
// Passo 2: Cache miss - buscar do banco de dados
this.metrics.record('cache_miss', { key: cacheKey });
const usuario = await this.database.usuarios.findUnique({
where: { id: usuarioId }
});
if (!usuario) {
throw new NotFoundError(`Usuario ${usuarioId} não encontrado`);
}
// Passo 3: Popular cache para requisições futuras
await this.cache.setex(cacheKey, 3600, JSON.stringify(usuario));
return usuario;
}
async atualizarUsuario(usuarioId: number, dados: Partial<DadosUsuario>): Promise<Usuario> {
// Passo 1: Atualizar banco de dados
const usuario = await this.database.usuarios.update({
where: { id: usuarioId },
dados
});
// Passo 2: Invalidar cache
const cacheKey = `usuario:${usuarioId}`;
await this.cache.del(cacheKey);
this.metrics.record('invalidacao_cache', { key: cacheKey });
// Próxima leitura repopulará cache com dados frescos
return usuario;
}
}Vantagens e trade-offs
| Vantagens | Trade-offs |
|---|---|
| Cache populado apenas sob demanda | Primeira requisição sofre de latência de cache miss |
| Implementação simples | Dados obsoletos possíveis entre invalidação e repopulação |
| Funciona bem para workloads leitura-intensivas | Thundering herd se cache expira e múltiplas requisições atingem simultaneamente |
Mitigando thundering herd
typescriptasync getUsuarioComLock(usuarioId: number): Promise<Usuario> {
const cacheKey = `usuario:${usuarioId}`;
const lockKey = `lock:${cacheKey}`;
// Verificar cache primeiro
const cached = await this.cache.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Adquirir lock para prevenir thundering herd
const lockAdquirido = await this.cache.set(lockKey, '1', {
NX: true,
EX: 10 // timeout de lock de 10 segundos
});
if (lockAdquirido) {
try {
// Temos o lock - buscar do banco de dados
const usuario = await this.database.usuarios.findUnique({
where: { id: usuarioId }
});
if (usuario) {
await this.cache.setex(cacheKey, 3600, JSON.stringify(usuario));
}
return usuario;
} finally {
// Liberar lock
await this.cache.del(lockKey);
}
} else {
// Esperar por detentor do lock e tentar novamente
await sleep(100);
return this.getUsuario(usuarioId);
}
}Padrão write-through
O padrão write-through escreve em ambos cache e banco de dados sincronamente. O cache é sempre consistente com o banco de dados.
Quando usar write-through
- Quando você precisa de forte consistência entre cache e banco de dados
- Quando latência de leitura é mais importante que latência de escrita
- Quando cache misses são caros e devem ser minimizados
- Quando você pode permitir operações de escrita ligeiramente mais lentas
Implementação
typescriptclass ServicoCacheWriteThrough {
async criarUsuario(dados: DadosUsuario): Promise<Usuario> {
// Escrever no banco de dados primeiro
const usuario = await this.database.usuarios.create({
dados
});
// Imediatamente escrever no cache
const cacheKey = `usuario:${usuario.id}`;
await this.cache.setex(cacheKey, 3600, JSON.stringify(usuario));
this.metrics.record('write_through', { key: cacheKey });
return usuario;
}
async atualizarUsuario(usuarioId: number, dados: Partial<DadosUsuario>): Promise<Usuario> {
// Atualizar no banco de dados
const usuario = await this.database.usuarios.update({
where: { id: usuarioId },
dados
});
// Atualizar no cache
const cacheKey = `usuario:${usuarioId}`;
await this.cache.setex(cacheKey, 3600, JSON.stringify(usuario));
this.metrics.record('write_through', { key: cacheKey });
return usuario;
}
async getUsuario(usuarioId: number): Promise<Usuario> {
const cacheKey = `usuario:${usuarioId}`;
const cached = await this.cache.get(cacheKey);
if (cached) {
this.metrics.record('cache_hit', { key: cacheKey });
return JSON.parse(cached);
}
// Cache miss - não deveria acontecer com write-through
// mas tratar graciosamente para falhas de cache
const usuario = await this.database.usuarios.findUnique({
where: { id: usuarioId }
});
if (usuario) {
await this.cache.setex(cacheKey, 3600, JSON.stringify(usuario));
}
return usuario;
}
}Vantagens e trade-offs
| Vantagens | Trade-offs |
|---|---|
| Forte consistência entre cache e banco de dados | Operações de escrita mais lentas (duas escritas) |
| Cache sempre contém dados mais recentes | Maior latência em escritas |
| Sem thundering herd em cache miss | Escritas desnecessárias para dados que não serão lidos |
Padrão write-behind
O padrão write-behind (também chamado write-back) escreve no cache primeiro e persiste assincronamente no banco de dados. Isso maximiza throughput de escrita.
Quando usar write-behind
- Workloads escrita-intensivas requerendo alto throughput
- Quando consistência eventual é aceitável
- Quando você pode tolerar potencial perda de dados em falha de cache
- Para escritas de alto volume que não precisam de persistência imediata
Implementação
typescriptclass ServicoCacheWriteBehind {
private filaEscrita: WriteQueue;
async atualizarUsuario(usuarioId: number, dados: Partial<DadosUsuario>): Promise<Usuario> {
const cacheKey = `usuario:${usuarioId}`;
// Atualizar no cache imediatamente (escrita rápida)
const atual = await this.cache.get(cacheKey);
const usuario = atual ? JSON.parse(atual) : {};
const atualizado = { ...usuario, ...dados };
await this.cache.setex(cacheKey, 3600, JSON.stringify(atualizado));
// Enfileirar para escrita assíncrona no banco de dados
await this.filaEscrita.push({
tipo: 'ATUALIZAR_USUARIO',
usuarioId,
dados,
timestamp: Date.now()
});
return atualizado;
}
async iniciarWorkerEscrita(): Promise<void> {
setInterval(async () => {
const batch = await this.filaEscrita.getBatch(100); // Batch até 100 itens
for (const item of batch) {
try {
await this.processarItemEscrita(item);
} catch (error) {
this.logger.error('Write-behind falhou', { error, item });
// Retentar ou mover para fila de dead letter
}
}
}, 1000); // Processar a cada segundo
}
private async processarItemEscrita(item: WriteQueueItem): Promise<void> {
switch (item.tipo) {
case 'ATUALIZAR_USUARIO':
await this.database.usuarios.update({
where: { id: item.usuarioId },
dados: item.dados
});
break;
// Tratar outros tipos de escrita
}
}
}Vantagens e trade-offs
| Vantagens | Trade-offs |
|---|---|
| Máximo throughput de escrita | Perda de dados possível se cache falhar antes de persistir |
| Tempo de resposta rápido para escritas | Recuperação e tratamento de consistência complexos |
| Batching reduz carga do banco de dados | Consistência eventual |
| Reduz amplificação de escrita | Difícil de implementar corretamente |
Padrão read-through
O padrão read-through abstrai lógica de cache atrás de uma interface simples. O cache lida com misses automaticamente buscando do banco de dados.
Quando usar read-through
- Quando você quer simplificar código da aplicação
- Quando lógica de cache deve ser centralizada
- Quando você tem muitas buscas de cache similares
- Quando você quer comportamento de cache consistente através da codebase
Implementação
typescriptclass CacheReadThrough<T> {
constructor(
private cache: RedisClient,
private loader: (key: string) => Promise<T>,
private ttl: number = 3600
) {}
async get(key: string): Promise<T> {
const cached = await this.cache.get(key);
if (cached) {
this.metrics.record('cache_hit', { key });
return JSON.parse(cached);
}
// Cache miss - usar loader
this.metrics.record('cache_miss', { key });
const value = await this.loader(key);
// Armazenar no cache
await this.cache.setex(key, this.ttl, JSON.stringify(value));
return value;
}
async invalidate(key: string): Promise<void> {
await this.cache.del(key);
this.metrics.record('invalidacao_cache', { key });
}
}
// Uso
class ServicoUsuario {
private cacheUsuario: CacheReadThrough<Usuario>;
constructor(cache: RedisClient, database: Database) {
this.cacheUsuario = new CacheReadThrough(
cache,
async (key) => {
const usuarioId = parseInt(key.split(':')[1]);
return database.usuarios.findUnique({ where: { id: usuarioId } });
},
3600 // TTL de 1 hora
);
}
async getUsuario(usuarioId: number): Promise<Usuario> {
return this.cacheUsuario.get(`usuario:${usuarioId}`);
}
async atualizarUsuario(usuarioId: number, dados: Partial<DadosUsuario>): Promise<Usuario> {
const usuario = await this.database.usuarios.update({
where: { id: usuarioId },
dados
});
await this.cacheUsuario.invalidate(`usuario:${usuarioId}`);
return usuario;
}
}Estratégias de invalidação de cache
A parte mais difícil de cache é saber quando invalidar dados em cache.
Expiração baseada em tempo
typescript// Definir TTL em chaves de cache
await this.cache.setex(`usuario:${usuarioId}`, 3600, JSON.stringify(usuario)); // 1 hora
// Configurar diferentes TTLs baseado em volatilidade de dados
const configTTL = {
estatico: 86400, // 24 horas
frequente: 3600, // 1 hora
volatil: 300, // 5 minutos
sessao: 1800 // 30 minutos
};Invalidação baseada em eventos
typescriptclass ServicoInvalidacaoCache {
async invalidarRelacionadoUsuario(usuarioId: number): Promise<void> {
// Invalidar cache direto de usuário
await this.cache.del(`usuario:${usuarioId}`);
// Invalidar caches relacionados
const postsUsuario = await this.database.posts.findMany({
where: { usuarioId },
select: { id: true }
});
for (const post of postsUsuario) {
await this.cache.del(`post:${post.id}`);
}
// Invalidar cache de feed do usuário
await this.cache.del(`feed:${usuarioId}`);
this.metrics.record('invalidacao_cascata', {
usuarioId,
itensInvalidados: postsUsuario.length + 2
});
}
}Invalidação baseada em tags
typescriptclass CacheTagged {
async set(key: string, value: any, tags: string[], ttl: number): Promise<void> {
await this.cache.setex(key, ttl, JSON.stringify(value));
// Rastrear tags para esta chave
for (const tag of tags) {
await this.cache.sadd(`tags:${tag}`, key);
}
}
async invalidarTag(tag: string): Promise<void> {
// Obter todas as chaves com esta tag
const keys = await this.cache.smembers(`tags:${tag}`);
// Deletar todas as chaves taggeadas
if (keys.length > 0) {
await this.cache.del(...keys);
}
// Limpar conjunto de tags
await this.cache.del(`tags:${tag}`);
}
}
// Uso
await cacheTagged.set('usuario:123', dadosUsuario, ['usuario', 'frequente'], 3600);
await cacheTagged.set('post:456', dadosPost, ['post', 'volatil'], 300);
// Invalidar todos os caches relacionados a usuário
await cacheTagged.invalidarTag('usuario');Consistência de cache distribuído
Em sistemas distribuídos com múltiplos nós de cache, manter consistência se torna desafiador.
Redis Cluster
typescriptclass CacheRedisCluster {
private client: RedisCluster;
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async set(key: string, value: string, opcoes: { ttl?: number }): Promise<void> {
if (opcoes.ttl) {
await this.client.setex(key, opcoes.ttl, value);
} else {
await this.client.set(key, value);
}
}
}Cache warming
typescriptclass ServicoCacheWarmup {
async warmCacheUsuario(usuarioIds: number[]): Promise<void> {
const tamanhoLote = 100;
for (let i = 0; i < usuarioIds.length; i += tamanhoLote) {
const lote = usuarioIds.slice(i, i + tamanhoLote);
await Promise.all(
lote.map(async (usuarioId) => {
const usuario = await this.database.usuarios.findUnique({
where: { id: usuarioId }
});
if (usuario) {
await this.cache.setex(
`usuario:${usuarioId}`,
3600,
JSON.stringify(usuario)
);
}
})
);
}
}
}Anti-padrões comuns de cache
Anti-padrão 1: Cachear tudo
Problema: Cachear todos os dados independentemente do padrão de acesso.
Consequências: Memória de cache esgotada, baixas taxas de hit, performance degradada.
Solução: Cachear apenas dados frequentemente acessados com custo de recuperação significativo.
Anti-padrão 2: Sem estratégia de invalidação de cache
Problema: Definir TTL muito longo ou nunca invalidar cache.
Consequências: Dados obsoletos se propagam através do sistema, estado inconsistente.
Solução: Implementar expiração baseada em tempo mais invalidação baseada em eventos para dados críticos.
Anti-padrão 3: Padrão de query N+1 com cache
Problema: Cachear itens individuais em um loop.
typescript// RUIM
for (const usuarioId of usuarioIds) {
const usuario = await getUsuario(usuarioId); // Cada chamada verifica cache individualmente
usuarios.push(usuario);
}
// BOM
const keys = usuarioIds.map(id => `usuario:${id}`);
const cached = await this.cache.mget(...keys);
// Então buscar apenas usuários ausentes do banco de dadosAnti-padrão 4: Ignorar falhas de cache
Problema: Não tratar falhas de cache graciosamente.
Consequências: Aplicação quebra quando cache está indisponível.
Solução: Implementar fallback para banco de dados quando cache falha.
typescriptasync getComFallback(key: string): Promise<any> {
try {
const cached = await this.cache.get(key);
if (cached) return JSON.parse(cached);
} catch (error) {
this.logger.warn('Cache indisponível, usando fallback para banco de dados', { error });
}
return this.database.query(...);
}Seleção de tecnologia de cache
| Tecnologia | Melhor para | Trade-offs |
|---|---|---|
| Redis | Estruturas de dados complexas, persistência | Single-threaded, intensivo em memória |
| Memcached | Simples chave-valor, alto throughput | Sem persistência, tipos de dados limitados |
| Hazelcast | Computação distribuída, data grid in-memory | Setup complexo, footprint mais pesado |
| Ignite | SQL-on-memory, transações distribuídas | Curva de aprendizado mais íngreme |
Monitoramento e métricas
Rastreie eficácia de cache para otimizar sua estratégia.
typescriptinterface MetricasCache {
taxaHit: number;
taxaMiss: number;
latenciaMediaHit: number;
latenciaMediaMiss: number;
qtdEvicoes: number;
usoMemoria: number;
}
async getMetricasCache(): Promise<MetricasCache> {
const hits = await this.metrics.getCounter('cache_hit');
const misses = await this.metrics.getCounter('cache_miss');
const total = hits + misses;
return {
taxaHit: total > 0 ? hits / total : 0,
taxaMiss: total > 0 ? misses / total : 0,
latenciaMediaHit: await this.metrics.getAverage('cache_hit_latency'),
latenciaMediaMiss: await this.metrics.getAverage('cache_miss_latency'),
qtdEvicoes: await this.cache.dbsize(),
usoMemoria: await this.cache.info('memory').then(info => info.used_memory)
};
}Conclusão
Padrões de cache distribuído fornecem um kit de ferramentas para construir sistemas escaláveis. Cache-aside se adequa a workloads leitura-intensivas, write-through garante consistência, write-behind maximiza throughput, e read-through simplifica lógica de aplicação.
A chave é combinar o padrão ao seu caso de uso. Não cacheie tudo—focar em dados frequentemente acessados e caros de recuperar. Implemente invalidação apropriada para prevenir dados obsoletos. Monitore métricas de cache para ajustar TTL e identificar padrões.
Comece com cache-aside para caminhos leitura-intensivos. Adicione write-through onde consistência importa. Considere write-behind para workloads escrita-intensivas aceitando consistência eventual. Sempre trate falhas de cache graciosamente retornando ao banco de dados.
Seu sistema precisa de uma estratégia de cache que escale com crescimento? Fale com especialistas em engenharia da Imperialis sobre implementação de cache distribuído, seleção de padrão e otimização de performance para sua arquitetura de produção.
Fontes
- Redis Caching Best Practices — padrões de cache Redis
- AWS ElastiCache for Redis — serviço gerenciado Redis
- Microsoft Cache Patterns — orientação de padrões de cache
- Martin Fowler's Cache-Aside — explicação de padrão