通过一次抓包来掌握memcached

通过一次抓包来掌握memcached

什么是 memcached #

memcached 是一个高性能的“分布式”内存对象缓存系统,用于动态 Web 应用以减轻数据库负载。它通过在内存中缓存数据和对象来减少读取数据库的次数,从而提高动态、数据库驱动网站的速度。memcached 是自由软件,以 BSD 许可证发布。

相比于大家熟知的 Redis,memcached 更加简单,只支持 key-value 存储,而 Redis 支持更多的数据结构,如 list、set、hash 等。

Github 地址:https://github.com/memcached/memcached

为什么有 redis 还要使用 memcached #

从我个人的角度来说,要在采用一个缓存系统的时候,我会优先选择 Redis,因为 Redis 功能更加强大,支持更多的数据结构,而且 Redis 也支持持久化,在高可用和分布式部分的设计上也更加完善。

但是 memcached 也有自己的优势,比如更加简单,更加轻量级,更加容易上手,因此在某些系统中也会选用 memcached。因此,了解 memcached 的设计也是有必要的。

memcached 协议概览 #

memcached 支持基本的文本协议和元文本协议,其中元文本协议于 2019 年推出。memcached 还曾支持过二进制协议,但已经被废弃。

memcached 的协议是基于文本的,因此我们可以通过 telnet 或者 netcat 工具来模拟 memcached 的客户端,从而方便的进行测试。

这两者是 “交叉兼容” 的,也就是说我们可以通过 文本协议来设置键值, 通过 元文本协议来查询,反之亦然。

Standard Text Protocol #

详细的协议文档可以参考:https://github.com/memcached/memcached/blob/master/doc/protocol.txt

memcached 的标准文本协议是一个基于文本的协议,它使用 ASCII 字符串来进行通信。memcached 服务器监听在默认端口 11211 上,客户端通过 TCP 连接到服务器,然后发送命令和数据。因此我们可以很容易的通过 telnet 工具就可以完成 memcached 的基本操作。

telnet 127.0.0.1 11211

然后我们可以输入 memcached 的命令,如 setget 等。

set key 0 0 5 # set key flag expire_time data_length
value         # data
STORED       # 表示存储成功
get key
VALUE key 0 5 # VALUE key flag data_length CAS
value         # data
END           # 表示获取结束

Storage Commands #

存储命令分为两类,一类是存储命令(set, add, replace, append 和 prepend),另一类是检查并设置命令(cas)。各自的格式如下:

  • 直接存储命令

    <command name> <key> <flags> <exptime> <bytes> [noreply]\r\n <data block> \r\n

    • key: 客户端声明的用于存储的 key
    • flags: 任意的 16 位无符号整数用于存储额外的信息,对服务器透明,当数据被获取时会原封不动的返回。在 v1.2.1 之后,flags 可以是 32 位无符号整数。
    • exptime: 以秒为单位的过期时间,0 表示永不过期(但仍然可能会因为内存不足而被删除)
    • bytes: 存储的数据长度
    • noreply: 可选参数,如果存在则不会返回响应
    • data block: 存储的数据
  • CAS 命令

    cas <key> <flags> <exptime> <bytes> <cas unique> [noreply]\r\n <data block> \r\n

    • cas unique: 64 位无符号整数。使用 cas 前需要先通过 gets 获取到 token(cas unique),使用 cas 时携带这个 token,否则会操作失败。
    • 其他参数和存储命令一致
命令 说明
set 存储数据,会覆盖任何现有数据
add 仅当该数据尚不存在时才存储该数据
replace 替换数据,但前提是该数据已存在
append 在现有数据字节之后追加数据
prepend 在现有数据字节之前追加数据
cas 检查并设置,用于实现乐观锁

Retrieval Commands #

命令 说明
get 获取数据
get key1 key2 ... keyn \r\n
gets 获取数据和 CAS 值
gets key1 key2 ... keyn \r\n

Statistics Commands #

