Redis过期策略

0. 前言

Redis所有的数据结构都可以设置过期时间,时间一到就会被自动删除。但是会不会因为统一时间太多的key过期,导致Redis执行执行出现卡顿。因为Redis是单线程的,收割的时间也会占用线程的处理时间,因此Redis需要采用一定的key的过期策略

1. 过期key集合

Redis会将每个设置了过期时间的key放入独立字典中,以后会定时遍历这个字典来删除到期的key。除了定时遍历外,他还会使用惰性策略来删除过期的key。

所谓的惰性删除就是在客户端访问这个key的时候,Redis对key的过期时间进行检查,如果过期就立即删除。

如果说定时删除就是集中处理,那么惰性删除就是零散处理

2. 定时扫描策略

Redis默认每秒进行10次过期扫描,过期扫描不会遍历过期字典中的所有key,而是采用了一种简单的贪心策略,步骤如下:

  1. 从过期字典中随机选出20个key.
  2. 删除这20个key中已经过期的key.
  3. 如果过期的key的比列超过1/4,那就重复步骤1

同时,为了保证过期扫描不会出现循环过度,导致线程卡死,该策略还加了扫描时间的上限,默认不会超过25ms。

💥假设一个大型的Redis实列中所有的key在同一时间过期了,会出现怎么样的结果?

⌚️Redis会持续扫描过期字典,直到过期字典中的key变得稀疏,才会停止。这样就会导致线上读写请求出现明显的卡顿现象。导致卡顿的另外一种原因是内存管理器需要频繁回收内存页,这也会产生一定的CPU消耗。

当客户端请求到来时,服务器如果正好进入过期扫描状态,客户端请求将会至少等待25ms后才会进行处理,如果客户端连接的超时时间设置的非常短(10ms),那么就会出现大量的连接因为超时而关闭,业务端就会出现很多异常。而且,这时还无法从Redis的slowlog中看到慢查询记录,因为慢查询指的是逻辑处理过程慢,不包含等待时间

所以业务开发人员一定要注意过期时间,如果出现大批的key过期,要给过期时间设置一个随机范围,而不能全部设置在统一时间

# 在目标过期时间上增加一天的随机时间
redis.expire_at(key, random.randomint(86400) + expire_ts)

在一些活动系统中,因为活动是一期一会,下一期活动举办时,前面几期活动的很多数据都可以丢弃了,所以需要给相关的活动设置一个过期时间,以减少不必要的Redis内存占用。如果不加注意,就可能会将过期时间设置为活动结束时间,导致大量的key同时过期。

从结点的过期策略

从节点不会进行过期扫描,从结点对过期的处理是被动的,主节点在key到期时,会在AOF文件中增加一条del指令,同步到所有从节点,从节点执行这个del指令来删除过期key,因为指令同步是异步进行的,所以如果主节点过期的key的del没有及时同步到从节点的化,就会出现主从数据不一致的情况,主结点没有的数据在从节点中还会存在。

3. LRU

当Redis内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换,交换会让Redis的性能急剧下降。在生产环境中,是不允许Redis出现交换行为的,未来限制最大使用内存,Redis提供了配置参数maxmemory来限制内存超出期望的大小。

