Pular para o conteúdo principal

Cache-aside com FastAPI e Redis: invalidação e requisições simultâneas

Todo mundo que já colocou uma API em produção conhece o filme: o endpoint de leitura é simples, mas a consulta por trás dele é cara, e a mesma linha é pedida centenas de vezes por minuto. Aí alguém sugere "põe um Redis na frente". Funciona, mas o difícil não é gravar no cache, é decidir quando ler, quando invalidar e o que fazer quando dez requisições chegam juntas e o cache está vazio.

Neste post eu montei um exemplo pequeno com FastAPI e Redis usando o padrão cache-aside, e dei atenção a esses dois pontos chatos: invalidação e requisições simultâneas. Tudo roda com docker compose e os testes não precisam de um Redis de verdade.

A ideia: cache-aside

No cache-aside quem manda é a aplicação, não o cache. O fluxo de leitura é sempre o mesmo: pergunta ao Redis; se tem, devolve; se não tem, busca na fonte de verdade, grava no Redis com um TTL e devolve. Na escrita, a gente muda a fonte e apaga a chave do cache, deixando a próxima leitura repovoar. É menos esperto que write-through, mas é fácil de entender e de depurar.

No exemplo, o "banco" é um repositório com asyncio.sleep simulando latência e um contador de consultas. Esse contador é o que permite provar nos testes que o cache está funcionando, em vez de confiar no olhômetro.

A leitura com cache

O endpoint de leitura é praticamente a descrição do padrão em código. Repare que a resposta informa a origem do dado, o que ajuda muito na hora de testar e de ver o comportamento com o curl.

em_cache = await r.get(chave(produto_id))
if em_cache:
    return {"origem": "cache", "produto": json.loads(em_cache)}

produto = await repo.buscar(produto_id)
if produto is None:
    raise HTTPException(status_code=404, detail="Produto não encontrado")

await r.set(chave(produto_id), json.dumps(produto), ex=TTL_SEGUNDOS)
return {"origem": "banco", "produto": produto}

Um detalhe: produto inexistente retorna 404 e não é cacheado. Dá para cachear a ausência também (cache negativo), mas aí você precisa de um TTL curto e de cuidado com invalidação quando o item passar a existir. Para um exemplo, fica de fora.

O problema das requisições simultâneas

Esse código tem uma falha clássica. Se a chave expira e chegam dez requisições ao mesmo tempo, as dez veem o cache vazio e as dez vão ao banco. Isso é o cache stampede, e costuma acontecer justamente no pior momento, com a carga alta.

Uma solução simples para um processo só é uma trava por chave. A primeira requisição pega o lock e vai ao banco; as outras esperam, e quando entram conferem o cache de novo (o famoso double-check), encontrando o valor que a primeira já gravou.

lock = request.app.state.locks.setdefault(produto_id, asyncio.Lock())
async with lock:
    em_cache = await r.get(chave(produto_id))
    if em_cache:
        return {"origem": "cache", "produto": json.loads(em_cache)}

    produto = await repo.buscar(produto_id)
    ...

Esse lock vive na memória do processo. Com várias instâncias da API, cada uma faria no máximo uma consulta por chave, o que já reduz muito o estrago, mas não elimina. Se precisar de garantia entre instâncias, o caminho é um lock distribuído no próprio Redis (SET chave valor NX EX), com os cuidados de expiração que isso traz.

Invalidação na escrita

Quando o produto muda, apagamos a chave. Só isso. A próxima leitura vai ao banco, pega o dado novo e repovoa o cache. O TTL continua existindo como rede de segurança: se alguma escrita esquecer de invalidar, o dado errado some sozinho em até 30 segundos.

@app.put("/produtos/{produto_id}")
async def atualizar(produto_id: int, dados: dict, request: Request):
    PRODUTOS[produto_id] = {**PRODUTOS[produto_id], **dados, "id": produto_id}
    await request.app.state.redis.delete(chave(produto_id))
    return PRODUTOS[produto_id]

Testando sem Redis

Os testes usam fakeredis, que implementa a API assíncrona do cliente em memória. Como o create_app recebe o cliente e o repositório por parâmetro, cada teste monta o seu próprio app, com o seu próprio contador, e nada depende da ordem de execução. O teste mais interessante dispara dez requisições em paralelo e confere que houve uma única consulta ao banco:

