数据库
首页 > 数据库> > redis过期key删除不得不说的事情

redis过期key删除不得不说的事情

作者:互联网

redis过期key删除不得不说的事情

1.过期key的删除策略

定时删除策略对于内存来说是最友好的,过期的key立刻被删除,不会过多的占用内存,但是会消耗大部分的时间片,对cpu很不友好。
惰性删除平时不做任何操作,只有key被访问到的时候才会进行判断与删除,对于cpu非常友好,但是这些已经过期的key会占用大量的内存,造成极大的浪费。
定期删除是每隔一段时间进行一次扫描,这是一种折衷的方案,既不会对cpu造成很高的负担,也不会让大量过期的key未被删除而造成浪费,但是对于扫描的频率和时间间隔设置较为复杂,如果设置的不科学,也会产生与其他方式类似的问题。

redis使用了后面两种方式来进行过期key的删除,在内存使用和cpu使用率上做了一定的取舍。与过期key删除策略经常弄混的是redis的内存逐出策略,redis的逐出策略只有在内存容量到达上线maxmemory之后才会使用,平时状态是不会使用的;而redis的过期key删除策略是每次循环都会使用的。
redis的redis的内存逐出策略有八种算法:

2.redis过期key删除详解

2.1 reids中的字典

redis作为一种k-v数据库,支持多种数据类型,这些数据的保存都和一个结构体redisDB相关。在redis中,有一个链表用来做数据上的逻辑区分,链表上的每个节点都是一个redisDb,编号从0到n(可以在配置文件中修改,默认为16,通过select命令可以切换编号,编号对应结构体中的id字段)。

typedef struct redisDb {
    dict *dict;                 /* DB的键空间,保存了所有的key */
    dict *expires;              /* 保存了所有的过期key,dict的子集 */
    dict *blocking_keys;        /* 实现阻塞客户端使用该字典 */
    dict *ready_keys;           /*  唤醒阻塞客户端使用该字典 */
    dict *watched_keys;         /* 实现简单事物使用该字典 */
    int id;                     /* 数据库ID */
    long long avg_ttl;          /* 平均过期时间 */
}

每次对数据库进行写命令操作时,就会在字典dict中进行相应的操作(增加或者修改),如果对该key的过期时间有操作,则去expires字典中进行操作(增加或者修改)。

字典的实现主要是由下面的结构体来实现,与本文有关的两个结构是ht[2]和rehashidx。ht[2]里面含有两个哈希表,哈希表1用来进行节点的存储,哈希表2通常情况下是空指针,没有被真实分配空间;当哈希表1的负载因子达到了扩容的要求时,则对哈希表2进行内存分配,用于进行哈希表扩容。由于redis主线程是单线程的reactor模型,为了防止rehash造成线程阻塞,所以redis采用了渐进式rehash来进行哈希表的扩缩容,每次对一定数量的槽位进行rehash,并将下一次需要进行rehash的槽位索引位置保存在rehashidx中,方便下一次进行rehash。

// 字典结构体
typedef struct dict {
    dictType *type;  /* 保存了一些功能函数 */
    void *privdata;  /* 保存一些数据用于修改字典 */
    dictht ht[2];    /* 哈希表 */
    long rehashidx; /* rehash进行到的索引位置 */  
    unsigned long iterators; /* 当前迭代器的运行数量 */
}

redis中的哈希表的实现方式可以归结为数组加链表,使用拉链法(链地址法)来解决哈希地址冲突;哈希表的槽位(哈希桶)个数为2^n个,每次扩缩容的结果都是2的幂次。 进行key操作的时候,首先计算放入的索引位置,idx=hash(key)%sizemark,由于掩码的值为 2^n-1,所以这里的取模运算可以简化为按位与,提高运算速度。

typedef struct dictht {
    dictEntry **table; // 哈希槽数组
    unsigned long size; // 哈希槽的个数(数组长度)
    unsigned long sizemask; // 哈希掩码,size-1
    unsigned long used; // 哈希表实际所拥有的元素个数
} dictht;

哈希表里有一个重要的指标:负载因子,可以通过used/size来计算。当负载超过一定限制的时候,需要进行扩容。与java不同的是,redis的哈希表在某个槽位过多的时候,并不会转化为红黑树;同时在负载因子过高的时候,进行渐进式的rehash。

redis从4.0之后开始使用siphash算法来进行运算,相比之前相比(murmurhash)提高了运行速度,并且降低了哈希碰撞的概率,可以有效防止hash-dos。一个良好的hash算法能够将key分散的比较均匀,在负载因子较小的情况下,可以保证每个槽位上的元素个数都是比较少的。hash表中每个槽位上的元素个数符合Poisson分布:

