Hot key y big key en Redis: detección y gestión

1. Descripción general

Redis procesa comandos en un único thread principal. Por eso, incluso una sola clave muy grande (big key) o una clave accedida excesivamente (hot key) puede ralentizar toda la instancia. En este artículo, aprenderás a detectar ambos tipos de claves usando herramientas nativas de Redis y código Java. Luego, los gestionaremos en una aplicación Spring Boot 3.x mediante técnicas de particionamiento de claves, eliminación segura, caché local y replicación de claves.

2. ¿Qué son hot key y big key?

Big key es una clave que consume mucha memoria o contiene demasiados elementos. No hay un umbral estándar, pero muchos equipos utilizan estos puntos de referencia:

  • Strings mayores de aproximadamente 10 KB.
  • Hash, List, Set, ZSet con más de aproximadamente 5.000 elementos, o un tamaño total superior a varios MB.

Hot key es una clave que recibe una tasa de solicitudes anormalmente alta comparada con otras claves. Ejemplo: claves que contienen información de ventas flash, página de inicio o configuración compartida.

Estos dos tipos de claves causan los siguientes problemas:

  • Bloqueo del thread principal: HGETALL en un hash de un millón de campos, o DEL en un ZSet gigantesco, puede bloquear Redis cientos de milisegundos. Todos los demás clientes deben esperar.
  • Congestión de ancho de banda: leer un valor de 2 MB con 5.000 solicitudes/segundo equivale a aproximadamente 10 GB/segundo.
  • Desequilibrio en Redis Cluster: una clave siempre reside en un slot, es decir, en un nodo. Un hot key o big key concentra la carga en ese nodo, sin importar cuántos nodos tenga el cluster.
  • Replicación y failover lentos: los big keys hacen que la sincronización y carga del RDB tarden más.

3. Detectar big keys

3.1. Usando redis-cli --bigkeys y --memkeys

La forma más rápida es usar las opciones nativas de redis-cli:

redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01

Este comando recorre todo el espacio de claves usando SCAN, así que no bloquea el servidor como lo haría KEYS *. El parámetro -i 0.01 espera 10 ms después de cada 100 comandos SCAN, ayudando a reducir la presión en el servidor de producción. El resultado se ve así:

[00.00%] Biggest hash   found so far '"cart:10023"' with 48211 fields
[12.35%] Biggest string found so far '"product:detail:88"' with 1048576 bytes
[45.10%] Biggest zset   found so far '"leaderboard:global"' with 920144 members

-------- summary -------
Biggest   hash found '"cart:10023"' has 48211 fields
Biggest string found '"product:detail:88"' has 1048576 bytes
Biggest   zset found '"leaderboard:global"' has 920144 members

--bigkeys mide por número de elementos. Solo reporta la clave más grande de cada tipo de dato. Si te preocupa el consumo real de memoria, usa --memkeys:

redis-cli --memkeys --memkeys-samples 0

--memkeys-samples 0 indica a Redis que mida todos los elementos en lugar de hacer muestreo. Este enfoque es más preciso pero más lento.

3.2. Escaneo con Java usando MEMORY USAGE

redis-cli solo devuelve la clave más grande de cada tipo. Para listar todas las claves que superan un umbral, puedes hacer tu propio escaneo. Primero, añade la dependencia:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

A continuación, escribe un componente que escanee el espacio de claves:

@Component
public class BigKeyScanner {

    private final StringRedisTemplate redis;

    public BigKeyScanner(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public Map<String, Long> findKeysLargerThan(long thresholdBytes) {
        Map<String, Long> result = new LinkedHashMap<>();
        ScanOptions options = ScanOptions.scanOptions().match("*").count(500).build();

        try (Cursor<String> cursor = redis.scan(options)) {
            while (cursor.hasNext()) {
                String key = cursor.next();
                Long bytes = redis.execute((RedisCallback<Long>) conn ->
                        (Long) conn.execute("MEMORY",
                                "USAGE".getBytes(), key.getBytes(), "SAMPLES".getBytes(), "0".getBytes()));
                if (bytes != null && bytes > thresholdBytes) {
                    result.put(key, bytes);
                }
            }
        }
        return result;
    }
}

Los puntos clave en el código:

  • redis.scan(options) recorre el espacio de claves en lotes de 500 claves. El thread principal no se bloquea por largo tiempo.
  • El Cursor debe cerrarse, así que usamos try-with-resources.
  • MEMORY USAGE key SAMPLES 0 devuelve el número de bytes que consume la clave, incluyendo overhead. Spring Data Redis no tiene una API específica para este comando, así que lo llamamos a través de conn.execute.

Ejemplo al llamar findKeysLargerThan(1_000_000):

{cart:10023=3145812, product:detail:88=1048648, leaderboard:global=89231560}

Nota: Ejecuta el escáner en una réplica en lugar del maestro, y en horarios de baja actividad. Otra forma es analizar el archivo RDB offline usando herramientas de lectura de RDB. Este enfoque no afecta al servidor en ejecución.

4. Detectar hot keys

4.1. Usando redis-cli --hotkeys

Redis solo rastrea la frecuencia de acceso cuando habilitas una política de evicción de tipo LFU:

# redis.conf
maxmemory-policy allkeys-lfu

Después de habilitar LFU, ejecuta:

redis-cli --hotkeys
[00.00%] Hot key '"config:homepage"' found so far with counter 255
[37.21%] Hot key '"product:stock:5001"' found so far with counter 201

-------- summary -------
hot key found with counter: 255	keyname: "config:homepage"
hot key found with counter: 201	keyname: "product:stock:5001"

El contador LFU es un valor logarítmico, máximo 255. No es el número real de solicitudes. Solo deberías usarlo para hacer comparaciones relativas entre claves.

Si ya sospechas de una clave específica, verifica directamente:

redis-cli OBJECT FREQ config:homepage
(integer) 255

Nota: No uses MONITOR en producción para encontrar hot keys. MONITOR envía todos los comandos al cliente y puede reducir el throughput más de la mitad.

4.2. Contar accesos desde la aplicación

Si no puedes cambiar maxmemory-policy, puedes contar directamente en la aplicación. El código siguiente cuenta cuántas veces se accede a cada clave en una ventana de tiempo:

@Component
public class HotKeyDetector {

    private final ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();

    public void record(String key) {
        counters.computeIfAbsent(key, k -> new LongAdder()).increment();
    }

    public List<Map.Entry<String, Long>> topAndReset(int n) {
        Map<String, Long> snapshot = new HashMap<>();
        counters.forEach((k, v) -> snapshot.put(k, v.sumThenReset()));
        counters.clear();
        return snapshot.entrySet().stream()
                .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
                .limit(n)
                .toList();
    }
}

Llamas a record(key) cada vez que lees Redis. Luego, usa un job @Scheduled que llame a topAndReset(10) cada 10 segundos, e imprime el resultado en log o métricas. LongAdder reduce la contención cuando múltiples threads incrementan el mismo contador. Por eso, es más adecuado que AtomicLong en esta situación.

5. Gestionar big keys

5.1. Particionamiento de claves (sharding)

Supón que almacenas las vistas de todos los productos en un único hash llamado product:views. Este hash crecerá a millones de campos. La solución es dividirlo en múltiples hashes pequeños:

public final class ShardRouter {

    private ShardRouter() {}

    public static String shardKey(String baseKey, String field, int shards) {
        int shard = Math.floorMod(field.hashCode(), shards);
        return baseKey + ":" + shard;
    }
}
@Service
public class ProductViewService {

    private static final int SHARDS = 64;
    private final StringRedisTemplate redis;

    public ProductViewService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public long increment(String productId) {
        String key = ShardRouter.shardKey("product:views", productId, SHARDS);
        return redis.opsForHash().increment(key, productId, 1);
    }

    public long get(String productId) {
        String key = ShardRouter.shardKey("product:views", productId, SHARDS);
        Object value = redis.opsForHash().get(key, productId);
        return value == null ? 0 : Long.parseLong(value.toString());
    }
}

Cómo funciona:

  • Math.floorMod siempre devuelve un número no negativo, incluso si hashCode() es negativo. Este es el lugar donde muchas personas usan % y obtienen errores de índice negativo.
  • El producto "P-1001" siempre cae en el mismo shard, por ejemplo product:views:17. Gracias a esto, la lectura y escritura siempre son consistentes.
  • Cada hash ahora contiene aproximadamente 1/64 del número de campos. En Redis Cluster, estas 64 claves residen en slots diferentes, así que la carga se distribuye entre múltiples nodos.

Nota: No añadas hash tags como product:views:{all}:17 si el objetivo es distribuir la carga. Los hash tags fuerzan todos los shards al mismo slot. El tamaño de cada clave disminuye, pero la carga sigue concentrada en un nodo.

Para big keys de tipo String, como JSON de detalles de producto de 1 MB, separa las partes que cambian poco de las que cambian frecuentemente en claves diferentes. También puedes comprimir el valor, o cachear solo los campos realmente necesarios.

5.2. Eliminación segura de big keys

DEL libera memoria de forma síncrona. Eliminar un ZSet de 1 millón de miembros puede bloquear Redis cientos de milisegundos. Usa UNLINK en su lugar:

public void removeLeaderboard() {
    redis.unlink("leaderboard:global");
}

UNLINK elimina la clave del espacio de claves inmediatamente. La liberación de memoria se desplaza a un thread de fondo. También deberías habilitar lazy free en la configuración, para que las operaciones de eliminación implícitas de Redis tampoco bloqueen el thread principal:

# redis.conf (Redis 6+)
lazyfree-lazy-user-del yes
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes
lazyfree-lazy-server-del yes

Con lazyfree-lazy-user-del yes, incluso el comando DEL actúa como UNLINK. Esta configuración te protege del código antiguo que aún llama a delete().

Si debes ejecutar en Redis antiguo, o deseas controlar la carga más estrictamente, elimina gradualmente en lotes:

public void deleteHashGradually(String key) {
    ScanOptions options = ScanOptions.scanOptions().count(500).build();
    try (Cursor<Map.Entry<Object, Object>> cursor = redis.opsForHash().scan(key, options)) {
        List<Object> batch = new ArrayList<>(500);
        while (cursor.hasNext()) {
            batch.add(cursor.next().getKey());
            if (batch.size() == 500) {
                redis.opsForHash().delete(key, batch.toArray());
                batch.clear();
            }
        }
        if (!batch.isEmpty()) {
            redis.opsForHash().delete(key, batch.toArray());
        }
    }
    redis.unlink(key);
}

Cada HDEL solo elimina 500 campos, así que el tiempo de bloqueo de cada comando es muy corto. Redis permite modificar un hash mientras se está ejecutando HSCAN sobre él. Los campos aún no eliminados se siguen devolviendo. El comando unlink final limpia los campos restantes.

6. Gestionar hot keys

6.1. Caché local antes de Redis

La forma más eficaz es hacer que la mayoría de las solicitudes nunca toquen Redis. Añade Caffeine:

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

Luego, coloca un caché local de corta duración antes de Redis:

@Service
public class HomepageConfigService {

    private final StringRedisTemplate redis;
    private final LoadingCache<String, String> localCache;

    public HomepageConfigService(StringRedisTemplate redis) {
        this.redis = redis;
        this.localCache = Caffeine.newBuilder()
                .maximumSize(1_000)
                .expireAfterWrite(Duration.ofSeconds(2))
                .build(key -> redis.opsForValue().get(key));
    }

    public String getConfig() {
        return localCache.get("config:homepage");
    }
}

Supón que tienes 20 instancias de aplicación. Cada instancia llama a Redis como máximo una vez cada 2 segundos para esta clave, es decir, Redis recibe aproximadamente 10 solicitudes/segundo, en lugar de decenas de miles. LoadingCache también agrupa las cargas simultáneas para la misma clave. Gracias a esto, cuando el caché expira, no hay cientos de threads lanzándose a Redis simultáneamente.

El precio es que los datos pueden tener hasta 2 segundos de antigüedad. Para la configuración de la página de inicio o catálogo de productos, este nivel de latencia normalmente es aceptable.

6.2. Replicación de claves

Cuando los datos necesitan ser más frescos, o ejecutas en Redis Cluster y un nodo está sobrecargado, puedes escribir el valor en múltiples copias y leer aleatoriamente de una:

@Service
public class FlashSaleService {

    private static final int REPLICAS = 8;
    private final StringRedisTemplate redis;

    public FlashSaleService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public void publish(String saleId, String json) {
        for (int i = 0; i < REPLICAS; i++) {
            redis.opsForValue().set("flashsale:" + saleId + ":r" + i, json, Duration.ofMinutes(10));
        }
    }

