摸鱼闲谈:从零开始设计和优化分布式缓存(上)

写在前面

本文将会从零开始设计与优化一个分布式缓存,围绕 Facebook 搭建分布式 Memcache 的经验,吸收各种论文优化技术,剖析设计意义,汇集出一条从单体到集群再到区域的设计道路。注意,由于优化技术来自不同的文献,因此本文提到的很多技术并不是天然可兼容的,需要额外设计,本文更偏向于在一个基础的分布式缓存架构上进行技术分享。希望能给你带来对分布式缓存的更进一步理解和思考。

想顺畅阅读本文需要一定的八股基础,如果你觉得阅读有难度障碍,可以先学习关于缓存的八股知识。考虑到篇幅,本文缩减了很多论文细节,如果你想要深入了解,可以阅读本文最后提供的参考资料。

了解缓存

想必学计算机的都或多或少了解过缓存,例如 CPU 的多级高速缓存、TLB 缓存、Page Cache、使用 Redis 作为缓存、哈希表本地缓存等等。缓存渗透了计算机世界的各个角落,那么你有进一步思考过缓存的作用么? 众所周知,磁盘的读写速度与内存相比低好几个数量级,因此我们可以通过将内存作为磁盘的缓存来降低读延迟,这是单机优化的方式。

让我们进一步思考下应用领域。现代网站往往是动态的,内容丰富,其提供的数据总是存储在数据库中。当一个网站变得流行时,数据库可能无法承受由此产生的负载,用户的高响应延迟和其他性能挑战随之而来。减少网站的端到端延迟不仅可以提高客户满意度,还可能对业务产生重大影响。例如,谷歌报告称,产生搜索结果的额外 0.5 秒会使流量降低 20%,而亚马逊发现,每 100 毫秒的延迟会使销售额降低 1%。因此,数据库成为可伸缩性瓶颈。那么在这种场景下加入缓存,从本质上而言,就是把高开销路径上的数据同步到了低开销路径。这种方式可以降低端到端延迟,降低后端(高开销路径的目的地)的负载。

除此之外,缓存还有另一个作用。我们需要考虑不同键的差异,在实际的工作负载下,不同键的流量分布是倾斜 的,这也就意味着总是存在某些热门的键,产生了远超其他键的流量。

在集群扩增时通常会将数据进行分区(例如 MySQL 分片集群),按照不同的访问模式,对于需要所有分区响应的模式而言,延迟由最慢节点决定;对于访问单分区的模式而言,流量倾斜会降低超负载节点的服务能力,这实际上增加了长尾延迟 。更进一步的,键的流量分布可能并不是静态的,一个键此时热门,不代表以后也热门。这就像一个公式化的爆款视频,往往只能在互联网中流行一段时间,然后突然销声匿迹,那么在这种情况下依旧把这个视频当成热门去缓存就不合理了。

上面提的情况归根结底都是负载不均衡,这也就引出缓存的另一个作用:动态负载均衡。之所以说是动态负载均衡,因为有另一种静态负载均衡就是平衡节点所需的恒定存储或内存容量,通常将其称为它们处理的数据量。而动态负载均衡是任何节点都不应该比其他节点处理过多的流量(与其相对能力、容量成正比)。设计良好的缓存可以自适应地吸收对于热键的流量,从而实现对后端节点的负载均衡。

有研究证明,一个缓存如果吞吐量和后端集群相当并且可以吸收对 O(nlogn) 个热键的流量(其中 n 为后端节点数量,和存储了多少数据没有关系) 那么这个缓存可以保证后端节点的负载均衡,这也就意味着一个小的高速缓存就足以达到负载均衡的效果。

单节点缓存

分布式缓存显然需要多个缓存节点,并需要具备可伸缩性,而更快的单节点可以让我们用更少的节点达到和更多节点一样的效果。更少的节点意味着可以降低缓存一致性(数据复制、数据更新)开销,可以通过减少客户端请求触及的服务器数量来减少网络拥塞。并且高速节点有能力承担更高的负载。这一切都说明着一个高速单节点缓存的重要性,让我们来进行宏观设计。

设计基础原型

数据结构

我们只考虑两个基本的数据结构:内存管理器和索引。
内存管理器负责对内存的申请和 GC,一个内存管理器的性能影响着缓存的整体吞吐量,此处我们直接选择像 Java、Go 这样的语言自带的管理器。
索引的作用是,当我们使用内存管理器把数据放好位置后,要怎么高效找到它,一个可以应对高并发的索引数据结构对于一个高性能缓存尤为重要。这里我们直接选择高性能并发哈希表(例如 Java 的 ConcurrentHashMap)。
*

淘汰策略

缓存的大小终究是有限的,并且一般而言会远远小于后端存储量,这意味当缓存空间用完后,需要淘汰一些现有的数据,为新的数据腾出空间。一个高效的淘汰策略对于缓存的命中率影响深远,这里我们使用基础的 LRU 策略,通过链表把数据链接在一起。
*

过期策略

某些数据我们并不需要它们永久呆在缓存里直到内存不够了才被淘汰。过期时间也是我们对缓存数据的更细控制,配置了数据的存活时间,为更值得被缓存的数据腾出空间,从而手动提升缓存命中率。因此一定程度上过期时间也是一种缓存优先级,过期时间越长也就意味着数据越适合缓存。同时如果你的缓存的一致性保证出现了问题,过期可以和淘汰策略一起防止旧数据一直存在于缓存中。
大多数缓存都会使用主动、被动过期相结合的模式。被动过期即当请求键时判断过期,实现简单,但会一定程度提高低请求率数据的存活时间,降低空间利用率。而主动过期需要一个时间感知的结构实现,我们可以使用基于堆的较为精确的时间任务调度器(例如 Java 的 ScheduledThreadPoolExecutor)。当然一般而言我们并不需要那么精确的时间调度,因此这里使用基础哈希轮或者多级哈希轮结构,通过调整桶所代表的时间间隔来控制精度,定时检查数据过期。
*

缓存填充

当请求了一个数据但是缓存未命中后,客户端从后端数据读取到数据,并且回填到缓存。
*

提供的API

GET(Key):获取 Key 对应的 Value SET(Key,Value):设置一个 Key DELETE(Key):删除一个 Key

一些优化

淘汰+准入

淘汰策略用来维持内存大小,在内存不足时挑选数据进行淘汰,如果淘汰策略不够明智,就会影响到缓存的命中率。例如,考虑极端情况,每次我们都把热点数据淘汰掉,将大量流量放给后端节点,带来大量负载。

而考虑基础的淘汰策略,LRU 策略很适合应对突发流量场景,这些突发数据全部都会被缓存起来。而代价便是,我们可能把一些更应该被缓存的数据淘汰了出去,这也就是出现了缓存污染 问题。LRU 固然实现简单,不需要维护过多元数据,对于维持命中率的效果也不错,但在数据的流量分布随时间不变时,LFU 会产生最高的缓存命中率。

那么 LFU 就可以完全替代 LRU 了么?显然不行,为了实现 LFU,需要在 LRU 的基础上额外维护访问次数,并且需要一个更复杂的整体的数据结构(相比之下,LRU 只需要链表)来维持淘汰优先级,需要在元数据的维护上付出更大的开销。LFU 还有一个问题就是不擅长应对变化 ,在实践中,访问频率的变化是很快的,一个数据在某个时段被高频访问,不代表之后也会,然而由于该数据的访问频率很高,其在很长一段时间内都不会被淘汰,这同样会降低缓存命中率。

正因这两种基础的淘汰策略都有问题,后续演化出了各种变种策略。例如,MySQL 的 LRU 会将链表划分两个区域,这实际上就是 SLRU,一定程度上可以缓解缓存污染问题。

现在让我们进一步思考一下,传统的缓存淘汰策略语义中,我们为了给一个新数据腾出空间,必须淘汰现有数据。然而,我们为什么一定要让这个新数据进入缓存呢 ?我们假设这个新数据只是偶然被访问,甚至在未来很长一段时间内它都不会再被访问了,而我们为了将这个数据缓存起来,淘汰了一个相比更需要被缓存的数据,这是不太明智的。为了缓解这个问题对于命中率的损害,我们需要准入策略。

准入策略和淘汰策略是相互配合的,当一个新数据希望进入缓存而内存不足时,由淘汰策略选出淘汰者,然后使用准入策略进行决策,评估新的数据替换被选出的淘汰数据后,是否可以提高命中率。如果没有提高,那就没有必要淘汰,直接拒绝对新数据的缓存。

那么考虑下,为了实现准入策略,我们需要什么元数据?核心数据就是访问次数,通过比较访问次数就能知道哪个数据更应该留在缓存。但问题来了,通常我们只维护缓存中存在的数据的元数据,那么此时一个新数据的访问次数必然是零,这个缓存将不允许任何新数据的进入。

因此我们需要维护不在缓存中的数据的元数据,这被称之为"影子"数据。而这一部分元数据的维护就对内存占用提出了要求,最简单的方式便是维持所有请求过缓存的访问元数据,但是开销太大,维护的信息不够准确又会损害准入策略的有效性。在此介绍一个知名的实现准入策略的数据结构-TinyLFU。

为了平衡空间开销和统计的准确性,TinyLFU 使用 Sketch 数据结构进行近似统计。Sketches(草图)是一类数据结构,它们使用相同的底层哈希方案(类似于布隆过滤器)来汇总数据。在更新哈希桶和评估的细节上有所区别,这里使用比较基础的 The count-min sketch。

  1. 更新过程 将键逐行使用绑定的哈希函数进行哈希,累加相应列的计数器
  2. 评估过程 将键逐行哈希找到所有计数器的最小值,作为评估值

