Índice

Caso de estudio — Cómo se construyó nyx-kv

¿Qué es nyx-kv?

nyx-kv es una base de datos clave-valor compatible con Redis escrita enteramente en Nyx. Habla el protocolo Redis (RESP), así que cualquier cliente Redis — redis-cli, redis-py de Python, ioredis de Node — puede conectarse sin modificación.

Maneja 6.76 millones de operaciones SET por segundo y 21.57 millones de operaciones GET por segundo en benchmarks. Este capítulo recorre cómo fue diseñado y construido, decisión por decisión.

La arquitectura a vista general

                      ┌─────────────┐
  redis-cli ─────────►│  TCP Accept  │
  python    ─────────►│    Loop      │
  node.js   ─────────►│  (main)     │
                      └──────┬──────┘
                             │ channel_send(fd)
                      ┌──────▼──────┐
                      │   Channel   │
                      │ (capacity:  │
                      │   512)      │
                      └──────┬──────┘
                             │ channel_recv(fd)
              ┌──────────────┼──────────────┐
              ▼              ▼              ▼
        ┌──────────┐  ┌──────────┐  ┌──────────┐
        │ Worker 1 │  │ Worker 2 │  │Worker 128│
        │  (RESP   │  │  (RESP   │  │  (RESP   │
        │  parser  │  │  parser  │  │  parser  │
        │  + cmds) │  │  + cmds) │  │  + cmds) │
        └────┬─────┘  └────┬─────┘  └────┬─────┘
             │              │              │
             └──────────────┼──────────────┘
                            ▼
                    ┌───────────────┐
                    │  Global Store │
                    │  (Map + TTL)  │
                    └───────────────┘

Tres componentes:

  1. Accept loop — el thread principal acepta conexiones TCP y distribuye file descriptors via un channel.
  2. Worker pool — 128 threads leen comandos de los clientes y los ejecutan.
  3. Global store — un Map para datos y un Map para TTL (time-to-live) de expiración.

Decisión 1: ¿Por qué una arquitectura basada en channels?

El modelo de servidor más simple es "un thread por conexión." Pero crear un thread para cada conexión es costoso. Redis mismo usa un event loop de un solo thread.

nyx-kv toma un camino intermedio: un pool fijo de 128 workers conectados al accept loop via un channel. Esto da:

fn main() {
    g_ch = channel_new(512)
    let server: int = tcp_listen("0.0.0.0", port)

    // Pre-crear todos los workers
    var i: int = 0
    while i < 128 {
        thread_spawn(kv_worker)
        i = i + 1
    }

    // Accept loop
    while 1 > 0 {
        let client: int = tcp_accept(server)
        if client >= 0 {
            channel_send(g_ch, client)
        }
    }
}

Decisión 2: El protocolo RESP

Redis usa un protocolo llamado RESP (REdis Serialization Protocol). Es basado en texto y simple:

*3\r\n          ← array de 3 elementos
$3\r\n          ← bulk string de 3 bytes
SET\r\n         ← el string "SET"
$4\r\n          ← bulk string de 4 bytes
name\r\n        ← el string "name"
$5\r\n          ← bulk string de 5 bytes
Alice\r\n       ← el string "Alice"

Respuestas:

El parser (resp.nx) lee este formato del socket:

fn resp_read_command(fd: int) -> Array {
    // Leer *N para obtener la cantidad de argumentos
    // Para cada argumento, leer $len y luego los datos
    // Retornar array de strings: ["SET", "name", "Alice"]
}

La optimización clave: el parser también soporta comandos inline (separados por espacios), así que comandos de redis-cli como SET name Alice funcionan directamente.

Decisión 3: La capa de almacenamiento

El almacenamiento más simple posible: dos Maps globales.

var g_store: Map = Map.new()     // clave → valor
var g_ttl: Map = Map.new()       // clave → expiración (microsegundos)

¿Por qué Maps y no una estructura de datos personalizada?

Expiración lazy

nyx-kv no ejecuta un thread de fondo para expirar claves. En cambio, verifica la expiración de forma lazy — cada vez que se accede a una clave:

