过期key处理机制
redisDb结构体
Redis本身是一个典型的key-value内存存储数据库,所有的key、value都以robj形式保存在Dict结构中。Redis的database结构体就是用来存放所有键值对的,结构体定义如下:
1 | typedef struct redisDb { |
可以发现结构体内实际上有很多dict,其中blocking_keys、ready_keys、watched_keys是用来实现阻塞队列、监视等功能的。重点需要关注的dict有两个:
dict
用来记录键值对,dictEntry里面存的是key和value的robj指针;expires
用来记录过期时间,如果有设置了过期时间的key,dictEntry里面存的是key->TTL;
redisDb结构图如下:
过期删除策略
惰性删除
TTL到期后就并不是立刻删除,而是在访问一个key的时候,检查该key的存活时间,如果已经过期才执行删除。这种方式避免了定期扫描整个数据集来查找过期键,从而显著减少了 CPU 的使用率。
1 | // 查找一个key执行写操作 |
然而,惰性删除也有其局限性,例如可能导致过期键长时间占用内存,或者在高并发场景下增加响应时间。因此,Redis 通常会结合定期删除(Active Expiration)策略来平衡内存使用和性能。
周期删除
在分析源码之前,需要先对Redis的事件循环机制和lazy_free机制有一定了解。
事件循环
Redis 是一个基于事件驱动的高性能键值对存储数据库,其事件循环机制是核心组成部分,用于高效处理网络 I/O 事件和定时事件。Redis 的事件循环机制主要处理两类事件:
- 文件事件(File Events):用于处理网络 I/O,如客户端的连接请求、读写操作等。Redis 使用 I/O 多路复用技术来监听多个文件描述符(如套接字),当某个文件描述符上有可读或可写事件发生时,就会触发相应的处理程序。
- 时间事件(Time Events):用于处理定时任务,如定期过期键检查、统计信息更新等。时间事件可以分为定时事件(在指定时间点执行)和周期性事件(每隔一段时间执行一次)。
在Redis的事件循环中,这两种事件的调度是如下的关系:
- 一种事件会等待另一种事件执行完毕之后,才开始执行,事件之间不会出现抢占。
- 事件处理器先处理文件事件(先处理命令请求),再执行时间事件。
- 文件事件的等待时间,由距离到达时间最短的时间事件决定。
基于上述调度逻辑可知,处理时间事件的时间通常会比时间事件所预定的时间要晚,至于延迟的时间有多长,取决于时间事件执行之前,执行文件事件所消耗的时间。循环的基本流程可以表述为:
1 | while (true) { |
Lazy_free
Redis的后台线程有三类,其中一类就是负责进行lazy_free的。lazy_free是 Redis 的一种删除机制,其核心思想是避免在删除大对象时阻塞主线程,只在主线程解除与key的连接,把耗时的内存释放操作放到后台线程去执行,以此来提升 Redis 的性能和响应能力。
SLOW模式与FAST模式
周期删除即周期性地抽样部分过期的key,然后执行删除。执行周期有两种:
- Redis服务初始化函数
initServer()
中设置定时任务,按照server.hz的频率来执行过期key清理,模式为SLOW; - Redis的每个事件循环前,会调用
beforeSleep()
函数,执行过期key清理,模式为FAST;
SLOW模式实际上是在初始化Server时注册了一个时间事件,参见以下代码:
1 | // server.c |
执行流程概述:
- 执行频率受server.hz影响,默认为10,即每秒执行10次,每个执行周期100ms;
- 执行清理耗时不超过一次执行周期的25%.默认slow模式耗时不超过25ms;
- 逐个遍历db,逐个遍历db中的bucket即dictEntry数组,抽取指定个数的key判断是否过期;
- 如果没达到时间上限并且过期key比例大于设定的比例,再进行一次抽样,否则结束;
FAST模式则是在beforeSleep中执行,执行时机为每次事件循环开始时,执行文件事件和时间事件前。参见以下代码:
1 | void beforeSleep(struct aeEventLoop *eventLoop){ |
执行流程概述:
- 执行频率受beforeSleep()调用频率影响,两次FAST模式间隔不低于2ms;
- 执行清理耗时不超过1ms;
- 逐个遍历db,逐个遍历db中的bucket,抽取指定个key判断是否过期;
- 如果没达到时间上限并且过期key比例大于大于设定的比例,再进行一次抽样,否则结束;
周期删除源码分析
SLOW和FAST两种模式最终都调用了activeExpireCycle()函数,在这个函数中,会主动扫描已设置过期时间的键,并检查它们是否已经过期,然后根据需要进行删除操作,删除操作主要在expireScanCallback()
函数中执行:
1 | void activeExpireCycle(int type) { |
扫到过期的键时,内部会通过expireScanCallback()
-> activeExpireCycleTryExpire()
-> deleteExpiredKeyAndPropagate()
这个调用链完成对应的删除操作:
1 | void expireScanCallback(void *privdata, const dictEntry *const_de) { |
在deleteExpiredKeyAndPropagate()
函数内调用dbGenericDelete()
函数内部执行删除操作的时候,如果开启了lazy_free机制,会调用freeObjAsync()
函数,通过bioCreateLazyFreeJob()
方法将这个key的内存回收操作包装为一个任务,塞进异步任务队列,后台的lazy_free线程就会从这个异步队列里面取任务并释放内存。否则,就会在当前的线程内执行内存释放操作。
1 | void deleteExpiredKeyAndPropagate(redisDb *db, robj *keyobj) { |
结合源码、事件循环机制和lazy_free机制,可以初步画出Redis的事件循环逻辑和周期删除逻辑如下图所示:
注意,该图实际上只体现了SLOW模式删除,没有体现FAST模式删除。FAST模式删除并不依赖时间事件,它是在Event Loop的最开始,即文件事件之前执行的。
周期删除是否会与主线程产生并发问题
答案是不会,由于Redis的事件循环模型,主线程的所有操作都来自文件事件或时间事件。Redis的事件循环的调度策略决定了,每次都会按顺序,首先执行beforeSleep中的FAST模式删除,再执行文件事件,最后执行时间事件中的SLOW模式删除,操作间不会竞争执行。并且异步lazy_free
之前一定会在主线程实现key的解绑,所以不存在并发问题。
内存淘汰策略
Redis配置文件中有一项配置为maxmemory
,即可以使用的最大内存上限。内存淘汰是指当Redis内存使用达到设置的上限时,主动挑选部分key删除以释放更多内存的流程。Redis会在处理客户端命令的方法processCommand()
中尝试做内存淘汰(即执行任何非LUA脚本命令之前进行内存淘汰):
1 | int processCommand(client *c) { |
Redis支持8种不同策略来选择要删除的key,可以在配置文件中选择:
- noeviction: 不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。
- volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
- allkeys-random:对全体key ,随机进行淘汰。也就是直接从db->dict中随机挑选
- volatile-random:对设置了TTL的key ,随机进行淘汰。也就是从db->expires中随机挑选。
- allkeys-lru: 对全体key,基于LRU算法进行淘汰
- volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
- allkeys-lfu: 对全体key,基于LFU算法进行淘汰
- volatile-lfu: 对设置了TTL的key,基于LFI算法进行淘汰
这里重点说一下LRU与LFU。首先可以参考我之前的文章 LRU与LFU的简单实现 复习一下LRU与LFU的概念与实现思路。
要实现LRU或LFU,就一定需要保存淘汰需要的依据,在redisObject结构体中其实已经定义好了变量lru,长24bit。若淘汰策略为LRU,其内容为最近一次访问时间;若淘汰策略为LFU,其内容的高16位以分钟为单位记录最近一次访问时间,低8位记录逻辑访问次数:
1 | typedef struct redisObject { |
LFU只用了8位保存访问次数,如果保存的是实际访问次数显然是远远不够的,因此它保存的是逻辑访问次数,并不是每次key被访问都计数,但计数值与实际访问次数正相关。值需要通过运算:
- 生成0~1之间的随机数R
- 计算 (旧次数 * lfu_log_factor + 1),记录为P
- 如果 R < P ,则计数器 + 1,且最大不超过255
- 访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟,计数器 -1
Redis的内存淘汰策略可以参考下图:
需要注意的是,Redis并没有严格按照LRU与LFU的实现对所有满足条件的key进行对比,而是采用了一个eviction_pool
淘汰池,淘汰池是一个固定大小的集合,采用采样+TopK
思想,根据指定的淘汰策略(TTL、LRU、LFU)寻找适合被删除的候选键。
内存淘汰机制会遍历所有DB中并从每个DB中随机挑选出maxmemory-samples
数量的key尝试加入淘汰池,然后从淘汰池中取出key并淘汰掉。
淘汰池虽然不能找出全局最优的结果,但其采样+局部优化 的策略能够快速找出适合被淘汰的若干个键。这种设计在性能和实用性之间取得了良好的平衡。
__END__