缓存设计
缓存击穿、缓存穿透、缓存雪崩
缓存击穿:是指热点key过期,大量请求就会直接到达数据库,导致数据库瞬间压力过大。
缓存穿透:指查询一个不存在的数据,缓存中没有相应的记录,每次请求都会去数据库查询,造成数据库负担加重。
缓存雪崩:指多个key在同一时间过期或Redis服务宕机,导致大量请求同时访问数据库,从而造成数据库瞬间负载激增。
解决方案
缓存击穿:
使用分布式锁(双重判定锁),确保同一时间只有一个请求可以去数据库查询并更新缓存。
但是这种的话有一个弊端,那就是获取分布式锁的请求,都会执行一遍查询数据库,并更新到缓存。理论上只有第一个加载数据库记录请求是有效的。 针对这个问题,可以通过双重判定锁的形式,在获取到分布式锁之后,再次查询一次缓存是否存在。如果缓存中存在数据,就直接返回;如果不存在,才继续执行查询数据库的操作。这样就可以避免大量请求访问数据库。 双重判定锁有效提升了锁性能以及数据库访问。
热点数据永不过期,并且添加逻辑过期(让别人去做,我自己返回过期的数据,旧一点嘛,也能用;这个过程中又有线程3访问了,获取锁失败,那怎么办,已经有人帮我获取了,我也比较佛系,返回旧数据就行),脏数据活动整体结束后再删除。
缓存穿透(redis和数据库里都没有):
缓存null值或{}结果
使用布隆过滤器,过滤掉不存在的请求,避免直接访问数据库。
缓存雪崩:
- 采用随机TTL策略,避免多个数据同时过期。
- Redis集群
- 给缓存业务添加降级限流策略(快速失败,牺牲部分服务,避免打到数据库)
- 使用双缓存策略,将数据同时存储在两层缓存中,减少数据库直接请求。浏览器->nginx->redis->jvm->数据库(五层防弹衣,一层打穿了,3,4,5层也能用)
Redis 中的缓存一致性问题该如何解决?

第二种情况出现的可能性极低。

延迟双删(对上图第一种情况做的优化):先删除Redis缓存,再更新Mysql,延迟几百毫秒再删除Redis缓存数据,这样就算再更新Mysql时,有其他线程读取了Mysql,把老数读到了Redis中,那么也会删掉,从而使数据保持一致。
异步更新缓存(基于订阅binlog的同步机制)
实际应用: 使用阿里的一款开源框架canal,通过该框架可以对Mysql的binlog进行订阅,而canal正是模仿了mysql的slave数据库的备份请求,使得Redis的数据更新达到相同的效果。MQ消息中间件可以采用RocketMQ来实现推送。
为什么是删除缓存而不是更新缓存
因为更新100次数据库就还得更新100次缓存,但是我大部分是写操作,读很少,那就有点浪费资源了;
删除的话,就不要主动更改了
为什么要先更新数据库,再删除缓存
因为更新数据库的速度比删除缓存的速度要慢得多。
因为先删除缓存再更新数据库的话,先删掉缓存,然后这时新增一个查询,就又把旧数据缓存到redis里了,再更新数据库的话,就会发生缓存不一致了,就产生了一个脏数据了。
但如果你现在是先更新数据库,再删除缓存,就会存在这样一种情况:
线程1查的时候,正好过期了(redis里没有这个key了),未命中,就去查询缓存并写入,写入的过程可能偏慢或者遇到了阻塞;那么阻塞期间线程2更新数据库并删除缓存,完了以后,阻塞消失,缓存中就会出现旧数据。
但是阻塞或偏慢这种情况遇到的概率太低了,因为缓存操作往往比数据库操作快非常多的。
不同场景用哪种比较合适?
方式 | 一致性 | 适用场景 |
---|---|---|
先改数据库后删缓存 | 较低,可能存在短暂不一致 | 读多写少,能容忍短暂的不一致 |
延迟双删 | 较高,可减少并发回滚问题 | 需要更高一致性,高并发写场景 |
基于 binlog 监听 | 最高,但有毫秒级延迟 | 高并发、大流量系统 |
布隆过滤器
不使用布隆的话,想快速判断一个用户名是否被注册或者短链接有没有被创建,只能将所有数据存入redis中,那数据量就大了,而且只能设置永久不过期,所以使用布隆,它更节省内存;
布隆过滤器是一种数据结构,用于快速判断一个元素是否存在于一个集合中。
具体来说,布隆过滤器包含一个位数组和一组哈希函数。位数组的初始值全部置为 0。在插入一个元素时,将该元素经过多个哈希函数映射到位数组上的多个位置,并将这些位置的值置为 1
在查询一个元素是否存在时,会将该元素经过多个哈希函数映射到位数组上的多个位置,如果所有位置的值都为 1,则认为元素存在;如果存在任一位置的值为 0,则认为元素不存在。
优点:
- 高效地判断一个元素是否属于一个大规模集合。
- 节省内存。
缺点:
- 可能存在一定的误判。
布隆过滤器误判理解
- 布隆过滤器可以通过tryInit初始容量。容量设置越大,冲突几率越低。
- 布隆过滤器会设置预期的误判值。其实就是散列 Hash 函数多少,越多计算耗时较长,但是错误率越低;
你布隆过滤器是怎么创建的
引入 Redisson 依赖→配置 Redis 参数→创建布隆过滤器实例RBloomFilter→想存的话直接add
布隆过滤器挂了,数据会丢失么?
在我看来,这个问题应该是问redis挂了,数据会丢失吗,因为布隆过滤器就是在redis内存内的;如果说挂,应该是整个 Redis 挂掉,而不仅仅说是布隆过滤器挂。
但是说如果真的没了,那就从数据库或缓存中 重新加载数据,批量计算哈希值,重建布隆过滤器。将新的布隆过滤器重新加载到内存中,恢复其拦截能力。
提示
Redis 提供了两套持久化机制,一个是 RDB,它会根据情况定期的 Fork 出一个子进程,生成当前数据库的全量快照;另一个是 AOF,它通过向 AOF 日志文件追加每一条执行过的指令实现。
如何保证本地缓存和分布式缓存的一致?
可能在一些项目中,为了减轻 Redis 的负载,设置了多级缓存,又追加了一层本地缓存 Caffeine。

为了保证本地缓存和 Redis 缓存的一致性,通常采用的策略有:
①、设置本地缓存的过期时间,这是最简单也是最直接的方法,当本地缓存过期时,就从 Redis 缓存中去同步。
②、使用 Redis 的 Pub/Sub 机制,当 Redis 缓存发生变化时,发布一个消息,本地缓存订阅这个消息,然后删除对应的本地缓存。
③、Redis 缓存发生变化时,引入消息队列,比如 RocketMQ、RabbitMQ 去更新本地缓存。
热 key问题?
所谓的热 key,就是指在很短时间内被频繁访问的键。
比如,热门新闻或热门商品,这类 key 通常会有大流量的访问,对存储这类信息的 Redis 来说,是不小的压力。
某天某流量明星突然爆出一个大瓜,微博突然就崩了,这就是热 key 的压力。
再比如说 Redis 是集群部署,热 key 可能会造成整体流量的不均衡(网络带宽、CPU 和内存资源),个别节点出现 OPS 过大的情况,极端情况下热点 key 甚至会超过 Redis 本身能够承受的 OPS。
OPS(Operations Per Second)是 Redis 的一个重要指标,表示 Redis 每秒钟能够处理的命令数。
通常以 Key 被请求的频率来判定,比如:
- QPS 集中在特定的 Key:总的 QPS(每秒查询率)为 10000,其中一个 Key 的 QPS 飙到了 8000。
- 带宽使用率集中在特定的 Key:一个拥有上千成员且总大小为 1M 的哈希 Key,每秒发送大量的 HGETALL 请求。
- CPU 使用率集中在特定的 Key:一个拥有数万个成员的 ZSET Key,每秒发送大量的 ZRANGE 请求。
- HGETALL 命令用于返回哈希表中,所有的字段和值。
- ZRANGE 命令用于返回有序集中,指定区间内的成员。
怎么处理?
最关键的是对热 key 的监控

①、客户端
客户端其实是距离 key ”最近” 的地方,因为 Redis 命令就是从客户端发出的,例如在客户端设置全局字典(key 和调用次数),每次调用 Redis 命令时,使用这个字典进行记录。
②、代理端
像 Twemproxy、Codis 这些基于代理的 Redis 分布式架构,所有客户端的请求都是通过代理端完成的,可以在代理端进行监控。
③、Redis 服务端
使用 monitor 命令统计热点 key 是很多开发和运维人员首先想到的方案,monitor 命令可以监控到 Redis 执行的所有命令。
monitor 命令的使用:
redis-cli monitor

还可以通过 bigkeys 参数来分析热 Key。
bigkeys 命令的使用:
redis-cli --bigkeys

只要监控到了热 key,对热 key 的处理就简单了:
①、把热 key 打散到不同的服务器,降低压⼒。
基本思路就是给热 Key 加上前缀或者后缀,见下例:
②、加⼊⼆级缓存,当出现热 Key 后,把热 Key 加载到 JVM 中,后续针对这些热 Key 的请求,直接从 JVM 中读取。
这些本地的缓存工具有很多,比如 Caffeine、Guava 等,或者直接使用 HashMap 作为本地缓存都是可以的。
缓存预热怎么做呢?
每天定时更新站点地图到 Redis 缓存中
/**
* 采用定时器方案,每天5:15分刷新站点地图,确保数据的一致性
*/
@Scheduled(cron = "0 15 5 * * ?")
public void autoRefreshCache() {
log.info("开始刷新sitemap.xml的url地址,避免出现数据不一致问题!");
refreshSitemap();
log.info("刷新完成!");
}
@Override
public void refreshSitemap() {
initSiteMap();
}
private synchronized void initSiteMap() {
long lastId = 0L;
RedisClient.del(SITE_MAP_CACHE_KEY);
while (true) {
List<SimpleArticleDTO> list = articleDao.getBaseMapper().listArticlesOrderById(lastId, SCAN_SIZE);
// 刷新站点地图信息
Map<String, Long> map = list.stream().collect(Collectors.toMap(s -> String.valueOf(s.getId()), s -> s.getCreateTime().getTime(), (a, b) -> a));
RedisClient.hMSet(SITE_MAP_CACHE_KEY, map);
if (list.size() < SCAN_SIZE) {
break;
}
lastId = list.get(list.size() - 1).getId();
}
}
‘