Hot key e big key no Redis: detecção e tratamento
1. Visão Geral
Redis processa comandos em uma única thread principal. Por isso, mesmo uma chave muito grande (big key) ou uma chave acessada com frequência excessiva (hot key) pode deixar a instância inteira lenta. Neste artigo, você aprenderá como detectar esses dois tipos de chave usando as ferramentas nativas do Redis e código Java. Em seguida, vamos tratá-los em uma aplicação Spring Boot 3.x usando técnicas de sharding de chaves, exclusão segura, cache local e replicação de chaves.
2. O que são hot key e big key?
Big key é uma chave que ocupa muita memória ou contém muitos elementos. Não há um limiar padrão, mas muitos times utilizam os seguintes pontos de referência:
- String maior que cerca de 10 KB.
- Hash, List, Set, ZSet com mais de cerca de 5.000 elementos, ou tamanho total excedendo vários MB.
Hot key é uma chave que recebe uma taxa de requisições muito elevada em comparação com outras chaves. Exemplo: chave contendo informações de flash sale, página inicial ou configuração compartilhada.
Esses dois tipos de chave causam os seguintes problemas:
- Bloqueio da thread principal:
HGETALLem um hash com 1 milhão de fields, ouDELem um ZSet gigante, pode bloquear Redis por centenas de milissegundos. Todo cliente aguarda. - Gargalo de largura de banda: ler um valor de 2 MB com 5.000 requisições/segundo equivale a aproximadamente 10 GB/segundo.
- Desbalanceamento em Redis Cluster: uma chave sempre fica em um slot, ou seja, em um node. Hot key ou big key concentram a carga naquele node, não importa quantos nodes o cluster tenha.
- Replicação e failover lentos: big key torna a sincronização e carregamento de RDB mais demorados.
3. Detectando big key
3.1. Usando redis-cli --bigkeys e --memkeys
A forma mais rápida é usar a opção nativa do redis-cli:
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01
Este comando percorre todo o keyspace usando SCAN, então não bloqueia o servidor como KEYS *. O parâmetro -i 0.01 aguarda 10 ms a cada 100 comandos SCAN, ajudando a reduzir a pressão no servidor em produção. O resultado se parece com o seguinte:
[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 mede pelo número de elementos. Ele apenas relata a chave maior de cada tipo de dado. Se você se importa com a memória real, use --memkeys:
redis-cli --memkeys --memkeys-samples 0
--memkeys-samples 0 solicita ao Redis medir todos os elementos em vez de amostrar. Isso é mais preciso, mas mais lento.
3.2. Varredura com Java usando MEMORY USAGE
redis-cli apenas retorna a chave maior de cada tipo. Para listar todas as chaves acima do limiar, você pode fazer a varredura manualmente. Primeiro, adicione a dependência:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
A seguir, escreva um componente que varre o keyspace:
@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;
}
}
Pontos principais no código:
redis.scan(options)percorre o keyspace em lotes de 500 chaves. A thread principal não fica bloqueada por muito tempo.Cursordeve ser fechado, então usamos try-with-resources.MEMORY USAGE key SAMPLES 0retorna o número de bytes que a chave ocupa, incluindo overhead. Spring Data Redis não tem uma API específica para isso, então chamamos viaconn.execute.
Exemplo ao chamar findKeysLargerThan(1_000_000):
{cart:10023=3145812, product:detail:88=1048648, leaderboard:global=89231560}
Nota: Execute a varredura em uma réplica em vez do master, e em horário de baixa atividade. Outra opção é analisar o arquivo RDB offline usando ferramentas de leitura de RDB. Isso não afeta o servidor em execução.
4. Detectando hot key
4.1. Usando redis-cli --hotkeys
Redis rastreia a frequência de acesso apenas quando você habilita uma política de eviction do tipo LFU:
# redis.conf
maxmemory-policy allkeys-lfu
Após habilitar LFU, execute:
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"
O contador LFU é um valor logarítmico, máximo 255. Ele não é o número real de requisições. Use-o apenas para comparação relativa entre chaves.
Se você já suspeita de uma chave específica, verifique diretamente:
redis-cli OBJECT FREQ config:homepage
(integer) 255
Nota: Não use
MONITORem produção para encontrar hot keys.MONITORenvia todos os comandos para o cliente e pode reduzir o throughput em mais de 50%.
4.2. Contando acessos na aplicação
Se você não puder alterar maxmemory-policy, pode contar na própria aplicação. O código abaixo conta quantas vezes cada chave é acessada em uma janela de tempo:
@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();
}
}
Você chama record(key) toda vez que lê do Redis. Depois, use um job @Scheduled chamando topAndReset(10) a cada 10 segundos, então registre os resultados em log ou métricas. LongAdder reduz contenção quando múltiplas threads incrementam o mesmo contador. Por isso, é mais apropriado que AtomicLong nesta situação.
5. Tratando big key
5.1. Sharding de chaves
Digamos que você armazena visualizações de todos os produtos em um único hash chamado product:views. Este hash crescerá para milhões de fields. A solução é dividi-lo em múltiplos hashes menores:
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());
}
}
Como funciona:
Math.floorModsempre retorna um número não negativo, mesmo quandohashCode()é negativo. Este é o lugar onde muitas pessoas usam%e enfrentam problemas com índices negativos.- O produto
"P-1001"sempre cai no mesmo shard, por exemploproduct:views:17. Assim, leituras e escritas são sempre consistentes. - Cada hash agora tem apenas cerca de 1/64 do número de fields. Em Redis Cluster, essas 64 chaves ficam em slots diferentes, então a carga é distribuída por múltiplos nodes.
Nota: Não adicione hash tags como
product:views:{all}:17se o objetivo é distribuir a carga. Hash tags forçam todos os shards para o mesmo slot. O tamanho de cada chave diminui, mas a carga ainda se concentra em um node.
Para big keys do tipo String, como JSON detalhado do produto de 1 MB, separe a parte que muda pouco da parte que muda frequentemente em chaves diferentes. Você também pode comprimir o value ou cachear apenas os campos realmente necessários.
5.2. Exclusão segura de big key
DEL libera memória sincronamente. Deletar um ZSet com 1 milhão de elementos pode bloquear Redis por centenas de milissegundos. Use UNLINK em seu lugar:
public void removeLeaderboard() {
redis.unlink("leaderboard:global");
}
UNLINK remove a chave do keyspace imediatamente. A liberação de memória é enviada para uma thread em background. Você também deve habilitar lazy free na configuração, para que operações de exclusão implícitas do Redis também não bloqueiem a thread principal:
# redis.conf (Redis 6+)
lazyfree-lazy-user-del yes
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes
lazyfree-lazy-server-del yes
Com lazyfree-lazy-user-del yes, até mesmo o comando DEL funciona como UNLINK. Esta configuração protege contra código antigo que ainda chama delete().
Se você precisa rodar em Redis antigo, ou quer controlar a carga mais rigorosamente, delete gradualmente em 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 delete apenas 500 fields, então o tempo de bloqueio de cada comando é muito curto. Redis permite modificar um hash enquanto HSCAN está em andamento. Fields não deletados ainda são retornados. O comando unlink final limpa qualquer field restante.
6. Tratando hot key
6.1. Cache local antes do Redis
A maneira mais eficaz é fazer a maioria das requisições não tocar Redis. Adicione Caffeine:
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
A seguir, coloque um cache local de curta duração antes do 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");
}
}
Suponha que você tenha 20 instâncias da aplicação. Cada instância chama Redis no máximo cerca de uma vez a cada 2 segundos para esta chave, ou seja, Redis recebe cerca de 10 requisições/segundo, em vez de dezenas de milhares. LoadingCache também agrupa chamadas de carregamento simultâneas para a mesma chave. Assim, quando o cache expira, não há centenas de threads correndo para Redis.
O preço é que os dados podem estar desatualizados por até 2 segundos. Para configuração de página inicial ou catálogo de produtos, esse nível de latência geralmente é aceitável.
6.2. Replicação de chaves
Quando os dados precisam ser mais frescos, ou rodando em Redis Cluster com um node sobrecarregado, você pode escrever o value em múltiplas cópias e ler aleatoriamente de uma:
@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);
}
}
8 chaves flashsale:42:r0 até flashsale:42:r7 geralmente caem em slots diferentes. Portanto, a carga de leitura é distribuída entre múltiplos nodes. Este método é adequado para dados leitura frequente, escrita rara.
Nota: Escrever em múltiplas cópias não é atômico. Por um breve momento, as cópias podem estar fora de sincronia. Não use para dados que exigem consistência absoluta, como saldo de conta. Para contadores hot de inventário, considere dividir o inventário em múltiplos “baldes” independentes. Cada balde mantém uma parte da quantidade em vez de ser uma cópia do mesmo valor.
Além dos dois métodos acima, você pode ler de réplica. Com Lettuce, configure ReadFrom.REPLICA_PREFERRED para distribuir a carga de leitura em um modelo master–réplica.
7. Testando a lógica de sharding
A lógica de roteamento de shard é a parte mais propensa a erros, então escreva testes para ela:
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" tem 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));
}
}
O segundo teste verifica casos extremos com Integer.MIN_VALUE. Se você usar Math.abs(hash) % n, este teste falhará porque Math.abs(Integer.MIN_VALUE) ainda é negativo. O terceiro teste confirma que 100.000 fields são distribuídos relativamente uniformemente: em média cerca de 1.560 fields por shard.
8. Erros comuns e quando não usar
- Executar
KEYS *para encontrar big keys: este comando bloqueia Redis até terminar a varredura. Sempre useSCAN. - Fazer sharding mas ainda precisar ler tudo: se o negócio frequentemente precisa fazer
HGETALLem todos os fields, o sharding apenas transforma 1 comando lento em 64 comandos. Reconsidere o negócio primeiro, por exemplo, paginação ou pré-computar agregações. - Mudar o número de shards durante execução: alterar
SHARDSde 64 para 128 faz a maioria dos fields mudar de posição. Você precisa de um plano de migração ou escolher o número de shards suficientemente grande desde o início. - Cache local para dados que mudam continuamente: inventário, preço em leilão… não combinam com TTL de poucos segundos. Se ainda quiser usar, combine com Pub/Sub ou client-side caching (
CLIENT TRACKING) do Redis 6+ para invalidar o cache. - Otimizar cedo demais: um hash com 2.000 fields ainda não é problema. Meça com
--bigkeys,--hotkeyse slowlog (SLOWLOG GET) antes de decidir.
9. Conclusão
Neste artigo, você viu por que hot keys e big keys são perigosos para Redis que processa comandos em uma única thread. Para detectá-los, você pode usar redis-cli --bigkeys, --memkeys, --hotkeys, MEMORY USAGE, OBJECT FREQ, ou contar na aplicação. Para tratar big keys, você faz sharding com Math.floorMod, deleta com UNLINK e habilita lazy free. Para reduzir carga de hot keys, você usa cache local Caffeine ou replicação de chaves. Coloque a varredura periódica no seu processo de operações, pois detectar cedo é sempre mais barato que tratar quando Redis já está congestionado.