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 中的报文格式, 对二进制格式有了较为深入的了解, 值得一提的是大部分的编程语言驱动包还只是支持文本协议, 要使用二进制协议需要安装非通用的驱动包.