此时我们就拥有了这样一块内存友好的元数据区域,该区域需要在内存占用和数据精确度之间权衡。让我们来考虑精确度问题,虽然此时的近似统计很有效,但依旧没法应对流量变化,该元数据区域由于使用了哈希,自然而然就会有哈希冲突问题,这会导致评估准确性下降,并且随着运转,统计数据会逐渐饱和,进一步降低精确度。

因此需要方案来让数据可以应对变化,简单来说,如果一个数据不再热门了,那么相应的计数次数不应当保持原样。这里可以使用半衰期思路,设定一个计数上限 w,当任意计数达到了 w,则将所有计数除 2,这被称之为 Reset。

该方案还可以帮助进一步降低内存开销,由于计数值有上限,因此我们不需要为每个计数器分配太多内存,通常只需要分配 4 个 bit 位。再进一步考虑,在许多工作负载中,不受欢迎的数据占所有访问的很大一部分。这种现象意味着,大多数计数器的内存被分配给样本内不太可能出现多次的数据。因此,为了进一步减小计数器的大小,我们可以使用 Doorkeeper 机制。

Doorkeeper(简称 Dk) 是一个普通的布隆过滤器,当数据被访问了,如果其存在于 Dk,则可以进行计数,否则只是加入到 Dk 中。 当评估计数时,如果数据在 Dk 中也存在,则计数+1。当 Reset 时,清空 Dk。 因此我们使用更少的内存过滤掉了更多的数据,这意味着我们可以进一步减少计数器占用的内存。

此时我们的准入策略已经比较完整了,但还存在一个场景问题,即不太适用于突发流量场景,短期突然热门的数据,可能会由于计数太低,在一段时间内都被拒绝进入缓存,给后端带来巨大的流量压力。考虑到 LRU 很适合应对突发流量,此处可以分出一块区域专门使用 LRU 策略,这样就转变成了 W-TinyLFU 架构。

我们将这一小块区域称为窗口缓存,W 即为窗口,所有数据被访问时,先进入窗口。窗口的淘汰者再使用 TinyLFU 判断是否可以进入主缓存。

当主缓存使用一个有效的淘汰策略(例如 SLRU),整体的缓存命中率将会进一步提升,而这个方案已被 Java 高性能缓存 Caffeine 采用,如果你对细节感兴趣,可以进一步阅读源码。

何必使用传统网络协议栈

网络 I/O 是内存存储最昂贵的处理步骤之一。在多核优化后,仅 TCP 处理就可能消耗 70% 的 CPU 时间。考虑到读操作通常占据请求的大多数,并且不太需要完整的 TCP 功能,因此可以使用 UDP 来进行读请求,我们只需要实现基本的可靠传输机制,去除拥塞控制、流量控制等不太需要的功能。

更进一步的,我们可以直接绕过操作系统内核,在用户态维护自己的网卡协议栈,和网卡直接交互。现代网卡可以使用基于硬件的数据包分类和多队列支持将数据包发送到特定的核心,以实现负载平衡或核心亲和性。 具体而言,可以使用 DPDK 而不是标准的套接字 I/O。这允许我们的用户级服务器软件以最小的开销控制网卡和传输数据包数据。使用网卡多队列支持为每个核分配一个专用的 RX 和 TX 队列。内核独占地访问它们自己的队列而不需要同步,并通过 Flow Director 技术进行路由。

使用包头的不同部分加上用户提供的映射表将其映射到网卡 RX 队列

批量包处理

每次从 RX 队列请求数据包或将它们传输到 TX 队列时,都使用 DPDK 的突发数据包 I/O 来批量传输多个数据包。该功能可以减少为处理每个数据包而访问和修改队列的成本,同时只给请求处理增加了微不足道的延迟。

零拷贝

避免在 RX/TX 和请求处理过程中复制数据包数据。为 RX 使用 MTU 大小的数据包缓冲区,即使传入的请求很小。在接收到请求后,通过重用请求数据包构造响应包来避免内存分配和复制(翻转源和目标地址和端口,并且只更新请求和响应之间不同的部分数据包有效负载)

利用多核的力量

很多设计充分利用计算机多核去访问共享数据结构,这会需要使用排他锁、乐观锁、无锁结构等。但这些设计都不擅长应对并发写的扩增,因为会出现频繁的 CPU 缓存行失效以及为达成缓存一致性进行的总线通讯(当进行写入时,只有一个CPU核心可以持有相应缓存行)。

