100K peticiones por segundo: caché single-flight, hash routing y un escalador propio

TL;DR — Una caché in-process con protocolo single-flight, combinada con enrutamiento basado en hash, una segunda capa de caché compartida y escalado horizontal, se validó en 2023 hasta 100.000 peticiones por segundo en Kubernetes, en entornos controlados, con una latencia p99 inferior a 500 ms. Este post presenta la arquitectura, el modelo de concurrencia y las lecciones operativas aprendidas.

Qué se midió y dónde. Las cifras de carga (hasta 100K RPS, p99 < 500 ms) proceden de pruebas realizadas en 2023 en entornos controlados con la implementación en Go (gateway_cache), no de tráfico real de producción. Esa implementación sí opera en producción desde hace más de cinco años en la plataforma de datos deportivos de Genius Sports. cpp-cache es la implementación en C++20 del mismo protocolo; sus mediciones de 2026, tomadas aparte y en un entorno declarado, están en Benchmarks; no le atribuimos las cifras de 2023. La arquitectura, lo que se midió y quién lo hizo están resumidos en la ficha de experiencia Caché y arquitectura para una API de apuestas deportivas.


El problema central: thundering herd en las cachés

Considera cualquier operación costosa: una consulta a la base de datos, una llamada a una API externa o un cómputo complejo. Sin coordinación, múltiples hilos que soliciten la misma clave simultáneamente dispararán la operación costosa de forma independiente.

Este es el problema que resuelve cpp-cache mediante la implementación de un Escudo contra el Thundering Herd. Para entender por qué este mecanismo es crítico para la estabilidad del sistema más allá del simple almacenamiento de datos, recomendamos leer nuestro post anterior: Cuando una caché hace más que solo caché.


Arquitectura del sistema

El sistema completo consta de tres capas trabajando en concierto. El diagrama representa la arquitectura con la que se obtuvieron las cifras, donde la caché L1 de cada worker es gateway_cache (Go); cpp-cache implementa el mismo diseño en C++20 y ocupa el mismo lugar en la arquitectura.

flowchart TD
    Client["🌐 Clientes\n(navegadores, móviles, servicios)"]
    LB["⚖️ Load Balancer / API Gateway\n(AWS ALB · NGINX · Envoy)"]
    Router["🔀 Hash Router\n(anillo de hashing consistente)"]

    subgraph GW ["Capa de Gateway — N procesos/pods de trabajo"]
        GW1["Worker 1\ncaché L1 · shard A"]
        GW2["Worker 2\ncaché L1 · shard B"]
        GWN["Worker N\ncaché L1 · shard Z"]
    end

    L2["🧱 Caché L2 compartida\n(ElastiCache Redis)"]
    Backend["🗄️ Backend de Origen\n(base de datos · microservicio · API externa)"]

    Client -->|HTTP/gRPC| LB
    LB -->|cualquier enrutamiento| Router
    Router -->|key hash → shard| GW1
    Router -->|key hash → shard| GW2
    Router -->|key hash → shard| GWN
    GW1 -->|fallo en L1| L2
    GW2 -->|fallo en L1| L2
    GWN -->|fallo en L1| L2
    L2 -->|fallo en L2| Backend

El equilibrador de carga distribuye las conexiones; mientras no cambien ni la membresía del anillo ni el proceso de cada worker, el enrutador de hash asegura que una clave determinada caiga siempre en el mismo proceso de trabajo y, por lo tanto, en la misma instancia de la caché L1. Cuando se añaden o retiran workers, el hashing consistente reasigna solo una fracción de las claves (del orden de 1/N), no todas; esas claves empiezan en frío en su nuevo worker. Y como la caché L1 vive en la memoria del proceso, un worker que se reinicia conserva sus claves asignadas pero vuelve con la caché vacía. Esta afinidad de enrutamiento convierte los fallos de caché aleatorios en una tasa de aciertos casi determinista, limitada por la capacidad de cada instancia (expulsión LRU) y por la expiración de las entradas (TTL).


Capa 1 — cpp-cache: arquitectura y modelo de concurrencia

cpp-cache es una caché in-process de C++20 (de solo cabeceras) construida sobre las estructuras de datos de Aleph-w. Implementa el mismo modelo de concurrencia diseñado en C++ en 1998 y que funciona en producción desde hace más de cinco años en su versión en Go, gateway_cache.

Componentes internos

Tres decisiones clave de implementación impulsan el rendimiento:

  • OLhashTable (direccionamiento abierto con sondeo lineal) ofrece búsquedas amigables para la caché sin saltos de punteros.
  • Mutex global breve + mutex por entrada con variable de condición — el mutex global protege solo la tabla y la lista LRU durante operaciones estructurales de microsegundos (búsqueda, inserción, reordenación), también en los aciertos; nunca se mantiene mientras se calcula un valor. La espera por un cálculo en curso ocurre en el mutex de la entrada, así que solo esperan entre sí los hilos que piden la misma clave.
  • Estadísticas libres de bloqueos mediante contadores std::atomic<size_t> — registrar métricas no añade contención sobre ningún mutex.

Máquina de estados de la entrada

Cada entrada de la caché transita por una máquina de estados bien definida que es el corazón de la prevención del thundering herd. El estado COMPUTING es el escudo: mientras un “solver” se ejecuta, cualquier otro hilo que solicite la misma clave encuentra el estado COMPUTING y espera en la variable de condición de la entrada en lugar de lanzar un cómputo duplicado. Cuando el solver termina, todos los hilos en espera son notificados atómicamente y comparten el resultado.


La implementación de producción en Go: gateway_cache y gw_cache

