架构之全面认识缓存

对于缓存,我相信大家都不陌生,如果你是一名程序员,可以问下自己这样一个问题:

你目前开发的系统中,哪里用到了缓存?为什么要引入缓存?如果系统中去掉缓存系统,对于整个系统会带来什么好处和坏处?

如果从软件和硬件的整体来看我们的系统,缓存的使用几乎无处不在。

  • HTTP的请求中可以约定缓存,请求头部Cache-Control就是用来控制缓存;
  • CDN是一种缓存,缓存静态的资源,保护源站;
  • Nginx可以配置proxy_cache定义代理缓存,进程中的Map,List亦可作为缓存;
  • 消息队列可以看作一种缓存,将数据缓存在一个独立的系统中,以便各个上下游处理;
  • Redis,Memcache等常常作为分布式的缓存系统,在系统中独立扮演缓存角色;
  • 数据库系统的Buffer缓存,操作系统的页缓存,CPU中的L1 L2 L3缓存
  • ……

接下来,我们主要还是围绕服务端系统中的缓存来讨论。

大家一个普遍的看法就是,引入缓存提高了响应性能。这种说法并没有问题,从本质上缓存它只是让数据更加靠近使用者,从而缓解了CPU的压力和IO的压力,结果就是带来了性能的提升,毕竟它是一个典型的空间换时间的例子。

缓存分类

从分类来讲,缓存分为进程内缓存分布式缓存

  • 进程内缓存的选择有很多,比如JDK自带的ConcurrentHashMap,开源的有Ehcache,Guava Cache以及Caffeine等等。
  • 分布式缓存也有像Memcache和Redis为代表。

对于进程内缓存的几种框架的性能对比, 借用Caffeine的Benchmarks报告

100%的读场景:

读 (75%) / 写 (25%)场景

100%写场景

从图片可以看出,Caffeine已经成为事实上的性能最高的进程内缓存开源组件。

接下来再对比下两种分布式缓存Memcached和Redis:

特性MemcachedRedis
亚毫秒级延迟
开发人员易用性
数据分区
支持多种编程语言
高级数据结构
多线程架构
快照
复制
事务处理
发布/订阅
Lua 脚本编写
地理空间支持

缓存的属性

再考虑使用缓存时,我们会重点考虑它的性能(吞吐量)命中率两种属性

性能

从上面的图可以看到,对于进程内的缓存,Caffeine比较好;分布式缓存Redis和Memcached的读写性能都不错,都是亚毫秒级别,但是redis支持的数据结构更多,并且天然支持快照持久化。

进程内缓存

如果是简单的KeyValue对缓存,并且数据量不太大,我们完全可以使用ConcurrentHashMap,在保证多线程安全方面的前提下,它是我们能找到的性能最佳选择,其内部对元素进行无锁CAS的操作。

如果业务复杂,需要更多功能。比如需要根据缓存写入时间或者缓存读取时间对数据进行过期删除;限制缓存最大容量并提供合适的数据删除策略;提供缓存的统计信息,比如命中率;有事件通知策略,能够监听数据失效刷新等,那可以考虑Caffeine。

