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
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 Dados | TTL | Raciocínio |
|---|---|---|
| Sessões de usuário | 30 min - 2 horas | Balanço entre segurança e UX |
| Perfis de usuário | 1 - 24 horas | Muda com pouca frequência |
| Catálogo de produtos | 5 - 60 minutos | Atualiza periodicamente |
| Dados em tempo real | 10 - 60 segundos | Precisa ser fresco |
| Conteúdo estático | 7 - 30 dias | Raramente 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
- Comece com caching baseado em TTL - Simples e efetivo
- Monitore taxas de acerto do cache - Saiba se o caching está funcionando
- Use estruturas de dados apropriadas - Hashes para objetos, sets para coleções
- Implemente circuit breakers - Degradação graciosa se cache falhar
- Criptografe dados sensíveis - Nunca cacheie senhas/tokens em texto plano
- Use hashing consistente - Para caches distribuídos
- 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! 🚀