一、关于Redis内存回收
Redis是基于内存操作的非关系型数据库,Redis中提供了多种内存回收策略,当内存容量不足时,为了保证程序的运行,这时就不得不淘汰内存中的一些对象,释放这些对象占用的空间,那么选择淘汰哪些对象呢?
Redis的内存回收,主要围绕以下两种方式:
- 1、Redis过期策略:删除已经过期的数据。
- 2、Redis淘汰策略:内存使用到达maxmemory上限时触发内存淘汰数据。
注意:过期策略和淘汰策略是两种不同的概念。
二、Redis过期策略
在Redis中,提供了expire命令设置一个键的过期时间,到期之后Redis会自动删除它,这个在我们的实际使用过程中用的非常多。
Redis中设置过期时间有如下两种方式:
- 1、expire命令:expire key seconds(先set key,然后设置过期时间。其中seconds 参数表示键的过期时间,单位为秒。expire 返回值为1表示设置成功,0表示设置失败或者键不存在)
- 2、setex命令:setex key seconds value(设置键的同时,直接设置过期时间)
expire命令的seconds单位为秒,最小精确至1秒,如果想要更精确的控制键的过期时间,可以使用pexpire命令,pexpire命令的单位是毫秒。pexpire key 1000与expire key 1相等。
Redis 过期时间相关命令
1、设置过期时间
Redis 提供了四个命令来设置过期时间(生存时间):
- EXPIRE <key> <ttl>:表示将键 key 的生存时间设置为 ttl 秒;
- PEXPIRE <key> <ttl>:表示将键 key 的生存时间设置为 ttl 毫秒;
- EXPIREAT <key> <timestamp>:表示将键 key 的生存时间设置为 timestamp 所指定的秒数时间戳;
- PEXPIREAT <key> <timestamp>:表示将键 key 的生存时间设置为 timestamp 所指定的毫秒数时间戳。
在Redis内部实现中,前面三个设置过期时间的命令最后都会转换成最后一个PEXPIREAT 命令来完成。
2、移除过期时间
- PERSIST <key>:表示将 key 的过期时间移除。
3、查看键的剩余过期时间
- TTL <key>:以秒的单位返回键 key 的剩余生存时间;
- PTTL <key>:以毫秒的单位返回键 key 的剩余生存时间。
三种过期策略
-
1、定时删除: 在设置key的过期时间的同时,为该key创建一个定时器,让定时器在key的过期时间来临时,对key进行删除。
- 优点:保证内存被尽快释放
- 缺点:若过期key很多,删除这些key会占用很多的CPU时间,在CPU时间紧张的情况下,CPU不能把所有的时间用来做要紧的事儿,还需要去花时间删除这些key。
-
2、惰性删除: key过期的时候不删除, 每次从数据库获取key的时候去检查是否过期,若过期,则删除,返回null。
- 优点:删除操作只发生在从数据库取出key的时候发生,而且只删除当前key,所以对CPU时间的占用是比较少的,而且此时的删除是已经到了非做不可的地步(如果此时还不删除的话,我们就会获取到了已经过期的key了)。
- 缺点:若大量的key在超出超时时间后,很久一段时间内,都没有被获取过,那么可能发生内存泄露(无用的垃圾占用了大量的内存)。
-
3、定期删除: 每隔一段时间执行一次删除过期key操作。
- 优点:通过限制删除操作的时长和频率,来减少删除操作对CPU时间的占用–处理"定时删除"的缺点,定期删除过期key–处理"惰性删除"的缺点。
- 缺点:在内存友好方面,不如"定时删除",在CPU时间友好方面,不如"惰性删除"。
难点:合理设置删除操作的执行时长(每次删除执行多长时间)和执行频率(每隔多长时间做一次删除)(这个要根据服务器运行情况来定了)。
Redis采用的过期策略:惰性删除+定期删除。
1、Redis定期删除策略
由redis.c/activeExpireCycle函数实现,函数以一定频率执行,每当Redis的服务器性执行redis.c/serverCron函数时,activeExpireCycle函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键 。
- Redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,默认每 100ms 进行一次过期扫描,随机抽取 20 个 key,删除这 20 个key中过期的key。
- 如果过期的 key 比例超过 1/4,就重复上述步骤,继续删除。
为什不扫描所有的 key?
- Redis 是单线程,全部扫描岂不是卡死了。而且为了防止每次扫描过期的 key 比例都超过 1/4,导致不停循环卡死线程,Redis 为每次扫描添加了上限时间,默认是 25ms。
如果在同一时间出现大面积 key 过期,Redis 循环多次扫描过期词典,直到过期的 key 比例小于 1/4。这会导致卡顿,而且在高并发的情况下,可能会导致缓存雪崩。
从库的过期策略
从库不会进行过期扫描,从库对过期的处理是被动的。主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。
因为指令同步是异步进行的,所以主库过期的 key 的 del 指令没有及时同步到从库的话,会出现主从数据的不一致,主库没有的数据在从库里还存在。
注意
Redis 的定期删除策略并不是一次运行就检查所有的库、所有的键,而是随机检查一定数量的键。
定期删除函数的运行频率,在 Redis2.6 版本中,规定每秒运行 10 次,大概 100ms 运行一次。在 Redis2.8 版本后,可以通过修改配置文件 redis.conf 的 hz 选项来调整这个次数:
...
# The range is tetween 1 and 500, however a value over 100 is usually not
# a good idea. Most users should use the default of 10 and raise this up to
# 100 only in environments where very low latency is requiried.
hz 10
...
在这个参数的上面注释可以看出,建议不要将这个值设置超过100,一般使用默认的10,只有当在需要非常低延迟的场景才设置为100。
2、Redis惰性删除策略
Redis的惰性删除策略由db.c/expireIfNeeded函数实现,所有键读写命令执行之前都会调用expireIfNeeded函数对其进行检查,如果过期,则删除该键,然后执行键不存在的操作;未过期则不作操作,继续执行原有的命令。
3、Redis 过期删除策略的问题
虽然 Redis 采用了惰性删除和定期删除两种策略,但对于一些永远使用不到的键,并且经过多次定期删除也没有被选定到并删除,那么这些键就会一直驻留在内存中。
所以,这时候就需要使用 Redis 的内存淘汰策略来解决了。
三、Redis 内存淘汰策略
- 1、设置 Redis 最大内存
在配置文件 redis.conf 中,可以通过参数 maxmemory <bytes> 来设定最大内存:
# In short... if you have slaves attached it is suggested that you set a lower
# limit for maxmemory so that there is some free RAM on the system for slave
# output buffers (but this is not needed if the policy is 'noeviction').
#
maxmemory <bytes>
# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory
# is reached. You can select among five behaviors:
#
# volatile-lru -> remove the key with an expire set using an LRU algorithm
# allkeys-lru -> remove any key according to the LRU algorithm
# volatile-random -> remove a random key with an expire set
# allkeys-random -> remove a random key, any key
# volatile-ttl -> remove the key with the nearest expire time (minor TTL)
# noeviction -> don't expire at all, just return an error on write operations
Redis可以设置内存大小:maxmemory 100mb,超过了这个内存大小,就会触发内存淘汰机制;
配置:在redis.conf 配置文件中,可以设置淘汰方式:maxmemory-policy noeviction
Redis 4.0开始,共有8种数据淘汰机制:见Redis的配置文件redis.conf:
针对设置过期时间的键值对:即使缓存没有写满,这些数据如果过期了,也会被删除。
除。当然,如果它的过期时间到了但未被策略选中,同样也会被删除。
设置过期时间的键值对
- volatile-random:从设置了过期时间的键值对中,进行随机删除。
- volatile-ttl:从设置了过期时间的键值对中,根据过期时间的先后进行删除,越早过期的越先被删除。
- volatile-lru:从设置了过期时间的键值对中,使用LRU算法筛选,移除最近最少使用的key。
- volatile-lfu:从设置了过期时间的键值对中,使用LFU算法筛选,移除最近最少使用的key。在 LRU算法的基础上,同时考虑了数据的访问时效性和数据的访问次数。
所有键值对
- allkeys-random:从所有键值对中,随机选择并删除数据;
- allkeys-lru:从所有键值对中,使用LRU算法筛选,移除最近最少使用的key;
- allkeys-lfu:从所有键值对中,使用LFU算法筛选,移除最近最少使用的key;
- noevction:不进行数据淘汰。一旦缓存被写满了,再有写请求来时,Redis不再提供服务,而是直接返回错误。默认选项,一般不会选用。
除 noeviction 比较特殊外,allkeys 开头的将从所有数据中进行淘汰,volatile 开头的将从设置了过期时间的数据中进行淘汰。淘汰算法又核心分为 lru、random、ttl、lfu 几种。如下图所示:
结和归纳
总体来说,可以从2个维度,四个方面来个8中淘汰策略分类:
-
从2个维度中筛选key:
- 过期键中筛选:volatile-ttl
- 所有键中筛选:allkeys-lru、volatile-lru、allkeys-random、 volatile-random、allkeys-lfu、volatile-lfu
-
从4个方面上筛选key:
- lru(最近最久未使用的键删除):allkeys-lru、volatile-lru
- lfu(最低频次的键删除):allkeys-lfu、volatile-lfu
- random(随机删除):allkeys-random、 volatile-random
- ttl(到了过期时间的键删除):volatile-ttl
生产环境中,如何配置缓存淘汰策略
- 生产环境中,推荐使用allkeys-lrul作为内存的淘汰策略,保证对所有key使用LRU算法进行删除
- 通过修改文件配置(永久生效):配置 maxmemory-policy 字段
maxmemory-policy allkeys-lru
- 使用命令查询和设置淘汰策略:
## 设置内存数据的淘汰策略为volatile-lru
config set maxmemory-policy volatile-lru
config get maxmemory-policy
四、Redis的LRU、LFU算法
1、LRU算法
LRU(Least Recently Used)最近最少使用。优先淘汰最近未被使用的数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。LRU底层结构是 hash 表 + 双向链表。hash 表用于保证查询操作的时间复杂度是O(1),双向链表用于保证节点插入、节点删除的时间复杂度是O(1)。
为什么是 双向链表而不是单链表呢?单链表可以实现头部插入新节点、尾部删除旧节点的时间复杂度都是O(1),但是对于中间节点时间复杂度是O(n),因为对于中间节点c,我们需要将该节点c移动到头部,此时只知道它的下一个节点,要知道其上一个节点需要遍历整个链表,时间复杂度为O(n)。
LRU GET操作:如果节点存在,则将该节点移动到链表头部,并返回节点值;
LRU PUT操作:①节点不存在,则新增节点,并将该节点放到链表头部;②节点存在,则更新节点,并将该节点放到链表头部。
【LRU缓存】【hash+双向链表】结构示意图如下:
2、近似LRU算法原理(approximated LRU algorithm)
Redis为什么不使用原生LRU算法?
- 原生LRU算法需要 双向链表 来管理数据,需要额外内存;
- 数据访问时涉及数据移动,有性能损耗;
- Redis现有数据结构需要改造;
在Redis中,Redis的key的底层结构是 redisObject,redisObject 中 lru: LRU_BITS 字段用于记录该key最近一次被访问时的Redis时钟 server.lruclock(Redis在处理数据时,都会调用lookupKey方法用于更新该key的时钟)。不太理解Redis时钟的,可以将其先简单理解成时间戳(不影响我们理解近似LRU算法原理。
# Redis的key的底层结构,源码位于:server.h
typedef struct redisObject {
unsigned type:4; // 类型
unsigned encoding:4; // 编码
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount; // 引用计数
void *ptr; // 指向存储实际值的数据结构的指针,数据结构由 type、encoding 决定。
} robj;
当 mem_used > maxmemory 时,Redis通过 freeMemoryIfNeeded 方法完成数据淘汰。LRU策略淘汰核心逻辑在 evictionPoolPopulate(淘汰数据集合填充) 方法。
Redis 近似LRU 淘汰策略逻辑:
-
首次淘汰:随机抽样选出【最多N个数据】放入【待淘汰数据池 evictionPoolEntry】;
- 数据量N:由 redis.conf 配置的 maxmemory-samples 决定,默认值是5,配置为10将非常接近真实LRU效果,但是更消耗CPU;
再次淘汰:随机抽样选出【最多N个数据】,只要数据比【待淘汰数据池 evictionPoolEntry】中的【任意一条】数据的 lru 小,则将该数据填充至 【待淘汰数据池】;
执行淘汰:挑选【待淘汰数据池】中 lru 最小的一条数据进行淘汰;
Redis为了避免长时间或一直找不到足够的数据填充【待淘汰数据池】,代码里(dictGetSomeKeys 方法)强制写死了单次寻找数据的最大次数是 [maxsteps = count*10; ],count 的值其实就是 maxmemory-samples。
3、LFU算法原理
LFU 使用 Morris counter 概率计数器,仅使用几比特就可以维护 访问频率,Morris算法利用随机算法来增加计数,在 Morris 算法中,计数不是真实的计数,它代表的是实际计数的量级。
LFU数据淘汰策略下,redisObject 的 lru:LRU_BITS 字段(24位)将分为2部分存储:
- Ldt:last decrement time,16位,精度分钟,存储上一次 LOG_C 更新的时间。
- LOG_C:logarithmic counter,8位,最大255,存储key被访问频率。
注意:
- LOG_C 存储的是访问频率,不是访问次数;
- LOG_C 访问频率随时间衰减;
- 为什么 LOG_C 要随时间衰减?比如在秒杀场景下,热key被访问次数很大,如果不随时间衰减,此部分key将一直存放于内存中。
- 新对象 的 LOG_C 值 为 LFU_INIT_VAL = 5,避免刚被创建即被淘汰。
Redis的LFU算法
- LFU:Least Frequently Used,使用频率最少的(最不经常使用的):
- 优先淘汰最近使用的少的数据,其核心思想是“如果一个数据在最近一段时间很少被访问到,那么将来被访问的可能性也很小”。
4、LFU与LRU的区别
如果一条数据仅仅是突然被访问(有可能后续将不再访问),在 LRU 算法下,此数据将被定义为热数据,最晚被淘汰。但实际生产环境下,我们很多时候需要计算的是一段时间下key的访问频率,淘汰此时间段内的冷数据。LFU 算法相比 LRU,在某些情况下可以提升 数据命中率,使用频率更多的数据将更容易被保留。
对比项 | 近似LRU算法 | LFU算法 |
---|---|---|
最先过期的数据 | 最近未被访问的 | 最近一段时间访问的最少的 |
适用场景 | 数据被连续访问场景 | 数据在一段时间内被连续访问 |
缺点 | 新增key将占据缓存 | 历史访问次数超大的key淘汰速度取决于lfu-decay-time |
淘汰策略使用
- 优先使用 allkeys-lru 策略。可以充分利用 LRU 这一经典缓存算法的优势,把最近最常访问的数据留在缓存中,提升应用的访问性能。如果业务数据中有明显的冷热数据区分,建议使用allkeys-lru策略。
- 如果业务应用中的数据访问频率相差不大,没有明显的冷热数据区分,建议使用allkeys-random策略,随机选择淘汰的数据就行。
- 如果业务中有置顶的需求,比如置顶新闻、置顶视频,那么,可以使用volatile-lru策略,同时不给这些置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据 LRU规则进行筛选。
缓存污染
在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。这种情况,就是缓存污染。
当缓存污染不严重时,只有少量数据占据缓存空间,此时,对缓存系统的影响不大。但是,缓
https://blog.csdn.net/Extraordinarylife/article/details/127344560存污染一旦变得严重后,就会有大量不再访问的数据滞留在缓存中。如果这时数据占满了缓存空间,我们再往缓存中写入新数据时,就需要先把这些数据逐步淘汰出缓存,这就会引入额外的操作时间开销,进而会影响应用的性能。
要解决缓存污染问题,最关键的技术点就是能识别出这些只访问一次或是访问次数很少的数据,在淘汰数据时,优先把它们筛选出来并淘汰掉。所以采用LFU策略
- volatile-lfu
- allkeys-lfu
参考:
https://blog.csdn.net/Extraordinarylife/article/details/127344560
https://blog.nowcoder.net/n/e7f3994adb62441ca40041f6854b3cb9
https://blog.csdn.net/FlyingFish868/article/details/125964074
https://blog.csdn.net/weixin_43863054/article/details/126243012
https://blog.csdn.net/aiwangtingyun/article/details/123995143
https://blog.csdn.net/DQWERww/article/details/126453008
https://blog.csdn.net/weixin_38871362/article/details/125678494