如履薄冰 —— Redis懒惰删除的巨大牺牲

【redis】redis 懒惰删除的巨大牺牲 摘自:仅供自己学习,方便查找,建议看原文。前面讲了 Redis懒惰删除的特性,它是使用异步线程对已删除的节点进行内 存回收。但讲得还不够深入,所以本节要对异步线程逻辑处理的细节进行分析, 看看 Antirez 是如何实现异步线程处理的。异步线程在 Redis 内部有一个特别的名称,就是“BIO”,全称是 Background IO,意思是在。不过内存回收本身并不是什么 IO 操作,只 是 CPU 的计算消耗可能会比较大而已。 阅读详情

之前我们介绍了Redis懒惰删除的特性,它是使用异步线程对已经删除的节点进行延后内存回收。但是还不够深入,所以本节我们要对异步线程逻辑处理的细节进行分析,看看Antirez是如何实现异步线程处理的。异步线程在Redis内部有一个特别的名称,它就是BIO,全称是Background IO,意思是在背后默默干活的IO线程。不过内存回收本身并不是什么IO操作,只是CPU的计算消耗可能会比较大而已。

640?wx_fmt=png

懒惰删除的最初实现不是异步线程

Antirez实现懒惰删除时,它并不是一开始就想到了异步线程。最初的尝试是使用类似于字典渐进式搬迁那样来实现渐进式删除回收,在主线程里。比如对于一个非常大的字典来说,懒惰删除是采用类似于scan操作的方法,通过遍历第一维数组来逐步删除回收第二维链表的内容,等到所有链表都回收完了,再一次性回收第一维数组。这样也可以达到删除大对象时不阻塞主线程的效果。

但是说起来容易做起来却很难,渐进式回收需要仔细控制回收频率,它不能回收的太猛,这会导致CPU资源占用过多,也不能回收的蜗牛慢,内存回收不及时可能导致内存持续增长。Antirez需要采用合适的自适应算法来控制回收频率。他首先想到的是检测内存增长的趋势是增长(+1)还是下降(-1)来渐进式调整回收频率系数,这样的自适应算法实现也很简单。但是测试后发现在服务繁忙的时候,QPS会下降到正常情况下65%的水平,这点非常致命。

所以Antirez才使用了如今使用的方案——异步线程,这套方案就简单多了,释放内存不用为每种数据结构适配一套渐进式释放策略,也不用搞个自适应算法来仔细控制回收频率。将对象从全局字典中摘掉,然后往队列里一扔,主线程就去干别的去了。异步线程从队列里取出对象来,直接走正常的同步释放逻辑就可以了。

不过使用异步线程也是有代价的,主线程和异步线程之间在内存回收器(jemalloc)的使用上存在竞争。这点竞争消耗是可以忽略不计的,因为Redis的主线程在内存的分配与回收上花的时间相对整体运算时间而言是极少的。

异步线程方案其实也相当复杂

刚才上面说异步线程方案很简单,为什么这里又说它很复杂呢?因为有一点我们没有想到,这点非常可怕,严重阻碍了异步线程方案的改造。那就是Redis的内部对象有共享机制。

比如集合的并集操作sunionstore用来将多个集合合并成一个新集合

> sadd src1 value1 value2 value3
(integer) 3
> sadd src2 value3 value4 value5
(integer) 3
> sunionstore dest src1 src2
(integer) 5
> smembers dest
1) "value2"
2) "value3"
3) "value1"
4) "value4"
5) "value5"

我们看到新的集合包含了旧集合的所有元素。但是这里有一个我们没看到的trick。那就是底层的字符串对象被共享了。

640?wx_fmt=png

为什么对象共享是懒惰删除的巨大障碍呢?因为懒惰删除相当于彻底砍掉某个树枝,将它扔到异步删除队列里去。注意这里必须是彻底删除,而不能藕断丝连。如果底层对象是共享的,那就做不到彻底删除。

640?wx_fmt=png

