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, servidor HTTP em produção, filas e workers em background e benchmarking em Zig.
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.
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.
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.
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.
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:
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:
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: 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 Golang Brasil. 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:
putseguido degetretorna valor;- inserir a mesma chave substitui valor e libera o anterior;
- ao passar do limite, a entrada menos usada recentemente sai;
getmove item para frente da ordem LRU;- entrada expirada é removida;
deinitlibera todas as chaves, valores e nós;- falha de alocação no meio de
putnã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:
- Qual é o
max_entriese por que esse número cabe no container? - O TTL é igual para todos os valores ou depende do tipo de dado?
- O cache negativo existe? Qual TTL menor ele usa?
- A chave inclui tenant, versão, idioma e permissões quando necessário?
- O cache tem métricas de hit, miss, expiração e evicção?
- Existe endpoint, log ou métrica para detectar crescimento inesperado?
- O
deinitlibera todos os buffers? - Os testes cobrem expiração e LRU sem depender de
sleep? - 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.