前面介绍了Redis的一些内存淘汰策略,一般比较常用的两种淘汰策略为LRU,LFU,而且他们的算法考察的也比较多。
LRU(最近最久未使用)
标准LRU算法是这样的:它把数据存放在链表中按照“最近访问”的顺序排列,当某个key被访问时就将此key移动到链表的头部,保证了最近访问过的元素在链表的头部或前面。当链表满了之后,就将"最近最久未使用"的,即链表尾部的元素删除,再将新的元素添加至链表头部。其中LinkedHashMapt就可以通过访问的顺序来实现LRU算法。
一般的LRU的做法如下:
1、为每个节点设置一个prev前继节点指针和next后继节点指针,将所有节点通过这两个指针连接成一个链表,链表有着head和tail节点。
2、用一个哈希表将key和对应的节点存放起来,用来快速的找到key对应的节点
3、当通过key访问对应的节点的时候,通过哈希表查询key得到对应的节点。然后通过节点的的prev和next指针将节点从链表中移除,并将节点放到链表的头部中。
4、当新加入key和节点的时候,如果对应的链表已经满了,就将尾部的节点去除,然后将新节点加入到链表的头部。
从上述流程可以看出,基于链表和哈希表的LRU需要设置prev指针、next指针、以及hash表中的key和value的指针,这是一个很大的内存开销。
因为标准LRU算法需要消耗大量的内存,所以Redis采用了一种近似LRU的做法 给每个key增加一个大小为24bit的属性字段,代表最后一次被访问的时间戳。然后随机采样出5个key,淘汰掉最旧的key,直到Redis占用内存小于maxmemory为止。其中随机采样的数量可以通过Redis配置文件中的 maxmemory_samples 属性来调整,默认是5,采样数量越大越接近于标准LRU算法,但也会带来性能的消耗。
这里为每一个key增加的24bit的属性字段来自于RedisObject,前面的redis数据结构里面也讲过redis中的每个数据的key和value都对应着一个redisObj对象。如果忘记了Redis的几种基本数据结构,可以参考我的这篇博客:Redis第一讲 Redis五种基本类型的底层数据结构
lru值还和内存回收有关系,如果redis打开了maxmemory选项,并且内存回收算法是volatile-lru或者allkeys-lru,当内存占用超过maxmemory指定的值得时候,redis会优先选择空转时间最长的对象进行释放。
redisObj中有一个属性 lru,lru是一个24位的数字,保存了一个时间戳,代表着一个对象最新被使用的时间。
redisServer中维护了一个lruclock属性来表示当前系统的时间戳,每隔100ms就会调用updateLRUClock()来更新server.lruclock的值。
当新建key或者访问key的时候,会将对应的redisObj.lru = server.lruclock。
用server.lruclock的值减去redisObj.lru的值就可以得到对应key的空闲时间了,但是当server.unixtime也就是当前系统时间大于REDIS_LRU_CLOCK_MAX的时候,会将server.lruclock从0开始计数,这就会出现redisObj.lru大于server.lruclock的情况,这种情况下如何算对应的空闲时间呢
//这里只是粗略的估计空闲时间,因为之前算server.lruclock()和redisObj.lru的时候,已将unixtime()/REDIS_LRU_CLOCK_RESOLUTION了,对应的精度已经损失了。
unsigned long long estimateObjectIdleTime(robj *o) {
unsigned long long lruclock = LRU_CLOCK();
//正常情况,server.lruclock()还未转完一圈
if (lruclock >= o->lru) {
return (lruclock - o->lru) * REDIS_LRU_CLOCK_RESOLUTION;
} else { server.lruclock()转完一圈了,那么需要将其结果加上对应的REDIS_LRU_CLOCK_MAX的值
return (lruclock + (REDIS_LRU_CLOCK_MAX - o->lru)) *
REDIS_LRU_CLOCK_RESOLUTION;
}
}
在Redis 3.0以后增加了LRU淘汰池,进一步提高了与标准LRU算法效果的相似度。淘汰池即维护的一个数组,数组大小等于抽样数量 maxmemory_samples,在每一次淘汰时,新随机抽取的key和淘汰池中的key进行合并,然后淘汰掉最旧的key,将剩余较旧的前面5个key放入淘汰池中待下一次循环使用。假如maxmemory_samples=5,随机抽取5个元素,淘汰池中还有5个元素,相当于变相的maxmemory_samples=10了,所以进一步提高了与LRU算法的相似度。
其中根据抽取key的数据来源不同,将lru分为了两类:
1、volatile-lru
从设置了过期时间的数据中随机抽取数据淘汰,也就是从redisDb.expires中抽取key。
2、allkeys-lru
从所有数据中随机抽取数据淘汰,也就是从redisDb.dict中抽取key。
JAVA实现LRU算法
public class LRUTest extends LinkedHashMap<String, String> {
int capacity;
public LRUTest(int capacity) {
//按照顺序访问
super(16, 0.75f, true);
this.capacity = capacity;
}
/**
* LinkedHashMap自带的判断是否删除最老的元素方法,默认返回false,即不删除老数据
*/
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
return size() > capacity;
}
public static void main(String[] args) {
Map<String, String> linkedHashMap = new LRUTest(6);
linkedHashMap.put("1", "1");
linkedHashMap.put("2", "2");
linkedHashMap.put("3", "3");
linkedHashMap.put("4", "4");
linkedHashMap.put("5", "5");
linkedHashMap.put("6", "6");
linkedHashMap.put("7", "7");
linkedHashMap.put("8", "8");
linkedHashMap.put("9", "9");
System.out.println("size="+linkedHashMap.size());
System.out.println(linkedHashMap.get("8"));
linkedHashMap.forEach((k,v) ->{
System.out.print(k + ":"+ v +" ");
});
System.out.println();
System.out.println("size="+linkedHashMap.size());
}
}
LFU(最近最少使用)
在Redis LFU算法中,为每个key维护了一个计数器,每次key被访问的时候,计数器增大,计数器越大,则认为访问越频繁。但其实这样会有问题,
1、因为访问频率是动态变化的,前段时间频繁访问的key,之后也可能很少再访问(如微博热搜)。为了解决这个问题,Redis记录了每个key最后一次被访问的时间,随着时间的推移,如果某个key再没有被访问过,计数器的值也会逐渐降低。
2、新生key问题,对于新加入缓存的key,因为还没有被访问过,计数器的值如果为0,就算这个key是热点key,因为计数器值太小,也会被淘汰机制淘汰掉。为了解决这个问题,Redis会为新生key的计数器设置一个初始值。
上面说过在Redis LRU算法中,会给每个key维护一个大小为24bit的属性字段,代表最后一次被访问的时间戳。在LFU中也维护了这个24bit的字段,不过被分成了16 bits与8 bits两部分:

