过期key处理机制

redisDb结构体

Redis本身是一个典型的key-value内存存储数据库,所有的key、value都以robj形式保存在Dict结构中。Redis的database结构体就是用来存放所有键值对的,结构体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct redisDb {
    dict *dict;                 /* 存放所有key及value的地方,也被称为keyspace*/
    dict *expires;              /* 存放每一个key及其对应的TTL存活时间,只包含设置了TTL的key*/
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID,0~15 */
    long long avg_ttl;          /* 记录平均TTL时长 */
    unsigned long expires_cursor; /* expire检查时在dict中抽样的索引位置. */
    list *defrag_later;         /* 等待碎片整理的key列表. */
} redisDb;

可以发现结构体内实际上有很多dict,其中blocking_keys、ready_keys、watched_keys是用来实现阻塞队列、监视等功能的。重点需要关注的dict有两个:

  • dict用来记录键值对,dictEntry里面存的是key和value的robj指针;
  • expires用来记录过期时间,如果有设置了过期时间的key,dictEntry里面存的是key->TTL;

redisDb结构图如下:

image-20250222205507090

过期删除策略

惰性删除

TTL到期后就并不是立刻删除,而是在访问一个key的时候,检查该key的存活时间,如果已经过期才执行删除。这种方式避免了定期扫描整个数据集来查找过期键,从而显著减少了 CPU 的使用率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 查找一个key执行写操作
robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags) {
    // 检查key是否过期
    expireIfNeeded(db,key);
    return lookupKey(db,key,flags);
}
// 查找一个key执行读操作
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
    robj *val;
// 检查key是否过期
if (expireIfNeeded(db,key) == 1) {
// ...略
    }
    return NULL;
}

int expireIfNeeded(redisDb *db, robj *key) {
    // 判断是否过期,如果未过期直接结束并返回0
    if (!keyIsExpired(db,key)) return 0;
    // ... 略
    // 删除过期key
    deleteExpiredKeyAndPropagate(db,key);
    return 1;
}

然而,惰性删除也有其局限性,例如可能导致过期键长时间占用内存,或者在高并发场景下增加响应时间。因此,Redis 通常会结合定期删除(Active Expiration)策略来平衡内存使用和性能。

周期删除

在分析源码之前,需要先对Redis的事件循环机制和lazy_free机制有一定了解。

事件循环

Redis 是一个基于事件驱动的高性能键值对存储数据库,其事件循环机制是核心组成部分,用于高效处理网络 I/O 事件和定时事件。Redis 的事件循环机制主要处理两类事件:

  • 文件事件(File Events):用于处理网络 I/O,如客户端的连接请求、读写操作等。Redis 使用 I/O 多路复用技术来监听多个文件描述符(如套接字),当某个文件描述符上有可读或可写事件发生时,就会触发相应的处理程序。
  • 时间事件(Time Events):用于处理定时任务,如定期过期键检查、统计信息更新等。时间事件可以分为定时事件(在指定时间点执行)和周期性事件(每隔一段时间执行一次)。

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
while (true) {
// 处理文件事件
// 调用I/O多路复用程序,阻塞等待文件事件发生
// 当有文件事件发生时,I/O多路复用程序返回
// 文件事件分派器将事件分发给相应的事件处理器进行处理

// 处理时间事件
// 遍历时间事件列表,找出已经到达执行时间的事件
// 执行这些时间事件的处理函数

// 继续下一次循环
}
Lazy_free

Redis的后台线程有三类,其中一类就是负责进行lazy_free的。lazy_free是 Redis 的一种删除机制,其核心思想是避免在删除大对象时阻塞主线程,只在主线程解除与key的连接,把耗时的内存释放操作放到后台线程去执行,以此来提升 Redis 的性能和响应能力。

image-20250222220806006

SLOW模式与FAST模式

周期删除即周期性地抽样部分过期的key,然后执行删除。执行周期有两种:

  • Redis服务初始化函数initServer()中设置定时任务,按照server.hz的频率来执行过期key清理,模式为SLOW
  • Redis的每个事件循环前,会调用beforeSleep()函数,执行过期key清理,模式为FAST

SLOW模式实际上是在初始化Server时注册了一个时间事件,参见以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// server.c
void initServer(void){
    // ...
    // 创建定时器,关联回调函数serverCron,处理周期取决于server.hz,默认10
    aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL)
}

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    // 更新lruclock到当前时间,为后期的LRU和LFU做准备
    unsigned int lruclock = getLRUClock();
    atomicSet(server.lruclock,lruclock);
    // 执行database的数据清理,例如过期key处理
    databasesCron();
}

void databasesCron(void) {
    // 尝试清理部分过期key,清理模式默认为SLOW
    activeExpireCycle(
ACTIVE_EXPIRE_CYCLE_SLOW);
}

