Memcache详解
参考链接
https://blog.51cto.com/freeloda/1289806
https://acecodeinterview.com/memcached/
https://hoverzheng.github.io/post/technology-blog/architect/memcached架构分析/
https://segmentfault.com/a/1190000016173095
https://blog.csdn.net/xin93/article/details/80712527
https://zhuanlan.zhihu.com/p/87810822
什么是memcache
- 官方的描述,Memcached是一个高性能分布式的内存对象缓存系统。
- 它将数据以key-value形式存储的存储在内存中,极大的提高了效率。
- 但是Memcached的缺点在于不支持持久化(不支持写入磁盘),所以一旦断电,内存中的全部数据都会丢失。
- 而Redis弥补了这个缺点,既在内存中存取数据,又支持持久化,所以Memcached可以理解为是Redis的前身
应用场景
- 在高并发的场景下, 大量的读/写请求涌向数据库, 此时磁盘IO将成为瓶颈, 从而导致过高的响应延迟
特点
特点 | 描述 |
---|---|
协议简单 | 它是基于文本行的协议,可以直接通过telnet在memcached服务器上可进行存取数据操作 |
基于libevent事件处理 | 异步I/O, 基于事件的单进程和单线程, 使用libevent作为事件处理机制; |
内置内存存储方式, 非持久性存储 | 所有数据都保存在内存中,存取数据比硬盘快,当内存满后,通过LRU算法自动删除不使用的缓存,但没有考虑数据的容灾问题,重启服务,所有数据会丢失。 |
分布式 | 各个memcached服务器之间互不通信,各自独立存取数据,不共享任何信息。服务器并不具有分布式功能,分布式部署取决于memcache客户端。 |
架构
- Memcached是在内存中维护一张巨大的Hash表。
- 这张Hash表的结构是由多个slab组成,每个slab的大小是1M;每个slab中存在多个chunk,chunk是数据最终存储的单位。
- chunk采用预分配的方式提高性能,在保存数据之前,需要制定chunk的大小来分配内存。
- Memcached官方版本不支持集群搭建,Memcached彼此之间不进行通信,也就是把一个数据存到一个Memcached上,一旦这个Memcached宕掉了,不能从其它Memcached上读取这些数据,会造成数据丢失。
写流程
-
应用程序输入需要写缓存的数据
-
API将Key输入路由算法模块,路由算法根据Key和MemCache集群服务器列表得到一台服务器编号
-
由服务器编号得到MemCache及其的ip地址和端口号
-
API调用通信模块和指定编号的服务器通信,将数据写入该服务器,完成一次分布式缓存的写操作
-
读缓存和写缓存一样,只要使用相同的路由算法和服务器列表,只要应用程序查询的是相同的Key,
-
MemCache客户端总是访问相同的客户端去读取数据,只要服务器中还缓存着该数据,就能保证缓存命中。
-
这种MemCache集群的方式也是从分区容错性的方面考虑的,假如Node2宕机了,那么Node2上面存储的数据都不可用了,此时由于集群中Node0和Node1还存在,下一次请求Node2中存储的Key值的时候,肯定是没有命中的,这时先从数据库中拿到要缓存的数据,然后路由算法模块根据Key值在Node0和Node1中选取一个节点,把对应的数据放进去,这样下一次就又可以走缓存了,这种集群的做法很好,但是缺点是成本比较大。
接口
分类 | 方法 | 描述 |
---|---|---|
存 | set | 添加一个新条目到memcached或是用新的数据替换替换掉已存在的条目 |
存 | add | 当KEY不存在的情况下,它向memcached存数据,否则,返回NOT_STORED响应 |
存 | replace | 当KEY存在的情况下,它才会向memcached存数据,否则返回NOT_STORED响应 |
存 | cas | 改变一个存在的KEY值 ,但它还带了检查的功能 |
存 | append | 在这个值后面插入新值 |
存 | prepend | 在这个值前面插入新值 |
取 | get | 取单个值 ,从缓存中返回数据时,将在第一行得到KEY的名字,flag的值和返回的value长度,真正的数据在第二行,最后返回END,如KEY不存在,第一行就直接返回END |
取 | get_multi | 一次性取多个值 |
删 | delete |
命令 | 描述 |
---|---|
stats | 统计memcached的各种信息 |
stats reset | 重新统计数据 |
stats slabs | 显示slabs信息,可以详细看到数据的分段存储情况 |
stats items | 显示slab中的item数目 |
stats cachedump 1 0 | 列出slabs第一段里存的KEY值 |
set/get | 保存/获取数据 |
STAT evictions 0 | 表示要腾出新空间给新的item而移动的合法item数目 |
- 不能够遍历MemCache中所有的item,因为这个操作的速度相对缓慢且会阻塞其他的操作
内存设计
-
Memcached利用slab allocation机制来分配和管理内存,它按照预先规定的大小,将分配的内存分割成特定长度的内存块,再把尺寸相同的内存块分成组,数据在存放时,根据键值 大小去匹配slab大小,找就近的slab存放,所以存在空间浪费现象。
-
传统的内存管理方式是,使用完通过malloc分配的内存后通过free来回收内存,这种方式容易产生内存碎片并降低操作系统对内存的管理效率。
-
Memcached的内存管理制效率高,而且不会造成内存碎片,但是它最大的缺点就是会导致空间浪费。因为每个 Chunk都分配了特定长度的内存空间,所以变长数据无法充分利用这些空间。如图二所示,将100个字节的数据缓存到128个字节的Chunk中,剩余的28个字节就浪费掉了。
-
MemCache将内存空间分为一组slab
-
每个slab下又有若干个page,每个page默认是1M,如果一个slab占用100M内存的话,那么这个slab下应该有100个page
-
每个page里面包含一组chunk,chunk是真正存放数据的地方,同一个slab里面的chunk的大小是固定的
-
有相同大小chunk的slab被组织在一起,称为slab_class
-
MemCache内存分配的方式称为allocator,slab的数量是有限的,几个、十几个或者几十个,这个和启动参数的配置相关。
-
MemCache中的value过来存放的地方是由value的大小决定的,value总是会被存放到与chunk大小最接近的一个slab中
-
MemCache的内存分配chunk里面会有内存浪费,88字节的value分配在128字节(紧接着大的用)的chunk中,就损失了30字节,但是这也避免了管理内存碎片的问题
-
MemCache存放的value大小是限制的,因为一个新数据过来,slab会先以page为单位申请一块内存,申请的内存最多就只有1M,所以value大小自然不能大于1M了
如何避免内存浪费
- 预先计算出应用存入的数据大小,或把同一业务类型的数据存入一个Memcached服务器中,确保存入的数据大小相对均匀,这样就可以减少对内存的浪费
- 在启动时指定“-f"参数,能在某种程度上控制内存组之间的大小差异
- 在应用中使用Memcached时,通常可以不重新设置这个参数,使用默认值1.25进行部署
- 如果想优化Memcached对内存的使用,可以考虑重新计算数据的预期平均长度,调整这个参数来获得合适的设置值
删除机制(缓存策略)
-
Memcached的缓存策略是LRU(最近最少使用)加上到期失效策略。
-
MemCache的LRU算法不是针对全局的,是针对slab的
-
当你在memcached内存储数据项时,你有可能会指定它在缓存的失效时间,默认为永久。当memcached服务器用完分配的内时,失效的数据被首先替换,然后也是最近未使用的数据。
-
在LRU中,memcached使用的是一种Lazy Expiration策略,自己不会监控存入的key/vlue对是否过期,而是在获取key值时查看记录的时间戳,检查key/value对空间是否过期,这样可减轻服务器的负载。
-
当空间占满时
- 使用LRU算法来分配空间,删除最近最少使用的key/value对
- 如果不使用LRU的话, 在启动参数上加入”-M”, 内存耗尽时,会返回报错信息
内存回收机制
目标
- 防止常被访问的 Key 被踢出
- 降低延迟 - 减少 LRU Lock 的使用
- 合理协调各 class 的内存
Segmented LRU (分段式LRU)
对于单个数据会维护两个状态。
- Fetched 当数据被访问时,设置为1。
- Active 当数据被访问第二次时设置为1。被往前提 (Bump) 或移动时设置为0。
LRU 被分成四个子 LRU。
- HOT
- 新的数据从这里进来
- 数据在这里排成队列,一旦到了队尾,如果 Active,放入 WARM;如果不是,放入 COLD
- 数据即使被访问了,顺序也始终不变
- 此 LRU 占用内存的大小主要会被限制在全部内存的一定百分比
- 队尾数据的年龄会相对 COLD 队尾数据的年龄被限制
- WARM
- 只有当数据被访问至少两次时,才会被放到 WARM
- 如果队尾数据是 Active,放到队头;如果不是,放入 COLD
- 此 LRU 占用内存的大小主要会被限制在全部内存的一定百分比 (与 HOT 相同)
- 队尾数据的年龄会相对 COLD 队尾数据的年龄被限制 (与 HOT 相同)
- COLD
- 内存满之后,COLD 的队尾数据会被踢出
- 当 COLD 队列里的数据变得 Active,该数据会被异步放入 WARM。
- 这个异步放入 WARM 的操作可能不及时,甚至在过载情况下变得在部分时候随机发生。
- TEMP
- 默认不使用
- 用于超短 TTL 数据
- 数据即使被访问了,顺序也始终不变,也不会移到其他地方
以上四个子 LRU 规则的维护是由称为 LRU Maintainer 的后台线程实现的。
- 遍历所有的子 LRU,看一下队尾
- 保证每个子 LRU 在大小范围以及队尾在年龄范围内,如果不满足,移动一些数据。
- 回收过期数据内存
- 异步将 COLD 队列里的 Active 数据放入 WARM
- 如果特定 class 已经没有 COLD 数据可以踢了,那么普通的 worker 线程会 block SET 指令,将数据踢出 HOT 和 WARM,而不是依赖后台线程处理。
这个机制很巧妙地做到了以下几个点
- LRU 维护不会在取数据时发生,也就不会有 LRU lock
- LRU 维护绝大多数情况下是异步发生的
- 多个子 LRU 各自维护自己的 LRU lock,使得一个子 LRU lock 时,别的依旧可以写
- 每个数据的状态仅 2 bit
LRU Crawler
如何保证 class 之间的内存分配是合理的呢,要准确了解内存情况就得先处理掉内存中的过期数据
- 从每个 class 的每个子 LRU 的队尾同步开始向队头方向寻找,回收过期数据。
- 这里同步的意思是排好队齐步走,这样较短的 class 会很快完成,不至于要一直等到较长的 class 完成后被处理。
- Crawler 边走边维护一个 TTL 直方图,并根据直方图决定多久以后再重新扫描该LRU。
- 如果一个 class 有很多马上就要过期的数据,那么 Crawler 就会在短时间内重新扫描,反之,则可以等等。
这里的设计目标是尽快地回收最大量的内存。
上述的规则会自然地反复快速地回收序号较大的 class 来回收尽可能多的内存,也会根据使用特征来在不同 class 之间平衡数据回收的频率。
Slab Rebalance
-
这是一个可选的功能。
-
随着信息结构的变化,信息的大小会有起伏,使得某一些 class 的大小不再合适。Slab Automove 和 Slab Reassign 功能使得一个 class 的内存可以重新分配到别的class里。
-
Slab Automove 会根据每个 class 里一定时间内内存被提出的次数来找到需要更多内存的class
-
Slab Automove 会根据每个 class 里空余的内存来找到可以减少内存的 class
-
Slab Reassign 实现将一个 page 从 class A 移交到 class B 的交接工作,使用一个后台进程将这个 page 内所有的数据全部提出并且完成移交。
路由算法
- Memcached路由算法,由它来决定数据最终存储在哪个Memcached上。
- Memcached路由算法是由客户端实现的。
hash算法取余
- 将key做hash运算,对memcached数量进行求余数,根据余数来决定存储到哪个Memcached实例。
- 这样根据余数路由的优点在于,能够使数据均匀分布在每个Memcached上,但是也有很大的缺点,一旦某个Memcached宕机,或有新的memcached加入就会找不到数据,出现*严重的数据丢失*。
- 假设服务器由3台扩容到20+的台数,只有前三个HashCode对应的Key是命中的,也就是15%。不仅仅是无法命中,那些大量的无法命中的数据还在原缓存中在被移除前占据着内存。这个结果显然是无法接受的。当大部分被缓存了的数据因为服务器扩容而不能正确读取时,这些数据访问的压力就落在了数据库的身上,这将大大超过数据库的负载能力,严重的可能会导致数据库宕机。
一致性hash
- 一致性hash能够将丢失的数据减小到最小,但不能完全解决宕机造成的数据丢失。
- 一般的一致性hash算法最大限度的抑制了键的重新分配, 并且有的实现方式还采用了虚拟节点的思想
- 服务器的映射地点的分布非常的不均匀, 导致数据访问倾斜, 大量的key被映射到同一台服务器上.
- 虚拟节点: 每台机器计算出多个hash值, 每个值对应环上的一个节点位置
- key的映射方式不变, 就多了层从虚拟节点到再映射到物理机的过程
memcache和redis的区别
对比点 | memcached | redis |
---|---|---|
数据结构 | 单一(存储数据的类型都是String字符串类型) | 丰富(String、List、Set、Sortedset、Hash) |
内存使用率 | 使用简单的key-value存储,Memcached的内存利用率更高 | Redis采用hash结构来做key-value存储,由于其组合式的压缩,其内存利用率会高于Memcached |
持久化 | 不可以 | 可以(可以把数据随时存储在磁盘上) |
数据同步 | 不可以 | 可以 |
多核 | 可以使用多核(多线程) | 单核 |
数据大小 | 单个key(变量)存放的数据有1M的限制 | 单个key(变量)存放的数据有1GB的限制 |
计算能力 | 无 | 本身有一定的计算功能 |
- Redis在存储小数据时比Memcached性能更高
- 在100k以上的数据中,Memcached性能要高于Redis
- memcached增加了网络IO的次数和数据体积
memcached和redis对过期数据的处理
Redis是lazy处理,对有有效期的数据会做有效期的标识,在指定时间会对有过期时间的数据做处理。
随机取出一部分数据,检查是否有过期数据,如果过期数据超过25/100,则反复此过程
服务端设计
在memcached中,可以大致把线程分为两种:
- 一种是分发线程(主线程)
- 一种是worker线程,也就是进行后续命令处理的线程。
- 主线程(main thread)会和每个worker线程都建立一个管道(pipe)
- 当client连接memcached时,会先把连接请求发送给主线程,并和主线程完成连接的建立,让后主线程会选择一个管道,也就是选择了一个worker thread发送一个字符: ‘c’,并把创建一个新的连接实体放到连接队列中,
- 此时阻塞在管道读区端的worker线程被唤醒,worker线程从连接队列中取出连接实体,并对完成连接的socket注册读事件处理函数,最后进入命令处理流程来处理client端的发送命令和各种连接异常的事件。
主线程
- 在memcached中主线程负责监听client端的连接请求,接收并创建和client的tcp连接。
- 并选择一个worker线程来处理该client端的后续请求。
主线程初始化
- 初始化时memcached的主线程主要完成以下几项工作:
- 创建N个worker线程,并和每个线程创建一个双向管道(pipe)
- 为每个woker线程创建保存conn_queue_item对象的队列:new_conn_queue
- 为管道的读端fd(代码中的变量是:notify_receive_fd),注册读事件处理函数:thread_libevent_process
- 创建conn实体,并初始化状态为conn_listening
- 创建绑定(bind)的socket:sfd,并为该socket注册读事件处理函数:event_handler
worker线程
- worker线程负责处理client端发送的命令,连接超时等。
worker线程初始化
- worker线程的初始化完成的工作如下:
- 为管道的读fd:notify_receive_fd,注册读事件监听函数thread_libevent_process 该函数会阻塞在管道的notify_receive_fd描述符上进行读取。
事件处理
处理流程概要
- 处理客户端创建连接请求的处理流程如下:
- 主线程和客户端完成tcp连接的建立
- 主线程创建conn对象,并把该对象放到连接队列中
- 主线程向worker线程的管道中发送字符:’c’
- worker线程从管道中读取命令’c’,并从连接队列中取出一个conn实体
- worker线程创建一个新的conn实体,并把最新的sfd(已完成tcp连接的socket)读事件注册到event_handler
- event_handler会调用drive_machine处理客户端的各种命令和事件
详细说明
-
当客户端向memcached发送连接请求时会触发sfd的读事件处理函数event_handler的执行。
-
该函数会调用drive_machine函数,在该函数中完成与client端的tcp连接建立。
-
当主线程接收到客户端的连接请求时,会选择一个worker线程的管道,选择哪一个worker线程呢,规则如下:
int tid = (last_thread + 1) % settings.num_threads;
-
其实是按轮训的方式来选择worker线程。这样可以保证每个worker线程服务的client的数量基本相同。
-
通过以上方式,选择好一个线程的管道后,创建一个conn_queue_item对象,并把该对象放到连接队列new_conn_queue中,然后向该管道发送’c’字符,该字符表示客户端要创建连接了。
-
此时会触发worker线程的管道读事件处理函数thread_libevent_process的执行,当worker线程的从管道中接收到该字符时,会从事件队列中取出conn_queue_item对象,根据该实体的信息创建一个新的conn实体,并为已经完成连接的socket描述,注册event_handler读事件处理函数。
-
此时已经由worker线程接管了client的连接,后续的客户端发送的命令都会由event_handler事件处理函数进行处理。
-
event_handler函数会最终调用driver_machine()函数来处理客户端所有的请求。