Redis缓存问题

1 缓存穿透

1.1 定义

缓存穿透是指查询一个根本不存在的数据, 缓存层和存储层都不会命中, 导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。

1.2 产生原因

  • 自身业务代码或者数据出现问题。
  • 一些恶意攻击、 爬虫等造成大量空命中。

1.3 解决方案

1.3.1 缓存空对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存储数据为空, 需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}

1.3.2 布隆过滤器

在访问缓存前,先用布隆过滤器先做一次过滤。对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。

布隆过滤器底层就是实现了一个大型的二进制数组,我们需要在使用前将所有的数据放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放,布隆过滤器可以根据多个hash算法对key进行hash+数组长度取模的方式,给每一条数据计算出多个位置。

所以在访问数据是否存在时,他也会把这几个位置算出来,如果这几个位置有一个为0,那么就说明这个数据一定不存在。因为存在hash碰撞,所以如果所有位都为1,不能说明这个key一定存在。

image-20230228225351935

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.redisson;

import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedissonBloomFilter {

public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
//构造Redisson
RedissonClient redisson = Redisson.create(config);

RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小
bloomFilter.tryInit(100000000L,0.03);

//把所有数据存入布隆过滤器
void init(){
for (String key: keys) {
bloomFilter.put(key);
}
}

String get(String key) {
// 从布隆过滤器这一级缓存判断下key是否存在
Boolean exist = bloomFilter.contains(key);
if(!exist){
return "";
}
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存储数据为空, 需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
else {
// 缓存非空
return cacheValue;
}
}
}
}

2 缓存失效(击穿)

2.1 定义

由于大批量缓存在同一时间失效可能导致大量请求同时穿透缓存直达数据库,可能会造成数据库瞬间压力过大 甚至挂掉。

2.2 产生原因

大批量缓存在同一时间失效

2.3 解决方案

2.3.1 将这一批数据的缓存过期时间设置为一个时间段内的不同时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
//设置一个过期时间(300到600之间的一个随机数)
int expireTime = new Random().nextInt(300) + 300;
if (storageValue == null) {
cache.expire(key, expireTime);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}

3 缓存雪崩

3.1 定义

缓存雪崩指的是缓存层支撑不住或宕掉后, 流量打向后端存储层 ,存储层的调用量会暴增, 造成存储层也会级联宕机的情况。

3.2 产生原因

  • redis集群挂了
  • 请求数量大于redis可承受的最大数量

3.3 解决方案

3.3.1 保证缓存层服务高可用性

比如使用Redis Sentinel或Redis Cluster。

3.3.2 后端限流熔断并降级。

比如使用Sentinel或Hystrix限流降级组件。

  • 比如服务降级,我们可以针对不同的数据采取不同的处理方式。
    • 当业务应用访问的是非核心数据(例如电商商 品属性,用户信息等)时,暂时停止从缓存中查询这些数据,而是直接返回预定义的默认降级信息、空值或是错误提示信息;
    • 当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失, 也可以继续通过数据库读取。

4 Redis热key问题

4.1定义

在Redis中,我们把访问频率高的Key,称为热Key(例如一个热门的娱乐新闻))。热key问题就是,突然有几十万的请求去访问redis上的某个特定key。那么这样会造成redis服务器短时间流量过于集中,很可能导致redis的服务器宕机。那么接下来对这个Key的请求,都会直接请求到我们的后端数据库中,数据库性能本来就不高,这样就可能直接压垮数据库,进而导致后端服务不可用。

4.2 产生原因

用户消费的数据远大于生产的数据,如商品秒杀、热点新闻、热点评论等读多写少的场景

双十一秒杀商品,短时间内某个爆款商品可能被点击/购买上百万次,或者某条爆炸性新闻等被大量浏览,此时会造成一个较大的请求Redis量,这种情况下就会造成热点Key问题。

4.3 怎么发现热key

  1. 凭借业务经验,进行预估哪些是热key
    其实这个方法还是挺有可行性的。比如某商品在做秒杀,那这个商品的key就可以判断出是热key。缺点很明显,并非所有业务都能预估出哪些key是热key。

  2. 在客户端进行收集

    操作redis之前,加入一行代码进行数据统计。那么这个数据统计的方式有很多种,也可以是给外部的通讯系统发送一个通知信息。缺点就是对客户端代码造成入侵。

  3. 用redis自带命令

    (1). monitor命令,该命令可以实时抓取出redis服务器接收到的命令,然后写代码统计出热key是啥。当然,也有现成的分析工具可以给你使用,比如redis-faina。但是该命令在高并发的条件下,有内存增暴增的隐患,还会降低redis的性能。
    (2). hotkeys参数,redis 4.0.3提供了redis-cli的热点key发现功能,执行redis-cli时加上–hotkeys选项即可。但是该参数在执行的时候,如果key比较多,执行起来比较慢。

  4. 自己抓包评估

    自己写程序监听端口,按照RESP协议规则解析数据,进行分析。缺点就是开发成本高,维护困难,有丢包可能性。