关于 stats 的详细字段解释查阅:https://github.com/memcached/memcached/blob/7d6bc7b09e3c6bb8eaff8b2b3d78d01e0bf17f6f/doc/protocol.txt#L1299-L1451

命令 说明
stats 基本统计命令
stats \r\n
stats items 获取服务器统计信息
stats items \r\n
stats slabs 获取服务器统计信息
stats slabs \r\n
stats sizes 获取服务器统计信息
stats sizes \r\n

Other Commands #

命令 说明
version 获取服务器版本信息
version \r\n
quit 关闭连接
quit \r\n
flush_all 清空所有数据
flush_all \r\n
delete 删除数据
delete key \r\n
incr/decr value为无符号 64 位整数的字符串表示形式,可以进行递增/递减操作。key 不存在会操作失败。
incr key value \r\n

Meta Text Protocol #

元文本协议中会出现大量的 token 字样的描述, 其指代的是跟在 flag 后面 “语法单元” 或 理解为flags 参数亦可。 如 mg foo k v R30 中,R 就是一个 flag, 30 就是就是 R 携带的token。

元文本协议(Meta Text Protocol)是 memcached 1.6.0 版本引入的新协议,它是一种基于文本的协议,用于支持 memcached 的元数据操作。相比于标准文本协议,元文本协议有以下特点:

  • 引入了新命令和(使用 ms 来取代 set;mg 来取代 get,gets, touch, GAT, GATS 等)
  • 新增了元数据标志,如 l 标志自上一次访问以来的秒数,h 表示自存储以来是否被访问过,t 表示过期前剩余的秒数。
  • 支持 key 采用二进制格式(传输时需要使用 base64 编码)
  • 支持原子性操作
  • 支持对频繁访问的对象进行提前缓存。通过 R 标志,可以在查询时就指示服务器刷新缓存。

新增的命令有:ms(meta set), mg(meta get), md(meta delete), me(meta debug) 等。

Meta Set 命令:ms #

详细的协议文档可以参考:https://github.com/memcached/memcached/blob/master/doc/protocol.txt#L685

在元文本协议中,请求的格式如下:

# 请求
ms <key> <datalen> <flags>\r\n
<data block>\r\n

# 响应
<CD> <flags>\r\n

举例如下:

# 设置一个 key=foo, value=bar 的数据, 其中 T90 表示 90 秒后过期, F1 表示 flags=1
ms foo 3 T90 F1\r\n
bar\r\n
# 响应
HD

ms 响应的 CD 有以下几种:

HD STORED 成功。

NS NOT_STORED 数据因为错误而未存储。

EX EXISTS 数据已经存在,往往是 CAS 语义,token失效的结果。

NF NOT_FOUND 数据不存在, 往往是 CAS 语义,key 不存在的结果。

ms 支持的 flags 标志如下:

Flags 说明
b key 采用 base64 编码
c 存储成功则返回 cas token,否则返回0(但 0 不能作为判断成功失败的依据)。
C(token) 比较 CAS 值,如果 token 与服务器的 CAS 值不一致则操作失败。可以配合 I 标记使用
E(token) 使用 token 作为新的 CAS 值,如果 item 被修改
F(token) 设置 client flags (32bit)
I 使 item 失效,如果提供的 CAS 值比 item 的 CAS 值旧则操作失败
k 把 key 作为 token 返回(mg 默认不回携带 key, 那么可以通过此 flag 返回)
O(token) 透明值, 会原封不动的返回
q 使用 noreply 语义
s 返回 item 字节大小
T(token) 设置 item 的 TTL
M(token) 模式切换,默认 Set。Token 可选值 E(add), A(Append), P(Prepend), R(Replace) , S(Set)
N(token) 如果是 append 模式下,如果不存在则会自动创建一个新的 item,接受 TTL 作为参数

Meta Get 命令:mg #