因此可以对缓存数据进行分区,每个核绑定一个线程,该线程独立负责一块数据分区。然而该方案会带来 CPU 核心间的负载不均衡问题(一核有难,八核围观),如果负载非常倾斜,可以退回到并发读,但避免并发写,因为并发写总是比独占写慢。那么如果还是难以维持吞吐量该怎么办呢?这里其实会有点反直觉,实际上在这种分区级倾斜下是可以保持高吞吐量的,因为可以更有效地处理热分区中的请求,原因有两个:

  1. 分区之所以受欢迎,是因为它包含热门数据,这些热点数据自然会在数据访问中创建缓存局部性。由于高度的局部性,在访问这些数据时会经历更少的 CPU 缓存未命中。
  2. 这种倾斜会导致批量 I/O (上面提到的)在热门分区中更有效。 而数据分区本身也可以降低单个热门数据的影响力。有实验证明,当使用大小为 192 × 2^20、偏度为 0.99 的 Zipf 分布中,最受欢迎的键的访问频率是平均值的 9.3 × 10^6 倍。然而,在将键划分为 16 个分区之后,最受欢迎的分区的请求频率仅比平均值高 53%。并且结果表明,Zipf 分布工作负载的吞吐量是均匀分布工作负载的 86%,这说明分区设计即使在倾斜的工作负载下也是实用的。

如果上述理论在你的工作负载下不太成立的话,还可以考虑使用本文后面会提到的自适应缓存方案。

具体而言,我们可以提供 EREW (独占读独占写) 和 CREW (并发读独占写) 两种操作模式,第一种便是每个 CPU 核心完全独占自己的分区,第二种模式下,每个 CPU 核都可以去读取其他分区的数据,写依旧是独占的。显然第一种模式让吞吐量随着 CPU 核心数线性增长,第二种模式用来应对读负载倾斜场景。

考虑到并发读场景会给缓存元数据统计带来问题,例如,你读取了某个数据,自然需要统计进对应的访问频率中,但这样就会和独占写的 CPU 核心形成并发。因此这里的选择是,仅仅让独占写的 CPU 核心处理的读请求被统计,这也是基于采样的近似统计,是比较高效的。

对于热门键,即便是采样,依旧会比其他键热门,因此不会损害正确性。

试试别的数据结构

内存分配器

我们之前使用了语言默认的内存分配器和 GC,而实际上知名语言的内存管理一直都在得到优化,因此性能已经比较可观了。不过考虑到有些语言并没有自带的自动内存管理,并且语言自带的管理是通用性质的,我们显然可以根据业务情况自行管理数据,定制数据结构和管理策略,从而进一步提升性能。
动态对象分配器是存储可变长度键值项的常见选择,Memcached 等系统通常使用 Adaptive Slab Allocator。而如果你了解过 Netty,其在堆外使用 Jemalloc 算法(较新版本使用 Jemalloc4)高效管理内存,思路也是大体相同的。

将对象按照大小分类,并为这些类维护单独的内存池。由于每个类使用的空间量通常会随着时间的推移而变化,因此系统使用全局内存管理器,该管理器将大型内存块分配给内存池并动态重新平衡类之间的分配。为了应对并发,每个线程也会持有自己的私有内存,当分配时,首先尝试从本地缓存中分配,分配失败则委托全局内存池进行内存分配。

动态内存分配器的主要挑战是内存碎片,为了缓解内存碎片会引入昂贵的内存复制。如果内存管理器与访问其他内存的线程并发执行,此过程更加复杂。

如果你感兴趣,可以了解下 Java 的 ZGC 是怎么处理内存管理器和用户线程的并发的。

动态内存分配器通常需要复杂而小心的设计,并且垃圾回收通常是高开销的,需要在内存效用和请求速率之间权衡。而这里我们可以考虑另一种数据结构:日志结构。你可能已经在 LSM-Tree 这种知名结构中了解过它,简单而言就是使用日志的方式进行数据存储,这种结构由于仅递增,因此对写很友好,内存管理简单,并且由于是顺序访问,可以高效利用高速缓存,很适合批量写。

考虑到缓存的大小通常是固定的,内存不够时进行淘汰,因此可以设计一个循环日志结构。这是一个简单的日志结构,当插入数据时,直接写入日志末尾。当要更新数据时,只要新数据占用的空间小于等于旧数据,就直接原地修改。

循环日志消除了传统日志结构和动态内存分配器中需要的昂贵的垃圾收集和空间碎片整理,几乎所有的自由空间都保持在尾部和头部之间。当日志写满时,会删除头部数据,即便该数据没有过期。显然该结构的默认淘汰策略就是 FIFO,我们可以通过将被访问的数据(只重新插入必要的,也就是那些远离尾部的数据)插入到末尾来实现 LRU。对于那些被淘汰的数据,显然索引中对它们的引用悬空了(索引还在但是对象没了),此时不会主动修复,而是增量更新。

对于这一块日志内存,我们可以使用大页和 NUMA 感知的内存分配。对于相同数量的内存,大页使用更少的 TLB 条目,这大大减少了请求处理期间的 TLB 丢失。具体实现中,将大页通过 mmap 直接映射到虚拟内存。

索引

