memcached 内存分配及性能优化
memcached 介绍
memcached 是一个基于内存, 通过 key-value 方式访问和存储数据的工具. 一般通过缓存数据库查询和 api 调用等不经常变更的结果来减少程序直接访问数据库或 api 服务的次数, 进而提高动态 web 应用的性能; 由于 memcached 基于内存存储, 并没有持久化到硬盘中, 所以重启 memcached 或主机的操作会引起数据的丢失. 另外数据达到指定的内存值后, memcached 则采用基于 LRU(least recently used)
算法自动删除不使用的缓存.
更具体的则包含以下特性:
协议简单(ascii 或 binary 协议)
基于 libevent 事件处理
内置的内存存储方式
不互相通信的分布式(由客户端程序负责分布式)
memcached 事件模型
memcached 使用 libevent 作为其底层的网络库, 不过 libevent 将 Linux 类操作系统的 epoll, BSD 类操作系统的 kqueue 等事件处理功能封装成统一了接口, 即使服务器的连接数增加也能很好的达到 O(1) 的性能, 详见 http://libevent.org/. 基于这点可以预见到 memcached 在大量并发连接的场景下会有比较好的表现.
memcached 使用 master-worker 的方式, 以多线程对外提供服务, 主线程监听端口建立连接, 然后顺序分配给各个工作线程(对应工具的 -t
选项). 每个线程都有一个 event loop
, 它们可以服务于不同的客户端. master 线程和 worker 线程之间使用管道通信, 每个工作的线程都会创建一个管道来保存写端和读端, 并将读端加入 event loop, 监听可读事件.多线程的模型可以充分发挥多核主机的优势.
memcached 内存分配
memcached 默认情况下采用了 Slab Allocator
的机制分配和管理内存. 在该机制出现之前内存分配简单的通过 malloc 和 free 来管理所有的记录, 旧的方式会导致产生很多内存碎片, 加重机器管理内存的负担, 甚至有可能导致操作系统比 memcached 进程本身还慢, Slab Allocator
则解决了该问题.
官方对 Slab Allocator 的原理介绍如下:
The primary goal of the slabs subsystem in memcached was to eliminate
memory fragmentation issues totally by using fixed-size memory chunks
coming from a few predetermined size classes (early versions of
memcached relied on malloc()'s handling of fragmentation which proved
woefully inadequate for our purposes).
由此可以看到 Slab 的基本原理是按照预先规定的大小, 将分配的内存分割成特定长度的块(chunk), 以解决内存碎片的问题. 这也意味着存取记录的时候可以减少内存分配的次数, 有点类似线程池/内存池的感觉. Slab 的原理也比较简单, 是将分配的内存分割成各种尺寸的块(chunk), 且把尺寸相同的 chunk 分成组(chunk 集合), 一个组称为 slab class
.
Slab class 的主要术语包括以下:
page: 分配给 Slab 的内存空间, 默认是 1MB, 分配给 slab 之后根据 slab 大小分成 chunk.
chunk: 用于缓存记录的内存空间.
slab class: 特定大小的 chunk 的组.
由此可以看出, 三者之间在内存分配上的关系为 slab class -> page -> chunk
, 比如以下信息:
$ memcached-tool 127.0.0.1:11211 display
# Item_Size Max_age Pages Count Full? Evicted Evict_Time OOM
1 96B 0s 1 0 no 0 0 0
2 120B 2417392s 30 257107 no 0 0 0
该 memcached 内存中, slab class 1
的 chunk 大小为 96 字节, 只分配了一个 page(1MB)
, 里面的记录数为 0, slab class 2
的 chunk 大小为 120 字节, 一共 30 个 page(约 30MB), 里面的记录数为 257107 个. 我们通过简单计算就可以得知, 30MB ~= 257107 * 120B
. 在达到 30M 上线的时候, 如果再增加 key-value 值, 则直接分配一个 page 给 slab class 2, 这就是预分配的功能. 所以 Slab Allocation
的构造图大致如下:
slab class 1 slab class 2
+-------------+ +---------------+
| 96B | 96B | | 120B | 120B |
+------+------+ +--------+------+
| 96B |more..| | 120B |more..|
+-------------+ +---------------+
slab class 3 slab class 4
+-------------+ +---------------+
| 152B | 152B | | 192B | 192B |
+------+------+ +-------+-------+
| 152B |more..| | 192B |more.. |
+-------------+ +---------------+
.....
每个 slab class 的大小按照增长因子(-f 选项, 默认 1.25)增加,比如上述的 slab 1 默认为 96 字节, 那么 slab 2 则为 96*1.25 = 120
字节, 以此类推. 另外 slab allocator 还有重复使用已分配内存的目的. 也就是说, 分配的到内存不会释放, 而是重复利用.
slab 缓存记录的原理
memcached 会根据收到数据的大小选择合适的 slab class
, slab 内保存着空闲的 chunk 列表, memcached 根据这些列表选择合适的 chunk, 然后将数据缓存到该 chunk 中. 如下图所示, 比如需要缓存 100 字节的记录, 则可以选择大小为 120B 的 chunk, 最后存到 slab class 2 中.
Slab classes
+----------------+
| class 1 (96B) |
+----------------+
+----------+ +--| class 2 (120B) |
| 100 byte | / +----------------+
+----------+ | class 3 (152B) |
+----------------+
| ...... |
+----------------+
slab allocator 的缺点
尽管 slab 很好的解决了内存碎片的问题, 但该机制也给 memcached 带来了新的问题. 比如上述的缓存记录原理一节中, 由于分配的 chunk 都是固定的长度 120 字节, 将 100 字节存到改 chunk 中之后, 剩余的 20 字节就会被浪费, 如下:
| 120 bytes chunk |
+-------------+-------+
| 100 bytes | ... |
+-------------+-------+
该问题还没有比较完美的解决方案, 不过应用程序如果能预先知道数据的大小, 只要尽力选择合适的 chunk, 就可以减少内存浪费, 比如 110 字节的数据都保存到 120字节的 chunk 中. 下面小节介绍的增长因子则能较好的减少内存浪费.
使用增长因子(growth factor)调优
上述小节中提到了增长因子(-f 选项), 此功能可以用来调节不同 slab class 之间 chunk 的大小差别, 默认为 1.25. 比如以下结果:
$ memcached-tool 127.0.0.1:11212 display
# Item_Size Max_age Pages Count Full? Evicted Evict_Time OOM
1 96B 2327514s 1 2 no 0 0 0
2 120B 2071695s 3 6981 no 0 0 0
3 152B 1038377s 23 90100 no 0 0 0
4 192B 0s 1 0 no 0 0 0
5 240B 2327378s 1 28 no 0 0 0
6 304B 2313765s 1 1 no 0 0 0
7 384B 2326507s 1 3 no 0 0 0
8 480B 2310076s 1 1 no 0 0 0
9 600B 2327669s 1 8 no 0 0 0
10 752B 2327425s 1 47 no 0 0 0
从 class 1 的 chunk 大小 96 字节开始, 增长因子为 1.25, 后续的 class 2 的 chunk 大小即为 96*1.25 = 120
, class 3 的即为 120*1.25 = 152
等等. 从示例中我们看到, memcached 中的记录都保存在于 class 2 和 class 3 中. 这意味着大部分数据在 96 ~ 152
字节之间. 假如这里有很多 100 字节和 125 字节的记录的话, 可想而知会有很多的内存被浪费掉, 这个时候, 如果我们调节增长因子为 1.1, 则会减少很多内存的浪费, 如下:
# Item_Size
1 96B
2 112B
3 128B
调整为 1.1 的因子后, 一个 100 字节的记录比调整前少浪费了 8 字节, 一个 125 字节的记录比调整前少浪费了 24 字节. 由此可见在内存使用很紧张的情况下, 调整增长因子也能节省相当多的内存.
如何处理连接过多的问题
这个问题在一些短连接的应用中较为突出, 比如使用短连接连接 memcached 的 php 应用, 每一次请求 php 就会新起一个进程连接 memcached, 而每起一个进程则消耗一个本地端口, 因为本地端口为两个字节所以最大就是 65535, 如果在并发较大的情况下, 很容易发生本地端口不够用的情况. 这个时候程序可以考虑通过 memcached socket 连接 memcached, 但是这种解决方案只适用于 memcached 和应用在一台机器上, 如果不在一台机器上则可以考虑官方文档中介绍的使用 udp 协议连接, 而不是 tcp 协议连接, 不过在数据包较大的情况下会引起 udp 连接跨包的现象, 这需要应用程序做一个很好的把控, 最好上线前做好测试.
查看 slabs 的使用状况
可以使用 memcached 作者编写的 memcahced-tool 工具查看 slabs 的使用状况, 比如上述示例中介绍的命令:
$ memcached-tool 127.0.0.1:11211 display
# Item_Size Max_age Pages Count Full? Evicted Evict_Time OOM
1 96B 0s 1 0 no 0 0 0
2 120B 2417392s 30 257107 no 0 0 0
各列的含义如下:
列 含义
---------------------------------------------------------------
# slab class 编号
Item_Size chunk 大小
Max_age LRU 内最旧的记录的生存时间
Pages slab 中 page 的个数, 一个 page 默认大小为 1MB 大小
Count slab 内的记录数
Full slab 内是否含有空闲的 chunk
Evicted slab 中未过期的条目被 LRU 移除的个数
Evict_Time slab 中最近被移除的条目最后一次访问经过的秒数
OOM slab 中存储新条目失败的次数
memcached 删除机制
所有的数据都缓存到 memcached 中, 所以数据不会永久保存到磁盘上. 上述小节中也介绍过 memcahced 通过 slab 机制不会释放已分配的内存, 记录超时或删除后不会释放内存, 而是重复利用. 另外 memcached 内部不会监视过期的条目, 而是在 get 时查看时间戳, 检查记录是否过期. 这种特性成为 lazy expiration
.
LRU: 删除数据的原理
memcached 优先使用超时的记录的内存空间, 但这也会发生增加新条目时空间不足的情况, memcached 采用 LRU(Least Recently Used)
机制解决内存不足的情况. LRU 即表示删除最近最少使用的意思, 很多数据库工具软件都采用 LRU 算法解决类似的问题, 比如 MySQL 的 InnoDB buffer 也采用该机制解决内存不足的问题; 在 memcached 可用内存不足的情况下, 就从最近未被使用的记录中进行搜索, 并将其分配给新的条目记录.
如果不想采用 LRU 机制可以使用 -M 选项启动 memcached, 该选项在内存用尽时直接返回错误.
memcached 二进制协议
老版本中仅支持 ascii 文本协议, 较新的版本都支持新的二进制协议, -B
选项可以选择要使用的协议, 默认情况自动选择. 使用了二进制协议后程序和 memcached 的交互不再需要文本的解析处理, 同样也能减少文本协议相关的漏洞, 所以理论上使用二进制协议效果更好.
memcached 源代码文件 memcached-master/doc/protocol-binary.xml
对二进制协议做了很详尽的描述, 一个 memcached 协议报文由两部分组成: header 和 memcached 命令. 这两部分组成一个通用格式的 memcached 协议报文, 如下:
General format of a packet:
Byte/ 0 | 1 | 2 | 3 |
/ | | | |
|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|
+---------------+---------------+---------------+---------------+
0/ HEADER /
/ /
/ /
/ /
+---------------+---------------+---------------+---------------+
24/ COMMAND-SPECIFIC EXTRAS (as needed) /
+/ (note length in the extras length header field) /
+---------------+---------------+---------------+---------------+
m/ Key (as needed) /
+/ (note length in key length header field) /
+---------------+---------------+---------------+---------------+
n/ Value (as needed) /
+/ (note length is total body length header field, minus /
+/ sum of the extras and key length body fields) /
+---------------+---------------+---------------+---------------+
Total 24 bytes
可以看到一个memcached 的数据报文, 其中包括24字节的头部(header), 8 字节的 Extras 信息(包括 4 字节的 flag 和 4 字节的 expiration 字段), 以及相应的 key 和 value 长度字节, 这两个长度可以 header 头部中查找到.
另外 header 头部则可以分为请求 header 和应答 header,如下所示:
Request header:
Byte/ 0 | 1 | 2 | 3 |
/ | | | |
|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|
+---------------+---------------+---------------+---------------+
0| Magic | Opcode | Key length |
+---------------+---------------+---------------+---------------+
4| Extras length | Data type | Reserved |
+---------------+---------------+---------------+---------------+
8| Total body length |
+---------------+---------------+---------------+---------------+
12| Opaque |
+---------------+---------------+---------------+---------------+
16| CAS |
| |
+---------------+---------------+---------------+---------------+
Total 24 bytes
Response header:
Byte/ 0 | 1 | 2 | 3 |
/ | | | |
|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|
+---------------+---------------+---------------+---------------+
0| Magic | Opcode | Key length |
+---------------+---------------+---------------+---------------+
4| Extras length | Data type | Reserved |
+---------------+---------------+---------------+---------------+
8| Total body length |
+---------------+---------------+---------------+---------------+
12| Opaque |
+---------------+---------------+---------------+---------------+
16| CAS |
| |
+---------------+---------------+---------------+---------------+
Total 24 bytes
可以看到请求头部和应答头部的格式相同, 我们同样查看文档 doc/protocol-binary.xml
, 各 header 字段的含义如下:
Magic: Magic number.
Opcode: Command code.
Key length: Length in bytes of the text key that follows the command extras.
Status: Status of the response (non-zero on error).
Extras length: Length in bytes of the command extras.
Data type: Reserved for future use (Sean is using this soon).
Reserved: Really reserved for future use (up for grabs).
Total body length: Length in bytes of extra + key + value.
Opaque: Will be copied back to you in the response.
CAS: Data version check.
抓包分析 memcached 二进制协议
我们使用以下 perl 程序以二进制方式连接测试 memcached, 再抓包分析进行说明, 如下程序:
#!/usr/bin/env perl
use strict;
use Data::Dumper;
use Cache::Memcached::libmemcached;
my $memd = Cache::Memcached::libmemcached->new({
'servers' => [ "127.0.0.1:11211"],
'debug' => 0,
'compress_threshold' => 10_000,
});
$memd->set_binary_protocol(1); # 使用二进制协议
print Dumper($memd);
$memd->set("foo10", "Some value"); # set 值
my $val = $memd->get("foo10");
if ($val) {
print Dumper($val);
}
抓包后, 使用 wireshark 分析, 我们以 set request 包分析如下, 下半部分为16进制数据:
Memcache Protocol, Set Request
Magic: Request (128)
Opcode: Set (1)
Key Length: 5
Extras length: 8
Data type: Raw bytes (0)
Reserved: 0
[Value length: 10]
Total body length: 23
Opaque: 65536
CAS: 0
Extras
Key: foo10
Value: Some value
0000 80 01 00 05 08 00 00 00 00 00 00 17 00 01 00 00 ................
0010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0020 66 6f 6f 31 30 53 6f 6d 65 20 76 61 6c 75 65 foo10Some value
对比上一小节的 header 头部可以分析如下, memcached 的数据(memcached 通用报文)大小总共为 47 字节:
Magic: 80
Opcode: 01
Key Length: 00 05
Extras length: 08
Data type: 00
Status: 00 00
Total body length: 00 00 00 17
Opaque: 00 01 00 00
CAS: 00 00 00 00 00 00 00 00
Extras: 00 00 00 00 00 00 00 00 (4 字节 Flags, 4 字节 Expiration)
Key: 66 6f 6f 31 30
Value: 53 6f 6d 65 20 76 61 6c 75 65
key length 为两个字节, 这意味着 key 的最大长度可以到 65535 字节, 比起老的版本确实是很大的进步.
总结
综上介绍, 我们可以很好的理解 memcached slab
内存分配的机制, 也能理解通过增长因子就可以达到一定的性能优化, 在一些场景下可能会节省较多的内存消耗. 最后通过测试详细说明了二进制协议在 memcached 中的报文格式, 对二进制格式有了较为深入的了解, 值得一提的是大部分的编程语言驱动包还只是支持文本协议, 要使用二进制协议需要安装非通用的驱动包.