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一起存进去。
redis-module 深入实现细节
上文对 module 的核心设计思路做了高层说明,本节从源码视角展开,逐一分析每个关键实现点。
数据类型设计:McTypeObject
Module 注册了一个名为 "memcached" 的自定义数据类型(RedisModuleType),每一个 mc key 对应一个 McTypeObject 实例:
1 | typedef struct __attribute__ ((__packed__)) { |
使用 __attribute__ ((__packed__)) 是为了消除结构体对齐产生的内存填充,减少内存占用。
为什么不用原生 Redis String 类型? 因为原生 string 命令(GET/SET)若能直接访问 mc 迁移过来的 key,会存在安全风险。引入自定义类型后,原生字符串命令访问该 key 将返回 WRONGTYPE 类型错误,从而在协议层隔离了 mc 数据与 redis 数据的访问路径。
全局 CAS ID 管理
1 | static uint64_t cas_id = 0; |
cas_id 是一个进程级别的全局单调递增计数器,每次写操作(set/add/replace/append/prepend/cas/incr/decr)都会调用 get_cas_id() 分配一个新的 CAS 值并写入 McTypeObject。
CAS 的作用:客户端通过 gets 命令获取 key 时会拿到当前的 cas_id,之后执行 cas 命令时携带这个 ID。Module 会比对当前对象的 cas 与客户端传入的 cas,若不一致说明此期间有其他客户端修改了该 key,返回 EXISTS,实现乐观锁语义。
跨重启的 CAS 连续性:通过 RDB 的 AuxSave/AuxLoad 机制持久化和恢复全局 cas_id(见下文持久化章节),确保重启后分配的 CAS ID 不会与历史值重叠。
过期时间转换:realtime()
Memcached 的 exptime 语义与 Redis 存在差异,module 通过 realtime() 函数统一转换为 Redis 的绝对毫秒时间戳:
1 |
|
这与原生 memcached 的行为完全一致:exptime ≤ 30天时为相对时间,> 30天时为绝对 Unix 时间戳。
noreply 机制
所有写命令(set/add/replace/append/prepend/cas/delete/touch/incr/decr)均支持 noreply 参数。
1 | int reply = 1; |
noreply 是模块初始化时预先缓存好的全局 RedisModuleString,避免每次命令调用都重新构建字符串。所有回包函数在 reply == 0 时直接返回 REDISMODULE_OK 不写入任何响应,对应 mc 协议的静默写入语义。
命令实现全览
写命令(存储类)
| MC 命令 | Redis Module 命令 | 语义 |
|---|---|---|
set |
kcc_mc.set |
无条件写入,key 不存在则新建,存在则覆盖 |
add |
kcc_mc.add |
仅在 key 不存在时写入,存在返回 NOT_STORED |
replace |
kcc_mc.replace |
仅在 key 已存在时写入,不存在返回 NOT_STORED |
append |
kcc_mc.append |
在已有 value 末尾追加,key 不存在返回 NOT_STORED |
prepend |
kcc_mc.prepend |
在已有 value 头部追加,key 不存在返回 NOT_STORED |
cas |
kcc_mc.cas |
CAS 原子更新,CAS 不匹配返回 EXISTS |
所有写命令注册时均带有 "write deny-oom" flag,在内存不足(OOM)时 Redis 会拒绝执行这些命令。
SET 命令流程:
ADD 命令的特殊逻辑:
add 命令在 key 已存在(且为 McType)时,除了返回 NOT_STORED,还会主动调用 RedisModule_SetLRU(key, 0) 刷新该 key 的 LRU 时钟。这是为了让 add 操作也能体现出”key 被访问过”的语义,与原生 mc 的行为保持一致。
APPEND/PREPEND 的过期时间继承:
1 | mstime_t old_expire = RedisModule_GetAbsExpire(key); |
append/prepend 在更新 value 时,会先取出旧 key 的过期时间,在 ModuleTypeSetValue(此操作会清除过期时间)之后再重新设置回去,确保过期时间不变。这与原生 memcached 的行为一致——追加/前置操作不改变 key 的过期时间。
CAS 命令的乐观锁:
读命令
GET 命令(支持多 key):
kcc_mc.get 和 kcc_mc.gets 支持在一条命令中传入多个 key,在 module 侧实现 key 批量聚合查询的逻辑:
1 | RedisModuleString *res = RedisModule_CreateString(ctx, "", 0); |
返回格式完全符合 mc 协议:
1 | VALUE key1 <flags> <bytes>\r\n |
gets 与 get 的区别仅在于多输出一个 <cas_id> 字段:
1 | VALUE key1 <flags> <bytes> <cas_id>\r\n |
注意,get 和 gets 命令使用 REDISMODULE_OPEN_KEY_NOTOUCH 标志打开 key,但随后会手动调用 RedisModule_SetLRU(key, 0) 来更新 LRU。这是因为直接用 NOTOUCH 避免了 Redis 内部的自动 LRU 更新,但 mc 语义需要 get 操作更新访问时间,因此需要手动补充。
INCR/DECR 命令
1 | McTypeObject *mco = (McTypeObject *)RedisModule_ModuleTypeGetValue(key); |
INCR/DECR 的关键特点:
- 要求 value 必须是合法的无符号整型字符串,否则返回
CLIENT_ERROR cannot increment or decrement non-numeric value - DECR 下溢时截断为 0(不产生负数),与原生 mc 一致
- 操作完成后返回的是新值的字符串(带
\r\n),不是STORED - 同样会继承旧 key 的过期时间(
GetAbsExpire+SetAbsExpire),操作不改变 TTL
DELETE 命令的格式校验
mc 的 delete 语法为:delete <key> [<time>] [noreply],其中 <time> 字段在新版本 mc 中已废弃(必须为 0),但仍需兼容解析:
1 | if (argc > 2) { |
TOUCH 命令的特殊处理
touch 命令用于刷新 key 的过期时间而不读取 value:
1 | if (expire_ms == 0) { |
当传入的 exptime 经 realtime() 转换后为 0(表示已过期),touch 会直接删除该 key 而非报错,这与原生 mc 的 touch 行为一致。
持久化机制
Module 实现了完整的 RDB + AOF 持久化钩子,注册如下:
1 | RedisModuleTypeMethods tm = { |
RDB 持久化
每个 McTypeObject 的序列化格式为(按顺序):
| 字段 | 类型 | 说明 |
|---|---|---|
cas |
uint64_t | 该 key 的 CAS 令牌 |
flags |
uint32_t | memcached flags 字段 |
data |
string buffer | value 数据 |
McTypeRdbSave 将以上三个字段依次写入 RDB,McTypeRdbLoad 按相同顺序读回,重建 McTypeObject。
全局 CAS ID 的 AUX 持久化
这是整个持久化方案中最关键、也最容易忽略的部分:
1 | void McTypeAuxSave(RedisModuleIO *rdb, int when) { |
aux_save_triggers 是一个位掩码,直接控制 aux_save/aux_load 回调是否被触发。这不是”选择时序”那么简单——如果不设置这个字段,aux_save 根本不会被调用。
Redis 在 rdbSaveModulesAux() 中有如下守卫逻辑(src/module.c):
1 | // module.c: rdbSaveModulesAux() |
RDB 写入流程(src/rdb.c)中,rdbSaveModulesAux 被调用两次:
1 | // rdb.c: rdbSaveRio() |
设置为 REDISMODULE_AUX_BEFORE_RDB 的含义是:在 key-value 数据写入之前触发 aux 回调,这对于 CAS ID 的正确恢复是必要的——加载 RDB 时,aux_load 会先于所有 rdb_load 回调执行,从而在每个 McTypeObject 被装载之前就已恢复好全局 cas_id,确保后续新分配的 ID 不与历史值冲突。若改为 AFTER_RDB,虽然持久化能正常进行,但加载时序上 cas_id 会在所有 key 加载完毕后才被恢复,这个时间窗口内如果有其他操作(理论上不会,但时序依赖更脆弱)就存在隐患,且与代码中两处 assert(when == REDISMODULE_AUX_BEFORE_RDB) 的契约相违背。
AOF 重写:KCC_MC.RESTORE 命令
1 | void McTypeAofRewrite(RedisModuleIO *aof, RedisModuleString *key, void *value) { |
AOF 重写时,每个 McTypeObject 被序列化为一条 KCC_MC.RESTORE 命令:
1 | KCC_MC.RESTORE <key> <flags> <cas> <value> <global_cas_id> |
为什么不用原始写命令(如 kcc_mc.set)来重写 AOF?
因为 kcc_mc.set 执行时会调用 get_cas_id() 分配新的 CAS ID,会破坏原有的 CAS 值。而 KCC_MC.RESTORE 是专为 AOF 重写设计的内部命令,它直接将 mco->cas 还原到对象中,不重新分配 CAS ID。
同时,每条 RESTORE 命令还携带了 cas_id(全局计数器当前值):
1 | // 取 restore 命令中 old_cas_id 字段的最大值来恢复全局 cas_id |
实际上所有 RESTORE 命令里的 old_cas_id 是一样的(写入 AOF 时的快照值),只有第一条生效,后续的都是等值比较不变,这是一个防御性的保险措施。
注意 AOF 重写无需显式记录 EXPIREAT,Redis 的 AOF 重写机制会自动在每条命令后追加 PEXPIREAT 命令来还原过期时间。
持久化流程全景
内存管理
所有 McTypeObject 均通过 Redis 的 RedisModule_Calloc/RedisModule_Alloc/RedisModule_Free 管理内存,接入 Redis 的内存跟踪体系(used_memory 统计)。
McTypeMemUsage 用于精确上报该对象的内存占用:
1 | size_t McTypeMemUsage(const void *value) { |
sdsZmallocSize 返回 SDS 字符串实际占用的 zmalloc 内存块大小(含 SDS header),比 sdslen 更准确地反映真实内存消耗。
对于写操作,每次都会分配一个新的 McTypeObject,然后通过 RedisModule_ModuleTypeSetValue 替换旧对象。ModuleTypeSetValue 内部会自动调用旧对象的 free 方法(即 McTypeFree)回收内存:
1 | void McTypeFree(void *value) { |
回复格式:RESP 包裹 MC 协议
Module 所有命令的回复均使用 Redis 标准 RESP 的字符串类型包裹 MC 格式的内容:
1 | // 简单字符串回复(如 "STORED\r\n") |
ReplyWithCString 底层实际是 ReplyWithStringBuffer,区别在于前者用 strlen 计算长度。对于包含 \r\n 的多行 mc 响应,必须使用带显式长度的 ReplyWithStringBuffer,否则遇到 \0 会截断。
这也是 proxy 侧协议转换的基础:proxy 收到 $xx\r\n<mc_content>\r\n 格式的 RESP bulk string 后,去掉头部的 $xx\r\n 和尾部的 \r\n,剩下的内容就是合法的 mc 协议响应。
内部管理命令
除了对应 mc 协议的用户命令,module 还提供了两个内部命令:
kcc_mc.restore
专为 AOF 重写和灾难恢复设计,不对外暴露给业务使用:
1 | KCC_MC.RESTORE <key> <flags> <cas> <value> <global_cas_id> |
直接将指定的 cas、flags、data 写入 McTypeObject,并将 global_cas_id 与当前 cas_id 取最大值更新全局计数器。
kcc_mc.len
返回指定 key 的 McTypeObject 的字段总字节数(sizeof(cas) + sizeof(flags) + len(data)),用于运维监控和 KCC 内部管理,不参与正常的 mc 协议流程:
1 | size_t len = sizeof(mco->cas) + sizeof(mco->flags) + sdslen(mco->data); |
注意此命令返回的是原生的 RESP 整数类型(RedisModule_ReplyWithError/ReplyWithLongLong),不走 mc 格式包裹,因为该命令的使用方是运维工具而非 mc 客户端。
命令错误信息一览
Module 定义了完整的客户端错误和服务端错误集合:
| 错误常量 | 错误内容 | 场景 |
|---|---|---|
KCC_MC_MODULE_STORED |
STORED\r\n |
写入成功 |
KCC_MC_MODULE_NOT_STORED |
NOT_STORED\r\n |
add/replace/append/prepend 条件不满足 |
KCC_MC_MODULE_NOT_FOUND |
NOT_FOUND\r\n |
delete/touch/incr/decr key 不存在 |
KCC_MC_MODULE_EXISTS |
EXISTS\r\n |
CAS 令牌不匹配 |
KCC_MC_MODULE_DELETED |
DELETED\r\n |
delete 成功 |
KCC_MC_MODULE_TOUCHED |
TOUCHED\r\n |
touch 成功 |
KCC_MC_MODULE_OK |
OK\r\n |
通用成功(当前未使用) |
KCC_MC_MODULE_BAD_CMD_FORMAT |
CLIENT_ERROR bad command line format\r\n |
参数格式错误 |
KCC_MC_MODULE_BAD_DATA_CHUNK |
CLIENT_ERROR bad data chunk\r\n |
value 实际长度与 nbytes 不符 |
KCC_MC_MODULE_INVALID_EXPTIME |
CLIENT_ERROR invalid exptime argument\r\n |
exptime 非整数 |
KCC_MC_MODULE_INVALID_NUMERIC |
CLIENT_ERROR invalid numeric delta argument\r\n |
incr/decr delta 非整数 |
KCC_MC_MODULE_CANNOT_INCR_OR_DECR |
CLIENT_ERROR cannot increment or decrement non-numeric value\r\n |
value 不是数字型字符串 |
KCC_MC_MODULE_NOT_MEMCACHED_TYPE |
SERVER_ERROR MC2REDIS this key exist, not memcached type\r\n |
key 已存在但类型非 McType |
KCC_MC_MODULE_OUT_OF_MEMORY |
SERVER_ERROR MC2REDIS out of memory\r\n |
内存分配失败 |
KCC_MC_MODULE_UNKNOWN_CMD |
SERVER_ERROR MC2REDIS server not supported\r\n |
参数数量不匹配 |
其中 NOT_MEMCACHED_TYPE 是一个关键的安全守门错误:当 Redis 中已经存在一个原生类型的 key(如原生 string、hash 等),mc 客户端尝试写入同名 key 时,module 会拒绝操作并返回该错误,防止数据类型混用。
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开源社区已不维护,可以完全自研修改,无升级后顾之忧。
- append/prepend 为何不重置过期时间?
与原生 mc 协议一致,追加/前置操作属于修改操作而非重新创建,因此沿用旧 key 的过期时间。module 实现中通过先 GetAbsExpire 保存旧过期时间,在 ModuleTypeSetValue 后再 SetAbsExpire 还原来保证这一语义。
- CAS ID 在主从复制时如何保证一致性?
主节点上的所有写命令执行后都会调用 RedisModule_ReplicateVerbatim(ctx),将原始命令原样复制到从节点。从节点执行同一条写命令时,get_cas_id() 会让 cas_id 递增,但由于命令参数中的 cas 值是通过 ModuleTypeSetValue 直接写入对象字段(而非依赖分配的新 ID),所以实际 key 的 cas 值在主从上完全一致。而全局 cas_id 计数器则通过 RDB 的 AuxSave/AuxLoad 在主从之间同步到相同的起点。