Redis内存管理(淘汰策略与过期键删除策略)

Redis的内存用完了会发生什么?

如果达到设置的上限(默认noeviction)时,Redis的写命令会返回oom错误信息(读命令还可以正常返回)。

redis.exceptions.ResponseError, OOM command not allowed when used memory > 'maxmemory'

或者配置内存淘汰机制,当Redis达到内存上限时会冲刷掉旧的内容。

一、内存说明

1.1 内存信息

查看Redis内存相关信息使用命令:info memory

属性名 属性说明
used_memory Redis分配器分配的内存总量,也就是内部存储的所有数据内存占用量
used_memory_human 以可读的格式返回used_memory
used_memory_rss 从操作系统的角度显示Redis进程占用的物理内存总量
used_memory_rss_human used_memory_rss的用户宜读格式的显示
used_memory_peak 内存使用的最大值,表示used_memory的峰值
used_memory_peak_human 以可读的格式返回used_memory_peak的值
used_memory_lua Lua引擎所消耗的内存大小
mem_fragmentation_ratio used_memory_rss/used_memory的比值,可以代表内存碎片率
maxmemory Redis能够使用的最大内存上限,0表示没有限制,以字节为单位

mem_fragmentation_ratio > 1时,说明有部分内存并没有用于数据存储,而是被内存碎片所消耗,如果该值很大,说明碎片率严重。当mem_fragmentation_ratio < 1时,这种情况一般出现在操作系统把Redis内存交换(swap)到硬盘导致,出现这种情况要格外关注,由于硬盘速度远远慢于内存,Redis性能会变得很差,甚至僵死。

Redis内存超出可以获得内存时,操作系统会进行swap,将旧的页写入硬盘。从硬盘读写大概比从内存读写要慢5个数量级。used_memory指标可以帮助判断Redis是否有被swap的风险或者它已经被swap

建议要设置和内存一样大小的交换区,如果没有交换区,一旦Redis突然需要的内存大于当前操作系统可用内存时,Redis会因为out of memory而被Linix KernelOOM Killer直接杀死。虽然当Redis的数据被换出(swap out) 时,Redis的性能会变差,但是总比直接被杀死的好。

1.2 内存组成

  1. 自身内存:Redis自身运行所消耗的内存,一般很小。
  2. 对象内存:这是Redis消耗内存最大的一块,存储着用户所有的数据。
  3. 缓冲内存:缓冲内存主要包括:客户端缓冲、复制积压缓冲区、AOF缓冲区。
    • 客户端缓冲:是指客户端连接Redis之后,输入或者输出数据的缓冲区,其中输出缓冲可以通过配置参数参数client-output-buffer-limit控制。
    • 复制积压缓冲区:一个可重用的固定大小缓冲区用于实现部分复制功能,根据repl-backlog-size参数控制,默认1MB。对于复制积压缓冲区整个主节点只有一个,所有的从节点共享此缓冲区,因此可以设置较大的缓冲区空间,如100MB,这部分内存投入是有价值的,可以有效避免全量复制。
    • AOF缓冲区:这部分空间用于在Redis重写期间保存最近的写入命令,AOF缓冲区空间消耗用户无法控制,消耗的内存取决于AOF重写时间和写入命令量,这部分空间占用通常很小。
  4. 内存碎片:当然,这是所有内存分配器无法避免的通病,但是可以优化。

二、淘汰策略

Redis内存淘汰策略是指在Redis的用于缓存的内存不足时,怎么处理需要新写入且需要申请额外空间的数据。

全局的键空间选择性移除

  • noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。(默认)
  • allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。(常用)
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key
  • allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最近频率最少使用的key

设置过期时间的键空间选择性移除

  • volatile-lru(least recently used):当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。
  • volatile-lfu(least frequently used):当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近频率最少使用的key

lfuRedis 4.0后的策略。

2.1 缓存污染

缓存污染:访问频率低或者不会被再次访问的数据,但又占据了缓存空间。

要解决缓存污染的关键点是能识别出只访问一次或者访问次数很少的数据。

从能否解决缓存污染这一维度来分析Redis8种缓存淘汰策略:

