System Design

Estratégias de Cache: Guia Completo para Otimização de Performance

✍️ Taylson Martinez
15 min read
Estratégias de Cache: Guia Completo para Otimização de Performance

Domine estratégias de cache incluindo cache-aside, write-through, write-back e mais. Aprenda quando usar cada padrão para performance otimizada.

🌐

Este artigo também está disponível em inglês

Ler em Inglês →

Por Que Cache Importa

Cache é uma das formas mais eficazes de melhorar a performance de aplicações. Um cache bem implementado pode reduzir:

  • Carga do banco de dados em 80-90%
  • Tempos de resposta de API de segundos para milissegundos
  • Custos de infraestrutura significativamente

Estratégias Populares de Caching

1. Cache-Aside (Lazy Loading)

A aplicação é responsável por carregar dados no cache.

@Service
class UserService(
    private val cacheManager: CacheManager,
    private val userRepository: UserRepository
) {
    fun getUser(userId: String): User? {
        val cache = cacheManager.getCache("users")
        
        // Tenta o cache primeiro
        return cache?.get(userId, User::class.java) ?: run {
            // Cache miss - carrega do banco de dados
            val user = userRepository.findById(userId).orElse(null)
            
            // Armazena no cache para próxima vez
            user?.let { cache?.put(userId, it) }
            
            user
        }
    }
}

Prós:

  • Apenas dados solicitados são cacheados
  • Resiliente a falhas de cache
  • Simples de implementar

Contras:

  • Requisição inicial é lenta (cache miss)
  • Cache e banco de dados podem ficar inconsistentes

Melhor para: Cargas de trabalho com muita leitura e padrões de acesso imprevisíveis

2. Read-Through

Cache fica entre aplicação e banco de dados. Cache carrega dados automaticamente em miss.

// Configuração do cache com carregamento automático
@Configuration
@EnableCaching
class CacheConfig {
    
    @Bean
    fun cacheManager(): CacheManager {
        return CaffeineCacheManager("users").apply {
            setCaffeine(Caffeine.newBuilder()
                .expireAfterWrite(3600, TimeUnit.SECONDS)
                .maximumSize(1000))
        }
    }
}

// Código da aplicação é mais simples
@Service
class UserService(private val userRepository: UserRepository) {
    
    @Cacheable(value = ["users"], key = "#userId")
    fun getUser(userId: String): User? {
        return userRepository.findById(userId).orElse(null)
    }
}

Prós:

  • Código da aplicação mais limpo
  • Lógica de cache centralizada

Contras:

  • Acoplamento mais forte com fonte de dados
  • Menos controle sobre população do cache

Melhor para: Aplicações com padrões de acesso bem definidos

3. Write-Through

Dados são escritos no cache e banco de dados simultaneamente.

@Service
class UserService(
    private val userRepository: UserRepository,
    private val cacheManager: CacheManager
) {
    @CachePut(value = ["users"], key = "#userId")
    fun updateUser(userId: String, data: User): User {
        // Atualiza banco de dados
        val updated = userRepository.save(data.copy(id = userId))
        
        // Cache é atualizado automaticamente com @CachePut
        return updated
    }
}

Prós:

  • Cache está sempre consistente
  • Sem cache misses para escritas

Contras:

  • Maior latência de escrita
  • Entradas desnecessárias no cache para dados raramente lidos

Melhor para: Aplicações que requerem forte consistência

4. Write-Back (Write-Behind)

Dados são escritos no cache primeiro, depois assincronamente no banco de dados.

@Service
class UserService(
    private val cacheManager: CacheManager,
    private val writeQueue: Queue<WriteOperation>,
    @Async private val asyncExecutor: Executor
) {
    fun updateUser(userId: String, data: User): User {
        // Escreve no cache imediatamente
        val cache = cacheManager.getCache("users")
        cache?.put(userId, data)
        
        // Enfileira para escrita assíncrona no banco
        writeQueue.offer(WriteOperation(
            table = "users",
            id = userId,
            data = data
        ))
        
        return data
    }
}

