Cache em memória resolve muitos problemas até o momento em que você sobe uma segunda réplica do serviço. A partir dali, duas instâncias não compartilham cache local, o contador de rate limiting conta só metade das requisições e o lock que impedia execução dupla de um job vira um falso senso de segurança. É exatamente nesse ponto que Redis entra: ele vira a memória compartilhada, o contador único e o coordinateador de exclusão mútua entre réplicas.
A proposta deste guia é mostrar como usar Redis a partir de Zig em três cenários comuns de produção: cache com TTL, rate limiting distribuído por chave e lock cooperativo para jobs críticos. O foco não é reimplementar um cliente Redis completo. É apresentar o desenho que se repete quando um binário Zig precisa falar com Redis de forma estável: pooling de conexões, timeouts, serialização RESP simples, tratamento de erros e padrões que sobrevivem a restart, partição de rede e falha do broker.
Este artigo complementa cache LRU com TTL em Zig, rate limiting com token bucket, webhooks em Zig com HMAC e idempotência, configuração segura com segredos e variáveis de ambiente e observabilidade em Zig. A mentalidade é a mesma: fronteira pequena, comportamento explícito e dependências reduzidas.
Por que Redis ao lado de Zig
Zig não tem um runtime com garbage collector, não tem goroutines e não tem um cliente Redis canônico mantido pela linguagem. Isso não é uma desvantagem para produção — é a razão pela qual a integração fica simples de auditar. Você enxerga cada socket aberto, cada byte escrito, cada timeout configurado. Em linguagens com clientes Redis “mágicos”, é comum descobrir tarde demais que o cliente reutiliza conexões de forma errada, faz pipeline silencioso, mascarando lentidão ou quebra o contrato RESP3 em servidores antigos.
Quando o binário Zig precisa de estado compartilhado, Redis cobre a maioria dos casos sem inventar outro serviço:
- cache de resultados caros (consulta de banco, cálculo, renderização parcial);
- contador global de requisições para rate limiting entre réplicas;
- lock distribuído para jobs que não podem rodar em paralelo;
- fila leve quando RabbitMQ ou Kafka seriam exagero;
- pub/sub para invalidação de cache entre instâncias.
A pergunta certa não é “Zig precisa de Redis?”, mas “qual parte do estado deste serviço precisa sobreviver a restart e ser compartilhada entre réplicas?”. O que for local fica em memória; o que for compartilhado vai para Redis com TTL explícito.
O protocolo RESP em poucas linhas
Redis fala RESP (REdis Serialization Protocol). RESP3 é a versão atual, mas a maior parte dos comandos continua funcionando com o subconjunto RESP2. Para os padrões deste artigo basta entender três tipos:
- Simple String: começa com
+, termina com\r\n. Exemplo:+OK\r\n. - Error: começa com
-. Exemplo:-ERR wrong number of arguments\r\n. - Bulk String: começa com
$, seguido do tamanho em bytes, depois\r\n, o conteúdo, e\r\nfinal. Exemplo:$5\r\nhello\r\n.-1indica nulo. - Integer: começa com
:, seguido do número. Exemplo::42\r\n. - Array: começa com
*, seguido da quantidade de elementos. Exemplo:*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n.
Um comando SET chave valor EX 60 vira o array:
*5\r\n$3\r\nSET\r\n$5\r\nchave\r\n$5\r\nvalor\r\n$2\r\nEX\r\n$2\r\n60\r\n
Escrever um serializador RESP em Zig é direto porque não há ambiguidade de codificação e o tamanho é sempre prefixado. Para produção, no entanto, vale avaliar clientes da comunidade (como zgredis, redis-zig ou bindings sobre hiredis) antes de reimplementar o protocolo inteiro. A recomendação prática: use um cliente existente para comandos complexos (SCAN, clustering, pub/sub com push) e só mantenha um serializador próprio para os poucos comandos centrais do seu domínio, onde você quer controle total do timeout e do formato.
Pool de conexões e timeouts
O erro mais comum em clientes Redis improvisados é abrir uma conexão por comando. Cada connect é caro e cada conexão ociosa consome memória no servidor. O padrão correto é manter um pool pequeno de conexões long-lived, reutilizar e devolver ao pool, com timeout de socket em todas as operações.
Em Zig, o pool pode ser um array de sockets protegido por um mutex. O desenho essencial:
const std = @import("std");
const net = std.net;
const PoolEntry = struct {
stream: ?net.Stream,
last_used_ns: i128,
dead: bool,
};
const RedisPool = struct {
entries: []PoolEntry,
mutex: std.Thread.Mutex,
addr: net.Address,
connect_timeout_ms: u32,
read_timeout_ms: u32,
pub fn acquire(self: *RedisPool) !*PoolEntry {
self.mutex.lock();
defer self.mutex.unlock();
for (self.entries) |*e| {
if (!e.dead and e.stream != null) {
return e;
}
}
// cresce sob demanda até o tamanho do array; em produção, bloqueie ou falhe
return error.PoolExhausted;
}
pub fn release(self: *RedisPool, entry: *PoolEntry) void {
_ = self;
entry.last_used_ns = std.time.nanoTimestamp();
}
pub fn markDead(self: *RedisPool, entry: *PoolEntry) void {
self.mutex.lock();
defer self.mutex.unlock();
if (entry.stream) |s| s.close();
entry.stream = null;
entry.dead = true;
}
};
As regras operacionais que importam mais que o código:
- tamanho do pool igual ao número de operações simultâneas que o binário realmente faz, não ao dobro “para garantir”;
- timeout de leitura sempre finito (1 a 5 segundos para cache; mais para BLPOP);
- healthcheck barato: um
PINGocioso a cada 30 segundos ou antes de reutilizar uma conexão parada há muito tempo; - reconexão preguiçosa: conexão morta é fechada e reaberta na próxima aquisição;
- sem pipeline implícito: cada comando lê sua resposta no mesmo socket antes de devolver ao pool, a não ser que você explicitamente faça pipeline.
Quando uma operação falha por timeout ou reset de conexão, marque a entrada como morta. Tentar escrever em um socket fechado é o sintoma número um de travamentos misteriosos em serviços que usam Redis de improviso.
Padrão 1 — Cache com SET EX e GET
O caso mais simples é o cache de uma resposta cara. A regra é usar SET com EX (expiração em segundos) e ler de volta com GET. Sem EX, o cache vira um vazamento de memória no Redis.
fn cacheLookup(pool: *RedisPool, key: []const u8) !?[]const u8 {
const entry = try pool.acquire();
defer pool.release(entry);
const stream = entry.stream.?;
var buf: [256]u8 = undefined;
const cmd = try std.fmt.bufPrint(&buf,
"*2\r\n$3\r\nGET\r\n${d}\r\n{s}\r\n",
.{ key.len, key });
try stream.writer().writeAll(cmd);
// leia o prefixo da resposta; em produção use um bufferedReader com timeout
// +OK / $N / $-1 indicam sucesso, valor ou ausência
return null; // simplificado
}
fn cacheStore(pool: *RedisPool, key: []const u8, value: []const u8, ttl_s: u32) !void {
const entry = try pool.acquire();
defer pool.release(entry);
const stream = entry.stream.?;
var buf: [512]u8 = undefined;
const cmd = try std.fmt.bufPrint(&buf,
"*5\r\n$3\r\nSET\r\n${d}\r\n{s}\r\n${d}\r\n{s}\r\n$2\r\nEX\r\n${d}\r\n",
.{ key.len, key, value.len, value, ttl_s });
try stream.writer().writeAll(cmd);
}
Boas práticas de cache que reduzem incidentes:
- chave com namespace e versão:
user:42:profile:v3em vez deuser:42. Mudar a versão invalida cache sem flush manual; - TTL com jitter:
EX 300mais um pequeno offset aleatório evita thundering herd quando mil chaves expiram juntas; - cache-aside, não write-through (a princípio): a aplicação lê cache; se faltar, lê a fonte autoritativa e grava no cache. Write-through só quando a consistência justificar;
- nunca confiar no cache como fonte de verdade: se o Redis sumir, o serviço precisa responder (talvez mais lento) a partir do banco.
Para invalidação coordenada entre réplicas, use DEL ou pub/sub: quando uma instância invalida uma chave, publica no canal cache:invalidate e as outras fazem DEL. É mais simples que parece e evita o clássico bug de cache stale por minutos.
Padrão 2 — Rate limiting distribuído
Rate limiting por IP ou por usuário em uma única réplica é fácil. O problema é que qualquer serviço sério roda com mais de uma réplica atrás de um balanceador. Cada réplica mantendo seu próprio contador de token bucket permite o dobro ou o triplo do tráfego permitido. A solução é manter o contador no Redis.
O algoritmo fixed window mais simples com INCR e EXPIRE:
fn rateLimit(pool: *RedisPool, user_id: []const u8, limit: u32, window_s: u32) !bool {
const entry = try pool.acquire();
defer pool.release(entry);
const stream = entry.stream.?;
var key_buf: [128]u8 = undefined;
const key = try std.fmt.bufPrint(&key_buf, "rl:{s}:{d}", .{ user_id, window_s });
// INCR key
var cmd_buf: [256]u8 = undefined;
const incr = try std.fmt.bufPrint(&cmd_buf,
"*2\r\n$4\r\nINCR\r\n${d}\r\n{s}\r\n", .{ key.len, key });
try stream.writer().writeAll(incr);
const count = try readInteger(stream);
if (count == 1) {
// primeira vez na janela: define TTL
var expire_buf: [256]u8 = undefined;
const expire = try std.fmt.bufPrint(&expire_buf,
"*3\r\n$6\r\nEXPIRE\r\n${d}\r\n{s}\r\n${d}\r\n",
.{ key.len, key, window_s });
try stream.writer().writeAll(expire);
_ = try readInteger(stream);
}
return count <= @as(i64, @intCast(limit));
}
Para evitar a corrida entre INCR e EXPIRE (em que o processo morre entre os dois e a chave fica sem TTL, virando vazamento), o padrão mais robusto é um Lua script executado atomicamente via EVAL. Lua roda no Redis de forma atômica, sem outra operação interferir:
-- rate_limit.lua
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
end
return current
Carregue o script uma vez com SCRIPT LOAD, guarde o hash SHA1 e chame com EVALSHA. Em produção isso é mais rápido e mais seguro que dois comandos separados. Para janelas deslizantes exatas (sliding window), a versão com sorted set por timestamp funciona bem, mas a complexidade sobe — só vale a pena quando a janela fixa causar problemas reais de UX.
Quando o Redis fica indisponível, decida explicitamente a política: fail-open (deixa passar, registra alerta) ou fail-closed (rejeita tudo). Para a maioria dos endpoints públicos, fail-open com alerta é preferível a travar o site inteiro. Para endpoints sensíveis (login, cobrança), fail-closed é mais seguro. Não deixe essa decisão implícita no código.
Padrão 3 — Lock distribuído
Lock distribuído é o padrão mais usado de forma errada. A versão ingênua é SET NX simples: se conseguiu setar, pegou o lock; quando terminar, DEL. O problema é que o DEL final pode apagar o lock de outro processo se o portador original travou por tempo demais, expirou, e outro pegou. A correção canônica é a técnica Redlock simplificada com valor único por lock:
fn acquireLock(pool: *RedisPool, resource: []const u8, ttl_s: u32) !?[]const u8 {
// token aleatório único desta tentativa
var token_buf: [32]u8 = undefined;
std.crypto.random.bytes(&token_buf);
const token = std.fmt.bytesToHex(token_buf, .lower);
var key_buf: [128]u8 = undefined;
const key = try std.fmt.bufPrint(&key_buf, "lock:{s}", .{resource});
// SET key token NX EX ttl
var cmd_buf: [256]u8 = undefined;
const cmd = try std.fmt.bufPrint(&cmd_buf,
"*5\r\n$3\r\nSET\r\n${d}\r\n{s}\r\n${d}\r\n{s}\r\n$2\r\nNX\r\n$2\r\nEX\r\n${d}\r\n",
.{ key.len, key, token.len, token, ttl_s });
// ... escreva e leia a resposta; "+OK" = adquirido, "$-1" = não
return token;
}
A liberação segura precisa comparar o token antes de apagar. Novamente, Lua é a forma confiável de fazer a comparação e o DEL atomicamente:
-- release_lock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
Regras práticas para lock distribuído que evitam os acidentes mais comuns:
- TTL maior que o pior caso razoável: se o job leva até 30 segundos, TTL de 60 a 90 segundos, nunca 10;
- renovação (fencing / watchdog) só se o trabalho for longo e você tiver um mecanismo de renovação confiável; caso contrário, prefira job idempotente a lock long-lived;
- token aleatório por aquisição: nunca reuse um token fixo como “lock do meu processo”;
- falhe se não conseguir o lock: trate o caminho “alguém está rodando” como normal, não como exceção. Mostre ao usuário (ou log) que a operação está em andamento;
- lock não substitui idempotência: mesmo com lock correto, a operação protegida deve ser segura de repetir. Lock é otimização de concorrência, não garantia de corretude.
Para jobs críticos (migração de schema, cobrança, envio de e-mail em massa), o casamento de lock distribuído com idempotência via SET NX do event_id (como no padrão de webhooks) é a combinação que sobrevive a crash, restart e rede partindo no meio.
Serialização de payloads
Cache e lock normalmente operam sobre strings de bytes, mas a aplicação quer structs. Para cache, o conselho prático é serializar de forma estável e explícita. JSON é onipresente, mas é lento e verboso para cache quente. Alternativas comuns:
- binário determinístico com layout
extern structquando o leitor e o escritor são ambos em Zig e a versão é controlada; - MessagePack para payloads heterogêneos entre serviços;
- JSON quando a chave é lida também por ferramentas externas (debug, dashboards) ou outros serviços;
Em Zig, prefira std.json para JSON em cache só leitura pela aplicação e mantenha um campo de versão no payload (ex.: {"v": 3, "data": ...}). Se a versão não bater no parse, trate como cache miss. Isso evita o clássico bug de deploy que muda o formato da struct e quebra leitura de cache antigo.
Observabilidade e operação
Redis integrado precisa ser observável como qualquer outra dependência. O mínimo:
- métrica de latência por comando (p50/p99) — cache com p99 acima de 5ms indica problemas de rede ou pool;
- métrica de erros (timeout, refused connection, protocol error);
- métrica de cache hit/miss por chave lógica (não por chave individual, para não explodir cardinalidade);
- log estruturado em falhas, sem vazar valores sensíveis (cache de dados pessoais precisa de cuidado extra);
- alerta quando o Redis está próximo da memória máxima (
maxmemory) — quando ele começa a evictar chaves aleatoriamente, o cache aparenta funcionar mas a taxa de miss sobe.
A integração com observabilidade em Zig segue o padrão de outros artigos: exporte contadores via Prometheus ou envie traces para OpenTelemetry. O cliente Redis não deve ser uma caixa preta. Para detalhes de traces e métricas, consulte também OpenTelemetry em Zig.
Quando NÃO usar Redis
Redis é a ferramenta certa para muitos problemas, mas não para todos. Vale enumerar os casos em que outra escolha é melhor:
- estado transacional que precisa de ACID real: use o banco relacional (Postgres). Redis não tem transações no sentido ACID;
- filas com ordem estrita e confirmação durável: RabbitMQ ou Kafka. As listas do Redis funcionam para filas leves, mas perdem mensagens em cenários de restart mal configurado;
- busca textual: use um mecanismo de busca dedicado. Redis não é eficiente para relevância e ranking;
- dados quentes maiores que a memória: Redis é in-memory. Se a working set não cabe, um banco com cache em disco é mais barato;
- configuração centralizada sensível: prefira um sistema com versionamento e auditoria. Redis é rápido, mas não foi desenhado para compliance.
O bom uso de Redis écirúrgico: ele resolve um conjunto específico de problemas de forma excelente e falha silenciosamente quando forçado fora desse conjunto.
Checklist de produção
Antes de considerar a integração Redis pronta para produção:
- pool de conexões com tamanho definido e timeouts em todas as operações;
- reconexão automática com backoff;
- TTL explícito em toda chave escrita;
- chaves com namespace e versão para permitir invalidação sem flush;
- locks distribuídos com token aleatório e liberação via Lua atômica;
- rate limiting com
EVALSHApara evitar corrida entreINCReEXPIRE; - política de falha explícita (fail-open ou fail-closed) por endpoint;
- métricas de latência, erro e cache hit/miss exportadas;
- alerta de memória próxima do
maxmemory; - segredos (senha, TLS) vindos de ambiente ou secret manager, nunca commitados;
- testes com Redis real em CI, inclusive os caminhos de falha (timeout, reset, broker fora do ar);
- documentação das chaves usadas, com prefixo e dono, para que a operação saiba de onde vem cada chave ao inspecionar
KEYSouSCAN.
Onde Zig contribui para essa integração
A vantagem de Zig aqui não é performance bruta em cada comando — Redis é rápido o suficiente para que a diferença entre linguagens seja dominada pela rede. A vantagem é previsibilidade operacional. Você vê o pool, vê os timeouts, vê o caminho de erro. Não há reconnector mágico, não há serializador que esconde falhas, não há cliente que abre 50 conexões por trás dos panos. Cada comportamento é explícito, e comportamento explícito é o que separa um cache que aguenta Black Friday de um que vira incidente às 14h de uma terça.
Para times que já operam serviços em Go, vale a pena comparar padrões. Há material de referência em Golang Brasil sobre pools de conexões, rate limiting e locks distribuídos que traduzem diretamente para o desenho apresentado aqui — a escolha entre as linguagens cai para o domínio do controle de memória, tamanho do binário e dependências, não para a arquitetura.
Comece pequeno: um cache de uma única operação cara, com TTL e versão na chave. Quando isso estiver estável, adicione rate limiting distribuído para o endpoint público mais sensível. Só depois considere locks para jobs críticos. Cada camada adiciona poder e complexidade operacional, e o melhor desenho é aquele em que cada peça tem um motivo claro para existir.