所以antirez为了支持懒惰删除,将对象共享机制彻底抛弃,它将这种对象结构称为「share-nothing」,也就是无共享设计。但是甩掉对象共享谈何容易!这种对象共享机制散落在源代码的各个角落,牵一发而动全身,改起来犹如在布满地雷的道路上小心翼翼地行走。

不过antirez还是决心改了,他将这种改动描述为「绝望而疯狂」,可见改动之大之深之险,前后花了好几周的时间才改完。不过效果也是很明显的,对象的删除操作再也不会导致主线程卡顿了。

异步删除的实现

主线程需要将删除任务传递给异步线程,它是通过一个普通的双向链表来传递的。因为链表需要支持多线程并发操作,所以它需要有锁来保护。

执行懒惰删除时,redis将删除操作的相关参数封装成一个bio_job结构,然后追加到链表尾部。异步线程通过遍历链表摘取job元素来挨个执行异步任务。

struct bio_job {
    time_t time;  // 时间字段暂时没有使用,应该是预留的
    void *arg1, *arg2, *arg3;
};

我们注意到这个job结构有三个参数,为什么删除对象需要三个参数呢?我们继续看代码

    /* What we free changes depending on what arguments are set:
     * arg1 -> free the object at pointer.
     * arg2 & arg3 -> free two dictionaries (a Redis DB).
     * only arg3 -> free the skiplist. */
    if (job->arg1)
        // 释放一个普通对象,string/set/zset/hash等等,用于普通对象的异步删除
        lazyfreeFreeObjectFromBioThread(job->arg1);
    else if (job->arg2 && job->arg3)
        // 释放全局redisDb对象的dict字典和expires字典,用于flushdb
        lazyfreeFreeDatabaseFromBioThread(job->arg2,job->arg3);
    else if (job->arg3)
        // 释放Cluster的slots_to_keys对象,参见源码篇的「基数树」小节
        lazyfreeFreeSlotsMapFromBioThread(job->arg3);

可以看到通过组合这三个参数可以实现不同结构的释放逻辑。接下来我们继续追踪普通对象的异步删除lazyfreeFreeObjectFromBioThread是如何进行的,请仔细阅读代码注释

void lazyfreeFreeObjectFromBioThread(robj *o) {
    decrRefCount(o); // 降低对象的引用计数,如果为零,就释放
    atomicDecr(lazyfree_objects,1); // lazyfree_objects为待释放对象的数量,用于统计
}

// 减少引用计数
void decrRefCount(robj *o) {
    if (o->refcount == 1) {
        // 该释放对象了
        switch(o->type) {
        case OBJ_STRING: freeStringObject(o); break;
        case OBJ_LIST: freeListObject(o); break;
        case OBJ_SET: freeSetObject(o); break;
        case OBJ_ZSET: freeZsetObject(o); break;
        case OBJ_HASH: freeHashObject(o); break;  // 释放hash对象,继续追踪
        case OBJ_MODULE: freeModuleObject(o); break;
        case OBJ_STREAM: freeStreamObject(o); break;
        default: serverPanic("Unknown object type"); break;
        }
        zfree(o);
    } else {
        if (o->refcount <= 0) serverPanic("decrRefCount against refcount <= 0");
        if (o->refcount != OBJ_SHARED_REFCOUNT) o->refcount--; // 引用计数减1
    }
}

// 释放hash对象
void freeHashObject(robj *o) {
    switch (o->encoding) {
    case OBJ_ENCODING_HT:
        // 释放字典,我们继续追踪
        dictRelease((dict*) o->ptr);
        break;
    case OBJ_ENCODING_ZIPLIST:
        // 如果是压缩列表可以直接释放
        // 因为压缩列表是一整块字节数组
        zfree(o->ptr);
        break;
    default:
        serverPanic("Unknown hash encoding type");
        break;
    }
}

// 释放字典,如果字典正在迁移中,ht[0]和ht[1]分别存储旧字典和新字典
void dictRelease(dict *d)
{
    _dictClear(d,&d->ht[0],NULL); // 继续追踪
    _dictClear(d,&d->ht[1],NULL);
    zfree(d);
}

// 这里要释放hashtable了
// 需要遍历第一维数组,然后继续遍历第二维链表,双重循环
int _dictClear(dict *d, dictht *ht, void(callback)(void *)) {
    unsigned long i;

    /* Free all the elements */
    for (i = 0; i < ht->size && ht->used > 0; i++) {
        dictEntry *he, *nextHe;

        if (callback && (i & 65535) == 0) callback(d->privdata);

        if ((he = ht->table[i]) == NULL) continue;
        while(he) {
            nextHe = he->next;
            dictFreeKey(d, he); // 先释放key
            dictFreeVal(d, he); // 再释放value
            zfree(he); // 最后释放entry
            ht->used--;
            he = nextHe;
        }
    }
    /* Free the table and the allocated cache structure */
    zfree(ht->table); // 可以回收第一维数组了
    /* Re-initialize the table */
    _dictReset(ht);
    return DICT_OK; /* never fails */
}

这些代码散落在多个不同的文件,我将它们凑到了一块便于读者阅读。从代码中我们可以看到释放一个对象要深度调用一系列函数,每种对象都有它独特的内存回收逻辑。

队列安全

前面提到任务队列是一个不安全的双向链表,需要使用锁来保护它。当主线程将任务追加到队列之前它需要加锁,追加完毕后,再释放锁,还需要唤醒异步线程,如果它在休眠的话。

void bioCreateBackgroundJob(int type, void *arg1, void *arg2, void *arg3) {
    struct bio_job *job = zmalloc(sizeof(*job));

    job->time = time(NULL);
    job->arg1 = arg1;
    job->arg2 = arg2;
    job->arg3 = arg3;
    pthread_mutex_lock(&bio_mutex[type]); // 加锁
    listAddNodeTail(bio_jobs[type],job); // 追加任务
    bio_pending[type]++; // 计数
    pthread_cond_signal(&bio_newjob_cond[type]); // 唤醒异步线程
    pthread_mutex_unlock(&bio_mutex[type]); // 释放锁
}

异步线程需要对任务队列进行轮训处理,依次从链表表头摘取元素逐个处理。摘取元素的时候也需要加锁,摘出来之后再解锁。如果一个元素的没有,它需要等待,直到主线程来唤醒它继续工作。

