提起使用Redis的优点,大家可以列举出许多,比如:数据存储在内存,读写速度快,性能优异。比如数据持久化,便于数据备份及恢复等等。
分布式服务系统平台发展至今,Redis活跃在平台的各个领域,比如日志系统、秒杀、限时活动等等。我们通常会将Redis的数据分为永久及过期删除数据。那么到过期时间后,数据是通过什么策略删除的呢?Redis又提供了哪些过期删除策略?
提到数据的过期删除,又不得不让我们联想到Redis的内存淘汰机制,两者都是删除数据,又有什么不同之处呢?
带着这些问题,我们来一起探究下Redis的过期删除与内存淘汰机制。
- 过期删除
1.Redis Expires Dict及删除逻辑源码解读
Redis可以对key设置过期时间,每当我们对一个key设置了过期时间时,Redis会把该key带上过期时间存储到一个过期字典中。过期字典是存储在redisDb这个结构里的,过期字典的键指向Redis数据库中的某个key,过期字典的值是一个long long类型的整数,这个整数保存了key所指向的数据库键的过期时间(毫秒精度的UNIX时间戳)。结构如下图所示:
Redis 在访问或者修改 key 之前,都会调用 expireIfNeeded 函数对其进行检查,检查 key 是否过期:
- 如果过期,则删除该 key,至于选择异步删除,还是选择同步删除,根据lazyfree_lazy_expire 参数配置决定(Redis 4.0版本开始提供参数),然后返回 null 给客服端;
- 如果没有过期,不做任何处理,然后返回正常的键值对给客户端;
过期判断逻辑:
删除逻辑的底层代码如下:
2.过期删除策略
Redis的数据删除有定时删除(立即删除)、惰性删除和定期删除三种方式。
定时过期:每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
惰性过期:只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
定期过期:每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。
上面介绍了三种过期删除策略,每一种都有优缺点,仅使用某一个策略都不能满足实际需求,所以Redis选择惰性删除+定期删除这两种策略配合使用,以求在合力使用CPU时间和避免内存浪费之间取得平衡。
定时删除与惰性删除这两种策略大家都好理解,那么Redis是怎么实现定期删除的呢?
在Redis中,默认每秒进行10次过期检查,此配置可以通过Redis的配置文件redis.conf进行配置。配置redis.conf的hz选项默认为10(即1秒执行10次,100ms一次,值越大说明刷新频率越快,对redis性能损耗也越大)。
过期键的定期删除策略由 redis.c/activeExpireCycle 函数实现,调用流程为 serverCron() -> databasesCron() -> activeExpireCycle()。核心代码如下(为了方便查看核心部分,对代码进行了截取):
这里有两个问题,
问题1:定期删除时候,每次随机抽取的数量是多少?
这个是由上图activeExpireCycle函数的ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP定义的,这个值是写死在程序中的,值为20。也就是说,每轮抽查时,会随机选择20个key判断是否过期。
问题2:定期删除是一个循环流程,redis如何保证不会出现循环过度,导致线程卡死现象?
为此,增加了定时删除循环流程的时间上限,默认不超过25ms
问题3: redis是单线程的,线程不仅处理定时任务,还处理客户端请求。单线程redis,如何知道要执行定时任务呢?
Redis 的定时任务会记录在一个称为最小堆的数据结构中。这个堆中,最快要执行的任务排在堆的最上方。在每个循环周期,Redis 都会将最小堆里面已经到点的任务立即进行处理。处理完毕后,将最快要执行的任务还需要的时间记录下来,这个时间就是接下来处理客户端请求的最大时长,若达到了该时长,则暂时不处理客户端请求而去运行定时任务。
- 内存淘汰策略
前面部分我们讨论的是删除已过期的key,而当redis的运行内存达到了我们设置的最大运行内存时,才会触发内存淘汰策略。
32位和64位操作系统,maxmemory的默认值是不同的。
64位操作系统:maxmemory的默认值是0,表示没有内存大小限制,那么不管用户存放多少数据到reids,redis也不会对可用内存进行检查,直到redis实例因内存不足而崩溃为止。
32位操作系统:maxmemory的默认值是3G,因为32位的机器最大只支持4GB的内存,而系统本身就需要一定的内存资源来支持运行,所以32位操作系统限制最大3GB的可用内存是非常合理的,这样可以避免因为内存不足而导致Redis实例崩溃。
在 4.0 版本之前 Redis 的内存淘汰策略有以下 6 种。
策略名称 | 说明 |
noeviction | 不淘汰任何数据,当内存不足时,执行缓存新增操作会报错,它是 Redis 默认内存淘汰策略 |
allkeys-lru | 淘汰整个键值中最久未使用的键值 |
allkeys-random | 随机淘汰任意键值 |
volatile-lru | 淘汰所有设置了过期时间的键值中最久未使用的键值 |
volatile-random | 随机淘汰设置了过期时间的任意键值 |
volatile-ttl | 优先淘汰更早过期的键值 |
而在 Redis 4.0 版本中又新增了 2 种淘汰策略:
策略名称 | 说明 |
volatile-lfu | 淘汰所有设置了过期时间的键值中最少使用的键值 |
allkeys-lfu | 淘汰整个键值中最少使用的键值 |
内存淘汰策略我们可以通过配置文件来修改,redis.conf 对应的配置项是“maxmemory-policy noeviction”,只需要把它修改成我们需要设置的类型即可。
需要注意的是,如果使用修改 redis.conf 的方式,当设置完成之后需要重启 Redis 服务器才能生效。还有另一种简单的修改内存淘汰策略的方式,我们可以使用命令行工具输入“config set maxmemory-policy noeviction”来修改内存淘汰的策略,这种修改方式的好处是执行成功之后就会生效,无需重启 Redis 服务器。但它的坏处是不能持久化内存淘汰策略,每次重启 Redis 服务器之后设置的内存淘汰策略就会丢失。
LRU 算法和 LFU 算法
- LRU 算法
LRU 全称是 Least Recently Used 翻译为最近最少使用,会选择淘汰最近最少使用的数据。
传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。
Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题:
- 需要用链表管理所有的缓存数据,这会带来额外的空间开销;
- 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。
Redis 是如何实现 LRU 算法的?
Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个。
Redis 实现的 LRU 算法的优点:
- 不用为所有的数据维护一个大链表,节省了空间占用;
- 不用在每次数据访问时都移动链表项,提升了缓存的性能;
但是 LRU 算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。
因此,在 Redis 4.0 之后引入了 LFU 算法来解决这个问题。
- LFU 算法
LFU 全称是 Least Frequently Used 翻译为最近最不常用的,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。
所以, LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。
- 总结
Redis使用惰性删除+定期删除来管理已过期的key。而内存淘汰策略是为了解决内存过大问题,当redis的运行内存超过最大运行内存时,就会触发内存淘汰策略。