背景

memcached作为历史上重要的缓存组件,以高qps著称,主要是以多线程的特性能做到抗热key,而这点redis无法做到。

除去抗热key以外,似乎memcached已经越来越丧失对redis的优势,即使在qps领域,经过个人测试,中小key的qps承载量甚至被redis远远甩开。

在加上memcached过于简单,只有简单string操作,没有高可用(主从),强依赖sdk做一致性哈希,难以水平扩容,以及在企业级能力如逃生、单元化等方面都难以建设。

并考虑到redis8的展现的多线程能力已经可以抗超大并发的qps,据测试,redis8抗热key qps的量已经是redis6开单工作线程的3倍了。

因此将memcached迁移到redis提上了日程,在笔者公司,有几千个memcached集群,业务代码历史悠久繁重,因此业务需求是不改一行代码,自身无需重新做任何发布部署便能成迁移成功。

详细方案

笔者最终了选择twemproxy + redis-server到组合替换掉memcached集群。在改造过程中有两种路线

  • 让redis支持memcached协议,使得可以直接用memcached协议访问redis,用可配配置来作为开关,学习阿里云某个开源项目的做法(该项目已经无人维护)。
  • 改造twemproxy,使其做memcached到redis的协议转换,并在redis上实现一套memcached命令

最终选择了改造twemproxy,并使用redis-module的方式实现以上迁移需求。

基本原理

每一个mc命令均对应一个相同前缀的redis命令

1
2
3
4
get -> kcc_mc.get
add -> kcc_mc.add
replace -> kcc_mc.replace
...

除了多key命令,每一个mc命令解析后都会原样的按resp协议转发给redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
set key 0 0 3\r\n
val\r\n

------>

*6\r\n
$10\r\n
kcc_mc.set\r\n
$3\r\n
key\r\n
$1\r\n
0\r\n
$1\r\n
0\r\n
$1\r\n
3\r\n
$3\r\n
val\r\n

而redis-module处理的返回,只会有两种情况,即redis自身错误-ERR或字符串回复。

1
2
3
4
-ERR xxxx\r\n
或者
$5\r\n
xxxxx\r\n

redis module的实现为,对于每一个mc的命令,其生成回复为resp的字符串回复,中间嵌套mc回复,如mc的get命令回复如下

1
2
3
4
5
$25\r\n
VALUE 0 0 5\r\n
hello\r\n
END\r\n
\r\n

对于应该返回mc的错误场景,同样用字符串回复嵌套

1
2
3
$22\r\n
SERVER_ERROR unknown\r\n
\r\n

proxy实现

以下说明皆基于最新twemproxy开源版本0.5.0release

基本处理

在配置文件中引入新的配置项,该配置为bool值,一旦该值置为1,原先proxy的决定工况(redis or mc)的变量将失效。

对于每一个发送过来的mc命令,在命令完全解析完毕后,确定要往redis一侧发送前,做协议转换。具体操作为

  1. 确定参数个数n,生成*n\r\n
  2. 根据原mc的命令字符串,生成增加前缀“kcc_mc.”的字符串,并以resp协议的方式追加到缓冲区中
  3. 每一个mc的参数逐个追加到缓冲区中
  4. 对于写命令的value字段,考虑其长度可能会超过一个缓冲区,直接将原缓冲区mbuf整体移动赋予目标。

在收到redis的回复命令后,在回包给客户端之前,做协议转换,具体操作为

  1. 对于-ERR xxx的redis错误,直接去除前4个字节”-ERR”, 并在缓冲区前追加”SERVER_ERROR”,以符合mc的错误格式
  2. 对于约定好的字符串回复($xx\r\n),去除第一行,以及最后的”\r\n”字符。

命令分段

mc的多key命令只有两种,即get和gets。

需要在协议转换后,做预处理和后处理。

整体流程如下

错误处理

我们将错误归为两类

  • redis原生错误,即”-ERR xxx”
  • mc格式的错误,即用resp协议的字符串套用的mc错误,如”$18\r\nSERVER_ERROR xxx\r\n\r\n”

对于非分段命令,只要是redis一侧成功发回来的回复,我们均在协议处理后直接返回给客户端

