17 | Cache:多级缓存架构在消息系统中的应用

你好,我是袁武林。

今天,我要带你了解的是一项在IM系统中相对比较通用的、使用比较高频的,而且对系统性能提升非常明显的技术:缓存。

说到缓存,你应该不陌生。相对于磁盘操作,基于内存的缓存对耗时敏感的高并发应用来说,在性能方面的提升是非常明显的。

下面是谷歌的技术奠基人杰夫·狄恩(Jeff Dean)给出的一些计算机相关的硬件指标,虽然有些数据可能由于时间太久不够准确,但大致的量级基本还是一致的。

  • L1 cache reference 0.5 ns
  • Branch mispredict 5 ns
  • L2 cache reference 7 ns
  • Mutex lock/unlock 100 ns
  • Main memory reference 100 ns
  • Compress 1K bytes with Zippy 10,000 ns
  • Send 2K bytes over 1 Gbps network 20,000 ns
  • Read 1 MB sequentially from memory 250,000 ns
  • Round trip within same datacenter 500,000 ns
  • Disk seek 10,000,000 ns
  • Read 1 MB sequentially from network 10,000,000 ns
  • Read 1 MB sequentially from disk 30,000,000 ns
  • Send packet CA->Netherlands->CA 150,000,000 ns

可以看到,同样是1MB的数据读取,从磁盘读取的耗时,比从内存读取的耗时相差近100倍,这也是为什么业界常说“处理高并发的三板斧是缓存、降级和限流”了。

使用缓存虽然能够给我们带来诸多性能上的收益,但存在一个问题是缓存的资源成本非常高。因此,在IM系统中对于缓存的使用,就需要我们左右互搏地在“缓存命中率”和“缓存使用量”两大指标间不断均衡。

在今天的课程中,我会围绕IM系统中缓存的使用,来聊一聊在使用过程中容易碰到的一些问题及相应的解决方案。

缓存的分布式算法

对于大规模分布式服务来说,大部分缓存的使用都是多实例分布式部署的。接下来,我们就先来了解一下缓存常见的两种分布式算法:取模求余与一致性哈希。

取模求余

取模求余的算法比较简单。比如说,用于存储消息内容的缓存,如果采用取模求余,就可以简单地使用消息ID对缓存实例的数量进行取模求余。

如下图所示:如果消息ID哈希后对缓存节点取模求余,余数是多少,就缓存到哪个节点上。

取模求余的分布式算法在实现上非常简单。但存在的问题是:如果某一个节点宕机或者加入新的节点,节点数量发生变化后,Hash后取模求余的结果就可能和以前不一样了。由此导致的后果是:加减节点后,缓存命中率下降严重。

一致性哈希

为了解决这个问题,业界常用的另一种缓存分布式算法是一致性哈希。它是1997年麻省理工学院提出的一种算法,目前主要应用在分布式缓存场景中。

一致性哈希的算法是:把全量的缓存空间分成2的32次方个区域,这些区域组合成一个环形的存储结构;每一个缓存的消息ID,都可以通过哈希算法,转化为一个32位的二进制数,也就是对应这2的32次方个缓存区域中的某一个;缓存的节点也遵循同样的哈希算法(比如利用节点的IP来哈希),这些缓存节点也都能被映射到2的32次方个区域中的某一个。

那么,如何让消息ID和具体的缓存节点对应起来呢?

很简单,每一个映射完的消息ID,我们按顺时针旋转,找到离它最近的同样映射完的缓存节点,该节点就是消息ID对应的缓存节点。大概规则我画了一个图,你可以参考一下:

那么,为什么一致性哈希能够解决取模求余算法下,加减节点带来的命中率突降的问题呢?

结合上图,我们一起来看一下。假设已经存在了4个缓存节点,现在新增加一个节点5,那么本来相应会落到节点1的mid1和mid9,可能会由于节点5的加入,有的落入到节点5,有的还是落入到节点1;落入到新增的节点5的消息会被miss掉,但是仍然落到节点1的消息还是能命中之前的缓存的。

另外,其他的节点2、3、4对应的这些消息还是能保持不变的,所以整体缓存的命中率,相比取模取余算法波动会小很多。

同样,如果某一个节点宕机的话,一致性哈希也能保证,只会有小部分消息的缓存归属节点发生变化,大部分仍然能保持不变。

数据倾斜

一致性哈希既然解决了加减节点带来的命中率下降的问题,那么是不是这种算法,就是缓存分布式算法的完美方案呢?

