Skip to content

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

Description

@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_FAST 和 ACTIVE_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的事件循环、事件模型、主线程它们之间的关系,解答了定期删除策略和主线程并发问题的疑问。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    已发布表示本文章已发布

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions