Redis内存回收

Redis本质上一个存储系统。所有的存储系统在数据量过大的情况下都会面临存储瓶颈,包括 MySQL, RabbitMQ等等。

这里我们解决要两个问题:
首先,作为一个内存的KV系统,Redis服务肯定不是无限制地使用内存,应该设置一个上限(max_memory)。
第二个,数据应该有过期属性, 这样就能清除不再使用的key。

网上有一个面试题就是:Redis内存满了怎么办?

我们先看一下key过期怎么处理,再看内存达到上限怎么处理。

内存回收

过期策略

要实现key过期,我们有几种思路。

立即过期(主动淘汰)

每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。

惰性过期(被动淘汰)

只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
例如所有的查询都会调用expirelfNeeded判断是否过期:

db.c 1299 行

expireIfNeeded(redisDb *db, robj *key)

第二种情况,每次写入key时,发现内存不够,调用activeExpireCycle释放一部分内存。

expire.c 123行

activeExpireCycle(int type)

定期过期

每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。

server.h 661 行

typedef struct redisDb {
  diet *dict;       /* 所有的键值对 */
  diet *expires;     /* 设置 了过期时间的键值对 */ 
  diet *blocking_keys;
  diet *ready_keys;
  diet *watched_keys; 
  int id; 
  long long avg_ttl;
  unsigned long expires_cursor; 
  list *defrag_later; 
} redisDb;

总结:Redis中同时使用了惰性过期和定期过期两种过期策略,并不是实时地清除过期的key。如果所有的key都没有设置过期属性,Redis内存满了怎么办?

淘汰策略

Redis的内存淘汰策略,是指当内存使用达到最大内存极限时,需要使用淘汰算法来决定清理掉哪些数据,以保证新数据的存入。

最大内存设置

# maxmemory <bytes>

如果不设置maxmemory或者设置为0, 32位系统最多使用3GB内存,64位系统不限制内存。

动态修改(先get一下):

redis> config set maxmemory 2GB

到达最大内存以后怎么办?

淘汰策略

https://redis.io/topics/lru-cache

redis.conf

maxmemory-policy noeviction

# volatile-lru -> Evict using approximated LRU, only keys with an expire set.
# allkeys-lru -> Evict any key using approximated LRU.
# volatile-lfu -> Evict using approximated LFU, only keys with an expire set.
# allkeys-lfu -> Evict any key using approximated LFU.
# volatile-random -> Remove a random key having an expire set.
# allkeys-random -> Remove a random key, any key.
# volatile-ttl -> Remove the key with the nearest expire time (minor TTL)

先从后缀的算法名来看:

LRU, Least Recently Used:最近最少使用。判断最近被使用的时间,目前最远的数据优先被淘汰。
LFU, Least Frequently Used,最不常用,按照使用频率删除,4.0版本新增。
random,随机删除。
从前缀针对的对象来分:volatile是针对设置了ttl的key, allkeys是针对所有key。

策略 含义
volatile-lru 根据LRU算法删除设置超时属性(expire)的键,直到腾岀足够内存为止。如果没有可删除的键对象,回退到noeviction策略。
allkeys-lru 根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够内存为止。
volatile-lfu 在带有过期时间的键中选择最不常用的。
allkeys-lfu 在所有的键中选择最不常用的,不管数据有没有设置超时属性。
volatile-random 在带有过期时间的键中随机选择。
allkeys-random 随机删除所有键,直到腾出足够内存为止
volatile-ttl 根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。
noeviction 默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error ) OOM command not allowed when used memory,此时 Redis 只响应读操作。

如果没有设置ttl或者没有符合前提条件的key被淘汰,那么volatile-lru、 volatile-random、volatile-ttl 相当于 noeviction (不做内存回收)。

动态修改淘汰策略(先get一下):

redis> config set maxmemoiy-policy volatile-lru

建议使用volatile-lru,在保证正常服务的情况下,优先删除最近最少使用的key。

LRU淘汰原理

LRU是一个很常见的算法,比如InnoDB的Buffer Pool也用到了 LRU。
传统的LRU:通过链表+HashMap实现,设置链表长度,如果新增或者被访问,就移动到头节点。超过链表长度,末尾的节点被删除。

问题:如果基于传统LRU算法实现Redis LRU会有什么问题?

需要额外的数据结构存储,消耗内存。Redis LRU对传统的LRU算法进行了改良,通过随机采样来调整算法的精度。

如果淘汰策略是LRU,则根据配置的采样值maxmemory_samples (默认是5个), 随机从数据库中选择m个key,淘汰其中热度最低的key对应的缓存数据。所以采样参数m配置的数值越大,就越能精确的查找到待淘汰的缓存数据,但是也消耗更多的CPU计 算,执行效率降低。

问题:如何找出热度最低的数据?

Redis中所有对象结构都有一个Iru字段,且使用了 unsigned的低24位,这个字段 用来记录对象的热度。对象被创建时会记录Iru值。在被访问的时候也会更新Iru的值。 但并不是获取系统当前的时间戳,而是设置为全局变量server.lruclock的值

