定时删除

定时删除策略对内存是最友好的: 因为它保证过期键会在第一时间被删除, 过期键所消耗的内存会立即被释放。

这种策略的缺点是, 它对 CPU 时间是最不友好的: 因为删除操作可能会占用大量的 CPU 时间 —— 在内存不紧张、但是 CPU 时间非常紧张的时候 (比如说,进行交集计算或排序的时候), 将 CPU 时间花在删除那些和当前任务无关的过期键上, 这种做法毫无疑问会是低效的。

除此之外, 目前 Redis 事件处理器对时间事件的实现方式 —— 无序链表, 查找一个时间复杂度为 O(N)O(N) —— 并不适合用来处理大量时间事件。

惰性删除

惰性删除对 CPU 时间来说是最友好的: 它只会在取出键时进行检查, 这可以保证删除操作只会在非做不可的情况下进行 —— 并且删除的目标仅限于当前处理的键, 这个策略不会在删除其他无关的过期键上花费任何 CPU 时间。

惰性删除的缺点是, 它对内存是最不友好的: 如果一个键已经过期, 而这个键又仍然保留在数据库中, 那么 dict 字典和 expires 字典都需要继续保存这个键的信息, 只要这个过期键不被删除, 它占用的内存就不会被释放。

在使用惰性删除策略时, 如果数据库中有非常多的过期键, 但这些过期键又正好没有被访问的话, 那么它们就永远也不会被删除(除非用户手动执行), 这对于性能非常依赖于内存大小的 Redis 来说, 肯定不是一个好消息。

举个例子, 对于一些按时间点来更新的数据, 比如日志(log), 在某个时间点之后, 对它们的访问就会大大减少, 如果大量的这些过期数据积压在数据库里面, 用户以为它们已经过期了(已经被删除了), 但实际上这些键却没有真正的被删除(内存也没有被释放), 那结果肯定是非常糟糕。

定期删除

从上面对定时删除和惰性删除的讨论来看, 这两种删除方式在单一使用时都有明显的缺陷: 定时删除占用太多 CPU 时间, 惰性删除浪费太多内存。

定期删除是这两种策略的一种折中:

  • 它每隔一段时间执行一次删除操作,并通过限制删除操作执行的时长和频率,籍此来减少删除操作对 CPU 时间的影响。
  • 另一方面,通过定期删除过期键,它有效地减少了因惰性删除而带来的内存浪费。

Redis 使用的策略

Redis 使用的过期键删除策略是惰性删除加上定期删除, 这两个策略相互配合,可以很好地在合理利用 CPU 时间和节约内存空间之间取得平衡。

 

实现过期键惰性删除策略的核心是 db.c/expireIfNeeded 函数 —— 所有命令在读取或写入数据库之前,程序都会调用 expireIfNeeded 对输入键进行检查, 并将过期键删除:

digraph expire_check {
    
    node [style = filled, shape = plaintext];

    edge [style = bold];

    // node

    write_commands [label = "SET 、\n LPUSH 、\n SADD 、 \n 等等", fillcolor = "#FADCAD"];

    read_commands [label = "GET 、\n LRANGE 、\n SMEMBERS 、 \n 等等", fillcolor = "#FADCAD"];

    expire_if_needed [label = "调用 expire_if_needed() \n 删除过期键", shape = box, fillcolor = "#A8E270"];

    process [label = "执行实际的命令流程"];

    // edge

    write_commands -> expire_if_needed [label = "写请求"];

    read_commands -> expire_if_needed [label = "读请求"];

    expire_if_needed -> process;

}

比如说, GET 命令的执行流程可以用下图来表示:

digraph get_with_expire {

    node [style = filled, shape = plaintext];

    edge [style = bold];

    // node
  
    get [label = "GET key", fillcolor = "#FADCAD"];

    expire_if_needed [label = "调用\n expire_if_needed() \n 如果键已经过期 \n 那么将它删除", shape = diamond, fillcolor = "#A8E270"];

    expired_and_deleted [label = "key 不存在\n 向客户端返回 NIL"];

    not_expired [label = "向客户端返回 key 的值"];

    get -> expire_if_needed;

    expire_if_needed -> expired_and_deleted [label = "已过期"];
    expire_if_needed -> not_expired [label = "未过期"];

}

expireIfNeeded 的作用是, 如果输入键已经过期的话, 那么将键、键的值、键保存在 expires 字典中的过期时间都删除掉。

用伪代码描述的 expireIfNeeded 定义如下:

def expireIfNeeded(key):

    # 对过期键执行以下操作 。。。
    if key.is_expired():

        # 从键空间中删除键值对
        db.dict.remove(key)

        # 删除键的过期时间
        db.expires.remove(key)

        # 将删除命令传播到 AOF 文件和附属节点
        propagateDelKeyToAofAndReplication(key)


过期键的定期删除策略

对过期键的定期删除由 redis.c/activeExpireCycle 函执行: 每当 Redis 的例行处理程序 serverCron 执行时, activeExpireCycle 都会被调用 —— 这个函数在规定的时间限制内, 尽可能地遍历各个数据库的 expires 字典, 随机地检查一部分键的过期时间, 并删除其中的过期键。

整个过程可以用伪代码描述如下:

def activeExpireCycle():

    # 遍历数据库(不一定能全部都遍历完,看时间是否足够)
    for db in server.db:

        # MAX_KEY_PER_DB 是一个 DB 最大能处理的 key 个数
        # 它保证时间不会全部用在个别的 DB 上(避免饥饿)
        i = 0
        while (i < MAX_KEY_PER_DB):

            # 数据库为空,跳出 while ,处理下个 DB
            if db.is_empty(): break

            # 随机取出一个带 TTL 的键
            key_with_ttl = db.expires.get_random_key()

            # 检查键是否过期,如果是的话,将它删除
            if is_expired(key_with_ttl):
                db.deleteExpiredKey(key_with_ttl)

            # 当执行时间到达上限,函数就返回,不再继续
            # 这确保删除操作不会占用太多的 CPU 时间
            if reach_time_limit(): return

            i += 1

小结

  • 数据库主要由 dict 和 expires 两个字典构成,其中 dict 保存键值对,而 expires 则保存键的过期时间。
  • 数据库的键总是一个字符串对象,而值可以是任意一种 Redis 数据类型,包括字符串、哈希、集合、列表和有序集。
  • expires 的某个键和 dict 的某个键共同指向同一个字符串对象,而 expires 键的值则是该键以毫秒计算的 UNIX 过期时间戳。
  • Redis 使用惰性删除和定期删除两种策略来删除过期的键。
  • 更新后的 RDB 文件和重写后的 AOF 文件都不会保留已经过期的键。
  • 当一个过期键被删除之后,程序会追加一条新的 DEL 命令到现有 AOF 文件末尾。
  • 当主节点删除一个过期键之后,它会显式地发送一条 DEL 命令到所有附属节点。
  • 附属节点即使发现过期键,也不会自作主张地删除它,而是等待主节点发来 DEL 命令,这样可以保证主节点和附属节点的数据总是一致的。
  • 数据库的 dict 字典和 expires 字典的扩展策略和普通字典一样。它们的收缩策略是:当节点的填充百分比不足 10% 时,将可用节点数量减少至大于等于当前已用节点数量。
 
posted on 2020-07-28 18:39  围龙小子  阅读(258)  评论(0编辑  收藏  举报