Redis的定期删除与主线程读写有并发问题吗

Posted by WGrape的博客 on June 12, 2023

文章内容更新请以 WGrape GitHub博客 : Redis的定期删除与主线程读写有并发问题吗 为准

前言

本文原创,著作权归WGrape所有,未经授权,严禁转载

一、背景概要

事情是这样的,昨天一位朋友A在面试时,被问了一个Redis主线程和后台定期删除线程的并发问题,聊天对话大概如下

面试官 :Redis的过期删除策略有哪些?

朋友A :一共有惰性删除和定期删除两种,定期删除是指在后台线程中定期扫描 …

面试官 :那么定期删除和主线程的并发问题,redis是怎么处理的呢 ?

朋友A :emm …

所以问题来了,在Redis的定期删除策略中,是如何进行删除的呢?以及Redis是如何解决和主线程并发的问题呢?

二、源码分析

为了解决这个问题,决定先从源码层面去查下。

1、定期删除的实现

(1) databasesCron()

Key的过期定时删除操作,是在负责Redis后台任务中的databasesCron()函数执行的,如下所示。在这个代码中可以发现,当开启主动过期删除选项,且当前是在主节点上执行时(因为从节点会接收主节点传递的DEL/UNLINK命令),会调用activeExpireCycle()函数执行定期删除策略。

image

(2) activeExpireCycle()

注意这个函数支持 ACTIVE_EXPIRE_CYCLE_FASTACTIVE_EXPIRE_CYCLE_SLOW 两种模式 ACTIVE_EXPIRE_CYCLE_FAST / 在每次进入事件循环之前调用 ACTIVE_EXPIRE_CYCLE_SLOW / 在databasesCron()中调用

在这个函数中,会主动扫描已设置过期时间的键,并检查它们是否已经过期,然后根据需要进行删除操作,删除操作主要在expireScanCallback()函数中执行。

void activeExpireCycle(int type) {
    // ......
    
    // 扫描过期键
    do {

        // 如果没有要过期的键,则处理下一个数据库
        if ((num = dictSize(db->expires)) == 0) {
            db->avg_ttl = 0;
            break;
        }

        // 通过回调函数对过期键进行相应的处理操作
        // 直到达到扫描的过期键数量或达到最大扫描桶数量的限制。
        while (data.sampled < num && checked_buckets < max_buckets) {
            db->expires_cursor = dictScan(db->expires, db->expires_cursor,
                                          expireScanCallback, &data);
            checked_buckets++;
        }
    } while (data.sampled == 0 ||
             (data.expired * 100 / data.sampled) > config_cycle_acceptable_stale);
}

(3) expireScanCallback()

扫到过期的键时,内部会通过expireScanCallback() -> activeExpireCycleTryExpire() -> deleteExpiredKeyAndPropagate()这个调用链完成对应的删除操作。

void expireScanCallback(void *privdata, const dictEntry *const_de) {
    // ... ...

    // privdata就是在activeExpireCycle中扫到的需要删除的Key
    expireScanData *data = privdata;

    // 尝试将键设置为过期状态
    if (activeExpireCycleTryExpire(data->db, de, data->now)) {
        data->expired++;
        
        // 传播DEL命令
        postExecutionUnitOperations();
    }
    
    // ... ...
}

int activeExpireCycleTryExpire(redisDb *db, dictEntry *de, long long now) {
    long long t = dictGetSignedIntegerVal(de);
    if (now > t) {
        sds key = dictGetKey(de);
        robj *keyobj = createStringObject(key,sdslen(key));
        
        // 删除过期的Key并传播DEL命令
        deleteExpiredKeyAndPropagate(db,keyobj);
        
        return 1;
    } else {
        return 0;
    }
}

(4) deleteExpiredKeyAndPropagate()

deleteExpiredKeyAndPropagate()函数内调用dbGenericDelete()函数内部执行删除操作的时候,如果开启了lazy_free机制,会调用freeObjAsync()函数,通过bioCreateLazyFreeJob()方法将这个key的内存回收操作包装为一个任务,塞进异步任务队列,后台的lazy_free线程就会从这个异步队列里面取任务并执行。否则,就会在当前的线程内执行内存释放操作。

void deleteExpiredKeyAndPropagate(redisDb *db, robj *keyobj) {
    mstime_t expire_latency;
    latencyStartMonitor(expire_latency);
    
    // 执行Key删除操作
    dbGenericDelete(db,keyobj,server.lazyfree_lazy_expire,DB_FLAG_KEY_EXPIRED);

    // 在propagateDeletion函数中会传播DEL命令(未开启lazyfree)或UNLINK命令(开启lazyfree)
    // argv[0] = lazy ? shared.unlink : shared.del;
    // argv[1] = key;
    propagateDeletion(db,keyobj,server.lazyfree_lazy_expire);
}

(5) 总结

到这里,定期删除的删除部分已经基本结束了,可以总结出如下图所示的内容。

image

2、定期删除的触发

下面需要找下Redis调用定期删除的时机,也就是调用databasesCron()函数的时机。顺着调用链会发现在serverCron()函数中会调用databasesCron()函数,可是却没有找到调用serverCron()函数的逻辑。