4.4 如何解决

  1. Redis集群扩容:增加分片副本,分摊客户端发过来的读请求;**
  2. 使用二级缓存,即JVM本地缓存,减少Redis的读请求。比如利用HashMap。在你发现热key以后,把热key加载到系统的JVM中。针对这种热key请求,会直接从jvm中取,而不会走到redis层。

4.3.1 互斥锁

利用互斥锁来解决,此方法只允许一个线程重建缓存, 其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
String get(String key) {
// 从Redis中获取数据
String value = redis.get(key);
// 如果value为空, 则开始重构缓存
if (value == null) {
// 只允许一个线程重建缓存, 使用nx, 并设置过期时间ex
String mutexKey = "mutext:key:" + key;
if (redis.set(mutexKey, "1", "ex 180", "nx")) {
// 从数据源获取数据
value = db.get(key);
// 回写Redis, 并设置过期时间
redis.setex(key, timeout, value);
// 删除key_mutex
redis.delete(mutexKey);
}// 其他线程休息50毫秒后重试
else {
Thread.sleep(50);
get(key);
}
}
return value;
}

5 缓存与数据库不一致

5.1 定义

在大并发下,同时操作数据库与缓存会存在数据不一致性问题

5.2 缓存一致性的解决方案

方案 一致性类型 优点 缺点
合理设置缓存时间
消息队列 最终一致性 实现简单,适合异步场景 存在短暂不一致状态
在数据库中增加时间戳/版本号 最终一致性 简单易实现 需要额外字段
双写 + 延迟双删 最终一致性 实现简单 存在短暂不一致状态
Binlog 同步 最终一致性 自动化程度高 需要额外的工具支持
分布式锁 最终一致性

5.2.1 合理设置缓存过期时间

对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。

5.2.2 异步消息队列

  1. 更新数据库中的数据,并将更新事件发送到消息队列。
  2. 消息队列中的消费者接收到事件后,更新 Redis 中的数据。
  3. 如果 Redis 更新失败,可以通过重试机制或人工干预解决问题。
优点
  • 实现简单,适合异步场景。
  • 减少了数据库的压力。
缺点

5.2.2基于时间戳或版本号的冲突检测

通过在数据库和 Redis 中记录时间戳或版本号,可以检测和解决数据冲突。

流程
  1. 在数据库中增加一个 versiontimestamp 字段。
  2. 更新数据库时,同时更新该字段。
  3. 更新 Redis 时,检查其版本号或时间戳是否与数据库一致。
  4. 如果不一致,则以数据库为准进行修正。
优点
  • 简单易实现,适合中小型系统。
  • 不需要额外的组件。

5.2.2 双写策略+ 延迟双删

这是一种常见的缓存更新策略,适用于读多写少的场景。

流程
  1. 写操作
    • 先更新数据库。
    • 再更新 Redis。
  2. 删除操作
    • 先删除 Redis 中的数据。
    • 延迟一段时间后再删除数据库中的数据(防止 Redis 删除后未及时同步)。
优点
  • 实现简单,适合大多数场景。
  • 不需要额外的组件。
缺点
  • 存在短暂的不一致状态。
  • 删除操作需要延迟处理。

5.2.1 使用分布式锁

在高并发场景下,使用分布式锁(如Redis,zookeeper分布式锁)来控制缓存的更新操作,确保数据的一致性。

5.5.4 基于数据库 Binlog 的自动同步

通过订阅数据库的 Binlog(二进制日志),可以实时将数据同步到 Redis。

流程
  1. 数据库写入数据时,生成 Binlog。
  2. 使用工具(如 Canal、Debezium)订阅 Binlog,捕获数据变更。
  3. 根据捕获的变更事件,自动更新 Redis。
优点
  • 自动化程度高,无需修改业务代码。
  • 实时性强,适合对一致性要求较高的场景。
缺点
  • 对数据库性能有一定影响。
  • 需要额外的工具支持。

选择哪种方案取决于具体需求:

  • 如果可以容忍短暂的不一致,可以选择 消息队列双写 + 延迟双删

  • 如果需要强一致性,可以选择 Binlog 同步分布式锁

注:放入缓存的数据应该是对实时性、一致性要求不是很高的数据。如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库。如果数据库抗不住压力,还可以把缓存作为数据读写的主存储,异步将数据同步到数据库,数据库只是作为数据的备份。


Redis缓存问题
http://example.com/Redis缓存问题/
作者
Panyurou
发布于
2021年12月29日
许可协议