// Worker em background processa fila
@Component
class WriteBackWorker(
    private val writeQueue: Queue<WriteOperation>,
    private val userRepository: UserRepository
) {
    @Scheduled(fixedDelay = 100)
    fun processWrites() {
        writeQueue.poll()?.let { operation ->
            when (operation.table) {
                "users" -> userRepository.save(operation.data as User)
            }
        }
    }
}

data class WriteOperation(
    val table: String,
    val id: String,
    val data: Any
)

Prós:

  • Escritas muito rápidas
  • Reduz carga do banco de dados
  • Pode fazer batch de escritas

Contras:

  • Risco de perda de dados se cache falhar
  • Complexo de implementar
  • Consistência eventual

Melhor para: Cargas de trabalho com muita escrita onde alguma perda de dados é aceitável

5. Refresh-Ahead

Cache atualiza proativamente dados antes da expiração.

@Component
class RefreshAheadCache(
    private val redisTemplate: RedisTemplate<String, Any>,
    private val userRepository: UserRepository,
    @Async private val executor: Executor
) {
    private val maxTTL = 3600L // segundos
    
    suspend fun get(key: String): User? {
        val data = redisTemplate.opsForValue().get(key) as? User
        val ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS)
        
        // Se TTL é menos de 20% restante, atualiza
        if (ttl > 0 && ttl < maxTTL * 0.2) {
            refreshAsync(key)
        }
        
        return data
    }
    
    @Async
    fun refreshAsync(key: String) = GlobalScope.launch {
        // Atualização não-bloqueante
        val userId = key.removePrefix("user:")
        val fresh = userRepository.findById(userId).orElse(null)
        
        fresh?.let {
            redisTemplate.opsForValue().set(
                key, 
                it, 
                maxTTL, 
                TimeUnit.SECONDS
            )
        }
    }
}

Melhor para: Dados previsíveis e frequentemente acessados

Estratégias de Invalidação de Cache

“Existem apenas duas coisas difíceis em Ciência da Computação: invalidação de cache e nomear coisas.” — Phil Karlton

Expiração Baseada em Tempo (TTL)

// TTL curto para dados que mudam frequentemente
redisTemplate.opsForValue().set(
    "stock_price", 
    price, 
    60, 
    TimeUnit.SECONDS
) // 1 minuto

// TTL mais longo para dados estáveis
redisTemplate.opsForValue().set(
    "user_profile", 
    profile, 
    3600, 
    TimeUnit.SECONDS
) // 1 hora

Invalidação Baseada em Eventos

@Service
class UserService(
    private val userRepository: UserRepository,
    private val cacheManager: CacheManager,
    private val eventPublisher: ApplicationEventPublisher
) {
    @CacheEvict(value = ["users"], key = "#userId")
    fun updateUser(userId: String, data: User): User {
        val updated = userRepository.save(data)
        
        // Publica evento para sistemas distribuídos
        eventPublisher.publishEvent(
            UserUpdatedEvent(this, userId)
        )
        
        return updated
    }
}

data class UserUpdatedEvent(
    val source: Any,
    val userId: String
) : ApplicationEvent(source)

Invalidação Baseada em Tags

@Service
class TaggedCacheService(private val redisTemplate: RedisTemplate<String, Any>) {
    
    // Marca entradas de cache relacionadas
    fun setWithTags(key: String, value: Any, tags: List<String>, ttl: Long) {
        redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS)
        
        // Associa key com tags
        tags.forEach { tag ->
            redisTemplate.opsForSet().add("tag:$tag", key)
        }
    }
    
    // Invalida todos os caches relacionados a uma tag
    fun deleteByTag(tag: String) {
        val keys = redisTemplate.opsForSet().members("tag:$tag") ?: emptySet()
        
        keys.forEach { key ->
            redisTemplate.delete(key as String)
        }
        
        redisTemplate.delete("tag:$tag")
    }
}

// Uso
taggedCacheService.setWithTags(
    "user:123", 
    userData, 
    listOf("user", "user:123"),
    3600
)

taggedCacheService.deleteByTag("user:123")

Escolhendo o TTL do Cache