缓存淘汰策略 分析 解决缓存污染
noeviction 不淘汰数据 不能
volatile-ttl 当缓存写满时,会淘汰剩余存活时间最短的数据,避免滞留在缓存中,造成污染。
volatile-random 随机选择数据,无法把不再访问的数据筛选出来,会造成缓存污染。 不能
allkeys-random
volatile-lru LRU策略只考虑数据的访问时效,对只访问一次的数据,不能很快筛选出来。 不能
allkeys-lru
volatile-lfu LFU策略在LRU策略基础上进行了优化,筛选数据时优先筛选并淘汰访问次数少的数据。
allkeys-lfu

三、淘汰算法

3.1 FIFO

FIFO(First in First out)先进先出。可以理解为是一种类似队列的算法实现。

最先进来的数据,被认为在未来被访问的概率也是最低的,因此,当规定空间用尽且需要放入新数据的时候,会优先淘汰最早进来的数据。

  • 优点:最简单、最公平的一种数据淘汰算法,逻辑简单清晰,易于实现。
  • 缺点:这种算法逻辑设计所实现的缓存的命中率是比较低的,因为没有任何额外逻辑能够尽可能的保证常用数据不被淘汰掉。

下面简单演示了FIFO的工作过程,假设存放元素尺寸是3,且队列已满,放置元素顺序如下图所示,当来了一个新的数据“ldy”后,因为元素数量到达了阈值,则首先要进行太淘汰置换操作,然后加入新元素,操作如图展示:

3.2 LRU

LRU(The Least Recently Used)最近最久未使用算法。相比于FIFO算法智能些。

如果一个数据最近很少被访问到,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰最久未被访问的数据。

  • 优点:LRU可以有效的对访问比较频繁的数据进行保护,也就是针对热点数据的命中率提高有明显的效果。
  • 缺点:对于周期性、偶发性的访问数据,有大概率可能造成缓存污染,也就是置换出去了热点数据,把这些偶发性数据留下了,从而导致LRU的数据命中率急剧下降。

下图展示了LRU简单的工作过程,访问时对数据的提前操作,以及数据满且添加新数据的时候淘汰的过程的展示如下:

3.2.1 算法实现

Java中,其实LinkedHashMap已经实现了LRU缓存淘汰算法,需要在构造函数第三个参数传入true,表示按照时间顺序访问。可以直接继承LinkedHashMap来实现。

import java.util.LinkedHashMap;
import java.util.Map;

public class LruCache<K, V> extends LinkedHashMap<K, V> {

    /**
     * 容量限制
     */
    private int capacity;

    LruCache(int capacity) {
        // 初始大小,0.75是装载因子,true是表示按照访问时间排序
        super(capacity, 0.75f, true);
        // 缓存最大容量
        this.capacity = capacity;
    }

    /**
     * 重写removeEldestEntry方法,如果缓存满了,则把链表头部第一个节点和对应的数据删除。
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }
}

测试类

public class Test {
    public static void main(String[] args) {
        LruCache<String, String> cache = new LruCache(3);
        cache.put("keyA", "valueA");
        System.out.println("put keyA");
        System.out.println(cache);
        System.out.println("=========================");

        cache.put("keyB", "valueB");
        System.out.println("put keyB");
        System.out.println(cache);
        System.out.println("=========================");

        cache.put("keyC", "valueC");
        System.out.println("put keyC");
        System.out.println(cache);
        System.out.println("=========================");

        cache.get("keyA");
        System.out.println("get keyA");
        System.out.println(cache);
        System.out.println("=========================");

        cache.put("keyD", "valueD");
        System.out.println("put keyD");
        System.out.println(cache);
    }
}

运行结果如下:

put keyA
{keyA=valueA}
=========================
put keyB
{keyA=valueA, keyB=valueB}
=========================
put keyC
{keyA=valueA, keyB=valueB, keyC=valueC}
=========================
get keyA
{keyB=valueB, keyC=valueC, keyA=valueA}
=========================
put keyD
{keyC=valueC, keyA=valueA, keyD=valueD}

我们可以使用双向链表和哈希表进行实现,哈希表用于存储对应的数据,双向链表用于数据被使用的时间先后顺序。

在访问数据时,如果数据已存在缓存中,则把该数据的对应节点移到链表尾部。如此操作,在链表头部的节点则是最近最少使用的数据。

当需要添加新的数据到缓存时,如果该数据已存在缓存中,则把该数据对应的节点移到链表尾部;如果不存在,则新建一个对应的节点,放到链表尾部;如果缓存满了,则把链表头部第一个节点和对应的数据删除。

import java.util.HashMap;
import java.util.Map;

public class LruCache<K, V> {

    /**
     * 头结点
     */
    private Node head;
    /**
     * 尾结点
     */
    private Node tail;
    /**
     * 容量限制
     */
    private int capacity;
    /**
     * key和数据的映射
     */
    private Map<K, Node> map;

