Memcached Meta Commands

2022-02-26 ⏳7.0分钟(2.8千字) 🕸️

Memcached 是老牌的内存缓存服务器。因为不支持像 Redis 那样丰富的数据结构,Memcached 用的越来越少。但我认为 Memcached 是 Unix 哲学的典范,一次只做一件事,并把它做好。而 Memcached 就是专注于内存缓存这一件事。其中,Memcached 在 1.6 版本引入的 Meta Commands 就是最好的佐证。今天就给大家介绍一下 Meta Commands(元指令)。

元指令的结构如下:

<cm> <key> <datalen*> <flag1> <flag2> <...>\r\n

这里<cm>表示command,使用两位字母表示不同的指令。<key>表示要操作的键值。<datalen*>表示本次请求数据部分的长度,有的元指令不需要传输额外数据,那就不需要这个字段。再后面就是 Meta 命令的精华:flag(标记)。每个元指令可以附加多个标记来影响 Memcached 的行为和返回值的内容。

元指令返回值结构如下:

<RC> <datalen*> <flag1> <flag2> <...>\r\n

这里<RC>表示return code,也就是返回码。<RC><cm>一样,也是使用两位字母表示。同样有可选的<datalen*>,后面附带多个标记。

下面给大家展示几个元指令的示例。

设置缓存:

ms foo 2 T90 F1\r\n
hi\r\n

HD\r\n

元指令以m开头。ms表示set,用于设置缓存。在这个例子中,foo是键名,2表示数据的长度。再后面是两个标记参数:T90表示设置TTL为90秒,F1表示将缓存对应的业务数据设为1。另起一行传输数据,长度为二。

查询缓存:

mg foo t f v\r\n
VA 2 s2 t78 f1\r\n

hi\r\n

mg表示get,用于查询缓存信息。本例中使用了三个标记参数:t表示返回缓存的TTL,f表示返回缓存的业务标记数据,v表示返回缓存数据。是的,mg默认不返回缓存数据。因为在很多场景下,缓存的内容会比较大,而我们又只需要查看TTL/CAS之类的信息,所以mg默认不返回缓存的数据,需要加上v标记才会返回。

删除缓存:

md foo I\r\n

HD\r\n

md表示delete,用于删除缓存。但在删除的时候,也可以通过标记来调整服务器的行为。比如这里的I就是告诉服务端把缓存标记为过期(stale),并不实际删除。具体用法我们下面再说。

有了上面的基础,我们接下来就介绍元指令具体是怎么解决各类缓存问题的。总共有三类问题,分别是:

缓存穿透问题

所谓缓存穿透就是在缓存失效或者删除之后,大量请求回源而导致的系统性能问题。应对穿透问题的方法也无外乎两类:一类是使用回源锁,只有拿到并发锁的请求才可以回源;另一类就是避免缓存失效。使用元指令,就可以轻松实现这两类方法。

先说回源并发锁。

我们在查询缓存的时候可以使用N标记:

mg foo c v N30
VA 0 c2 W

这里表示查询foo对应的缓存,如果不存在,则创建新的空缓存。如果同时有多个请求执行这条语句,只有一个请求的返回值中有W标记,表示拿到了回源锁,该请求需要通过回源更新缓存。其他请求收到的是Z标记。因为这次查询也通过c标记返回了CAS版本,所以更新缓存的时候应该加上c标记,也就是:

ms foo 2 c2
hi

如果在回源过程中有其他进程更新了缓存,那缓存的CAS版本也会更新,刚才的回源更新就会失败,避免写入脏数据。

以上是对于缓存已经失效的场景。缓存失效也就两种情况,一种是超过了过期时间,再一种是源数据发生变更,缓存被主动删除。元指令分别为这种两情况提供了回源控制机制。

第一种叫缓存预刷新(Early Recache)。所以谓预刷新,就是在缓存临近过期的时候获取更新并发锁,争取在过期之前更新缓存,从而避免穿透。具体方法如下:

mg foo v t R30
VA 2 t29 W
hi

这里的mg添加了R30标记,表示如果缓存的TTL不足30秒,则获取并发锁。多个请求同时执行的时候,只有一个请求会返回W标记。该请求可以从容地完成回源和更新缓存。更新缓存的时候需要传入T设置新的TTL,从而避免出现缓存过期问题。

另一种叫脏缓存(Stale Cache)。也就是说,我们在删除缓存的时候并不是真正的删除,而是次其标记为脏缓存:

md foo I T30
HD

这里的I标记表示只标记,不删除。T30表示脏缓存最大保持30秒。查询的时候一旦遇到脏缓存,就会额外返回X标记:

mg foo t c v
VA 4 t29 c777 W X
data

X标记表示当前缓存已经过时了,但业务代码还可以决定是否继续使用。更重要的是,在所有并发请求中,只有一个请求会收到W标记,求救该请求可以回源更新缓存。通过脏缓存可以实现更加精确的回源控制。