当负载因子为0.5时,每个槽位上的元素个数概率分布如下所示:
P(x=k) = exp(-0.5) * pow(0.5, k) / factorial(k)
*0     0.606530659713
*1     0.303265329856
*2     0.0758163324641
*3     0.0126360554107
*4     0.00157950692633
*5     0.000157950692633
*6     1.31625577195e-05
*7     9.40182694247e-07
*8     5.87614183904e-08
当元素个数多于8个时,概率小于千万分之一

当负载因子为1.0时,每个槽位上当元素个数如下所示:
P(x=k) = exp(-1) * pow(1, k) / factorial(k)
*0     0.367879441171
*1     0.367879441171
*2     0.183939720586
*3     0.0613132401952
*4     0.0153283100488
*5     0.00306566200976
*6     0.000510943668294
*7     7.29919526134e-05
*8     9.12399407667e-06
当元素个数多于8个时,概率约为十万分之一

在正常情况下,每个哈希槽位上的节点一般都很少,可以认为在常数级时间复杂度O(1) 获取到元素。

2.2 redis的内存删除逻辑

2.2.1 redis的事件驱动模型

redis是一个单线程事件驱动的模型,主线程主要在aeMain中进行,通过配置文件中的属性hz来确定每秒钟含有几个循环周期,默认为10。redis在初始化和加载后,就在aeMain中进行循环,直到接收到信号或发生错误才会退出,每个周期都会按照如下步骤进行处理:

void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        if (eventLoop->beforesleep != NULL)
            eventLoop->beforesleep(eventLoop);
        aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
    }
}

2.2.2 redis过期key删除的实现

这里使用了pyhton的伪代码代替了原始的c代码做说明,主要承接上文介绍了两种清理模式(快速清理和慢速清理),这个函数是过期key淘汰功能的核心,可以动态的调节cpu用在扫描过期键的时间,当过期键较少时,使用更少的cpu时间片;当过期key较多时,则会表现的比较激进。

在beforesleep回调函数调用时,会尝试一次快速清理功能,这种清理最大能持续EXPIRE_FAST_CYCLE_DURATION时间,并且在相同的时间内不会重复调用;在aftersleep回调函数阶段,会调用慢速清理功能,最大能调用每个redis处理周期ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC百分比的时间片。

通常情况下慢速淘汰功能能够淘汰大量的key,只有频繁触发时间限制的时候,说明慢速清理还剩下很多过期的key没有清理,需要穿插一些快速清理功能来尽可能的删除过期key。


ACTIVE_EXPIRE_CYCLE_SLOW = 0  # type标志位,持续时间比较久的慢速清理模式
ACTIVE_EXPIRE_CYCLE_FAST = 1  # type标志位,持续时间比短的快速清理模式

ACTIVE_EXPIRE_CYCLE_FAST_DURATION = 1000  # 快速清理模式的最长持续时间,单位:微秒
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP = 20  # 每次扫描随机抽取的key个数
ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC = 25  # 扫描key所占用的cpu最长时间片
CRON_DBS_PER_CALL = 16  # 每次随机扫描的db个数

dbnums = 0  # server里面设置了多少个redisDb的逻辑分区
current_db = 0  # 当前遍历到的数据库编号
timelimit_exit = 0  # flag标识位,用于判断是否是到达了时间上限才退出循环
last_fast_cycle = 0  # 上一次快速淘汰开始的时间