fn kv_check_expired(key: String) -> bool {
    if g_ttl.contains(key) {
        let expires: int = string_to_int(g_ttl.get(key))
        if expires > 0 and time_us() >= expires {
            g_store.remove(key)
            g_ttl.remove(key)
            return true
        }
    }
    return false
}

Esta es la misma estrategia que usa Redis. Evita el overhead de un escáner de fondo y mantiene la implementación simple.

Decisión 4: Optimización del dispatch de comandos

Los benchmarks mostraron que SET y GET representan ~95% del tráfico. Así que el dispatcher de comandos usa matching por primer carácter para el camino rápido:

fn dispatch_command(cmd: Array, fd: int) -> String {
    let first: int = cmd_name.charAt(0)

    // Camino rápido: SET (S=83, s=115)
    if first == 83 or first == 115 {
        if cmd_name == "SET" or cmd_name == "set" {
            kv_set(cmd[1], cmd[2])
            return RESP_OK    // "+OK\r\n" cacheado
        }
    }

    // Camino rápido: GET (G=71, g=103)
    if first == 71 or first == 103 {
        if cmd_name == "GET" or cmd_name == "get" {
            // Escribir directamente al socket — cero allocations
            resp_write_bulk(fd, kv_get(cmd[1]))
            return ""    // respuesta ya enviada
        }
    }

    // Camino lento: normalizar y comparar otros comandos
    // ...
}

Optimizaciones clave:

Decisión 5: Respuestas GET sin allocations

La mayor ganancia de rendimiento fue eliminar allocations de las respuestas GET. En lugar de:

// Alloca un nuevo string cada vez
let response: String = resp_bulk_string(value)
tcp_write(client, response)

nyx-kv escribe el framing RESP directamente al socket:

fn resp_write_bulk(fd: int, value: String) {
    // Escribir "$<len>\r\n<value>\r\n" directamente al fd
    tcp_write(fd, "$")
    tcp_write(fd, int_to_string(value.length()))
    tcp_write(fd, "\r\n")
    tcp_write(fd, value)
    tcp_write(fd, "\r\n")
}

Esto evita crear un string intermedio, reduciendo la presión sobre el GC en el camino caliente.

Comandos soportados

AUTH y multi-tenancy

nyx-kv soporta aislamiento multi-tenant mediante tokens de autenticación. Cada conexión puede autenticarse con AUTH <token>, lo que asigna la conexión a un namespace de usuario.

$ redis-cli -p 6380
127.0.0.1:6380> AUTH abc123def456
OK
127.0.0.1:6380> WHOAMI
"alice:pro"
127.0.0.1:6380> SET name Alice     ← almacenado como alice::name internamente
OK

Los tokens se crean por un admin desde localhost:

127.0.0.1:6380> TOKEN_CREATE alice pro
"abc123def456..."

Tres planes controlan los límites de recursos:

PlanRateMax keysMax valorTTL forzado
free100 req/s1,000100 KB72 horas
pro10,000 req/s100,0001 MBNinguno
enterpriseilimitadoilimitadoilimitadoNinguno

Las conexiones sin AUTH reciben el free tier automáticamente, identificadas por IP. El aislamiento de namespaces es transparente — todas las operaciones de claves pasan por auth_prefix_key, que antepone el ID del usuario.

Pub/Sub

nyx-kv soporta el patrón de mensajería publish/subscribe. Los suscriptores registran interés en canales, y los publicadores transmiten mensajes a todos los suscriptores de un canal.

$ redis-cli -p 6380
127.0.0.1:6380> SUBSCRIBE notifications
Reading messages...
1) "subscribe"
2) "notifications"
3) (integer) 1

Desde otra terminal:

$ redis-cli -p 6380
127.0.0.1:6380> PUBLISH notifications "user signed up"
(integer) 1

El suscriptor recibe:

1) "message"
2) "notifications"
3) "user signed up"

Esto es fan-out delivery — cada suscriptor en un canal recibe cada mensaje. A diferencia de las colas de mensajes, no hay persistencia ni acknowledgment. Una vez que un suscriptor entra en modo SUBSCRIBE, solo se aceptan los comandos SUBSCRIBE, UNSUBSCRIBE y PING.

Persistencia (formato .ndb)

