过期策略

面试官:你了解Redis的键过期策略吗?

我:不了解

面试官:(出门右拐,顺便把门口的垃圾带走)那让你来设计一个过期策略,你怎么去实现

我:简单啊,给每个有过期时间的key绑定一个定时器就好了

定时器删除策略

给每个有过期时间的key绑定一个定时器,时间一到,立马将该key从内存中删除。

优点:及时删除,有效解决了内存被过期key大量占用的问题。

缺点:大量占用CPU时间片,干不了正事,一直忙着删除过期key,解决不了大量占用cpu的问题。

面试官:既然定时器策略十分占用cpu,会导致redis查询缓慢,还有别的想法吗?

我:反正最终的效果,只是用户获取不到过期的key,那只要在获取的时候,判断一下是否过期就行了。

惰性删除策略

当key过期时间来临时,不作任何处理。只在获取该key时,判断是否过期,过期了则删除key,并且返回null;否则返回该key的值。

优点:不会大量占用CPU,Redis可以将大部分精力放在查询上。

缺点:如果用户一直不获取key,那么该过期的key就会一直存放在内存中,相当于内存泄漏。

面试官:惰性删除确实方便简单,但这十分浪费内存。还有别的想法吗?

我:或者只起一个定时任务,定时扫描redis中所有有过期时间的key。

定期删除

起一个全局的定时任务,定时扫描redis中所有有过期时间的key。但是为了保证不影响其他的业务,给这个定时任务设置执行时间与频率。

当这个定时任务的第一次执行到达了时间限制后,停止执行,下次执行的开始点就是上一次的终点。

当然Redis中存在大量的key,不可能扫描所有有过期时间的key,不然全部扫一遍的总耗时将十分漫长。

优点:在设置执行时间与频率后,不会影响其他业务。

缺点:如果扫描全部key,则依然存在内存泄漏的风险。如果每次扫描随机选取几个key,则会存在漏网之鱼。


Redis采用的过期策略

Redis综合以上3种策略的优缺点,最终采取的策略是惰性删除+定期删除。

对于定期删除:redis每隔指定的时间,扫描即将要过期的key。值得注意的是,这里的扫描并不是扫描全部带有过期时间的key,更像是一种抽查。

首先循环每一个库,随机抽查其中带有过期时间的key,若过期的key占的比例超过某一个阈值,则继续抽查,一直到小于阈值的时候,才前往下一个库进行扫描。

redis默认的执行时间为25ms,超过这个时间后,只能等待下一次扫描继续处理剩余的任务。redis每隔100ms触发一次扫描任务。

下面使用java代码,简要描述一下定期删除的原理

package com.qcy.testRedis; import lombok.Data; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; /**
 * @author qcy
 * @create 2020/08/31 17:03:22
 */ public class Main { /**
     * Redis库的总数
     */ public static final int DB_SUM = 16; /**
     * 当前扫描的库的索引
     */ public static int CURRENT_SCAN_DB_INDEX = 0; /**
     * 随机抽查的key的数量
     */ public static final int RANDOM_KEY_SUM = 20; /**
     * 过期key占抽查总数的阈值
     */ public static final int EXPIRE_TIME_THRESHOLD = RANDOM_KEY_SUM / 4; /**
     * 单次扫描的最大执行时间,单位ms
     */ public static final int MAX_SCAN_TIME = 25; public static void main(String[] args) { //开始时间 long startTime = System.currentTimeMillis(); //如果全部扫描完,则开启新一轮 if (CURRENT_SCAN_DB_INDEX == DB_SUM) {
            CURRENT_SCAN_DB_INDEX = 0;
        } for (int i = CURRENT_SCAN_DB_INDEX; i < DB_SUM; i++) { //main方法执行耗时最大为25ms,当然这用写是有问题的,这里只是做一个演示 long currentTime = System.currentTimeMillis(); if (currentTime - startTime >= MAX_SCAN_TIME) return;
        } //当前扫描的库 RedisDB db = get(CURRENT_SCAN_DB_INDEX); while (true) { //获取有过期时间的key的总数 int sum = db.getSumKeyWithExpireTime(); if (sum == 0) { break;
            } //默认随机找出20个 List<RedisKey> keyList = db.getRandomKeyWithExpireTime(); //找出其中已经过期的key,删除并且记录总数 List<RedisKey> expireKeyList = keyList.stream().filter(RedisKey::isExpire).collect(Collectors.toList()); long expireCount = expireKeyList.size(); //删除key expireKeyList.forEach(k -> db.delete(k.getName())); //如果过期key的数量不大于随机的key总数的1/4,即小于5个后,则换一个库 if (expireCount <= EXPIRE_TIME_THRESHOLD) { break;
            }
        }

        CURRENT_SCAN_DB_INDEX++;
    }

}