执行流程概述:

  1. 执行频率受server.hz影响,默认为10,即每秒执行10次,每个执行周期100ms;
  2. 执行清理耗时不超过一次执行周期的25%.默认slow模式耗时不超过25ms;
  3. 逐个遍历db,逐个遍历db中的bucket即dictEntry数组,抽取指定个数的key判断是否过期;
  4. 如果没达到时间上限并且过期key比例大于设定的比例,再进行一次抽样,否则结束;

FAST模式则是在beforeSleep中执行,执行时机为每次事件循环开始时,执行文件事件和时间事件前。参见以下代码:

1
2
3
4
5
void beforeSleep(struct aeEventLoop *eventLoop){
    // 尝试清理部分过期key,清理模式默认为FAST
    activeExpireCycle(
ACTIVE_EXPIRE_CYCLE_FAST);
}

执行流程概述:

  1. 执行频率受beforeSleep()调用频率影响,两次FAST模式间隔不低于2ms;
  2. 执行清理耗时不超过1ms;
  3. 逐个遍历db,逐个遍历db中的bucket,抽取指定个key判断是否过期;
  4. 如果没达到时间上限并且过期key比例大于大于设定的比例,再进行一次抽样,否则结束;
周期删除源码分析

SLOW和FAST两种模式最终都调用了activeExpireCycle()函数,在这个函数中,会主动扫描已设置过期时间的键,并检查它们是否已经过期,然后根据需要进行删除操作,删除操作主要在expireScanCallback()函数中执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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);
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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;
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
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);
}

结合源码、事件循环机制和lazy_free机制,可以初步画出Redis的事件循环逻辑和周期删除逻辑如下图所示:

image-20250222224055035

注意,该图实际上只体现了SLOW模式删除,没有体现FAST模式删除。FAST模式删除并不依赖时间事件,它是在Event Loop的最开始,即文件事件之前执行的。

周期删除是否会与主线程产生并发问题

答案是不会,由于Redis的事件循环模型,主线程的所有操作都来自文件事件或时间事件。Redis的事件循环的调度策略决定了,每次都会按顺序,首先执行beforeSleep中的FAST模式删除,再执行文件事件,最后执行时间事件中的SLOW模式删除,操作间不会竞争执行。并且异步lazy_free之前一定会在主线程实现key的解绑,所以不存在并发问题。

内存淘汰策略

Redis配置文件中有一项配置为maxmemory,即可以使用的最大内存上限。内存淘汰是指当Redis内存使用达到设置的上限时,主动挑选部分key删除以释放更多内存的流程。Redis会在处理客户端命令的方法processCommand()中尝试做内存淘汰(即执行任何非LUA脚本命令之前进行内存淘汰):

1
2
3
4
5
6
7
8
9
10
11
12
13
int processCommand(client *c) {
    // 如果服务器设置了server.maxmemory属性,并且并未有执行lua脚本
    if (server.maxmemory && !server.lua_timedout) {
        // 尝试进行内存淘汰performEvictions
        int out_of_memory = (performEvictions() == EVICT_FAIL);
        // ...
        if (out_of_memory && reject_cmd_on_oom) {
            rejectCommand(c, shared.oomerr);
            return C_OK;
        }
        // ....
    }
}

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
2
3
4
5
6
7
8
typedef struct redisObject {
    unsigned type:4;        // 对象类型
    unsigned encoding:4;    // 编码方式
    unsigned lru:LRU_BITS;  // LRU:以秒为单位记录最近一次访问时间,长度24bit
// LFU:高16位以分钟为单位记录最近一次访问时间,低8位记录逻辑访问次数
    int refcount;           // 引用计数,计数为0则可以回收
    void *ptr;              // 数据指针,指向真实数据
} robj;

LFU只用了8位保存访问次数,如果保存的是实际访问次数显然是远远不够的,因此它保存的是逻辑访问次数,并不是每次key被访问都计数,但计数值与实际访问次数正相关。值需要通过运算:

  • 生成0~1之间的随机数R
  • 计算 (旧次数 * lfu_log_factor + 1),记录为P
  • 如果 R < P ,则计数器 + 1,且最大不超过255
  • 访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟,计数器 -1

Redis的内存淘汰策略可以参考下图:

1653984085095

需要注意的是,Redis并没有严格按照LRU与LFU的实现对所有满足条件的key进行对比,而是采用了一个eviction_pool淘汰池,淘汰池是一个固定大小的集合,采用采样+TopK思想,根据指定的淘汰策略(TTL、LRU、LFU)寻找适合被删除的候选键。

内存淘汰机制会遍历所有DB中并从每个DB中随机挑选出maxmemory-samples数量的key尝试加入淘汰池,然后从淘汰池中取出key并淘汰掉。

淘汰池虽然不能找出全局最优的结果,但其采样+局部优化 的策略能够快速找出适合被淘汰的若干个键。这种设计在性能和实用性之间取得了良好的平衡。

__END__