    LruCache(int capacity) {
        this.capacity = capacity;
        this.map = new HashMap<>();
    }

    public V put(K key, V value) {
        Node node = map.get(key);
        // 数据存在,将节点移动到队尾
        if (node != null) {
            V oldValue = node.value;
            // 更新数据
            node.value = value;
            moveToTail(node);
            return oldValue;
        } else {
            Node newNode = new Node(key, value);
            // 数据不存在,判断链表是否满
            if (map.size() == capacity) {
                // 如果满,则删除队首节点,更新哈希表
                map.remove(removeHead().key);
            }
            // 放入队尾节点
            addToTail(newNode);
            map.put(key, newNode);
            return null;
        }
    }

    public V get(K key) {
        Node node = map.get(key);
        if (node != null) {
            moveToTail(node);
            return node.value;
        }
        return null;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("LruCache{");
        Node curr = this.head;
        while (curr != null) {
            if(curr != this.head){
                sb.append(',').append(' ');
            }
            sb.append(curr.key);
            sb.append('=');
            sb.append(curr.value);
            curr = curr.next;
        }
        return sb.append('}').toString();
    }

    private void addToTail(Node newNode) {
        if (newNode == null) {
            return;
        }
        if (head == null) {
            head = newNode;
            tail = newNode;
        } else {
            // 连接新节点
            tail.next = newNode;
            newNode.pre = tail;
            // 更新尾节点指针为新节点
            tail = newNode;
        }
    }

    private void moveToTail(Node node) {
        if (tail == node) {
            return;
        }
        if (head == node) {
            head = node.next;
            head.pre = null;
        } else {
            // 调整双向链表指针
            node.pre.next = node.next;
            node.next.pre = node.pre;
        }
        node.pre = tail;
        node.next = null;
        tail.next = node;
        tail = node;
    }

    private Node removeHead() {
        if (head == null) {
            return null;
        }
        Node res = head;
        if (head == tail) {
            head = null;
            tail = null;
        } else {
            head = res.next;
            head.pre = null;
            res.next = null;
        }
        return res;
    }

    class Node {
        K key;
        V value;
        Node pre;
        Node next;

        Node(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }
}

结果如下:

put keyA
LruCache{keyA=valueA}
=========================
put keyB
LruCache{keyA=valueA, keyB=valueB}
=========================
put keyC
LruCache{keyA=valueA, keyB=valueB, keyC=valueC}
=========================
get keyA
LruCache{keyB=valueB, keyC=valueC, keyA=valueA}
=========================
put keyD
LruCache{keyC=valueC, keyA=valueA, keyD=valueD}

3.2.2 LRU变种

如上所述,对于偶发性、周期性的数据没有良好的抵抗力,很容易就造成缓存的污染,影响命中率,因此衍生出了很多的LRU算法的变种,用以处理这种偶发冷数据突增的场景,目的就是当判别数据为偶发或周期的冷数据时,不会存入空间内,从而降低热数据的淘汰率,比如:LRU-KTwo Queues等。

3.2.2.1 LRU-K

LRU-K中的K其实是指最近访问元素的次数,传统LRU与此对比则可以认为传统LRULRU-1。其核心思想就是将访问一次就能替代的“1”提升为"K"。

LRU-K算法需要维护两个队列:历史队列和缓存队列。