    public String read(String saleId) {
        int i = ThreadLocalRandom.current().nextInt(REPLICAS);
        return redis.opsForValue().get("flashsale:" + saleId + ":r" + i);
    }
}

Las 8 claves flashsale:42:r0 a flashsale:42:r7 generalmente caen en slots diferentes. Por eso, la carga de lectura se distribuye entre múltiples nodos. Este método es apropiado para datos que son leídos mucho, escritos poco.

Nota: Las escrituras en múltiples copias no son atómicas. En un breve momento, las copias pueden divergir. No uses este enfoque para datos que requieren consistencia absoluta, como saldos de cuentas. Para contadores de inventario hot, considera dividir el inventario en múltiples “cubos” independientes. Cada cubo mantiene parte de la cantidad en lugar de ser una copia del mismo valor.

Además de estos dos enfoques, puedes leer desde la réplica. Con Lettuce, configura ReadFrom.REPLICA_PREFERRED para distribuir la carga de lectura en la configuración master–replica.

7. Pruebas de la lógica de particionamiento de claves

La lógica de enrutamiento de shards es la parte más propensa a errores, así que escribe pruebas para ella:

class ShardRouterTest {

    @Test
    void sameFieldAlwaysMapsToSameShard() {
        String first = ShardRouter.shardKey("product:views", "P-1001", 64);
        String second = ShardRouter.shardKey("product:views", "P-1001", 64);

        assertThat(first).isEqualTo(second).startsWith("product:views:");
    }

    @Test
    void shardIndexIsNeverNegative() {
        // "polygenelubricants" has hashCode() == Integer.MIN_VALUE
        String key = ShardRouter.shardKey("product:views", "polygenelubricants", 64);
        int index = Integer.parseInt(key.substring(key.lastIndexOf(':') + 1));

        assertThat(index).isBetween(0, 63);
    }

    @Test
    void fieldsAreSpreadAcrossShards() {
        Map<String, Long> distribution = IntStream.range(0, 100_000)
                .mapToObj(i -> ShardRouter.shardKey("product:views", "P-" + i, 64))
                .collect(Collectors.groupingBy(k -> k, Collectors.counting()));

        assertThat(distribution).hasSize(64);
        assertThat(distribution.values()).allSatisfy(count ->
                assertThat(count).isBetween(1_000L, 2_200L));
    }
}

La segunda prueba verifica el caso extremo con Integer.MIN_VALUE. Si usas Math.abs(hash) % n, esta prueba fallaría porque Math.abs(Integer.MIN_VALUE) sigue siendo negativo. La tercera prueba confirma que 100.000 campos se distribuyen relativamente de manera uniforme: un promedio de aproximadamente 1.560 campos por shard.

8. Errores comunes y cuándo no usar esto

  • Ejecutar KEYS * para encontrar big keys: este comando bloquea Redis hasta que termina de recorrer. Siempre usa SCAN.
  • Particionar claves pero aun necesitar leer todo: si el negocio frecuentemente necesita HGETALL para todos los campos, particionar solo convierte 1 comando lento en 64 comandos. Revisa la lógica del negocio primero, por ejemplo, paginación o precalcular resultados agregados.
  • Cambiar el número de shards mientras está en ejecución: cambiar SHARDS de 64 a 128 hace que la mayoría de los campos cambien de ubicación. Necesitas un plan de migración, o elige un número de shards suficientemente grande desde el principio.
  • Caché local para datos que cambian constantemente: inventario, precio en subasta… no son compatibles con TTL de pocos segundos. Si aun deseas usarlo, combina Pub/Sub o client-side caching (CLIENT TRACKING) de Redis 6+ para invalidar el caché.
  • Optimizar demasiado pronto: un hash con 2.000 campos aún no es un problema. Mide con --bigkeys, --hotkeys y slowlog (SLOWLOG GET) antes de decidir.

9. Conclusión

En este artículo, viste por qué los hot keys y big keys son peligrosos para Redis, que procesa comandos en un solo thread. Para detectarlos, puedes usar redis-cli --bigkeys, --memkeys, --hotkeys, MEMORY USAGE, OBJECT FREQ, o contar desde la aplicación. Para gestionar big keys, particiona claves usando Math.floorMod, elimina con UNLINK y habilita lazy free. Para reducir la carga en hot keys, usa caché local Caffeine o replicación de claves. Incluye escaneos periódicos en tu proceso de operaciones, porque la detección temprana siempre es más barata que gestionar cuando Redis ya está congestionado.

redisspring-bootperformancecachingjava