文章内容更新请以 WGrape GitHub博客 : Redis的定期删除与主线程读写有并发问题吗 为准
前言
本文原创,著作权归WGrape所有,未经授权,严禁转载
一、背景概要
事情是这样的,昨天一位朋友A在面试时,被问了一个Redis主线程和后台定期删除线程的并发问题,聊天对话大概如下
面试官 :Redis的过期删除策略有哪些?
朋友A :一共有惰性删除和定期删除两种,定期删除是指在后台线程中定期扫描 …
面试官 :那么定期删除和主线程的并发问题,redis是怎么处理的呢 ?
朋友A :emm …
所以问题来了,在Redis的定期删除策略中,是如何进行删除的呢?以及Redis是如何解决和主线程并发的问题呢?
二、源码分析
为了解决这个问题,决定先从源码层面去查下。
1、定期删除的实现
(1) databasesCron()
Key的过期定时删除操作,是在负责Redis后台任务中的databasesCron()
函数执行的,如下所示。在这个代码中可以发现,当开启主动过期删除选项,且当前是在主节点上执行时(因为从节点会接收主节点传递的DEL/UNLINK命令),会调用activeExpireCycle()
函数执行定期删除策略。
(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) 总结
到这里,定期删除的删除部分已经基本结束了,可以总结出如下图所示的内容。
2、定期删除的触发
下面需要找下Redis调用定期删除的时机,也就是调用databasesCron()
函数的时机。顺着调用链会发现在serverCron()
函数中会调用databasesCron()
函数,可是却没有找到调用serverCron()
函数的逻辑。
难道是没有调用serverCron()
函数的逻辑吗 ?当然不是,我们继续往下看。
(1) 时间事件的回调
在main入口中,会调用initServer()
函数完成server初始化相关的工作,其中在initServer()
函数内部会完成时间事件和文件事件的回调注册。
到这里会发现,原来serverCron()
函数不是直接在某个逻辑中调用的,而是被注册为时间事件的回调函数了!
(2) 什么是时间事件
在 Redis 中,时间事件是一种特殊类型的事件,用于执行定时任务或周期性任务,比如定期删除过期的键。时间事件由 Redis 事件循环(event loop)管理,它会根据设定的时间间隔周期性地触发回调函数的执行。
(3) 总结
简单点说,就是Redis内部会有一种叫做事件循环的机制,它会按照一定规则定期触发时间事件的回调,达到触发过期Key删除的效果,下面会主要说下这部分的实现。
三、事件循环
事件循环是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的事件循环中,这两种事件的调度是如下的合作关系
- 一种事件会等待另一种事件执行完毕之后,才开始执行,事件之间不会出现抢占
- 事件处理器先处理文件事件(先处理命令请求),再执行时间事件(再调用
serverCron
) - 文件事件的等待时间,由距离到达时间最短的时间事件决定。
(2) 调度延迟
基于上述调度逻辑可知,际处理时间事件的时间,通常会比时间事件所预定的时间要晚,至于延迟的时间有多长,取决于时间事件执行之前,执行文件事件所消耗的时间。
(3) 总结
基于已知理解,Redis内部的整个事件循环逻辑大概如下图所示。
- 事件循环 :先读取文件事件,后读取时间事件
- 单线程处理 :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的事件循环、事件模型、主线程它们之间的关系,解答了定期删除策略和主线程并发问题的疑问。