我们需要一个结构来索引由内存管理器分配好的对象。常见的索引通常是树状结构或者哈希表,对于缓存基本都会选择哈希表。不过这两个结构通常是面向读场景,在写方面不是特别高效。虽然哈希表可以通过优化(链表 + 红黑树)来避免开放地址法带来的随机内存访问开销,一定程度上对写友好,但仍然避免不了多次的随机地址访问。

那么缓存还有哪些索引结构可以用呢?让我们想想 CPU 的高速缓存,其使用的结构通过硬件进一步优化可以带来可观的效果,只不过有一个缺陷,它是有损的,即当空间不够时会淘汰现有的索引。然而我们的缓存本身就需要淘汰数据,并且缓存淘汰了数据最多带来命中率的降低,不会影响正确性。

因此我们可以设计一个有损的并发哈希索引。

类似多路组相连缓存,分成多个组,每个组内有一个版本号以及多个条目,每个条目包含一个 Tag 和偏移。当查询时,用键的哈希值的一部分找到所在的组,一部分去匹配 Tag,当找到一致的 Tag 时便找到了对象在循环日志中的偏移。

这里要避免零标记值,因为其代表空索引,写入零值即可删除项。之所以说是有损的,因为当为一个新的数据找到组后,如果所在的组是满的,那么会需要删除一个最老的索引条目(根据偏移)。这种有损的性质带来了高速插入,因为不需要关心哈希冲突。

  • 悬空的索引 当一个键被从日志淘汰后,不会主动删除索引,因为这需要额外的随机内存写并且需要同步机制来进行并发控制。正因如此,索引是可以悬空的。为了解决这个问题,指针所占的空间会多于我们需要的索引空间,在使用指针前,计算这个偏移值和当前日志尾部的差距,如果这个差距大于日志大小,那么就是无效指针。
  • 并发控制 对于需要并发访问的模式(如 CREW),整体是乐观读悲观写,读写需要同步。每个桶包含一个版本号,用于进行读的乐观锁设计。读请求只有在版本号为偶数时可以进行,并且需要在读完后再次检查版本号,如果变化,则需要重试。
    对于写操作,写请求可以通过把版本号 +1 阻止读取,在完成时 +1 结束,这实际上也是一种原子操作的实现方式。如果允许并发写,则会出现对于同一个桶的并发写入,此时通过检查偶数版本号以及自旋 CAS 来进行占锁。

并发维护元数据

我们上面提到了为准入策略额外维护一块 TinyLFU 区域,其不需要单独为每个键维护元数据,而是近似统计,这也意味着并发不可避,即便每个核只管理自己的数据,依旧会并发修改同一个计数器。我们固然可以设计一个并发安全的 TinyLFU 区域,但需要考虑下为此提升的性能是否值得我们为并发控制而产生的额外开销,并且这也不是一件容易的事。这里我们参考下 Caffeine 的设计,元数据的维护都是异步进行的,处理请求的线程所要做的就是将元数据维护任务投递到队列。

我们可以在此思路之上进行设计,由于每个 CPU 核心单独负责一块数据,性能很可观,因此对于那些可以独占的元数据,由负责的核心进行同步维护。而对于无法独占的元数据(例如 TinyLFU),可以考虑将任务投递到高性能并发队列(例如 Disruptor),并单独让一个线程负责进行维护。这里我们可以对任务投递进行优化,依旧是利用采样进行近似统计,可以一定程度减少负载。

本人还没有找到成熟的 多核+准入策略 方案,此处仅为个人思考,也欢迎你评论自己的想法。

缓存一致性

规范化用词:我们用读代表客户端读取缓存数据,更新代表把后端旧值改成新值,写代表往缓存写入新数据,删除代表从缓存中删除数据

作为一个缓存,我们显然会有后端的数据需要进行同步,如果没有这个后端,那么就不是缓存而是独立的键值数据库了。而数据同步就引入了一致性问题,我们得考虑缓存需要提供怎样的一致性保证。

如果需要强一致性,也就意味着当更新请求完成了,之后的所有读请求都应当能读到数据的变化。我们考虑采用 Write-Through 策略,即当有数据更新的时候,如果不在缓存,直接更新后端即可。如果在缓存,则更新缓存,然后再由缓存同步更新后端。显然这会给缓存引入额外开销,并且提高请求延迟,降低整体吞吐量。更进一步考虑,如果缓存更新了自己的数据,但是更新后端失败了怎么办?我们是让缓存重试么,还是回滚这个操作?可见这里我们为了强一致性需要提供事务支持,这个事务包含对于缓存和后端的更新,具体而言,可以使用两阶段提交协议。我们为了保证强一致性引入了那么高的开销,这似乎和我们引入缓存提升性能的初衷有点相悖了。

考虑下大部分对数据同步不敏感的场景,例如,当你更新了自己的一篇博客内容,你需要别人立刻就能看到变化么?延迟个几百毫秒似乎也是可以接受的,这意味着我们可以先更新后端,然后异步更新缓存,两个操作之间可以有延迟。而我们应当保证的是:

