Explicação detalhada dos problemas de cache do ApsaraDB for Redis

penetração de cache
A penetração de cache ocorre quando os dados solicitados pelo cliente não existem no cache nem no banco de dados, de modo que o cache nunca é acionado e essas solicitações atingem diretamente o banco de dados
Se um usuário malicioso utilizar inúmeras threads para acessar simultaneamente dados inexistentes, todas essas solicitações chegarão ao banco de dados, o que pode causar sua falha
solução
Armazenar objetos vazios em cache
Ideia: quando um usuário solicita um id que não existe nem no Redis nem no banco de dados, armazenamos diretamente o valor nulo correspondente a esse id no Redis. Assim, na próxima vez que o usuário solicitar repetidamente esse id, o Redis será acionado (acerto nulo), sem consultar o banco de dados
Vantagens: simples de implementar, fácil de manter
desvantagens:

Consumo adicional de memória (pode ser resolvido adicionando TTL)


- Pode causar inconsistência temporária (controlar o TTL pode mitigar parcialmente): quando o valor nulo está em cache e definimos o valor no banco de dados, a consulta do usuário retorna nulo, mas na verdade o dado existe no banco de dados, causando inconsistência (pode ser resolvido sobrescrevendo automaticamente os dados nulos anteriores ao inserir novos dados)
Filtro de Bloom
Uma camada de filtro de Bloom é adicionada entre o cliente e o Redis. Quando o usuário acessa, o filtro de Bloom determina se os dados existem. Se não existirem, a solicitação é rejeitada diretamente; se existirem, o fluxo normal é processado.
Como o filtro de Bloom determina se os dados existem?

O filtro de Bloom pode ser entendido simplesmente como um array de bytes que armazena bits binários. Quando é necessário determinar se os dados existem no banco de dados, ele não armazena os dados diretamente no filtro, mas calcula o valor hash por meio de um algoritmo de hash e converte esses hashes em bits binários, armazenando-os no filtro de Bloom. Ao verificar se os dados existem, a posição correspondente é avaliada como 0/1 (essa verificação é probabilística, não 100% precisa; portanto, se indicar que não existe, pode ainda assim existir, havendo risco de penetração)
Vantagens: menor uso de memória, sem chaves redundantes (binário)
desvantagens:

complexo de implementar
Há possibilidade de falso positivo (não é necessariamente preciso)


Implementação em Java de armazenamento de objetos vazios em cache
/**
* Penetração de cache
*
* @param id
* @return
*/
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Consultar o cache da loja no Redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Verificar se existe
if (StrUtil.isNotBlank(shopJson)) {
// 3. Se existir, retornar diretamente
return JSONUtil.toBean(shopJson, Shop.class);
}
// Verificar se o acerto é um valor nulo
if (shopJson != null) {
// retornar mensagem de erro
return null;
}
// 4. Se não existir, consultar o banco de dados pelo id
Shop shop = getById(id);
// 5. Se não existir, retornar erro
if (shop == null) {
// gravar valor nulo no Redis (penetração de cache)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. Se existir, gravar no Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. Retornar
return shop;
}
Avalanche de cache
A avalanche de cache ocorre quando um grande número de chaves de cache expira simultaneamente ou o serviço Redis fica indisponível ao mesmo tempo, resultando em um volume massivo de solicitações atingindo o banco de dados e causando enorme pressão


solução

Adicionar valores aleatórios ao TTL de diferentes chaves (para resolver o problema de expiração simultânea): por exemplo, durante o aquecimento de cache, os dados do banco de dados precisam ser importados em lotes para o cache antecipadamente. Como os dados são importados ao mesmo tempo, o valor TTL desses dados é idêntico. Isso pode fazer com que os dados expirem simultaneamente em determinado momento, causando uma avalanche. Para resolver esse problema, podemos adicionar um número aleatório ao TTL durante a importação (por exemplo, TTL de 30±1~5), de modo que o tempo de expiração dessas chaves seja distribuído ao longo de um período, em vez de expirarem todas ao mesmo tempo, evitando assim a ocorrência de avalanche
Usar cluster Redis para melhorar a disponibilidade do serviço (resolver indisponibilidade do Redis): com o auxílio do mecanismo Sentinel do Redis, quando uma máquina fica indisponível, o Sentinel pode selecionar automaticamente outra máquina para substituí-la, e o mestre e os réplicas podem sincronizar dados para garantir alta disponibilidade do Redis
Adicionar estratégia de degradação e limitação de taxa ao negócio de cache: como falha rápida, recusa de serviço e prevenção de que solicitações sejam encaminhadas ao banco de dados
Adicionar caches multiníveis ao negócio: navegadores podem ter caches (geralmente recursos estáticos), o servidor de proxy reverso Nginx pode ter caches; quando o cache do Nginx falha, a solicitação vai para o Redis; quando o cache do Redis falha, chega à JVM, onde também pode ser estabelecido um cache local, finalmente alcançando o banco de dados
ruptura de cache
O problema de ruptura de cache, também conhecido como problema de chave quente, ocorre quando uma chave acessada por um alto volume de requisições concorrentes e que possui uma lógica complexa de reconstrução de cache expira repentinamente. Inúmeras solicitações terão um impacto enorme no banco de dados em um instante.
Reconstrução de cache: o cache no Redis expira após o tempo definido e precisa ser reconsultado no banco de dados e regravado no Redis após a expiração. O processo de consulta e construção de dados a partir do banco de dados pode ser complexo, exigindo consultas com junção de múltiplas tabelas, e finalmente os resultados são armazenados em cache. Esse processo pode levar muito tempo (dezenas ou até centenas de milissegundos). Durante esse período, não há cache no Redis, e as solicitações recebidas acabarão acessando o banco de dados.


