Moka em Rust: Cache em Memória para Produção | Rust Brasil

Aprenda a usar Moka em Rust para cache concorrente: TTL, TTI, W-TinyLFU, get_with contra cache stampede, limites por peso, métricas e integração com Axum.

Use Moka quando uma aplicação Rust precisa reutilizar dados dentro do próprio processo com acesso concorrente, expiração e limite de memória — sem construir um cache manual com HashMap e locks. Para backends com Tokio e Axum, a API moka::future::Cache oferece get_with, que também ajuda a impedir que muitas requisições recarreguem a mesma chave ao mesmo tempo. Moka não substitui Redis quando o cache precisa ser compartilhado entre réplicas; ele resolve a camada local, de latência muito baixa, que vive em cada instância.

Este guia monta um cache de produção, explica TTL, TTI, W-TinyLFU, peso, invalidação e observabilidade, e mostra como decidir entre Moka, Redis ou uma combinação dos dois.

Resposta rápida: quando usar Moka

CenárioEscolha inicialMotivo
Resultado caro reutilizado por requisições na mesma instânciaMokaacesso local, concorrente e sem round trip de rede
Dados precisam ser iguais e compartilhados entre réplicasRedisestado central acessível por vários processos
API recebe muito tráfego e consulta PostgreSQLMoka + Redis ou bancocamada local absorve chaves quentes; origem continua autoritativa
Configuração pequena carregada raramenteOnceLock, LazyLock ou Arccache com política de remoção seria complexidade desnecessária
Cache simples, pequeno e sem concorrênciaHashMapa biblioteca padrão já resolve
Valores com tamanhos muito diferentesMoka com weighercapacidade por peso representa melhor o consumo
Cálculo assíncrono sujeito a rajadas na mesma chaveMoka com get_withreduz inicializações concorrentes duplicadas

A regra de bolso é: Moka acelera uma instância; Redis conecta várias instâncias. Se perder todas as entradas ao reiniciar o processo não é aceitável, o dado não pode existir apenas no Moka.

O que é Moka e por que não usar apenas RwLock HashMap

Um cache real faz mais do que guardar pares chave-valor. Ele precisa decidir:

  • quais entradas admitir quando a capacidade está pressionada;
  • qual entrada remover;
  • quando um valor fica velho;
  • como impedir trabalho duplicado para a mesma chave;
  • como atender muitas threads sem um lock global virar gargalo;
  • como invalidar uma chave, um grupo ou todo o conjunto;
  • como limitar custo quando um valor pesa 100 bytes e outro pesa 10 MB.

É possível começar com Arc<RwLock<HashMap<K, V>>>, mas logo surgem timers de expiração, filas de recência, contenção, limpeza e corridas entre get e insert. Moka entrega essas políticas em uma estrutura thread-safe com handles clonáveis.

A política de admissão e remoção é inspirada em W-TinyLFU. Em termos práticos, o cache considera recência e frequência para evitar que uma varredura de chaves usadas uma única vez expulse todo o conjunto quente. Isso costuma ser mais robusto que um LRU ingênuo para tráfego web, no qual endpoints podem receber tanto itens populares quanto long tails.

Moka oferece duas famílias principais:

  • moka::sync::Cache para código síncrono e aplicações multithread;
  • moka::future::Cache para aplicações assíncronas, como serviços Tokio.

Este artigo usa a API future, mais natural para backends modernos.

Instalação e primeiro cache

Adicione a feature assíncrona no Cargo.toml:

[dependencies]
moka = { version = "0.12", features = ["future"] }
tokio = { version = "1", features = ["full"] }

Crie um cache limitado por quantidade e com expiração:

use std::time::Duration;
use moka::future::Cache;

#[derive(Clone, Debug)]
struct Perfil {
    nome: String,
    plano: String,
}

