文章目录
- 1.为什么要有缓存?
- 2.缓存使用场景
- 3.缓存分类
- 4.缓存使用模式
- 5.淘汰策略
- 6.缓存的崩溃与修复
- 7.缓存最佳实践
- 参考文献
1.为什么要有缓存?
数据访问具有局部性,符合二八定律:80% 的数据访问集中在 20% 的数据上,这部分数据也被称为热点数据。
不同层级的存储访问速率不同,内存读写速度快于磁盘,磁盘快于远端存储。基于内存的存储系统(如 Redis)高于基于磁盘的存储系统(如 MySQL)。
因为存在热点数据和存储访问速率的不同,我们可以考虑采用缓存。
缓存缓存一般使用内存作为本地缓存。
必要情况下,可以考虑多级缓存,如一级缓存采用本地缓存,二级缓存采用基于内存的存储系统(如 Redis、Memcache 等)。
缓存是原始数据的一个拷贝,其本质是空间换时间,主要为了解决高并发读。
2.缓存使用场景
缓存是空间换时间的艺术,使用缓存能提高系统的性能。“劲酒虽好,不要贪杯”,使用缓存的目的是为了提高性价比,而不是一上来就为了所谓的提高性能不计成本的使用缓存,而是要看场景。
适合使用缓存的场景,以之前参与的项目企鹅电竞为例:
(1)一旦生成后基本不会变化的数据:如企鹅电竞的游戏列表,在后台创建一个游戏之后基本很少变化,可直接缓存整个游戏列表;
(2)读密集型或存在热点的数据:典型的就是各种 App 的首页,如企鹅电竞首页直播列表;
(3)计算代价大的数据:如企鹅电竞的 Top 热榜视频,如 7 天榜在每天凌晨根据各种指标计算好之后缓存排序列表;
(4)千人一面的数据:同样是企鹅电竞的 Top 热榜视频,除了缓存的整个排序列表,同时直接在进程内按页缓存了前 N 页数据组装后的最终回包结果;
不适合使用缓存的场景:
(1)写多读少,更新频繁。
(2)对数据一致性要求严格。
3.缓存分类
(1)进程缓存
数据直接缓存在进程地址空间内,这可能是访问速度最快使用最简单的缓存方式了。主要缺点是受制于进程空间大小,能缓存的数据量有限,进程重启缓存数据会丢失。一般通常用于缓存数据量不大的场景。
(2)集中式缓存
缓存的数据集中在一台机器上,如共享内存。这类缓存容量主要受制于机器内存大小,而且进程重启后数据不丢失。常用的集中式缓存中间件有单机版 Redis、Memcache 等。
(3)分布式缓存
缓存的数据分布在多台机器上,通常需要采用特定算法(如 Hash)进行数据分片,将海量的缓存数据均匀的分布在每个机器节点上。常用的组件有:Memcache(客户端分片)、Codis(代理分片)、Redis Cluster(集群分片)。
(4)多级缓存
指在系统中的不同层级缓存数据,以提高访问效率和减少对后端存储系统的冲击。
4.缓存使用模式
关于缓存的使用,已经有人总结出了一些模式,主要分为 Cache-Aside 和 Cache-As-SoR 两类。其中 SoR(System-of-Record)表示记录系统,即数据源,而 Cache 正是 SoR 的拷贝。
- Cache-Aside:旁路缓存
这应该是最常见的缓存模式了。对于读,首先从缓存读取数据,如果没有命中则回 SoR 读取并更新缓存。对于写操作,先写 SoR,再写缓存。
这种模式用起来简单,但对应用层不透明,需要业务代码完成读写逻辑。同时对于写来说,写数据源和写缓存不是一个原子操作,可能出现以下情况导致两者数据不一致。
(1)在并发写时,可能出现数据不一致。
如下图所示,user1 和 user2 几乎同时进行读写。在 t1 时刻 user1 写 db,t2 时刻 user2 写 db,紧接着在 t3 时刻 user2 写缓存,t4 时刻 user1 写缓存。这种情况导致 db 是 user2 的数据,缓存是 user1 的数据,出现数据不一致。
(2)先写数据源成功,但是接着写缓存失败,两者数据不一致。
对于这两种情况如果业务不能忍受,可简单的通过先 delete 缓存然后再写 db 解决,其代价就是下一次读请求的 cache miss。
- Cache-as-SoR:缓存即数据源
该模式把 Cache 当作 SoR,所以读写操作都是针对 Cache,然后 Cache 再将读写操作委托给 SoR,即 Cache 是一个代理。
有三种实现方式:
(1)Read-Through:穿透读模式。首先查询 Cache,如果不命中则再由 Cache 回源到 SoR 查询,而不是业务去数据源查询。
(2)Write-Through:穿透写模式。由业务先调用写操作,然后由 Cache 负责写缓存和 SoR。
(3)Write-Behind:回写模式。发生写操作时业务只更新缓存并立即返回,然后由缓存异步写 SoR,这样可以利用合并写/批量写提高性能。
5.淘汰策略
在空间有限、低频访问或无主动更新通知的情况下,需要对缓存数据进行淘汰。常用的淘汰策略有以下几种:
(1)基于时间。
- TTL(Time To Live)存活时间。
从缓存数据创建开始到指定的过期时间段,不管有没有被访问缓存都会过期。如 Redis 的 EXPIRE。
- TTI(Time To Idle)空闲时间。
缓存在指定的时间没有被访问将会被回收。
- LRU(Least Recently Used)最久未使用。
LRU 基于访问时间,淘汰最长时间未被使用的数据。基于时间局部性原理,即如果数据最近被使用,那么它在未来也极有可能被使用。反之,如果数据很久未使用,那么未来被使用的概率较低。
缺点是可能会由于一次冷数据的批量查询而误淘汰大量热点数据。
LRU 缓存一般采用哈希表(hash map)和双向链表(doubly linked list)来实现。
访问数据时,直接从哈希表通过 key 在 O(1) 时间内获取到所需数据。当缓存命中时,将数据移动到链表头部;有新数据时,插入到链表的头部;当缓存满时将链表尾部的数据丢弃。
(2)基于次数。
LFU(Least Frequently Used):最少使用。统计每个对象的使用次数,当需要淘汰时,选择被使用次数最少的淘汰。
基本思想:如果数据过去被访问多次,那么将来被访问的频率也更高。
注意 LFU 和 LRU 的区别,LRU 的淘汰规则是基于访问时间,而 LFU 是基于访问次数。
LFU 相对于 LRU,在应对周期性或者偶发性的冷数据批量查询,适应性较好,不会淘汰大量热点数据,导致缓存命中率下降。但LFU需要记录数据的历史访问记录,一旦数据访问模式改变,LFU需要更长时间来适用新的访问模式,即LFU存在历史数据影响将来数据的"缓存污染"问题。
LFU 缓存同样可以采用哈希表(hash map)和双向链表(doubly linked list)来实现。
双向链表来维护访问次数和时间先后顺序。当数据被访问时,那么访问次数加1,并将数据沿着链表往前移,直到前一个数据访问次数大于当前数据。
(3)基于顺序。
FIFO(First In First Out):先进选出原则,先进入缓存的数据先被移除。
FIFO 策略通常基于一个队列来实现。当缓存空间不足时,新的数据项被添加到队列的末尾,而最早添加到队列的数据项会被移除。
(4)基于大小。
基于大小的淘汰(Size-based Eviction)基于缓存中存储项目的大小来选择要移除的项目。在这种策略中,通常会选择以下几种方式来确定要移除的项目:
- 移除占用空间最大的项目:当缓存空间不足时,选择占用空间最大的项目进行淘汰。这样可以最大程度地释放缓存空间,以容纳更多的新数据。
- 移除占用空间最小的项目:当缓存空间不足时,选择占用空间最小的项目进行淘汰。这样可以尽量减少淘汰的影响,同时保留更多的缓存数据。
- 按照空间大小的优先级进行淘汰:将缓存中的项目按照大小进行排序,然后依次选择要移除的项目,直到腾出足够的空间。这样可以在保证缓存空间有效利用的同时,尽量减少淘汰带来的影响。
Size-based Eviction 策略通常适用于对缓存空间有严格限制的场景,可以根据缓存空间的大小和数据的大小来灵活选择要淘汰的项目,以实现最佳的缓存效果。
6.缓存的崩溃与修复
由于在设计不足、请求攻击(并不一定是恶意攻击)等会造成一些缓存问题,下面列出了常见的缓存问题和解决方案。
- 缓存穿透
大量使用不存在的 Key 进行查询时,缓存没有命中,这些请求都穿透到后端的存储,最终导致后端存储压力过大甚至被压垮。这种情况原因一般是存储中数据不存在,主要有三个解决办法。
(1)设置空置或默认值:如果存储中没有数据,则设置一个空置或者默认值缓存起来,这样下次请求时就不会穿透到后端存储。但这种情况如果遇到恶意攻击,不断的伪造不同的 Key 来查询时并不能很好的应对,这时候需要引入一些安全策略对请求进行过滤。
(2)布隆过滤器:采用布隆过滤器将,将所有可能存在的数据哈希到一个足够大的 Bitmap 中,一个一定不存在的数据会被这个 Bitmap 拦截掉,从而避免了对底层数据库的查询压力。
(3)singleflight:多个并发请求对一个失效的 Key 进行源数据获取时,只让其中一个得到执行,其余阻塞等待到执行的那个请求完成后,将结果传递给阻塞的其他请求达到防止击穿的效果。
- 缓存雪崩
指大量的缓存在某一段时间内集体失效,导致后端存储负载瞬间升高甚至被压垮。通常是以下原因造成:
(1)缓存失效时间集中在某段时间,对于这种情况可以采取对不同的 Key 使用不同的过期时间,在原来基础失效时间的基础上再加上不同的随机时间;
(2)采用取模机制的某缓存实例宕机,这种情况移除故障实例后会导致大量的缓存不命中。有两种解决方案:
(a)采取主从备份,主节点故障时直接将从实例替换主。
(b)使用一致性哈希替代取模,这样即使有实例崩溃也只是少部分缓存不命中。
- 缓存热点
虽然缓存系统本身性能很高,但也架不住某些热点数据的高并发访问从而造成缓存服务本身过载。假设一下微博以用户 ID 作为哈希 Key,突然有一天亦菲姐姐宣布婚了,如果她的微博内容按照用户 ID 缓存在某个节点上,当她的万千粉丝查看她的微博时必然会压垮这个缓存节点,因为这个 Key 太热了。这种情况可以通过生成多份缓存到不同节点上,每份缓存的内容一样,减轻单个节点的访问压力。
7.缓存最佳实践
- 动静分离
对于一个缓存对象,可能分为很多种属性,这些属性中有的是静态的,有的是动态的。在缓存的时候最好采用动静分离的方式。以免因经常变动的数据发生更新而要把经常不变的数据也更新至缓存,成本很高。
- 慎用大对象
如果缓存对象过大,每次读写开销非常大并且可能会卡住其他请求,特别是在redis这种单线程的架构中。典型的情况是将一堆列表挂在某个 value 的字段上或者存储一个没有边界的列表,这种情况下需要重新设计数据结构或者分割 value 再由客户端聚合。
- 过期设置
尽量设置过期时间减少脏数据和存储占用,但要注意过期时间不能集中在某个时间段。
- 超时设置
缓存作为加速数据访问的手段,通常需要设置超时时间而且超时时间不能过长(如100ms左右),否则会导致整个请求超时连回源访问的机会都没有。
- 缓存隔离
首先,不同的业务使用不同的 Key,防止出现冲突或者互相覆盖。其次,核心和非核心业务进行通过不同的缓存实例进行物理上的隔离。
- 失败降级
使用缓存需要有一定的降级预案,缓存通常不是关键逻辑,特别是对于核心服务,如果缓存部分失效或者失败,应该继续回源处理,不应该直接中断返回。
- 容量控制
使用缓存要进行容量控制,特别是本地缓存,缓存数量太多内存紧张时会频繁的swap存储空间或GC操作,从而降低响应速度。
- 业务导向
以业务为导向,不要为了缓存而缓存。对性能要求不高或请求量不大,分布式缓存甚至数据库都足以应对时,就不需要增加本地缓存,否则可能因为引入数据节点复制和幂等处理逻辑反而得不偿失。
- 监控告警
对大对象、慢查询、内存占用等进行监控,做到缓存可观测,用得放心。
参考文献
Cache replacement policies - wikipedia