始因
有时候线上可能会遇到这样的问题:
明明我设置了对应的 key 以及超时时间,但是在使用的过程当中发现对应的 key 丢失了,尤其是在用户账号登录状态保持有效期的场景下,会越发的明显。即:一个用户正常登录会产生一个有效期为一天的 token,这样用户再次进入网站是不需要登录的。但是发生 key 丢失问题就会导致用户需要频繁的重新登录,用户体验相当不好。导致这种问题的原因一般有以下两种情况:
1. token 生成时出现逻辑问题
2. 验证 token 时出问题了
对于上线稳定的项目来说,发生 1 的概率基本为 0。那么会立马定位到 2 的情况。这种情况就会引发我们今天讨论的问题:
redis 如何自动清理过期 key,以及对应 key 没有过期但是也会被清理掉呢?说人话:redis 内部如何清理过期 key?
常见删除策略(抛开 redis)
- 定时删除:在设置键的过期时间的同时,创建一个定时器 timer。让定时器在键的过期时间来临时,立即执行对键的删除操作。
- 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。
- 定期删除:每隔一段时间程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。
在上述的三种策略中 定时删除 和 定期删除 属于不同时间粒度的 主动删除,惰性删除属于 被动删除。
以上三种策略都有各自的优缺点:
1. 定时删除 对内存使用率有优势,但是对 CPU 不友好;
2. 惰性删除 对内存不友好,如果某些键值对一直不被使用,那么会造成一定量的内存浪费;
3. 定期删除 是 定时删除 和 惰性删除 的折中。
正常情况下 redis 中的实现
Reids 采用的是 惰性删除 和 定时删除 的结合,一般来说可以借助 最小堆 来实现 定时器,不过 Redis 的设计考虑到时间事件的有限种类 和 数量,使用了 无序链表 存储时间事件,这样如果在此基础上实现定时删除,就意味着 O(N)
遍历获取最近需要删除的数据。
定期删除策略
Redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,默认每 100ms 进行一次过期扫描:
-
随机抽取 20 个 key
-
删除这 20 个key中过期的key
-
如果过期的 key 比例超过 1/4,就重复步骤 1,继续删除。
为什不扫描所有的 key?
Redis 是单线程,全部扫描岂不是卡死了。而且为了防止每次扫描过期的 key 比例都超过 1/4,导致不停循环卡死线程,Redis 为每次扫描添加了上限时间,默认是 25ms。
如果客户端将超时时间设置的比较短,比如 10ms,那么就会出现大量的链接因为超时而关闭,业务端就会出现很多异常。而且这时你还无法从 Redis 的 slowlog 中看到慢查询记录,因为慢查询指的是逻辑处理过程慢,不包含等待时间。
如果在同一时间出现大面积 key 过期,Redis 循环多次扫描过期词典,直到过期的 key 比例小于 1/4。这会导致卡顿,而且在高并发的情况下,可能会导致缓存雪崩。
为什么 Redis 为每次扫描添的上限时间是 25ms,还会出现上面的情况?
因为 Redis 是单线程,每个请求处理都需要排队,而且由于 Redis 每次扫描都是 25ms,也就是每个请求最多 25ms,100 个请求就是 2500ms。
如果有大批量的 key 过期,要给过期时间设置一个随机范围,而不宜全部在同一时间过期,分散过期处理的压力。
从库的过期策略
从库不会进行过期扫描,从库对过期的处理是被动的。主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。
因为指令同步是异步进行的,所以主库过期的 key 的 del 指令没有及时同步到从库的话,会出现主从数据的不一致,主库没有的数据在从库里还存在。
懒惰删除策略
Redis 为什么要懒惰删除(lazy free)?
删除指令 del 会直接释放对象的内存,大部分情况下,这个指令非常快,没有明显延迟。不过如果删除的 key 是一个非常大的对象,比如一个包含了千万元素的 hash,又或者在使用 FLUSHDB 和 FLUSHALL 删除包含大量键的数据库时,那么删除操作就会导致单线程卡顿。
redis 4.0 引入了 lazyfree 的机制,它可以将删除键或数据库的操作放在后台线程里执行, 从而尽可能地避免服务器阻塞。
unlink
unlink 指令,它能对删除操作进行懒处理,丢给后台线程来异步回收内存。
> unlink key OK
flush
flushdb 和 flushall 指令,用来清空数据库,这也是极其缓慢的操作。Redis 4.0 同样给这两个指令也带来了异步化,在指令后面增加 async 参数就可以将整棵大树连根拔起,扔给后台线程慢慢焚烧。
> flushall async OK
内存淘汰通过近似 LRU来实现
在解释近似LRU之前,先来简单了解一下LRU。
当 Redis 的内存占用超过我们设置的 maxmemory 时,会把长时间没有使用的key清理掉。按照 LRU算法,我们需要对所有key(也可以设置成只淘汰有过期时间的key)按照空闲时间进行排序,然后淘汰掉空闲时间最大的那部分数据,使得Redis的内存占用降到一个合理的值。
LRU算法的缺点:
1. 我们需要维护一个全部(或只有过期时间)key的列表,还要按照最近使用时间排序。这会消耗大量内存
2. 每次操作 key 时更新对应维护列表的排序也会占用额外的CPU资源。
对于Redis这样对性能要求很高的系统来说是不被允许的。
因此,Redis采用了一种 近似LRU 的算法。当 Redis 接收到新的写入命令,而内存又不够时,就会触发 近似LRU 算法来强制清理一些key。
具体清理的步骤是:
1. Redis会对 key 进行采样,通常是取5个,然后会把过期的key放到我们上面说的“过期池”中
2. 过期池中的 key 是按照空闲时间来排序的,Redis 会优先清理掉空闲时间最长的 key,直到内存小于 maxmemory。
清理策略
最后我们来看一下Redis支持的几种清理策略
1. noeviction:不会继续处理写请求(DEL可以继续处理)。
2. allkeys-lru:对所有key的近似LRU
3. volatile-lru:使用近似LRU算法淘汰设置了过期时间的key
4. allkeys-random:从所有key中随机淘汰一些key
5. volatile-random:对所有设置了过期时间的key随机淘汰
6. volatile-ttl:淘汰有效期最短的一部分key
Redis4.0 开始支持了 LFU 策略,和 LRU 类似,它分为两种:
7. volatile-lfu:使用LFU算法淘汰设置了过期时间的key
8. allkeys-lfu:从全部key中进行淘汰,使用LFU
最后
针对文章开始提到的问题,最好的解决办法是将使用内存量较大的业务 和 用户账号服务 使用的 redis 隔离开,这样就单个用户账号正常情况下是不会发生以上类似的问题了。