---
title: "Cache LRU e TTL em Zig: Controle de Memória para Serviços"
url: "https://ziglang.com.br/artigos/zig-cache-lru-ttl-producao/"
markdown_url: "https://ziglang.com.br/artigos/zig-cache-lru-ttl-producao.MD"
description: "Como desenhar cache LRU com TTL em Zig para serviços e CLIs: limites de memória, expiração, chaves, métricas, testes e cuidados de produção."
date: "2026-05-28"
author: ""
---

# Cache LRU e TTL em Zig: Controle de Memória para Serviços

Como desenhar cache LRU com TTL em Zig para serviços e CLIs: limites de memória, expiração, chaves, métricas, testes e cuidados de produção.


Cache parece uma otimização simples até ele virar fonte de incidente. Um serviço consulta uma API lenta, guarda a resposta por alguns minutos e reduz latência. Depois alguém aumenta o tráfego, as chaves crescem sem limite, itens expirados continuam ocupando memória, uma entrada negativa fica viva tempo demais e o processo começa a competir com o restante do container. Em linguagens com garbage collector, parte desse custo fica escondida no runtime. Em Zig, ele aparece onde deve aparecer: no desenho da estrutura, no allocator escolhido, no tamanho máximo e no ponto exato em que cada buffer é liberado.

Este guia mostra como pensar em **cache LRU com TTL em Zig** para serviços pequenos, CLIs persistentes, workers e ferramentas internas. O objetivo não é competir com Redis, Memcached ou um CDN. O objetivo é resolver o caso local: evitar recomputação cara, reduzir chamadas repetidas e manter previsibilidade de memória dentro de um binário Zig. Para conectar com outras bases do site, combine este texto com [estratégias de alocação de memória em Zig](/artigos/zig-alocacao-memoria-estrategias/), [servidor HTTP em produção](/artigos/zig-http-server-producao/), [filas e workers em background](/artigos/zig-filas-workers-background/) e [benchmarking em Zig](/artigos/zig-benchmarking-medir-performance/).

## Quando cache local faz sentido

Cache local funciona bem quando o dado é pequeno, repetido e tolera alguma defasagem. Exemplos comuns:

- resultado de parsing de configuração;
- metadados de arquivo já lido do disco;
- resposta de API com validade curta;
- lookup de usuário, tenant ou feature flag;
- template compilado ou plano de consulta;
- resultado de cálculo determinístico caro;
- cache negativo para evitar repetir uma busca que acabou de falhar.

O ponto importante é que o cache local pertence ao processo. Se você roda dez réplicas, existem dez caches. Se o processo reinicia, o cache desaparece. Se precisa de consistência entre instâncias, invalidação externa, warmup compartilhado ou durabilidade, use uma camada própria. Zig entra bem no cache local porque permite representar esse contrato sem mágica: memória limitada, expiração explícita e métricas simples.

## TTL, LRU e limite de memória são problemas diferentes

Três decisões costumam ser confundidas.

**TTL** define quanto tempo uma entrada pode ser considerada fresca. Ele protege contra dado velho, mas não limita memória sozinho. Se mil chaves diferentes chegam em um minuto e todas têm TTL de dez minutos, as mil continuam ocupando espaço.

**LRU** remove o item menos usado recentemente quando o cache atinge capacidade. Ele protege contra crescimento indefinido, mas não garante frescor. Um item popular pode ficar vivo para sempre se não houver TTL.

**Limite de memória** define quanto o processo pode gastar. Capacidade por número de entradas é um bom começo, mas nem toda entrada tem o mesmo tamanho. Um cache de 1.000 strings pequenas é diferente de 1.000 respostas JSON de 200 KB. Em produção, acompanhe pelo menos `entries`, `hits`, `misses`, `evictions`, `expired` e, quando possível, bytes aproximados.

## Modelo de dados em Zig

Um cache simples pode começar com `std.StringHashMap` para busca por chave e uma lista ligada para ordem LRU. A lista guarda a ordem de uso; o mapa aponta da chave para o nó da lista. Quando uma chave é lida, o nó sobe para a frente. Quando a capacidade estoura, o nó do fundo é removido.