为什么Caffeine的性能会比较好,我们从它的设计上可以看出一些端倪。

  1. 从本质上Caffeine内部仍然用ConcurrentHashMap来缓存数据。
  2. 为了满足过期删除,淘汰等功能,需要对缓存数据维护额外的统计值,比如最近的访问时间(accessTime),写入时间(writeTime),访问次数(increment)等。对于这些功能,Caffeine模仿数据库的预写机制,数据的变更读取事件分别存入writeBufferreadBuffer中,然后利用异步线程来读取Buffer更新统计值。

    readBuffer本质上是一个环形缓冲区,代码上就是一个有界数组模拟环形,它的好处就是内存友好,并且消除消费者和生产者竞争,为了进一步的消除竞争,每一个线程单独使用一个环形缓冲区(即:对线程取 Hash,哈希值相同的使用同一个缓冲区)。在缓存的场景中,读的频率是非常高的,readBuffer数据插入频率很可能大于消费速率,如果仅仅用阻塞队列来实现该需求,就不得不有锁等待导致线程挂起从而性能严重损耗。如果是环形缓冲区,只需要写入和消费线程各自维护自己的数组索引位置即可(readCounter和writeCounter)。writeCounter追上了readCounter,说明缓冲区满了,Caffeine允许读事件的丢失来维持高性能。 其内部没有任何锁机制,并且对readCounter和writeCounter加入Padding值来规避CPU伪共享进一步提高性能。

    writeBuffer则是一个有界队列(ArrayQueue)。不用环形缓冲区是因为读事件允许丢失,但是写事件绝不能允许丢失。它借鉴了JCTools中的多生产者单消费者(Multiple Producer Single Consumer, MPSC)的实现。能不同样是采用了分离生产者索引(producerIndex)和消费者索引(consumerIndex)以及避免伪共享方式来提升性能。
  3. 对于缓存的访问计数,Caffeine 使用了 基于CountMin Sketch的W-TinyLFU算法来淘汰数据,CountMinSketch本质上是在节省内存的基础上,来近似的计算每个元素的频率。对key进行多次 hash 函数运算后,对应二维数组不同位置存储频率加1,Key的频率则对应每个位置上的频率值,取最小值返回。见下图:

    为了保证某些热点不再是热点数据情况下,未来能够被删除,会定时对所有元素的频率次数除以2,达到降级效果。
  4. W-TinyLFU中设计中,将容量划分为三个主要区域(Window、Probation和Protected)来实现高效的缓存管理。

    Window区域:主要作为新进入缓存项的”接待区”,使用LRU(最近最少使用)策略管理,可以用来捕获突发性的稀疏流量。

    Probation区域:当Window区域满时,最老的项会被降级到Probation区域,评估缓存项是否有资格进入Protected区域,使用TinyLFU频率筛选机制。当Probation区域满时,会与Protected区域的候选者进行”淘汰赛”,新来的项与Protected区域最老的项进行频率比较,胜者进入Protected,败者被淘汰或者降级

    Protected区域:被确认为热点的缓存项,为高频访问项提供长期保护。这种三区域的结构能够同时很好地处理突发性流量和长期热点流量。
  5. 复杂的定时任务淘汰策略,又引入了时间轮(TimeWheel)算法来实现。

分布式缓存

对于Redis和Memcached,要想理解它们的性能表现,我们就要知道它们各自的网络模型。

Redis是典型的单线程,基于事件驱动模型的网络多路复用模型。所有用户的请求都是在一个线程中处理,因此大部分情况能够保证操作的顺序性。

Memcached采用多线程,以及非阻塞的IO模型。主线程来处理客户端的连接,工作线程来处理用户的请求。因此Memcached能够更好的利用多核的优势。

在就目前我的观察来看,由于Redis单线程具备更少的上下文切换,高并发情况下,它也能够维持在略低于Memcached的吞吐量上工作,但是客户端连接数多,请求量大,单线程终究是一个瓶颈,导致响应时延会比Memcached高一个数量级

因此在产品决择时,你需要问自己是否真的需要redis其它的扩展功能?

缓存的隐患

天底下没有100%命中率的缓存,除非有无限大的空间。作为系统架构,我们始终要保持一个谨慎的心态。增加一个组件,能够享受它的收益,同时就会带来其它的风险,这需要仔细的考量

缓存穿透—Cache Penetration

缓存不命中情况下,最终会请求数据源查询。对于大量查询了不存在的Key,而导致请求末端的数据源,我们称之为缓存穿透。注意:不存在的Key表示数据库也没有。

缓存穿透有可能是代码问题或者恶意的攻击行为。对于解决方案,我们可以对不存在的插入空key进行缓存,一段时间内最多穿透一次。恶意情况下,还有必要设置一个前置的过滤器,比如布隆过滤器。

缓存击穿—Cache Breakdown

缓存击穿则是:某些热点数据如果某些原因全部或者大部分失效,导致大量到达数据源,导致压力剧增。典型的原因,比如由于超期时间而失效,此时又有多个针对这个热点数据的请求。

解决方法:

  • 互斥锁(Mutex Lock):只允许一个线程重建缓存,其他线程等待或返回旧数据。
  • 手动更新管理:如果是由于自动失效导致的,可以热点Key设置为永不过期,通过后台任务定期刷新。

