redis 数据删除策略和逐出算法的问题小结

    数据存储和有效期

    redis 工作流程中,过期的数据并不需要马上就要执行删除操作。因为这些删不删除只是一种状态表示,可以异步的去处理,在不忙的时候去把这些不紧急的删除操作做了,从而保证 redis 的高效

    数据的存储

    在redis中数据的存储不仅仅需要保存数据本身还要保存数据的生命周期,也就是过期时间。在redis 中 数据的存储结构如下图:

    获取有效期

    Redis是一种内存级数据库,所有数据均存放在内存中,内存中的数据可以通过TTL指令获取其状态

    删除策略

    在内存占用与CPU占用之间寻找一种平衡,顾此失彼都会造成整体redis性能的下降,甚至引发服务器宕机或内存泄漏。

    定时删除

    创建一个定时器,当key设置过期时间,且过期时间到达时,由定时器任务立即执行对键的删除操作

    优点

    节约内存,到时就删除,快速释放掉不必要的内存占用

    缺点

    CPU压力很大,无论CPU此时负载多高,均占用CPU,会影响redis服务器响应时间和指令吞吐量

    总结

    用处理器性能换取存储空间

    惰性删除

    数据到达过期时间,不做处理。等下次访问该数据,如果未过期,返回数据。发现已经过期,删除,返回不存在。这样每次读写数据都需要检测数据是否已经到达过期时间。也就是惰性删除总是在数据的读写时发生的。

    expireIfNeeded函数

    对所有的读写命令进行检查,检查操作的对象是否过期。过期就删除返回过期,不过期就什么也不做~。

    执行数据写入过程中,首先通过expireIfNeeded函数对写入的key进行过期判断。

    
    /*
     * 为执行写入操作而取出键 key 在数据库 db 中的值。
     *
     * 和 lookupKeyRead 不同,这个函数不会更新服务器的命中/不命中信息。
     *
     * 找到时返回值对象,没找到返回 NULL 。
     */
    robj *lookupKeyWrite(redisDb *db, robj *key) {
    
     // 删除过期键
     expireIfNeeded(db,key);
    
     // 查找并返回 key 的值对象
     return lookupKey(db,key);
    }

    执行数据读取过程中,首先通过expireIfNeeded函数对写入的key进行过期判断。

    
    /*
     * 为执行读取操作而取出键 key 在数据库 db 中的值。
     *
     * 并根据是否成功找到值,更新服务器的命中/不命中信息。
     *
     * 找到时返回值对象,没找到返回 NULL 。
     */
    robj *lookupKeyRead(redisDb *db, robj *key) {
     robj *val;
    
     // 检查 key 释放已经过期
     expireIfNeeded(db,key);
    
     // 从数据库中取出键的值
     val = lookupKey(db,key);
    
     // 更新命中/不命中信息
     if (val == NULL)
     server.stat_keyspace_misses++;
     else
     server.stat_keyspace_hits++;
    
     // 返回值
     return val;
    }

    执行过期动作expireIfNeeded其实内部做了三件事情,分别是:

    • 查看key判断是否过期
    • 向slave节点传播执行过期key的动作并发送事件通知
    • 删除过期key
    
    /*
     * 检查 key 是否已经过期,如果是的话,将它从数据库中删除。
     *
     * 返回 0 表示键没有过期时间,或者键未过期。
     *
     * 返回 1 表示键已经因为过期而被删除了。
     */
    int expireIfNeeded(redisDb *db, robj *key) {
    
     // 取出键的过期时间
     mstime_t when = getExpire(db,key);
     mstime_t now;
    
     // 没有过期时间
     if (when < 0) return 0; /* No expire for this key */
    
     /* Don't expire anything while loading. It will be done later. */
     // 如果服务器正在进行载入,那么不进行任何过期检查
     if (server.loading) return 0;
    
     // 当服务器运行在 replication 模式时
     // 附属节点并不主动删除 key
     // 它只返回一个逻辑上正确的返回值
     // 真正的删除操作要等待主节点发来删除命令时才执行
     // 从而保证数据的同步
     if (server.masterhost != NULL) return now > when;
    
     // 运行到这里,表示键带有过期时间,并且服务器为主节点
    
     /* Return when this key has not expired */
     // 如果未过期,返回 0
     if (now <= when) return 0;
    
     /* Delete the key */
     server.stat_expiredkeys++;
    
     // 向 AOF 文件和附属节点传播过期信息
     propagateExpire(db,key);
    
     // 发送事件通知
     notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
     "expired",key,db->id);
    
     // 将过期键从数据库中删除
     return dbDelete(db,key);
    }

    判断key是否过期的数据结构是db->expires,也就是通过expires的数据结构判断数据是否过期。
    内部获取过期时间并返回。

    
    /*
     * 返回字典中包含键 key 的节点
     *
     * 找到返回节点,找不到返回 NULL
     *
     * T = O(1)
     */
    dictEntry *dictFind(dict *d, const void *key)
    {
     dictEntry *he;
     unsigned int h, idx, table;
    
     // 字典(的哈希表)为空
     if (d->ht[0].size == 0) return NULL; /* We don't have a table at all */
    
     // 如果条件允许的话,进行单步 rehash
     if (dictIsRehashing(d)) _dictRehashStep(d);
    
     // 计算键的哈希值
     h = dictHashKey(d, key);
     // 在字典的哈希表中查找这个键
     // T = O(1)
     for (table = 0; table <= 1; table++) {
    
     // 计算索引值
     idx = h & d->ht[table].sizemask;
    
     // 遍历给定索引上的链表的所有节点,查找 key
     he = d->ht[table].table[idx];
     // T = O(1)
     while(he) {
    
     if (dictCompareKeys(d, key, he->key))
     return he;
    
     he = he->next;
     }
    
     // 如果程序遍历完 0 号哈希表,仍然没找到指定的键的节点
     // 那么程序会检查字典是否在进行 rehash ,
     // 然后才决定是直接返回 NULL ,还是继续查找 1 号哈希表
     if (!dictIsRehashing(d)) return NULL;
     }
    
     // 进行到这里时,说明两个哈希表都没找到
     return NULL;
    }

    优点

    节约CPU性能,发现必须删除的时候才删除。

    缺点

    内存压力很大,出现长期占用内存的数据。

    总结

    用存储空间换取处理器性能

    定期删除

    周期性轮询redis库中时效性数据,采用随机抽取的策略,利用过期数据占比的方式删除频度。

    优点

    CPU性能占用设置有峰值,检测频度可自定义设置

    内存压力不是很大,长期占用内存的冷数据会被持续清理

    缺点

    需要周期性抽查存储空间

    定期删除详解

    redis的定期删除是通过定时任务实现的,也就是定时任务会循环调用serverCron方法。然后定时检查过期数据的方法是databasesCron。定期删除的一大特点就是考虑了定时删除过期数据会占用cpu时间,所以每次执行databasesCron的时候会限制cpu的占用不超过25%。真正执行删除的是 activeExpireCycle方法。

    时间事件

    对于持续运行的服务器来说, 服务器需要定期对自身的资源和状态进行必要的检查和整理, 从而让服务器维持在一个健康稳定的状态, 这类操作被统称为常规操作(cron job)

    在 Redis 中, 常规操作由 redis.c/serverCron() 实现, 它主要执行以下操作

    1 更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。

    2 清理数据库中的过期键值对。

    3 对不合理的数据库进行大小调整。

    4 关闭和清理连接失效的客户端。

    5 尝试进行 AOF 或 RDB 持久化操作。

    6 如果服务器是主节点的话,对附属节点进行定期同步。

    7 如果处于集群模式的话,对集群进行定期同步和连接测试。

    因为 serverCron() 需要在 Redis 服务器运行期间一直定期运行, 所以它是一个循环时间事件: serverCron() 会一直定期执行,直到服务器关闭为止。

    在 Redis 2.6 版本中, 程序规定 serverCron() 每秒运行 10 次, 平均每 100 毫秒运行一次。 从 Redis 2.8 开始, 用户可以通过修改 hz选项来调整 serverCron() 的每秒执行次数, 具体信息请参考 redis.conf 文件中关于 hz 选项的说明

    查看hz

    
    way1 : config get hz # "hz" "10"
    way2 : info server # server.hz 10

    serverCron()

    serverCron()会定期的执行,在serverCron()执行中会调用databasesCron() 方法(serverCron()还做了其他很多事情,但是现在不讨论,只谈删除策略)

    
    int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
     // 略去多无关代码
    
     /* We need to do a few operations on clients asynchronously. */
     // 检查客户端,关闭超时客户端,并释放客户端多余的缓冲区
     clientsCron();
    
     /* Handle background operations on Redis databases. */
     // 对数据库执行各种操作
     databasesCron(); /* !我们关注的方法! */

    databasesCron()

    databasesCron() 中 调用了 activeExpireCycle()方法,来对过期的数据进行处理。(在这里还会做一些其他操作~ 调整数据库大小,主动和渐进式rehash)

    
    // 对数据库执行删除过期键,调整大小,以及主动和渐进式 rehash
    void databasesCron(void) {
    
     // 判断是否是主服务器 如果是 执行主动过期键清除
     if (server.active_expire_enabled && server.masterhost == NULL)
     // 清除模式为 CYCLE_SLOW ,这个模式会尽量多清除过期键
     activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
    
     // 在没有 BGSAVE 或者 BGREWRITEAOF 执行时,对哈希表进行 rehash
     if (server.rdb_child_pid == -1 && server.aof_child_pid == -1) {
     static unsigned int resize_db = 0;
     static unsigned int rehash_db = 0;
     unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL;
     unsigned int j;
    
     /* Don't test more DBs than we have. */
     // 设定要测试的数据库数量
     if (dbs_per_call > server.dbnum) dbs_per_call = server.dbnum;
    
     /* Resize */
     // 调整字典的大小
     for (j = 0; j < dbs_per_call; j++) {
     tryResizeHashTables(resize_db % server.dbnum);
     resize_db++;
     }
    
     /* Rehash */
     // 对字典进行渐进式 rehash
     if (server.activerehashing) {
     for (j = 0; j < dbs_per_call; j++) {
     int work_done = incrementallyRehash(rehash_db % server.dbnum);
     rehash_db++;
     if (work_done) {
      /* If the function did some work, stop here, we'll do
      * more at the next cron loop. */
      break;
     }
     }
     }
     }
    }

    activeExpireCycle()

    大致流程如下

    1 遍历指定个数的db(默认的 16 )进行删除操作

    2 针对每个db随机获取过期数据每次遍历不超过指定数量(如20),发现过期数据并进行删除。

    3 如果有多于25%的keys过期,重复步骤 2

    除了主动淘汰的频率外,Redis对每次淘汰任务执行的最大时长也有一个限定,这样保证了每次主动淘汰不会过多阻塞应用请求,以下是这个限定计算公式:

    #define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* CPU max % for keys collection */ “… “timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;

    也就是每次执行时间的25%用于过期数据删除。

    
    void activeExpireCycle(int type) {
     // 静态变量,用来累积函数连续执行时的数据
     static unsigned int current_db = 0; /* Last DB tested. */
     static int timelimit_exit = 0; /* Time limit hit in previous call? */
     static long long last_fast_cycle = 0; /* When last fast cycle ran. */
    
     unsigned int j, iteration = 0;
     // 默认每次处理的数据库数量
     unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL;
     // 函数开始的时间
     long long start = ustime(), timelimit;
    
     // 快速模式
     if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
     // 如果上次函数没有触发 timelimit_exit ,那么不执行处理
     if (!timelimit_exit) return;
     // 如果距离上次执行未够一定时间,那么不执行处理
     if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return;
     // 运行到这里,说明执行快速处理,记录当前时间
     last_fast_cycle = start;
     }
    
     /* 
     * 一般情况下,函数只处理 REDIS_DBCRON_DBS_PER_CALL 个数据库,
     * 除非:
     *
     * 1) 当前数据库的数量小于 REDIS_DBCRON_DBS_PER_CALL
     * 2) 如果上次处理遇到了时间上限,那么这次需要对所有数据库进行扫描,
     * 这可以避免过多的过期键占用空间
     */
     if (dbs_per_call > server.dbnum || timelimit_exit)
     dbs_per_call = server.dbnum;
    
     // 函数处理的微秒时间上限
     // ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 默认为 25 ,也即是 25 % 的 CPU 时间
     timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
     timelimit_exit = 0;
     if (timelimit <= 0) timelimit = 1;
    
     // 如果是运行在快速模式之下
     // 那么最多只能运行 FAST_DURATION 微秒 
     // 默认值为 1000 (微秒)
     if (type == ACTIVE_EXPIRE_CYCLE_FAST)
     timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* in microseconds. */
    
     // 遍历数据库
     for (j = 0; j < dbs_per_call; j++) {
     int expired;
     // 指向要处理的数据库
     redisDb *db = server.db+(current_db % server.dbnum);
    
     // 为 DB 计数器加一,如果进入 do 循环之后因为超时而跳出
     // 那么下次会直接从下个 DB 开始处理
     current_db++;
    
     do {
     unsigned long num, slots;
     long long now, ttl_sum;
     int ttl_samples;
    
     /* If there is nothing to expire try next DB ASAP. */
     // 获取数据库中带过期时间的键的数量
     // 如果该数量为 0 ,直接跳过这个数据库
     if ((num = dictSize(db->expires)) == 0) {
     db->avg_ttl = 0;
     break;
     }
     // 获取数据库中键值对的数量
     slots = dictSlots(db->expires);
     // 当前时间
     now = mstime();
    
     // 这个数据库的使用率低于 1% ,扫描起来太费力了(大部分都会 MISS)
     // 跳过,等待字典收缩程序运行
     if (num && slots > DICT_HT_INITIAL_SIZE &&
     (num*100/slots < 1)) break;
    
     /* 
     * 样本计数器
     */
     // 已处理过期键计数器
     expired = 0;
     // 键的总 TTL 计数器
     ttl_sum = 0;
     // 总共处理的键计数器
     ttl_samples = 0;
    
     // 每次最多只能检查 LOOKUPS_PER_LOOP 个键
     if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
     num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;
    
     // 开始遍历数据库
     while (num--) {
     dictEntry *de;
     long long ttl;
    
     // 从 expires 中随机取出一个带过期时间的键
     if ((de = dictGetRandomKey(db->expires)) == NULL) break;
     // 计算 TTL
     ttl = dictGetSignedIntegerVal(de)-now;
     // 如果键已经过期,那么删除它,并将 expired 计数器增一
     if (activeExpireCycleTryExpire(db,de,now)) expired++;
     if (ttl < 0) ttl = 0;
     // 累积键的 TTL
     ttl_sum += ttl;
     // 累积处理键的个数
     ttl_samples++;
     }
    
     /* Update the average TTL stats for this database. */
     // 为这个数据库更新平均 TTL 统计数据
     if (ttl_samples) {
     // 计算当前平均值
     long long avg_ttl = ttl_sum/ttl_samples;
     
     // 如果这是第一次设置数据库平均 TTL ,那么进行初始化
     if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
     /* Smooth the value averaging with the previous one. */
     // 取数据库的上次平均 TTL 和今次平均 TTL 的平均值
     db->avg_ttl = (db->avg_ttl+avg_ttl)/2;
     }
    
     // 我们不能用太长时间处理过期键,
     // 所以这个函数执行一定时间之后就要返回
    
     // 更新遍历次数
     iteration++;
    
     // 每遍历 16 次执行一次
     if ((iteration & 0xf) == 0 && /* check once every 16 iterations. */
     (ustime()-start) > timelimit)
     {
     // 如果遍历次数正好是 16 的倍数
     // 并且遍历的时间超过了 timelimit
     // 那么断开 timelimit_exit
     timelimit_exit = 1;
     }
    
     // 已经超时了,返回
     if (timelimit_exit) return;
    
     // 如果已删除的过期键占当前总数据库带过期时间的键数量的 25 %
     // 那么不再遍历
     } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
     }
    }

    hz调大将会提高Redis主动淘汰的频率,如果你的Redis存储中包含很多冷数据占用内存过大的话,可以考虑将这个值调大,但Redis作者建议这个值不要超过100。我们实际线上将这个值调大到100,观察到CPU会增加2%左右,但对冷数据的内存释放速度确实有明显的提高(通过观察keyspace个数和used_memory大小)。

    可以看出timelimit和server.hz是一个倒数的关系,也就是说hz配置越大,timelimit就越小。换句话说是每秒钟期望的主动淘汰频率越高,则每次淘汰最长占用时间就越短。这里每秒钟的最长淘汰占用时间是固定的250ms(1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/100),而淘汰频率和每次淘汰的最长时间是通过hz参数控制的。

    因此当redis中的过期key比率没有超过25%之前,提高hz可以明显提高扫描key的最小个数。假设hz为10,则一秒内最少扫描200个key(一秒调用10次*每次最少随机取出20个key),如果hz改为100,则一秒内最少扫描2000个key;另一方面,如果过期key比率超过25%,则扫描key的个数无上限,但是cpu时间每秒钟最多占用250ms。

    当REDIS运行在主从模式时,只有主结点才会执行上述这两种过期删除策略,然后把删除操作”del key”同步到从结点。

    
    if (server.active_expire_enabled && server.masterhost == NULL) // 判断是否是主节点 从节点不需要执行activeExpireCycle()函数。
     // 清除模式为 CYCLE_SLOW ,这个模式会尽量多清除过期键
     activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);

    随机个数

    redis.config.ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 决定每次循环从数据库 expire中随机挑选值的个数

    逐出算法

    如果不限制 reids 对内存使用的限制,它将会使用全部的内存。可以通过 config.memory 来指定redis 对内存的使用量 。

    下面是redis 配置文件中的说明

    543 # Set a memory usage limit to the specified amount of bytes.
     544 # When the memory limit is reached Redis will try to remove keys
     545 # according to the eviction policy selected (see maxmemory-policy).
     546 #
     547 # If Redis can’t remove keys according to the policy, or if the policy is
     548 # set to ‘noeviction’, Redis will start to reply with errors to commands
     549 # that would use more memory, like SET, LPUSH, and so on, and will continue
     550 # to reply to read-only commands like GET.
     551 #
     552 # This option is usually useful when using Redis as an LRU or LFU cache, or to
     553 # set a hard memory limit for an instance (using the ‘noeviction’ policy).
     554 #
     555 # WARNING: If you have replicas attached to an instance with maxmemory on,
     556 # the size of the output buffers needed to feed the replicas are subtracted
     557 # from the used memory count, so that network problems / resyncs will
     558 # not trigger a loop where keys are evicted, and in turn the output
     559 # buffer of replicas is full with DELs of keys evicted triggering the deletion
     560 # of more keys, and so forth until the database is completely emptied.
     561 #
     562 # In short… if you have replicas attached it is suggested that you set a lower
     563 # limit for maxmemory so that there is some free RAM on the system for replica
     564 # output buffers (but this is not needed if the policy is ‘noeviction’).
     
    将内存使用限制设置为指定的字节。当已达到内存限制Redis将根据所选的逐出策略(请参阅maxmemory策略)尝试删除数据。

    如果Redis无法根据逐出策略移除密钥,或者策略设置为“noeviction”,Redis将开始对使用更多内存的命令(如set、LPUSH等)进行错误回复,并将继续回复只读命令,如GET。

    当将Redis用作LRU或LFU缓存或设置实例的硬内存限制(使用“noeviction”策略)时,此选项通常很有用。

    警告:如果将副本附加到启用maxmemory的实例,则将从已用内存计数中减去馈送副本所需的输出缓冲区的大小,这样,网络问题/重新同步将不会触发收回密钥的循环,而副本的输出缓冲区将充满收回的密钥增量,从而触发删除更多键,依此类推,直到数据库完全清空。

    简而言之。。。如果附加了副本,建议您设置maxmemory的下限,以便系统上有一些空闲RAM用于副本输出缓冲区(但如果策略为“noeviction”,则不需要此限制)。

    驱逐策略的配置

    Maxmemery-policy volatile-lru

    当前已用内存超过 maxmemory 限定时,触发主动清理策略

    易失数据清理

    volatile-lru:只对设置了过期时间的key进行LRU(默认值)

    volatile-random:随机删除即将过期key

    volatile-ttl : 删除即将过期的

    volatile-lfu:挑选最近使用次数最少的数据淘汰

    全部数据清理

    allkeys-lru : 删除lru算法的key

    allkeys-lfu:挑选最近使用次数最少的数据淘汰

    allkeys-random:随机删除

    禁止驱逐

    (Redis 4.0 默认策略)

    noeviction : 永不过期,返回错误当mem_used内存已经超过maxmemory的设定,对于所有的读写请求都会触发redis.c/freeMemoryIfNeeded(void)函数以清理超出的内存。注意这个清理过程是阻塞的,直到清理出足够的内存空间。所以如果在达到maxmemory并且调用方还在不断写入的情况下,可能会反复触发主动清理策略,导致请求会有一定的延迟。

    清理时会根据用户配置的maxmemory-policy来做适当的清理(一般是LRU或TTL),这里的LRU或TTL策略并不是针对redis的所有key,而是以配置文件中的maxmemory-samples个key作为样本池进行抽样清理。

    maxmemory-samples在redis-3.0.0中的默认配置为5,如果增加,会提高LRU或TTL的精准度,redis作者测试的结果是当这个配置为10时已经非常接近全量LRU的精准度了,并且增加maxmemory-samples会导致在主动清理时消耗更多的CPU时间,建议:

    1 尽量不要触发maxmemory,最好在mem_used内存占用达到maxmemory的一定比例后,需要考虑调大hz以加快淘汰,或者进行集群扩容。

    2 如果能够控制住内存,则可以不用修改maxmemory-samples配置;如果Redis本身就作为LRU cache服务(这种服务一般长时间处于maxmemory状态,由Redis自动做LRU淘汰),可以适当调大maxmemory-samples。

    这里提一句,实际上redis根本就不会准确的将整个数据库中最久未被使用的键删除,而是每次从数据库中随机取5个键并删除这5个键里最久未被使用的键。上面提到的所有的随机的操作实际上都是这样的,这个5可以用过redis的配置文件中的maxmemeory-samples参数配置。

    数据逐出策略配置依据

    使用INFO命令输出监控信息,查询缓存int和miss的次数,根据业务需求调优Redis配置。

    总结

    到此这篇关于redis 数据删除策略和逐出算法的问题小结的文章就介绍到这了,更多相关redis 删除策略 逐出算法内容请搜索lingkb以前的文章或继续浏览下面的相关文章希望大家以后多多支持lingkb!