// 异步线程执行逻辑
void *bioProcessBackgroundJobs(void *arg) {
...
    pthread_mutex_lock(&bio_mutex[type]); // 先加锁
    ...
    // 循环处理
    while(1) {
        listNode *ln;

        /* The loop always starts with the lock hold. */
        if (listLength(bio_jobs[type]) == 0) {
            // 对列空,那就睡觉吧
            pthread_cond_wait(&bio_newjob_cond[type],&bio_mutex[type]);
            continue;
        }
        /* Pop the job from the queue. */
        ln = listFirst(bio_jobs[type]); // 获取队列头元素
        job = ln->value;
        /* It is now possible to unlock the background system as we know have
         * a stand alone job structure to process.*/
        pthread_mutex_unlock(&bio_mutex[type]); // 释放锁

        // 这里是处理过程,为了省纸,就略去了
        ...

        // 释放任务对象
        zfree(job);

        ...

        // 再次加锁继续处理下一个元素
        pthread_mutex_lock(&bio_mutex[type]);
        // 因为任务已经处理完了,可以放心从链表中删除节点了
        listDelNode(bio_jobs[type],ln);
        bio_pending[type]--; // 计数减1
    }

研究完这些加锁解锁的代码后,我开始有点当心主线程的性能。我们都知道加锁解锁是一个相对比较耗时的操作,尤其是悲观锁最为耗时。如果删除很频繁,主线程岂不是要频繁加锁解锁。所以这里肯定还有优化空间,Java的ConcurrentLinkQueue就没有使用这样粗粒度的悲观锁,它优先使用cas来控制并发。

思考

  1. Redis还有其它地方用到了对象共享机制么?

  2. Java的ConcurrentLinkQueue具体是如何实现的?

640?wx_fmt=png

本文节选之《Redis深度历险》,点击「阅读原文」,现在就开始学习《Redis深度历险》

传热学专业英语术语知识图谱构建与工程应用 传热学专业英语术语不是孤立词汇,而是嵌入物理机制、量纲体系与工程场景的语义网络。理解‘convection’‘Nusselt number’等核心概念,需厘清其定义式、适用前提及国际标准符号(如ISO 80000-6),避免将‘heat transfer coefficient’与‘overall heat transfer coefficient’混用或误标特征长度。这类术语错误本质是物理建模偏差,直接影响COMSOL/ANSYS仿真设置、SCI论文审稿通过率及国际会议答辩严谨性。本文聚焦传热方向研究生与 阅读详情

相关推荐

懒惰删除.cpp

已通过编译,可运行

Redis的懒惰删除

Redis的懒惰删除 1.Redis中的懒惰删除   Redis内部实际上并不只有一个主线程,还有几个异步线程专门用来处理一些耗时的操作。异步线程在Redis内部被称为“BIO”,全称是Background IO,意思是在背后默默干活的IO线程。 (1)del指令   Redis的删除指令del会直接释放对象的内存,多数情况下,这个指令非常快。但是如果被删除的key是一个非常大的对象,那么删除操作就会导致单线程卡顿。   为了解决这个问题,Redis4.0版本引入了unlink指令,它能对删除指令进行懒处理

loser与你 1357

S32DS IDE调试技巧:如何用条件断点精准捕获汽车电子MCU的硬件状态变化

本文详细介绍了S32DS IDE中条件断点功能在汽车电子MCU开发中的应用技巧。通过精准捕获硬件状态变化,工程师可以高效调试外设寄存器异常、时序敏感型交互等复杂场景,大幅提升汽车电子系统的开发效率。文章还提供了多种实战案例和性能优化建议,帮助开发者充分利用S32DS的调试功能。

CAT789的专栏 209

Redis的懒惰删除策略是如何工作的?

如果发现有键过期了,就会立即将它们删除。当 Redis 中的键设置了过期时间,并且这个时间已经过去时,键并不会立即从内存中移除。相反,Redis 会在下次尝试读取或写入该键时检查其是否已过期,如果已过期,则在实际执行命令前将其删除。如果过期了,那么 Redis 会删除这个键并返回空值或者错误信息给客户端,具体取决于执行的是什么类型的命令。为了进一步优化内存管理,Redis 还引入了其他机制,如 LRU(最近最少使用)淘汰算法和最大内存限制等,这些可以帮助 Redis 在面临内存压力时更有效地管理数据。

qq_33240556的博客 489

在macOS上使用GitHub的超详细指南

GitHub:免费的远程仓库,可用来托管自己的开源项目。它利用了Git强大的克隆和分支功能,让我们参与别人的开源项目和让别人参与自己的开源项目变得简单高效。

Mars的博客 3392

SAP-HR 模块 PPOSE 的详细信息标签页增强

步骤1:T-CODE : SE11 创建一个名为 HRI9106 的结构 命名:HRI + 91 + 两位数字。人事管理 - 组织管理 - 集成新信息类型 - 将信息类型添加为明细范围中的标签页。步骤6:SE51 查看 7000屏幕,修改屏幕属性 让其更美观。步骤3:T-CODE : SM30维护 T777I 表。步骤5:PPCI 新建 7000屏幕用于显示。标签页 IT 开头 + 信息类型 9106。步骤2:T-CODE : PPCI。步骤4:T-CODE : SPRO。

常回来看看呀!! 1630

Redis-懒惰删除内部实现

        异步线程在Redis内部有个特别的名称,叫作BIO(background IO),意思是后台运行的io线程,不过内存回收本身不是io操作,只是cpu计算消耗比较大。 1、Redis的内部对象共享机制会阻碍异步线程的改造,原因是懒惰删除是要测底删除某个对象,不能藕断丝连,如果底层数据的共享的,就不能做到彻底删除了效果。所以为了支持懒惰删除,只能将对象共享机制彻底抛弃。 2、异步删...

哆啦A梦 1561

【VSCode Python无法格式化代码】

提示:需要Install Formatter…安装插件autopep8,点击install。出现Format Document。

AIFRZ的博客 1万+

二叉查找树——懒惰删除

由于学习树这一章节,查阅了很多资料,网上书上代码上,自己的别人的已然分不开了,如有侵权请留言,如同意我记录于此,也请留言区留下大名,必然感谢! 此篇参考 ----》》》传送门 懒惰删除(lazy deletion): 当一个元素要被删除时,它仍然留在树中,而是只作了个被删除的标记。 ·这特别是在有重复项时很流行,因为此时记录出现频率数的域可以减1; ·如果树中的实际节点数和“

Immorwave 2881

STM32 HAL库ADC+DMA配置详解:从CubeMX到实战调试

在嵌入式系统开发中,模数转换器(ADC)是实现传感器数据采集、信号处理等功能的基石。其工作原理是将连续的模拟信号转换为离散的数字量,为微控制器(MCU)提供可处理的数字信息。为了提升系统效率,直接存储器访问(DMA)技术被引入,它作为独立于CPU的数据搬运工,能在不占用CPU资源的情况下,自动完成外设与内存间的数据传输,从而解放CPU以执行更复杂的控制算法或通信任务。这种ADC与DMA的协同工作模式,对于实现高效、实时的数据流处理具有重要价值,广泛应用于电机控制、环境监测、音频处理等场景。本文聚焦于STM3

weixin_30698297的博客 408

OpenCv案例(七): 基于OpenCvSharp计算图像的清晰度(自动对焦)

自动对焦,其实是对相机成像的清晰值得计算,若对焦不清晰,成像的清晰度低,视觉效果模糊。若是在工业检测行业,对焦不准确,可能会造成严重后果;对焦准确的图像,其清晰度高,对比度鲜明,层次明显。

qq_37835727的博客 7115

如履薄冰:Redis 懒惰删除的巨大牺牲

作者 | 老钱责编 | 胡巍巍大家都知道 Redis 是单线程的,但是 Redis 4.0 增加了懒惰删除功能,懒惰删除需要使用异步线程对已删除的节点进行内存回收,这意味...

程序人生的博客 2870

第3.4章 机器人第三方库实战总结(四):G2O优化库从入门到实战(附SLAM实例代码)

自定义边同样是满足复杂应用需求的重要手段。自定义边时,需要明确边所连接的顶点类型以及边所表示的误差项的计算方式。假设我们要定义一条边,用于约束两个上述自定义顶点之间的相对位姿和传感器偏移量关系。我们可以这样定义边类:public:// 计算误差// 计算雅可比矩阵// 读盘和存盘函数,这里暂不实现。

行知SLAM 362

码洞原创深度技术文章大全 —— 高端面试必备

《快学 Go 语言》第 11 课 —— 协程《快学 Go 语言》第 10 课 —— 错误与异常《快学 Go 语言》第 9 课 —— 接口《快学 Go 语言》第 8 课 —...

codehole_的博客 293

第02章:HSA-Runtime架构概览

摘要: 本章深入解析HSA Runtime的三层架构设计,包括公共API层(C语言接口)、核心实现层(C++业务逻辑)和驱动抽象层(硬件交互)。重点介绍了Runtime的核心组件(Agent、Queue、Signal、Memory)及其关系,以及初始化流程、配置选项和调试功能。通过学习,读者将掌握HSA Runtime的整体架构,为后续模块学习奠定基础。

图形/计算GPU的驱动与虚拟化技术分享园地 2928

假人有限元模型

假人模型,车辆碰撞假人有限元模型,适合做乘员安全的研究者利用

Redis - 扩展 - 懒惰删除

Redis内部并不是只有一个主线程,他还有几个异步线程专门用来处理一些耗时的操作。 1、Redis使用懒惰删除 删除指令del会直接释放对象的内存,如果删除的key是一个非常的对象,那么删除操作旧会导致单线程卡顿,为了解决卡顿的问题,Redis在4.0中引入unlink指令,对删除指令懒处理,让后台线程来处理。 2、flush Redis提供了flushdb和flushall指令,来清空数...

哆啦A梦 1763

复分析 阿尔福斯(Ahlfors) 第三版答案 英文

答案分为十章,有部分习题没有答案,全英文复分析阿尔福斯(Ahlfors) 第三版答案

二叉树懒惰删除

在读《数据结构与算法描述》的时候,讲到二叉树的删除的时候,提到了给节点增加一个删除标记,删除的时候并不删除节点,只是修改这个标记的值,书中没有讲,只是一笔带过,没有明白为什么增加了一个标记就不管了,那我查询的时候不就可能把已经删除的数据给查出来了吗!?上网查找,发现原来是我理解错了,因为这个标记的存在,我们可以对我们的insert,remove,contains方法都修改一下,来提高算法的效率,看...

bbsdsz的博客 616

lwip-2.0.3

lwip是瑞典计算机科学院(SICS)的Adam Dunkels 开发的一个小型开源的TCP/IP协议栈。实现的重点是在保持TCP协议主要功能的基础上减少对RAM 的占用。

TMS_WebGMaps_1.9.8.0_Full_Source

TMS_WebGMaps_1.9.8.0_Full_Source 全源码 delphi cbuilder

【Redis源码分析】Redis 懒删除(lazy free)简史

张仕华 提一个问题:Redis是单进程单线程模式吗?   下图为Redis5.0启动之后的效果。LWP为线程ID,NLWP为线程数量。可以看到,5.0的redis server共有四个线程,一个主线程48684,三个bio(background IO,后台io任务)线程,三个后台线程分别执行不同的io任务,我们重点考察删除一个key时的i...

weixin_33872566的博客 302

懒惰删除 概念

在计算机科学中,懒惰删除(英文:lazy deletion)指的是从一个散列表(也称哈希表)中删除元素的一种方法。在这个方法中,删除仅仅是指标记一个元素被删除,而不是整个清除它。被删除的位点在插入时被当作空元素,在搜索之时被当作已占据。 示例 // javascript var myarr=["frist","2nd","3rd","4th"]; delete myarr[2]; // 删除第3个 "3rd" console.info(myarr); // 输出 0: "frist" 1: "2nd"

技术小站 2102

Redis对象及redisObject源码解析

Redis对象及redisObject源码解析

weixin_46935110的博客 1346

redis中增删改查普通操作和使用管道pipeline的效率区别

redis中增删改查普通操作和使用管道pipeline的效率区别

IT之一小佬的博客 720

采取懒惰删除的左式堆

《数据结构与算法分析——c语言描述》 练习6.21 答案 leftheap.h #ifndef _LeftHeap_H #define _LeftHeap_H typedef int ElementType; struct TreeNode; typedef struct TreeNode *PriorityQueue; PriorityQueue initialize(voi

wwj_bupt 1037

【Redis】懒惰删除

一直以来我们认为 Redis 是单线程的,单线程为 Redis 带来了代码的简洁性和丰富多样的数据结构。不过Redis内部实际上并不是只有一个主线程,它还有几个异步线程专门用来处理一些耗时的操作。 Redis 为什么要懒惰删除(lazy free)? 删除指令del会直接释放对象的内存,大部分情况下,这个指令非常快,没有明显延迟。不过如果删除的 key 是一个非常大的对象,比如一个包含了千...

Franco的博客 2064

08----------懒惰删除

一直以来我们认为 Redis 是单线程的,单线程为 Redis 带来了代码的简洁性和丰富多样的数据结构。不过Redis内部实际上并不是只有一个主线程,它还有几个异步线程专门用来处理一些耗时的操作。 Redis 为什么要懒惰删除(lazy free)? 删除指令 del 会直接释放对象的内存,大部分情况下,这个指令非常快,没有明显延迟。不过如果删除的 key 是一个非常大的对象,比如一个包含了千...

冷炎嗄的博客 882
上一篇: 通俗易懂的Redis数据结构基础教程
下一篇: 《Redis深度历险》《深入理解RPC》答读者问
codehole_
博客等级 码龄7年 34粉丝 132原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值