1. Introdução ao Redis com Zig
O Redis é um dos bancos de dados em memória mais utilizados no mundo. Ele funciona como um armazenamento de estruturas de dados chave-valor, oferecendo desempenho extremamente alto para operações de leitura e escrita. Combinar o Redis com Zig nos permite construir sistemas de cache, filas de mensagens e mecanismos de pub/sub com controle total sobre a memória e o desempenho.
Por que usar Redis com Zig?
- Desempenho: Zig oferece controle de baixo nível sem garbage collector, ideal para clientes de alta performance.
- Protocolo simples: O protocolo RESP (REdis Serialization Protocol) é baseado em texto e fácil de implementar via TCP.
- Controle de memória: Com os alocadores de Zig, você decide exatamente como e quando a memória é usada.
- Sem dependências ocultas: Diferente de bibliotecas em C, nosso cliente Zig usa apenas a biblioteca padrão.
O que vamos construir
Neste guia, vamos implementar do zero um cliente Redis funcional em Zig. Ao final, você terá:
- Conexão TCP direta ao Redis
- Parser do protocolo RESP
- Comandos GET, SET, DEL, EXPIRE e TTL
- Operações com listas e hashes
- Sistema de pub/sub
- Padrão de cache-aside completo
- Pool de conexões reutilizáveis
Todos os exemplos usam std.net.Stream da biblioteca padrão de Zig e tratam erros de forma explícita com error unions.
2. Conectando ao Redis via TCP
O Redis escuta conexões TCP na porta padrão 6379. Em Zig, usamos std.net para estabelecer a conexão. O protocolo RESP é baseado em texto, então enviamos comandos como sequências de bytes e lemos as respostas da mesma forma.
Conexão básica
const std = @import("std");
const net = std.net;
pub const RedisConnection = struct {
stream: net.Stream,
pub fn connect(host: []const u8, port: u16) !RedisConnection {
const address = try net.Address.resolveIp(host, port);
const stream = try net.tcpConnectToAddress(address);
return RedisConnection{ .stream = stream };
}
pub fn connectDefault() !RedisConnection {
return connect("127.0.0.1", 6379);
}
pub fn close(self: *RedisConnection) void {
self.stream.close();
}
};
pub fn main() !void {
var conn = try RedisConnection.connectDefault();
defer conn.close();
std.debug.print("Conectado ao Redis com sucesso!\n", .{});
}
Entendendo o protocolo RESP
O RESP (REdis Serialization Protocol) usa prefixos de tipo para cada dado:
| Prefixo | Tipo | Exemplo |
|---|---|---|
+ | String simples | +OK\r\n |
- | Erro | -ERR unknown command\r\n |
: | Inteiro | :1000\r\n |
$ | Bulk String | $5\r\nhello\r\n |
* | Array | *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n |
Quando enviamos um comando ao Redis, usamos o formato de array RESP. Por exemplo, o comando SET chave valor é enviado como:
*3\r\n$3\r\nSET\r\n$5\r\nchave\r\n$5\r\nvalor\r\n
Isso significa: um array de 3 elementos, onde cada elemento é uma bulk string com seu comprimento declarado.
Enviando o primeiro comando
const std = @import("std");
const net = std.net;
fn sendRawCommand(stream: net.Stream, command: []const u8) !void {
_ = try stream.write(command);
}
fn readResponse(stream: net.Stream, buffer: []u8) ![]const u8 {
const bytes_read = try stream.read(buffer);
if (bytes_read == 0) return error.ConnectionClosed;
return buffer[0..bytes_read];
}
pub fn main() !void {
const address = try net.Address.resolveIp("127.0.0.1", 6379);
const stream = try net.tcpConnectToAddress(address);
defer stream.close();
// Enviando PING ao Redis
const ping_cmd = "*1\r\n$4\r\nPING\r\n";
_ = try stream.write(ping_cmd);
var buffer: [4096]u8 = undefined;
const response = try readResponse(stream, &buffer);
std.debug.print("Resposta do Redis: {s}\n", .{response});
// Saída esperada: +PONG
}
3. Implementando o Protocolo RESP
Para ter um cliente Redis robusto, precisamos de um encoder que transforma comandos em formato RESP e um parser que interpreta as respostas do servidor.
Encoder RESP: transformando comandos em protocolo
const std = @import("std");
const Allocator = std.mem.Allocator;
pub const RespEncoder = struct {
allocator: Allocator,
pub fn init(allocator: Allocator) RespEncoder {
return RespEncoder{ .allocator = allocator };
}
/// Codifica uma lista de argumentos no formato RESP array.
/// Exemplo: encodeCommand(&.{"SET", "chave", "valor"})
/// Produz: *3\r\n$3\r\nSET\r\n$5\r\nchave\r\n$5\r\nvalor\r\n
pub fn encodeCommand(self: *const RespEncoder, args: []const []const u8) ![]u8 {
var list = std.ArrayList(u8).init(self.allocator);
errdefer list.deinit();
// Cabeçalho do array: *N\r\n
try list.writer().print("*{d}\r\n", .{args.len});
// Cada argumento como bulk string: $L\r\nDADOS\r\n
for (args) |arg| {
try list.writer().print("${d}\r\n", .{arg.len});
try list.appendSlice(arg);
try list.appendSlice("\r\n");
}
return list.toOwnedSlice();
}
};
Parser RESP: interpretando respostas do Redis
pub const RespValue = union(enum) {
simple_string: []const u8,
error_msg: []const u8,
integer: i64,
bulk_string: ?[]const u8, // null para bulk string nil ($-1)
array: ?[]RespValue, // null para array nil (*-1)
};
pub const RespParser = struct {
data: []const u8,
pos: usize,
allocator: Allocator,
pub fn init(allocator: Allocator, data: []const u8) RespParser {
return RespParser{
.data = data,
.pos = 0,
.allocator = allocator,
};
}
fn readLine(self: *RespParser) ![]const u8 {
const start = self.pos;
while (self.pos < self.data.len - 1) {
if (self.data[self.pos] == '\r' and self.data[self.pos + 1] == '\n') {
const line = self.data[start..self.pos];
self.pos += 2; // pula \r\n
return line;
}
self.pos += 1;
}
return error.IncompleteData;
}
fn readBytes(self: *RespParser, count: usize) ![]const u8 {
if (self.pos + count + 2 > self.data.len) return error.IncompleteData;
const slice = self.data[self.pos .. self.pos + count];
self.pos += count + 2; // pula os dados + \r\n
return slice;
}
pub fn parse(self: *RespParser) !RespValue {
if (self.pos >= self.data.len) return error.IncompleteData;
const type_byte = self.data[self.pos];
self.pos += 1;
return switch (type_byte) {
'+' => {
const line = try self.readLine();
return RespValue{ .simple_string = line };
},
'-' => {
const line = try self.readLine();
return RespValue{ .error_msg = line };
},
':' => {
const line = try self.readLine();
const value = try std.fmt.parseInt(i64, line, 10);
return RespValue{ .integer = value };
},
'$' => {
const line = try self.readLine();
const length = try std.fmt.parseInt(i64, line, 10);
if (length == -1) {
return RespValue{ .bulk_string = null };
}
const content = try self.readBytes(@intCast(length));
return RespValue{ .bulk_string = content };
},
'*' => {
const line = try self.readLine();
const count = try std.fmt.parseInt(i64, line, 10);
if (count == -1) {
return RespValue{ .array = null };
}
const arr_len: usize = @intCast(count);
const items = try self.allocator.alloc(RespValue, arr_len);
for (0..arr_len) |i| {
items[i] = try self.parse();
}
return RespValue{ .array = items };
},
else => return error.UnknownRespType,
};
}
};
Juntando encoder e parser no cliente
pub const RedisClient = struct {
stream: std.net.Stream,
allocator: Allocator,
read_buffer: [8192]u8,
pub fn init(allocator: Allocator, host: []const u8, port: u16) !RedisClient {
const address = try std.net.Address.resolveIp(host, port);
const stream = try std.net.tcpConnectToAddress(address);
return RedisClient{
.stream = stream,
.allocator = allocator,
.read_buffer = undefined,
};
}
pub fn deinit(self: *RedisClient) void {
self.stream.close();
}
/// Envia um comando RESP e retorna a resposta parseada.
pub fn sendCommand(self: *RedisClient, args: []const []const u8) !RespValue {
const encoder = RespEncoder.init(self.allocator);
const encoded = try encoder.encodeCommand(args);
defer self.allocator.free(encoded);
_ = try self.stream.write(encoded);
const bytes_read = try self.stream.read(&self.read_buffer);
if (bytes_read == 0) return error.ConnectionClosed;
var parser = RespParser.init(self.allocator, self.read_buffer[0..bytes_read]);
return parser.parse();
}
};
4. Comandos Básicos: GET, SET, DEL
Agora que temos o cliente funcional, podemos implementar os comandos mais comuns do Redis. Estes são os blocos fundamentais de qualquer aplicação que usa Redis.
SET: armazenando valores
O comando SET armazena uma string associada a uma chave. Se a chave já existir, o valor é sobrescrito.
pub fn set(self: *RedisClient, key: []const u8, value: []const u8) !void {
const args = &[_][]const u8{ "SET", key, value };
const result = try self.sendCommand(args);
switch (result) {
.simple_string => |s| {
if (!std.mem.eql(u8, s, "OK")) {
return error.UnexpectedResponse;
}
},
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
GET: recuperando valores
O comando GET retorna o valor de uma chave, ou null se a chave não existir.
pub fn get(self: *RedisClient, key: []const u8) !?[]const u8 {
const args = &[_][]const u8{ "GET", key };
const result = try self.sendCommand(args);
switch (result) {
.bulk_string => |maybe_value| {
return maybe_value; // retorna null se a chave não existir
},
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
DEL: removendo chaves
O comando DEL remove uma ou mais chaves e retorna o número de chaves removidas.
pub fn del(self: *RedisClient, key: []const u8) !i64 {
const args = &[_][]const u8{ "DEL", key };
const result = try self.sendCommand(args);
switch (result) {
.integer => |count| return count,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
/// Remove múltiplas chaves de uma vez.
pub fn delMultiple(self: *RedisClient, keys: []const []const u8) !i64 {
var args = std.ArrayList([]const u8).init(self.allocator);
defer args.deinit();
try args.append("DEL");
for (keys) |key| {
try args.append(key);
}
const result = try self.sendCommand(args.items);
switch (result) {
.integer => |count| return count,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
EXISTS: verificando se uma chave existe
pub fn exists(self: *RedisClient, key: []const u8) !bool {
const args = &[_][]const u8{ "EXISTS", key };
const result = try self.sendCommand(args);
switch (result) {
.integer => |count| return count > 0,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
Exemplo completo: operações básicas
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var client = try RedisClient.init(allocator, "127.0.0.1", 6379);
defer client.deinit();
// SET: armazena um valor
try client.set("usuario:1:nome", "Maria Silva");
std.debug.print("SET usuario:1:nome = 'Maria Silva'\n", .{});
// GET: recupera o valor
if (try client.get("usuario:1:nome")) |nome| {
std.debug.print("GET usuario:1:nome = '{s}'\n", .{nome});
} else {
std.debug.print("Chave não encontrada\n", .{});
}
// EXISTS: verifica existência
const existe = try client.exists("usuario:1:nome");
std.debug.print("EXISTS usuario:1:nome = {}\n", .{existe});
// DEL: remove a chave
const removidas = try client.del("usuario:1:nome");
std.debug.print("DEL usuario:1:nome = {d} chave(s) removida(s)\n", .{removidas});
// GET após remoção: deve retornar null
if (try client.get("usuario:1:nome")) |_| {
std.debug.print("Chave ainda existe (inesperado)\n", .{});
} else {
std.debug.print("Chave removida com sucesso\n", .{});
}
}
5. Trabalhando com TTL e Expiração
Uma das funcionalidades mais poderosas do Redis é a possibilidade de definir tempo de vida (TTL) para as chaves. Isso é essencial para implementar sistemas de cache, tokens temporários e sessões.
SETEX: SET com expiração
O comando SETEX define uma chave com um valor e um tempo de expiração em segundos, em uma única operação atômica.
/// Define uma chave com valor e tempo de expiração em segundos.
pub fn setex(self: *RedisClient, key: []const u8, seconds: u32, value: []const u8) !void {
var seconds_buf: [20]u8 = undefined;
const seconds_str = std.fmt.bufPrint(&seconds_buf, "{d}", .{seconds}) catch unreachable;
const args = &[_][]const u8{ "SETEX", key, seconds_str, value };
const result = try self.sendCommand(args);
switch (result) {
.simple_string => |s| {
if (!std.mem.eql(u8, s, "OK")) {
return error.UnexpectedResponse;
}
},
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
EXPIRE: definindo expiração em chave existente
/// Define tempo de expiração em segundos para uma chave existente.
/// Retorna true se o timeout foi definido, false se a chave não existe.
pub fn expire(self: *RedisClient, key: []const u8, seconds: u32) !bool {
var seconds_buf: [20]u8 = undefined;
const seconds_str = std.fmt.bufPrint(&seconds_buf, "{d}", .{seconds}) catch unreachable;
const args = &[_][]const u8{ "EXPIRE", key, seconds_str };
const result = try self.sendCommand(args);
switch (result) {
.integer => |val| return val == 1,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
TTL: consultando o tempo restante
/// Retorna o tempo restante de vida da chave em segundos.
/// Retorna -1 se a chave existe mas não tem expiração.
/// Retorna -2 se a chave não existe.
pub fn ttl(self: *RedisClient, key: []const u8) !i64 {
const args = &[_][]const u8{ "TTL", key };
const result = try self.sendCommand(args);
switch (result) {
.integer => |val| return val,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
PERSIST: removendo a expiração
/// Remove a expiração de uma chave, tornando-a permanente.
/// Retorna true se a expiração foi removida.
pub fn persist(self: *RedisClient, key: []const u8) !bool {
const args = &[_][]const u8{ "PERSIST", key };
const result = try self.sendCommand(args);
switch (result) {
.integer => |val| return val == 1,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
Exemplo completo: gerenciando expiração
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var client = try RedisClient.init(allocator, "127.0.0.1", 6379);
defer client.deinit();
// Cria uma sessão com expiração de 30 minutos (1800 segundos)
try client.setex("sessao:abc123", 1800, "usuario:42");
std.debug.print("Sessão criada com TTL de 1800 segundos\n", .{});
// Verifica o TTL restante
const tempo_restante = try client.ttl("sessao:abc123");
std.debug.print("TTL restante: {d} segundos\n", .{tempo_restante});
// Renova a sessão (redefine o TTL)
const renovado = try client.expire("sessao:abc123", 3600);
std.debug.print("Sessão renovada para 1 hora: {}\n", .{renovado});
// Token temporário com SET e EXPIRE separados
try client.set("token:reset:xyz", "usuario:42");
_ = try client.expire("token:reset:xyz", 300); // 5 minutos
const ttl_token = try client.ttl("token:reset:xyz");
std.debug.print("Token de reset expira em: {d} segundos\n", .{ttl_token});
// Remove expiração (torna permanente)
_ = try client.persist("sessao:abc123");
const ttl_sessao = try client.ttl("sessao:abc123");
std.debug.print("TTL após persist: {d} (deve ser -1)\n", .{ttl_sessao});
}
6. Listas: LPUSH, RPUSH, LRANGE
As listas do Redis são listas ligadas que suportam inserção e remoção em ambas as extremidades em tempo O(1). São ideais para filas de tarefas, logs recentes e feeds de atividades.
LPUSH e RPUSH: inserindo elementos
/// Insere um ou mais elementos no início (esquerda) da lista.
/// Retorna o comprimento da lista após a inserção.
pub fn lpush(self: *RedisClient, key: []const u8, value: []const u8) !i64 {
const args = &[_][]const u8{ "LPUSH", key, value };
const result = try self.sendCommand(args);
switch (result) {
.integer => |len| return len,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
/// Insere um ou mais elementos no final (direita) da lista.
/// Retorna o comprimento da lista após a inserção.
pub fn rpush(self: *RedisClient, key: []const u8, value: []const u8) !i64 {
const args = &[_][]const u8{ "RPUSH", key, value };
const result = try self.sendCommand(args);
switch (result) {
.integer => |len| return len,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
LPOP e RPOP: removendo elementos
/// Remove e retorna o primeiro elemento da lista (esquerda).
pub fn lpop(self: *RedisClient, key: []const u8) !?[]const u8 {
const args = &[_][]const u8{ "LPOP", key };
const result = try self.sendCommand(args);
switch (result) {
.bulk_string => |maybe_value| return maybe_value,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
/// Remove e retorna o último elemento da lista (direita).
pub fn rpop(self: *RedisClient, key: []const u8) !?[]const u8 {
const args = &[_][]const u8{ "RPOP", key };
const result = try self.sendCommand(args);
switch (result) {
.bulk_string => |maybe_value| return maybe_value,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
LRANGE: consultando intervalos da lista
/// Retorna os elementos da lista no intervalo [start, stop].
/// Use start=0 e stop=-1 para obter todos os elementos.
pub fn lrange(
self: *RedisClient,
key: []const u8,
start: i64,
stop: i64,
) !?[]RespValue {
var start_buf: [20]u8 = undefined;
var stop_buf: [20]u8 = undefined;
const start_str = std.fmt.bufPrint(&start_buf, "{d}", .{start}) catch unreachable;
const stop_str = std.fmt.bufPrint(&stop_buf, "{d}", .{stop}) catch unreachable;
const args = &[_][]const u8{ "LRANGE", key, start_str, stop_str };
const result = try self.sendCommand(args);
switch (result) {
.array => |maybe_items| return maybe_items,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
/// LLEN: retorna o comprimento da lista.
pub fn llen(self: *RedisClient, key: []const u8) !i64 {
const args = &[_][]const u8{ "LLEN", key };
const result = try self.sendCommand(args);
switch (result) {
.integer => |len| return len,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
Exemplo completo: fila de tarefas
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var client = try RedisClient.init(allocator, "127.0.0.1", 6379);
defer client.deinit();
const fila = "fila:emails";
// Adiciona tarefas na fila (RPUSH para inserir no final)
_ = try client.rpush(fila, "enviar:boas-vindas:usuario@email.com");
_ = try client.rpush(fila, "enviar:confirmacao:admin@email.com");
_ = try client.rpush(fila, "enviar:relatorio:gerente@email.com");
std.debug.print("3 tarefas adicionadas à fila\n", .{});
// Verifica o tamanho da fila
const tamanho = try client.llen(fila);
std.debug.print("Tamanho da fila: {d}\n", .{tamanho});
// Lista todas as tarefas pendentes
if (try client.lrange(fila, 0, -1)) |items| {
std.debug.print("Tarefas pendentes:\n", .{});
for (items, 0..) |item, i| {
switch (item) {
.bulk_string => |maybe_val| {
if (maybe_val) |val| {
std.debug.print(" [{d}] {s}\n", .{ i, val });
}
},
else => {},
}
}
}
// Processa tarefas uma a uma (LPOP para consumir do início)
std.debug.print("\nProcessando tarefas:\n", .{});
while (try client.lpop(fila)) |tarefa| {
std.debug.print(" Processando: {s}\n", .{tarefa});
// Aqui você processaria cada tarefa de verdade
}
std.debug.print("Fila vazia, todas as tarefas processadas\n", .{});
}
7. Hashes: HSET, HGET, HGETALL
Os hashes do Redis são mapas de campos para valores, ideais para representar objetos. Em vez de serializar um objeto inteiro em uma string JSON, você pode armazenar cada campo separadamente, permitindo atualizações parciais e consultas eficientes.
HSET: definindo campos
/// Define um campo em um hash. Retorna 1 se o campo foi criado,
/// 0 se o campo já existia e foi atualizado.
pub fn hset(
self: *RedisClient,
key: []const u8,
field: []const u8,
value: []const u8,
) !i64 {
const args = &[_][]const u8{ "HSET", key, field, value };
const result = try self.sendCommand(args);
switch (result) {
.integer => |val| return val,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
/// Define múltiplos campos de um hash de uma vez.
pub fn hsetMultiple(
self: *RedisClient,
key: []const u8,
fields_and_values: []const [2][]const u8,
) !void {
var args = std.ArrayList([]const u8).init(self.allocator);
defer args.deinit();
try args.append("HSET");
try args.append(key);
for (fields_and_values) |pair| {
try args.append(pair[0]); // campo
try args.append(pair[1]); // valor
}
const result = try self.sendCommand(args.items);
switch (result) {
.integer => {},
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
HGET: recuperando um campo
/// Retorna o valor de um campo específico do hash.
/// Retorna null se a chave ou o campo não existirem.
pub fn hget(
self: *RedisClient,
key: []const u8,
field: []const u8,
) !?[]const u8 {
const args = &[_][]const u8{ "HGET", key, field };
const result = try self.sendCommand(args);
switch (result) {
.bulk_string => |maybe_value| return maybe_value,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
HGETALL: recuperando todos os campos
/// Retorna todos os campos e valores do hash como array alternado
/// [campo1, valor1, campo2, valor2, ...].
pub fn hgetall(self: *RedisClient, key: []const u8) !?[]RespValue {
const args = &[_][]const u8{ "HGETALL", key };
const result = try self.sendCommand(args);
switch (result) {
.array => |maybe_items| return maybe_items,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
/// HDEL: remove um campo do hash.
pub fn hdel(
self: *RedisClient,
key: []const u8,
field: []const u8,
) !i64 {
const args = &[_][]const u8{ "HDEL", key, field };
const result = try self.sendCommand(args);
switch (result) {
.integer => |val| return val,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
/// HEXISTS: verifica se um campo existe no hash.
pub fn hexists(
self: *RedisClient,
key: []const u8,
field: []const u8,
) !bool {
const args = &[_][]const u8{ "HEXISTS", key, field };
const result = try self.sendCommand(args);
switch (result) {
.integer => |val| return val == 1,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
Exemplo completo: perfil de usuário
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var client = try RedisClient.init(allocator, "127.0.0.1", 6379);
defer client.deinit();
const chave_perfil = "usuario:42";
// Cria o perfil com múltiplos campos de uma vez
const campos = [_][2][]const u8{
.{ "nome", "Maria Silva" },
.{ "email", "maria@exemplo.com" },
.{ "cidade", "São Paulo" },
.{ "profissao", "Engenheira de Software" },
.{ "linguagem_favorita", "Zig" },
};
try client.hsetMultiple(chave_perfil, &campos);
std.debug.print("Perfil criado com {d} campos\n", .{campos.len});
// Recupera um campo específico
if (try client.hget(chave_perfil, "nome")) |nome| {
std.debug.print("Nome: {s}\n", .{nome});
}
// Atualiza um campo existente
_ = try client.hset(chave_perfil, "cidade", "Rio de Janeiro");
std.debug.print("Cidade atualizada\n", .{});
// Verifica se um campo existe
const tem_telefone = try client.hexists(chave_perfil, "telefone");
std.debug.print("Tem telefone? {}\n", .{tem_telefone});
// Lista todos os campos e valores
if (try client.hgetall(chave_perfil)) |items| {
std.debug.print("\nPerfil completo:\n", .{});
var i: usize = 0;
while (i + 1 < items.len) : (i += 2) {
const campo = switch (items[i]) {
.bulk_string => |v| v orelse "(nil)",
else => "(erro)",
};
const valor = switch (items[i + 1]) {
.bulk_string => |v| v orelse "(nil)",
else => "(erro)",
};
std.debug.print(" {s}: {s}\n", .{ campo, valor });
}
}
// Remove um campo
_ = try client.hdel(chave_perfil, "profissao");
std.debug.print("\nCampo 'profissao' removido\n", .{});
}
8. Pub/Sub: Publicar e Assinar
O mecanismo de Pub/Sub do Redis permite que clientes publiquem mensagens em canais e que outros clientes assinem esses canais para receber as mensagens em tempo real. Isso é útil para sistemas de notificação, chat e eventos distribuídos.
Importante sobre Pub/Sub
No Redis, um cliente que está em modo de assinatura (SUBSCRIBE) não pode executar outros comandos. Por isso, precisamos de conexões separadas: uma para publicar e outra para assinar.
Assinante (Subscriber)
const std = @import("std");
const net = std.net;
pub const RedisSubscriber = struct {
stream: net.Stream,
allocator: std.mem.Allocator,
read_buffer: [16384]u8,
pub fn init(allocator: std.mem.Allocator, host: []const u8, port: u16) !RedisSubscriber {
const address = try net.Address.resolveIp(host, port);
const stream = try net.tcpConnectToAddress(address);
return RedisSubscriber{
.stream = stream,
.allocator = allocator,
.read_buffer = undefined,
};
}
pub fn deinit(self: *RedisSubscriber) void {
self.stream.close();
}
/// Assina um canal. Após esta chamada, o cliente entra em modo
/// de assinatura e só pode receber mensagens.
pub fn subscribe(self: *RedisSubscriber, channel: []const u8) !void {
const encoder = RespEncoder.init(self.allocator);
const args = &[_][]const u8{ "SUBSCRIBE", channel };
const encoded = try encoder.encodeCommand(args);
defer self.allocator.free(encoded);
_ = try self.stream.write(encoded);
// Lê a confirmação de assinatura
const bytes_read = try self.stream.read(&self.read_buffer);
if (bytes_read == 0) return error.ConnectionClosed;
// A resposta é um array: ["subscribe", canal, contagem]
}
/// Estrutura representando uma mensagem recebida via Pub/Sub.
pub const Message = struct {
channel: []const u8,
payload: []const u8,
};
/// Aguarda e retorna a próxima mensagem publicada nos canais assinados.
/// Esta função bloqueia até que uma mensagem chegue.
pub fn receiveMessage(self: *RedisSubscriber) !Message {
const bytes_read = try self.stream.read(&self.read_buffer);
if (bytes_read == 0) return error.ConnectionClosed;
var parser = RespParser.init(self.allocator, self.read_buffer[0..bytes_read]);
const result = try parser.parse();
// A mensagem Pub/Sub é um array: ["message", canal, conteúdo]
switch (result) {
.array => |maybe_items| {
const items = maybe_items orelse return error.UnexpectedResponse;
if (items.len < 3) return error.UnexpectedResponse;
const channel = switch (items[1]) {
.bulk_string => |v| v orelse return error.UnexpectedResponse,
else => return error.UnexpectedResponse,
};
const payload = switch (items[2]) {
.bulk_string => |v| v orelse return error.UnexpectedResponse,
else => return error.UnexpectedResponse,
};
return Message{
.channel = channel,
.payload = payload,
};
},
else => return error.UnexpectedResponse,
}
}
};
Publicador (Publisher)
/// Publica uma mensagem em um canal.
/// Retorna o número de assinantes que receberam a mensagem.
pub fn publish(self: *RedisClient, channel: []const u8, message: []const u8) !i64 {
const args = &[_][]const u8{ "PUBLISH", channel, message };
const result = try self.sendCommand(args);
switch (result) {
.integer => |count| return count,
.error_msg => return error.RedisError,
else => return error.UnexpectedResponse,
}
}
Exemplo completo: sistema de notificações
Para testar o Pub/Sub, você precisa de dois processos separados. Aqui está o código para cada um:
Processo 1: Assinante
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var subscriber = try RedisSubscriber.init(allocator, "127.0.0.1", 6379);
defer subscriber.deinit();
// Assina o canal de notificações
try subscriber.subscribe("notificacoes:sistema");
std.debug.print("Aguardando notificações no canal 'notificacoes:sistema'...\n", .{});
// Loop de escuta: recebe mensagens até o canal ser fechado
while (true) {
const msg = subscriber.receiveMessage() catch |err| {
switch (err) {
error.ConnectionClosed => {
std.debug.print("Conexão encerrada\n", .{});
break;
},
else => {
std.debug.print("Erro ao receber mensagem: {}\n", .{err});
continue;
},
}
};
std.debug.print("[{s}] {s}\n", .{ msg.channel, msg.payload });
}
}
Processo 2: Publicador
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var client = try RedisClient.init(allocator, "127.0.0.1", 6379);
defer client.deinit();
const mensagens = [_][]const u8{
"Deploy da versão 2.5.0 iniciado",
"Backup do banco de dados concluído",
"Alerta: uso de CPU acima de 90%",
"Deploy da versão 2.5.0 concluído com sucesso",
};
for (mensagens) |msg| {
const assinantes = try client.publish("notificacoes:sistema", msg);
std.debug.print("Mensagem enviada para {d} assinante(s): {s}\n", .{ assinantes, msg });
// Pausa de 1 segundo entre mensagens
std.time.sleep(1_000_000_000);
}
std.debug.print("Todas as notificações enviadas\n", .{});
}
9. Padrão de Cache
O padrão cache-aside (também chamado de lazy loading) é a estratégia de cache mais comum. A aplicação primeiro verifica o cache; se houver um hit, retorna o valor diretamente. Se houver um miss, busca os dados na fonte original, armazena no cache e retorna ao cliente.
Implementação do cache-aside
const std = @import("std");
/// Função genérica que implementa o padrão cache-aside.
/// Verifica o Redis primeiro; se não encontrar, chama a função de fallback
/// para obter os dados da fonte original e armazena no cache.
pub fn cacheAside(
client: *RedisClient,
key: []const u8,
ttl_seconds: u32,
/// Função que busca os dados da fonte original (banco de dados, API, etc.)
comptime fetchFn: fn ([]const u8) anyerror!?[]const u8,
) !?[]const u8 {
// 1. Tenta obter do cache
if (try client.get(key)) |cached_value| {
std.debug.print("[CACHE HIT] {s}\n", .{key});
return cached_value;
}
std.debug.print("[CACHE MISS] {s}\n", .{key});
// 2. Busca da fonte original
const value = try fetchFn(key) orelse return null;
// 3. Armazena no cache com TTL
try client.setex(key, ttl_seconds, value);
std.debug.print("[CACHE SET] {s} (TTL: {d}s)\n", .{ key, ttl_seconds });
return value;
}
Cache com invalidação
pub const CacheManager = struct {
client: *RedisClient,
default_ttl: u32,
prefix: []const u8,
pub fn init(client: *RedisClient, prefix: []const u8, default_ttl: u32) CacheManager {
return CacheManager{
.client = client,
.default_ttl = default_ttl,
.prefix = prefix,
};
}
/// Gera a chave completa no Redis com o prefixo do cache.
fn makeKey(self: *const CacheManager, allocator: std.mem.Allocator, key: []const u8) ![]u8 {
return std.fmt.allocPrint(allocator, "{s}:{s}", .{ self.prefix, key });
}
/// Busca um valor do cache.
pub fn fetch(self: *CacheManager, key: []const u8) !?[]const u8 {
return self.client.get(key);
}
/// Armazena um valor no cache com o TTL padrão.
pub fn store(self: *CacheManager, key: []const u8, value: []const u8) !void {
try self.client.setex(key, self.default_ttl, value);
}
/// Armazena um valor no cache com um TTL personalizado.
pub fn storeWithTtl(self: *CacheManager, key: []const u8, value: []const u8, ttl: u32) !void {
try self.client.setex(key, ttl, value);
}
/// Invalida (remove) uma entrada específica do cache.
pub fn invalidate(self: *CacheManager, key: []const u8) !void {
_ = try self.client.del(key);
}
/// Invalida múltiplas entradas do cache.
pub fn invalidateMultiple(self: *CacheManager, keys: []const []const u8) !void {
_ = try self.client.delMultiple(keys);
}
};
Exemplo completo: cache de consultas
const std = @import("std");
/// Simula uma consulta lenta ao banco de dados.
fn consultarBancoDeDados(id: []const u8) ![]const u8 {
std.debug.print(" [DB] Consultando banco de dados para ID: {s}...\n", .{id});
// Simula latência de banco de dados
std.time.sleep(100_000_000); // 100ms
return "{\"nome\": \"Produto Exemplo\", \"preco\": 29.90}";
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var client = try RedisClient.init(allocator, "127.0.0.1", 6379);
defer client.deinit();
var cache = CacheManager.init(&client, "produto", 300); // TTL de 5 minutos
const produto_id = "produto:1001";
// Primeira consulta: cache miss, busca do banco
std.debug.print("Primeira consulta:\n", .{});
var timer = try std.time.Timer.start();
const resultado1 = try cache.fetch(produto_id);
if (resultado1 == null) {
const dados = try consultarBancoDeDados(produto_id);
try cache.store(produto_id, dados);
std.debug.print(" Resultado: {s}\n", .{dados});
} else {
std.debug.print(" Resultado (cache): {s}\n", .{resultado1.?});
}
const tempo1 = timer.read();
std.debug.print(" Tempo: {d}ms\n\n", .{tempo1 / 1_000_000});
// Segunda consulta: cache hit, retorna direto do Redis
std.debug.print("Segunda consulta:\n", .{});
timer.reset();
if (try cache.fetch(produto_id)) |cached| {
std.debug.print(" Resultado (cache): {s}\n", .{cached});
}
const tempo2 = timer.read();
std.debug.print(" Tempo: {d}ms\n\n", .{tempo2 / 1_000_000});
// Atualização: invalida o cache após modificação
std.debug.print("Produto atualizado no banco. Invalidando cache...\n", .{});
try cache.invalidate(produto_id);
// Próxima consulta será um cache miss novamente
if (try cache.fetch(produto_id)) |_| {
std.debug.print("Cache ainda ativo (inesperado)\n", .{});
} else {
std.debug.print("Cache invalidado com sucesso\n", .{});
}
}
10. Boas Práticas e Pool de Conexões
Em aplicações de produção, criar uma nova conexão TCP para cada operação Redis é ineficiente. Um pool de conexões mantém um conjunto de conexões prontas para uso, reduzindo a latência e o consumo de recursos.
Implementando um pool de conexões
const std = @import("std");
const net = std.net;
pub const ConnectionPool = struct {
allocator: std.mem.Allocator,
host: []const u8,
port: u16,
available: std.ArrayList(*RedisClient),
max_size: usize,
current_size: usize,
mutex: std.Thread.Mutex,
pub fn init(
allocator: std.mem.Allocator,
host: []const u8,
port: u16,
max_size: usize,
) ConnectionPool {
return ConnectionPool{
.allocator = allocator,
.host = host,
.port = port,
.available = std.ArrayList(*RedisClient).init(allocator),
.max_size = max_size,
.current_size = 0,
.mutex = std.Thread.Mutex{},
};
}
pub fn deinit(self: *ConnectionPool) void {
for (self.available.items) |client| {
client.deinit();
self.allocator.destroy(client);
}
self.available.deinit();
}
/// Obtém uma conexão do pool. Se não houver conexões disponíveis
/// e o pool ainda não atingiu o tamanho máximo, cria uma nova.
pub fn acquire(self: *ConnectionPool) !*RedisClient {
self.mutex.lock();
defer self.mutex.unlock();
// Tenta reutilizar uma conexão existente
if (self.available.popOrNull()) |client| {
return client;
}
// Cria uma nova conexão se possível
if (self.current_size < self.max_size) {
const client = try self.allocator.create(RedisClient);
client.* = try RedisClient.init(self.allocator, self.host, self.port);
self.current_size += 1;
return client;
}
return error.PoolExhausted;
}
/// Devolve uma conexão ao pool para reutilização.
pub fn release(self: *ConnectionPool, client: *RedisClient) void {
self.mutex.lock();
defer self.mutex.unlock();
self.available.append(client) catch {
// Se não conseguir devolver ao pool, fecha a conexão
client.deinit();
self.allocator.destroy(client);
self.current_size -= 1;
};
}
};
Usando o pool de conexões
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Cria o pool com no máximo 10 conexões
var pool = ConnectionPool.init(allocator, "127.0.0.1", 6379, 10);
defer pool.deinit();
// Simula múltiplas operações concorrentes
var threads: [5]std.Thread = undefined;
for (&threads, 0..) |*t, i| {
t.* = try std.Thread.spawn(.{}, workerThread, .{ &pool, i });
}
for (&threads) |*t| {
t.join();
}
std.debug.print("Todas as operações concluídas\n", .{});
}
fn workerThread(pool: *ConnectionPool, thread_id: usize) void {
// Obtém uma conexão do pool
const client = pool.acquire() catch |err| {
std.debug.print("[Thread {d}] Erro ao obter conexão: {}\n", .{ thread_id, err });
return;
};
defer pool.release(client);
// Executa operações com a conexão
var key_buf: [64]u8 = undefined;
const key = std.fmt.bufPrint(&key_buf, "thread:{d}:contador", .{thread_id}) catch return;
client.set(key, "1") catch |err| {
std.debug.print("[Thread {d}] Erro no SET: {}\n", .{ thread_id, err });
return;
};
if (client.get(key) catch null) |valor| {
std.debug.print("[Thread {d}] {s} = {s}\n", .{ thread_id, key, valor });
}
}
Boas práticas gerais
Seguem recomendações importantes para usar Redis com Zig em produção:
Tratamento de erros robusto: Sempre trate erros de conexão e timeout.
fn operacaoSegura(client: *RedisClient) void {
client.set("chave", "valor") catch |err| {
switch (err) {
error.ConnectionClosed => {
std.log.err("Conexão com Redis perdida. Tentando reconectar...", .{});
// Lógica de reconexão aqui
},
error.BrokenPipe => {
std.log.err("Pipe quebrado. Conexão resetada pelo servidor.", .{});
},
else => {
std.log.err("Erro inesperado no Redis: {}", .{err});
},
}
};
}
Prefixos de chave: Use prefixos para organizar os dados e evitar colisões.
const KeyPrefix = struct {
pub const sessao = "sess";
pub const cache = "cache";
pub const fila = "fila";
pub const contador = "cnt";
pub fn format(
allocator: std.mem.Allocator,
prefix: []const u8,
key: []const u8,
) ![]u8 {
return std.fmt.allocPrint(allocator, "{s}:{s}", .{ prefix, key });
}
};
Serialização de dados: Para armazenar estruturas complexas, serialize em JSON.
const Produto = struct {
id: u32,
nome: []const u8,
preco: f64,
estoque: u32,
pub fn toJson(self: *const Produto, allocator: std.mem.Allocator) ![]u8 {
return std.fmt.allocPrint(
allocator,
"{{\"id\":{d},\"nome\":\"{s}\",\"preco\":{d:.2},\"estoque\":{d}}}",
.{ self.id, self.nome, self.preco, self.estoque },
);
}
};
fn armazenarProduto(client: *RedisClient, allocator: std.mem.Allocator, produto: *const Produto) !void {
const json = try produto.toJson(allocator);
defer allocator.free(json);
var key_buf: [64]u8 = undefined;
const key = std.fmt.bufPrint(&key_buf, "produto:{d}", .{produto.id}) catch unreachable;
try client.setex(key, 3600, json); // Cache de 1 hora
}
Timeouts de conexão: Configure timeouts para evitar bloqueios indefinidos.
pub fn connectWithTimeout(host: []const u8, port: u16, timeout_ns: u64) !net.Stream {
const address = try net.Address.resolveIp(host, port);
const stream = try net.tcpConnectToAddress(address);
// Configura timeout de leitura usando setsockopt via o fd do socket
const timeout = std.posix.timeval{
.sec = @intCast(timeout_ns / 1_000_000_000),
.usec = @intCast((timeout_ns % 1_000_000_000) / 1000),
};
try std.posix.setsockopt(
stream.handle,
std.posix.SOL.SOCKET,
std.posix.SO.RCVTIMEO,
std.mem.asBytes(&timeout),
);
return stream;
}
11. Próximos Passos
Parabéns! Agora você tem um cliente Redis funcional implementado do zero em Zig. Com esse conhecimento, você pode construir sistemas de cache de alta performance, filas de mensagens e mecanismos de comunicação em tempo real.
Recapitulação
Neste guia, abordamos:
- Conexão TCP direta ao Redis usando
std.net - Protocolo RESP completo: encoding e parsing
- Comandos básicos: GET, SET, DEL, EXISTS
- Expiração: SETEX, EXPIRE, TTL, PERSIST
- Listas: LPUSH, RPUSH, LPOP, RPOP, LRANGE
- Hashes: HSET, HGET, HGETALL, HDEL
- Pub/Sub: SUBSCRIBE e PUBLISH
- Cache-aside: padrão de cache com invalidação
- Pool de conexões: gerenciamento de conexões para produção
Explorando mais
Para continuar aprendendo sobre Zig e bancos de dados, recomendamos os seguintes recursos:
Conectando ao PostgreSQL com Zig: Aprenda a implementar um driver PostgreSQL para consultas SQL com Zig, incluindo queries parametrizadas e transações.
SQLite com Zig: Banco de Dados Embarcado: Descubra como usar o SQLite embarcado em aplicações Zig para armazenamento local sem precisar de um servidor externo.
Networking e Sockets em Zig: Aprofunde seus conhecimentos sobre programação de rede em Zig, incluindo servidores TCP/UDP, sockets não-bloqueantes e I/O assíncrono.
Ideias para expandir o cliente
- Suporte a MULTI/EXEC: Implemente transações Redis para executar múltiplos comandos de forma atômica.
- Sentinel e Cluster: Adicione suporte a Redis Sentinel para alta disponibilidade e Redis Cluster para sharding.
- Streams: Explore os Redis Streams (
XADD,XREAD,XGROUP) para processamento de eventos. - Lua scripting: Use
EVALpara executar scripts Lua diretamente no servidor Redis. - Benchmarks: Compare o desempenho do seu cliente Zig com clientes em C, Go e Rust.
O Redis é uma ferramenta versátil e Zig oferece o controle necessário para extrair o máximo de desempenho. Combine os conceitos deste guia com os recursos da biblioteca padrão de Zig para construir sistemas robustos e eficientes.