难道是没有调用serverCron()函数的逻辑吗 ?当然不是,我们继续往下看。

(1) 时间事件的回调

在main入口中,会调用initServer()函数完成server初始化相关的工作,其中在initServer()函数内部会完成时间事件和文件事件的回调注册。

到这里会发现,原来serverCron()函数不是直接在某个逻辑中调用的,而是被注册为时间事件的回调函数了!

image

(2) 什么是时间事件

在 Redis 中,时间事件是一种特殊类型的事件,用于执行定时任务或周期性任务,比如定期删除过期的键。时间事件由 Redis 事件循环(event loop)管理,它会根据设定的时间间隔周期性地触发回调函数的执行。

(3) 总结

简单点说,就是Redis内部会有一种叫做事件循环的机制,它会按照一定规则定期触发时间事件的回调,达到触发过期Key删除的效果,下面会主要说下这部分的实现。

image

三、事件循环

事件循环是Redis的核心逻辑,它需要调度两种不同的事件类型,分别是文件事件和时间事件。

1、文件事件

Redis通过IO多路复用技术,实现了高效的命令请求处理 :当多个客户端通过套接字连接Redis时,只有在套接字可以无阻塞地进行读或者写时,服务器才会和这些客户端进行交互。

Redis将这类因为对套接字进行多路复用而产生的事件称为文件事件(file event),文件事件可以分为读事件和写事件两类。

(1) 读/写事件的区别

读事件实现了命令请求的接收,写事件实现了命令结果的返回。

2、时间事件

时间事件记录着那些要在指定时间点运行的事件, 多个时间事件以无序链表的形式保存在服务器状态中。其中每个时间事件主要由三个属性组成:

  • when :以毫秒格式的 UNIX 时间戳为单位,记录了应该在什么时间点执行事件处理函数
  • timeProc :事件处理函数
  • next :指向下一个时间事件,形成链表。

根据 timeProc 函数的返回值,可以将时间事件划分为两类:单次执行和循环执行

(1) 单次执行

如果事件处理函数 timeProc 返回 ae.h/AE_NOMORE ,那么这个事件为单次执行事件

该事件会在指定的时间被处理一次,之后该事件就会被删除,不再执行。

(2) 循环执行

如果事件处理函数 timeProc 返回非 ae.h/AE_NOMORE 的整数值,那么这个事件为循环执行事件

该事件会在指定的时间被处理,之后它会按照事件处理函数的返回值,更新事件的 when 属性,让这个事件在之后的某个时间点再次运行,并以这种方式一直更新并运行下去。

3、事件调度

因为在 Redis 中既有文件事件,又有时间事件,所以Redis的事件循环如何调度这两个事件呢 ?

(1) 调度逻辑

在Redis的事件循环中,这两种事件的调度是如下的合作关系

  1. 一种事件会等待另一种事件执行完毕之后,才开始执行,事件之间不会出现抢占
  2. 事件处理器先处理文件事件(先处理命令请求),再执行时间事件(再调用 serverCron
  3. 文件事件的等待时间,由距离到达时间最短的时间事件决定。

(2) 调度延迟

基于上述调度逻辑可知,际处理时间事件的时间,通常会比时间事件所预定的时间要晚,至于延迟的时间有多长,取决于时间事件执行之前,执行文件事件所消耗的时间。

(3) 总结

基于已知理解,Redis内部的整个事件循环逻辑大概如下图所示。

image

  • 事件循环 :先读取文件事件,后读取时间事件
  • 单线程处理 :Redis内部整个命令处理模块是单线程的
  • 多线程处理 :除了和Redis核心命令逻辑相关的部分,其他的像网络IO(6.0版本以上)、lazyFree等都是多线程的处理

四、回到问题

现在让我们再次回到开始的问题 “在Redis的定期删除策略中,是如何进行删除的呢?以及Redis是如何解决和主线程并发的问题呢?”

1、如何进行删除

答 :redis的activeExpireCycle()函数扫描到过期的Key时,首先,无论是开启了lazyFree机制,都会在数据库字典中把数据键进行解绑,这样客户端就访问不到Key了。

然而,对于对象内存的真正释放,和当前的lazyFree机制有关。

  • 未开启LazyFree :直接在当前线程中释放对象的内存
  • 开启LazyFree :把释放对象的操作放在后台任务中,由后台的LazyFree线程进行真正的内存释放

也就是说,如果没有开启LazyFree机制,即使是定期删除策略扫描到了需要删除的Key,也会在当前主线程中执行,也会有主线程阻塞的风险。

2、定时过期策略与主线程的并发问题

答 :不存在并发问题。因为Redis是基于Reactor的事件循环模型,主线程的所有操作都来自文件事件或时间事件。Redis的事件循环的调度策略决定了,每次都会按顺序先执行文件事件,然后再执行时间事件,这两个操作不会竞争执行,所以不存在并发问题。

五、总结

本文主要通过定期删除策略开始切入,由点到面的介绍了Redis的事件循环、事件模型、主线程它们之间的关系,解答了定期删除策略和主线程并发问题的疑问。