其中高16 bits用来记录计数器的上次缩减时间,时间戳,单位精确到分钟。低8 bits用来记录计数器的当前数值。
你可能会奇怪8位最多只能记录255,这不是在开玩笑吗?其实每次访问不会一定将counter++,而是通过一定的概率来判断是否将counter++。
//counter就是当前key对应的counter
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
//随机生成一个数字
double r = (double)rand()/RAND_MAX;
//将当前的counter减去一个固定值
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
//server.lfu_log_factor是增长控制因子
double p = 1.0/(baseval*server.lfu_log_factor+1);
//如果随机生成的数小于p,就将counter++
if (r < p) counter++;
return counter;
}
在redis.conf配置文件中还有2个属性可以调整LFU算法的执行参数:lfu-log-factor、lfu-decay-time。
其中lfu-log-factor用来调整计数器counter的增长速度,lfu-log-factor越大,counter增长的越慢。
lfu-decay-time是一个以分钟为单位的数值,用来调整counter的缩减速度。
衰减机制
当一个key在前一段时间被频繁访问,但是之后慢慢用不到了,对应的counter应该变小,要不然key会一直保存在内存中,无法被移除,降低内存使用率。所以redis提供了一个衰减机制。
衰减机制 与 key的空闲时间有关, key空闲时间越长,对应的counter就减少的越多。
unsigned long LFUDecrAndReturn(robj *o) {
//将lru左移8位,得到对应的时间戳
unsigned long ldt = o->lru >> 8;
//将lru & 0000 0000 0000 0000 1111 得到对应的counter
unsigned long counter = o->lru & 255;
//算出对应的空闲周期数。
//server.lfu_decay_time是衰减因子,可以在配置文件中修改,默认是10,单位是分钟
//LFUTimeElapsed(ldt)就是算出对应的空闲时间,然后将空闲时间除去衰减因子,得到衰减周期 num_periods
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
//将counter减去对应的衰减周期
if (num_periods)
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter;
}
从上述代码看出,如果一个Key在N分钟没有被访问,当衰减检查的时候,就会将其counter - N。
lfu工作过程
当访问对应的key的时候,会更新其redisObj中的lru中的时间戳和counter信息。
和lru一样,lfu也会维护一个Pool,只不过Pool中的排序依据是counter,如果counter一致,就按照时间戳排序。每次需要内存替换的时候,就随机抽取出若干个key,如果Pool不满,或者counter小于Pool中的最小值的话,就将key加入进去,然后选出Pool中counter值最小的那个Key,将其对应的数据从内存中移除。
volatile-lfu: 只从redisDb.expires中,也就是设置了过期时间的key中随机选取对应的key按照lfu规则移除
allkeys-lfu: 从redisDb.dict中,也就是全部key中随机选出对应的key按照lfu规则移除。
java实现的LFU算法:
public class LfuTest {
/**
* 记录一个是缓存的元素
*
* @param <K>
* @param <V>
*/
static class LFUNode<K, V> {
private K key;
private V value;
private int count;
public LFUNode(K key, V value) {
this.key = key;
this.value = value;
}
public LFUNode(K key, V value, int count) {
this.key = key;
this.value = value;
this.count = 1;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
public int getCount() {
return count;
}
public void setCount(int count) {
this.count = count;
}
}
static class LFUCache<K, V> {
//根据访问counter排序
private TreeMap<Integer, List<LFUNode<K, V>>> countMap;
// cache存储插入的node
private HashMap<K, LFUNode<K, V>> cache;
private int size;
public LFUCache(int size) {
if (size <= 0) {
throw new IllegalArgumentException("Invalid size");
}
//排序
countMap = new TreeMap<Integer, List<LFUNode<K, V>>>(new Comparator<Integer>() {
public int compare(Integer o1, Integer o2) {
return o1 - o2;
}
});
cache = new HashMap<K, LFUNode<K, V>>(size);
this.size = size;
}
public void put(K key, V value) {
if (cache.containsKey(key)) {
//访问过
LFUNode node = cache.get(key);
int count = node.getCount();
node.setCount(count + 1);
rmCountNode(count, node);
addCountNode(count + 1, node);
return;
}
if (cache.size() == size) {
Map.Entry<Integer, List<LFUNode<K, V>>> entry = countMap.firstEntry();
LFUNode node = entry.getValue().get(0);
rmCountNode(node.getCount(), node);
cache.remove(node.getKey());
}
LFUNode node = new LFUNode(key, value);
cache.put(key, node);
addCountNode(node.getCount(), node);
}
public V get(K key) {
if (!cache.containsKey(key)) {
return null;
}
LFUNode<K, V> node = cache.get(key);
rmCountNode(node.getCount(), node);
addCountNode(node.getCount() + 1, node);
node.setCount(node.getCount() + 1);
return node.getValue();
}
public void rmCountNode(int count, LFUNode node) {
List<LFUNode<K, V>> list = countMap.get(count);
if (list.size() == 1) {
countMap.remove(count);
} else {
list.remove(node);
}
}
public void addCountNode(int count, LFUNode node) {
List<LFUNode<K, V>> list = countMap.get(count);
if (list == null) {
list = new ArrayList<LFUNode<K, V>>();
countMap.put(count, list);
}
list.add(node);
}
}
public static void main(String[] args) {
LFUCache<String, String> lfuCache = new LFUCache<>(5);
lfuCache.put("1", "1");
lfuCache.put("2", "2");
lfuCache.put("3", "3");
lfuCache.put("4", "4");
lfuCache.put("5", "5");
lfuCache.put("6", "6");
System.out.println(lfuCache);
lfuCache.put("2", "2");
lfuCache.put("7", "7");
lfuCache.put("8", "8");
lfuCache.put("9", "9");
System.out.println(lfuCache);
}
}
本文详细介绍了Redis中的LRU和LFU内存淘汰策略。LRU通过近似方法,利用24bit属性字段记录访问时间,通过随机采样和淘汰池提高效果。LFU则维护计数器并考虑访问频率变化,通过衰减机制减少不常用key的内存占用。Redis可通过配置调整这些算法的行为。
1943

被折叠的 条评论
为什么被折叠?



