Helios多级缓存实践
1 缓存定位
在讲述缓存之前,我们首先需要确认系统是否真的需要缓存。而引入缓存的理由,总结起来无外乎以下两种:
为缓解 CPU 压力而做缓存:譬如把方法运行结果存储起来、把原本要实时计算的内容提前算好、把一些公用的数据进行复用,这可以节省 CPU 算力,顺带提升响应性能。
为缓解 I/O 压力而做缓存:譬如把原本对网络、磁盘等较慢介质的读写访问变为对内存等较快介质的访问,将原本对单点组件(如数据库)的读写访问变为到可扩缩部件(如缓存中间件)的访问,顺带提升响应性能。
缓存是典型以空间换时间来提升性能的手段,一般引入缓存的出发点都是缓解 CPU 和 I/O 资源在峰值流量下的压力,进而提升系统的响应性能。
2 缓存属性
目前 ,“缓存”其实已经被看作一项技术基础设施,针对该种基础设施,除了缓存基本的存储与读取能力,通用、高效、可统计、可管理等方面的需求也被重视。通常,我们设计或者选择缓存至少会考虑以下四个维度的属性:
吞吐量:缓存的吞吐量使用 OPS 值(每秒操作数,Operations per Second,ops/s)来衡量,反映了对缓存进行并发读、写操作的效率,即缓存本身的工作效率高低。
命中率:缓存的命中率即成功从缓存中返回结果次数与总请求次数的比值,反映了引入缓存的价值高低,命中率越低,引入缓存的收益越小,价值越低。
扩展功能:缓存除了基本读写功能外,还提供哪些额外的管理功能,譬如最大容量、失效时间、失效事件、命中率统计,等等。
分布式支持:缓存可分为“进程内缓存”和“分布式缓存”两大类,前者只为节点本身提供服务,无网络访问操作,速度快但缓存的数据不能在各个服务节点中共享,后者则相反。
3 本地缓存
下面围绕几个主流的本地缓存,HashMap, Guava, Ehcache, Caffeine对上述属性进行简单介绍。
吞吐量:因为涉及到并发读写,所以对于吞吐量影响最大的即是并发访问方式。最原始的 HashMap缓存,因为没有进行并发访问控制,其吞吐量最高, 但也决定了其无法在多线程并发下正确地工作;后续线程安全版本ConcurrentHashMap 采用分段加锁的方式进行了访问控制;ConcurrentHashMap的并发访问用于解决并发读写时的数据丢失,而在其他几种本地缓存的设计中,因为涉及到数据淘汰与驱逐能力,其主要的数据竞争源于读取数据的同时,也会伴随着对数据状态的写入操作,写入数据的同时,也会伴随着数据状态的读取操作。针对这种一种是以 Guava Cache 为代表的同步处理机制,即在访问数据时一并完成缓存淘汰、统计、失效等状态变更操作,通过分段加锁等优化手段来尽量减少竞争。另一种是以 Caffeine 为代表的异步日志提交机制,将对数据的读、写过程看作是日志(即对数据的操作指令)的提交过程,然后通过异步批量处理的方式降低锁的并发访问。下图是Caffeine官方文档中压测得到的吞吐量数据。
命中率:主要用于最大化有限物理内存的使用价值。优秀的缓存需要能够自动地实现淘汰低价值数据,而该能力则会涉及到不同的淘汰策略。目前,最基础的淘汰策略实现方案有以下三种:
FIFO(First In First Out):优先淘汰最早进入被缓存的数据。
LRU(Least Recent Used):优先淘汰最久未被使用访问过的数据。对大多数的缓存场景来说,LRU 都明显要比 FIFO 策略合理,尤其适合用来处理短时间内频繁访问的热点对象。但相反,它的问题是如果一些热点数据在系统中经常被频繁访问,但最近一段时间因为某种原因未被访问过,此时这些热点数据依然要面临淘汰的命运,LRU 依然可能错误淘汰价值更高的数据。
LFU(Least Frequently Used):优先淘汰最不经常使用的数据。LFU 可以解决上面 LRU 中热点数据间隔一段时间不访问就被淘汰的问题,但同时它又引入了两个新的问题,首先是需要对每个缓存的数据专门去维护一个计数器,每次访问都要更新,存在高昂的维护开销;另一个问题是无法有效反应随时间变化的热度变化信息,譬如某个曾经频繁访问的数据现在不需要了,它也很难自动被清理出缓存
在此之上,针对LFU策略最近又衍生出了以下两种变形:
TinyLFU(Tiny Least Frequently Used):TinyLFU 是 LFU 的改进版本。为了缓解 LFU 每次访问都要修改计数器所带来的性能负担,TinyLFU 会首先采用 Sketch 对访问数据进行分析,关于sketch的详细原理读者可自行参考Count–min sketch
W-TinyLFU(Windows-TinyLFU):W-TinyLFU 又是 TinyLFU 的改进版本。TinyLFU 在实现减少计数器维护频率的同时,也带来了无法很好地应对稀疏突发访问的问题,所谓稀疏突发访问是指有一些绝对频率较小,但突发访问频率很高的数据,譬如某些运维性质的任务,也许一天、一周只会在特定时间运行一次,其余时间都不会用到,此时 TinyLFU 就很难让这类元素通过 Sketch 的过滤,因为它们无法在运行期间积累到足够高的频率。应对短时间的突发访问是 LRU 的强项,W-TinyLFU 就结合了 LRU 和 LFU 两者的优点,从整体上看是它是 LFU 策略,从局部实现上看又是 LRU 策略。具体做法是将新记录暂时放入一个名为 Window Cache 的前端 LRU 缓存里面,让这些对象可以在 Window Cache 中累积热度,如果能通过 TinyLFU 的过滤器,再进入名为 Main Cache 的主缓存中存储,主缓存根据数据的访问频繁程度分为不同的段(LFU 策略,实际上 W-TinyLFU 只分了两段),但单独某一段局部来看又是基于 LRU 策略去实现的(称为 Segmented LRU)。每当前一段缓存满了之后,会将低价值数据淘汰到后一段中去存储,直至最后一段也满了之后,该数据就彻底清理出缓存。下图是W-TinyLFU驱逐算法的原理图,详细的介绍以及在Caffeine中的应用可以参阅caffeine的官方设计文档。
扩展功能:是基础数据读写功能之外的额外功能。主要侧重于监控统计能力,过期控制,容量控制,引用方式等。
分布式支持:Caffeine只作为本地进程内缓存,而Ehcache则演变为同时能够支持分布式部署的模式。另外,Ehcache在3.x也支持了堆外缓存的能力,而该能力在本地缓存在GB以上,且对RT敏感的场景就有了用武之地。反观Caffeine,则更聚焦于单实例本地进程堆内缓存。
4 分布式缓存
在微服务的背景下,Ehcache、Infinispan 等也演进为能够同时支持分布式部署和进程内嵌部署的缓存方案。Ehcache类的缓存共享方案是通过RMI或者Jgroup多播方式进行广播缓存通知更新,缓存共享复杂,维护不方便;简单的共享可以,但是涉及到缓存恢复,大数据缓存,则不合适。
对分布式缓存来说,处理与网络相关的操作是对吞吐量影响更大的因素,目前Redis已经成为分布式缓存技术的首选,我们暂不对Redis分布式缓存技术做过多的探讨,有兴趣的读者可以参阅相关官网和技术书籍。
5 多级缓存
分布式缓存与进程内的本地缓存各有所长,也有各有局限,它们是互补而非竞争的关系,如有需要,完全可以同时把进程内缓存和分布式缓存互相搭配,构成透明多级缓存(Transparent Multilevel Cache,TMC)。
典型的多级缓存结构如下图所示,使用进程内缓存作为一级缓存,分布式缓存作为二级缓存,DB等其他数据源作为三级缓存。应用进程首先读取一级缓存,未命中的情况下读取二级缓存并回填数据到一级缓存。如果二级缓存也查询不到,就发起对最终源的查询,将结果回填到一、二级缓存中去。各级缓存数据的读取命中率依次是: 进程内缓存 > 分布式缓存 > 数据源。
对应于上述抽象的多级缓存结构,Helios多级缓存的架构设计图如下所示:
在Helios多级缓存的设计中,缓存边界被定义为:
用户触发的数据读取99.9%以上只走本地缓存,极少数miss到分布式缓存或DB 。
DB只会被分布式调度任务访问,用以将最新的数据刷新到分布式缓存。
分布式缓存绝大部分情况只会被本地缓存和reload任务访问,用以中转最新的数据。
同时采用了如下的分层刷新机制:
分布式调度从数据库reload最新的数据覆盖分布式缓存;有条件进行增量更新的缓存数据基于变更事件触发,分布式调度全量reload兜底。
本地调度定期从分布式缓存同步数据覆盖本地缓存。
下文将聚焦于在Helios多级缓存建设中的一些实践。
5.1 缓存一致性
缓存意味着副本,就必然存在着各数据副本之间的一致性问题。而从多级缓存中,存在着本地进程内缓存与分布式缓存的一致性问题,分布式缓存与DB等外部数据源的一致性问题。
关于本地进程内缓存与分布式缓存的一致性问题,因为本地进程内的缓存一般是分布式多实例的结点,所以一般做法是数据发生变动时,在集群内发送推送通知(简单点的话可采用 Redis 的 PUB/SUB,或者MQ的广播消息机制,严谨的话引入 ZooKeeper 或 Etcd 来处理),让各个节点的一级缓存自动失效或者刷新。
关于分布式缓存和DB等外部数据源之间的一致性问题,二者皆有成熟的数据访问接口,无需考虑分布式多实例之间的数据复制问题。问题的复杂性在于如何保证并发读写情况下的一致性。更新缓存的的Design Pattern有基础的有四种:cache aside, Read through, Write through, Write behind caching。其中最经常使用,成本最低的 Cache Aside 模式逻辑如下:
失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
命中:应用程序从cache中取数据,取到后返回。
更新:先把数据存到数据库中,成功后,再让缓存失效。
关于缓存更新过程中,使用失效而非刷新主要因为避免多个更新请求并发操作导致的脏数据问题。
当然Cache Aside 模式在极端情况下也会存在脏数据问题,针对该一致性问题,要么通过2PC或是Paxos协议保证强一致性,要么就是拼命的降低并发时脏数据的概率。而Facebook在论文使用了这个降低概率的玩法,因为2PC太慢,而Paxos太复杂。CacheAside更新模式是最简单也是最常用的更新模式,但在实际应用场景下,还需要考虑到业务侧对缓存Miss的容忍度,分布式缓存更新请求失败的兜底和补偿策略等等。
Helios的缓存更新策略基于实际场景稍有不同, 具体更新模式如下:
失效:应用程序先从cache取数据,没有取到数据, 则直接返回未命中(数据不存在)
命中:应用程序从cache中取数据,取到后返回。
更新:先把数据更新存储到数据库中,成功后,再主动刷新分布式缓存,之后通过MQ触发本地进程内缓存的刷新。
Helios多级缓存在更新策略放弃了可能出现的脏数据,而选择避免缓存穿透,相当于AP模型,而cacheAside模式则属于CP模型。在实际应用场景下,一般情况下出现脏数据的概率会非常低,但是高并发和高频更新的数据,将放大出现脏的概率。在实际的使用场景中,为了兜底可能的失败或者遗漏的更新请求,而增加的全量兜底功能:通过定时任务将DB等数据源的数据全量刷新到分布式缓存。而该兜底方案却带来了另外一个可能并发更新分布式缓存的场景。针对可能出现的脏数据以及其他管控诉求,Helios提供了KEY级别的手动驱逐与缓存刷新能力。同时为了降低全量兜底策略对数据不一致的影响,全量兜底策略也被设计成为支持流式更新,业务侧可自主选择更新。
缓存一致性的是无法避免的问题,也没有绝对合适的一致性方案。未来Helios多级缓存架构会提供多种更新模式,供业务在不同的业务场景下选择。
5.2 缓存监控
对于进程内缓存,例如Ehcache, Caffeine等都虽然都提供了成熟的访问统计指标监控能力。但该统计指标均为从运行时刻起的累计指标,无法有效反应随时间变化的监控信息。同时对于业务侧关心的大KEY和热KEY 指标均不支持。
时间敏感的统计指标监控能力需要使用滑动时间窗口的方式进行分窗口统计上报, Helios内部使用Disruptor异步消费监控事件,以避免对用户侧请求的影响。同时在访问频率统计上,又借鉴了Sketch对低频访问数据进行过滤,避免大量统计数据带来的稳定性风险。
需要注意的是,异步设计往往带来的副作用就是指标延迟,且大流量下固定缓冲队列往往会牺牲一部分的数据准确性,这点在Caffeine的异步设计中也有所体现。
5.3 缓存热点
热点缓存经常是业务需要引入多级缓存的一个重要原因,针对频繁访问的热点数据,如果每次都要从缓存服务器获取,可能导致缓存服务器负载过高、或者带宽过⾼。而针对热点数据最直接的想法就是将数据放到使用最近的地方,也就是本地内存。
针对缓存热点最简单有效的方式就是手动指定,提前预热到本地缓存,比如在大促活动前手动将秒杀的商品信息提前缓存到本地进程内缓存。但是手动预热的方式无法解决突发或者异常热点流量。这就需要多级缓存框架能够透明的支持热点发现与同步机制。上文中提到过Caffeine这种本地进程内缓存通过优秀的淘汰策略W-TinyLFU解决了热点统计,衰减和稀疏突发访问的问题,但是Caffeine本身热点统计周期,热度衰减策略可能无法match业务的统计周期,而且当业务变更时,存在一定的时间成本优化容量参数以平衡内存使用和缓存命中率。
热点发现与缓存机制整个流程必然: 热度统计,热KEY认定,热KEY同步与更新这3个流程。目前业界成熟的热点方案主要有有赞的TMC方案以及京东的HotKey方案。二者均定位于实现全局热点方案,且整体架构和流程类似。以有赞的TMC解决方案为例:
本地实例对KEY的访问进行拦截,监控统计,并进行秒级别的数据上报
中央决策结点使用滑动窗口的方式聚合计算窗口内部的热度计算
通过热点阈值判断出热点KEY,然后通过etcd等方式通知各应用结点
应用结点获知热KEY变化后,变更本地热KEY列表
全局热点探测模式相较本地热点探测会更加精确:特别是在微服务背景下,服务实例较多的情况下,当单个实例探测到热KEY时可以快速通知其他结点;而且能够应对流量不均情况下的异常热点KEY的发现,而代价则是较高的通信成本和相对较高的架构复杂性。
当前Helios热点探测放弃了全局探测的模式,转而专注于优先探索本地热点方案。因为针对目前严选应用环境 流量几乎均分到每个实例,可以使用较小的准入阈值来提升本地热点探测的灵敏度;另外一方面,严选环境的当前痛点是希望通过本地热点自管理的方式去缓解本地缓存过热,以及缓存参数调优的复杂性。Helios本地热点探测流程如下如所示:
本地访问请求在Miss的情况下,会触发热点KEY统计,这里借鉴sketch的模式进行频率统计,避免频率统计导致的内存占用风险
热点准入模块会根据t-1和当前周期t的KEY访问频率进行
当统计周期轮转时,对内存非热KEY进行驱逐,降低内存使用水位
自动化的本地热点探测,热点管理非热点驱逐能力降低了本地内存的水位占用,避免出现本地缓存过热的情况,同时热点统计模块的热点KEY调用分析统计也可以用于指导缓存实例的参数调整和性能优化。
6. 总结与展望
缓存分为本地进程内缓存和分布式缓存,因为定位和使用场景的不同,二者在技术选型时候选对象及考察点存在很大不同 ,而且目前二者也已呈现不同的演进方向。透明多级缓存结构则是希望结合二者的长处,通过封装了本地进程内缓存和分布式缓存之间常用的使用模式,降低了多级缓存的接入与开发成本。而副作用是,本地缓存与分布式缓存Redis类接口命令协议上的差异性导致了多级缓存存在着诸多的限制,同时也带来缓存一致性等问题。要实现多级缓存架构的透明性仍然存在很大挑战,未来也将继续在易用性,一致性,缓存治理等方面与业务侧深入探讨继续前行。