Caching distribuído e Redis, do cache-aside ao cluster
Cache é a alavanca de performance mais usada e a fonte de bug mais sutil. Esta apostila trata os dois lados: os padrões e as armadilhas de caching (stampede, avalanche, consistência) e o Redis a fundo — estruturas, eviction, rate limiting, locks, filas, Cluster, persistência e operação.
O módulo 5 da apostila de NoSQL apresenta Redis; esta aprofunda o uso de Redis e a disciplina de caching como um todo (que vale para qualquer cache: CDN, in-process, HTTP). A parte de consistência dialoga com a Modelagem de Dados NoSQL e a de operação com Observabilidade & SRE.
Por que cache existe e onde ele vive
Objetivo: entender o que um cache troca (frescor por velocidade/custo), a hierarquia de caches de um sistema web, e as três métricas que governam tudo.
1.1 O trade-off fundamental
Um cache guarda o resultado de uma operação cara (consulta, cálculo, chamada de API) perto de quem pede, para não repeti-la. Você troca frescor (o dado pode estar desatualizado) por latência (ordens de magnitude menor), carga (o backend recebe menos) e custo (menos CPU, menos chamadas pagas). A pergunta de projeto: quanto de desatualização este dado tolera? — de zero (saldo) a minutos (catálogo) a "para sempre até mudar" (um post publicado).
1.2 A hierarquia de caches
| Camada | Onde | Escopo | TTL típico |
|---|---|---|---|
| Browser cache | no cliente | 1 usuário | minutos a dias (via Cache-Control) |
| CDN / edge | PoPs perto do usuário | global, por objeto | segundos a dias |
| Cache HTTP reverso | Varnish/Nginx à frente da app | por rota | segundos a minutos |
| Cache in-process (L1) | memória da instância da app | 1 instância | segundos |
| Cache distribuído (L2) | Redis/Memcached | todas as instâncias | segundos a horas |
| Cache do banco | buffer pool, materialized views | o banco | gerido pelo SGBD |
Cada camada resolve o pedido mais barato que a de baixo. O erro comum é pular direto para "colocar Redis" sem considerar CDN (para conteúdo público) ou L1 (para dados minúsculos e ultra-quentes).
1.3 As três métricas
- Hit ratio = hits ÷ (hits + misses). Abaixo de ~80% para um cache de leitura pesada, algo está errado (TTL curto demais, chave mal desenhada, working set maior que a memória).
- Latência (p50/p99) do cache e do caminho de miss. Um cache com p99 alto ou um miss que satura o banco anulam o ganho.
- Custo: memória do Redis + tráfego + a economia no backend. Um cache que guarda o que quase nunca é relido só custa.
Pergunta de triagem: "o que é hit ratio e qual seria um valor ruim?". Pleno: "onde você colocaria cache num sistema com estas telas?" — a boa resposta percorre a hierarquia (CDN para o estático e o público, HTTP/edge para páginas, Redis para dados de sessão/consulta, L1 para o punhado de chaves quentíssimas) em vez de "põe tudo no Redis".
✏️ Exercício 1 — Escolha a camada
Para cada item, diga a camada de cache mais adequada e o TTL aproximado: (a) o logo e o CSS do site; (b) a página de um produto (muda de preço 1×/dia); (c) o feed personalizado do usuário logado; (d) a tabela de câmbio usada em todo request (atualiza a cada 10 min); (e) o resultado de uma agregação de relatório que leva 8 s e é aberta ~5×/dia.
Gabarito: (a) CDN, TTL longo (dias) com hash no nome do arquivo para invalidar por deploy. (b) CDN/edge ou cache HTTP reverso, TTL de minutos + stale-while-revalidate; purge no evento de mudança de preço. (c) Não cacheia a página inteira (é por usuário); cacheia os componentes compartilhados e o resultado de consultas em Redis com TTL curto (segundos a 1–2 min), chave por usuário. (d) L1 in-process com TTL de ~1 min (dado minúsculo, lido em todo request, tolera 1 min de atraso) + L2 Redis como fonte. (e) Redis (ou tabela materializada) com TTL de algumas horas e, melhor, refresh-ahead/recomputação agendada — 8 s no caminho do usuário é inaceitável mesmo raramente.
Redis como cache: estruturas e eviction
Objetivo: usar as estruturas do Redis com intenção (não só GET/SET), e configurar maxmemory + política de expulsão para o Redis se comportar como cache e não estourar.
2.1 As estruturas e para que servem
| Tipo | Uso típico em cache/infra |
|---|---|
| String | valor serializado (JSON, protobuf), contador (INCR), flag; SET k v EX 300 |
| Hash | objeto com campos acessados/atualizados isoladamente (perfil, config); economiza memória vs N strings |
| List | fila simples (LPUSH/BRPOP), timeline recente com LTRIM |
| Set | pertencimento, deduplicação, tags; operações de conjunto |
| Sorted Set (ZSet) | leaderboard, fila de prioridade, janela deslizante de rate limit (score = timestamp) |
| Stream | log append-only com consumer groups — fila durável com confirmação e replay |
| Bitmap / Bitfield | presença/flags por id em pouquíssima memória (usuário ativo hoje) |
| HyperLogLog | contagem de únicos aproximada (~0,8% de erro) em 12 KB fixos — visitantes únicos |
| Geo | índice geoespacial (GEOADD/GEOSEARCH) — "lojas perto de mim" |
2.2 TTL e expiração
EXPIRE k 300/SET k v EX 300/PEXPIRE(ms).TTL kconsulta;PERSIST kremove.- Expiração é preguiçosa + amostragem ativa: a chave some quando acessada após vencer, ou quando o ciclo de background a pega. Não conte com precisão de milissegundo.
- Escreveu sem TTL? A chave fica para sempre — a causa nº 1 de Redis lotando aos poucos. Num cache, todo
SETtem TTL.
2.3 maxmemory e políticas de eviction
maxmemory 4gb maxmemory-policy allkeys-lru
| Política | Comportamento | Quando |
|---|---|---|
noeviction | recusa escritas quando cheio (erro) | Redis como store/fila — não como cache |
allkeys-lru | expulsa a chave menos usada recentemente | cache genérico — padrão |
allkeys-lfu | expulsa a menos frequentemente usada | cache com "quentes" muito estáveis; resiste a varreduras |
volatile-lru / volatile-ttl | só expulsa chaves com TTL (as com TTL, primeiro as de menor TTL) | instância mista cache + dados persistentes |
noeviction num cache = incidenteSe a política é noeviction e a memória enche, todo SET passa a falhar — e a aplicação, se não trata isso, quebra em cascata. Para um cache, use allkeys-lru/lfu e monitore evicted_keys: eviction constante e alta significa working set > memória (aumente a RAM ou reduza o que se cacheia).
Cobrado com frequência: "diferença entre allkeys-lru e volatile-lru", "o que acontece quando o Redis enche" (depende da política — e noeviction quebra escritas), "quando usar Hash em vez de várias strings" (campos isolados, economia de memória), "para que serve um Sorted Set" (leaderboard, rate limit por janela).
✏️ Exercício 2 — Estrutura e política
(a) Você precisa contar visitantes únicos por dia de um site com dezenas de milhões de acessos, com erro de ~1% tolerável. Qual estrutura e quanto de memória? (b) Um ranking dos 100 jogadores com maior pontuação, atualizado a cada partida. Qual estrutura e comando para "top 10"? (c) A instância guarda cache e tokens de sessão (que não podem ser expulsos). Qual maxmemory-policy?
Gabarito: (a) HyperLogLog: PFADD visitas:2026-09-01 <userId> por acesso, PFCOUNT para ler — ~12 KB por dia, independente do volume. (b) Sorted Set: ZADD ranking <score> <jogador>; "top 10" = ZREVRANGE ranking 0 9 WITHSCORES. (c) volatile-lru (ou volatile-ttl): só expulsa chaves com TTL — os itens de cache têm TTL e podem sair; as sessões, se gravadas sem TTL (ou com TTL longo e política ttl), ficam protegidas. Melhor ainda: separar em duas instâncias/DBs.
Padrões de cache e invalidação
Objetivo: conhecer os cinco padrões de leitura/escrita de cache pelo nome, com seus trade-offs, e as estratégias de invalidação.
3.1 Os padrões
| Padrão | Como funciona | Trade-off |
|---|---|---|
| Cache-aside (lazy loading) | a app lê o cache; no miss, busca no banco e popula o cache | o mais comum; só cacheia o que é pedido; primeiro acesso é lento; risco de stampede |
| Read-through | a app lê sempre da camada de cache, que busca no banco no miss (lógica no cache/lib) | código da app mais limpo; acopla ao provedor de cache |
| Write-through | toda escrita passa pelo cache, que grava no banco de forma síncrona | cache sempre fresco; escrita mais lenta; cacheia dados que talvez nunca sejam lidos |
| Write-behind (write-back) | escreve no cache e confirma; o banco é atualizado em lote, assíncrono | escrita rapidíssima; risco de perda se o cache cai antes de persistir; complexidade alta |
| Refresh-ahead | o cache recarrega proativamente itens quentes antes de expirarem | elimina o miss para os quentes; desperdiça recurso se a previsão erra |
3.2 Cache-aside, o passo a passo (e onde ele erra)
// leitura v = redis.get(k) if v is None: v = db.query(...) // MISS redis.set(k, serialize(v), ex=300) return v // escrita — INVALIDA, não atualiza o cache db.update(...) redis.delete(k) // próximo leitor repopula com o valor fresco do banco
- Invalidar (delete) > atualizar (set) na escrita: escrever o novo valor no cache na hora da escrita cria corrida (duas escritas concorrentes deixam o cache com o valor da mais lenta). Deletar e deixar o próximo leitor repopular do banco é mais seguro.
- TTL sempre, mesmo com invalidação explícita — é a rede de segurança contra invalidações perdidas (deploy, bug, mensagem não entregue).
3.3 Estratégias de invalidação
| Estratégia | Como | Bom para |
|---|---|---|
| TTL puro | deixa expirar; aceita janela de staleness | dado que tolera atraso fixo (catálogo, contadores) |
| Write-invalidate | a escrita deleta a(s) chave(s) afetada(s) | dado que precisa refletir mudança rápido |
| Versão / geração de chave | a chave inclui um número que muda quando o conjunto muda: user:42:v7:profile | invalidar um grupo inteiro de chaves de uma vez (bump da versão) |
| Tags / índice reverso | manter um Set tag:produto:99 → {chaves} e apagar todas ao mudar o produto | quando uma mudança afeta muitas chaves derivadas |
| CDC / eventos | mudanças no banco viram eventos que invalidam o cache | desacoplar a invalidação do código de escrita; múltiplos consumidores |
Invalidar "todas as chaves de listagem do usuário 42" com KEYS user:42:list:* é proibido em produção (KEYS bloqueia o Redis). Em vez disso, guarde user:42:gen = 7 e componha as chaves como user:42:g7:list:.... Para invalidar tudo, INCR user:42:gen — as chaves antigas ficam órfãs e são expulsas por LRU/TTL. Zero varredura.
"Explique cache-aside e por que, na escrita, você deleta a chave em vez de atualizá-la" é pergunta clássica. E "como você invalidaria muitas chaves relacionadas sem usar KEYS?" — resposta: versão/geração de chave, ou tags com Set reverso, ou eventos. Citar o perigo do KEYS mostra experiência.
✏️ Exercício 3 — Escolha o padrão
(a) Perfil de usuário, lido muito, escrito pouco, precisa refletir edições em segundos. (b) Contador de visualizações de vídeo — milhares de incrementos/s, leitura tolera minutos de atraso. (c) Preferências de UI — precisa estar sempre certo logo após salvar, escrita e leitura na mesma sessão. Diga o padrão de leitura, o de escrita e a invalidação.
Gabarito: (a) Cache-aside na leitura; na escrita, db.update + redis.del(user:X:profile); TTL de ~10 min como rede. (b) Write-behind de fato: INCR no Redis a cada view (rápido, absorve o pico), um job a cada minuto faz GETDEL por vídeo e soma no banco; leitura pode servir do Redis (fresco) ou do banco (atrasado). (c) Write-through ou write-invalidate com read-your-writes: ao salvar, atualiza o banco e seta o cache com o novo valor (aqui atualizar é aceitável porque a escrita é do próprio usuário, sem concorrência típica), ou deleta e força a próxima leitura a ir ao banco; TTL curto.
Os problemas clássicos
Objetivo: reconhecer e mitigar os cinco modos de falha que todo sistema com cache encontra em escala — e que caem em toda entrevista de system design.
4.1 Cache stampede / dogpile / thundering herd
Sintoma: uma chave quente expira; centenas de requisições dão miss ao mesmo tempo e todas vão ao banco recomputar o mesmo valor, saturando-o.
- Lock de recomputação (single-flight / mutex): o primeiro a dar miss pega um lock (
SET k:lock 1 NX PX 5000); os outros esperam um pouco e releem o cache. Só um recomputa. - Early recomputation probabilístico (XFetch): recompute antes de expirar, com probabilidade que cresce à medida que o TTL se aproxima do fim — dilui a recomputação no tempo.
- Stale-while-revalidate: serve o valor vencido por alguns segundos enquanto um worker recomputa em background. Ninguém espera.
4.2 Cache avalanche
Sintoma: muitas chaves expiram ao mesmo tempo (populadas juntas num deploy, com TTL idêntico) — ou o Redis inteiro cai — e a carga desaba no banco de uma vez.
- Jitter no TTL:
ex = base + random(0, base*0.2)— nunca TTL fixo para um lote. - Warm-up pós-deploy: pré-carregar as chaves quentes antes de mandar tráfego.
- Degradação graciosa: se o Redis cai, a app aplica rate limit / circuit breaker no banco e serve resposta degradada — não repassa 100% da carga.
4.3 Cache penetration
Sintoma: requisições por chaves que não existem no banco (ids inválidos, ataque) sempre dão miss e sempre batem no banco — o cache não protege.
- Negative caching: cacheie o "não existe" com TTL curto (
SET k "__nil__" EX 30). - Bloom filter na frente: um filtro probabilístico responde "definitivamente não existe" sem tocar o banco (falsos positivos raros passam adiante; falsos negativos, nunca).
- Validar o formato da chave/id antes de consultar.
4.4 Hot key
Sintoma: uma única chave (o post viral, o produto da Black Friday) recebe tantas leituras que satura o nó do Redis que a hospeda (num Cluster, um slot num nó).
- L1 in-process na frente do Redis para as chaves quentíssimas (TTL de 1–5 s): a maioria das leituras nem chega ao Redis.
- Replicar a chave em N cópias (
hotkey:0..9) e o cliente sortear qual ler — espalha a carga por mais slots/nós. - Detecção:
redis-cli --hotkeys, ou métricas por slot/nó.
4.5 Big key
Sintoma: uma chave gigante (um Hash com milhões de campos, uma List de centenas de MB) — operações sobre ela bloqueiam o Redis (single-thread), a replicação engasga, e a expulsão dela causa um stall.
- Quebrar em sub-chaves (
hash:{user}:{shard}); nunca deixar uma coleção crescer sem limite (LTRIM, TTL, particionamento). - Nunca
HGETALL/SMEMBERSnuma coleção enorme — useHSCAN/SSCANcom cursor. - Detecção:
redis-cli --bigkeys,MEMORY USAGE k.
stampede = uma chave quente expira → lock / SWR / early recompute. avalanche = muitas expiram juntas / Redis cai → jitter no TTL / warm-up / degradação. penetration = chave inexistente → negative cache / bloom. hot key = leitura concentrada → L1 / réplicas de chave. big key = chave enorme → quebrar / SCAN.
Estes cinco são o conteúdo de cache em system design de nível pleno/sênior. Saber diferenciar stampede de avalanche (uma chave vs muitas), e ter uma mitigação nomeada para cada, é o que se espera. "Eu poria jitter no TTL e um lock de single-flight" numa pergunta sobre pico pós-deploy é resposta direta.
✏️ Exercício 4 — Diagnostique
Toda segunda às 9h, logo após o deploy de sexta, o p99 do serviço de catálogo dispara, o banco satura e o hit ratio do Redis cai de 96% para 55% por ~3 minutos. Além disso, um produto específico em promoção deixa um nó do Redis Cluster a 100% de CPU. Nomeie os dois problemas e proponha as correções.
Gabarito: (1) Avalanche: o deploy de sexta repopulou o cache com TTL uniforme (ex.: 72 h), que vence em bloco na segunda de manhã; a recomputação simultânea satura o banco. Correções: jitter no TTL (base 72 h ± 20%), warm-up das chaves quentes no pipeline de deploy, e um lock de single-flight para o caso de várias instâncias recomputarem a mesma chave. (2) Hot key: o produto em promoção concentra leitura num slot/nó. Correções: L1 in-process (TTL 2–5 s) para esse produto (e para o "top N" em geral), e/ou replicar a chave em produto:99:r0..r9 com o cliente sorteando; monitorar com --hotkeys.
Consistência cache + banco
Objetivo: enxergar as corridas entre "atualizar o banco" e "mexer no cache", e escolher a estratégia que dá a consistência que o dado exige.
5.1 Por que "atualizar os dois" não é atômico
Cache e banco são sistemas separados; não há transação entre eles. Qualquer sequência de "escreve no banco" + "mexe no cache" tem uma janela onde um crash, um timeout ou uma reordenação deixa os dois divergentes. O objetivo não é eliminar a divergência (impossível), é torná-la breve e auto-corrigível.
5.2 A corrida clássica da invalidação
// Thread A (leitura, deu miss) Thread B (escrita) A: lê do banco → v_antigo B: escreve no banco → v_novo B: DEL cache[k] A: SET cache[k] = v_antigo // ← cache agora tem valor velho, sem TTL de socorro próximo
- Mitigação 1 — TTL curto: a janela de inconsistência fica limitada ao TTL. Simples e suficiente para a maioria dos dados.
- Mitigação 2 — delayed double delete: a escrita deleta a chave, atualiza o banco, e agenda um segundo
DELalguns segundos depois (pega oSETatrasado do leitor). - Mitigação 3 — versão monotônica: o valor no cache carrega a versão do banco; um
SETsó sobrescreve se a versão for maior (via Lua/CAS). OSETatrasado do leitor tem versão menor e é rejeitado.
5.3 Invalidate vs update na escrita
| Abordagem | Prós | Contras |
|---|---|---|
| Write-invalidate (DEL na escrita) | simples; o próximo leitor traz o valor canônico do banco; menos corridas | o primeiro leitor após a escrita paga um miss |
| Write-update (SET com o novo valor na escrita) | sem miss depois da escrita | duas escritas concorrentes podem inverter a ordem; precisa de versão/CAS para ser seguro |
Padrão de mercado: write-invalidate por default; write-update só quando o miss pós-escrita é caro e você adiciona controle de versão.
5.4 Invalidação por CDC avançado
Em vez de o código de escrita lembrar de invalidar todas as chaves derivadas, um consumidor de Change Data Capture (Debezium lendo o WAL/oplog) recebe cada mudança e invalida as chaves afetadas. Vantagens: desacopla (quem escreve não precisa conhecer o cache), cobre todas as fontes de escrita (incluindo migrações e jobs), e é uma trilha auditável. Custo: mais uma peça de infra e consistência eventual (o atraso do pipeline). Liga com o módulo 9 da apostila de NoSQL.
Se um dado não pode estar desatualizado nem por 1 segundo (saldo, estoque no checkout, permissões), a resposta honesta muitas vezes é não cacheá-lo, ou cachear com TTL de poucos segundos e ler a fonte no caminho crítico. "Consistência forte" com cache exige invalidação síncrona e coordenada que anula boa parte do ganho de performance.
Pergunta de sênior: "desenhe a race condition entre um leitor que deu miss e um escritor concorrente, e diga como mitigar". A resposta completa cita: TTL curto como piso, delayed double delete, versão monotônica com CAS, e o reconhecimento de que dado crítico talvez não deva ser cacheado.
✏️ Exercício 5 — Escolha a estratégia de consistência
(a) Nome e foto de exibição do usuário, mostrados em todo lugar. (b) Saldo de créditos usado para autorizar uma compra. (c) Configuração de feature flags lida em todo request. Para cada um: cachear? qual TTL? invalidate ou update? algum mecanismo extra?
Gabarito: (a) Cachear, TTL 5–15 min, write-invalidate na edição; tolera minutos de atraso, corrida é inofensiva. Se quiser instantâneo, publicar um evento de invalidação. (b) Não cachear o saldo autoritativo para autorização — ler a fonte (ou um contador transacional) no caminho do checkout. Pode-se cachear um saldo "para exibição" com TTL de segundos e rótulo "aproximado". (c) Cachear em L1 in-process com TTL de 30–60 s (dado minúsculo, lido em todo request) + L2 Redis; invalidação por pub/sub quando uma flag muda, para propagar em segundos a todas as instâncias; nunca update cego, e sim recarregar o conjunto do provedor de flags.
Redis além do cache
Objetivo: usar o Redis para os problemas de infraestrutura que ele resolve melhor que qualquer alternativa — rate limiting, locks, filas, pub/sub — com atenção aos perigos de cada um.
6.1 Rate limiting
// Token bucket atômico em Lua (evita corrida entre GET e SET) // KEYS[1]=bucket ARGV: taxa, capacidade, agora, custo local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts') local tokens = tonumber(b[1]) or tonumber(ARGV[2]) local ts = tonumber(b[2]) or tonumber(ARGV[3]) tokens = math.min(tonumber(ARGV[2]), tokens + (ARGV[3]-ts)*ARGV[1]) if tokens < tonumber(ARGV[4]) then return 0 end redis.call('HMSET', KEYS[1], 'tokens', tokens-ARGV[4], 'ts', ARGV[3]) redis.call('EXPIRE', KEYS[1], 3600) return 1
- Fixed window (
INCR+EXPIRE): simples, mas permite 2× o limite na virada da janela. - Sliding window log (ZSet com score = timestamp,
ZREMRANGEBYSCORE+ZCARD): preciso, custa memória por evento. - Token bucket / leaky bucket: permite rajada controlada; o padrão para APIs. Faça atômico com Lua.
- Bibliotecas maduras existem (
redis-cellcomo módulo, ou libs de linguagem) — não reinvente sem motivo.
6.2 Locks distribuídos com ressalvas
SET lock:recurso <token-aleatório> NX PX 30000 // adquire: só se não existe, com expiração // libera com Lua: só apaga se o token for meu (senão apago o lock de outro) if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
- Sempre com token único (para liberar só o seu) e TTL (para não travar para sempre se o dono morrer).
- Redlock (lock sobre N nós Redis independentes) existe para mais garantia, mas é controverso: Martin Kleppmann mostrou que ele não é seguro sob pausas de GC / relógios divergentes; Antirez respondeu. Consenso prático: para correção (nunca dois donos), use um sistema de consenso de verdade (ZooKeeper, etcd) ou fencing tokens; para eficiência (evitar trabalho duplicado, e tudo bem se raramente falhar), o lock simples do Redis basta.
- Fencing token: o lock devolve um número crescente; o recurso protegido rejeita operações com token menor que o último visto. É o que torna um lock realmente seguro.
6.3 Filas: Lists vs Streams
List (LPUSH/BRPOP) | Stream (XADD/XREADGROUP) | |
|---|---|---|
| Entrega | uma vez, sem confirmação (some ao ler) | consumer groups com XACK; mensagem não confirmada volta (PEL) |
| Histórico / replay | não | sim (log append-only, XRANGE) |
| Vários consumidores | competição simples | grupos com partição por consumidor + fan-out |
| Uso | fila de trabalho simples, best-effort | fila durável, event sourcing leve, pipelines |
Redis não substitui Kafka/RabbitMQ para garantias fortes e volume altíssimo, mas Streams cobrem muito caso de fila sem trazer outra peça de infra.
6.4 Pub/Sub e Keyspace Notifications
- Pub/Sub clássico é fire-and-forget: quem não está inscrito no momento perde a mensagem. Bom para invalidação de cache L1 entre instâncias, sinais efêmeros. Para durável, use Streams.
- Keyspace notifications: o Redis pode publicar eventos quando chaves mudam/expiram (
notify-keyspace-events) — útil para reagir a expiração (ex.: "sessão expirou → limpar recurso").
6.5 Outros usos consagrados
- Sessões distribuídas (Hash ou string serializada, TTL = duração da sessão).
- Leaderboards e rankings em tempo real (ZSet).
- Deduplicação / "processei este evento?" (Set ou string com TTL; ou HyperLogLog para contagem).
- Feature store online / cache de features de ML no caminho de inferência (Hash por entidade).
"Para que você usa Redis além de cache?" espera uma lista com um caso que você implementou: rate limiting (e qual algoritmo), lock distribuído (e por que token único + TTL, e a ressalva do Redlock), fila com Streams vs List, pub/sub para invalidação, leaderboards. Mencionar fencing tokens ao falar de lock é um diferencial forte.
✏️ Exercício 6 — Lock seguro?
Um job de faturamento roda em cron em 3 instâncias; só uma deve executar por ciclo. O time usa SET lock:faturamento 1 NX EX 600 e, no fim, DEL lock:faturamento. Aponte os dois bugs e diga quando esse esquema ainda assim é aceitável.
Gabarito: Bug 1: valor fixo 1 em vez de token único — se o job da instância A demora mais que 600 s, o lock expira, a instância B adquire, e quando A termina ela dá DEL no lock de B, deixando C entrar: dois donos. Correção: valor = token aleatório por execução, e liberar com Lua que só apaga se o token bate. Bug 2: TTL fixo de 600 s sem renovação — se o job legitimamente passa de 600 s, perde o lock no meio. Correção: heartbeat que estende o TTL enquanto o job vive, ou dimensionar o TTL com folga e tornar o job idempotente. Aceitável assim mesmo quando o job é idempotente e rodar duas vezes só desperdiça recurso (não corrompe dado) — é um lock "de eficiência", não "de correção". Para correção, fencing token + verificação no recurso, ou etcd/ZooKeeper.
Escala e operação do Redis
Objetivo: entender replicação, Sentinel, Cluster, persistência e o modelo de execução do Redis o suficiente para operá-lo — e para responder por que ele é rápido.
7.1 Por que o Redis é rápido (e onde isso morde)
- Tudo em memória + estruturas de dados otimizadas + protocolo simples (RESP).
- Single-thread para a execução de comandos: sem locks, sem troca de contexto, previsível. Consequência: um comando lento bloqueia todos (
KEYS,SMEMBERSde coleção enorme, Lua pesada,FLUSHALLsíncrono). Desde a 6.0 há I/O threads só para ler/escrever sockets — o processamento continua serial. - Regra de ouro: comandos O(1) ou O(log n); nada de O(n) sobre coleções grandes no caminho quente.
7.2 Disponibilidade: réplica + Sentinel
- Replicação assíncrona: um primário, N réplicas (
replicaof). Leituras podem ir a réplicas (aceitando lag). A replicação assíncrona significa que um failover pode perder as últimas escritas não replicadas. - Redis Sentinel: processos que monitoram o primário, elegem um novo entre as réplicas em caso de falha e reconfiguram os clientes. É a alta disponibilidade para topologia não-clusterizada.
WAIT numreplicas timeoutforça uma escrita a esperar confirmação de N réplicas — durabilidade sob demanda, com custo de latência.
7.3 Escala horizontal: Redis Cluster
- O keyspace é dividido em 16384 hash slots;
slot = CRC16(key) mod 16384. Cada nó primário é dono de uma faixa de slots (e tem réplicas). - O cliente é "cluster-aware": sabe qual nó tem qual slot e segue redirecionamentos (
MOVED/ASK) durante rebalanceamento. - Operações multi-chave (
MGET, transações, Lua com várias chaves) só funcionam se todas as chaves caem no mesmo slot. Use hash tags:{user:42}:profilee{user:42}:carttêm o mesmo slot porque só o que está entre{}conta no hash. - Rebalancear (adicionar/remover nó) move slots e as chaves deles — planeje janelas.
7.4 Persistência: RDB e AOF
| RDB (snapshot) | AOF (append-only file) | |
|---|---|---|
| O quê | dump binário periódico do dataset | log de cada comando de escrita |
| Perda em crash | tudo desde o último snapshot (minutos) | everysec: ~1 s; always: ~0 (lento) |
| Restart | rápido | mais lento (reexecuta o log); mitigado por rewrite/compactação |
| Uso | backup, réplica inicial | durabilidade melhor; combine os dois |
Para cache puro, muitos rodam sem persistência (o dado é reconstruível do banco) — reduz latência e risco de fork pesado. Para Redis como store/fila, AOF everysec + RDB.
7.5 Memória e latência: o que monitorar
INFO memory:used_memoryvsmaxmemory;mem_fragmentation_ratio(> 1,5 = fragmentação; jemalloc +activedefragajudam);evicted_keys.INFO stats:keyspace_hits/misses(hit ratio),instantaneous_ops_per_sec,expired_keys,rejected_connections.- Slowlog (
SLOWLOG GET) e Latency Monitor (LATENCY DOCTOR): achar comandos lentos e picos (fork do RDB, expiração em massa, swap). - Nunca deixar o Redis usar swap — mata a latência.
maxmemorycom folga da RAM física. - Clientes:
CLIENT LIST, buffers de saída (um cliente lento comMONITORou pub/sub pode inchar a memória).
Vaga de backend sênior / SRE cobra: por que o Redis é single-thread e o que isso implica (comando lento bloqueia tudo), diferença Sentinel vs Cluster, os 16384 slots e hash tags, RDB vs AOF, e as métricas de operação (fragmentação, evicted_keys, hit ratio, slowlog). "Rodo o cache sem persistência porque o dado é reconstruível" é uma resposta que mostra que você pensou no trade-off.
✏️ Exercício 7 — Cluster e multi-chave
(a) Sua app faz MGET user:1:name user:1:tier user:1:plan e, ao migrar para Redis Cluster, passa a receber erro de "CROSSSLOT". Por quê e como resolver sem mudar a lógica? (b) O mem_fragmentation_ratio está em 1,8 e a latência p99 subiu. O que investigar? (c) É um cache puro; vale ligar AOF always?
Gabarito: (a) As três chaves caem em slots diferentes, e operações multi-chave no Cluster exigem mesmo slot. Solução: hash tag — {user:1}:name, {user:1}:tier, {user:1}:plan forçam o mesmo slot (só o conteúdo de {} entra no CRC16), e o MGET volta a funcionar. (b) Fragmentação de 1,8 significa que o processo reserva muito mais RAM que os dados usam — investigar: padrão de escrita com muitos tamanhos diferentes de valor, big keys que foram removidas, falta de activedefrag; verificar se há swap (fatal), rodar MEMORY DOCTOR, considerar activedefrag yes e reiniciar réplicas em rotação para compactar. (c) Não — always faz fsync a cada escrita, derruba o throughput, e para um cache o dado é reconstruível do banco. Rodar sem persistência (ou só RDB esparso para acelerar warm-up) é o certo.
Cache em camadas e na borda
Objetivo: combinar L1 in-process + L2 Redis com coerência entre instâncias, e usar o cache HTTP/CDN corretamente (Cache-Control, ETag, stale-while-revalidate).
8.1 L1 (in-process) + L2 (Redis)
- L1: um cache de memória dentro da instância (Caffeine em Java,
lru-cacheem Node,functools.lru_cache/cachetools em Python). Latência de nanossegundos, zero rede. Escopo: uma instância. TTL curtíssimo (1–10 s). - L2: Redis, compartilhado por todas as instâncias. Latência de sub-milissegundo, com rede.
- Leitura: L1 → (miss) L2 → (miss) banco, populando de volta as camadas. A maioria esmagadora das leituras para as chaves quentes morre no L1 — é a defesa contra hot key (módulo 4).
8.2 O problema da coerência entre L1s
Cada instância tem seu próprio L1. Quando um dado muda, invalidar o L2 (Redis) não invalida os N L1s. Estratégias:
- TTL curtíssimo no L1 (1–5 s): aceita-se a inconsistência por segundos. Simples e, na prática, suficiente para a maioria dos casos.
- Pub/Sub de invalidação: ao mudar um dado, publica
invalidate user:42num canal Redis; toda instância assina e limpa a chave do seu L1. Propagação em milissegundos. - Client-side caching do Redis (RESP3 tracking) 6.0+: o próprio Redis notifica os clientes quando uma chave que eles leram muda — L1 coerente "de fábrica", com modo broadcasting ou por opt-in de chave.
8.3 Cache HTTP e na borda
| Cabeçalho | Efeito |
|---|---|
Cache-Control: public, max-age=60 | qualquer cache (browser, CDN) pode guardar por 60 s |
Cache-Control: private, no-cache | só o browser; revalida sempre antes de usar |
s-maxage=300 | TTL só para caches compartilhados (CDN), separado do browser |
stale-while-revalidate=30 | serve o conteúdo vencido por até 30 s enquanto revalida em background — sem espera |
stale-if-error=600 | serve o vencido se a origem está fora — resiliência |
ETag / If-None-Match | revalidação barata: origem responde 304 Not Modified sem corpo |
Vary: Accept-Encoding | cacheia variantes por cabeçalho — cuidado, Vary demais destrói o hit ratio |
- Purga da CDN por evento (mudança de preço → purge da URL do produto) +
stale-while-revalidatecobre a janela. - Edge compute (Cloudflare Workers, Vercel Edge, Fastly Compute) pode montar respostas personalizadas na borda com um KV de baixa latência — cache "quase na origem do usuário".
- ESI / fragmentos: cachear partes da página com TTLs diferentes (o cabeçalho por 1 dia, o bloco de recomendação por 5 min).
Full-stack e frontend sênior: saber a diferença max-age vs s-maxage, o valor de stale-while-revalidate, como ETag economiza banda, e o perigo do Vary. Backend distribuído: L1+L2 com coerência por pub/sub ou RESP3 tracking, e por que o L1 é a defesa contra hot key.
✏️ Exercício 8 — Camadas para uma home
A home de um portal de notícias tem: cabeçalho/menu (muda 1×/semana), a manchete principal (pode mudar a qualquer momento, precisa propagar em ~1 min), um bloco "mais lidas" (recalculado a cada 5 min), e uma faixa de anúncios personalizada por usuário. Descreva a estratégia de cache camada por camada.
Gabarito: Cabeçalho/menu: fragmento com Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600 na CDN; purge no deploy de conteúdo. Manchete: fragmento com s-maxage=60, stale-while-revalidate=30 + purge por evento quando a editoria troca a manchete (propaga em segundos, o SWR cobre a janela). "Mais lidas": recalculado por job a cada 5 min, gravado no Redis; o fragmento tem s-maxage=300 com jitter. Faixa de anúncios: Cache-Control: private, no-store (por usuário) — montada na origem ou na edge com um KV; nunca em cache compartilhado. A página é composta desses fragmentos (ESI/edge) para que cada um tenha seu TTL. L1 in-process nas APIs internas que servem "mais lidas" e manchete, TTL 2–5 s, com pub/sub de invalidação para a manchete.
Casos especiais e alternativas
Objetivo: cache de LLM (exato e semântico), Redis como banco primário (quando e riscos), e o panorama de alternativas — Memcached, Dragonfly, Valkey, KeyDB — com o ângulo de custo.
9.1 Cache de LLM tema em alta
| Tipo | Como | Cuidado |
|---|---|---|
| Exato | chave = hash do prompt normalizado (+ modelo + parâmetros); valor = resposta | só acerta prompts idênticos; normalize (trim, lowercase onde couber); inclua a versão do sistema/prompt na chave |
| Semântico | embedding do prompt; no miss exato, busca vetorial por similaridade > limiar e reusa a resposta | limiar mal calibrado devolve resposta errada com confiança; use limiar alto e valide; não use para prompts com contexto único (dados do usuário) |
| Prefixo / KV de contexto | reaproveitar o processamento de um prefixo grande e estável (system prompt, documentos) — via prompt caching do provedor | é do lado do provedor; estruture o prompt com o estável primeiro |
Redis serve os três: string para o exato, Redis com busca vetorial (RediSearch/Redis Query Engine) para o semântico. Ganho típico: latência e custo de tokens caem muito em fluxos com repetição (FAQ, classificação, prompts de template). Ver a apostila de RAG e a de LLMOps.
9.2 Redis como banco primário — quando e riscos
- Quando dá certo: dados que são naturalmente chave-valor / estruturas do Redis, cabem na memória (com folga e crescimento previsto), e cuja perda de segundos em um failover é aceitável ou mitigada. Ex.: estado de jogo em tempo real, sessões, filas, feature store.
- Configuração:
maxmemory-policy noeviction(não pode expulsar dado autoritativo!), AOFeverysec+ RDB, réplicas + Sentinel/Cluster, backups testados. - Riscos: replicação assíncrona = janela de perda no failover; custo de RAM vs disco; um
FLUSHALLou bug apaga tudo em memória. Para dado que não pode se perder, ou use Redis Enterprise/serviços gerenciados com garantias mais fortes, ou mantenha uma fonte de verdade durável e trate o Redis como camada acelerada.
9.3 O panorama de alternativas
| Opção | Perfil |
|---|---|
| Memcached | cache puro, multi-thread, sem estruturas ricas, sem persistência; simples e previsível para "string → string" em escala. Sem replicação nativa. |
| Valkey | fork do Redis mantido pela Linux Foundation após a mudança de licença do Redis (2024); compatível, multi-thread em evolução; o que muitas nuvens adotaram como padrão. |
| Redis (Redis Inc.) | o original; licença dupla (RSALv2/SSPL) desde 2024, com módulos (Query Engine, JSON, TimeSeries, Bloom) e Redis Enterprise (Active-Active/CRDT, tiered storage). |
| Dragonfly | reimplementação compatível com o protocolo, multi-thread, foco em throughput vertical e menos nós; boa quando um só nó Redis não aguenta e você quer evitar Cluster. |
| KeyDB | fork multi-thread do Redis (mais antigo); nicho. |
9.4 O ângulo de custo
- Cache é RAM, e RAM é cara:
custo do Redisvseconomia no banco/nas APIs pagas. Meça as duas pontas. - TTL curto demais = hit ratio baixo = você paga o Redis e a carga no backend. TTL longo demais = memória inflada com lixo frio.
- Comprima valores grandes (gzip/zstd) se a CPU compensar a RAM; use Hash em vez de N strings;
OBJECT ENCODINGpara ver se a estrutura está na codificação compacta. - Serverless: cada instância abre pool — em picos, isso multiplica conexões; use um proxy/pooler ou os clientes HTTP (Upstash e afins) desenhados para isso.
- Tiered storage (RAM + SSD) em ofertas gerenciadas pode baratear datasets grandes com cauda fria.
Tópicos que aparecem em 2026: cache de LLM (exato vs semântico e o risco do limiar), a existência do Valkey e por que ele surgiu (mudança de licença do Redis), quando Redis pode ser primary store (e a config noeviction + AOF + o risco do failover assíncrono), e a análise de custo dos dois lados. Não precisa decorar — precisa saber que existem e o trade-off de cada um.
✏️ Exercício 9 — Cache semântico com juízo
Um time quer cache semântico para um assistente de suporte: no miss exato, se houver uma pergunta anterior com similaridade > 0,80, reusa a resposta. Aponte os riscos e proponha salvaguardas. Onde esse cache não deve ser aplicado?
Gabarito: Riscos: 0,80 é baixo — "como cancelo minha assinatura?" e "como faço upgrade da assinatura?" podem passar de 0,80 e devolver a resposta errada com confiança; respostas dependem de contexto que mudou (política atual, status da conta); a resposta cacheada pode ter ficado desatualizada. Salvaguardas: limiar bem mais alto (0,92–0,95) calibrado contra um conjunto rotulado; TTL no cache semântico (respostas de suporte envelhecem); um passo de verificação leve ("esta resposta responde a esta pergunta?") antes de servir; nunca cachear respostas que incluíram dados específicos da conta/usuário no contexto; registrar hits semânticos para auditoria e medir a taxa de reclamação nesses hits. Não aplicar: qualquer pergunta cuja resposta dependa do estado atual da conta, de valores/preços, de disponibilidade, ou de qualquer coisa personalizada — só para conhecimento genérico e estável.
Mercado de trabalho: roadmap, portfólio e entrevistas
Objetivo: caching é assunto garantido em entrevista de backend e system design — o que estudar, o que construir, e as perguntas com a resposta que aprova.
10.1 Onde cai
| Papel | O que cobram |
|---|---|
| Backend pleno | cache-aside, invalidar vs atualizar, TTL + jitter, estruturas do Redis, rate limiting com INCR/ZSet |
| Backend sênior | os 5 problemas (stampede/avalanche/penetration/hot key/big key), consistência cache+DB e as corridas, lock distribuído e a ressalva do Redlock, L1+L2 |
| System Design | onde colocar cache na hierarquia, dimensionar (hit ratio, working set, memória), degradação quando o cache cai |
| SRE / Infra | Sentinel vs Cluster, slots + hash tags, RDB/AOF, métricas (fragmentação, evicted_keys, slowlog), capacity |
| IA Engineering | cache de LLM exato e semântico, feature store online, cache de embeddings |
10.2 Roadmap (4 semanas)
- Sem. 1 — módulos 1–3: adicionar cache-aside com Redis a uma API real, com TTL + jitter e invalidação por delete; medir hit ratio e latência antes/depois.
- Sem. 2 — módulo 4: provocar e mitigar cada um dos 5 problemas num teste de carga (k6): stampede (lock/SWR), avalanche (jitter), penetration (negative cache/bloom), hot key (L1), big key (SCAN).
- Sem. 3 — módulo 6: implementar rate limiter (token bucket em Lua), lock distribuído com token + TTL, e uma fila com Streams + consumer group.
- Sem. 4 — módulos 7–8: subir Redis Cluster (3+3) local, lidar com CROSSSLOT/hash tags, e montar L1+L2 com invalidação por pub/sub; brincar com
Cache-Control/ETag/SWR numa CDN.
10.3 Banco de perguntas (com a resposta que aprova)
Pleno — "Explique cache-aside e por que deletar em vez de atualizar na escrita."
Cache-aside: a app lê o cache; no miss, busca no banco e popula. Na escrita, atualiza o banco e deleta a chave. Deletar evita a corrida em que duas escritas concorrentes deixam no cache o valor da que terminou por último (que pode ser o antigo); o próximo leitor repopula com o valor canônico do banco. Sempre com TTL como rede de segurança contra invalidações perdidas.
Pleno/Sênior — "Diferença entre cache stampede e cache avalanche?"
Stampede: uma chave quente expira e muitas requisições recomputam o mesmo valor ao mesmo tempo, saturando o banco — mitiga com lock de single-flight, stale-while-revalidate, ou recomputação probabilística antes de expirar. Avalanche: muitas chaves expiram juntas (TTL uniforme, populadas no mesmo deploy) ou o Redis inteiro cai, e a carga desaba de uma vez — mitiga com jitter no TTL, warm-up pós-deploy e degradação graciosa (circuit breaker no banco).
Sênior — "Como você garante consistência entre o cache e o banco?"
Não se garante consistência forte com cache barato — se busca minimizar a janela e torná-la auto-corrigível. Write-invalidate por padrão; TTL curto como piso; para a corrida leitor-com-miss × escritor concorrente, delayed double delete ou versão monotônica com CAS (Lua) que rejeita SET de versão menor. Para desacoplar a invalidação, um consumidor de CDC invalida as chaves. Dado que não pode estar stale nem por 1 s (saldo, permissões) não deve ser cacheado, ou só com TTL de poucos segundos lendo a fonte no caminho crítico.
Sênior — "Redis é single-thread. Isso é um problema?"
É uma escolha: sem locks, sem context switch, latência previsível, e simplicidade. O custo é que um comando lento bloqueia todos os outros — daí a regra de nunca rodar KEYS, SMEMBERS/HGETALL em coleção enorme, ou Lua pesada no caminho quente. Desde a 6.0 há I/O threads para o tráfego de rede, mas o processamento continua serial. Para throughput além de um núcleo, escala-se com Cluster (mais nós) ou usa-se um fork multi-thread (Valkey/Dragonfly).
Sênior — "Você usaria o lock distribuído do Redis para garantir exclusão mútua?"
Para eficiência (evitar trabalho duplicado, e tudo bem se raramente dois rodarem), sim: SET key token NX PX ttl + liberação com Lua verificando o token + heartbeat se o job for longo. Para correção (nunca dois donos, sob risco de corromper dado), não confio só nisso — Redlock tem a crítica do Kleppmann (pausas de GC, relógios) e a resposta do Antirez; a forma segura é fencing token (o lock devolve um número crescente e o recurso rejeita operações com token menor) ou um sistema de consenso de verdade (etcd/ZooKeeper).
10.4 Projetos de portfólio
- Laboratório dos 5 problemas: uma API + Redis + k6, com um branch por problema (stampede, avalanche, penetration, hot key, big key) mostrando o sintoma no gráfico de carga e a correção. README com os números antes/depois. Extremamente convincente.
- Toolkit de Redis: rate limiter (token bucket em Lua), lock com fencing token, fila com Streams + DLQ, tudo com testes de concorrência.
- Cache de LLM: exato + semântico (com limiar calibrado e um conjunto de avaliação medindo falso-reuso), sobre um assistente simples, comparando latência e custo de tokens com e sem cache.
10.5 Fontes
- Documentação oficial do Redis (a página de cada comando traz a complexidade — decore os O(n)); o livro Redis in Action; o post de Martin Kleppmann "How to do distributed locking" e a resposta do Antirez.
- Designing Data-Intensive Applications para consistência e sistemas distribuídos.
- MDN sobre
Cache-Control; a RFC 5861 (stale-while-revalidate/stale-if-error). - Apostilas irmãs: NoSQL, Modelagem de Dados NoSQL, Observabilidade & SRE, Arquitetura & System Design.
Três ideias sustentam a apostila: (1) cache troca frescor por velocidade/carga/custo — a decisão é quanto de staleness cada dado tolera, e a resposta define TTL, padrão e invalidação; (2) em escala, todo cache encontra os mesmos cinco problemas (stampede, avalanche, penetration, hot key, big key) e cada um tem uma mitigação nomeada; (3) não existe cache fortemente consistente barato — minimize a janela, ponha TTL como piso, e não cacheie o que não pode estar velho. O Redis é a ferramenta; a disciplina de caching é o que evita o bug.