El protocolo de concurrencia descrito anteriormente es anterior a ambas implementaciones: fue diseñado en C++ en 1998. En 2020 se publicó su versión en Go, gateway_cache, que lleva más de cinco años funcionando en producción en la plataforma de datos deportivos de Genius Sports; en 2023 su capacidad se validó hasta 100K RPS en entornos controlados. Dos repositorios de Go mantienen este legado:

  • gateway_cache — la biblioteca en Go publicada en 2020 por Genius Sports y usada en producción; la copia del autor, con desarrollo posterior, está en lrleon/gateway_cache.
  • gw_cache — una variante independiente con una estructura más limpia.

Elegir entre Go y C++

Ambas implementaciones siguen el mismo protocolo de concurrencia, cuyo modelo formal en Promela/SPIN está en el repositorio (ver el artículo anterior y la verificación formal con SPIN). En la práctica: comienza con Go si tu servicio ya está basado en Go — la integración es trivial y el historial en producción es largo. Considera C++ si estás integrando la caché en un binario nativo o si las pausas del recolector de basura (GC) son inaceptables para tus objetivos de latencia. Evitar el GC ayuda a conseguir un p99 sub-milisegundo, pero no lo garantiza por sí solo: influyen la carga, el camino crítico, la memoria, la sincronización y la infraestructura.


Capa 2 — Hash routing

El enrutamiento por hash de clave transforma una flota de cachés independientes en una caché lógicamente particionada y colectivamente enorme. Sin él, cada trabajador cachea todo el espacio de claves de forma independiente: se desperdicia memoria y la carga del backend se multiplica con el tamaño de la flota.

Con 150 nodos virtuales por nodo físico, el reparto de claves entre nodos se desvía menos de un 5 % de la media para distribuciones de claves típicas. Ojo: un reparto equilibrado de claves no garantiza un reparto equilibrado de trabajo. Una clave muy caliente concentra todo su tráfico en un único worker, y ese caso hay que vigilarlo aparte.

Multiplicación efectiva de capacidad

Sin enrutamiento, N pods con capacidad C ofrecen una capacidad de caché efectiva de aproximadamente C (cada pod cachea las mismas claves calientes). Con enrutamiento por hash, la capacidad se acerca a N × C.


Capa 3 — El escalador predictivo

La configuración más habitual del Horizontal Pod Autoscaler (HPA) de Kubernetes escala por CPU o memoria, y en nuestro caso reaccionaba demasiado tarde a picos rápidos de tráfico. HPA también admite métricas personalizadas y externas, como RPS; la comparación de este artículo es con HPA configurado por CPU/memoria, no con cualquier configuración posible. Nosotros utilizamos un escalador propio.

Cómo funciona: realimentación tipo PID + anticipación por tendencia

El escalador combina dos mecanismos distintos. Un controlador de bucle cerrado (tipo PID) corrige el número de réplicas según la diferencia entre las peticiones por segundo (RPS) observadas y la capacidad disponible; por sí solo es reactivo y no predice la demanda. La parte anticipatoria es un componente aparte que extrapola la tendencia. Su eficacia se basa en dos factores:

  1. Conocimiento determinista de la capacidad: Hemos medido empíricamente cuántas RPS puede sostener un solo pod sin degradar la latencia.
  2. Lógica Anticipatoria: Al analizar las tendencias históricas y la velocidad actual de crecimiento del tráfico, el escalador “pronostica” el número de réplicas necesarias y las aprovisiona antes de que el tráfico alcance el pico.

Esto reduce los picos de latencia por “arranque en frío” típicos del escalado puramente reactivo y la probabilidad de que la flota quede subdimensionada ante un pico.


Lecciones de producción

  1. El enrutamiento por hash es el multiplicador de fuerza — la afinidad de clave es más impactante que cualquier aumento de tamaño de caché.
  2. TTL negativo + circuit breaker — pareja esencial; uno evita avalanchas por claves inexistentes, el otro limita la degradación del backend.
  3. Escalar por la señal correcta — en nuestro caso, escalar por RPS y tendencia funcionó claramente mejor que escalar por CPU. Con HPA, eso implica configurar métricas personalizadas en lugar de quedarse con CPU/memoria.
  4. El failover es el eslabón más débil — Kubernetes reprograma los pods sin considerar el estado de la caché; el pico de caché fría es el mayor riesgo operativo.
  5. El periodo de calentamiento (warmup) importa — sube el tráfico linealmente a los nuevos pods durante 2–5 minutos.

Resumen

CapaTecnologíaContribución clave
Caché L1gateway_cache (Go) en producción; cpp-cache (C++20) como implementación nativaSingle-flight, LRU, ~50 µs de latencia
EnrutamientoHashing consistente (xxHash, 150 nodos virtuales)Afinidad de clave, capacidad efectiva N×
Caché L2ElastiCache RedisAlmacén compartido, ~500 µs de latencia
OrigenAurora + microserviciosRecibe ≈ 0,25 % del tráfico total a 100K RPS
ScalerEscalador propioRealimentación tipo PID sobre RPS + anticipación por tendencia

En las pruebas de 2023 en entornos controlados, la combinación alcanzó 100K RPS con p99 por debajo de 500 ms y más del 95 % de aciertos en L1. El origen recibe mucho menos que la tasa de fallos de L1 porque la mayoría de esos fallos se resuelven en L2 (Redis): alrededor del 0,25 % del tráfico total llega al origen.


En SIMYL Research diseñamos y construimos sistemas como este, desde la arquitectura de alto rendimiento hasta las operaciones en Kubernetes: desarrollo de backends y APIs de alto rendimiento y consultoría técnica en rendimiento y arquitectura. Si tu backend sufre bajo carga, el diagnóstico de rendimiento y arquitectura es un primer paso con alcance cerrado y precio de referencia.


Tags: C++20 caching alto-rendimiento sistemas-distribuidos hash-routing kubernetes aws backend-engineering