当你改了博客后,你自己可以立刻看到变化(否则体验感会很差),其他用户可以延迟一点;当一个用户看到了你新的博客内容后,就不应该再看到旧的博客内容 。前者类似于 Zookeeper 提供的一致性保证,在其论文里被称作异步的线性一致性 ,而后者可以被称为读的单调性

为了保证前者,需要发送更新请求的客户端在更新后端之后接着修改缓存,注意这不同于上面的同步更新,在这里后端更新和缓存修改是分离的两个操作,当后端更新后这一次请求其实就成功了,我们只不过是为了保证异步的线性一致性才去修改缓存。

为了保证后者,我们需要数据同步的过程不出现并发导致的不一致性,在缓存场景下通常就是给缓存写入了旧值。首先我们要确定,更新了后端后所谓的修改缓存,这个修改是更新还是删除。一般而言为了尽可能保证一致性,会选择删除,因为删除操作是幂等的。如果选择更新,那么即使是两个并发的更新操作在更新后端和删除缓存两个时间点的顺序不一致,也可以引发一致性问题,而这个场景出现的概率不小:

其次,我们考虑会出现的对于同一个键的并发操作:读操作、缓存未命中引发的写操作、数据更新引发的删除操作。那么会有如下场景,一个读操作没有命中缓存,前往后端读取数据,紧接着的写缓存操作延迟了。此时来了一个更新操作,在后端更新完后,删除了缓存中的数据,然后,之前延迟的的那个写操作把旧数据写进了缓存。

自此,虽然有过期时间和淘汰策略可以保证旧数据不会永久留在缓存里,但还是让旧数据存在的时间过于长了,并且,对于请求更新的客户端,其下一次读取缓存依旧会读取到旧数据,读的单调性并没有得到保证。

当然这个情况出现的概率比较小,因为出现条件是读缓存时缓存未命中,然后同时有一个更新操作,读操作必须在更新操作完成前从后端读取数据,又要晚于删除操作将数据写入缓存。然而当网络波动、硬件等问题出现后,这终究是一个会发生的场景,就像 TCP 协议中两将军困境发生的概率也很小,但不是不会发生。我们要么彻底解决这个场景,要么就降低其概率。实际上我们选择更新后端后删除缓存而不是更新缓存,已经在一定程度上降低了概率。现在让我们考虑下是否可以彻底解决该问题。

并发控制

说到底这里出现的问题就是对于同一个数据的并发操作并没有得到有效控制,我们再一次列出这些并发操作:读操作、缓存未命中引发的写操作、数据更新引发的删除操作。其中读操作没啥影响,因此我们只需要控制后两者。这里我们引入租约 机制。

当读操作缓存未命中后,由缓存给其中一个请求读的客户端授予租约,让其在一定时间内拥有将后端数据写入缓存的权力,其他没有拿到租约的客户端可以选择等待一小段时间(通常是几十毫秒,足够数据被写入缓存),然后重试。当客户端想要将数据写入缓存时,需要提供租约,由缓存检验租约的有效性。当数据更新引发的删除操作到达缓存,会同时把授予出去的租约无效化。

这个机制为什么能发挥作用呢?依旧还是上面提到过的场景,只不过这次加入租约:一个读操作没有命中缓存,拿到租约后前往后端读取数据,紧接着的写回缓存操作延迟了。此时来了一个更新操作,在后端更新完后,删除了缓存中的数据,同时把租约也给无效了,然后,之前延迟的的那个写操作想把旧数据放进缓存,但由于租约无效,该操作失败。下一个拿到租约的客户端会负责把新数据写入缓存。

并且这里引入租约机制还有另一个好处:降低后端负载。默认环境下没有租约保护,当某个热门键过期后,大量的读操作都会缓存未命中,然后转向后端读取数据,给后端带去大量没必要的负载(这也是八股里经常提到的缓存穿透 问题)。

那么租约机制彻底解决了一致性问题么?读者可能知道租约机制以同步时钟 为前提,即便可以通过减少租约申请者的持续时间来缓解(可以看我的关于分布式锁的文章),但依旧会有概率因为时钟不同步产生机制失效问题。但这里并不需要担心这个缺陷,因为是由租约管理者(缓存)自己使用自己的时钟去检验租约有效性,并不需要依赖租约持有者(客户端)的时钟。当然如果换成使用独立的分布式锁服务而不是嵌入在缓存里,那么依旧有概率出现问题。

综上,租约机制彻底解决了单缓存节点场景下的并发一致性问题,我们可以在没有服务异常的情况下百分百保证异步线性一致性和读的单调性。

服务异常下的一致性