solução
Mutex
Quando uma solicitação de thread resulta em falta no cache, a operação de bloqueio é executada antes de consultar o banco de dados, e o bloqueio é liberado após a gravação no cache. Dessa forma, quando outras threads tiverem falta, o mutex também será adquirido ao consultar o banco de dados. Se a aquisição falhar, a thread dormirá por um período e tentará novamente.
Obviamente, outras threads só podem obter os dados após a gravação no cache. Embora a consistência possa ser garantida, o desempenho é relativamente inferior e pode causar deadlock.


Implementação em Java


/**
* Obter o bloqueio
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}


/**
* Liberar bloqueio
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}


/**
* Mutex
*
* @param id
* @return
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Consultar o cache da loja no Redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Verificar se existe
if (StrUtil.isNotBlank(shopJson)) {
// 3. Se existir, retornar diretamente
return JSONUtil.toBean(shopJson, Shop.class);
}
// Verificar se o acerto é um valor nulo
if (shopJson != null) {
// retornar mensagem de erro
return null;
}

// 4. Implementar reconstrução de cache
// 4.1 Adquirir o mutex
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 Verificar se a aquisição foi bem-sucedida
if (!isLock) {
// 4.3 Falha, dormir e tentar novamente
Thread.sleep(50);
// recursão
return queryWithMutex(id);
}
// 4.4 Sucesso, consultar o banco de dados pelo id
shop = getById(id);
// simular atraso de reconstrução
Thread.sleep(200);
// 5. Se não existir, retornar erro
if (shop == null) {
// gravar valor nulo no Redis (penetração de cache)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. Se existir, gravar no Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7. Liberar o mutex
unlock(lockKey);
}
// 8. Retornar
return shop;
}
Vamos testar com o JMeter, enviando 1.000 solicitações; podemos ver que todas as solicitações foram processadas e o banco de dados foi consultado apenas uma vez



expiração lógica
Como o nome sugere, não expira de verdade; pode ser considerada como nunca expirando. Quando armazenamos dados em cache no Redis, não definimos TTL, mas adicionamos um campo de tempo de expiração (não TTL, baseado em hora atual + tempo de expiração, mantido logicamente) ao armazenar os dados, de modo que qualquer thread possa consultá-lo. Basta verificar logicamente se expirou
Conforme mostrado na figura abaixo, se a thread 1 detectar que o tempo lógico expirou ao consultar o cache, ela precisa reconstruir o cache e adquirir o mutex. Em vez de executar a operação de reconstrução de cache por si mesma, após a conclusão da reconstrução, o bloqueio é liberado e a thread 1 retorna diretamente os dados expirados. Quando outras threads também tiverem falta, a falha na aquisição do mutex fará com que retornem diretamente os dados expirados. Embora o desempenho seja garantido, a consistência não pode ser assegurada.


Implementação em Java
/**
* Aquecimento de cache
*
* @param id
* @param expireSeconds tempo de expiração lógica
*/
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1. Consultar dados da loja
Shop shop = getById(id);
Thread.sleep(200);
// 2. Encapsular tempo de expiração lógica
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. Gravar no Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

/**
* Expiração lógica
*
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Consultar o cache da loja no Redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Verificar se existe
if (StrUtil.isBlank(shopJson)) {
// 3. Falha no cache, retornar diretamente
return null;
}
// 4. Acerto no cache, desserializar JSON para objeto primeiro
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. Verificar se expirou
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1 Se não expirou, retornar as informações da loja diretamente
return shop;
}
// 5.2 Expirou, precisa ser reconstruído
// 6. Reconstrução de cache
// 6.1 Adquirir o mutex
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2 Verificar se o bloqueio foi adquirido com sucesso
if (isLock) {
// 6.3 Sucesso, abrir thread independente para realizar a reconstrução de cache
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// reconstruir cache
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// liberar o bloqueio
unlock(lockKey);
}
});
}
// 6.4 Retornar informações expiradas da loja
return shop;
}

Artigos relacionados

Explorar mais ofertas especiais

  1. Short Message Service (SMS) e Serviço de e-mail

    Pacote de 50.000 e-mails a partir de US$ 1,99; 120 mensagens curtas a partir de apenas US$ 1,00

phone Fale Conosco
Oi, eu sou o assistente de IA da Alibaba Cloud!
Posso ajudar com perguntas e soluções.