从实现角度看redis lazy free的使用和注意事项

众所周知,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的功能在使用中有几个注意事项(以下为个人观点,有误的地方请评论区交流):

  1. lazy free不是在遇到快OOM的时候直接执行命令,放后台释放内存,而是也需要block一段时间去获得足够的内存来执行命令
  2. lazy free不适合kv的平均大小太小或太大的场景,大小均衡的场景下性价比比较高(当然,可以根据业务场景调整源码里的宏,重新编译一个版本)
  3. redis短期内其实是可以略微超出一点内存上限的,因为前一条命令没检测到内存超标(其实快超了)的情况下,是可以写入一个很大的kv的,当后续命令进来之后会发现内存不够了,交给后续命令执行释放内存操作
  4. 如果业务能预估到可能会有集中的大量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_ERRfreeMemoryIfNeededAndSafe()包装了底下的实现函数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多功能之后,代码主体逻辑依旧没有大改,真的是做到了对修改关闭,对扩展开放。向大佬学习

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354

推荐阅读更多精彩内容

  • Redis4.0新增了非常实用的lazy free特性,从根本上解决Big Key(主要指定元素较多集合类型Key...
    RogerZhuo阅读 9,655评论 0 5
  • 1.1 资料 ,最好的入门小册子,可以先于一切文档之前看,免费。 作者Antirez的博客,Antirez维护的R...
    JefferyLcm阅读 17,056评论 1 51
  • Redis的那些最常见面试问题 随笔:经过长达一周的奔波和面试,电话面试,回首今天终于成功的入职了,总共面试了大概...
    伐无道阅读 411评论 0 1
  • 1.什么是redis? Redis 是一个基于内存的高性能key-value数据库。 支持string...
    付存阅读 470评论 0 1
  • 昨天窗帘掉下来了一半,本想让学校修理,可是也想拆下来洗洗,就随口一说,薛立璇说她可以带回家洗。没想到,课间那几个积...
    宝宝语录阅读 266评论 0 0