租约机制固然有效,但并不是不会出现一致性问题了,核心便是这个异常:更新完后端后删除缓存失败了。我们上面有提到过缓存删除可以让请求更新的客户端负责,但如果删除失败了怎么办?我们固然可以让客户端重试,但如果是缓存服务崩溃了,客户端就这样长时间卡在重试中么?我们固然也可以不进行重试,放弃删除,但这也意味着客户端下一次读看不到自己的修改。

在这里并没有完美的方案,我们只能进行重试,但重试就不太适合让客户端负责了。如果真的是缓存服务崩溃了,那么此时所有更新后端的客户端都会进行重试,客户端下一次的读操作被自己阻塞住了,并且也给网络环境带来了额外负载。这里可以参考比较经典的方案,由一个独立的服务监听后端更新,然后负责删除缓存。如果你的后端是 MySQL,那么就可以使用 Canal 监听 Binlog 然后负责删除缓存。之后不论是删除异常还是这个服务本身出现异常,都由该服务自己负责解决。

小缓存的额外方案

我们前面提到过,一个缓存如果吞吐量和后端集群相当并且可以吸收对 O(nlogn) 个热键的流量,那么这个缓存可以保证后端节点的负载均衡。这也就意味着,如果你仅仅是为了负载均衡,那么一个高性能的小缓存足矣。这并不是什么罕见的场景,你可以注意到本文之前提的都是后端节点,而不是什么限定的传统关系型数据库,这意味着你的后端节点可能吞吐量足够高,只不过需要负载均衡来进一步发挥它的吞吐量能力。而既然可以只使用小缓存,我们就可以应用一些额外的设计。

注意,这一部分的设计会一定程度上破坏设计的简单性,并让组件之间更加耦合

混合缓存更新架构

在传统的缓存架构,通常用两种方式对缓存进行修改:

  • 读请求未命中,从后端获取数据后写入缓存。一般由客户端负责。
  • 写请求在后端完成,后续修改缓存。可以由上面提过的可监听后端节点的独立服务负责。

后者实际上你可以认为是后端的延伸,是可以嵌入到后端实现的,并且我们是为了减少客户端的复杂性才使用了独立的服务,那么前者很显然也会提升客户端的复杂性,我们是不是也能设计一个独立的服务呢?

更进一步的,上面已经提过了准入策略,很多最近被访问的键并不热门,将它们放入缓存反而会降低性能。那么客户端的很多期望准入的请求实际上就是浪费的,付出了资源开销但并没有带来什么回报。我们可以设计一个混合的缓存更新机制。

定时报告

我们让后端将请求的流量划分区间,每次统计一个区间,为了减少计算开销,我们依旧可以使用采样,只更新一小部分查询的计数器。统计完后使用 TopK 数据结构加权更新键的负载列表:

新负载 = α · Topk 频率 + (1 − α) · 旧负载 其中 α 表示权重降低的程度,并且不在 Topk 中的键会被从列表中删除。

更新完后,从负载列表选出前 k 个键。并将这些键报告给缓存节点。缓存节点维护和更新所有缓存键的负载信息(你可以使用类似 TinyLFU 的结构),用于计算负载阈值。对于报告中负载高于阈值的键,缓存向后端发送 Fetch 请求,获取数据后更新缓存。

突发的热键更新

定期报告可以以较低的通信和内存开销有效地更新缓存,但在某些键突然热门时无法快速响应。为此,除了定期报告之外,后端还需要向缓存发送即时报告。我们需要跟踪突然热门的键并且进行即时报告。

具体而言,可以每个后端维护一个小型的循环日志来跟踪最近访问的键,以及一个哈希表,该哈希表仅保留日志中的键并跟踪这些键的出现次数。当后端收到某个键的查询时,将它插入到循环日志中,并清除该位置的现有键。哈希表相应地更新键的计数,并在必要时添加或删除相关条目。如果某个键的计数超过某个阈值,并且后端节点的总体负载也超过某个阈值,则该键及其值将立即发送并添加到缓存中。

缓存一致性

当后端数据被修改时显然需要通知缓存,而由于缓存很小,可能大多数请求都是针对的未缓存的键,因此将每个更新或删除请求转发到缓存会引入很多不必要的开销。有个比较直观的思路就是可以让后端感知哪些键被缓存了。后端节点维护已缓存的键集合,当收到缓存发的 Fetch 请求、报告突发热点时将键加入集合。当收到缓存淘汰引发的请求、Fetch 或者突发报告中的数据准入失败时,从集合中删除键。

为了容错,这里会使用租约机制,后端给缓存授予租约并让缓存定期续约。如果租约失效则缓存不能处理请求。当后端因收到对缓存键的更新和删除从而向缓存发送请求时,需要等待直到收到响应或者租约过期。

用上交换机

上面的方案已经有点不同寻常了,但我们还可以更进一步。由于小缓存并不需要存储多少数据,那么这么一点数据量是否可以进行硬件优化呢?考虑到网络传输会经过交换机,并且现如今 SDN 技术广泛运用,我们可以使用 OpenFlow 交换机对架构进一步优化。