缓存雪崩—Cache Avalanche

雪崩:大量缓存数据失效,或者服务崩溃重启,此时所有的请求都将落到数据源中,导致数据源一起崩溃。

解决方案:

提升可用性,高可用设计

  • 多级缓存:结合本地缓存(Caffeine)和分布式缓存(Redis),即使Redis崩溃,本地缓存仍可部分拦截请求。
  • 集群容灾:使用Redis Sentinel或Cluster模式,避免单点故障。

分散过期时间,避免Key集中过期

  • 差异化过期时间:在基础过期时间上增加随机值(如 TTL = 基础时间 + 随机1~5分钟)。
  • 分层过期:对不同类型的Key设置不同的过期策略。

熔断降级

  • 限流机制:通过Hystrix、Sentinel等工具限制数据库访问流量。
  • 降级策略:缓存失效时返回默认值或兜底数据(如旧缓存、静态页)。

上述的三种缓存隐患常常容易混淆,因此我特地加上了英文表述,并且为了进一步加深印象,总结了下表:

维度缓存穿透缓存击穿缓存雪崩
触发条件查询不存在的数据热点Key突然失效全局或大面积Key
数据存在性数据库本就不存在该数据数据库存在,但缓存失效数据库存在,缓存失效
并发量可能低也可能高(看攻击强度)高并发请求集中访问一个Key大量Key同时失效或缓存宕机
解决方案布隆过滤器、缓存空对象互斥锁、逻辑过期提高可用性,分散过期时间,熔断降级

缓存一致性

缓存本质是数据冗余,冗余就会有数据不一致现象!大部分情况,虽然缓存和数据源很难保障强一致性,但是要保证最终一致性!对于一致性的保证,有很多设计模式可以借鉴。

缓存模式

Cache Aside

缓存旁路模式。也是一种懒加载的模式。应用程序要和缓存系统以及数据库同时交互!

  1. 读数据时,先读缓存。没有再读数据源,并且写回缓存,再响应请求。
  2. 写数据时,先写数据源,然后将缓存失效(不直接更新缓存)。

Read Through

读操作的缓存回填交给缓存系统,即如果缓存不命中,则缓存系统直接从DB中获取数据。应用程序不需要和数据库交互

Write through

写操作同时更新缓存和DB,保证了数据的实时性。但是增加了一致性的复杂度,毕竟同时更新两个系统,相当于分布式事务。

Write behind

先写缓存,再异步通知到DB更新。可以极大提高写的效率,但是增加了数据更新延迟,导致数据更新的丢失。

大多数情况,Cache aside都是一个比较好的模式。

进程内缓存一致性

对于分布式缓存系统而言,它们内部大多支持数据的分片和复制,来保证数据的最终一致性(比如Redis的AP模型,Sentinel,Cluster机制)。

进程内的缓存由于缺少节点间的通信和交互,很容易导致节点间缓存不一致。在构建进程内缓存时,一定要考量一致性的因素,如果一致性高度敏感,就要考虑转换成分布式缓存。但也有一些方法来构建节点间的一致性:

  1. 单节点处理写请求,然后通过rpc通知其他节点更新。
  2. 事件驱动方式。引入MQ,单节点处理数据写请求后,异步广播通知更新其他节点。
  3. 放弃实时一致性,每个节点各自定时更新缓存数据。

方案1和方案2的虽然具备强一致性要求,然而节点多的情况下,通信成本和原子性保证困难。

一致性的其他问题

本身来说,缓存加入,数据的冗余肯定会导致数据不一致。现在大多数底层的数据库都采用了读写分离,主库从库数据同步的方式,这些方案都会进一步加剧缓存不一致的扩散。比如,缓存失效去DB查询数据,此时主从库同步并没有完成,查询从库仍然可能缓存了脏数据。

这里有一种方案可以借鉴:二次淘汰法。

  1. 第一种,异步方法。第三方工具来订阅从库的binlog,完成同步后,再失效掉缓存。或者服务启动一个Timer,服务删除缓存。
  2. 第二种,缓存设置过期时间,未来仍然有机会修正数据,代价就是这段时间内的数据不一致。

综上,我们在缓存的使用过程中,我们仍然要基于底层DB的特性来做进一步设计和考量!