当实际内存超出了maxmemory时,Redis提供了几种可选策略来让用户自己决定该如何腾出新的空间以继续提供读写服务。

  • noeviction:不会继续服务写请求(del请求可以继续服务),读请求可以继续进行,这样可以保证不会丢失数据,但是会让线上的业务不能持续进行。这个是默认的淘汰策略
  • volatile-lru:尝试淘汰已经设置了过期时间的key,最少使用的key优先被淘汰,没有设置过期时间的key不会被淘汰,这样可以保证 需要持久化的数据不会突然丢失
  • volatile-ttl:和``volatile-lru`几乎一样,不过淘汰的策略不是LRU,而是比较key的剩余寿命ttl值,剩余寿命越小的 优先被淘汰
  • volatile-random:和上面几乎一样,不过淘汰的策略是设置了过期key集合中随机的key.
  • allkeys-lru:区别于volatile-xxx,这个策略要淘汰的key对象是全体key集合,而不只是设置了过期的key集合,也就意味着一些没有设置过期时间的key也会被淘汰。
  • allkeys-random:淘汰的key是全体随机的key。

volatile-xxx策略只会针对带过期时间的Key进行淘汰,allkeys-xxx策略会对所有的key进行淘汰。如果只是拿Redis做缓存,那么应该使用allkeys-xxx策略,客户端写缓存时不必携带过期时间,如果想同时使用Redis的持久化功能,那就使用volatile-xxx策略,这样可以保证没有设置过期时间的Key不会被淘汰,它们是永久的key。

LRU算法

实现LRU算法除了需要key/value字典外,还需要附加一个链表,链表中的元素按照一定的顺序进行排列。当空间满的时候,会移除链表尾部的元素。当字典的某个元素被访问时,它在链表的位置会被移动到表头,所以链表的元素排列顺序就是元素最近被访问的时间顺序。

from collections import OrderedDict

LRUDict(OrderedDict):

    def __init__(self, capacity):
        super(LRUDict, self).__init__()
        self.capacity = capacity
        self.items = OrderedDict()

    def __setitem__(self, key, value):
        old_value = self.items.get(key)
        if old_value is not None:
            self.items.pop(key)
            self.items[key] = value
        elif len(self.items) < self.capacity:
            self.items[key] = value
        else:
            self.items.popitem(last=True)
            self.items[key] = value

    def __getitem__(self, key):
        value = self.items.get(key)
        if value is not None:
            self.items.pop(key)
            self.items[key] = value
        return value

    def __repr__(self):
        return repr(self.items)


if __name__ == '__main__':
    d = LRUDict(10)
    for i in range(15):
        d[i] = i
    print(d)

近似LRU算法

Redis使用的是一种近视LRU算法,它跟LRU算法不太一样,之所以不适用LRU算法,是因为其需要消耗大量的额外内存,需要对现有的数据结构进行较大的改造。

近似LRU算法在现有数据结构的基础上使用随机采样法来淘汰元素,能达到和LRU算法非常近似的效果。Redis为了实现近似LRU算法,给每个key增加了一个额外的小字段,这个字段的长度是24bit,也就是最后依次被访问的时间戳。

之前提到处理key过期方式分为集中处理和懒惰处理,LRU淘汰的处理方式只有懒惰处理。当Redis执行写操作时,发现内存超出maxmemory,就会执行依次LRU淘汰算法,该算法随机采样出5(数量可以自己设置)个key,然后淘汰掉最旧的key,如果淘汰后内存还是超出maxmemory,那就继续采样淘汰,直到内存低于maxmemory为止。

4. 懒惰删除

删除指令del会直接释放对象的内存,大部分情况下,这个指令非常快,没有明显的延迟。不过如果被删除的key是一个非常大的对象,比如一个包含了上千万个元素的hash,那么删除操作就会导致单线程卡顿。

Redis为了解决这个卡顿问题,在4.0版本中引入了unlink指令,它能对删除操作进行懒处理,丢给后台线程来异步回收内存

> unlink key
OK

❓这样删除key会不会引起线程安全问题?

不会,打个比方,可以将整个Redis内存里面所有有效的数据想象成一棵大树,当unlink指令发出时,他只是把大树中的一个树枝剪断了,然后扔到异步线程池去处理,在树枝离开大树的一瞬间,它旧再也无法被主线程访问,因此不会引起线程安全问题。

flush

Redis提供了flushdbflushall指令,用来清空数据库,这也是非常缓慢的操作,Redis4.0同样给这两个指令带来了异步化,在指令后面增加async参数就可以将整整棵大树连根拔起,然后丢给后台线程慢慢处理

> flushall async
OK

异步队列

主线程将对象的引用从"大树"中摘除后,会将这个key的内存回收操作包装成一个任务,放入异步任务队列中,后台线程会从这个异步队列中获取任务。任务队列被主线程和异步线程同时操作,所以必须是一个线程安全的队列。

不是所有的unlink操作都会延后处理,如果对应的key对象占用的内存很小,Redis会将对应key的内存立即回收。

AOF Sync 也很慢

Redis需要每秒执行1(该数量可以设置)次AOF同步日志到磁盘,确保消息尽量不丢失,需要调用sync(同步)函数,这个操作比较耗时,会导致主线程的效率下降,所以Redis也将这个操作移到异步线程来完成。执行AOF sync操作的线程是一个独立的异步线程,和前面提到的懒惰删除线程不是一个线程,同样它也有一个属于自己的任务队列,队列里存放的AOF sync任务。

更多异步删除点

除了del指令和flush操作之外,Redis在key的过期,LRU淘汰,rename指令过程中,也会实施回收内存,此外,还有一种特殊的flush操作,其发生于正在进行全量同步的从节点中,在接受完整的rdb文件后,也需要将当前的内存中数据一次清空,以加载整个rdb文件的内容。

Redis4.0为删除点也带来了异步删除机制,打开这些点需要额外的设置选项:

slave-lazy-flush # 从结点接受完rdb文件后的flush操作
lazyfree-lazy-eviction  # 内存达到maxmemory时进行淘汰
lazyfree-lazy-expire key # 过期删除
lazyfree-lazy-server-del rename # 指令删除destKey