热Key问题

缓存的另一个突出问题就是热Key问题。如果某个Key访问量特别大,会对单个节点产生很大的压力。通常大家会通过分片的方式将压力分散到不同的实例。但这样做需要一个前提,就是你需要事先知道哪些Key是热Key。所以,这只能说是一种事后补救的办法。元指令为我们提供了自动发现热Key的能力。

其实也就是引入了两个标记而已:

mg foo v h l c
VA 4 h1 l5 c4
data

这里的标记分别是hl。其中h1表示当前key创建之后有被访问过,而l5表示上次访问距离当前的时间,也就是5秒前有过访问。通过这两个标记,业务代码就可以决定是否需要设置本地缓存。比如,我们可以这样:

if (h == 1 && l < 5 && random(1000) == 0) {
  add_to_local_cache(it)
}

这里的random(1000)可以是其他数据指标。如果某个key最近有过访问,就可能是热key,可以考虑在本地缓存一小段时间。

一旦在本地缓存了数据,如何更新就成了麻烦事。因为是热key,又不能把缓存时间设置的太小。一种方案是使用消息队列广播数据更新,但这种太重了。我们可以利用mg起一个本地的定时进程,周期性地查询热key的CAS版本:

mg foo c
HD c500

这里的mg只返回了版本号。如果版本有变更,再更新本地缓存。这样就比较简单地解决了热key的发现和更新问题。

数据传输与内存占用问题

在减少数据传输方面也有两种优化方式,一种是只传必要的数据,另一种是支持在一次请求中返回多种数据。

我们前面说过,mg指令,默认不返回缓存的数据和key。这样,如果只想看某个 key 的 TTL,只需要执行:

mg foo t
HD t94

这里的HD表示header,后面的t94表示当前TTL是94秒。我们还可以使用一条指令查询多种信息:

mg foo t c
HD t94 c8

这里的c表示CAS版本,缓存数据更新后这个版本都会变。

如果需要查多条缓存,传统的指令需要写成这样:

get bar foooooooooooooooooooooooooooooooo baz
VALUE foooooooooooooooooooooooooooooooo 0 2
hi
END

这里只有foooooooooooooooooooooooooooooooo有值,END表示结束。但是mg不支持查询多个key。同样的效果需要写成:

mg bar v\r\nmg foooooooooooooooooooooooooooooooo v\r\ngm baz v\r\n
VA 2
hi

因为mg默认不返回key,所以分不清楚hi对应的是哪个key。这个问题我们当然可以通过添加k标记来要求服务器返回对应的key:

mg bar k v\r\nmg foooooooooooooooooooooooooooooooo k v\r\ngm baz k v\r\n
VA 2 kfoooooooooooooooooooooooooooooooo
hi

但这里的foo+相比数据hi来说,有点太浪费了。为了减少数据传输,mg支持O标记来传入业务数据:

mg bar O1 v\r\nmg foooooooooooooooooooooooooooooooo O2 v\r\ngm baz O3 v\r\n
VA 2 O1
hi

这样,业务代码就可以通过返回值中的O标记完成映射。又因为mg不像get那样返回END,为了明确标记数据传输完成,我们需要额外传一个mn命令,服务端会直接返回MN响应。完整的指令如下:

mg bar O1 v\r\nmg foooooooooooooooooooooooooooooooo O2 v\r\ngm baz O3 v\r\nmn\r\n
VA 2 O1
hi
MN

这样就以最小的数据传输实现了查询多个key的效果。

因为元指令可以附加多种标记信息,我们可以在一次交到过程中完成多次操作。举个例子。如果我们想使用缓存控制秒杀库存,最大库存数量为100,则可以执行如下操作:

ma foo M- N120 J99 v
VA 2
99

这里的ma表示arithmetic,也就是算术操作。M-表示数字减一;N120表示没有缓存的话就新建一个,TTL为120秒;J99表示新添加的缓存值为99v表示返回结果。因为 Memcached 创建缓存时并不会执行减一操作,所以将初始值定为100,保证每一次都会减一。但减到0之后就没再继续减小了,也就是说,不管当前值是1还是0,减一之后的结果都是0。所以我们需要把返回值1当作库存用完的标志。如果没有ma指令,我们需要用decradd两种命令才能实现上面的效果。

以上就是 Memcached Meta Commands 的主要功能和设计。元指令的内容还有很多,可以完全替换原有的指令,如果有兴趣可以继续阅读官方协议,也可以阅读官方的元指令介绍。如果把 Memcached 当成内存数据库,那自然没办法跟 Redis 竞争。但如果只把 Memcached 当成内存缓存,那它基本上已经做到了极致。虽然现有 Memcached 用的比之前少了,但 AWS 的 Elasticache 居然也支持 1.6 版的 Memcached,也就是说在 AWS 上的服务可以用元指令了。由此可见,Memcached 正是老骥伏枥,志在前里,应该不会那么容易被 Redis 取代。