MemCache 是什么
MemCache 是一个自由、源码开放、高性能、分布式的分布式内存对象缓存系统,用于动态 Web 应用以减轻数据库的负载。它通过在内存中缓存数据和对象来减少读取数据库的次数,从而提高了网站访问的速度。MemCaChe 是一个存储键值对的 HashMap,在内存中对任意的数据(比如字符串、对象等)所使用的 key-value 存储,数据可以来自数据库调用、API 调用,或者页面渲染的结果。MemCache 设计理念就是小而强大,它简单的设计促进了快速部署、易于开发并解决面对大规模的数据缓存的许多难题,而所开放的 API 使得 MemCache 能用于 Java、C/C++/C#、Perl、Python、PHP、Ruby 等大部分流行的程序语言。
另外,说一下 MemCache 和 MemCached 的区别:
1、MemCache 是项目的名称
2、MemCached 是 MemCache 服务器端可以执行文件的名称
MemCache 的官方网站为 http://memcached.org/
MemCache 访问模型
为了加深理解,我模仿着原阿里技术专家李智慧老师《大型网站技术架构 核心原理与案例分析》一书 MemCache 部分,自己画了一张图:
特别澄清一个问题,MemCache 虽然被称为"分布式缓存",但是 MemCache 本身完全不具备分布式的功能,MemCache 集群之间不会相互通信(与之形成对比的,比如 JBoss Cache,某台服务器有缓存数据更新时,会通知集群中其他机器更新缓存或清除缓存数据),所谓的"分布式",完全依赖于客户端程序的实现,就像上面这张图的流程一样。
同时基于这张图,理一下 MemCache 一次写缓存的流程:
1、应用程序输入需要写缓存的数据
2、API 将 Key 输入路由算法模块,路由算法根据 Key 和 MemCache 集群服务器列表得到一台服务器编号
3、由服务器编号得到 MemCache 及其的 ip 地址和端口号
4、API 调用通信模块和指定编号的服务器通信,将数据写入该服务器,完成一次分布式缓存的写操作
读缓存和写缓存一样,只要使用相同的路由算法和服务器列表,只要应用程序查询的是相同的 Key,MemCache 客户端总是访问相同的客户端去读取数据,只要服务器中还缓存着该数据,就能保证缓存命中。
这种 MemCache 集群的方式也是从分区容错性的方面考虑的,假如 Node2 宕机了,那么 Node2 上面存储的数据都不可用了,此时由于集群中 Node0 和 Node1 还存在,下一次请求 Node2 中存储的 Key 值的时候,肯定是没有命中的,这时先从数据库中拿到要缓存的数据,然后路由算法模块根据 Key 值在 Node0 和 Node1 中选取一个节点,把对应的数据放进去,这样下一次就又可以走缓存了,这种集群的做法很好,但是缺点是成本比较大。
一致性 Hash 算法
从上面的图中,可以看出一个很重要的问题,就是对服务器集群的管理,路由算法至关重要,就和负载均衡算法一样,路由算法决定着究竟该访问集群中的哪台服务器,先看一个简单的路由算法。
1、余数 Hash
比方说,字符串 str 对应的 HashCode 是 50、服务器的数目是 3,取余数得到 2,str 对应节点 Node2,所以路由算法把 str 路由到 Node2 服务器上。由于 HashCode 随机性比较强,所以使用余数 Hash 路由算法就可以保证缓存数据在整个 MemCache 服务器集群中有比较均衡的分布。
如果不考虑服务器集群的伸缩性(什么是伸缩性,请参见大型网站架构学习笔记),那么余数 Hash 算法几乎可以满足绝大多数的缓存路由需求,但是当分布式缓存集群需要扩容的时候,就难办了。
就假设 MemCache 服务器集群由 3 台变为 4 台吧,更改服务器列表,仍然使用余数 Hash,50 对 4 的余数是 2,对应 Node2,但是 str 原来是存在 Node1 上的,这就导致了缓存没有命中。如果这么说不够明白,那么不妨举个例子,原来有 HashCode 为 0~19 的 20 个数据,那么:
| HashCode | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 路由到的服务器 | 0 | 1 | 2 | 0 | 1 | 2 | 0 | 1 | 2 | 0 | 1 | 2 | 0 | 1 | 2 | 0 | 1 | 2 | 0 | 1 |
现在我扩容到 4 台,加粗标红的表示命中:
| HashCode | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 路由到的服务器 | 0 | 1 | 2 | 3 | 0 | 1 | 2 | 3 | 0 | 1 | 2 | 3 | 0 | 1 | 2 | 3 | 0 | 1 | 2 | 3 |
如果我扩容到 20+ 的台数,只有前三个 HashCode 对应的 Key 是命中的,也就是 15%。当然这只是个简单例子,现实情况肯定比这个复杂得多,不过足以说明,使用余数 Hash 的路由算法,在扩容的时候会造成大量的数据无法正确命中(其实不仅仅是无法命中,那些大量的无法命中的数据还在原缓存中在被移除前占据着内存)。这个结果显然是无法接受的,在网站业务中,大部分的业务数据度操作请求上事实上是通过缓存获取的,只有少量读操作会访问数据库,因此数据库的负载能力是以有缓存为前提而设计的。当大部分被缓存了的数据因为服务器扩容而不能正确读取时,这些数据访问的压力就落在了数据库的身上,这将大大超过数据库的负载能力,严重的可能会导致数据库宕机。
这个问题有解决方案,解决步骤为:
(1)在网站访问量低谷,通常是深夜,技术团队加班,扩容、重启服务器
(2)通过模拟请求的方式逐渐预热缓存,使缓存服务器中的数据重新分布
2、一致性 Hash 算法
一致性 Hash 算法通过一个叫做一致性 Hash 环的数据结构实现 Key 到缓存服务器的 Hash 映射,看一下我自己画的一张图:
具体算法过程为:先构造一个长度为 232 的整数环(这个环被称为一致性 Hash 环),根据节点名称的 Hash 值(其分布为[0, 232-1])将缓存服务器节点放置在这个 Hash 环上,然后根据需要缓存的数据的 Key 值计算得到其 Hash 值(其分布也为[0, 232-1]),然后在 Hash 环上顺时针查找距离这个 Key 值的 Hash 值最近的服务器节点,完成 Key 到服务器的映射查找。
就如同图上所示,三个 Node 点分别位于 Hash 环上的三个位置,然后 Key 值根据其 HashCode,在 Hash 环上有一个固定位置,位置固定下之后,Key 就会顺时针去寻找离它最近的一个 Node,把数据存储在这个 Node 的 MemCache 服务器中。使用 Hash 环如果加了一个节点会怎么样,看一下:
看到我加了一个 Node4 节点,只影响到了一个 Key 值的数据,本来这个 Key 值应该是在 Node1 服务器上的,现在要去 Node4 了。采用一致性 Hash 算法,的确也会影响到整个集群,但是影响的只是加粗的那一段而已,相比余数 Hash 算法影响了远超一半的影响率,这种影响要小得多。更重要的是,集群中缓存服务器节点越多,增加节点带来的影响越小,很好理解。换句话说,随着集群规模的增大,继续命中原有缓存数据的概率会越来越大,虽然仍然有小部分数据缓存在服务器中不能被读到,但是这个比例足够小,即使访问数据库,也不会对数据库造成致命的负载压力。
至于具体应用,这个长度为 232 的一致性 Hash 环通常使用二叉查找树实现,至于二叉查找树,就是算法的问题了,可以自己去查询相关资料。
MemCache 实现原理
首先要说明一点,MemCache 的数据存放在内存中,存放在内存中个人认为意味着几点:
1、访问数据的速度比传统的关系型数据库要快,因为 Oracle、MySQL 这些传统的关系型数据库为了保持数据的持久性,数据存放在硬盘中,IO 操作速度慢
2、MemCache 的数据存放在内存中同时意味着只要 MemCache 重启了,数据就会消失
3、既然 MemCache 的数据存放在内存中,那么势必受到机器位数的限制,这个之前的文章写过很多次了,32 位机器最多只能使用 2GB 的内存空间,64 位机器可以认为没有上限
然后我们来看一下 MemCache 的原理,MemCache 最重要的莫不是内存分配的内容了,MemCache 采用的内存分配方式是固定空间分配,还是自己画一张图说明:
这张图片里面涉及了 slab_class、slab、page、chunk 四个概念,它们之间的关系是:
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 中,比如 slab[1]的 chunk 大小为 80 字节、slab[2]的 chunk 大小为 100 字节、slab[3]的 chunk 大小为 128 字节(相邻 slab 内的 chunk 基本以 1.25 为比例进行增长,MemCache 启动时可以用-f 指定这个比例),那么过来一个 88 字节的 value,这个 value 将被放到 2 号 slab 中。放 slab 的时候,首先 slab 要申请内存,申请内存是以 page 为单位的,所以在放入第一个数据的时候,无论大小为多少,都会有 1M 大小的 page 被分配给该 slab。申请到 page 后,slab 会将这个 page 的内存按 chunk 的大小进行切分,这样就变成了一个 chunk 数组,最后从这个 chunk 数组中选择一个用于存储数据。
如果这个 slab 中没有 chunk 可以分配了怎么办,如果 MemCache 启动没有追加-M(禁止 LRU,这种情况下内存不够会报 Out Of Memory 错误),那么 MemCache 会把这个 slab 中最近最少使用的 chunk 中的数据清理掉,然后放上最新的数据。针对 MemCache 的内存分配及回收算法,总结三点:
1、MemCache 的内存分配 chunk 里面会有内存浪费,88 字节的 value 分配在 128 字节(紧接着大的用)的 chunk 中,就损失了 30 字节,但是这也避免了管理内存碎片的问题
2、MemCache 的 LRU 算法不是针对全局的,是针对 slab 的
3、应该可以理解为什么 MemCache 存放的 value 大小是限制的,因为一个新数据过来,slab 会先以 page 为单位申请一块内存,申请的内存最多就只有 1M,所以 value 大小自然不能大于 1M 了
再总结 MemCache 的特性和限制
上面已经对于 MemCache 做了一个比较详细的解读,这里再次总结 MemCache 的限制和特性:
1、MemCache 中可以保存的 item 数据量是没有限制的,只要内存足够
2、MemCache 单进程在 32 位机中最大使用内存为 2G,这个之前的文章提了多次了,64 位机则没有限制
3、Key 最大为 250 个字节,超过该长度无法存储
4、单个 item 最大数据是 1MB,超过 1MB 的数据不予存储
5、MemCache 服务端是不安全的,比如已知某个 MemCache 节点,可以直接 telnet 过去,并通过 flush_all 让已经存在的键值对立即失效
6、不能够遍历 MemCache 中所有的 item,因为这个操作的速度相对缓慢且会阻塞其他的操作
7、MemCache 的高性能源自于两阶段哈希结构:第一阶段在客户端,通过 Hash 算法根据 Key 值算出一个节点;第二阶段在服务端,通过一个内部的 Hash 算法,查找真正的 item 并返回给客户端。从实现的角度看,MemCache 是一个非阻塞的、基于事件的服务器程序
8、MemCache 设置添加某一个 Key 值的时候,传入 expiry 为 0 表示这个 Key 值永久有效,这个 Key 值也会在 30 天之后失效,见 memcache.c 的源代码:
#define REALTIME_MAXDELTA 606024*30
static rel_time_t realtime(const time_t exptime) { if (exptime == 0) return 0; if (exptime > REALTIME_MAXDELTA) { if (exptime <= process_started) return (rel_time_t)1; return (rel_time_t)(exptime - process_started);
} else { return (rel_time_t)(exptime + current_time);
}
}
这个失效的时间是 memcache 源码里面写的,开发者没有办法改变 MemCache 的 Key 值失效时间为 30 天这个限制
MemCache 指令汇总
上面说过,已知 MemCache 的某个节点,直接 telnet 过去,就可以使用各种命令操作 MemCache 了,下面看下 MemCache 有哪几种命令:
| 命 令 | 作 用 |
| get | 返回 Key 对应的 Value 值 |
| add | 添加一个 Key 值,没有则添加成功并提示 STORED,有则失败并提示 NOT_STORED |
| set | 无条件地设置一个 Key 值,没有就增加,有就覆盖,操作成功提示 STORED |
| replace | 按照相应的 Key 值替换数据,如果 Key 值不存在则会操作失败 |
| stats | 返回 MemCache 通用统计信息(下面有详细解读) |
| stats items | 返回各个 slab 中 item 的数目和最老的 item 的年龄(最后一次访问距离现在的秒数) |
| stats slabs | 返回 MemCache 运行期间创建的每个 slab 的信息(下面有详细解读) |
| version | 返回当前 MemCache 版本号 |
| flush_all | 清空所有键值,但不会删除 items,所以此时 MemCache 依旧占用内存 |
| quit | 关闭连接 |
stats 指令解读
stats 是一个比较重要的指令,用于列出当前 MemCache 服务器的状态,拿一组数据举个例子:
STAT pid 1023 STAT uptime 21069937 STAT time 1447235954 STAT version 1.4.5 STAT pointer_size 64 STAT rusage_user 1167.020934 STAT rusage_system 3346.933170 STAT curr_connections 29 STAT total_connections 21 STAT connection_structures 49 STAT cmd_get 49 STAT cmd_set 7458 STAT cmd_flush 0 STAT get_hits 7401 STAT get_misses 57 ..(delete、incr、decr、cas 的 hits 和 misses 数,cas 还多一个 badval)
STAT auth_cmds 0 STAT auth_errors 0 STAT bytes_read 22026555 STAT bytes_written 8930466 STAT limit_maxbytes 4134304000 STAT accepting_conns 1 STAT listen_disabled_num 0 STAT threads 4 STAT bytes 151255336 STAT current_items 57146 STAT total_items 580656 STAT evicitions 0
这些参数反映着 MemCache 服务器的基本信息,它们的意思是:
| 参 数 名 | 作 用 |
| pid | MemCache 服务器的进程 id |
| uptime | 服务器已经运行的秒数 |
| time | 服务器当前的 UNIX 时间戳 |
| version | MemCache 版本 |
| pointer_size | 当前操作系统指针大小,反映了操作系统的位数,64 意味着 MemCache 服务器是 64 位的 |
| rusage_user | 进程的累计用户时间 |
| rusage_system | 进程的累计系统时间 |
| curr_connections | 当前打开着的连接数 |
| total_connections | 当服务器启动以后曾经打开过的连接数 |
| connection_structures | 服务器分配的连接构造数 |
| cmd_get | get 命令总请求次数 |
| cmd_set | set 命令总请求次数 |
| cmd_flush | flush_all 命令总请求次数 |
| get_hits | 总命中次数,重要,缓存最重要的参数就是缓存命中率,以 get_hits / (get_hits + get_misses)表示,比如这个缓存命中率就是 99.2% |
| get_misses | 总未命中次数 |
| auth_cmds | 认证命令的处理次数 |
| auth_errors | 认证失败的处理次数 |
| bytes_read | 总读取的字节数 |
| bytes_written | 总发送的字节数 |
| limit_maxbytes | 分配给 MemCache 的内存大小(单位为字节) |
| accepting_conns | 是否已经达到连接的最大值,1 表示达到,0 表示未达到 |
| listen_disabled_num | 统计当前服务器连接数曾经达到最大连接的次数,这个次数应该为 0 或者接近于 0,如果这个数字不断增长, 就要小心我们的服务了 |
| threads | 当前 MemCache 总线程数,由于 MemCache 的线程是基于事件驱动机制的,因此不会一个线程对应一个用户请求 |
| bytes | 当前服务器存储的 items 总字节数 |
| current_items | 当前服务器存储的 items 总数量 |
| total_items | 自服务器启动以后存储的 items 总数量 |
stats slab 指令解读
如果对上面的 MemCache 存储机制比较理解了,那么我们来看一下各个 slab 中的信息,还是拿一组数据举个例子:
1 STAT1:chunk_size 96
2 ...
3 STAT 2:chunk_size 144
4 STAT 2:chunks_per_page 7281
5 STAT 2:total_pages 7
6 STAT 2:total_chunks 50967
7 STAT 2:used_chunks 45197
8 STAT 2:free_chunks 1
9 STAT 2:free_chunks_end 5769
10 STAT 2:mem_requested 6084638
11 STAT 2:get_hits 48084
12 STAT 2:cmd_set 59588271
13 STAT 2:delete_hits 0
14 STAT 2:incr_hits 0
15 STAT 2:decr_hits 0
16 STAT 2:cas_hits 0
17 STAT 2:cas_badval 0
18 ... 19 STAT 3:chunk_size 216
20 ...
首先看到,第二个 slab 的 chunk_size(144)/第一个 slab 的 chunk_size(96)=1.5,第三个 slab 的 chunk_size(216)/第二个 slab 的 chunk_size(144)=1.5,可以确定这个 MemCache 的增长因子是 1.5,chunk_size 以 1.5 倍增长。然后解释下字段的含义:
| 参 数 名 | 作 用 |
| chunk_size | 当前 slab 每个 chunk 的大小,单位为字节 |
| chunks_per_page | 每个 page 可以存放的 chunk 数目,由于每个 page 固定为 1M 即 10241024 字节,所以这个值就是(10241024/chunk_size) |
| total_pages | 分配给当前 slab 的 page 总数 |
| total_chunks | 当前 slab 最多能够存放的 chunk 数,这个值是 total_pageschunks_per_page |
| used_chunks | 已经被分配给存储对象的 chunks 数目 |
| free_chunks | 曾经被使用过但是因为过期而被回收的 chunk 数 |
| free_chunks_end | 新分配但还没有被使用的 chunk 数,这个值不为 0 则说明当前 slab 从来没有出现过容量不够的时候 |
| mem_requested | 当前 slab 中被请求用来存储数据的内存空间字节总数,(total_chunkschunk_size)-mem_requested 表示有多少内存在当前 slab 中是被闲置的,这包括未用的 slab+ 使用的 slab 中浪费的内存 |
| get_hits | 当前 slab 中命中的 get 请求数 |
| cmd_set | 当前 slab 中接收的所有 set 命令请求数 |
| delete_hits | 当前 slab 中命中的 delete 请求数 |
| incr_hits | 当前 slab 中命中的 incr 请求数 |
| decr_hits | 当前 slab 中命中的 decr 请求数 |
| cas_hits | 当前 slab 中命中的 cas 请求数 |
| cas_badval | 当前 slab 中命中但是更新失败的 cas 请求数 |
看到这个命令的输出量很大,所有信息都很有作用。举个例子吧,比如第一个 slab 中使用的 chunks 很少,第二个 slab 中使用的 chunks 很多,这时就可以考虑适当增大 MemCache 的增长因子了,让一部分数据落到第一个 slab 中去,适当平衡两个 slab 中的内存,避免空间浪费。
MemCache 的 Java 实现实例
讲了这么多,作为一个 Java 程序员,怎么能不写写 MemCache 的客户端的实现呢?MemCache 的客户端有很多第三方 jar 包提供了实现,其中比较好的当属 XMemCached 了,XMemCached 具有效率高、IO 非阻塞、资源耗费少、支持完整的协议、允许设置节点权重、允许动态增删节点、支持 JMX、支持与 Spring 框架集成、使用连接池、可扩展性好等诸多优点,因而被广泛使用。这里利用 XMemCache 写一个简单的 MemCache 客户单实例,也没有验证过,纯属抛砖引玉:
public class MemCacheManager
{ private static MemCacheManager instance = new MemCacheManager(); /** XMemCache 允许开发者通过设置节点权重来调节 MemCache 的负载,设置的权重越高,该 MemCache 节点存储的数据越多,负载越大 /
private static MemcachedClientBuilder mcb =
new XMemcachedClientBuilder(AddrUtil.getAddresses("127.0.0.1:11211 127.0.0.2:11211 127.0.0.3:11211"), new int[]{1, 3, 5}); private static MemcachedClient mc = null; /* 初始化加载客户端 MemCache 信息 */
static {
mcb.setCommandFactory(new BinaryCommandFactory()); // 使用二进制文件
mcb.setConnectionPoolSize(10); // 连接池个数,即客户端个数
try {
mc = mcb.build();
} catch (IOException e)
{
e.printStackTrace();
}
} private MemCacheManager()
{
} public MemCacheManager getInstance()
{ return instance;
} /** 向MemCache服务器设置数据 */
public void set(String key, int expiry, Object obj) throws Exception
{
mc.set(key, expiry, obj);
} /** 从MemCache服务器获取数据 */
public Object get(String key) throws Exception
{ return mc.get(key);
} /** * MemCache通过compare and set即cas协议实现原子更新,类似乐观锁,每次请求存储某个数据都要附带一个cas值,MemCache
* 比对这个cas值与当前存储数据的cas值是否相等,如果相等就覆盖老数据,如果不相等就认为更新失败,这在并发环境下特别有用 */
public boolean update(String key, Integer i) throws Exception
{
GetsResponse result = mc.gets(key); long cas = result.getCas(); // 尝试更新key对应的value
if (!mc.cas(key, 0, i, cas))
{ return false;
} return true;
}
}
**==================================================================================
我不能保证写的每个地方都是对的,但是至少能保证不复制、不黏贴,保证每一句话、每一行代码都经过了认真的推敲、仔细的斟酌。每一篇文章的背后,希望都能看到自己对于技术、对于生活的态度。
我相信乔布斯说的,只有那些疯狂到认为自己可以改变世界的人才能真正地改变世界。面对压力,我可以挑灯夜战、不眠不休;面对困难,我愿意迎难而上、永不退缩。
**其实我想说的是,我只是一个程序员,这就是我现在纯粹人生的全部。
==================================================================================****
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于