Redis em Zig: Cliente, Cache, Rate Limiting e Lock Distribuído sem Framework Pesado

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\n final. Exemplo: $5\r\nhello\r\n. -1 indica 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 PING ocioso 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:v3 em vez de user:42. Mudar a versão invalida cache sem flush manual;
  • TTL com jitter: EX 300 mais 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 struct quando 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 EVALSHA para evitar corrida entre INCR e EXPIRE;
  • 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 KEYS ou SCAN.

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.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.