  • 历史队列:用于记录元素的访问次数,当尚未达到K次时则继续保存,直至历史队列满了,采用的淘汰策略(FIFOLRU)进行淘汰。
  • 缓存队列:当历史队列中的元素访问次数达到K的时候,才会进入缓存队列。当该队列面了之后,淘汰最后一个元素,也就是第K次访问距离现在最久的那个元素。

原理解析

元素第一次被访问,添加到历史队列中。当历史队列中的元素满了,根据一定的缓存策略(FIFOLRU)进行淘汰老的元素。

当历史队列中的某个元素第k次访问时,该元素从历史队列中出栈,并存放至缓存队列。

缓存队列中的元素再次被访问k次时,历史队列中该元素出栈,并且更新缓存队列中该元素的位置。

当缓存队列需要淘汰元素时,淘汰最后一个元素,也就是第k次访问距离现在最久的那个元素。

小结

它的命中率要比LRU要高,但是因为需要维护一个历史队列,因此内存消耗会比LRU多。

实际应用中LRU-2是综合各种因素后最优的选择,LRU-3或者更大的K值命中率会高,但适应性差,需要大量的数据访问才能将历史访问记录清除掉。

3.2.2.2 Two Queues

Two Queues(2Q)其实是LRU-K的一个具体版本:LRU-2。并且2Q的历史队列是采用FIFO的方法进行缓存的。

原理解析

同样维护两个队列:历史队列(采用FIFO的淘汰策略)和缓存队列(采用LRU-1的淘汰策略)。

  1. 新访问的数据插入到FIFO队列;
  2. 如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰;
  3. 如果数据在FIFO队列中被再次访问,则将数据移到LRU队列头部;
  4. 如果数据在LRU队列再次被访问,则将数据移到LRU队列头部;
  5. LRU队列淘汰末尾的数据。

小结

缺点跟LRU-K一致,其实2Q算法就是LRU-2,并且历史队列采用了FIFO的淘汰策略。

3.2.2.3 Multi Queue

Multi Queue(MQ)算法其实是2Q算法的一个扩展。2Q算法维护两个队列,而MQ算法根据访问频率将数据划分为多个队列,不同的队列具有不同的访问优先级。其核心思想是:优先缓存访问次数多的数据。

原理解析

MQ算法将缓存划分为多个LRU队列,每个队列对应不同的访问优先级。访问优先级是根据访问次数计算出来的,

详细的算法结构图如下,Q0,Q1....Qk代表不同的优先级队列,Q-history代表从缓存中淘汰数据,但记录了数据的索引和引用次数的队列:

如上图,算法详细描述如下:

  1. 新插入的数据放入Q0
  2. 每个队列按照LRU管理数据;
  3. 当数据的访问次数达到一定次数,需要提升优先级时,将数据从当前队列删除,加入到高一级队列的头部;
  4. 为了防止高优先级数据永远不被淘汰,当数据在指定的时间里访问没有被访问时,需要降低优先级,将数据从当前队列删除,加入到低一级的队列头部;
  5. 需要淘汰数据时,从最低一级队列开始按照LRU淘汰;每个队列淘汰数据时,将数据从缓存中删除,将数据索引加入Q-history头部;
  6. 如果数据在Q-history中被重新访问,则重新计算其优先级,移到目标队列的头部;
  7. Q-history按照LRU淘汰数据的索引。

如果在高优先级队列中一个页面长时间没有被访问,当新页面进栈,没位置存放时,该长时间未被访问的页面就出栈,进入低一级优先级的队列中。

MQ算法需要维护多个队列以及多个页面的数据,成本较大。

3.3 LFU

LFU(The Least Frequently Used)最近很少使用算法,与LRU的区别在于LRU是以时间衡量,LFU是以时间段内的次数

如果一个数据在一定时间内被访问的次数很低,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰时间段内访问次数最低的数据。