Tipo de DadosTTLRaciocínio
Sessões de usuário30 min - 2 horasBalanço entre segurança e UX
Perfis de usuário1 - 24 horasMuda com pouca frequência
Catálogo de produtos5 - 60 minutosAtualiza periodicamente
Dados em tempo real10 - 60 segundosPrecisa ser fresco
Conteúdo estático7 - 30 diasRaramente muda

Anti-Padrões Comuns de Caching

❌ Cachear Tudo

Nem todos os dados devem ser cacheados. Não cacheie:

  • Dados que mudam frequentemente
  • Objetos grandes que não cabem na memória
  • Dados raramente acessados
  • Dados sensíveis sem criptografia

❌ Ignorar Cache Stampede

Múltiplas requisições atingindo o banco quando cache expira simultaneamente.

Solução: Cache Locking

@Service
class CacheService(
    private val redisTemplate: RedisTemplate<String, Any>,
    private val userRepository: UserRepository
) {
    fun getWithLock(key: String): User? {
        // Verifica cache primeiro
        redisTemplate.opsForValue().get(key)?.let { 
            return it as User 
        }
        
        val lockKey = "lock:$key"
        val lockAcquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS) ?: false
        
        return if (lockAcquired) {
            try {
                // Esta requisição carrega dados
                val userId = key.removePrefix("user:")
                val data = userRepository.findById(userId).orElse(null)
                
                data?.let {
                    redisTemplate.opsForValue().set(
                        key, 
                        it, 
                        3600, 
                        TimeUnit.SECONDS
                    )
                }
                data
            } finally {
                redisTemplate.delete(lockKey)
            }
        } else {
            // Aguarda outra requisição popular o cache
            Thread.sleep(100)
            getWithLock(key)
        }
    }
}

❌ Não Monitorar Taxa de Acerto do Cache

Acompanhe a performance do seu cache:

@Service
class CacheService(
    private val cacheManager: CacheManager,
    private val userRepository: UserRepository,
    private val meterRegistry: MeterRegistry
) {
    fun getWithMetrics(key: String): User? {
        val cache = cacheManager.getCache("users")
        val data = cache?.get(key, User::class.java)
        
        return if (data != null) {
            meterRegistry.counter("cache.hit", "cache", "users").increment()
            data
        } else {
            meterRegistry.counter("cache.miss", "cache", "users").increment()
            loadFromDatabase(key)
        }
    }
    
    private fun loadFromDatabase(key: String): User? {
        val userId = key.removePrefix("user:")
        return userRepository.findById(userId).orElse(null)
    }
}

Busque uma taxa de acerto de 80%+ para caching efetivo.

Caching Multi-Camada

Combine múltiplas camadas de cache para performance otimizada:

Cache do Navegador (L1)

Cache CDN (L2)

Cache da Aplicação (L3) → Redis

Cache de Query do Banco (L4)

Banco de Dados

Ferramentas e Tecnologias

Redis

O cache em memória mais popular. Suporta:

  • Múltiplas estruturas de dados
  • Pub/sub
  • Persistência
  • Clustering

Memcached

Cache distribuído simples e de alta performance.

CDN Caching

CloudFlare, Fastly, CloudFront para assets estáticos.

Application-Level Caching

Caches em memória na sua aplicação (Node.js: node-cache, Python: cachetools)

Boas Práticas

  1. Comece com caching baseado em TTL - Simples e efetivo
  2. Monitore taxas de acerto do cache - Saiba se o caching está funcionando
  3. Use estruturas de dados apropriadas - Hashes para objetos, sets para coleções
  4. Implemente circuit breakers - Degradação graciosa se cache falhar
  5. Criptografe dados sensíveis - Nunca cacheie senhas/tokens em texto plano
  6. Use hashing consistente - Para caches distribuídos
  7. Comprima valores grandes - Economize memória e largura de banda

Conclusão

Caching é uma técnica poderosa de otimização, mas adiciona complexidade. Escolha a estratégia certa baseada em:

  • Proporção leitura/escrita
  • Requisitos de consistência
  • Padrões de acesso aos dados
  • Restrições de infraestrutura

Comece simples, meça o impacto e itere. Uma estratégia básica de cache-aside com TTLs apropriados pode dar 90% dos benefícios com 10% da complexidade.

Feliz caching! 🚀