这里我们会发现,一致性哈希算法中,如果节点比较少,会容易出现节点间数据不均衡的情况,发生数据倾斜;如果节点很多,相应的消息就能在多个节点上分布得更均匀。

但在实际的线上业务中,部署的缓存机器节点是很有限的。

所以,为了解决物理节点少导致节点间数据倾斜的问题,我们还可以引入虚拟节点,来人为地创造更多缓存节点,以此让数据分布更加均匀。

虚拟节点的大概实现过程,你可以参考下图:

我们为每一个物理节点分配多个虚拟节点,比如在上图这里,给节点1虚拟出4个节点。当消息进行缓存哈希定位时,如果落到了这个物理节点上的任意一个虚拟节点,那么就表示,真正的缓存存储位置在这个物理节点上,然后服务端就可以从这个物理节点上进行数据的读写了。

如上面这个例子,本来都落在节点3的4条消息mid4、mid5、mid6、mid7,在加入节点1的虚拟节点后,mid4和mid5落到了虚拟节点1-2上,这样mid4和mid5就被分配到物理节点1上了。可见,通过这种方式,能更好地打散数据的分布,解决节点间数据不平衡的问题。

缓存热点问题

通过一致性哈希配合虚拟节点,我们解决了节点快速扩容和宕机,导致命中率下降的问题及节点间数据倾斜的问题。但在IM的一些场景里,还可能会出现单一资源热点的问题。

比如,一个超级大V给他的粉丝群发了一篇精心编写的长文章,可能一瞬间服务端会有上万的文章阅读请求涌入。由于这些长文章都是作为富文本进行存储的,所以存储的数据较大,有的文章都超过1MB,而且用户还需要随时能够修改文章,也不好通过CDN来进行分发。

那么,我们如何去解决这种缓存热点问题呢?

多级缓存架构-主从模式

我以上面的“长文章流量热点”的例子来说明一下。为了防止文章下载阅读出现热点时,造成后端存储服务的压力太大,我们一般会通过缓存来进行下载时的加速。比如说,我们可以通过文章的唯一ID来进行哈希,并且通过缓存的一主多从模式来进行部署,主从模式的部署大概如下图:

一般来说,主从模式下,主库只用于数据写入和更新,从库只用于数据读取。当然,这个也不是一定的。

比如,在写多读少的场景下,也可以让主库承担一部分的数据读取工作。当缓存的数据读取QPS比较大的情况下,可以通过增加从库的方式来提升整体缓存层的抗读取能力。

主从模式是最常见的、使用最多的缓存应用模式。但是主从模式在某些突发流量的场景下会存在一些问题,就比如刚刚提到的“长文章流量热点”问题。

我们对某篇长文章的唯一ID来进行哈希,在主从模式下,一篇文章只会映射到一个从库节点上。虽然能够通过增加从库副本数来提升服务端对一篇文章的读取能力,但由于文章大小比较大,即使是多从库副本,对于千兆网卡的从库实例机器来说,带宽层面也很难抗住这个热点。举个例子,单台机器120MB带宽,对于1MB大小的文章来说,如果QPS到1000的话,至少需要8个实例才可以抗住。

另外,多从库副本是对主库数据的完整拷贝,从成本上考虑也是非常不划算的。除了带宽问题,对于某些QPS很高的资源请求来说,如果采用的是单主单从结构,一旦从库宕机,瞬间会有大量请求直接穿透到DB存储层,可能直接会导致资源不可用。

多级缓存架构-L1+主从模式

为了解决主从模式下,单点峰值过高导致单机带宽和热点数据在从库宕机后,造成后端资源瞬时压力的问题,我们可以参考CPU和主存的结构,在主从缓存结构前面再增加一层L1缓存层。

L1缓存,顾名思义一般它的容量会比较小,用于缓存极热的数据。那么,为什么L1缓存可以解决主从模式下的带宽问题和穿透问题呢?

我们来看一下,L1+主从模式的部署和访问形式:

L1缓存作为最前端的缓存层,在用户请求的时候,会先从L1缓存进行查询。如果L1缓存中没有,再从主从缓存里查询,查询到的结果也会回种一份到L1缓存中。

与主从缓存模式不一样的地方是:L1缓存有分组的概念,一组L1可以有多个节点,每一组L1缓存都是一份全量的热数据,一个系统可以提供多组L1缓存,同一个数据的请求会轮流落到每一组L1里面。