def test_requisicoes_simultaneas_geram_uma_unica_consulta():
    async def cenario():
        app, repo = novo_app(latencia=0.1)
        async with cliente_http(app) as c:
            respostas = await asyncio.gather(*[c.get("/produtos/1") for _ in range(10)])
        return [r.json()["origem"] for r in respostas], repo.consultas

    origens, consultas = rodar(cenario())
    assert consultas == 1
    assert origens.count("banco") == 1
    assert origens.count("cache") == 9

Os outros testes cobrem a primeira leitura vindo do banco e a segunda do cache, o 404, a invalidação depois do PUT e o TTL definido na chave.

Rodando com Docker

O compose sobe a API e um Redis com healthcheck, e a API só inicia depois que o Redis responde ao ping. Os testes ficam em um serviço separado, atrás de um profile, para não subirem junto com a aplicação.

services:
  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]

  app:
    build: { context: ., target: runtime }
    environment:
      REDIS_URL: redis://redis:6379/0
      CACHE_TTL: "30"
    ports: ["8000:8000"]
    depends_on:
      redis:
        condition: service_healthy
docker compose up --build
curl localhost:8000/produtos/1   # origem: banco
curl localhost:8000/produtos/1   # origem: cache

docker compose run --rm tests

Se você repetir o curl depois de 30 segundos, vai ver a origem voltar para banco, porque o TTL expirou. E se fizer um PUT no produto, a próxima leitura também volta ao banco.

Código

O projeto completo, com Dockerfile, compose, testes e um workflow de CI, está em programero-python/2026-11-cache-aside-fastapi-redis. No próximo passo natural, vale trocar o lock em memória por um lock distribuído e medir a diferença com várias instâncias.

Comentários

Mais visitadas

Como pegar valor de coluna invisível do GridView

Em determinado momento nos deparamos com a seguinte situação, nossa tabela do banco de dados possui como campo chave, um ID por exemplo, incrementado por uma rotina da aplicação, do banco ou algum outro mecanismo de incremento (sequences, increment, etc), logo não há porque mostrar essa coluna para o usuário porque o mesmo não sabe de sua existência, e seu valor não tem qualquer relevância para ele, sendo assim o mais lógico a se fazer é não mostra-lá na aplicação. Seu valor é necessário já que por se tratar de uma chave primária precisaremos dela para atualizar, inserir ou excluir os dados, então o que fazer? A resposta seria simples, deixemos ele invisível, buscaremos seu valor e o usuário não perceberá, porém a partir da versão 2005 do visual Studio as colunas setadas com "visible = false" não são renderizadas, com isso ao se tentar acessar seu valor o que recebemos é nada. Para contornarmos essa situação podemos codificar o seguinte: protected void GridView1_R...

Lista de políticos com ficha suja

ATUALIZAÇÃO (08/03/2012 ano de eleição) Representantes de duas pessoas da lista (Eliseu Padilha e Alex Canziani) entraram em contato e pediram que esses fossem removidos, alegando que não houve condenação. É justo essa requisição, porém vale lembrar que escândalos nacionalmente reconhecidos de corrupção não deram em nada, por isso o que realmente conta nesse ano de eleição é uma pesquisa minuciosa sobre os candidatos escolhidos, eu particularmente, por não estar nem um pouco satisfeito, não irei votar em ninguém que já tenha sido eleito. Estou voltando a postar por um motivo nobre, meu tempo continua apertado mas esse post é rápido, na verdade nem meu ele é (visitem o espaço de nosso amigo Lord ), estou apenas repassando essa valiosa informação. Teremos eleição esse ano, e é importante não repetirmos erros passados, vamos ficar atentos em relação a esses nomes e exclui-los de vez do cenário politico nacional. Façamos nossa parte, publicando em nossos blog...

Manipulando Arquivos no Java

Olá a todos hoje falaremos de manipulação de arquivos, uma situação muito utilizada seja para persistência de dados como arquivos de cofiguração, relatórios simples, armazenamento para transferência de dados ou em até alguns casos pequenas bases de dados (para armazenamento de um grande volume de informações dê preferência aos DBMS, mesmo em dispositivos móveis com as versões compacts). A classe File É com ela que iremos trabalhar para manipular arquivos: File arq = new File(“c:\\programero\\arquivo.txt”); if (!arq.exists()){    System.out.println(“Arquivo não existe !”); } Na classe File temos os métodos: getName Retorna o nome do arquivo. renameTo Renomeia o arquivo. exists Verifica se o arquivo existe. canWrite Verifica se é possível escrevet no arquivo. canRead Verifica se um arquivo pode ser lido. isFile Verifica se o caminho definido é um a...