缓存击穿
什么是缓存击穿?
缓存击穿是指某个热点数据缓存过期时,大量请求就会穿透缓存直接访问数据库,导致数据库瞬间承受的压力巨大。
解决缓存击穿有两种常用的策略:
第一种是加互斥锁。当缓存失效时,第一个访问的线程先获取锁并负责重建缓存,其他线程等待或重试。这种策略虽然会导致部分请求延迟,但实现起来相对简单。
第二种是永不过期策略。缓存项本身不设置过期时间,也就是永不过期,但在缓存值中维护一个逻辑过期时间。当缓存逻辑上过期时,返回旧值的同时,异步启动一个线程去更新缓存。
什么是缓存穿透?
缓存穿透是指查询的数据在缓存中没有命中,因为数据压根不存在,所以请求会直接落到数据库上。如果这种查询非常频繁,就会给数据库造成很大的压力。
缓存击穿是因为单个热点数据缓存失效导致的,而缓存穿透是因为查询的数据不存在,原因可能是自身的业务代码有问题,或者是恶意攻击造成的,比如爬虫。
常用的解决方案有两种:第一种是布隆过滤器,它是一种空间效率很高的数据结构,可以用来判断一个元素是否在集合中。
我们可以将所有可能存在的数据哈希到布隆过滤器中,查询时先检查布隆过滤器,如果布隆过滤器认为该数据不存在,就直接返回空;否则再去查询缓存,这样就可以避免无效的缓存查询。
布隆过滤器存在误判,即可能会认为某个数据存在,但实际上并不存在。但绝不会漏判,即如果布隆过滤器认为某个数据不存在,那它一定不存在。因此它可以有效拦截不存在的数据查询,减轻数据库压力。
第二种是缓存空值。对于不存在的数据,我们将空值写入缓存,并设置一个合理的过期时间。这样下次相同的查询就能直接从缓存返回,而不再访问数据库。
缓存空值的方法实现起来比较简单,但需要给空值设置一个合理的过期时间,以免数据库中新增了这些数据后,缓存仍然返回空值。
在实际的项目当中,还需要在接口层面做一些处理,比如说对参数进行校验,拦截明显不合理的请求;或者对疑似攻击的 IP 进行限流和封禁
什么是缓存雪崩?
缓存雪崩是指在某一时间段,大量缓存同时失效或者缓存服务突然宕机了,导致大量请求直接涌向数据库,导致数据库压力剧增,甚至引发系统崩溃的现象。
缓存击穿是单个热点数据失效导致的,缓存穿透是因为请求不存在的数据,而缓存雪崩是因为大范围的缓存失效。
缓存雪崩主要有三种成因和应对策略。
第一种,大量缓存同时过期,解决方法是添加随机过期时间。
第二种,缓存服务崩溃,解决方法是使用高可用的缓存集群。
比如说使用 Redis Cluster 构建多节点集群,确保数据在多个节点上有备份,并且支持自动故障转移
对于一些高频关键数据,可以配置本地缓存作为二级缓存,缓解 Redis 的压力。
这个过程称为“缓存降级”,保证 Redis 发生故障时,系统能够继续提供服务。
第三种,缓存服务正常但并发请求量超过了缓存服务的承载能力,这种情况下可以采用限流和降级措施。
能说说布隆过滤器吗?
布隆过滤器是一种空间效率极高的概率性数据结构,用于快速判断一个元素是否在一个集合中。它的特点是能够以极小的内存消耗,判断一个元素“一定不在集合中”或“可能在集合中”,常用来解决 Redis 缓存穿透的问题。
布隆过滤器存在误判吗?
是的,布隆过滤器存在误判。它可能会错误地认为某个元素在集合中,而元素实际上并不在集合中。
但如果布隆过滤器认为某个元素不存在于集合中,那么它一定不存在。
误判产生的原因是因为哈希冲突。在布隆过滤器中,多个不同的元素可能映射到相同的位置。随着向布隆过滤器中添加的元素越来越多,位数组中的 1 也越来越多,发生哈希冲突的概率随之增加,误判率也就随之上升
误判率取决于以下 3 个因素:
位数组的大小(m):m 决定了可以存储的标志位数量。如果位数组过小,那么哈希碰撞的几率就会增加,从而导致更高的误判率。
哈希函数的数量(k):k 决定了每个元素在位数组中标记的位数。哈希函数越多,碰撞的概率也会相应变化。如果哈希函数太少,过滤器很快会变得不精确;如果太多,误判率也会升高,效率下降。
存入的元素数量(n):n 越多,哈希碰撞的几率越大,从而导致更高的误判率。
要降低误判率,可以增加位数组的大小或者减少插入的元素数量。
要彻底解决布隆过滤器的误判问题,可以在布隆过滤器返回"可能存在"时,再通过数据库进行二次确认。
布隆过滤器支持删除吗?
布隆过滤器并不支持删除操作,这是它的一个重要限制。
当我们添加一个元素时,会将位数组中的 k 个位置设置为 1。由于多个不同元素可能共享相同的位,如果我们尝试删除一个元素,将其对应的 k 个位重置为 0,可能会错误地影响到其他元素的判断结果。、
例如,元素 A 和元素 B 都将位置 5 设为 1,如果删除元素 A 时将位置 5 重置为 0,那么对元素 B 的查询就会产生错误的"不存在"结果,这违背了布隆过滤器的基本特性。
如果想要实现删除操作,可以使用计数布隆过滤器,它在每个位置上存储一个计数器而不是单一的位。这样可以通过减少计数器的值来实现删除操作,但会增加内存开销。
为什么不能用哈希表而是用布隆过滤器?
布隆过滤器最突出的优势是内存效率。
假如我们要判断 10 亿个用户 ID 是否曾经访问过特定页面,使用哈希表至少需要 10G 内存(每个 ID 至少需要8字节),而使用布隆过滤器只需要 1.2G 内存。
如何保证缓存和数据库的数据⼀致性?
对于文章标签这种允许短暂不一致的数据,我会采用 Cache Aside + TTL 过期机制来保证缓存和数据库的一致性。
具体做法是读取时先查 Redis,未命中再查 MySQL,同时为缓存设置一个合理的过期时间;更新时先更新 MySQL,再删除 Redis。
这种方式简单有效,适用于读多写少的场景。TTL 过期时间也能够保证即使更新操作失败,未能及时删除缓存,过期时间也能确保数据最终一致。
那再来说说为什么要删除缓存而不是更新缓存?
最初设计缓存策略时,我也考虑过直接更新缓存,但通过实践发现,删除缓存是更优的选择。
最主要的原因是在并发环境下,假设我们有两个并发的更新操作,如果采用更新缓存的策略,就可能出现这样的时序问题:
操作 A 和操作 B 同时发生,A 先更新 MySQL 将值改为 10,B 后更新 MySQL 将值改为 11。但在缓存更新时,可能 B 先执行将缓存设为 11,然后 A 才执行将缓存设为10。这样就会造成 MySQL 是 11 但 Redis 是 10 的不一致状态。
而采用删除策略,无论 A 和 B 谁先删除缓存,后续的读取操作都会从 MySQL 获取最新值。
另外,相对而言,删除缓存的速度比更新缓存的速度快得多。因为删除操作只是简单的 DEL 命令,而更新可能需要重新序列化整个对象再写入缓存。
那再说说为什么要先更新数据库,再删除缓存?
这个操作顺序的选择也是我在实际项目中踩过坑才深刻理解的。假设我们采用先删缓存再更新数据库的策略,在高并发场景下就可能出现这样的问题:
线程 A 要更新用户信息,先删除了缓存
线程 B 恰好此时要读取该用户信息,发现缓存为空,于是查询数据库,此时还是旧值
线程 B 将查到的旧值重新放入缓存
线程 A 完成数据库更新
结果就是数据库是新的值,但缓存中还是旧值。
而采用先更新数据库再删缓存的策略,即使出现类似的并发情况,最坏的情况也只是短暂地从缓存中读取到了旧值,但缓存删除后的请求会直接从数据库中获取最新值。
另外,如果先删缓存再更新数据库,当数据库更新失败时,缓存已经被删除了。这会导致短期内所有读请求都会穿透到数据库,对数据库造成额外的压力。
而先更新数据库再删缓存,如果数据库更新失败,缓存保持原状,系统仍然能继续正常提供服务。
那假如对缓存数据库一致性要求很高,该怎么办呢?
当业务对缓存与数据库的一致性要求很高时,比如支付系统、库存管理等场景,我会采用多种策略来保证强一致性。、第一种,引入消息队列来保证缓存最终被删除,比如说在数据库更新的事务中插入一条本地消息记录,事务提交后异步发送给 MQ 进行缓存删除。
即使缓存删除失败,消息队列的重试机制也能保证最终一致性。
第二种,使用 Canal 监听 MySQL 的 binlog,在数据更新时,将数据变更记录到消息队列中,消费者消息监听到变更后去删除缓存。
这种方案的优势是完全解耦了业务代码和缓存维护逻辑。
当然了,如果说业务比较简单,不需要上消息队列,可以通过延迟双删策略降低缓存和数据库不一致的时间窗口,在第一次删除缓存之后,过一段时间之后,再次尝试删除缓存。
这种方式主要针对缓存不存在,但写入了脏数据的情况。
如何保证本地缓存和分布式缓存的一致?
为了保证 Caffeine 和 Redis 缓存的一致性,我采用的策略是当数据更新时,通过 Redis 的 pub/sub 机制向所有应用实例发送缓存更新通知,收到通知后的实例立即更新或者删除本地缓存。
考虑到消息可能丢失,我还会引入版本号机制作为补充。每次从 Redis 获取数据时添加一个最新的版本号。从本地缓存获取数据前,先检查自己的版本号是否是最新的,如果发现版本落后,就主动从 Redis 中获取最新数据。
如果在项目中多个地方都要使用到二级缓存的逻辑,如何设计这一块?
我的思路是将二级缓存抽象成一个统一的组件。设计一个 CacheManager 作为核心入口,提供 get、put、evict 等基本操作,执行先查本地缓存,再查分布式缓存,最后查数据库的完整流程。
本地缓存和 Redis 的区别了解吗?
Redis 可以部署在多个节点上,支持数据分片、主从复制和集群。而本地缓存只能在单个服务器上使用。
对于读取频率极高、数据相对稳定、允许短暂不一致的数据,我优先选择本地缓存。比如系统配置信息、用户权限数据、商品分类信息等。
而对于需要实时同步、数据变化频繁、多个服务需要共享的数据,我会选择 Redis。比如用户会话信息、购物车数据、实时统计信息等。
什么是热Key?
所谓的热 Key,就是指在很短时间内被频繁访问的键。比如电商大促期间爆款商品的详情信息,流量明星爆瓜时的个人资料、热门话题等,都可能成为热Key。
由于 Redis 是单线程模型,大量请求集中到同一个键会导致该 Redis 节点的 CPU 使用率飙升,响应时间变长。
在 Redis 集群环境下,热Key 还会导致数据分布不均衡,某个节点承受的压力过大而其他节点相对空闲。
更严重的情况是,当热Key 过期或被误删时,会引发缓存击穿问题。
那怎么监控热Key 呢?
临时的方案可以使用 redis-cli --hotkeys 命令来监控 Redis 中的热 Key。
或者在访问缓存时,在本地维护一个计数器,当某个键的访问次数在一分钟内超过设定阈值,就将其标记为热Key。
那怎么处理热Key 呢?
最有效的解决方法是增加本地缓存,将热 Key 缓存到本地内存中,这样请求就不需要访问 Redis 了。
对于一些特别热的 Key,可以将其拆分成多个子 Key,然后随机分布到不同的 Redis 节点上。比如将 hot_product:12345 拆分成 hot_product:12345:1、hot_product:12345:2 等多个副本,读取时随机选择其中一个。
怎么处理大 Key 呢?
大Key 是指占用内存空间较大的缓存键,比如超过 10M 的键值对。常见的大Key 类型包括:包含大量元素的 List、Set、Hash 结构,存储大文件的 String 类型,以及包含复杂嵌套对象的 JSON 数据等。
在内存有限的情况下,可能导致 Redis 内存不足。另外,大Key 还会导致主从复制同步延迟,甚至引发网络拥塞。
可以通过 redis-cli --bigkeys 命令来监控 Redis 中的大 Key。
或者编写脚本进行全量扫描:
对于大 Key 问题,最根本的解决方案是拆分大 Key,将其拆分成多个小 Key 存储。比如将一个包含大量用户信息的 Hash 拆分成多个小 Hash。
另外,对于 JSON 数据,可以进行 Gzip 压缩后再存储,虽然会增加一些 CPU 开销,但在内存敏感的场景在是值得的。
缓存预热怎么做呢?
缓存预热是指在系统启动或者特定时间点,提前将热点数据加载到缓存中,避免冷启动时大量请求直接打到数据库。
缓存预热的方法有多种,我会在项目启动时将热门文章提前加载到 Redis 中,在每天凌晨定时将最新的站点地图更新到 Redis中,以确保用户在第一次访问时就能获取到缓存数据,从而减轻数据库的压力。
无底洞问题听说过吗?如何解决?
无底洞问题的核心在于,随着缓存节点数量的增加,虽然总的存储容量和理论吞吐量都在增长,但是单个请求的响应时间反而变长了。
这个问题的根本原因是网络通信开销的增加。当节点数量从几十个增长到几千个时,客户端需要与更多的节点进行通信。
其次就是数据分布的碎片化。随着节点增多,数据分散得更加细碎,原本可以在一个节点获取的相关数据,现在可能分散在多个节点上。
针对这个问题,可以采取以下几种解决方案:
第一,可以将同一节点的多个请求合并成一个批量请求,减少网络往返次数。
第二,可以使用一致性哈希算法来优化数据分布,减少数据迁移和重分布的开销。