#[tokio::main]
async fn main() {
    let cache: Cache<u64, Perfil> = Cache::builder()
        .max_capacity(10_000)
        .time_to_live(Duration::from_secs(5 * 60))
        .time_to_idle(Duration::from_secs(60))
        .build();

    cache.insert(
        42,
        Perfil {
            nome: "Ferris".into(),
            plano: "pro".into(),
        },
    ).await;

    if let Some(perfil) = cache.get(&42).await {
        println!("{} usa o plano {}", perfil.nome, perfil.plano);
    }
}

Cache é barato de clonar: cada clone aponta para a mesma estrutura interna. Isso combina com o modelo de State do Axum e com serviços clonados por layers do Tower.

TTL vs TTI: idade máxima e ociosidade

Os dois relógios respondem a problemas diferentes.

TTL (time to live) define quanto tempo uma entrada pode viver depois de inserida ou atualizada. Se o perfil de um usuário pode ficar até cinco minutos desatualizado, TTL de cinco minutos expressa esse orçamento de stale data.

TTI (time to idle) remove uma entrada depois de um período sem acesso. Ele ajuda a liberar capacidade de itens que já foram populares, mas deixaram de receber tráfego.

Ao combinar os dois:

  • uma chave frequentemente acessada continua sujeita ao TTL e será atualizada periodicamente;
  • uma chave abandonada pode sair antes, ao atingir o TTI;
  • nenhum acesso prolonga indefinidamente um dado que precisa ser renovado.

Não escolha durações apenas por performance. O TTL é uma decisão de produto e consistência:

Tipo de dadoTTL inicial razoávelPergunta antes de definir
feature flag operacionalsegundosquanto atraso de rollout é aceitável?
perfil públicominutosedição precisa aparecer imediatamente?
catálogo quase estáticodezenas de minutosexiste invalidação por evento?
permissão/autorizaçãocurto ou sem cacherevogação atrasada cria risco?
resposta de API externa caraconforme contratoo provedor informa validade?

Para permissões, saldo, estoque ou qualquer dado sensível, trate invalidação e consistência como requisito. Cache não corrige um modelo sem fonte de verdade.

Evitando cache stampede com get_with

O padrão ingênuo faz duas operações separadas:

if let Some(valor) = cache.get(&chave).await {
    return Ok(valor);
}

let valor = consultar_banco(&chave).await?;
cache.insert(chave, valor.clone()).await;
Ok(valor)

Sob uma rajada, cem requisições podem observar o miss antes que a primeira termine e disparar cem consultas iguais. Isso é cache stampede — o cache falha exatamente quando a chave fica mais quente.

Use get_with para inicialização por chave:

use std::sync::Arc;
use moka::future::Cache;

async fn buscar_perfil(
    cache: &Cache<u64, Arc<Perfil>>,
    usuario_id: u64,
) -> Arc<Perfil> {
    cache
        .get_with(usuario_id, async move {
            // Substitua pelo acesso real ao SQLx ou serviço remoto.
            Arc::new(carregar_perfil(usuario_id).await)
        })
        .await
}

async fn carregar_perfil(usuario_id: u64) -> Perfil {
    Perfil {
        nome: format!("usuario-{usuario_id}"),
        plano: "free".into(),
    }
}

Chamadas concorrentes para a mesma chave podem compartilhar a inicialização, enquanto chaves diferentes avançam em paralelo. O uso de Arc<Perfil> também evita copiar um valor grande a cada leitura.

get_with não resolve sozinho todos os picos. Em produção, combine-o com:

  1. timeout na consulta à origem;
  2. limite de conexões do banco;
  3. backpressure ou limite de concorrência;
  4. TTL com pequena variação aleatória quando muitas chaves são carregadas juntas;
  5. retry apenas para erros transitórios e com orçamento total;
  6. monitoramento de misses e latência da origem.

Para políticas transversais de timeout e concorrência em APIs, veja o guia de tower-http com Axum.

Tratando erros sem cachear falhas acidentalmente

Nem todo carregamento produz um valor. Banco, rede e parsing podem falhar. Nesses casos, use a variante apropriada de inicialização que aceita resultado falível, como try_get_with, na versão adotada pelo projeto:

use std::sync::Arc;

async fn perfil_com_erro(
    cache: &moka::future::Cache<u64, Arc<Perfil>>,
    usuario_id: u64,
) -> Result<Arc<Perfil>, Arc<ErroRepositorio>> {
    cache
        .try_get_with(usuario_id, async move {
            repositorio_buscar(usuario_id).await.map(Arc::new)
        })
        .await
}

O objetivo é não transformar indisponibilidade temporária em um valor válido por minutos. Decida separadamente se um “não encontrado” deve ser cacheado. Negative caching é útil contra consultas repetidas a IDs inexistentes, mas pede TTL curto para não esconder um registro criado logo depois.

Uma abordagem clara é modelar o valor:

#[derive(Clone)]
enum PerfilCacheado {
    Encontrado(Arc<Perfil>),
    NaoEncontrado,
}

Assim, ausência esperada não é confundida com timeout, falha de conexão ou bug de deserialização.

Limite por quantidade ou por peso

max_capacity(10_000) considera dez mil unidades. Sem weigher, cada entrada normalmente conta como uma unidade, seja ela minúscula ou enorme. Isso funciona para valores de tamanho parecido, mas pode mascarar consumo quando o cache guarda JSON, imagens, documentos ou vetores.

Use um peso estimado:

use std::{sync::Arc, time::Duration};
use moka::future::Cache;

#[derive(Clone)]
struct Documento {
    titulo: String,
    corpo: String,
}

let cache: Cache<String, Arc<Documento>> = Cache::builder()
    .max_capacity(128 * 1024 * 1024) // 128 MiB em peso aproximado
    .weigher(|chave: &String, doc: &Arc<Documento>| {
        let bytes = chave.len()
            + doc.titulo.len()
            + doc.corpo.len()
            + std::mem::size_of::<Documento>();

        u32::try_from(bytes).unwrap_or(u32::MAX)
    })
    .time_to_live(Duration::from_secs(300))
    .build();

O peso não mede heap com precisão absoluta: há overhead de alocadores, Arc, índices e metadados internos. Ele cria um orçamento proporcional, muito melhor do que fingir que todas as entradas custam o mesmo.

Valide o limite com métricas de processo. Se RSS continuar crescendo além do esperado, investigue valores retidos fora do cache, fragmentação, tarefas pendentes e outras estruturas.

Integração com Axum

Coloque o cache no estado da aplicação:

use std::sync::Arc;
use axum::{
    extract::{Path, State},
    routing::get,
    Json, Router,
};
use moka::future::Cache;
use serde::Serialize;

#[derive(Clone)]
struct AppState {
    perfis: Cache<u64, Arc<PerfilResposta>>,
}

#[derive(Clone, Serialize)]
struct PerfilResposta {
    id: u64,
    nome: String,
}