源码:server.h 622 行

typedef struct redisObject (
  unsigned type:4;       /* 对象的类型,包括:OBJ_STRING, OBJ_LIST、OBJ_HASH、OBJ_SET、OBJ_ZSET */
  unsigned encoding:4;      /* 具体的数据结构 */
  unsigned lru:LRU_BITS;       /* 24位,对象最后一次被命令程序访问的时间,与内存回收有关*/
                                         /* LRU time (relative to global lru_clock) or
                                          * LFU data (least significant 8 bits frequency
                                          * and most significant 16 bits access time). */
 	
  int refcount;                  /*引用计数。当refcount为0的时候,表示该对象已经不被任何对象引用,则可以进行垃圾回收了 */
  void *ptr;                      /*指向对象实际的数据结构*/
} robj;

server.lruclock的值怎么来的?

Redis中有个定时处理的函数serverCron ,默认每100毫秒调用函数 updateCachedTime更新一次全局变量的server.lruclock的值,它记录的是当前unix 时间戳。

源码:server.c 1756 行

void updateCachedTime(int update_daylight_info) {
  server.ustime = ustime();
  server.mstime = server.ustime / 1000;
  server.unixtime = seiver.mstime / 1000;
  /* To get information about daylight saving time, we need to call
    * localtime r and cache the result. However calling localtime_r in this
    * context is safe since we will never fbrk() while here, in the main
    * thread. The logging fimction will call a thread safe version of
    * localtime that has no locks. */  
  if (update_daylight_info) {
    struct tm tm;
    time_t ut = seiver.unixtime;
    localtime_r(&ut,&tm);
    server. daylight_active = tm.tm_isdst;
  } 
}

问题:为什么不获取精确的时间而是放在全局变量中?不会有延迟的问题吗?

这样函数査询key调用lookupKey中更新数据的Iru热度值时,就不用每次调用系统函数time,可以提高执行效率。OK,当对象里面已经有了 LRU字段的值,就可以评估对象的热度了。

/* Given an object returns the min number of milliseconds the object was never
* requested, using an approximated LRU algorithm. */
unsigned long long estimateObjectIdleTime(robj *o) {
  unsigned long long Iruclock = LRU_CLOCK();
  if (Iruclock >= o->lru) (
    return (Iruclock - o->lru) * LRU_CLOCK_RESOLUTION;
  } else (
    return (Iruclock + (LRU_CLOCK_MAX - o->lru)) * LRU_CLOCK_RESOLUTION;
  }
}

函数estimateObjectldleTime评估指定对象的Iru热度,方法就是对象的Iru值和全局的server.lruclock的差值越大(越久没有得到更新),该对象热度越低。
server.lruclock只有24位,按秒为单位来表示才能存储194天。当超过24bit能表示的最大时间的时候,它会从头开始计算。
在这种情况下,可能会出现对象的Iru大于server.lruclock的情况,如果这种情况出现那么就两个相加而不是相减来求最久的key。

为什么不用常规的哈希表+双向链表的方式实现?需要额外的数据结构,消耗资源。

而Redis LRU算法在sample为10的情况下,已经能接近传统LRU算法了。

https://redis.io/topics/lru-cache

问题:除了消耗资源之外,传统LRU还有什么问题?

如图,假设A在10秒内被访问了5次,而B在10秒内被访问了3次。因为B最后一次被访问的时间比A要晚,在同等的情况下,A反而先被回收。

问题:要实现基于访问频率的淘汰机制,怎么做?

LFU

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;
} robj;

当这24 bits用作LFU时,其被分为两部分:
高16位用来记录访问时间(单位为分钟,Idt, last decrement time)
低8位用来记录访问频率,简称counter (logc, logistic counter) counter是用基于概率的对数计数器实现的,8位可以表示百万次的访问频率。 对象被读写的时候,lfu的值会被更新。

db.c 55 行 ---lookupKey

void updateLFU(robj *val) {
  unsigned long counter = LFUDecrAndReturn(val);
  counter = LFULogIncr(counter);
  val->lru = (LFUGetTimelnMinutes()«8) | counter;
}

当然,这里并不是访问一次,计数就加1。增长的速率由一个参数决定,Ifu-log-factor越大,counter增长的越慢
redis.conf配置文件:

# lfu-log-factor 10

注意一下,这个算法是LRU,如果一段时间热点高,就一直保持这个热度,肯定也是不行的,体现不了整体频率。所以,没有被访问的时候,计数器还要递减。
没有被访问的时候,计数器怎么递减呢?
减少的值由衰减因子Ifu-decay-time (分钟)来控制,如果值是1的话,N分钟没有访问,计数器就要减少N。Ifu-decay-time越大,衰减越慢。

redis.conf配置文件

# lfu-decay-time 1
posted @   snail灬  阅读(47)  评论(0编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
阅读排行:
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 零经验选手,Compose 一天开发一款小游戏!
· 一起来玩mcp_server_sqlite,让AI帮你做增删改查!!
点击右上角即可分享
微信分享提示