对于分段命令,只要出现了其中一个sub_msg错误,我们将返回给客户端

mc+redis双协议支持

实际上,笔者最终在twemproxy上实现了同时支持redis协议和mc协议的方案。主要思路是通过请求的第一个字节是否为*+来更换处理协议函数集。

对于不转发到redis-server的命令可以无条件直接处理,而对于需要转发到redis-server的命令集可以设置一个拦截函数,规定哪些命令可以通过proxy转发,如可以允许scan命令转发用于集群扫表。

总体目标:

  1. proxy实现支持redis和memcached的协议包混合,即一个客户端连接,既可以发送redis命令,又可以发送mc命令。
  2. 需要对redis透传命令实现拦截器,即从稳定性考虑,必须能够支持选择性的透传redis命令,如当前只需要透传kcc_scan命令即可。

难点:

  1. 对于一个新的命令请求,我们无法判断使用哪种协议解析函数对其解析
  2. 对于s_conn(proxy与redis-server的连接),两种命令请求都在同一条tcp上,收到redis-server的回包时难以判断该回包是哪种类型。对于mc的命令需要做redis2mc的协议转换,而redis的回复可直接透传或多key命令聚合后再透传。

实现方案:

  1. 对于mc2redis的proxy集群,收到一个命令包时先默认使用mc的解析函数解析,在状态机初始处判断该命令包的第一个字符是否为“*”或”+”,如果是,完全替换该msg的所有处理函数,并重入命令解析函数。
  2. 彻底区分redis与mc命令集,并提供拦截函数用于判断可透传的redis命令集,在请求向redis-server发送前进行判断,若不能进行转发,返回客户端错误。
    对于s_conn的回包,利用tcp的有序性,取出挂在等待其回复队列的第一条msg作为对端请求msg,通过对端msg的”mc2redis”标识判断其是否需要做redis到memcached的协议转换。

redis-module实现

对应每一个mc命令,我们需要调研清楚其语义,如参数范围和对应的返回值表现。

这里不叙述任何的语义细节。主要阐述一下跟架构设计相关的实现思想。

  1. 维护cas_id的全局递增

对于每一个module的object,很显然可以跟随RdbSave将其flag和cas_id存放到到rdb中,但是全局cas_id如何维护递增?比如主从复制,从节点是需要拿到主节点的全局cas_id大小的。

实际上可以放到aux_save()里面,将这个全局id存进去,在aux_load()时再取出来。

  1. AOF重写怎么处理?

如果我们是用原命令来做aof重写,势必会丢失该对象的cas_id。实际上,我们可以实现一个新命令如mc.restore。用这个新命令把flag、对象的cas_id,和全局cas_id一起存进去。

QA

  1. 二进制协议怎么处理?

proxy后续可以处理两种命令,但转化为相同的文本redis命令,即redis命令只给出文本回复。据分析,所有的命令的文本回复在原理上,可以转换为二进制回复。只是实现问题。
附录(协议):
https://github.com/memcached/memcached/blob/master/doc/protocol.txt
https://github.com/memcached/memcached/blob/master/doc/protocol-binary.txt

  1. 为什么采用redis-module?

答:考虑到mc每一个命令都需要开发对应的redis命令,为了代码集中统一管理迭代,也符合redis官方的推荐实现。

  1. 为什么不采用哈希表实现?

这样每一个mc的key都会变成哈希表,无论从性能开销还是内存开销上来说,都是完全没有经过测试和难以估量的。

  1. 为什么不在module中直接用原生字符串,而是引入了新类型?

假如未来redis-sdk可以直接访问该redis,如果业务用string的get、set命令访问了mc的key,会有安全性风险。而引入新类型保证了原生string命令无法访问mc迁移过来的key,会返回类型错误。

  1. 为什么采用proxy做协议转换,而不是redis内核直接处理mc协议的方式,仿造ApsaraCache?
  • redis内核侵入大,影响范围大。比如将引入新的redis配置参数。
  • 后续每次跟随开源社区升级redis版本都需要搬运迁移,有额外工作量且有稳定性风险。
  • 当前twemproxy开源社区已不维护,可以完全自研修改,无升级后顾之忧。