不同于应用服务器的伸缩性设计,分布式缓存集群的伸缩性不能使用简单的负载均衡手段来实现。
和所有服务器都部署相同应用的应用服务器集群不同,分布式缓存服务器集群中不同服务器中缓存的数据各不相同,缓存访问请求不可以在缓存服务器集群中的任意一台处理,必须先找到缓存有需要数据的服务器,然后才能访问。这个特点会严重制约分布式缓存集群的伸缩性设计,因为新上线的缓存服务器没有缓存任何数据,而已下线的缓存服务器还缓存这网站的许多热点数据。
必须让新上线的缓存服务器对整个分布式缓存集群影响最小,也就是说新加入缓存服务器后应使整个缓存服务器集群中已经缓存的数据尽可能还被访问到。
Memcached 分布式缓存访问模型
应用程序通过 Memcached 客户端访问 Memcached 的服务器集群。Memcached 客户端主要由 API、路由算法、服务器集群列表和通信模块组成。
路由算法会根据缓存数据的 KEY,计算出应该把数据写入到哪一台服务器(写入缓存)或从哪一台服务器读取数据(读取缓存)。
一个典型的缓存写操作,如图所示。应用程序输入需要写缓存的数据 <'BEIJING',DATA> ,API 将 KEY('BEIJING')输入到路由算法模块。然后路由算法会根据 KEY 和 Memcached 集群服务器列表计算得到一台服务器编号(Node 1),这样就可以得到这台服务器的 IP 地址与端口。然后 Memcached API 调用通信模块与编号为 Node 1 的服务器通信,把数据 <'BEIJING',DATA> 写入这台服务器。这样就完成了一次分布式缓存写操作。
读缓存的过程与写类似,因为都使用同样的路由算法和服务器列表,所以只要应用程序提供相同的 KEY,那么 Memcached 客户端就总是会访问相同的服务器来读取数据。因此只要服务器还缓存着数据,就能保证缓存被命中。
分布式缓存集群的伸缩性挑战
对于服务器集群的管理,路由算法和负载均衡算法一样,决定访问集群中的哪台服务器。
简单的路由算法可使用余数 Hash:用服务器数除缓存数据 KEY 的 Hash 值,求得的余数即为服务器列表的下标。因为 Hash 值的随机性,所以余数 Hash 可以保证缓存数据在整个 Memcached 服务器集群中比较均衡地分布。对余数 Hash 路由算法稍加改进,就可以实现和负载均衡算法中加权负载均衡一样的加权路由。事实上,如果不需要考虑缓存服务器集群伸缩性,余数 Hash 几乎可以满足绝大多数的缓存路由需求。
但是,当分布式缓存服务器集群需要扩容时,事情就棘手咯。假设把目前已有的 3 台缓存服务器扩容为 4 台。更改服务器列表后,仍然使用余数 Hash 算法,会导致缓存不命中,大约有 75%(3/4)被缓存的数据不命中。随着服务器集群规模的增大,这个比例呈线性上升。当在 N 台服务器集群中加入一台新服务器时,不能命中的概率为 N/(N+1)。如在 100 台中加入一台,不能命中的概率为 99%。
这个结果显然不能接受。网站的大部分业务的读操作请求,实际上都是通过缓存获取的,只有少量的读操作请求会访问数据库,因此数据库的负载能力是以有缓存的前提而设计的。当大部分缓存的数据因为服务器扩容而不能正确读取时,这些访问数据的压力就都落在了数据库身上,这将大大超出数据库的负载能力,甚至会导致数据库宕机。
一种方法是:在网站访问量最少的时候再扩容,这时候对数据库的负载压力最小。然后通过模拟请求来逐步预热缓存,使得缓存服务器中的数据可以重新分布。但这种方案对业务场景有要求,而且还需要技术团队通宵加班(网站访问低谷通常是在半夜)。看来好像不是个好主意!
一致性 Hash 算法
一致性 Hash 算法通过一个叫一致性 Hash 环的数据结构来实现 KEY 到缓存服务器的 Hash 映射:
先构造一个长度为 0 ~ 2 的 32 次方的整数环(一致性 Hash 环),根据节点名称的 Hash 值(范围在 0 ~ 2 的 32 次方),把缓存服务器节点放置在这个 Hash 环上。然后根据需要缓存数据的 KEY 值计算出 Hash 值(范围在 0 ~ 2 的 32 次方),最后再在 Hash 环上顺时针查找距离这个 KEY 的 Hash 值最近的缓存服务器节点,完成 KEY 到服务器的 Hash 映射查找。
当缓存服务器需要扩容时,只需要将新加入的节点名称(比如 Node 3)的 Hash 值放入环中,因为 KEY 是顺时针查找距离最近的节点,所以新加入的节点只会影响整个环中的一小段。
加入新节点 Node 3 后,原来的大部分的 KEY 还能继续使用原来的节点,这样就能保证大部分被缓存的数据还能被命中。3 台服务器扩容至 4 台服务器,可以继续命中原有缓存数据的概率为 75%,而且随着集群规模越大,继续命中原有缓存数据的概率也逐渐增大,100 台服务器集群增加一台服务器,继续命中原有缓存数据的概率为 99%。虽然仍有小部分数据不能被读到,但是这个比例足够小,通过访问数据库获取也不会造成致命的负载压力。
一致性 Hash 环通常使用二叉查找树实现,树最右边的叶子节点和最左边的叶子节点是相连接,构成环。Hash 查找的过程是在树中查找不小于查找数的最小数值。
一致性 Hash 环有一个缺陷:比如上例,新加入的节点 NODE 3 只影响了原来的节点 NODE 1。这意味着 NODE 0 和 NODE 2 缓存的数据量和负载量是 NODE 1 和 NODE 3 的两倍。如果这 4 台服务器性能相同,那么我们自然希望这些服务器缓存的数据量和负载量分布是均衡的。
计算机的任何问题都可以通过增加一个虚拟层来解决
将每一台物理缓存服务器虚拟为一组虚拟缓存服务器,这样就可以将虚拟服务器的 Hash 值放置在 Hash 环上。KEY 会先在环上找出虚拟服务器节点,然后再得到物理服务器的信息。
这样新加入的物理服务器节点时,是将一组虚拟节点加入环中,如果虚拟节点数目足够多,这组虚拟节点将会影响同样多数目的已经在环上存在的虚拟节点,这些已经存在的虚拟节点又对应不同的物理节点。最终结果是:新加入一台缓存服务器,将会较为均匀地影响原来集群中已经存在的所有服务器,也就是说分摊原有缓存服务器集群中所有服务器的一小部分负载。
在图中,新加入节点 NODE 3 对应的一组虚拟节点为 V30,V31,V32,加入到一致性 Hash 环上后,影响 V01,V12,V22 三个虚拟节点,而这三个虚拟节点分别对应 NODE 0,NODE 1,NODE 2 三个物理节点。最终 Memcached 集群中加入一个节点,但是同时影响到集群中已存在的三个物理节点,理想情况下,每个物理节点受影响的数据量(还在缓存中,但是不能被访问到的数据)为其节点缓存数据量的 1/4(X/(N+X),N 为原有物理节点数,X 为新加入物理节点数),也就是集群中已经被缓存的数据有 75% 可以被继续命中,和未使用虚拟节点的一致性 Hash 算法结果相同。
显然,每个物理节点对应的虚拟节点越多,那么各个物理节点之间的负载就会越均衡,新加入的物理服务器对原有的物理服务器的影响越保持一致(这就是一致性 Hash 这个名称的由来)。虚拟节点数太多会影响性能,太少会导致负载不均衡,一般说来,经验值是 150,根据集群规模和负载均衡的精度需求,具体情况具体分析。