async fn obter_perfil(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Json<PerfilResposta> {
    let perfil = state.perfis
        .get_with(id, async move {
            Arc::new(PerfilResposta {
                id,
                nome: format!("usuario-{id}"),
            })
        })
        .await;

    Json((*perfil).clone())
}

fn app() -> Router {
    let state = AppState {
        perfis: Cache::builder()
            .max_capacity(50_000)
            .build(),
    };

    Router::new()
        .route("/perfis/{id}", get(obter_perfil))
        .with_state(state)
}

Em um projeto real, mantenha a política de cache em uma camada de serviço ou repositório, não espalhada por todos os handlers. O handler deve pedir “obter perfil”; o serviço decide se lê Moka, Redis, SQLx ou a origem.

Essa separação facilita testes e evita que detalhes como TTL virem regra implícita da API.

Invalidação: por escrita, evento ou tempo

Expiração por tempo é a rede de segurança, não precisa ser a única estratégia. Quando a aplicação atualiza um perfil, pode invalidar a chave imediatamente:

async fn atualizar_perfil(
    cache: &Cache<u64, Arc<Perfil>>,
    usuario_id: u64,
    novo: Perfil,
) -> Result<(), ErroRepositorio> {
    salvar_no_banco(usuario_id, &novo).await?;
    cache.invalidate(&usuario_id).await;
    Ok(())
}

A ordem importa: grave primeiro na fonte de verdade; invalide depois. Se invalidar antes e a escrita falhar, você perde apenas performance. Se preencher o cache antes da transação confirmar, pode servir um estado que nunca existiu no banco.

Em várias réplicas, invalidar o Moka da instância A não remove a chave das instâncias B e C. Opções:

  • TTL curto o bastante para o produto;
  • evento em Redis Pub/Sub, NATS ou Kafka para invalidar caches locais;
  • versionamento da chave (perfil:{id}:{versao});
  • não usar cache local para o dado que exige coerência imediata.

O padrão de duas camadas é comum:

  1. L1 Moka, local e muito rápido;
  2. L2 Redis, compartilhado entre instâncias;
  3. banco ou serviço autoritativo como origem.

O guia de Redis com Rust cobre a camada distribuída. Não adicione L1 e L2 automaticamente: cada nível aumenta as combinações de invalidação e observabilidade.

Observabilidade: hit rate sem se enganar

Um cache deve melhorar uma métrica de negócio técnico: latência, carga no banco, consumo de API externa ou custo de CPU. Registre pelo menos:

  • hits e misses;
  • latência de hit;
  • latência de carregamento na origem;
  • erros de carregamento;
  • quantidade ou peso de entradas;
  • invalidações por motivo;
  • taxa de evicção;
  • memória RSS do processo;
  • p95/p99 do endpoint antes e depois.

Uma razão simples é:

hit_rate = hits / (hits + misses)

Mas hit rate alto não prova sucesso. Um cache pode ter 99% de hits em dados baratos e ainda não reduzir a latência do endpoint caro. Segmente por operação, tipo de chave ou rota — sem colocar IDs individuais em labels, o que criaria cardinalidade explosiva.

Instrumente carregamentos com tracing: um span para a operação lógica, campos como cache.hit = true/false e histogramas agregados no sistema de métricas. Nunca registre o valor cacheado se ele puder conter dados pessoais ou segredos.

Moka vs Redis: a decisão completa

CritérioMokaRedis
Localizaçãomemória do processoserviço externo compartilhado
Latênciasem redeinclui rede e serialização
Compartilhamentosomente a instânciavárias instâncias e linguagens
Reiníciocache começa vaziopode sobreviver conforme configuração
Tiposvalores Rust sem serialização obrigatóriabytes/estruturas do Redis
Coordenação distribuídanãopossível, com cuidado
Escala de memóriaRAM de cada réplicamemória do cluster/servidor
Falhaafeta só aquela instânciapode afetar todos os consumidores
Melhor usoL1 e memoização concorrenteL2, sessão, rate limit e dados compartilhados

Escolha Moka quando o resultado pode ser recalculado e sua ausência inicial é aceitável. Escolha Redis quando compartilhar o dado faz parte do requisito. Use ambos somente quando o ganho medido justifica uma política de invalidação em duas camadas.

Erros comuns em produção

Cachear sem definir a fonte de verdade

Se ninguém sabe se o valor correto está no cache, no banco ou em outro serviço, a arquitetura já perdeu. Cache é derivado e descartável.

Usar TTL longo para esconder origem lenta

Isso adia o incidente. Corrija índices, queries, pools, timeouts e limites. O cache deve reduzir carga saudável, não esconder uma origem quebrada.

Capacidade por número para valores heterogêneos

Dez mil documentos não têm custo previsível. Use weigher e acompanhe RSS.

Fazer get e insert separados sob concorrência

Esse padrão cria stampede. Use get_with ou try_get_with para inicialização coordenada por chave.

Cachear erro transitório como resposta válida

Modele “não encontrado” separadamente e não transforme timeout em ausência.

Esperar coerência entre réplicas

Cada processo possui seu próprio Moka. Invalidação local não é broadcast.

Colocar dados secretos na chave ou em logs

Chaves podem aparecer em tracing e métricas. Prefira IDs internos ou hashes quando houver informação sensível.

Testar apenas o caminho de hit

Teste miss simultâneo, expiração, invalidação depois de escrita, falha da origem, limite de capacidade e reinício com cache frio.

Checklist de produção

  1. Defina a fonte de verdade e confirme que o cache é descartável.
  2. Escolha moka::future::Cache para Tokio e moka::sync::Cache para fluxo síncrono.
  3. Use get_with ou try_get_with na inicialização concorrente.
  4. Defina TTL pelo orçamento de desatualização do produto.
  5. Adicione TTI se itens abandonados devem sair antes.
  6. Use weigher quando valores tiverem custos diferentes.
  7. Guarde valores grandes em Arc para evitar clones caros.
  8. Invalide depois que a escrita autoritativa confirmar.
  9. Planeje cache frio durante deploy e autoscaling.
  10. Meça hits, misses, latência da origem, evicções e RSS.
  11. Teste rajadas na mesma chave e em chaves diferentes.
  12. Documente por que Moka é suficiente ou por que existe também Redis.

Moka como projeto de portfólio e competência de carreira

Cache é um bom tema de portfólio porque expõe decisões que aparecem em sistemas reais: consistência, concorrência, memória, backpressure e observabilidade. Em vez de mostrar apenas um endpoint rápido, publique um benchmark reproduzível com:

  • API Axum + banco;
  • carga com cache desligado e ligado;
  • rajada de misses na mesma chave;
  • gráfico de p50/p95/p99;
  • consultas por segundo no banco;
  • política de TTL e justificativa;
  • teste de invalidação após update;
  • limite por peso e memória do processo.

Esse repositório conversa diretamente com vagas de backend, plataforma e sistemas distribuídos. Para conectar o estudo ao mercado, veja vagas Rust, empresas que usam Rust e o roadmap de carreira backend em Rust.

Perguntas frequentes

O que é Moka em Rust?

Moka é um cache concorrente em memória para Rust, com APIs síncrona e assíncrona, expiração, invalidação, capacidade e política W-TinyLFU. Ele evita que cada projeto precise implementar locks, limpeza e remoção do zero.

Moka substitui Redis?

Não quando os dados precisam ser compartilhados entre processos. Moka é local à instância; Redis é acessado por várias réplicas. Moka pode ser a camada L1 na frente de Redis, desde que a complexidade de invalidação seja justificada.

Como evitar cache stampede com Moka?

Use get_with ou try_get_with para coordenar a inicialização da mesma chave. Acrescente timeout, limite de concorrência na origem e jitter se muitas entradas puderem expirar juntas.

Qual a diferença entre TTL e TTI?

TTL limita a idade desde a inserção ou atualização. TTI limita o tempo sem acesso. Juntos, eles renovam dados populares e removem cedo os abandonados.

Como limitar memória?

Para valores semelhantes, limite por quantidade pode bastar. Para valores heterogêneos, configure weigher com bytes estimados e acompanhe RSS; o peso é orçamento aproximado, não medição exata do heap.

Moka funciona com Axum e Tokio?

Sim. Guarde moka::future::Cache no State ou em um serviço compartilhado. Clonar o cache cria outro handle para a mesma estrutura, não uma cópia completa dos valores.

Conclusão

Moka é a escolha prática para um cache local concorrente em Rust: oferece política de admissão robusta, TTL, TTI, capacidade por peso e inicialização coordenada sem obrigar a equipe a manter uma estrutura caseira. Em Axum, comece com moka::future::Cache, get_with, TTL baseado no requisito de consistência e métricas da origem.

A decisão mais importante não é a duração exata do TTL. É saber se o dado pode ficar local, se pode desaparecer no restart e como será invalidado depois de uma escrita. Se as respostas exigirem compartilhamento entre réplicas, use Redis ou eventos; se não exigirem, Moka entrega uma camada L1 rápida, type-safe e alinhada ao modelo concorrente de Rust.