比如同一个文章ID,第一次请求会落到第一组L1缓存,第二次请求可能就落到第二组L1缓存。通过穿透后的回种,最后每一组L1缓存,都会缓存到同一篇文章。通过这种方式,同一篇文章就有多个L1缓存节点来抗读取的请求量了。

而且,L1缓存一般采用LRU(Least Recently Used)方式进行淘汰,这样既能减少L1缓存的内存使用量,也能保证热点数据不会被淘汰掉。并且,采用L1+主从的双层模式,即使有某一层节点出现宕机的情况,也不会导致请求都穿透到后端存储上,导致资源出现问题。

多级缓存架构-本地缓存+L1+主从的多层模式

通过L1缓存+主从缓存的双层架构,我们用较少的资源解决了热点峰值的带宽问题和单点穿透问题。

但有的时候,面对一些极热的热点峰值,我们可能需要增加多组L1才能抗住带宽的需要。不过内存毕竟是比较昂贵的成本,所以有没有更好的平衡极热峰值和缓存成本的方法呢?

对于大部分请求量较大的应用来说,应用层机器的部署一般不会太少。如果我们的应用服务器本身也能够承担一部分数据缓存的工作,就能充分利用应用层机器的带宽和极少的内存,来低成本地解决带宽问题了。那么,这种方式是否可以实现呢?

答案是可以的,这种本地缓存+L1缓存+主从缓存的多级缓存模式,也是业界比较成熟的方案了。多级缓存模式的整体流程大概如下图:

本地缓存一般位于应用服务器的部署机器上,使用应用服务器本身的少量内存。它是应用层获取数据的第一道缓存,应用层获取数据时先访问本地缓存,如果未命中,再通过远程从L1缓存层获取,最终获取到的数据再回种到本地缓存中。

通过增加本地缓存,依托应用服务器的多部署节点,基本就能完全解决热点数据带宽的问题。而且,相比较从远程L1缓存获取数据,本地缓存离应用和用户设备更近,性能上也会更好一些。

但是使用本地缓存有一个需要考虑的问题,那就是数据的一致性问题。

还是以“长文章”为例。我们的服务端可能会随时接收到用户需要修改文章内容的请求,这个时候,对于本地缓存来说,由于应用服务器的部署机器随着扩缩容的改变,其数量不一定是固定的,所以修改后的数据如何同步到本地缓存中,就是一个比较复杂和麻烦的事情了。

要解决本地缓存一致性问题,业界比较折中的方式是:对本地缓存采用“短过期时间”的方式,来平衡本地缓存命中率和数据更新一致性的问题。比如说,针对“长文章”的本地缓存,我们可以采用5秒过期的策略,淘汰后再从中央缓存获取新的数据。这种方式对于大部分业务场景来说,在产品层面上也是都能接受的。

小结

好了,下面简单回顾一下今天课程的内容。

首先,我介绍了缓存在高并发应用中的重要性,以及在IM系统中使用的部分场景。然后再带你了解了缓存分布式的两种算法:取模求余和一致性哈希。

  • 取模求余算法在实现上非常简单,但存在的问题是,取模求余算法在节点扩容和宕机后会出现震荡,缓存命中率会严重降低。
  • 一致性哈希算法解决了节点增删时震荡的问题,并通过虚拟节点的引入,缓解了“数据倾斜”的情况。

最后,我着重介绍了业界通用的三种分布式缓存的常见架构。

  • 一种是主从模式。简单的主从模式最常见,但是在面对峰值热点流量时,容易出现带宽问题,也存在缓存节点宕机后穿透到存储层的问题。
  • 第二种是L1+主从模式。通过增加L1缓存层,以并行的多组小容量的L1缓存,解决了单一热点的带宽问题,也避免了单一节点宕机后容易穿透到DB存储层的情况。
  • 最后一种是本地缓存+L1+主从的多层模式。作为低成本的解决方案,我们在L1+主从模式的基础上,引入了本地缓存。本地缓存依托应用服务器的本机少量内存,既提升了资源的有效利用,也彻底解决了带宽的问题。同时在性能方面,也比远程缓存获取更加优秀。对于本地缓存的数据一致性问题,我们可以通过“短过期时间”来平衡缓存命中率和数据一致性。

面对高并发业务带来的流量压力,我们不可否认的是,缓存的使用是目前为止最有效的提升系统整体性能的手段。作为系统优化的一把利器,如何用好这个强大的工具,是你需要去不断思考和学习的。希望今天介绍的这几种缓存使用的姿势,能够让你有所收获,并能在自己的业务中去尝试实践。

