Memcache详解

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将成为瓶颈, 从而导致过高的响应延迟

image

特点

特点 描述
协议简单 它是基于文本行的协议,可以直接通过telnet在memcached服务器上可进行存取数据操作
基于libevent事件处理 异步I/O, 基于事件的单进程和单线程, 使用libevent作为事件处理机制;
内置内存存储方式, 非持久性存储 所有数据都保存在内存中,存取数据比硬盘快,当内存满后,通过LRU算法自动删除不使用的缓存,但没有考虑数据的容灾问题,重启服务,所有数据会丢失。
分布式 各个memcached服务器之间互不通信,各自独立存取数据,不共享任何信息。服务器并不具有分布式功能,分布式部署取决于memcache客户端。

架构

  • Memcached是在内存中维护一张巨大的Hash表。
  • 这张Hash表的结构是由多个slab组成,每个slab的大小是1M;每个slab中存在多个chunk,chunk是数据最终存储的单位。
  • chunk采用预分配的方式提高性能,在保存数据之前,需要制定chunk的大小来分配内存。

image

image

image

  • Memcached官方版本不支持集群搭建,Memcached彼此之间不进行通信,也就是把一个数据存到一个Memcached上,一旦这个Memcached宕掉了,不能从其它Memcached上读取这些数据,会造成数据丢失。

写流程

  1. 应用程序输入需要写缓存的数据

  2. API将Key输入路由算法模块,路由算法根据Key和MemCache集群服务器列表得到一台服务器编号

  3. 由服务器编号得到MemCache及其的ip地址和端口号

  4. 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个字节就浪费掉了。

image

image

  1. MemCache将内存空间分为一组slab

  2. 每个slab下又有若干个page,每个page默认是1M,如果一个slab占用100M内存的话,那么这个slab下应该有100个page

  3. 每个page里面包含一组chunk,chunk是真正存放数据的地方,同一个slab里面的chunk的大小是固定的

  4. 有相同大小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。

image

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能够将丢失的数据减小到最小,但不能完全解决宕机造成的数据丢失。

image

  • 一般的一致性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线程,也就是进行后续命令处理的线程。

image

  • 主线程(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()函数来处理客户端所有的请求。

posted @ 2022-10-30 22:08  Jamgun  阅读(236)  评论(0编辑  收藏  举报