在我们上面的方式中,客户端必须首先向缓存发送所有读取请求。当命中率较低时,这种方法会带来很高的开销,这是小缓存会面临的情况。一些系统使客户端负责处理缓存未命中,这进一步增加了系统开销和尾部延迟。因此我们的主要目标是删除查询路径上的冗余组件,以便所有查询的延迟都可以最小化,吞吐量可以通过后端节点数量进行扩展,可用性不受缓存节点故障的影响。

交换机硬件已经优化了几十年,可以用高速和低成本执行基本查找。这个简单但高效的功能与查询请求的第一步完美匹配:确定键是否被缓存。我们让客户端将键编码到包头,让 MAC 地址由一个表明请求类型的前缀(表明是一个查询请求)以及键的哈希值组成。而为了让请求可以到达对应的后端节点,客户端存储映射信息(需要一个额外的配置中心服务),将处理请求的后端节点的标识符编码进 IP 地址。

交换机 L2 表存储每个缓存中的键以及每个缓存和后端节点的目标 MAC 地址的精确匹配规则。TCAM 表存储每个后端节点的目标 IP 地址的通配符匹配规则。L2 表被设置为具有更高的优先级,交换机将首先在 L2 表中精确匹配,如果数据包是直接寻址到节点的,或者它是对缓存键的查询,则将数据包转发到出口端口。如果 L2 表中没有匹配,那么交换机将在 TCAM 中查找通配符匹配,并将数据包转发到适当的后端节点。

  • 读流程
    根据转发表中的匹配结果将查询请求路由到缓存或后端。在大多数情况下,发送到缓存节点的查询将击中缓存,但由于从交换机中删除规则的小延迟,或者与另一个键发生罕见的哈希冲突,会出现短暂不一致。当这种情况发生时,缓存节点必须改变 MAC 前缀,直接绕过 L2 查询,将数据包转发到后端。
  • 写流程
    客户端发送插入和删除请求时使用的 MAC 前缀与查询的前缀不同,这样数据包就不会触发交换机 L2 表中的规则,而是直接转发到后端。每个后端节点跟踪其本地存储中的哪些键也被缓存,将在回复客户端之前发送请求更新缓存节点。这也是上面的混合缓存更新机制提过的,只不过这里需要额外加一个操作,即缓存节点负责将更新同步到网络控制器,以更新交换机规则。此策略确保缓存和后端中的数据与客户端请求的一致(保证了异步的线性一致性),但允许实际缓存的键和交换机转发规则之间的临时不一致。

处理突发的规则变化

实际的工作负载不是恒定的,键流行度的突然变化可能会导致短时间内大量的缓存更新。缓存淘汰算法通常要涉及到一个新键的进入以及一个旧键的退出(如果允许了新键的准入),这意味着每次添加可能都会涉及交换机中的两次转发规则更新(一次添加和一次删除)。因此,缓存只能以平均交换机更新速率的一半添加键。

为了快速响应突发的工作负载变化,我们优先考虑添加而不是删除。缓存淘汰和交换规则删除请求在缓存添加和规则更新之后排队并执行,直到达到最大延迟。延迟删除当然会导致 L2 表中的转发规则过时,从而增加不必要的请求开销,只不过理论上这里的额外开销很小,因为不太可能频繁访问被淘汰或删除的键。

更进一步

这个引入交换机的方案实际上支持多缓存节点,通常通过一个交换机来降低这些缓存的负载。只不过在这里我们只有一个缓存节点,那么这也意味着我们完全可以去掉这个缓存节点,让交换机存储实际数据,进一步删除冗余组件。

写在最后

考虑到篇幅,本文暂时先写到这里了。上半部分主要集中在如何设计与优化单个缓存节点,而下半部分将正式迈入分布式领域,我们将进一步设计分布式缓存集群,应用上更加奇妙的设计。如果你有兴趣的话,欢迎关注下我。由于本人目前处于躺平摆烂状态,因此请耐心等待下半部分。

如果你发现了本文的错误,或者有一些单节点缓存优化技术愿意分享,欢迎发在评论区。

参考资料

[1] Small Cache, Big Effect: Provable Load Balancing for Randomly Partitioned Cluster Services.

[2] TinyLFU: A Highly Efficient Cache Admission Policy.

[3] Caffeine: A high performance caching library for java 8. github.com/benmanes/ca....

[4] The Eternal Sunshine of the Sketch Data Structure.

[5] MICA: A Holistic Approach to Fast In-Memory Key-Value Storage.

[6] Be Fast, Cheap and in Control with SwitchKV.

相关推荐
Chrikk1 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*1 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue1 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man1 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
customer083 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
Yaml44 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
小码编匠5 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#
AskHarries5 小时前
Java字节码增强库ByteBuddy
java·后端