memcached迁移redis实践
背景
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 | get -> kcc_mc.get |
除了多key命令,每一个mc命令解析后都会原样的按resp协议转发给redis
1 | set key 0 0 3\r\n |
而redis-module处理的返回,只会有两种情况,即redis自身错误-ERR或字符串回复。
1 | -ERR xxxx\r\n |
redis module的实现为,对于每一个mc的命令,其生成回复为resp的字符串回复,中间嵌套mc回复,如mc的get命令回复如下
1 | 25\r\n |
对于应该返回mc的错误场景,同样用字符串回复嵌套
1 | 22\r\n |
proxy实现
以下说明皆基于最新twemproxy开源版本0.5.0release
基本处理
在配置文件中引入新的配置项,该配置为bool值,一旦该值置为1,原先proxy的决定工况(redis or mc)的变量将失效。
对于每一个发送过来的mc命令,在命令完全解析完毕后,确定要往redis一侧发送前,做协议转换。具体操作为
- 确定参数个数n,生成*n\r\n
- 根据原mc的命令字符串,生成增加前缀“kcc_mc.”的字符串,并以resp协议的方式追加到缓冲区中
- 每一个mc的参数逐个追加到缓冲区中
- 对于写命令的value字段,考虑其长度可能会超过一个缓冲区,直接将原缓冲区mbuf整体移动赋予目标。
在收到redis的回复命令后,在回包给客户端之前,做协议转换,具体操作为
- 对于-ERR xxx的redis错误,直接去除前4个字节”-ERR”, 并在缓冲区前追加”SERVER_ERROR”,以符合mc的错误格式
- 对于约定好的字符串回复($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命令转发用于集群扫表。
总体目标:
- proxy实现支持redis和memcached的协议包混合,即一个客户端连接,既可以发送redis命令,又可以发送mc命令。
- 需要对redis透传命令实现拦截器,即从稳定性考虑,必须能够支持选择性的透传redis命令,如当前只需要透传kcc_scan命令即可。
难点:
- 对于一个新的命令请求,我们无法判断使用哪种协议解析函数对其解析
- 对于s_conn(proxy与redis-server的连接),两种命令请求都在同一条tcp上,收到redis-server的回包时难以判断该回包是哪种类型。对于mc的命令需要做redis2mc的协议转换,而redis的回复可直接透传或多key命令聚合后再透传。
实现方案:
- 对于mc2redis的proxy集群,收到一个命令包时先默认使用mc的解析函数解析,在状态机初始处判断该命令包的第一个字符是否为“*”或”+”,如果是,完全替换该msg的所有处理函数,并重入命令解析函数。
- 彻底区分redis与mc命令集,并提供拦截函数用于判断可透传的redis命令集,在请求向redis-server发送前进行判断,若不能进行转发,返回客户端错误。
对于s_conn的回包,利用tcp的有序性,取出挂在等待其回复队列的第一条msg作为对端请求msg,通过对端msg的”mc2redis”标识判断其是否需要做redis到memcached的协议转换。
redis-module实现
对应每一个mc命令,我们需要调研清楚其语义,如参数范围和对应的返回值表现。
这里不叙述任何的语义细节。主要阐述一下跟架构设计相关的实现思想。
- 维护cas_id的全局递增
对于每一个module的object,很显然可以跟随RdbSave将其flag和cas_id存放到到rdb中,但是全局cas_id如何维护递增?比如主从复制,从节点是需要拿到主节点的全局cas_id大小的。
实际上可以放到aux_save()
里面,将这个全局id存进去,在aux_load()
时再取出来。
- AOF重写怎么处理?
如果我们是用原命令来做aof重写,势必会丢失该对象的cas_id。实际上,我们可以实现一个新命令如mc.restore
。用这个新命令把flag、对象的cas_id,和全局cas_id一起存进去。
QA
- 二进制协议怎么处理?
proxy后续可以处理两种命令,但转化为相同的文本redis命令,即redis命令只给出文本回复。据分析,所有的命令的文本回复在原理上,可以转换为二进制回复。只是实现问题。
附录(协议):
https://github.com/memcached/memcached/blob/master/doc/protocol.txt
https://github.com/memcached/memcached/blob/master/doc/protocol-binary.txt
- 为什么采用redis-module?
答:考虑到mc每一个命令都需要开发对应的redis命令,为了代码集中统一管理迭代,也符合redis官方的推荐实现。
- 为什么不采用哈希表实现?
这样每一个mc的key都会变成哈希表,无论从性能开销还是内存开销上来说,都是完全没有经过测试和难以估量的。
- 为什么不在module中直接用原生字符串,而是引入了新类型?
假如未来redis-sdk可以直接访问该redis,如果业务用string的get、set命令访问了mc的key,会有安全性风险。而引入新类型保证了原生string命令无法访问mc迁移过来的key,会返回类型错误。
- 为什么采用proxy做协议转换,而不是redis内核直接处理mc协议的方式,仿造ApsaraCache?
- redis内核侵入大,影响范围大。比如将引入新的redis配置参数。
- 后续每次跟随开源社区升级redis版本都需要搬运迁移,有额外工作量且有稳定性风险。
- 当前twemproxy开源社区已不维护,可以完全自研修改,无升级后顾之忧。