共计 4562 个字符,预计需要花费 12 分钟才能阅读完成。
众所周知,redis 对外提供的服务是由单线程支撑,通过事件 (event) 驱动各种内部逻辑,比如网络 IO、命令处理、过期 key 处理、超时等逻辑。在执行耗时命令(如范围扫描类的 keys, 超大 hash 下的 hgetall 等)、瞬时大量 key 过期 / 驱逐等情况下,会造成 redis 的 QPS 下降,阻塞其他请求。近期就遇到过大容量并且大量 key 的场景,由于各种原因引发的 redis 内存耗尽,导致有 6 位数的 key 几乎同时被驱逐,短期内 redis hang 住的情况
耗时命令是客户端行为,服务端不可控,优化余地有限,作者 antirez 在 4.0 这个大版本中增加了针对大量 key 过期 / 驱逐的 lazy free 功能,服务端的事情还是可控的,甚至提供了异步删除的命令 unlink(前因后果和作者的思路变迁,见作者博客:Lazy Redis is better Redis – <antirez>)
lazy free 的功能在使用中有几个注意事项(以下为个人观点,有误的地方请评论区交流):
- lazy free 不是在遇到快 OOM 的时候直接执行命令,放后台释放内存,而是也需要 block 一段时间去获得足够的内存来执行命令
- lazy free 不适合 kv 的平均大小太小或太大的场景,大小均衡的场景下性价比比较高(当然,可以根据业务场景调整源码里的宏,重新编译一个版本)
- redis 短期内其实是可以略微超出一点内存上限的,因为前一条命令没检测到内存超标(其实快超了)的情况下,是可以写入一个很大的 kv 的,当后续命令进来之后会发现内存不够了,交给后续命令执行释放内存操作
- 如果业务能预估到可能会有集中的大量 key 过期,那么最好 ttl 上加个随机数,匀开来,避免集中 expire 造成的 blocking,这点不管开不开 lazy free 都一样
具体分析请见下文
参数
redis 4.0 新加了 4 个参数,用来控制这种 lazy free 的行为
- lazyfree-lazy-eviction:是否异步驱逐 key,当内存达到上限,分配失败后
- lazyfree-lazy-expire:是否异步进行 key 过期事件的处理
- lazyfree-lazy-server-del:del 命令是否异步执行删除操作,类似 unlink
- replica-lazy-flush:replica client 做全同步的时候,是否异步 flush 本地 db
以上参数默认都是 no,按需开启,下面以 lazyfree-lazy-eviction 为例,看看 redis 怎么处理 lazy free 逻辑,其他参数的逻辑类似
源码分析
命令处理逻辑
int processCommand(client *c)
是 redis 处理命令的主方法,在真正执行命令前,会有各种检查,包括对 OOM 情况下的处理
int processCommand(client *c) {
// ...
if (server.maxmemory && !server.lua_timedout) {// 设置了 maxmemory 时,如果有必要,尝试释放内存(evict)
int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR;
// ...
// 如果释放内存失败,并且当前将要执行的命令不允许 OOM(一般是写入类命令)if (out_of_memory &&
(c->cmd->flags & CMD_DENYOOM ||
(c->flags & CLIENT_MULTI && c->cmd->proc != execCommand))) {flagTransaction(c);
// 向客户端返回 OOM
addReply(c, shared.oomerr);
return C_OK;
}
}
// ...
/* Exec the command */
if (c->flags & CLIENT_MULTI &&
c->cmd->proc != execCommand && c->cmd->proc != discardCommand &&
c->cmd->proc != multiCommand && c->cmd->proc != watchCommand)
{queueMultiCommand(c);
addReply(c,shared.queued);
} else {call(c,CMD_CALL_FULL);
c->woff = server.master_repl_offset;
if (listLength(server.ready_keys))
handleClientsBlockedOnKeys();}
return C_OK;
内存释放 (淘汰) 逻辑
内存的释放主要在 freeMemoryIfNeededAndSafe()
内进行,如果释放不成功,会返回 C_ERR
。freeMemoryIfNeededAndSafe()
包装了底下的实现函数freeMemoryIfNeeded()
int freeMemoryIfNeeded(void) {
// slave 不管 OOM 的情况
if (server.masterhost && server.repl_slave_ignore_maxmemory) return C_OK;
// ...
// 获取内存用量状态,如果够用,直接返回 ok
// 如果不够用,这个方法会返回总共用了多少内存 mem_reported,至少需要释放多少内存 mem_tofree
// 这个方法很有意思,暗示了其实 redis 是可以用超内存的。即,在当前这个方法调用后,判断内存足够,但是写入了一个很大的 kv,等下一个倒霉蛋来请求的时候发现,内存不够了,这时候才会在下一次请求时触发清理逻辑
if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)
return C_OK;
// 用来记录本次调用释放了多少内存的变量
mem_freed = 0;
// 不需要 evict 的策略下,直接跳到释放失败的逻辑
if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
goto cant_free; /* We need to free memory, but policy forbids. */
// 循环,尝试释放足够大的内存
// 同步释放的情况下,如果要删除的对象很多,或者是很大的 hash/set/zset 等,需要反复循环多次
// 所以一般在监控里看到有大量 key evict 的时候,会跟着看到 QPS 下降,RTT 上升
while (mem_freed < mem_tofree) {
// 根据配置的 maxmemory-policy,拿到一个可以释放掉的 bestkey
// 中间逻辑比较多,可以再开一篇,先略过了
if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) { // 带 LRU/LFU/TTL 的策略
// ...
}
else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM) { // 带 random 的策略
// ...
}
// 最终选中了一个 bestkey
if (bestkey) {if (server.lazyfree_lazy_eviction)
// 如果配置了 lazy free,尝试异步删除(不一定异步,相见下文)dbAsyncDelete(db,keyobj);
else
dbSyncDelete(db,keyobj);
// ...
// 如果是异步删除,需要在循环过程中定期评估后台清理线程是否释放了足够的内存,默认每 16 次循环检查一次
// 可以想到的是,如果 kv 都很小,那么前面的操作并不是异步,lazy free 不生效。如果 kv 都很大,那么几乎所有 kv 都走异步清理,主线程接近空转,如果清理线程不够,那么还是会话相对长的时间的。所以应该是大小混合的场景比较合适 lazy free,需要实验数据验证
if (server.lazyfree_lazy_eviction && !(keys_freed % 16)) {if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
// 如果释放了足够内存,那么可以直接跳出循环了
mem_freed = mem_tofree;
}
}
}
}
cant_free:
// 无法释放内存时,做个好人,本次请求卡就卡吧,检查一下后台清理线程是否还有任务正在清理,等他清理出足够内存之后再退出
while(bioPendingJobsOfType(BIO_LAZY_FREE)) {if (((mem_reported - zmalloc_used_memory()) + mem_freed) >= mem_tofree)
// 这里有点疑问,如果已经能等到足够的内存被释放,为什么不直接返回 C_OK???break;
usleep(1000);
}
return C_ERR;
}
异步删除逻辑
// 用来评估是否需要异步删除的阈值
#define LAZYFREE_THRESHOLD 64
int dbAsyncDelete(redisDb *db, robj *key) {
// 先从 expire 字典中删了这个 entry(释放 expire 字典的 entry 内存,因为后面用不到),不会释放 key/value 本身内存
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
// 从 db 的 key space 中摘掉这个 entry,但是不释放 entry/key/value 的内存
dictEntry *de = dictUnlink(db->dict,key->ptr);
if (de) {robj *val = dictGetVal(de);
// 评估要删除的代价
// 默认 1
// list 对象,取其长度
// 以 hash 格式存储的 set/hash 对象,取其元素个数
// 跳表存储的 zset,取跳表长度
size_t free_effort = lazyfreeGetFreeEffort(val);
// 如果代价大于阈值,扔给后台线程删除
if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {atomicIncr(lazyfree_objects,1);
bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL);
dictSetVal(db->dict,de,NULL);
}
// 释放 entry 内存
}
}
总结
感觉 redis 可以考虑一个功能,给一个参数配置内存高水位,超过高水位之后就可以触发 evict 操作。但是有个问题,可能清理速度赶不上写入速度,怎么合理平衡这两者需要仔细想一下。
另外感叹一下 antirez 代码层面上的架构能力,几年前看过 redis 2.8 的代码,从 2.8 的分支直接切到 5.0 之后,原来阅读的位置并没有偏离主线太远。历经几个大版本的迭代,加了 N 多功能之后,代码主体逻辑依旧没有大改,真的是做到了对修改关闭,对扩展开放。向大佬学习