背景

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一起存进去。


redis-module 深入实现细节

上文对 module 的核心设计思路做了高层说明,本节从源码视角展开,逐一分析每个关键实现点。

数据类型设计:McTypeObject

Module 注册了一个名为 "memcached" 的自定义数据类型(RedisModuleType),每一个 mc key 对应一个 McTypeObject 实例:

1
2
3
4
5
typedef struct __attribute__ ((__packed__)) {
uint64_t cas; // CAS 令牌,用于乐观锁(Compare-And-Swap)
uint32_t flags; // memcached 的 flags 字段,客户端自定义语义(如序列化方式)
sds data; // value 数据,使用 Redis 的 SDS 字符串
} McTypeObject;

使用 __attribute__ ((__packed__)) 是为了消除结构体对齐产生的内存填充,减少内存占用。

为什么不用原生 Redis String 类型? 因为原生 string 命令(GET/SET)若能直接访问 mc 迁移过来的 key,会存在安全风险。引入自定义类型后,原生字符串命令访问该 key 将返回 WRONGTYPE 类型错误,从而在协议层隔离了 mc 数据与 redis 数据的访问路径。

全局 CAS ID 管理

1
2
3
4
5
6
static uint64_t cas_id = 0;

uint64_t get_cas_id() {
uint64_t next_id = ++cas_id;
return next_id;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define REALTIME_MAXDELTA  60*60*24*30  // 30天,单位秒

static mstime_t realtime(const int32_t exptime) {
if (exptime == 0) return REDISMODULE_NO_EXPIRE; // 0 表示永不过期
if (exptime < 0) return 0; // 负数表示立即过期(直接删除)

mstime_t now = RedisModule_Milliseconds();

if (exptime > REALTIME_MAXDELTA) {
// 超过 30 天的值被解释为绝对 Unix 时间戳(秒)
if (exptime <= now / 1000) return 0; // 已过期
return (mstime_t)exptime * 1000;
} else {
// 30 天以内的值被解释为相对秒数,转换为绝对毫秒时间
return now + (mstime_t)exptime * 1000;
}
}

这与原生 memcached 的行为完全一致:exptime ≤ 30天时为相对时间,> 30天时为绝对 Unix 时间戳

noreply 机制

所有写命令(set/add/replace/append/prepend/cas/delete/touch/incr/decr)均支持 noreply 参数。

1
2
3
4
int reply = 1;
if (RedisModule_StringCompare(argv[argc - 2], noreply) == 0) {
reply = 0;
}

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 命令流程:

SET命令流程

ADD 命令的特殊逻辑:

add 命令在 key 已存在(且为 McType)时,除了返回 NOT_STORED,还会主动调用 RedisModule_SetLRU(key, 0) 刷新该 key 的 LRU 时钟。这是为了让 add 操作也能体现出”key 被访问过”的语义,与原生 mc 的行为保持一致。

APPEND/PREPEND 的过期时间继承:

1
2
3
mstime_t old_expire = RedisModule_GetAbsExpire(key);
RedisModule_ModuleTypeSetValue(key, McType, new_mco);
RedisModule_SetAbsExpire(key, old_expire);

append/prepend 在更新 value 时,会先取出旧 key 的过期时间,在 ModuleTypeSetValue(此操作会清除过期时间)之后再重新设置回去,确保过期时间不变。这与原生 memcached 的行为一致——追加/前置操作不改变 key 的过期时间。

CAS 命令的乐观锁:

CAS命令乐观锁流程

读命令

GET 命令(支持多 key):

kcc_mc.getkcc_mc.gets 支持在一条命令中传入多个 key,在 module 侧实现 key 批量聚合查询的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
RedisModuleString *res = RedisModule_CreateString(ctx, "", 0);
int idx = KEY_IDX;
for ( ; idx < argc; idx++) {
// 依次查找每个 key
RedisModuleKey *key = RedisModule_OpenKey(ctx, argv[idx], REDISMODULE_READ | REDISMODULE_OPEN_KEY_NOTOUCH);
if (RedisModule_KeyType(key) == REDISMODULE_KEYTYPE_EMPTY) {
RedisModule_CloseKey(key);
continue; // key 不存在时直接跳过,不报错
}
// 拼接 VALUE <key> <flags> <bytes>\r\n<data>\r\n
...
RedisModule_SetLRU(key, 0); // 刷新 LRU 时钟
}
RedisModule_StringAppendBuffer(ctx, res, "END\r\n", 5);

返回格式完全符合 mc 协议:

1
2
3
4
5
VALUE key1 <flags> <bytes>\r\n
<data>\r\n
VALUE key2 <flags> <bytes>\r\n
<data>\r\n
END\r\n

getsget 的区别仅在于多输出一个 <cas_id> 字段:

1
VALUE key1 <flags> <bytes> <cas_id>\r\n

注意,getgets 命令使用 REDISMODULE_OPEN_KEY_NOTOUCH 标志打开 key,但随后会手动调用 RedisModule_SetLRU(key, 0) 来更新 LRU。这是因为直接用 NOTOUCH 避免了 Redis 内部的自动 LRU 更新,但 mc 语义需要 get 操作更新访问时间,因此需要手动补充。

INCR/DECR 命令

1
2
3
4
5
6
7
8
9
10
11
12
McTypeObject *mco = (McTypeObject *)RedisModule_ModuleTypeGetValue(key);
unsigned long long value;
if (string2ull(mco->data, &value) == 0) {
return ReplyMemcacheSimpleString(ctx, KCC_MC_MODULE_CANNOT_INCR_OR_DECR, reply);
}
value += delta; // INCR:加法
// 或者 DECR:
if (delta > value) {
value = 0; // 下溢时截断为 0,不返回负数
} else {
value -= delta;
}

INCR/DECR 的关键特点:

  1. 要求 value 必须是合法的无符号整型字符串,否则返回 CLIENT_ERROR cannot increment or decrement non-numeric value
  2. DECR 下溢时截断为 0(不产生负数),与原生 mc 一致
  3. 操作完成后返回的是新值的字符串(带 \r\n),不是 STORED
  4. 同样会继承旧 key 的过期时间(GetAbsExpire + SetAbsExpire),操作不改变 TTL

DELETE 命令的格式校验

mc 的 delete 语法为:delete <key> [<time>] [noreply],其中 <time> 字段在新版本 mc 中已废弃(必须为 0),但仍需兼容解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (argc > 2) {
int hold_is_zero = RedisModule_StringCompare(argv[KEY_IDX + 1], num_zero) == 0;
if (RedisModule_StringCompare(argv[argc - 1], noreply) == 0) {
reply = 0;
}
// 合法的情况:
// argc==3: delete key 0 或 delete key noreply
// argc==4: delete key 0 noreply
int valid = (argc == 3 && (hold_is_zero || !reply))
|| (argc == 4 && hold_is_zero && !reply);
if (!valid) {
return ReplyMemcacheSimpleString(ctx, KCC_MC_MODULE_BAD_TOUCH_COMMAND, reply);
}
}

TOUCH 命令的特殊处理

touch 命令用于刷新 key 的过期时间而不读取 value:

1
2
3
4
5
6
if (expire_ms == 0) {
RedisModule_DeleteKey(key); // 过期时间为 0 意味着立即删除
} else {
RedisModule_SetAbsExpire(key, expire_ms);
RedisModule_SetLRU(key, 0); // 刷新 LRU 时钟
}

当传入的 exptime 经 realtime() 转换后为 0(表示已过期),touch 会直接删除该 key 而非报错,这与原生 mc 的 touch 行为一致。

持久化机制

Module 实现了完整的 RDB + AOF 持久化钩子,注册如下:

1
2
3
4
5
6
7
8
9
10
RedisModuleTypeMethods tm = {
.rdb_load = McTypeRdbLoad,
.rdb_save = McTypeRdbSave,
.aof_rewrite = McTypeAofRewrite,
.free = McTypeFree,
.aux_load = McTypeAuxLoad,
.aux_save = McTypeAuxSave,
.aux_save_triggers = REDISMODULE_AUX_BEFORE_RDB,
.mem_usage = McTypeMemUsage,
};

RDB 持久化

每个 McTypeObject 的序列化格式为(按顺序):

字段 类型 说明
cas uint64_t 该 key 的 CAS 令牌
flags uint32_t memcached flags 字段
data string buffer value 数据

McTypeRdbSave 将以上三个字段依次写入 RDB,McTypeRdbLoad 按相同顺序读回,重建 McTypeObject

全局 CAS ID 的 AUX 持久化

这是整个持久化方案中最关键、也最容易忽略的部分:

1
2
3
4
5
6
7
8
9
10
11
void McTypeAuxSave(RedisModuleIO *rdb, int when) {
assert(when == REDISMODULE_AUX_BEFORE_RDB);
RedisModule_SaveUnsigned(rdb, cas_id); // 在 RDB 正文之前写入全局 cas_id
}

int McTypeAuxLoad(RedisModuleIO *rdb, int encver, int when) {
assert(when == REDISMODULE_AUX_BEFORE_RDB);
uint64_t old_cas_id = RedisModule_LoadUnsigned(rdb);
cas_id = old_cas_id; // 恢复全局 cas_id
...
}

aux_save_triggers 是一个位掩码,直接控制 aux_save/aux_load 回调是否被触发。这不是”选择时序”那么简单——如果不设置这个字段,aux_save 根本不会被调用。

Redis 在 rdbSaveModulesAux() 中有如下守卫逻辑(src/module.c):

1
2
3
// module.c: rdbSaveModulesAux()
if (!mt->aux_save || !(mt->aux_save_triggers & when))
continue; // 跳过该 module,aux_save 不会执行

RDB 写入流程(src/rdb.c)中,rdbSaveModulesAux 被调用两次:

1
2
3
4
// rdb.c: rdbSaveRio()
rdbSaveModulesAux(rdb, REDISMODULE_AUX_BEFORE_RDB); // 在所有 key-value 之前
// ... 逐 db 写入所有 key-value ...
rdbSaveModulesAux(rdb, REDISMODULE_AUX_AFTER_RDB); // 在所有 key-value 之后

设置为 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
2
3
4
5
6
7
void McTypeAofRewrite(RedisModuleIO *aof, RedisModuleString *key, void *value) {
McTypeObject *mco = (McTypeObject *)value;
if (mco) {
RedisModuleString *data = RedisModule_CreateString(NULL, mco->data, sdslen(mco->data));
RedisModule_EmitAOF(aof, "KCC_MC.RESTORE", "sllsl", key, mco->flags, mco->cas, data, cas_id);
}
}

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
2
3
4
// 取 restore 命令中 old_cas_id 字段的最大值来恢复全局 cas_id
if (old_cas_id > cas_id) {
cas_id = old_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
2
3
4
size_t McTypeMemUsage(const void *value) {
McTypeObject *mco = (McTypeObject *) value;
return sizeof(*mco) + sdsZmallocSize(mco->data);
}

sdsZmallocSize 返回 SDS 字符串实际占用的 zmalloc 内存块大小(含 SDS header),比 sdslen 更准确地反映真实内存消耗。

对于写操作,每次都会分配一个新的 McTypeObject,然后通过 RedisModule_ModuleTypeSetValue 替换旧对象。ModuleTypeSetValue 内部会自动调用旧对象的 free 方法(即 McTypeFree)回收内存:

1
2
3
4
5
6
7
8
9
void McTypeFree(void *value) {
if (value) {
McTypeObject *mco = (McTypeObject *) value;
if (mco->data) {
sdsfree(mco->data); // 释放 SDS 字符串
}
RedisModule_Free(mco); // 释放 McTypeObject 本身
}
}

回复格式:RESP 包裹 MC 协议

Module 所有命令的回复均使用 Redis 标准 RESP 的字符串类型包裹 MC 格式的内容:

1
2
3
4
5
6
7
8
9
10
11
// 简单字符串回复(如 "STORED\r\n")
static int ReplyMemcacheSimpleString(RedisModuleCtx *ctx, const char *buf, int reply) {
if (!reply) return REDISMODULE_OK;
return RedisModule_ReplyWithCString(ctx, buf);
}

// 带长度的字节回复(如 get 的多行响应)
static int ReplyMemcacheString(RedisModuleCtx *ctx, const char *buf, size_t len, int reply) {
if (!reply) return REDISMODULE_OK;
return RedisModule_ReplyWithStringBuffer(ctx, buf, len);
}

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>

直接将指定的 casflagsdata 写入 McTypeObject,并将 global_cas_id 与当前 cas_id 取最大值更新全局计数器。

kcc_mc.len

返回指定 key 的 McTypeObject 的字段总字节数(sizeof(cas) + sizeof(flags) + len(data)),用于运维监控和 KCC 内部管理,不参与正常的 mc 协议流程:

1
2
size_t len = sizeof(mco->cas) + sizeof(mco->flags) + sdslen(mco->data);
RedisModule_ReplyWithLongLong(ctx, len);

注意此命令返回的是原生的 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

  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开源社区已不维护,可以完全自研修改,无升级后顾之忧。
  1. append/prepend 为何不重置过期时间?

与原生 mc 协议一致,追加/前置操作属于修改操作而非重新创建,因此沿用旧 key 的过期时间。module 实现中通过先 GetAbsExpire 保存旧过期时间,在 ModuleTypeSetValue 后再 SetAbsExpire 还原来保证这一语义。

  1. CAS ID 在主从复制时如何保证一致性?

主节点上的所有写命令执行后都会调用 RedisModule_ReplicateVerbatim(ctx),将原始命令原样复制到从节点。从节点执行同一条写命令时,get_cas_id() 会让 cas_id 递增,但由于命令参数中的 cas 值是通过 ModuleTypeSetValue 直接写入对象字段(而非依赖分配的新 ID),所以实际 key 的 cas 值在主从上完全一致。而全局 cas_id 计数器则通过 RDB 的 AuxSave/AuxLoad 在主从之间同步到相同的起点。