定期删除,难免会有漏网之鱼的存在。因此,最后一道防线则是利用惰性删除。

定期删除结合惰性删除,听起来确实很不错。但是,那些经历过定期删除仍然存活的过期key,倘若一直没有被查询过,就会一直存放在内存中,称为老赖。

清除这些老赖,就要谈到内存淘汰机制。


内存淘汰机制

主要有以下机制:

  • noeviction:         当内存不足时,写入操作会直接报错(这个确实很简单粗暴)
  • allkeys-lru:         当内存不足时,删除最近最少使用的key
  • allkeys-random: 当内存不足时,随机删除某些key
  • allkeys-lfu:            当内存不足时,删除那些最近访问频率最低的key
  • volatile-lru:         当内存不足时,删除最近最少使用的带有过期时间的key
  • volatile-random: 当内存不足时,随机删除某些带有过期时间的key
  • volatile-ttl:          当内存不足时,删除最快过期的key
  • volatile-lfu:            当内存不足时,删除那些最近访问频率最低的带有过期时间的key

其中redis在4.0中引进的LFU策略,即淘汰最近访问频率较低的key,LFU比LRU更加精确地表示了key被关注的热度。如果有一个key刚被访问过了,LRU策略认为这个key最不可能删除的;而LFU策略需要追踪这一段时间内该key的访问频率,仅仅被访问一次,不足以让该key变得很热,在LFU策略下,还是很容易删除该key的。

查看redis默认的淘汰策略,可以使用以下命令:

config get maxmemory-policy

使用以下命令设置redis的淘汰策略为volatile-lfu:

config set maxmemory-policy volatile-lfu



你说了这么多,来手写个LRU吧

我:LinkedHashMap天然支持LRU,仅仅需要传入特定的参数就可以实现

详细的过程可以参考这篇文章使用LinkedHashMap实现一个简易的LRU缓存

面试官:那你使用LinkedList与HashMap实现LRU

我:你说什么,我没听见

使用LinkedList来保存key的顺序,使用HashMap来保存key对应的value。

仅仅只操作LinkedList头尾部的元素,因此时间复杂度为O(1),由key在HashMap中获取value的时间复杂度也为O(1)。

package com.yang.testLRU; import java.util.HashMap; import java.util.LinkedList; public class Main { static class LRUCache { //维护位置的LinkedList,set()的时间复杂度O(n),但如果只操作头尾元素,则时间复杂度为O(1) private LinkedList<Integer> list; //维护键值的HashMap,get()的时间复杂度O(1) private HashMap<Integer, Integer> map; //缓存的容量 private int capacity; public LRUCache(int capacity) { this.list = new LinkedList<>(); this.map = new HashMap<>(); this.capacity = capacity;
        } private Integer get(Integer key) { if (map.get(key) == null) { //说明缓存中没有该key return null;
            } //缓存中有该key,则先将该key在链表中删除,再移动到链表的尾部,从而保证头部是最近最久未使用的元素 list.remove(key);
            list.offer(key); return map.get(key);
        } private void put(Integer key, Integer value) { if (map.get(key) != null) { //说明缓存中有该key,先在链表中删除,再移动到尾部 list.remove(key);
                list.offer(key);
            } else { //说明缓存中没有该key,需要往缓存中插入 if (list.size() == capacity) { //说明缓存已经满了 //删除链表头部元素 Integer head = list.poll(); //删除键值対 map.remove(head);
                } //此时缓存没满,或刚删除了头部元素 list.offer(key);
            } //插入map或刷新vaule map.put(key, value);
        } //输出链表元素 private void print() { for (int i = list.size() - 1; i >= 0; i--) {
                System.out.print(list.get(i) + " ");
            }
        }

    } public static void main(String[] args) { //假设内存容量只能存下4个数 LRUCache cache = new LRUCache(4);
        cache.put(1, 1);
        cache.put(2, 2);
        cache.put(3, 3);
        cache.put(4, 4);
        cache.print();
        System.out.println();

        cache.get(1);
        cache.print();
        System.out.println();

        cache.put(5, 5);
        cache.print();
        System.out.println();

        cache.get(3);
        cache.print();
    }

}

输出如下: