最近整理了一下关于分布式缓存方面的知识点,温故知新,也是夯实基础,也希望对你能有所收获!
随着互联网行业的迅猛发展,类似大促、秒杀等高并发场景还有春节红包、12306春运车票等亿级流量场景越来越普遍,我们的应用需要扛的访问量、并发量要求更高,而我们服务器的资源是有限的,数据库的连接数、每秒的读写数也是有限的,那么如何利用有限资源来满足尽可能高的吞吐需求呢?两个有效的方法是引入缓存和队列。现在,让我们来回顾一下,关于缓存的基础知识。
一、缓存基础
1.1. 存储空间
一般的,缓存的存储空间可以分为:
- 内存(Java堆和Java堆外)
- 硬盘
- 数据库。
内存缓存无疑是最常见的选择,无额外的I/O开销,高响应、低时延。一般又可以分为:堆缓存和堆外缓存,区别在于:前者存储空间受限于堆内存大小,且当缓存数据量大时GC停顿时间变长(GC需要扫描的对象变多了),后者存储空间只受限于机器内存大小,数据量大小和GC停顿没关系,但在读写时需要序列化/反序列化,没有前者高效。内存缓存的问题,是因缓存数据没有持久化而无可用性,所以我们一般是将内存、磁盘缓存结合使用,充分利用缓存空间,在内存分配空间满了或是触碰到阈值的情况下,将缓存数据落到磁盘中数据备份。
数据库缓存,非“数据库的缓存”,而是利用数据库做缓存的存储空间。我们引入缓存的目的是为了减少DB的请求数、缓解DB的I/O压力,而在缓存之后再引入数据库是为了利用非关系型DB的高性能比如K/V存储来做缓存的持久化空间,而这往往是无上限的。
1.2. 缓存淘汰
在缓存空间不够的情况下,为了提升缓存的命中率,就会进行缓存淘汰,将旧数据移除。
1.2.1. 缓存过期策略
- 基于空间:存储空间的阈值;
- 基于容量:单个条目的最大值;
- 基于时间:TTL-存活期(即在缓存中存活了多久),TTI-空闲期(即多久未被访问过);
- 基于对象引用:软引用和弱引用;
缓存过期策略的实现方式主要有两种:
- 定时淘汰:即主动淘汰,基于时间事件,周期性的中断一下进行缓存淘汰;
- 懒淘汰:即被动淘汰,每次读写操作时比如基于TTL排序发现已失效,则进行缓存淘汰;
定时淘汰是异步的,存在竞态条件(清理时守护线程与用户线程竞争共享锁),而懒淘汰是同步的,有时间开销。一般缓存中都会启用专门的清扫线程周期性的清理缓存,而有些缓存会在每次写操作时按照过期时间排序的优先级队列来清理过期缓存。
1.2.2. 缓存淘汰策略
常见的有三种:
♦ FIFO(先进先出):
最先进入缓存的数据先被移除,主要是比较元素的创建时间,优先保证最新数据可用,在有数据实效性要求的场景下适用;
♦ LRU算法(最近最少使用):
使用时间最远的数据先被移除,主要是比较元素最近一次被get的时间戳,优先保证热点数据有效性,在热点数据场景下很适用;
-【实现】:新数据插入到链表头部,每次缓存命中就将数据移到链表头部,当链表满时将链表尾部的数据丢弃;
-【命中率】:存在热点数据时LRU效率较好,但存在缓存污染的问题;
-【复杂度】:只需要LRU队列,实现简单;
-【代价】:只需在get时遍历链表,找到命中的数据块索引,然后将其移到头部;
实现上,LRU或LFU算法一般就两种选择,一种是LinkedHashMap,一种是自己设计数据结构,包装双向链表和HashMap。反正数据结构肯定会是链表,为了便于元素移动,而缓存常见的结构就是KV结构。
为了解决LRU算法的“缓存污染”问题,衍生出了几个LRU算法的变种:
♢ LRU-K算法:
K代表最近使用的次数,LRU可以认为是LRU-1。LRU-K算法的核心思想,是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。
-【实现】:数据第一次被访问时加入到访问历史列表,当访问次数达到K次时,将数据放入LRU队列;当需要淘汰数据时,淘汰第K次访问时间距离当前最久的数据;
-【命中率】:降低“缓存污染”的问题,命中率比LRU要高;
-【复杂度】:多维护一个访问历史列表,复杂度比LRU要高;
-【代价】:由于维护两个队列,内存消耗比LRU多;访问历史列表基于时间进行排序(淘汰时再排序或即时排序),CPU消耗比LRU高;
在实际应用中LRU-2是综合各种因素后最优的选择,LRU-3或更大K值命中率更高,但适应性更差,需要大量的数据访问才能将历史访问记录清除掉。
♢ 2Q算法:
类似于LRU-2,区别在于:2Q将LRU-2算法中的访问历史队列(数据索引)变为FIFO队列(缓存数据),那么这意味着,2Q的内存消耗与LRU-2相似,但CPU消耗会比LRU-2更优。
-【实现】:数据第一次被访问时加入到FIFO队列中,数据第二次被访问时,从FIFO队列迁移到LRU队列里;两个队列按照各自的方式淘汰数据;
-【命中率】:降低“缓存污染”的问题,命中率比LRU要高;
-【复杂度】:多维护一个FIFO队列,复杂度比LRU要高;
-【代价】:FIFO和LRU的代价之和;
♢ MQ算法:
MQ算法的核心思想是“优先缓存访问次数多的数据”。根据访问频率将将缓存划分为多个LRU队列,不同的队列具有不同的访问优先级(根据访问次数计算出访问优先级),并按LRU管理。Q-history代表从缓存中被淘汰数据的引用队列。
-【实现】:新加入的数据放入Q0,当数据达到一定的访问次数,则提升优先级(从当前队列迁移到高一级的队列头部);当数据在一定时间内未被访问,则降低优先级;当需要淘汰数据时,从低优先级队列开始淘汰(将数据从缓存中删除,对应的缓存索引加入Q-history头部);当被淘汰数据在Q-history被重新访问,重新计算其优先级;
-【命中率】:降低“缓存污染”的问题,命中率比LRU要高;
-【复杂度】:维护多个队列,复杂度比LRU要高;
-【代价】:虽然需要定时扫描所有队列,但由于所有队列之和受限于缓存容量的大小,因此多个队列长度之和和一个LRU队列是一样的,队列扫描性能也相近;
实际应用中需要根据业务场景来看,并不是命中率越高越好。比如,虽然LRU命中率低且存在”缓存污染“问题,但由于简单高效、代价小,实际应用中反而是应用最多的。
对比点 |
对比 |
---|---|
命中率 |
LRU-2 > MQ(2) > 2Q > LRU |
复杂度 |
LRU-2 > MQ(2) > 2Q > LRU |
代价 |
LRU-2 > MQ(2) > 2Q > LRU |
♦ LFU算法(最少使用):
根据数据的历史访问频率来淘汰数据,淘汰一定时间内最少使用的数据。主要是为了解决“新加入的数据总是易于被淘汰”的问题。
每个数据块都有一个hit值-引用计数,所有数据块按照hit值排序,具有相同hit的数据块按时间排序。
-【实现】:新加入的数据放到队尾,此时引用计数为1;被访问后,引用计数++;当需要淘汰数据时,列表末尾的数据被淘汰;
-【命中率】:一般情况下,LFU效率要优于LRU,且能够避免“缓存污染”问题,但存在访问模式问题;
-【复杂度】:需要维护一个访问历史队列,每个数据维护引用计数;
-【代价】:需要记录所有数据的访问记录,内存消耗较高,需要基于引用计数排序,性能消耗较高;
为了解决LFU算法的“访问模式”和成本问题,又衍生出了几个LFU算法的变种:
♢ LFU*:
其核心思想是“只淘汰访问过一次的数据”。类似于LFU,区别在于:淘汰数据时,LFU*只淘汰引用计数为1的数据,且如果所有引用计数为1的数据大小之和小于新加入的数据大小,则不淘汰数据,新的数据也不缓存。
-【实现】:同LFU;
-【命中率】:与LFU类似,但由于不淘汰引用计数大于1的数据,仍存在严重的访问模式问题(一旦访问模式改变,LFU*无法缓存新的数据,命中率更低);
-【复杂度】:只维护一个引用计数为1的访问历史队列,复杂度比LFU稍低;
-【代价】:因为只需要维护引用次数为1的数据的访问记录,无需排序,代价比LFU要低;
♢ LFU-Aging:
其核心思想是“除了访问次数外,还要考虑访问时间”,LFU-Aging并不直接记录数据的访问时间,而是通过平均引用计数来标识时间。
-【实现】:增加一个引用计数,如果当前缓存中的数据“引用计数平均值”>=“引用计数最大平均值”时,则将所有数据的引用计数减少为原来的一半或减去固定的值;
-【命中率】:降低“访问模式”的问题(缩小为原来的一半),命中率比LFU要高;
-【复杂度】:增加了平均引用次数的判断、处理,复杂度比LFU要高;
-【代价】:同LFU;
加上TinyLFU,对比图如下:
对比点 |
对比 |
---|---|
命中率 |
TinyLFU > LFU-Aging > LFU > LFU* |
复杂度 |
LFU-Aging > LFU> TinyLFU > LFU* |
代价 |
LFU-Aging > LFU > TinyLFU > LFU* |
缓存的关注点之一,是在于如何提升缓存的命中率,而缓存的淘汰策略模型就是为了预测哪些数据以后可能会被访问到,以达到更高的命中率、更好的性能。常见的比如LRU基于“如果数据最近被访问过,那么以后被访问的几率也更高”,LFU基于“如果数据过去被访问多次,那么以后被访问的几率也更高”,显而易见,它们二者的区别,一个是比较最近的访问时间,淘汰长时间未被访问的数据,一个是比较访问频率,淘汰访问次数少的数据。而使用LRU或使用LFU就对应的引出了两个问题:
- 缓存污染问题:由于偶发性或周期性的冷数据批量查询,热点数据被挤出去,导致缓存命中率下降,影响缓存整体效率。
- 访问模式问题:一旦访问模式改变,缓存需要更长时间来适应新的访问模式,导致缓存命中率下降,进而拉低整体效率。
LRU能够迅速反映随时间变化的数据访问模式,LRU的优点是优先保证热点数据有效性,常规场景下有着不错的命中率,但LRU的缺点是存在“缓存污染”问题,它认为最后到来的数据是最可能被访问到的,这是有局限的。而LFU优在当数据访问模式固定时,通过统计访问频次能够带来最佳的缓存命中率,但LFU的缺点在于,需要频繁更新数据块计数以及排序,代价昂贵,且存在“访问模式”问题。比如一段时间内,一个数据块3,若访问走向为2 1 2 1 2 3,则按照LRU策略应该淘汰数据块1,因为它最久未被使用,而按照LFU策略应该淘汰数据块3,因为它被使用的频率最低。实际上,常见的数据访问模式并不固定或随时间变化,所以大多数的缓存设计都基于LRU策略设计,或为了达到更高命中率而维护多个队列的LRU变种,再或者是综合了LRU和LFU长处的W-TinyLFU,通过LFU来应对多数场景、LRU来应对突发流量。
1.3. 缓存使用模式
有几个名词需要注意:
- SoR(system-of-record):数据源,实际存储原始数据的系统;
- Cache:缓存,是SoR的快照数据,Cache的访问速度比SoR要快,放入Cache的目的是为了提高访问速度,减少回源到SoR的次数;
- 回源:Cache没有命中时,需要回到SoR获取数据;
1.3.1. Cache-Aside
常见的标准方案,由业务代码围绕着Cache写,业务代码直接维护缓存:
- 缓存失效:读操作先从Cache里取,没命中则回源到SoR去取而后转载缓存,命中则直接返回;
- 缓存更新:先写库再失效缓存;
Cache-Aside模式在一些应用频繁访问相同数据的时候尤其有效。
很多人采用“先失效缓存再写库,再后续读转载缓存”,这个逻辑是错误的,存在缓存并发问题:写的时候读。两个并发操作,一个写一个读,写库之前先失效缓存,这时候读操作没有命中缓存,导致老数据装载到缓存中从而形成脏数据。那么,为什么不是写完库后更新缓存呢?实际上,这也存在并发问题:并发的写。两个并发的写操作A和B,A写完库后去更新缓存,此时B也写完库并先一步更新了缓存,然后A覆盖更新了缓存,从而形成脏数据。
但Cache-Aside模式也不是没有问题,也存在并发问题:读的时候写。一样是两个并发操作,一个读一个写,读操作未命中缓存则回源到SoR获取数据,这时候写操作写完库,然后之前的读操作再把老数据回填到缓存中,从而形成脏数据。但这种情况概率很低,因为要求读的时候写进来并且在写完之后完成,而实际上写通常比读要慢,而且还要锁表。
1.3.2. Cache-AS-SoR
简单来说,就是把Cache看作SoR代理,所有操作在Cache进行,然后Cache再委托给SoR进行真实的读/写,对应用透明。
它具体可以分为以下三种:
-
Read/Write Through:
即穿透读/写模式,“Read Through”就是在读操作中,缓存失效或未命中时由Cache回源到SoR,而“Write Through”就是在写操作中,由Cache负责同步写缓存和SoR; -
Write Back:
即回写模式,同样是业务代码只操作缓存,但是由Cache与DB做异步同步(批量写•合并写•延时写);
-
Copy pattern:
分为Copy-On-Read(读时复制)和Copy-On-Write(写时复制),不常用。
Cache-AS-SoR的优点在于,让应用的业务代码保持简洁,而不必像Cache-Aside模式那样将对缓存操作的代码和SoR代码交织在一起;第二是提供了高性能、高可用,即使数据库宕机了,缓存也能起到部分缓冲作用。但Cache-AS-SoR的优点,是数据不是强一致性的(有中间态),要做很多数据冗余和数据判断来保证缓存一致性。比如跟踪那份数据被跟踪了、哪份缓存数据要失效需要做持久化了、那个机器内存不够了或者进程退出了也要做持久化,而且,因为DB的更新是滞后于Cache的,这意味着写DB的事务只能成功不能失败,写DB的队列入队列时要做很多校验。
1.4. 缓存问题
缓存数据不存在或缓存失效导致的问题可以分为3类:缓存穿透(大量请求不命中),缓存击穿(热点缓存失效),缓存雪崩(缓存同一时间失效)。一般我们会架设多级缓存,虽然同样也会有缓存一致性问题,但它解决的是缓存穿透与程序的健壮性,当集中式缓存出现问题的时候,我们的应用仍能够继续运行。
1.4.1. 缓存穿透
即使用不存在的key进行并发量的查询,每次都穿透到SoR,致使数据库服务被压死。一般的解决方案有以下几种:
- Bloom Filter:通过将所有可能存在的数据哈希到一个足够大的BitMap中来预防;
- 空值缓存:
即将空值缓存起来,比如数据查询时,Cache未hit,DB也未hit,则将null或空值放入Cache,收到同样的查询请求时就直接返回,来避免缓存穿透。
但这要求有效性,缓存起来的空值只能有较短的过期时间(一般最长不超5min),避免放大缓存不一致窗口。 -
参数过滤:对输入的请求参数进行过滤,比如格式校验、放入时间戳做校验,来过滤非业务请求和外部恶意攻击。
1.4.2. 缓存击穿
对于一些热点数据,多个请求同时发现缓存过期,迫使多个请求同时查询数据库然后同时回写缓存,致使数据库服务、缓存服务被压死。一般的解决方案,就是软过期机制:在value内设置一个比缓存实际的过期时间要小的timeout,当Cache发现它快过期时,延长timeout并重新load(定时扫描或懒加载)。
1.4.3. 缓存雪崩
缓存服务宕机或大量缓存在同个时间点集中失效,系统宕机后重启重新构建缓存时大量请求涌进来,DB瞬时负载过重,直至压垮。一般的解决方案,就是失效时间分散:对不同的数据使用不同的失效时间,对相同的数据也使用不同的失效时间,比如在原有失效时间的基础上增加一个1~2min的随机值,避免集中过期。另外要求,重新构建缓存服务时需要预热阶段,全量转载或基于历史记录装载热点数据,完成后才对外提供缓存服务。
很多时候,特别是读多写少且读远大于写的场景下,缓存击穿比缓存不一致更可怕,很容易导致缓存宕机而造成雪崩。这就要求我们,不仅在应用层面或缓存服务上,数据库也要考虑抗压,因为可能击穿后就永远起不来了,连接上接着击穿。通常我们会在业务上做拆分,区分出热点数据并放入缓存,并降低冷数据在缓存的滞留时间,也会做负载均衡,平衡缓存服务的负载压力等。
1.4.4. 缓存一致性
只要有数据冗余的地方,就会有数据不一致性问题。缓存的一致性问题,主要就两点:脏数据,数据不存在。用典型的Cache-Aside来说,写DB后失效Cache失败了,即使retry也没成功,可能中间网络抖动了几秒或者就是缓存服务器挂了,导致缓存脏数据。
一般的解决方案是:
-
不要缓存那些对数据一致性要求很高的数据;
- 对失效缓存请求做异步的梯度重试;
- 做心跳检测,缓存服务起来之后,按顺序依次回填到Cache中;
- 给缓存数据项一个统一范围内的过期时间作为默认下限,同时对过期策略适时调整(太短则频繁装载,太长则易出现脏数据且浪费存储空间);
- Cache启动后会有“预热”操作,多数情况下在应用启动时会将缓存数据装载完成,而后定期做全量更新+数据变更通知做增量更新;
要保证缓存DB强一致性是很难的,肯定会存在一个不一致的时间窗口,如果业务能够容忍缓存自动过期的最终一致性,那就没什么问题;如果不能够容忍,则要设法降低并发访问时脏数据的概率。比如上面,其中第3步、第4步是为了降低缓存脏数据的概率。
二、缓存应用
2.1. 近端缓存
即应用本地的缓存组件,满足单机场景下小数据量的缓存并发需求,特点是应用和Cache在同一个进程内,请求高效、响应时延低,没有过多的网络开销,特别适应比如秒杀、大促这种流量大、并发大,需要挡住流量的场景。但它也有缺点:
- 本地缓存与应用程序耦合;
- 集群的各个节点都需要维护自己单独的缓存空间的成本;
- 受heap区影响,缓存中存放的数据总量不能超出内存容量,且缓存时间受GC影响;
- 进一步提升不一致窗口,容易带来缓存不一致问题,读到脏数据;
近端缓存对数据变更是无敏感感知的,近端缓存一致性问题的核心,就是时间窗口能否接受,“只要数据冗余,都会有不一致的时间窗口”。如果能够容忍,允许数据一致性有时延,我们一般通过近端定时refresh、远端变更时通知近端主动去拉取这样非实时的方式,来缩减不一致窗口,降低成本。
常见的近端缓存实践,比如Ehcache、GuavaCache还有Caffeine。
2.1.1. Ehcache
Ehcache是一个轻量级的开源缓存框架,它的核心定义包括:CacheManager(缓存管理器),Cache(缓存数据),SoR(真实数据源)。它的特性主要是:
- 快速,特别是高并发场景;
- 简单,很小的jar包,简单配置就可直接使用,单机场景下无需过多的服务依赖;
- 支持两种缓存存储,能满足更高的数据缓存需求,也能在异常宕机情形下避免数据丢失;
内存(堆内存heap,堆外内存BigMemory)和磁盘。 - 具有缓存和缓存管理器的监听接口,能方便的进行缓存实例的监控管理
Ehcache的问题,主要是Ehcache的过期策略是针对整个Cache实例设置,没有直接针对单独key的过期设置,而且过期失效的缓存元素无法被GC回收,需要手动清理,否则容易造成内存泄露。
2.1.2. GuavaCache
GuavaCache是Google开源的Java工具集库Guava里缓存框架,主要提供的特性:
- 根据entry节点被访问或写入的时间计算它的过期值,并在下次被访问时验证是否已过期、是否已回收(懒淘汰机制);
- 支持强引用、WeakReference、SoftReference的Value和WeakReference的Key,来保证KV的GC可回收;
- 自动统计缓存使用过程中的命中率、未命中率、异常率等;
GuavaCache的内存数据模型是这样的:
GuavaCache主要基于ConcurrentHashMap设计,使用Segments来做细粒度的锁拆分,保证线程安全同时支持高并发场景。但与ConcurrentHashMap相比,GuavaCache增加了更灵活的元素过期策略,而ConcurrentHashMap只能显示的移除元素。GuavaCache提供了三种常见的元素回收策略:基于容量回收(可以设置权重)、定时回收和基于引用回收。定时回收有两种:按照访问时间进行LRU淘汰,按照写入时间进行过LRU淘汰。
- accessQueue、writerQueue双向链表,是为了适应定时回收的写后过期和读后过期,需要清理时只需要检查Segments是否设置了expire时间,如果有的话遍历Queue(存放ReferenceEntry的引用)查找已expire的Entry,将其移除;
- ValueReference对象会保留对ReferenceEntry的引用,因为Value在因为WeakReference、SoftReference被回收时,需要将其key对应的元素从Segment的table中移除;
- 一般为了限制内存占用,将GuavaCache设置为定时回收。但所谓“自动回收”并是说明会自动执行清理和回收工作,也不会在某个缓存项过期后马上清理,GuavaCache会在写操作时顺带做回收工作,或者偶尔在读操作时做——如果写操作实在太少的话。这样做的原因在于:如果要自动地持续清理缓存,就必须要有一个线程,势必会和用户操作竞争共享锁。那么这意味着,如果你的业务场景是高吞吐、写多读少的话,那就无需担心缓存的维护和清理工作,如果你的业务场景只是偶尔会有写操作,而你又不想缓存清理工作阻碍了读操作(因为是同步的),那么只能自己起一个维护线程,固定周期的调用Cache.cleanUp();
- 缓存刷新和缓存淘汰不太一样,缓存刷新表示为键加载新值,这是可以异步的,在刷新操作时,缓存仍可以向其他线程返回旧值。而缓存淘汰表示对缓存空间的清理,此时缓存是中断的,读缓存的线程必须等待缓存清理完成。如果缓存刷新过程抛出异常,缓存将保留旧值;
2.1.3. Caffeine
Caffeine是基于Java8的高效缓存库,提供了高命中率和高并发能力。在内部,Caffeine的淘汰策略使用了W-TinyLFU,过期策略实现上是定时淘汰机制,并且也维护了写后过期与读后过期两个顺序队列来做缓存清理。
前面我们说到了TinyLFU,它类似于LFU,但它只维护一段近期的访问频率,记录过去N个数据块的访问历史,当需要淘汰时再将这N个数据块的访问记录按照频次排序进行淘汰。TinyLFU基于Bloom过滤器理论,实现上,它通过一种叫“Sketch”的数据结构,来避免维护访问频率信息的高开销,保证很低的False Positive Rate。“Sketch”数据结构由计数矩阵和多个哈希组成,发生读取时,矩阵中每行对应的计数器加1,而估算频率时,取数据对应行中计数的最小值,通过对矩阵长宽和哈希碰撞的错误率上的适配来做空间、效率上的平衡。
TinyLFU通过将Sketch作为过滤器,当新来的数据比要淘汰的数据高频时,这个数据才会被缓存接纳,这种“许可窗口”给予了每个数据项积累热度的机会,避免了持续的未命中。特别是在突发流量量很高的场景,一些短暂的突发流量不会被长期保留。而且,为了适应随时间变化的访问模式,它通过基于这种“许可窗口”的时间衰减机制(当计数达到一个阈值时,所有的计数减半),由一个线程周期性或增量的执行。
♦ W-TinyLFU
对于长期保留的数据,W-TinyLFU使用了分段SLRU(Segmented LRU)策略,来确保热数据不被淘汰。一个数据项最先被存储在试用段中,当后续被访问到时,则提升到保护段中(保护段占总容量的 80%);保护段满后,相对低频的数据被淘汰回试用段,同时也级联的触发试用段的LRU淘汰。SLRU的目的,是把在一个时间窗口内命中至少2次的记录和命中1次的单独存放,来将短期内较频繁的缓存元素区分开来。
具体的顺序是这样的,数据首先送到前端小的LRU(容量只占总空间的1%),作为存放短期突发访问流量,LRU满了之后再送到TinyLFU做过滤,之后存放到大的SLRU缓存里,作为存放长期的保留数据。
鉴于W-TinyLFU由LRU、TinyLFU、SLRU组成,也有“缓存污染”问题和“访问模式”问题但并不严重,相比其他缓存策略,W-TinyLFU的缓存命中率可以达到最优,且内存消耗、性能消耗都要更低。但W-TinyLFU也有缺点,它主要用来解决稀疏的访问突发情况,在缓存很小、突发访问量很大的情况下,W-TinyLFU理论上也不能很好的处理,因为这些数据项无法在给定的时间内积累到足够高的访问频率而被淘汰掉了。
♦ 并发更新
大多数场景中,缓存的读取伴随着缓存状态的写操作,传统的解决办法就是加锁,或是对缓存做分区通过锁拆分来降低并发度。但对于热点数据,这样的方式并没能带来多少性能提升。Caffeine对这种并发更新问题,采用了缓冲区的方式来避免锁操作:读取时的写操作先放到RingBuffer中(每个线程独立维护),当RingBuffer满了之后做异步批处理。这个策略依然需要tryLock,但通过把对锁的竞争转移到对缓冲区的追加写上,提升了写的性能。
对缓存进行更新时,通过并发队列,多个线程写、单个线程来执行这种多生产者/单个消费者的模式,并通过状态机来管理单个数据项的生命周期,来避免单个数据项的操作乱序的竞态条件。
2.2. 远端缓存
一般分布式缓存架构,按请求顺序是这样的:
- CDN缓存层:缓存视频、图片、文件等静态资源;
- 反向代理层:缓存用户请求的HTML、CSS、JS等静态资源;
- 本地应用缓存层:缓存应用字典等常用、固定的数据;
- 分布式缓存层:缓存数据库中的热点数据,或者经过复杂运算求出的数据;
通常意义上的分布式系统缓存,多实例、集群部署,解决了本地缓存的单点问题、单机容量问题。通常,我们把最热的数据放到堆缓存,相对热的数据放到堆外缓存,全量数据放到分布式缓存中。分布式缓存或者说分布式场景下缓存的适用场景,就是读多写少少更新,缓存热数据而不是冷数据。我们操作分布式缓存的过程,读操作是先缓存后DB,写操作是先DB后缓存。
常见的远端缓存实践,比如Memcached,Redis和Tair。
2.2.1. Memcached
Memcached基于Libevent高性能通信,简单、高效。其实Memcached比较老了,本身并不提供分布式缓存解决方案,在服务器端实际就是一个个Memcached服务器的堆积,各节点之间互不通信、彼此隔离。Memcached的分布式功能主要依靠客户端实现,通过一致性Hash的路由策略,同一个key的所有操作都落在Memcached集群的同一个节点上。
Memcached是一个内存缓存,没有持久化机制,意味当服务故障重启会导致缓存数据丢失。而且上面说过,内存缓存系统受限于内存大小的影响,当缓存空间容量达到阈值之后,就会基于LRU淘汰策略剔除最早未被使用过的缓存数据,来提高命中率。
Memcached的内存管理还有一个特点:内存预分配,省去了内存分配时间。Memcached的内存结构由slab内存块和其中若干个chunk结构体组成(一对KV)。当我们要添加一块数据item时,Memcached首先根据item数据大小选择一块slab(默认1M),并检查其中的chunk还有无空闲,若没有则再申请一块slab并划分为该chunk空间(2个slab)。
♦ 一致性Hash(DHT):
假如我们通过hash(i)%N,对key做哈希值取模来做散列,路由访问同样的规则。比如我们有100台服务器,一份数据101进来,按散列公式 hash(i)%100 计算存放的服务器。假设hash(i) = i,那么数据被散列到标号为1的服务器,然后这时候服务器新增了一台,然后散列公式为hash(i)%101,请求访问101数据时,被分配到了0号服务器但其实数据是在1号服务器的,所以缓存失效了(访问不到)。那么如果我们这时对数据进行重新散列,进行数据迁移会怎么样?这意味着每次增减服务器的时候,需要大量的IO通信来做数据迁移,并影响可用性和并发度。最关键的问题在于,服务器数量变动时,要能够保证旧的数据走老的散列算法、新的数据走新的散列算法,都能找到数据所在的服务器节点。
一致性Hash就是为了解决分布式存储结构下动态增/删节点所带来的问题,具体过程:
- 一个环形结构的缓存空间,总共分成0 ~ 2^32个缓存区;
- 每一个缓存key都可以通过Hash算法转化为一个32位的二进制数,映射到环形空间的不同位置。遵循顺时针规则,每一个key顺时针方向的最近节点,就是key所归属的存储节点;
- 增删节点时,只有一小部分key的归属会受到影响(不是直接的数据迁移,而是在查找时去寻找顺时针的后继节点,因缓存未命中而刷新缓存),整个环形空间的映射仍然保持一致性哈希的顺时针缓存结构;
为了避免服务节点较少时数据倾斜问题,我们通过虚拟节点来解决:假设有4台服务器,A上面有热点数据,负载过高导致A挂掉了,然后按照一致性hash算法,A的数据迁移至B,然而B需要承受A+B的数据负载,承受不住也挂了,然后继续CD都挂了,这就造成了雪崩效应。关键的问题在于,如果有一台机器挂了,那么它的压力会被全部分配到后继机器上,导致雪崩,但如果,一个节点挂了,能将它的压力引流到剩余节点上而不是全部都压到后继节点上,这样的问题就能够解决。这就是虚拟节点了:基于原来的物理节点映射出N个子节点,将这些子节点映射到环形空间上。在实际应用中,通常将虚拟节点数设置为32甚至更大,使得缓存key与虚拟节点的映射关系变得相对均衡。并且通过引入虚拟节点,如果一台服务器宕机,能够将压力引流至不同的服务器上(虚拟节点是均匀散列的,而非顺序节点)。如果存在热点数据,通过增加节点的方式对热点区间进行划分,将压力引流至其他节点,从而重新达到负载均衡的状态。
DHT的优点是,减少了数据映射关系的变动,不会像hash(i)%N取模增删节点会带来全局变动;解决了分布式环境下负载均衡问题,实际上,只要是涉及到分布式存储的负载均衡问题,一致性hash都是常见的解决方案。
2.2.2. Redis
Redis其实是一个分布式KV数据库(非关系型),支持五种数据类型:string、hash、list、set及zset,并且作为数据库,提供对缓存的持久化支持。Redis在内部存取数据时,使用一个redisObject对象来标识所有的key和value数据。
Redis的缓存淘汰策略是这样的:
- volatile-lru:从已设置过期时间的数据集中淘汰最近最少使用的数据
- volatile-tti:从已设置过期时间的数据集中淘汰将要过期的数据
- volatile-random:从已设置过期时间的数据集中随机淘汰
- allkeys-lru:从数据集中淘汰最近最少使用的数据
- allkeys-random:从数据集中随机淘汰
- no-enviction:禁止驱逐
比如,当缓存数据呈现幂律分布时,也就是访问频率一边高,一般会使用allkeys-lru即普通的LRU策略,但如果缓存数据呈现平等分布时,也就是访问频率相当,则使用allkeys-random随机淘汰策略。
Redis的缓存固化方式,主要有以下这两种:
♦ RDB:定期内存快照,默认的持久化方案,数据库快照以二进制方式保存到磁盘中。根据设置的保存点,当触发条件满足时,进行数据库快照保存。
默认的保存点:
- 如果5分钟内,有10个键被修改
- 如果15分钟内,有一个键被修改
- 如果60秒内有10000个键被修改
优点:灾难恢复时恢复速度比AOF要快
缺点:如果保存点设置的过大,会导致宕机丢失的数据过多;保存点设置的过小,又会造成IO瓶颈;写RDB内存快照时会(AOF重写也会)阻塞主线程。如果数据集过大可能会导致更多的操作耗时,这意味着Redis可能在短时间内不可用。
♦ AOF:记录数据状态,以协议文本的方式,将所有对数据库写操作的命令记录追加到AOF文件中。当Redis故障重启时,通过回访AOF文件来还原数据集。
AOF重写:当AOF文件过大的时候,会在后台进行重写(rewrite),确保AOF日志文件不会过大。一般,AOF文件保存的数据集会比RDB文件保存更完整。因为Redis缓存中的数据是有限的,很多数据会被自动过期或被用户删除,但这些操作都会被AOF文件记录下来。这也就意味着,重建数据集时根本不需要执行所有AOF记录的命令。
aof重写点:
-
AOF_FSYNC_NO:不保存。每执行一条命令都会将协议字符追加到server.aof_buf文件中,但不会写入数据到AOF文件。只有发生Redis被正常关闭重写缓存已满时,才开始写入aof文件;
-
AOF_FSYNC_EVERYSECS:每秒保存一次,默认策略。由后台每次save执行时,判断是否已经过去1s来决定是否进行写入到AOF文件;
-
AOF_FSYNC_ALWAYS:每执行一个命令都保存一次。保证每条命令都被保存,保证数据不会丢失,但缺点是性能大大下降;
优点:保障数据不丢失:默认情况下AOF会每隔一秒执行一次fsync操作,所以如果发生宕机,最多只会丢失1秒的数据(而RDB是5分钟或者更长时间才做一次)。
缺点:追加模式写入,没有任何磁盘寻址的开销,所以写入速度比RDB要快很多;
- 同一份数据,AOF通常会RDB更大
- 重写AOF时会占用大量的CPU和内存资源,导致服务load过高,可能会导致短暂停服的现象
- 灾难恢复时,AOF模式性能与RDB模式性能的高低,取决于AOF选用的fsync模式(因为数据集过大会影响重启速度),但一般,AOF都会比RDB要慢
♦ RDB+AOF:
Redis的缓存固化,更多的是提供一个缓存数据备份的功能。RDB内存快照的方式,是根据配置周期化将内存数据刷到磁盘上,生成一份rdb快照文件,而AOF日志文件的方式,是在每次执行写命令之后记录下来。仅仅使用RDB方式,有可能会丢失较多数据(比如5分钟内),而仅仅使用AOF方式,又存在复杂的数据备份和较慢的灾难恢复的问题。那么,为什么不将两者结合使用呢?实际应用中,通过综合使用AOF和RDB两种持久化机制,用AOF保证数据不丢失,作为数据恢复的第一选择(因为其中的日志更完整),用RDB来做不同程度的冷备(比如每小时备份一次+保留最近48小时的数据,每天备份一次+保留最近一个月的数据,或每天将RDB备份文件都上传到云端,做留存),在AOF文件丢失或者不可用的情况下,可以使用RDB来进行数据恢复。
但是,Master不建议做任何Redis缓存持久化工作,包括特别是RDB内存快照,非常耗性能。如果数据比较关键,某个Slave开启AOF备份数据,默认每秒同步一次就能满足要求了。
Redis和Memcached相比较:
Redis | Memcached | |
---|---|---|
支持的数据类型 | 支持复杂数据类型(字符串+哈希+列表+集合) | 单索引的纯KV结构 |
是否持久化 | 支持RDB或AOF持久化 | 不支持 |
是否高可用 | 支持集群模式和哨兵模式 | 没有可用性 |
单个存储内容 | 最大512M | 最大1M |
性能 | 普通场景、同等环境下,Memcached比Redis性能高30%左右,而单机QPS,Memcache约60W,Redis约10W | |
线程模型 | 单线程的I/O复用模型 | 多线程管理 |
最后,来张知识体系导图作为总结:
※ 附录:
参考:
- 缓存哪些事:tech.meituan.com/2017/03/17/cache-about.html;
- 现代化的缓存设计方案:ifeve.com/design-of-a-modern-cache/;
- Caffeine GitHub地址:github.com/ben-manes/caffeine;