1.Memcached内存分配原理介绍
掌握Memcached的安装、使用命令,其实对大部分的同学来说已经足以开展相关开发工作了。但当碰到一些线上问题的时候,单纯的会用Memcached是无法快速、合理的分析问题所在的。所以接下来我们将介绍Memcached的内存分配管理原理。
Memcached默认情况下采用了名为Slab Allocator的机制分配、管理内存。Slab Allocator的基本原理是按照预先规定的大小,将分配的内存分割成特定长度的块, 以完全解决内存碎片问题。
在介绍memcached的内存分配原理之前,需要跟大家说明以下几个关键的名词的概念:
item
一个待存储的元素,按字节计算大小,可以理解为一个物品
Chunk
用于缓存item的内存空间。可以理解为一个储物格
Slab Class
特定大小的chunk的组。可以理解为储物格按大小进行分类,如80B作为一类,96B作为一类….
Page
分配给Slab class的内存空间,默认是1MB。分配给Slab之后根据slab class的大小切分成chunk。可以理解为一个page是一个固定大小的柜子,上面可以按slab class进行分割,一个柜子只能按一个slab class进行分割。柜子上的格子数为柜子大小/ 储物格的大小
介绍完上述的几个基本概念后,我们可以来看看mc在分配内存的时候是怎么处理的。
图1 memcached 初始化示意图
如图1为一个memcached示例在启动的时候,可以指定的一些参数,初始大小为slab class的起始大小,增长因子为下一个slab class是初始因子的倍数。如图中所示,初始大小为80B,增长因子为1.5。则mc在启动后,会按下图生成slab class表。
图2 slab分布图
完成初始后,当某一个请求到来的时候——如图中所示由一个123B大小的元素希望找到存储空间,memcached会通过slab class表找到最合适的slab class:比元素大的最少的那个,在图中场景下为180B,即使所需的空间只要123B。
此时Memcached示例并没有分配任何的空间给180B的slab进行管理。所以为了能让请求的元素能存储上,Memcached实例会分配1 个page给180这个slab(在默认情况下为1MB实际内存)。
图 3 page分配图
180B slab class在获取到1MB的空间后,会按照自己的大小对page进行分隔,也即1MB/180=5828个具体的存储空间(chunk)。此时,123B的请求就可以被存储起来了。
随着时间的慢慢推移,memcached的内存空间会逐步被分配完,如下图4所示:
图 4内存slab分配图
我们可以看到,memcached划分给每个slab的page数是不均等的,存在部分的slab是可能一个page都分配不到的。
假设所有的内存都分配完,同时每个slab内部的chunk也都分配完了。此时又来了一个新的元素123B,那么就会触发memcached的淘汰机制了。
memcached首先会查看180B的slab是否存在过期的元素,如果存在,则先清理部分,预留空位。如果180B这个slab的数据都比较热(没有过期),则按LRU进行淘汰。需要注意的是,淘汰是在slab内部进行的,也即在上面的场景中只有180Bslab内部进行淘汰剔除,对于其他的slab,是没有受到影响的。memcached也不会回收比较空余的其他slab的page。也即一个page被分配给某个slab后,他将一直被这个slab所占用,永远无法被mc回收,直到memcached重启。
这个特性被称为Memcached的钙化问题:Memcached在线上跑了一段时间后,内存按原始访问模式分配内存。当访问模式变更后,原有的分配模式可能导致缓存频繁出现数据剔除问题。最典型的场景即为内存尚有空余,但一直有数据被剔除,命中率一直上不去。对于这种情况,解决方法为重启缓存。
主从双层结构
高并发&高可用
2. 主从双层结构
通过数据分片,将mc从单台实例增加到一组缓存后,我们可以解决单端口容量、访问量不足的问题,但是如果出现某一台缓存挂了的情况。请求依然会落到后端的DB上。可以通过一致性hash的方式,来减少损失。
但基于一致性哈希策略的分布式实现在微博业务场景下也存在一些问题:
(1)微博线上业务对缓存命中率要求高。某台缓存挂了,会导致缓存整体命中率下降(即使一致性hash会在一定时间后将数据重新种到另一个节点上),对于命中率要求在99%以上的Feed流核心业务场景来说,命中率的下降是难以接受的
(2)一致性hash存在请求漂移的情况,假设某一段时间服务因网络因素访问某个服务节点失败,则在这时候,会将数据的更新、获取都迁移到下一个节点上。后续网络恢复后,应用服务探测到服务节点可用,则继续从原服务节点中获取数据,这就导致了在故障期间所做的更新操作,对于原服务节点不可见了
目前我们对于这种单点问题主要是通过引入主从缓存结构来解决的。主从结构示意图如下图5所示:
图 5主从双层结构缓存
服务端在上行逻辑中,进行双写操作——由应用服务负责更新master、slave数据。
下行获取数据,先获取master数据,当master返回空,或者无法取到数据的时候,访问slave。
在这种模式下,为了避免两份数据带来的不一致问题,需要以master数据为准。即如果有更新数据操作,需要从master中获取数据,再对master进行cas更新。更新成功后,才更新slave。如果cas多次后都失败,则对master、slave进行delete操作,后续让请求穿透回种即可。
2横向线性扩展
在双层结构下,我们可以很好的解决单点问题,即某一个节点如果crash了,请求可以被slave承接住,请求不会直接落在DB上。
但这种架构仍然存在一些问题:
(1)带宽问题。由于存在热点访问的情况,线上经常出现单个服务节点的带宽跑满的情况。
(2)请求量问题。随着业务的不断发展,并发请求数超过了单个节点的机器上限。数据分片、双层结构都不能解决这种问题。
上面的两个问题,其实总结起来是如何快速横向扩展系统的支撑能力。对于这个问题,我们的解决思路为增加数据的副本数。即让数据副本存在于多个节点中,从而平摊原本落在一个节点的请求。
从我们经验来看,对于线性扩展,可以在原来的master上引入一层L1层缓存。整体示意图如6所示:
图6采用L1的缓存架构
上行操作需要对L1进行多写。写缓存的顺序为master-slave-L1(所有),写失败则进行delete操作,后续由穿透请求进行回种。
L1可以由多组缓存组成,每组缓存相互独立。应用服务在获取数据的时候,先从L1中选取一组资源,然后再进行hash选取特定节点。对于multiget的场景也是先选取一组缓存,然后才对这组缓存进行multiget操作。如果L1获取不到数据,再依次获取master、slave数据。获取成功,则回种到L1中。
在采用L1的模式中,数据也是以master中数据为主的。即如果有更新数据的需要,需要从master中获取数据原本,再进行cas更新。如果cas更新成功,才同时更新slave、L1资源。如果对master的操作失败,则进行delete all操作,让后续请求穿透回种。
当线上流量、请求量达到一个水位的时候,我们会进行L1的扩容——增加一组、或几组L1缓存,从而提升系统整体的承载能力。此时系统的整体响应请求量是可以做到线性扩展的。
可以看到,双层结构下,slave作为主的备份存在。假设线上master缓存命中率为99%,则落在slave上的请求只有1%,并且这1%的请求都是很偏、很少人访问到的。可以想象,在这种情况下,如果master真的出现问题,请求全部落在slave上,slave也是没有任何数据可供访问的。Slave作为防单点措施是失败的。
引入L1后,slave过冷并没有被解决,同时,由于master被放置到L1之下,也遇到了slave的问题,master的数据也存在过冷的风险。为了解决上面的问题,我们在线上配置的时候,会将整组slave做为L1的一组资源进行配置,让slave以L1的身份承担部分的热请求。同时为了解决master过冷的问题,我们也会让应用服务在选择L1的时候有一定的概率落空,从而让master作为L1逻辑分组,去承担部分热请求。整体结构图如图7所示:
图7 slave、master同时作为L1架构