详细的协议文档可以参考:[https://github.com/memcached/memcached/blob/master/doc/protocol.txt#L685

mg 命令用于获取数据,请求的格式如下:

# 请求
mg <key> <flags>\r\n

# 响应
<CD> <flags> <flags token>\r\n
<data block>\r\n

举例如下:

# 获取 key=foo 的数据, v 表示 value,t 表示过期时间,f 表示 flags
mg foo t f v\r\n
# 响应
VA 3 t79 f1
bat

mg 响应的 CD 有以下几种:

VA <size> <flags>\r\n <data block>\r\n 如果使用了 v 标志 且 服务器有数据。

HD <flags>\r\n 如果没有使用 v 标志。

EN\r\n 代表 key 不存在。

mg 支持的 flags 标志如下:

Flags 说明
b key 采用 base64 编码
c 返回 cas token
f 返回 client flags token
h 返回是否被访问过的标志
k 返回 key
l 返回自上次访问以来的秒数
O 透明值
q 使用 noreply 语义
s 返回 item 大小
t 返回剩余的 TTL
u 不更新 LRU
v 返回 value
E(token) 使用 token 作为新的 CAS 值,如果 item 被修改
N(token) 如果 item 不存在,创建一个新的 item,接受 TTL 作为参数
R(token) 如果剩余的 TTL 小于 token,为 recache 而胜出
T(token) 更新剩余的 TTL

由此可见,元文本协议相比于标准文本协议更加灵活,支持更多的元数据操作。尤其是 mg 跟 flags 的结合更加灵活,将以前需要执行多个命令的操作合并到了一个命令中,减少了网络往返的次数。

元文本协议中当然不止这两个命令,还有 md, me 等命令,就不再一一列举了。通过 ms, mg 命令就足够掌握了元文本协议的设计理念和使用方法。

抓包/源码分析 #

下面我们通过抓包的方式来分析 memcached 的协议交互过程,再结合一部分源码来分析下 memcached 是怎么支持“分布式”场景的。

分析0: memcached 的协议长什么样? #

要回答这个问题,我们甚至不需要抓包,从 memcached 的协议介绍我们就已经知道了它是一个文本协议,直接使用 telnet 就可以完成 memcached 的基本操作。下面就是一个 telnet 从连接到 set 并 get 的完整抓包截图:

memcached 协议抓包

从截图我们可以看到,经过三次握手后,客户端发送了 ms foo 3 t90\r\nbar\r\n 命令,然后 memcached 服务器返回了 HD 表示存储成功。接着客户端发送了 mg foo t f v\r\n 命令,memcached 服务器返回了 VA 3 t-1 s3\r\nbar\r\n 表示获取成功。

都非常的直观,这里唯一需要注意 t90 这 个flag 使用错了,应该是 T90,表示 90 秒后过期,但是 memcached 服务器并没有报错,而是直接存储了 -1 表示永不过期。

另外我们也会注意到telnet客户端发送 ms foo 3 t90\r\nbar\r\n 是分成了两个 TCP 包发送的,这是因为 telnet 默认是行缓冲模式,需要按下回车键才会发送数据。

分析1: memcached 集群是如何工作的? #

我们通常看到 memcached 的介绍都会说 memcached 是一个“分布式”缓存系统,那么 memcached 是如何支持分布式场景的呢?从官方文档中我们还可以看到这样的描述:

Logic Half in Client, Half in Server

A “memcached implementation” is partially in a client, and partially in a server. Clients understand how to choose which server to read or write to for an item, what to do when it cannot contact a server.

Servers are Disconnected From Each Other

Memcached servers are unaware of each other. There is no crosstalk, no synchronization, no broadcasting, no replication. Adding servers increases the available memory. Cache invalidation is simplified, as clients delete or overwrite data on the server which owns it directly.

意思其实很简单,虽然 memcached 是一个“分布式”缓存系统,但是它的分布式是需要客户端配合的伪分布式,而不是通常意义上的分布式。它所支持的分布式是指客户端可以采取分片算法来决定数据存储在哪个服务器上,而 memcached 服务器本身是不会相互通信的,也不会进行数据同步的。如下图所示:

memcached 分布式

这样的设计有利有弊,优点是 memcached 服务器之间不需要相互通信,不需要同步数据,因此 memcached 服务器可以很容易的扩展,只需要增加服务器就可以增加缓存容量。缺点也是显而易见的,分布式的特性需要客户端来实现,这就需要客户端有一定的分片算法,如果想要实现高可用(某台机器宕机),还需要客户端支持“复制”机制。

接下来我们就可以通过,抓包来验证这一点:

部署 memcached 集群
# 通过 docker 启动两个 memcached 实例, 分别监听宿主机的 11211 和 11212 端口
version: '3'
services:
  memcached1:
    image: memcached:1.5.6
    ports:
      - "11211:11211"
  memcached2:
    image: memcached:1.5.6
    ports:
      - "11212:11211"
客户端代码
from pymemcache.client.hash import HashClient

client = HashClient([
  "127.0.0.1:11211",
  "127.0.0.1:11212",
])

client.set("key-0", "I'll be sent to node1(11211)", expire=30)
client.set("key-2", "I'll be sent to node2(11212)", expire=30)

通过 wirehsark 抓包,我们可以看到客户端按照预期发送了两个 set 命令,分别存储在 11211 和 11212 端口的 memcached 服务器上:

memcached 分布式抓包

而客户端(pymemcache)的逻辑也很简单,通过使用 Rendezvous Hashing 算法来计算组合 key (node-$key) 的 hash 值,选择 score 最高的服务器,如下:

# self.hash_function = lambda x: murmur3_32(x)

def get_node(self, key):
    high_score = -1
    winner = None

    for node in self.nodes:
        score = self.hash_function(f"{node}-{key}")

        if score > high_score:
            (high_score, winner) = (score, node)
        elif score == high_score:
            (high_score, winner) = (score, max(str(node), str(winner)))

    return winner

这里有意思的是,客户端的做法不是非常常见的 hash 取模的方式,而是通过一种叫做 HRW(Highest Random Weight)Hashing 的算法,这种算法的优点是当节点增加或者减少时,影响的数据量最小,而且不需要维护一致性哈希环。

假如我们有三个节点(node1, node2, node3),key1 -> node1,key2 -> node2,key3 -> node3

如果 node2 挂掉了,那么最坏的情况下:key1-> node3,key2 -> node3,key3 -> node1,这所有的 key 都要重新分配。

而 HRW 算法的优点是:当 node2 挂掉了,只有 key2 -> node3 需要重新分配,其他的 key 仍然保持原来的分配,这是因为一开始 key1 -> node1 就已经是最高,那么节点减少这个状况也不会发生变化。

分析2: 元文本协议有什么区别? #

顾名思义,元文本协议还是基于文本的,但是它引入了更多的元数据标志,通过标志的组合避免了多次网络往返的问题,这对于需要优化 memcached 使用场景是非常有帮助的。

但是这里,我们可以看看 memcached 内部是怎么处理元文本协议的,以下是 memcached 源码中对于元文本协议的处理:

协议解析片段
void process_command_ascii(conn *c, char *command) {
    // ... 省略部分代码

    // Meta commands are all 2-char in length.
    char first = tokens[COMMAND_TOKEN].value[0];
    if (first == 'm' && tokens[COMMAND_TOKEN].length == 2) {
        switch (tokens[COMMAND_TOKEN].value[1]) {
            case 'g':
                process_mget_command(c, tokens, ntokens);
                break;
            case 's':
                process_mset_command(c, tokens, ntokens);
                break;
            // ... 省略部分代码
        }
    } else if (first == 'g') {
        // Various get commands are very common.
        WANT_TOKENS_MIN(ntokens, 3);
        if (strcmp(tokens[COMMAND_TOKEN].value, "get") == 0) {
            process_get_command(c, tokens, ntokens, false, false);
        }
        // ... 省略部分代码
    } else if (first == 's') {
        if (strcmp(tokens[COMMAND_TOKEN].value, "set") == 0 && (comm = NREAD_SET)) {
            WANT_TOKENS_OR(ntokens, 6, 7);
            process_update_command(c, tokens, ntokens, comm, false);
        } else if (strcmp(tokens[COMMAND_TOKEN].value, "stats") == 0) {
            process_stat(c, tokens, ntokens);
        } else {
            out_string(c, "ERROR");
        }
    } else if (first == 'd') {
        if (strcmp(tokens[COMMAND_TOKEN].value, "delete") == 0) {
            WANT_TOKENS(ntokens, 3, 5);
            process_delete_command(c, tokens, ntokens);
        } else {
            out_string(c, "ERROR");
        }
    } else if (first == 't') {
        if (strcmp(tokens[COMMAND_TOKEN].value, "touch") == 0) {
            WANT_TOKENS_OR(ntokens, 4, 5);
            process_touch_command(c, tokens, ntokens);
        } else {
            out_string(c, "ERROR");
        }
    } else if (strcmp(tokens[COMMAND_TOKEN].value, "version") == 0) {
        process_version_command(c);
    } else if (strcmp(tokens[COMMAND_TOKEN].value, "quit") == 0) {
        process_quit_command(c);
    }
    // ... 省略部分代码
    return;
}

可以看到 meta 协议是在文本协议之前的,通过判断第一个字符是否是 m 来判断是否是 meta 协议,然后再根据第二个字符来判断具体的 meta 命令。至于内部的处理逻辑,大体流程上和文本协议是一致的,只是在命令中增加了更多的元数据标志,这里就不再展开了。

文本协议梳理 #

🟩 标记的是标准文本协议的命令 ⭕️ 标记的是元文本协议的命令

Command Usage Description Remark
set set <key> <flags> <exptime> <bytes> [noreply]\r\n<data block>\r\n Stores data. 🟩
add add <key> <flags> <exptime> <bytes> [noreply]\r\n<data block>\r\n Stores data only if the server doesn’t already hold data for this key. 🟩
replace replace <key> <flags> <exptime> <bytes> [noreply]\r\n<data block>\r\n Stores data only if the server already holds data for this key. 🟩
append append <key> <bytes> [noreply]\r\n<data block>\r\n Appends data to an existing key. 🟩
prepend prepend <key> <bytes> [noreply]\r\n Prepends data to an existing key. 🟩
cas cas <key> <flags> <exptime> <bytes> <cas unique> [noreply]\r\n<data block>\r\n “Check and set” operation; stores data only if no one else has updated since it was last fetched. 🟩
ms (Meta Set) ms <key> <datalen> <flags>*\r\n<data block>\r\n Generic command for storing data to memcached. ⭕️
get get <key>*\r\n Retrieves data for one or more keys. 🟩
gets gets <key>*\r\n Retrieves data for one or more keys, including CAS unique values. 🟩
gat gat <exptime> <key>*\r\n Retrieves data and updates the expiration time of existing items. 🟩
gats gats <exptime> <key>*\r\n Retrieves data and updates the expiration time of existing items, including CAS unique values. 🟩
mg (Meta Get) mg <key> <flags>*\r\n Generic command for retrieving key data from memcached. ⭕️
delete delete <key> [noreply]\r\n Explicitly deletes items. 🟩
incr incr <key> <value> [noreply]\r\n Increments the value of an item. 🟩
decr decr <key> <value> [noreply]\r\n Decrements the value of an item. 🟩
touch touch <key> <exptime> [noreply]\r\n Updates the expiration time of an existing item without fetching it. 🟩
me (Meta Debug) me <key> <flag>\r\n Human readable dump of all available internal metadata of an item. ⭕️
md (Meta Delete) md <key> <flags>*\r\n Explicitly deletes items, marking them as “stale”. ⭕️
ma (Meta Arithmetic) ma <key> <flags>*\r\n Basic operations against numerical values, replacing “incr” and “decr” commands. ⭕️
mn (Meta No-Op) mn\r\n Returns a static response code. ⭕️
slabs reassign slabs reassign <source class> <dest class>\r\n Redistributes memory after the server hits its limit. 🟩
访问量 访客数