最后给你留一道思考题:

L1+主从模式下,如果热点数据都被L1缓存层拦截命中,会导致主从缓存层相应的这个热点数据,由于长时间得不到读取而被LRU淘汰掉。这样,如果下线L1缓存,还是会有不少的请求直接穿透到DB存储层。那么有没有办法,能够让主从缓存在有L1缓存层的情况下,依旧能保持数据热度?

以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论,感谢你的收听,我们下期再见。

精选留言

  • 那时刻

    2019-10-06 15:46:34

    请问怎么保证local cache到cache master的数据一致性呢?毕竟local cache在分布式的服务器集群,会有同一个文档发到不同服务器local cache的情形。从图中看出,是某一个local cache负责同步数据到cache master么?或者我有什么误解?烦请指正
    作者回复

    本地缓存的一致性我课程中有讲哈,本地缓存只会从中央缓存回种,中央缓存数据有变化并不需要同步告知本地缓存。本地缓存通过短过期时间来重新从中央缓存回种。

    2019-10-09 20:27:11

  • 小小小丶盘子

    2019-10-04 00:20:27

    开线程定时访问热点数据,保证不被淘汰。
    作者回复

    也是一种思路,不过实现上可能会比较复杂。另外可以考虑把master也加入到L1缓存层中,这样能保持数据热度。

    2019-10-05 19:36:37

  • 好运连连

    2019-11-12 09:44:12

    为什么把master也当作L1可以解决热度问题?难道每一次master过期也会主动把从库的对应缓存删除?
    作者回复

    master加入到L1层主要是解决master的热点数据缓存得不到访问而被lru淘汰掉,如果这时L1挂了或者被下线,会穿透master对底层db产生压力。对于redis这种本身支持主从的缓存实现,master上过期了slave上也会过期,对于memcached这种本身不支持主从的缓存实现就需要人为来维护主从关系,数据也并不会从master同步到slave。

    2019-11-12 21:12:55

  • Geek_37e993

    2019-10-09 20:27:14

    请教一下,L1缓存和slave以及后端存储的数据一致性如何保证呢?
    作者回复

    有数据更新的时候会同步更新L1和master,虽然多组L1会导致多次更新的问题,但对于大部分读多写少的互联网场景来说刚更新不是一个频繁的操作,所以基本可控。

    2019-10-10 20:53:21

  • clip

    2019-10-08 14:05:48

    主从以及那边有点不明白。
    为什么从库节点中有全部的数据?
    某个节点有全量的数据却只有部分对象的查询分配到自己。
    我理解的如果有全量数据那就直接循环着去不同节点中取不就行了吗,也就不需要缓存分布式算法了吧。
    作者回复

    不是哈,这里是说:对于主从缓存来说,从库拥有和主库一样的数据,所以靠不停扩展多个从库来解决某几个单key热点的问题很浪费。并不是说每个主从都有全量所有的业务数据,主从里的数据是根据hash规则来分片的,是全量数据的子集。

    2019-10-10 11:26:25

  • 大魔王汪汪

    2019-10-07 09:03:56

    老师L1缓存具体是实现和缓存层究竟有什么区别呢?还是说技术实现上本身没有区别,只不过存储目的不同?比如L1就是为了解决热数据的
    作者回复

    技术实现上基本一样,只不过L1主要解决热数据对master slave缓存的单点压力,也可以防止master slave缓存故障导致穿透db的问题。

    2019-10-09 20:24:06

  • 王蒙

    2019-11-05 21:36:19

    举个例子,单台机器 120MB 带宽,对于 1MB 大小的文章来说,如果 QPS 到 1000 的话,至少需要 8 个实例才可以抗住。

    请问,8是怎么算的,有什么公式吗?
    8个实例是指单台物理机,上面8个虚拟机,还是一台机器上部8个相同应用?
    还有平时手机的流量是指http请求的request的请求头和body的大小吗,response的算吗?
    作者回复

    QPS 1000的话整体流量就是1G,一般常见的千兆网卡单台机器单方向120MB带宽, 所以大概需要8台左右。
    这里的流量是指网卡流量,http属于应用层的,所以是包括header和body的。

    2019-11-06 18:45:09

  • AI

    2019-10-15 12:23:57

    对于L1解决带宽问题,感觉文中没讲太清楚。机器带宽和网卡有关系,相同配置的机器带宽一样。从库和L1如果机器数量一样,同样带宽是没问题的。也就是说L1在存储成本上有优势,带宽上没优势吧?
    作者回复

    L1的容量一般比从库容量小很多,但是会冗余多组,通过这种方式来承担极热点数据的访问,带宽上由于冗余多组来随机访问,所以带宽上自然相当于扩大了,另外由于容量都很小,也比扩从库成本上要更省。

    2019-10-16 19:28:04

  • piboye

    2020-05-10 17:04:11

    可以用一个随机数,如果命中某个值就异步放量到master上。这样就有一定比例的访问到master了
  • yic

    2019-11-07 10:33:21

    老师,有两个问题请教一下:
    1. L1缓存业界一般是如何实现的?
    2. 关于课后问题的解答,老师提供的思路是:“另外可以考虑把master也加入到L1缓存层中,这样能保持数据热度“。我想问一下,如果发生了主从关系变化呢?这时如何把master加入到L1缓存层呢?
    作者回复

    L1缓存一般也是和主从缓存一样,采用中央缓存如memcached或者redis,只是在hash规则上和主从缓存有区别。
    您指的主从关系变化是说节点数变化了还是主库切成从库了?如果是节点数发生了变化采用一致性hash是可以不需要干预的,如果是主从关系变化了一般需要及时调整L1的配置。

    2019-11-11 21:07:15

  • 独酌相思解千愁

    2019-11-06 21:34:47

    对于思考题,将各级缓存的存活时间分级设定,L1最小,每当L1重新拉取数据时同时更新其他缓存层的相应热点数据。同时对于一段时间类使用比较频繁的数据可以按照一定规则增加其存活时间(或者权重) 不知道这样有可行性不
    作者回复

    理论上应该可以,不过L1如果承载的是热点数据,那么对于更新主从缓存就会比较频繁,带来额外压力。一种简单的方法可以把主从缓存也作为一组L1加入,这样也能保证主从缓存里的数据热度。

    2019-11-11 20:54:05

  • 卫江

    2019-10-06 00:01:01

    思考题,我感觉可以在L1层,根据访问的特定数据的频率,比如qps超过100就去后面缓存拉一下,这样一来实现简单,频率不高又能保证热数据不被淘汰,同时也可以作为L1缓存的数据更新保证数据一致性的实现方式。
    作者回复

    嗯,这个也是一种实现方案,实现上可能会稍微麻烦一点。也可以考虑把master当做一组L1,也加入到L1层中来保证热度。

    2019-10-09 20:35:08

  • 孙凯

    2019-10-04 14:40:47

    老师能详细讲下L1级缓存怎么用么?
    作者回复

    实现上并不复杂,其实就是二次哈希的过程,比如将原来哈希到slave1的请求再采用round robin轮流打到多组L1上,这样就实现流量分散了。

    2019-10-05 19:50:20

  • 追风筝的人

    2022-09-19 16:59:14

    取模求余算法在实现上非常简单,但存在的问题是,取模求余算法在节点扩容和宕机后会出现震荡,缓存命中率会严重降低。
    一致性哈希算法解决了节点增删时震荡的问题,并通过虚拟节点的引入,缓解了“数据倾斜”的情况。
  • EveryDayIsNew

    2020-09-02 09:40:07

    这种要是放弃L1缓存,然后检测出来热点key,之后把热点key加个几个随机数再散列给集群呢,这样集群的每个节点都理论上会有一份,加上一层L1缓存是不是复杂性高了呢
  • stevensafin

    2020-08-26 12:03:54

    L1Cache表示什么?用什么实现?和主从Cache的区别是什么?概念本身还是希望老师能够先解释清楚哈
  • 问题究竟系边度

    2020-08-07 09:14:18

    L1缓存可以参考知乎的已读服务架构,两层缓存的维度会不一样
  • delete is create

    2020-06-04 20:34:58

    长轮训在集群中会不会出问题 比如你的长轮训是挂在了a节点 但是你要请求的数据是对方在b节点返回的
  • delete is create

    2020-06-04 17:15:12

    如果分布式缓冲用的是redis,是否可以使用redis的发布订阅模式 通知客户端去更新缓冲? 因为多级缓冲还有一种场景是存放配置,这些配置一般都不咋变,没必要每次都访问redis,可以保存在本地缓冲中,这些配置一般都不咋变,如果时间设置的太短,那多级缓冲的意义就不明显了。
  • piboye

    2020-05-10 17:05:54

    一致性hash不讲谷歌的jumphash吗?我发现市面上的课程,都是老一套的环状的。