---
title: "Redis em Zig: Cliente, Cache, Rate Limiting e Lock Distribuído sem Framework Pesado"
url: "https://ziglang.com.br/artigos/zig-redis-cache-rate-limit-lock/"
markdown_url: "https://ziglang.com.br/artigos/zig-redis-cache-rate-limit-lock.MD"
description: "Como usar Redis a partir de Zig para cache, rate limiting distribuído e locks por chave, com pooling de conexões, timeouts, tratamento de erros e padrões de produção."
date: "2026-06-20"
author: ""
---

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

Como usar Redis a partir de Zig para cache, rate limiting distribuído e locks por chave, com pooling de conexões, timeouts, tratamento de erros e padrões de produção.


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](/artigos/zig-cache-lru-ttl-producao/), [rate limiting com token bucket](/artigos/zig-rate-limiting-token-bucket/), [webhooks em Zig com HMAC e idempotência](/artigos/zig-webhooks-hmac-idempotencia/), [configuração segura com segredos e variáveis de ambiente](/artigos/zig-configuracao-segura-segredos-env/) e [observabilidade em Zig](/artigos/zig-observabilidade/). 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:

```zig
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.

```zig
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`:

```zig
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:

```lua
-- 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:

```zig
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:

```lua
-- 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](/artigos/zig-webhooks-hmac-idempotencia/)) é 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](/artigos/zig-observabilidade/) 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](/artigos/zig-opentelemetry-traces-metricas-logs/).

## 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 <a href="https://golang.com.br/" target="_blank" rel="noopener noreferrer" onclick="umami.track('portfolio-site-click', { destination: 'golang.com.br' })">Golang Brasil</a> 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.