  • 优点:LFU也可以有效的保护缓存,相对场景来讲,比LRU有更好的缓存命中率。因为是以次数为基准,所以更加准确,自然能有效的保证和提高命中率。
  • 缺点:因为LFU需要记录数据的访问频率,因此需要额外的空间;当访问模式改变的时候,算法命中率会急剧下降,这也是他最大弊端。

下面描述了LFU的简单工作过程,首先是访问元素增加元素的访问次数,从而提高元素在队列中的位置,降低淘汰优先级,后面是插入新元素的时候,因为队列已经满了,所以优先淘汰在一定时间间隔内访问频率最低的元素。

3.2.1 算法实现

import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class LfuCache<K, V> {

    /**
     * 容量限制
     */
    private int capacity;

    /**
     * 当前最小使用次数
     */
    private int minUsedCount;

    /**
     * key和数据的映射
     */
    private Map<K, Node> map;
    
    /**
     * 数据频率和对应数据组成的链表
     */
    private Map<Integer, List<Node>> usedCountMap;

    public LfuCache(int capacity) {
        this.capacity = capacity;
        this.minUsedCount = 1;
        this.map = new HashMap<>();
        this.usedCountMap = new HashMap<>();
    }

    public V get(K key) {
        Node node = map.get(key);
        if (node == null) {
            return null;
        }
        // 增加数据的访问频率
        addUsedCount(node);
        return node.value;
    }

    public V put(K key, V value) {
        Node node = map.get(key);
        if (node != null) {
            // 如果存在则增加该数据的访问频次
            V oldValue = node.value;
            node.value = value;
            addUsedCount(node);
            return oldValue;
        } else {
            // 数据不存在,判断链表是否满
            if (map.size() == capacity) {
                // 如果满,则删除队首节点,更新哈希表
                List<Node> list = usedCountMap.get(minUsedCount);
                Node delNode = list.get(0);
                list.remove(delNode);
                map.remove(delNode.key);
            }
            // 新增数据并放到数据频率为1的数据链表中
            Node newNode = new Node(key, value);
            map.put(key, newNode);
            List<Node> list = usedCountMap.get(1);
            if (list == null) {
                list = new LinkedList<>();
                usedCountMap.put(1, list);
            }

            list.add(newNode);
            minUsedCount = 1;
            return null;
        }
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("LfuCache{");
        List<Integer> usedCountList = this.usedCountMap.keySet().stream().collect(Collectors.toList());
        usedCountList.sort(Comparator.comparingInt(i -> i));
        int count = 0;
        for (int usedCount : usedCountList) {
            List<Node> list = this.usedCountMap.get(usedCount);
            if (list == null) {
                continue;
            }
            for (Node node : list) {
                if (count > 0) {
                    sb.append(',').append(' ');
                }
                sb.append(node.key);
                sb.append('=');
                sb.append(node.value);
                sb.append("(UsedCount:");
                sb.append(node.usedCount);
                sb.append(')');
                count++;
            }
        }
        return sb.append('}').toString();
    }

    private void addUsedCount(Node node) {
        List<Node> oldList = usedCountMap.get(node.usedCount);
        oldList.remove(node);

        // 更新最小数据频率
        if (minUsedCount == node.usedCount && oldList.isEmpty()) {
            minUsedCount++;
        }

        node.usedCount++;
        List<Node> set = usedCountMap.get(node.usedCount);
        if (set == null) {
            set = new LinkedList<>();
            usedCountMap.put(node.usedCount, set);
        }
        set.add(node);
    }

    class Node {

        K key;
        V value;
        int usedCount = 1;

        Node(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }
}

结果如下:

put keyA
LfuCache{keyA=valueA(UsedCount:1)}
=========================
put keyB
LfuCache{keyA=valueA(UsedCount:1), keyB=valueB(UsedCount:1)}
=========================
put keyC
LfuCache{keyA=valueA(UsedCount:1), keyB=valueB(UsedCount:1), keyC=valueC(UsedCount:1)}
=========================
get keyA
LfuCache{keyB=valueB(UsedCount:1), keyC=valueC(UsedCount:1), keyA=valueA(UsedCount:2)}
=========================
put keyD
LfuCache{keyC=valueC(UsedCount:1), keyD=valueD(UsedCount:1), keyA=valueA(UsedCount:2)}

3.4 小结

  • FIFO(先进先出算法):判断被存储的时间,离目前最远的数据优先被淘汰,可以使用队列实现。
  • LRU(最近最少使用算法):判断最近被使用的时间,目前最远的数据优先被淘汰,可以使用双向链表和哈希表实现。
  • LFU(最不经常使用算法):在一段时间内,数据被使用次数最少的,优先被淘汰,可以使用双哈希表实现。

其他算法

自适应缓存替换算法(ARC):在IBM%2BAlmaden研究中心开发,这个缓存算法同时跟踪记录LFULRU,以及驱逐缓存条目,来获得可用缓存的最佳使用。
最近最常使用算法(MRU):这个缓存算法最先移除最近最常使用的条目。一个MRU算法擅长处理一个条目越久,越容易被访问的情况。

四、过期键的删除策略

4.1 关于键的过期时间或生存时间

Redis数据库提供了常用的EXPIRE命令或者PEXPIRE命令,用户可以使用这两个命令以秒或者毫秒为精度为数据库中的某个键设置生存时间。在经过指定的时间后,Redis服务器就会自动删除生存时间为0的键。

可以设置键的生存时间的命令如下:

# 该命令用于将键Key的生存时间设置为ttl秒
EXPIRE <key> <ttl> 

# 该命令用于将键Key的生存时间设置为ttl毫秒
PEXPIRE <key> <ttl>

# 该命令用于将键Key的生存时间设置为timstamp所指定的秒数时间戳
EXPIREAT <key> <timstamp>

# 该命令用于将键Key的生存时间设置为timstamp所指定的毫秒数时间戳。
PEXPIREAT <key> <timstamp>

虽然有四种不同的命令用于指定过期时间,但是实际上,无论使用哪一种命令,最终都会转换为PEXPIREAT命令来执行。

那么,Redis是如何存储过期时间的呢?

typedef struct redisDb {
    dict *dict;                   /* The keyspace for this DB */
    dict *expires;                /* Timeout of keys with a timeout set */
    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 */
    unsigned long expires_cursor; /* Cursor of the active expire cycle. */
    list *defrag_later;           /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

我们可以通过以上源码看出,redisDb结构的expires这个字典保存了数据库中所有的过期时间,我们叫这个字典为过期字典。

每当我们为一个数据库的某一个键添加过期时间就会在该字典中添加一个键值对,键为这个需要添加过期时间的键,值为过期时间的时间戳。相反,如果删除一个键的过期时间,也会相应的操作这个字典,删除该键对应的过期时间键值对。如图所示:

4.2 过期删除策略

如果一个键过期了,那么什么时候被删除呢?

关于这个问题,可以实现的有一下三种方案(Redis只采用了其中两种):

4.2.1 定时删除

设置键的过期时间的同时,创建一个定时器,让定时器在键过期时间来临时,立即执行对键的删除操作。

优点:这种删除策略对于内存来说是友好的,因为这种删除方式可以保证过期的键尽可能快的被删除掉,并释放过期键所占用的内存。
缺点:

  • 这种删除策略对CPU时间不友好,在过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分的CPU时间,在内存不紧张的但是CPU时间紧张的情况下,这无疑会对服务器的响应时间和吞吐量造成影响。
  • 创建一个定时器需要用到Redis服务器中的时间时间,而当前时间时间的实现方式为无序链表,查找一个事件的时间复杂度为O(N),所以说,如果采用这种策略,并不能高效的处理大量的时间事件。

4.2.2 惰性删除

放任过期不管,但是每次从键空间中获取值的时候,检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。

优点:这种删除策略对于CPU来说是友好的,程序只会在取出键的时候才会对键进行过期检查,这样可以保证对键的删除操作仅限于当前处理的键,这个策略不会在删除其他过期的键上花费任何的时间。
缺点:显而易见的,这种删除策略对于内存来说是十分不友好的。因为如果大量的过期键,长期不使用的情况下,就会造成大量的内存被无效的键占用。我们甚至可以将这中情况看作是内存泄露。

4.2.3 定期删除

每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键,至于要删除多少个过期键,以及要检查多少个数据库则由算法决定。

针对定期删除来说,这种策略实际上是定时删除和惰性删除这两种策略的折中和整合。定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行时长和频率来减少删除操作对CPU时间的影响。除此之外,通过定期删除过期键,定期删除策略有效的减少了因为过期键而带来的内存浪费。

当然,这种策略的难点就在于如何确定删除的时长和频率。比如,如果设定的删除太频繁或者执行删除的时间太长,就直接回退化为定时删除。如果删除的频率过低或者指定的时间太短,定期删除又会和惰性删除一样,造成内存浪费的情况。

4.3 Redis采用的过期键删除策略

Redis数据库实际上采用了两种删除策略:定期删除和惰性删除。通过这两种删除策略的配合使用,服务器可以很好的在合理使用CPU时间和避免内存空间浪费之间取得平衡。

那么,Redis数据库是如何实现这两种删除策略的呢?

惰性删除策略的实现

过期键的删除策略由expireIfNeeded函数实现,所有读写数据库的Redis命令都会在执行钱调用该函数进行检查。

int expireIfNeeded(redisDb *db, robj *key) {
    if (!keyIsExpired(db,key)) return 0;
    if (server.masterhost != NULL) return 1;
    /* Delete the key */
    server.stat_expiredkeys++;
    propagateExpire(db,key,server.lazyfree_lazy_expire);

    notifyKeyspaceEvent(NOTIFY_EXPIRED, "expired", key, db -> id);

    int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) 
                                             : dbSyncDelete(db,key);
    if (retval) signalModifiedKey(NULL,db,key);
    return retval;
}

我们可以看出,如果输入键已经过期,那么expireIfNeed函数将输入键从数据库删除。如果输入键没有过期,则不会做其他动作。所以,每个命令的实现函数都必须能同时处理键存在和不存在两种情况。

定期删除策略的实现

该策略由activeExpireCycle函数实现,每当服务器周期性的操作serverCron函数执行的时候,activeExpireCycle函数就会被调用,在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires字典中随你检查一部分的过期时间,并删除其中的过期键。

具体代码实现由于太多,有感兴趣可以去看一下,redis6expire.c中,redis3redis.c中。

4.4 AOF、RDB对过期键的处理

  1. 生成RDB文件

在执行SAVE或者BGSAVE命令创建一个新的RDB文件的时候,程序会对数据库中的过期键进行检查,过期的键不会被保存到新创建的RDB文件中。

  1. 载入RDB文件

载入的时候分为两种情况:

  • 服务器以主服务器运行。当服务器以主服务器运行的时候,会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期的键则会被忽略,所以过期键对载入RDB文件的主服务器不会造成影响。
  • 服务器以从服务器运行。当服务器以从服务器运行的时候,会将文件中保存的所有键进行保存,不论是否过期。但是由于主从服务器进行数据同步的时候,从服务器的数据库就会被清空,所以一般来讲,过期键载入RDB文件的从高服务器也不会造成影响。
  1. AOF文件的写入

当数据库中某个键已经过期,但是它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。当过期键被惰性删除或者定期删除之后,程序会向AOF文件追加一条DEL命令进行显示的删除。

  1. AOF文件的重写:

重写的时候程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中。

当服务器运行在主从复制模式下的时候,从服务器的过期键删除动作是由主服务器控制的。主服务器在删除一个过期键后,会显示的向所有从服务器发送一个DEL命令,命令从服务器删除这个键;从服务器在执行客户端发送的命令的时候,即使遇到过期的键也不会将过期的键进行删除,是继续像处理未过期的键一样来处理过期键;从服务器只有在接到主服务器发送来的DEL命令的时候才会删除过期键。

五、总结

Redis的内存淘汰策略的选取并不会影响过期的key的处理。内存淘汰策略用于处理内存不足时的需要申请额外空间的数据;过期策略用于处理过期的缓存数据。

参考文章

posted @ 2022-07-15 21:38  夏尔_717  阅读(247)  评论(0编辑  收藏  举报