nyx-kv persiste datos a disco usando un formato binario .ndb. El formato comienza con un header mágico NYXDB, un byte de versión, luego las entradas clave-valor, seguido por un checksum CRC32 y un marcador de fin de archivo 0xFF.

La persistencia está siempre activa:

Al iniciar, persist_load lee el archivo .ndb y restaura todas las claves, listas, sets y hashes a memoria.

Comandos soportados

nyx-kv implementa 52+ comandos en strings, listas, sets, hashes, Pub/Sub y gestión del servidor:

Comando Descripción
PING Health check, retorna PONG
SET key value Almacenar un valor
GET key Recuperar un valor
DEL key [key...] Eliminar claves
EXISTS key Verificar si una clave existe
KEYS Listar todas las claves
EXPIRE key seconds Establecer tiempo de vida
TTL key Obtener TTL restante
INCR key Incremento atómico
DECR key Decremento atómico
MSET k1 v1 k2 v2... Set masivo
MGET k1 k2... Get masivo
DBSIZE Cantidad de claves
FLUSHDB Limpiar todos los datos
INFO Información del servidor
CONFIG/COMMAND Stubs de compatibilidad Redis

Resultados de rendimiento

Testeado con redis-benchmark:

SET: 6,760,000 ops/seg (pipelined)
GET: 21,570,000 ops/seg (pipelined)
SET: 161,000 ops/seg (sin pipeline)
GET: 170,000 ops/seg (sin pipeline)

Para contexto, Redis mismo alcanza alrededor de 100,000 ops/seg sin pipeline y 1-2 millones pipelined en hardware similar.

Lecciones aprendidas

  1. Empezar simple, optimizar después. La primera versión usaba concatenación de strings para respuestas. El profiling mostró que ese era el cuello de botella. Solo entonces se agregó el GET sin allocations.
  1. El patrón de channels escala. 128 workers + 1 accept loop maneja miles de conexiones concurrentes limpiamente.
  1. Los Maps de Nyx son suficientemente rápidos. No hay necesidad de una tabla hash personalizada — Robin Hood hashing en el runtime maneja 21M+ ops/seg.
  1. La expiración lazy funciona. Un timer de fondo agregaría complejidad. Verificar en el acceso es simple y correcto.
  1. La compatibilidad de protocolo importa. Al implementar RESP, nyx-kv funciona con todos los clientes Redis en todos los lenguajes — gratis.

El módulo main completo

import "products/kv/resp"
import "products/kv/store"
import "products/kv/commands"

var g_ch: Map = Map.new()

fn kv_worker() -> int {
    while 1 > 0 {
        let client: int = channel_recv(g_ch)
        if client < 0 { return 0 }
        g_connections = g_connections + 1

        var connected: bool = true
        while connected {
            let cmd: Array = resp_read_command_fast(client)
            if cmd.length() == 0 {
                connected = false
            } else {
                let response: String = dispatch_command(cmd, client)
                if response.length() > 0 {
                    tcp_write(client, response)
                }
            }
        }
        tcp_close(client)
    }
    return 0
}

fn main() {
    let port: int = 6380
    let num_workers: int = 128

    g_ch = channel_new(512)
    let server: int = tcp_listen("0.0.0.0", port)

    var i: int = 0
    while i < num_workers {
        thread_spawn(kv_worker)
        i = i + 1
    }

    while 1 > 0 {
        let client: int = tcp_accept(server)
        if client >= 0 {
            channel_send(g_ch, client)
        }
    }
    return 0
}

72 líneas de código para el módulo principal. La base de datos completa son unas 400 líneas en 4 archivos.

Ejercicios

  1. Extiende nyx-kv con un comando APPEND key value que concatene a un valor existente.
  1. Agrega un comando SETNX key value que solo establezca la clave si no existe ya.
  1. Implementa un comando RANDOMKEY que retorne una clave aleatoria del store.
  1. Agrega persistencia: guardar el store a un archivo en FLUSHDB y cargarlo al iniciar.
  1. Construye tu propia mini base de datos: elige un protocolo (HTTP, TCP raw, o personalizado), un modelo de datos, e impleméntalo usando los patrones de este capítulo.

Resumen

← Volver al Índice

← Anterior: Sistemas — Assembly inline, volatile, atomic Siguiente: El gestor de paquetes →