```zig
const std = @import("std");

const Clock = struct {
    pub fn nowMs() u64 {
        return @intCast(std.time.milliTimestamp());
    }
};

const Entry = struct {
    key: []u8,
    value: []u8,
    expires_at_ms: u64,
};

const Cache = struct {
    allocator: std.mem.Allocator,
    ttl_ms: u64,
    max_entries: usize,
    map: std.StringHashMap(*Node),
    head: ?*Node = null,
    tail: ?*Node = null,

    const Node = struct {
        entry: Entry,
        prev: ?*Node = null,
        next: ?*Node = null,
    };
};
```

Esse esqueleto deixa duas escolhas visíveis. Primeiro, `key` e `value` são slices alocados pelo cache, não referências temporárias vindas do chamador. Segundo, o cache sabe seu `allocator`. Isso facilita testes com `std.testing.allocator`, integração com `GeneralPurposeAllocator` e auditoria de vazamento.

## Inserção com ownership explícito

Ao inserir, duplique chave e valor. Isso evita uma classe comum de bug: guardar ponteiro para buffer temporário de request, linha de arquivo ou resposta que será liberada pelo chamador.

```zig
pub fn put(self: *Cache, key: []const u8, value: []const u8) !void {
    if (self.map.get(key)) |node| {
        self.allocator.free(node.entry.value);
        node.entry.value = try self.allocator.dupe(u8, value);
        node.entry.expires_at_ms = Clock.nowMs() + self.ttl_ms;
        self.moveToFront(node);
        return;
    }

    const owned_key = try self.allocator.dupe(u8, key);
    errdefer self.allocator.free(owned_key);

    const owned_value = try self.allocator.dupe(u8, value);
    errdefer self.allocator.free(owned_value);

    const node = try self.allocator.create(Cache.Node);
    node.* = .{
        .entry = .{
            .key = owned_key,
            .value = owned_value,
            .expires_at_ms = Clock.nowMs() + self.ttl_ms,
        },
    };

    try self.map.put(node.entry.key, node);
    self.pushFront(node);
    try self.enforceCapacity();
}
```

O uso de `errdefer` é importante. Se duplicar a chave funciona, mas duplicar o valor falha, a chave precisa ser liberada. Se criar o nó falha, chave e valor precisam ser liberados. Em cache, falha de alocação não pode deixar lixo invisível porque a estrutura costuma viver durante todo o processo.

## Leitura deve limpar expiração

Na leitura, verifique o TTL antes de retornar. Se a entrada expirou, remova imediatamente e conte como miss. Retornar item expirado "só desta vez" pode parecer aceitável, mas normalmente cria comportamento difícil de explicar durante incidentes.

```zig
pub fn get(self: *Cache, key: []const u8) ?[]const u8 {
    const node = self.map.get(key) orelse return null;

    if (Clock.nowMs() >= node.entry.expires_at_ms) {
        self.removeNode(node);
        return null;
    }

    self.moveToFront(node);
    return node.entry.value;
}
```

Observe o contrato: o slice retornado pertence ao cache. O chamador não deve liberar nem guardar por tempo indeterminado. Se a aplicação precisa manter o valor depois de outra operação que pode invalidar o cache, duplique no chamador ou exponha uma API que copie para um buffer controlado.

## Evicção precisa liberar tudo

O erro mais caro em cache manual é remover do mapa e esquecer de liberar campos internos. A função de remoção deve ser o único lugar que destrói nó, chave e valor.

```zig
fn removeNode(self: *Cache, node: *Cache.Node) void {
    _ = self.map.remove(node.entry.key);

    if (node.prev) |prev| prev.next = node.next else self.head = node.next;
    if (node.next) |next| next.prev = node.prev else self.tail = node.prev;

    self.allocator.free(node.entry.key);
    self.allocator.free(node.entry.value);
    self.allocator.destroy(node);
}

fn enforceCapacity(self: *Cache) !void {
    while (self.map.count() > self.max_entries) {
        const victim = self.tail orelse return;
        self.removeNode(victim);
    }
}
```

Em código real, adicione contadores de `evictions` e `expired`. Não deixe métricas como detalhe para depois: cache sem métrica é chute. Se a taxa de hit está baixa, talvez o cache só esteja gastando memória. Se a taxa de evicção está alta, a capacidade pode estar pequena ou a chave pode estar granular demais.

## Chaves boas evitam bugs invisíveis

Chave de cache deve carregar tudo que muda o resultado. Em serviço HTTP, não use apenas o caminho da URL se o resultado varia por `tenant`, idioma, autenticação, feature flag ou versão do schema. Prefira montar uma chave explícita:

```text
tenant:{id}:user:{id}:permissions:v3
```

Inclua versão quando a representação mudar. Isso evita precisar invalidar tudo ao trocar formato interno. Em Zig, a montagem de chave também precisa respeitar ownership. Uma boa prática é usar `std.fmt.allocPrint` no ponto de inserção e liberar após `put`, já que `put` duplica a chave.

## Cache negativo exige TTL menor

Guardar falhas também é útil: se uma API respondeu 404 para um ID inexistente, talvez valha evitar repetir a chamada por alguns segundos. Mas cache negativo deve ter TTL menor que cache positivo. Um recurso pode ser criado logo depois, um token pode ser reativado, uma permissão pode mudar. Se o cache guarda "não existe" por tempo demais, ele vira bug de produto.

Modele isso no tipo:

```zig
const CachedUser = union(enum) {
    found: User,
    not_found,
    temporarily_unavailable,
};
```

Nem toda falha deve ser cacheada. Timeout, erro 500 e falha de rede geralmente pedem retry com backoff, não TTL longo. Esse é um ponto em que cache se aproxima de [padrões de retry em Zig](/padroes/retry-pattern/): a decisão correta depende de idempotência, custo da chamada e impacto para o usuário.

## Concorrência: comece simples

Se o cache é usado por múltiplas threads, proteja a estrutura inteira com `std.Thread.Mutex` antes de tentar granularidade fina. Um mutex ao redor de `get` e `put` pode ser suficiente para serviços pequenos. Otimizar cedo com locks por shard, atomics e estruturas lock-free aumenta o risco de use-after-free e race de invalidação.

Quando a carga justificar, divida por shards: calcule hash da chave, escolha um shard e mantenha um cache LRU separado por shard. Isso reduz contenção sem exigir uma estrutura global complexa. Mas meça antes. Para muitas aplicações Zig, o gargalo será I/O externo, parsing ou serialização, não o mutex do cache.

Se o time também mantém serviços em Go, vale comparar a cultura operacional de cache e concorrência no <a href="https://golang.com.br/" target="_blank" rel="noopener noreferrer" onclick="umami.track('portfolio-site-click', { destination: 'golang.com.br' })">Golang Brasil</a>. Go torna cache em memória conveniente com goroutines e garbage collector; Zig exige mais código, mas entrega um limite de memória mais auditável quando a estrutura é bem desenhada.

## Testes que pegam regressão real

Teste cache com relógio controlável. Se `Clock.nowMs()` chama o sistema diretamente, seus testes ficam lentos ou frágeis. Em produção, o relógio real faz sentido; em teste, injete uma função ou struct de relógio falso.

Casos mínimos:

- `put` seguido de `get` retorna valor;
- inserir a mesma chave substitui valor e libera o anterior;
- ao passar do limite, a entrada menos usada recentemente sai;
- `get` move item para frente da ordem LRU;
- entrada expirada é removida;
- `deinit` libera todas as chaves, valores e nós;
- falha de alocação no meio de `put` não vaza memória.

Use `std.testing.allocator` para detectar vazamento. Também vale rodar o binário em Debug durante desenvolvimento, porque Zig ajuda a encontrar uso incorreto de allocator e bounds checks antes de você medir performance em ReleaseFast.

## Checklist de produção

Antes de colocar cache local em produção, responda:

1. Qual é o `max_entries` e por que esse número cabe no container?
2. O TTL é igual para todos os valores ou depende do tipo de dado?
3. O cache negativo existe? Qual TTL menor ele usa?
4. A chave inclui tenant, versão, idioma e permissões quando necessário?
5. O cache tem métricas de hit, miss, expiração e evicção?
6. Existe endpoint, log ou métrica para detectar crescimento inesperado?
7. O `deinit` libera todos os buffers?
8. Os testes cobrem expiração e LRU sem depender de `sleep`?
9. A aplicação continua correta se o cache estiver sempre vazio?

Essa última pergunta é a mais importante. Cache deve melhorar latência e custo, não ser a fonte da verdade. Em Zig, a vantagem é que essa arquitetura fica explícita no código: quem aloca, quem libera, quando expira e o que acontece quando a capacidade acaba. Essa clareza combina com a proposta da linguagem e com a operação de serviços pequenos que precisam ser previsíveis por meses, não apenas rápidos em benchmark.
