Redis入坟(四)Redis内存回收知多少

时间:2022-07-24
本文章向大家介绍Redis入坟(四)Redis内存回收知多少,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

Reids 所有的数据都是存储在内存中的,在某些情况下需要对占用的内存空间进行回收。内存回收主要分为两类,一类是 key 过期,一类是内存使用达到上限(max_memory)触发内存淘汰。

过期策略

定时过期(主动淘汰)

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

惰性过期(被动淘汰)

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

例如 String,在 getCommand 里面会调用 expireIfNeeded server.c expireIfNeeded(redisDb *db, robj *key)

第二种情况,每次写入 key 时,发现内存不够,调用 activeExpireCycle 释放一部分内存。 expire.c activeExpireCycle(int type)

定期过期

源码: server.h

typedef struct redisDb {
  dict *dict; /* 所有的键值对 */
  dict *expires; /* 设置了过期时间的键值对 */
  dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
  dict *ready_keys; /* Blocked keys that received a PUSH */
  dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
  int id; /* Database ID */
  long long avg_ttl; /* Average TTL, just for stats */
  list *defrag_later; /* List of key names to attempt to defrag one by   one, gradually. */
} redisDb;

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

Redis 中同时使用了惰性过期和定期过期两种过期策略。 问题:如果都不过期,Redis 内存满了怎么办?

淘汰策略

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

最大内存设置

redis.conf 参数配置:maxmemory <bytes> 指定Redis最大内存限制,Redis在启动时会把数据加载到内存中,达到最大内存后,Redis会先尝试清除已到期或即将到期的Key,当此方法处理后,仍然到达最大内存设置,将无法再进行写入操作,但仍然可以进行读取操作。Redis新的vm机制,会把Key存放内存,Value会存放在swap区。

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

动态修改:

redis> config set maxmemory 2GB

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

淘汰策略

maxmemory-policy noeviction 当内存使用达到最大值时,redis使用的清除策略。 官方解释:

# volatile-lru -> Evict using approximated LRU among the keys with an expire set.
# allkeys-lru -> Evict any key using approximated LRU.
# volatile-lfu -> Evict using approximated LFU among the keys with an expire set.
# allkeys-lfu -> Evict any key using approximated LFU.
# volatile-random -> Remove a random key among the ones 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 evict anything, just return an error on write operations

先从算法来看:

  • LRU,Least Recently Used:最近最少使用。判断最近被使用的时间,目前最远的数据优先被淘汰。
  • LFU,Least Frequently Used,最不常用,4.0 版本新增。
  • random,随机删除。

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

动态修改淘汰策略:redis> config set maxmemory-policy volatile-lru

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

LRU 淘汰原理

问题:如果基于传统 LRU 算法实现 Redis LRU 会有什么问题? 需要额外的数据结构存储,消耗内存。Redis LRU 对传统的 LRU 算法进行了改良,通过随机采样来调整算法的精度。

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

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

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

typedef struct redisObject {
  unsigned type:4;
  unsigned encoding:4;
  unsigned lru:LRU_BITS; 
  int refcount;
  void *ptr;
} robj;

server.lruclock 的值怎么来的?

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

源码:server.c

void updateCachedTime(void) {
  time_t unixtime = time(NULL);
  atomicSet(server.unixtime,unixtime);
  server.mstime = mstime();
  ​
  struct tm tm;
  localtime_r(&server.unixtime,&tm);
  server.daylight_active = tm.tm_isdst;
}

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

这样函数 lookupKey 中更新数据的 lru 热度值时,就不用每次调用系统函数 time,可以提高执行效率。

OK,当对象里面已经有了 LRU 字段的值,就可以评估对象的热度了。 函数 estimateObjectIdleTime 评估指定对象的 lru 热度,思想就是对象的 lru 值和全局的 server.lruclock 的差值越大(越久没有得到更新), 该对象热度越低。

源码 evict.c

unsigned long long estimateObjectIdleTime(robj *o) {
  unsigned long long lruclock = LRU_CLOCK();
  if (lruclock >= o->lru) {
    return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
  } else {
    return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
    LRU_CLOCK_RESOLUTION;
  }
}

server.lruclock 只有 24 位,按秒为单位来表示才能存储 194 天。当超过 24bit 能表示的最大时间的时候,它会从头开始计算。

server.h

#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */

在这种情况下,可能会出现对象的 lru 大于 server.lruclock 的情况,如果这种情况出现那么就两个相加而不是相减来求最久的 key。

为什么不用常规的哈希表+双向链表的方式实现?需要额外的数据结构,消耗资源。而 Redis LRU 算法在 sample 为 10 的情况下,已经能接近传统 LRU 算法了。

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

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

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

JAVA实现LRU算法

public class LRUCache {
    // KV形式存储缓存
    private HashMap<String, LRUNode> map;
    private int capacity; // 链表容量
    private LRUNode head; // 头结点
    private LRUNode tail; // 尾节点

    public void set(String key, Object value) {
        // 设置值,节被被访问时,移除节点,放到队头
        LRUNode node = map.get(key);
        if (node != null) {
            node = map.get(key);
            node.value = value;
            remove(node, false);
        } else {
            node = new LRUNode(key, value);
            if (map.size() >= capacity) {
                // 每次容量不足时先删除最久未使用的元素
                remove(tail, true);
            }
            map.put(key, node);
        }
        // 将刚添加的元素设置为head
        setHead(node);
    }

    // 取值,节被被访问时,移除节点,放到队头
    public Object get(String key) {
        LRUNode node = map.get(key);
        if (node != null) {
            // 将刚操作的元素放到head
            remove(node, false);
            setHead(node);
            return node.value;
        }
        return null;
    }

    // 将节点设置为头节点
    private void setHead(LRUNode node) {
        // 先从链表中删除该元素
        if (head != null) {
            node.next = head;
            head.prev = node;
        }
        head = node;
        if (tail == null) {
            tail = node;
        }
    }

    // 从链表中删除此Node,需注意该Node是head或者是tail的情形
    private void remove(LRUNode node, boolean flag) {
        if (node.prev != null) {
            node.prev.next = node.next;
        } else {
            head = node.next;
        }
        if (node.next != null) {
            node.next.prev = node.prev;
        } else {
            tail = node.prev;
        }
        node.next = null;
        node.prev = null;
        if (flag) {
            map.remove(node.key);
        }
    }

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.map = new HashMap<String, LRUNode>();
    }

    // 链表中的节点
    class LRUNode {
        String key;
        Object value;
        LRUNode prev;
        LRUNode next;
        public LRUNode(String key, Object value) {
            this.key = key;
            this.value = value;
        }
    }
}

LFU

当这 24 bits 用作 LFU 时,其被分为两部分:

  • 高 16 位用来记录访问时间(单位为分钟,ldt,last decrement time)
  • 低 8 位用来记录访问频率,简称 counter(logc,logistic counter)

counter 是用基于概率的对数计数器实现的,8 位可以表示百万次的访问频率。对象被读写的时候,lfu 的值会被更新。

db.c——lookupKey

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

增长的速率由,lfu-log-factor 越大,counter 增长的越慢。 redis.conf 配置文件:# lfu-log-factor 10

如果计数器只会递增不会递减,也不能体现对象的热度。没有被访问的时候,计数器怎么递减呢?

减少的值由衰减因子 lfu-decay-time(分钟)来控制,如果值是 1 的话,N 分钟没有访问就要减少 N。 redis.conf 配置文件# lfu-decay-time 1