def activeExpireCycle(type):
    # 获取现在的运行时间,用于信息push和计算是否需要退出的baseline
    start = time.time()
    # 设定每次需要遍历的数据库
    dbs_per_call = CRON_DBS_PER_CALL

    if type == ACTIVE_EXPIRE_CYCLE_FAST:
        # 如果上一次扫描没有触发时间限制就退出了,说明过期key数量不多,不要浪费性能在扫描key上
        if not timelimit_exit:
            return
        # 如果距离上次快速淘汰开始的时间间隔小于2倍的快速淘汰持续时间,直接返回
        if start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION * 2:
            return
        # 设定本次快速淘汰开始的时间,为下一次调用做时间baseline
        last_fast_cycle = start

    # 如果每次扫描的数据库数量大于实际系统分配的逻辑分区数量,按照实际逻辑分区数量进行扫描
    # 如果上次扫描触发了时间限制,说明过期key比较多,需要进行全部逻辑分区的扫描
    if dbs_per_call > server.dbnum or timelimit_exit:
        dbs_per_call = dbnums

    # 计算本次循环最多能持续的时间,1000000微秒*(25/100)/ 10
    # 1秒钟10hz,每次redis的处理周期为100毫秒(100000微秒),25%cpu时间分片就是25000微秒
    timelimit = 1000000 * ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC / server.hz / 100
    # 如果是快速清理模式,则每次最多能持续1000微秒
    if type == ACTIVE_EXPIRE_CYCLE_FAST:
        timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION

    # 遍历指定数量(dbs_per_call)的数据库
    for i in range(0, dbs_per_call):
        # 如果触发了时间限制,直接退出循环
        if time.time() - start > timelimit:
            break
        # 当前数据库中不存在过期的key,直接扫描下一个数据库
        if redisDb[i].expires.size() == 0:
            continue
        # 如果当前数据库含有过期时间的key数量小于1%,直接扫描下一个数据库
        if redisDb[i].expires.size() * 100.0 / redisDb[i].dict.size() < 1:
            continue

        while True:
            # 获取每次扫描的key的数量
            lookup_nums = min(redisDb[i].expires.size(), ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
            # 已经过期的key的数量
            expired = 0

            while lookup_nums > 0:
                # 随机获取一个key判断是否过期
                lookup_key = random_get_key_from_expire(redisDb[i])
                # 如果过期了,就删除
                if is_expire(lookup_key):
                    del (lookup_key)
                    expired += 1
            # 判断当前时刻是否触发了时间限制,如果触发了限制,直接退出
            if time.time() - start > timelimit:
                break
                break  # 两个break,不符合python语法,这里表示结束全部的循环

            # 如果本次循环扫描出来的key已经过期的比例小于25%,则去扫描下一个数据库
            # 否则说明本数据库过期的key较多,立刻在本数据库中再次扫描和清理
            if expired * 4 / lookup_nums < 1:
                break

2.2.3 redis过期key优化点

上文中提到了,过期key是随机从全部的含有过期时间的key中进行选择。使用的算法如下,这里存在的不足是,当过期key数量较少时,哈希桶槽位上大部分的元素为空,随机数生成后所得到的哈希桶槽位经常miss,需要再次进行随机,会消耗过多的时间片在生成随机数上,而不是清理过期key。

def random_get_key_from_expire(redisDb):
    slot_nums = 0
    # 如果在进行rehash,则两个哈希表都可能有key存在,需要从中随机选择一个
    if (is_rehash(redisDb.expires)):
        slot_nums = ht[0].size + ht[1].size
    else:
        slot_nums = ht[0].size

    headEntry = None
    while True:
        # 从所有的槽位中随机选择一个
        slot_idx = random.randint(0, slot_nums)
        # 直到找到一个槽位不为空的哈希桶,就停止循环
        if (slot_idx > ht[0].size):
            # 如果所得到的索引大于ht[0]的大小,说明落在了ht[1]中
            tempEntry = ht[1].get_index(slot_idx - ht[0].size)
            if tempEntry != None:
                headEntry = tempEntry
                break
        else:
            tempEntry = ht[0].get_index(slot_idx)
            if tempEntry != None:
                headEntry = tempEntry
                break

    currEntry = headEntry
    len = 0
    # 遍历链表获得长度
    while currEntry.next:
        currEntry = currEntry.next
        len += 1

    # 从链表中随机选择一个节点返回
    listele = random.randint(0, len)
    while listele:
        headEntry = headEntry.next
        listele -= 1
    return headEntry

此外,如果redis的写入量比较大,且key过期时间比较短,即使两种淘汰方式同时进行,也会超过redis的处理能力,依然会造成数据的堆积,如果容量继续上涨超过,就会进行内存淘汰(使用allkeys-lru淘汰策略,会优先删除lru较小的key,由于惰性删除的原因,已过期的key的lru会通常情况下比较小,会被优先逐出)。由于走了另外一套逻辑(逐出逻辑)造成了cpu时间片的浪费,它们最终的结果都是key删除,但是却进行了不同的筛选策略。实际使用场景中遇到这种情况的时刻不多,对于性能和吞吐量的影响不大,但是也是一个可以优化的点。

实际使用中,还有一种人为触发过期key淘汰的方法,就是通过scan命令来进行全库的扫描,通过控制scan命令的游标和count数量,可以预期的控制淘汰的激烈程度,不会对主线程造成阻塞。redis在进行key的删除的时候,如果开启了异步删除,则当被删除的key所对应的val占用空间大于64字节时,会将这个key标记为删除后直接返回+OK,然后将val放到后台的bio线程里面进行删除,防止阻塞主线程;如果占用的空间小于64字节,即使开启了异步删除,在最后运行的时候也会同步的进行删除(redis优秀的性能优化在细节之末随处可见,针对很多场景都做了优化,并抽象出参数给用户动态配置,它的高性能是与redis作者精益求精的修改分不开的)。

2.2.4 redis6对过期key删除的优化

redis6针对以上的问题提出了几个改进点,首先将原来的随机抽样检查过期key变成了按照哈希桶顺序遍历检查过期key。通过在redisDb结构体中增加long expires_cursor字段,用来记录上一次遍历到的游标位置,每次遍历都会取到这个游标位置对应的索引,然后继续下去,使得cpu的时间片更多的用来进行key过期的判断和删除上。

typedef struct redisDb {
    dict *dict;                 
    dict *expires;              
    dict *blocking_keys;        
    dict *ready_keys;           
    dict *watched_keys;         
    int id;                     
    long long avg_ttl;          
    unsigned long expires_cursor; /* 主动过期循环的游标*/
    list *defrag_later;         /* key组成的链表,用来进行内存碎片整理 */
} redisDb;

另外在server的配置中增加了activate_expire_effort标识位,可以被设定为1-10,用来表示定期删除淘汰key的激烈程度。在系统设定好activate_expire_effort之后,redis的定期删除循环逻辑每次都会重新计算阈值,比如快速淘汰循环最大的持续时间,慢速淘汰循环最大的持续时间,这些参数在之前的淘汰算法中都是一个确定的值。通过抽象成一个配置文件的参数,可以通过命令热加载动态的调节,达到redis吞吐量和容量使用的“均衡”。

struct redisServer {
            ...
    int active_expire_enabled;      /* Can be disabled for testing purposes. */
    int active_expire_effort;       /* From 1 (default) to 10, active effort. */
            ...
}
ACTIVE_EXPIRE_CYCLE_SLOW = 0  # type标志位,持续时间比较久的慢速清理模式
ACTIVE_EXPIRE_CYCLE_FAST = 1  # type标志位,持续时间比短的快速清理模式

ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP = 20  # 每次每个db扫描的key个数
ACTIVE_EXPIRE_CYCLE_FAST_DURATION = 1000  # 快速清理模式的最长持续时间,这里只是一个基线
ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC = 25  # 扫描key所占用的cpu最长时间片
ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE = 10  # 开启efforts后,过期key占据的百分比
CRON_DBS_PER_CALL = 16  # 每次随机扫描的db个数

current_db = 0  # 当前遍历到的数据库编号
timelimit_exit = 0  # flag标识位,用于判断是否是到达了时间上限才退出循环
last_fast_cycle = 0  # 上一次快速淘汰开始的时间


def activeExpireCycle(type):
    # 一个统计量,表示系统对于过期key扫描的激进程度(0~9)
    # 该配置可以通过配置文件来进行设置,根据集群的平均过期时间,动态的设定
    effort = server.active_expire_effort - 1,
    # 每次循环扫描的key的数量
    config_keys_per_loop = ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP + ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP / 4 * effort
    # 快速清理循环的持续时间
    config_cycle_fast_duration = ACTIVE_EXPIRE_CYCLE_FAST_DURATION + ACTIVE_EXPIRE_CYCLE_FAST_DURATION / 4 * effort
    # 清理循环周期所占用的最大cpu时间片百分比
    config_cycle_slow_time_perc = ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC + 2 * effort
    # 有过期时间的key所全部key占的最多的百分比
    config_cycle_acceptable_stale = ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE - effort

    # 全局变量,函数退出后值仍然保存,留待下一次函数调用
    global last_fast_cycle
    global current_db
    global timelimit_exit

    start = time.time()
    dbs_per_call = CRON_DBS_PER_CALL
    iteration = 0

    if type == ACTIVE_EXPIRE_CYCLE_FAST:
        # 如果上一次扫描没有触发时间限制就退出,并且系统所含有的过期key的比例小于配置的阈值直接返回
        # 因为此时过期快key的数量不多,并不需要浪费过多的cpu时间片在扫描过期key上
        if (not timelimit_exit) and (server.stat_expired_stale_perc < config_cycle_acceptable_stale):
            return
        # 如果距离上次快速周期循环开始的时间间隔小于2倍的快速周期循环持续时间,直接返回
        if start < last_fast_cycle + config_cycle_fast_duration * 2:
            return
        # 设定本次快速淘汰开始的时间,为下一次调用做时间baseline
        last_fast_cycle = start

    # 如果每次扫描的数据库数量大于实际系统分配的逻辑分区数量,按照实际逻辑分区数量进行扫描
    # 如果上次扫描触发了时间限制,说明过期key比较多,需要进行全部逻辑分区的扫描
    if dbs_per_call > server.dbnum or timelimit_exit:
        dbs_per_call = server.dbnums

    # 计算本次循环最多能持续的时间,1000000微秒*(25/100)/ 10
    # 1秒钟10hz,每次redis的处理周期为100毫秒(100000微秒),25%cpu时间分片就是25000微秒
    # 如果设置了effort,则会增加用于过期key删除的cpu时间片,例如effort=6,那么将有35000微秒用于处理过期key
    timelimit = 1000000 * config_cycle_slow_time_perc / server.hz / 100

    # 如果是快速清理模式,则每次最多能持续config_cycle_fast_duration微秒
    if type == ACTIVE_EXPIRE_CYCLE_FAST:
        timelimit = config_cycle_fast_duration

    # 遍历指定数量(dbs_per_call)的数据库
    for i in range(0, dbs_per_call):
        # 如果触发了时间限制,直接退出循环
        if time.time() - start > timelimit:
            break

        # 获取每次要扫描的数据库编号,并记录下来,这样可以让cpu将时间片公平的分给每个数据库
        db_idx = current_db % server.dbnum
        # 先变更下一次要扫描的数据库,方式程序因超时返回时忘记变更需要扫描的数据库,
        # 也是为了将时间片公平的分散到每个数据库
        db_idx += 1

        while True:

            # 当前数据库中不存在过期的key,直接扫描下一个数据库
            if redisDb[db_idx].expires.size() == 0:
                break
            # 如果当前数据库含有过期时间的key数量小于1%,直接扫描下一个数据库
            if redisDb[db_idx].expires.size() * 100.0 / redisDb[db_idx].dict.size() < 1:
                break

            # 获取每次扫描的key的数量
            num = redisDb[db_idx].expires.size()
            if num > config_keys_per_loop:
                num = config_keys_per_loop  # 每次至多只会选择config_keys_per_loop个key进行查询

            # 最多遍历的哈希桶数量
            max_buckets = num * 20
            # 已经遍历的哈希桶数量
            checked_buckets = 0
            # 已经扫描的过期key的个数
            sampled = 0
            # 已经过期key的个数
            expires = 0

            # 如果扫描的哈希桶数量过多或者已经扫描了规定数量的key,就退出循环防止占用过多时间
            while sampled < num and checked_buckets < max_buckets:
                # 字典里只有两个哈希表ht[0],ht[1],每次循环会选择其中的一个
                for table in (0, 1):
                    # 没有进行rehash时,ht[1]为空,遍历到此处时直接返回
                    if table == 1 and is_rehash(redisDb[db_idx].expires):
                        break

                    # 保存了上一次遍历到的哈希桶索引位置
                    idx = redisDb[db_idx].expires_cursor
                    # 与掩码进行计算,确定本次开始遍历的哈希桶索引位置
                    idx = idx & redisDb[db_idx].expires.ht[i].sizemark
                    # 获取哈希桶索引位置对应的链表的头节点
                    dictEntry = redisDb[db_idx].expires.ht[i].bucket[idx]


                    while dictEntry:
                        # 遍历链表,如果过期了就删除,并计数已经过期的key
                        if is_expire(dictEntry):
                            del (dictEntry)
                            expires += 1
                        dictEntry = dictEntry.next
                        sampled += 1

                # 记录下一次要遍历的哈希桶的索引位置
                redisDb[db_idx].expires_cursor += 1
                # 如果扫描时间过长达到了规定的阈值,则直接返回
                if time.time() - start > timelimit:
                    return  # 直接返回全部的
            # 如果过期的key占比高于阈值,说明当前库里面的过期key很多,需要再次遍历进行淘汰
            if expires * 100.0 / sampled > config_cycle_acceptable_stale:
                continue
            # 如果扫描到的所有的桶都是空的,触发了max_buckets限制而退出,说明没有清理到过期的key
            # 过期的key都在其他的桶之中,需要进行再次的扫描
            elif sampled == 0:
                continue
            else:
                break

标签:key,过期,redis,扫描,EXPIRE,哈希
来源: https://blog.csdn.net/qq_38856118/article/details/115796879