Redis系列1—内存管理、缓存淘汰策略
Redis是基于内存的key-value数据库,默认最大使用内存即为服务器的内存大小,因为系统的内存大小有限,一旦超过系统内存大小,linux会将系统内存中的数据转移到swap交换区中,当使用内存大小超过交换区+内存大小时,Redis 的写命令会返回错误信息(但是读命令还可以正常返回)。解决这个问题可以通过配置Redis最大内存和缓存淘汰策略,当内存大小超过Redis配置的最大内存限制时,Redis会启用缓存淘汰策略,淘汰一部分老数据,释放内存空间用来存储新的数据。
本文大纲:
配置最大内存
配置缓存淘汰策略
缓存淘汰策略及算法
总结
一. 配置最大内存
配置最大内存有两种方式:
修改redis.conf配置文件
maxmemory 100
使用config set命令
使用redis-cli命令在命令行登陆redis
127.0.0.1:6666>config set maxmemory 100mb
二. 配置缓存淘汰策略
修改redis.conf配置文件
maxmemory-policy allkeys-lru
使用config set 命令
127 .0.0.1 :6666> config set maxmemory-policy allkeys-lru
Redis一共提供以下8种缓存淘汰策略,默认是noeviction,即不配置缓存淘汰策略
volatile-lru -> 从设置了过期时间的key中使用LRU算法进行淘汰
allkeys-lru -> 所有key使用LRU算法进行淘汰
volatile-lfu -> 从设置了过期时间的key中使用LFU算法进行淘汰
allkeys-lfu -> 所有key使用LFU算法进行淘汰
volatile-random -> 从设置了过期时间的key中随机淘汰数据
allkeys-random -> 从所有key中随机淘汰数据
volatile-ttl -> 在设置了过期时间的key中,根据key的过期时间进行淘汰,越早过期的越优先被淘汰
noeviction(默认策略) -> 对于写请求不再提供服务,直接返回错误(DEL请求和部分特殊请求除外)
(1) 何为LRU算法
LRU(Least Recently Used),即最近最少使用,是一种缓存置换算法。在使用内存作为缓存的时候,缓存的大小一般是固定的。当缓存被占满,这个时候继续往缓存里面添加数据,就需要淘汰一部分老的数据,释放内存空间用来存储新的数据。这个时候就可以使用LRU算法了。其核心思想是:如果一个数据在最近一段时间没有被用到,那么将来被使用到的可能性也很小,所以就可以被淘汰掉。
/**
* LRU缓存算法实现
*/
public class LRUCache<k, v> {
//容量
private int capacity;
//当前有多少节点的统计
private int count;
//缓存节点
private Map<k, Node<k, v>> nodeMap;
private Node<k, v> head;
private Node<k, v> tail;
public LRUCache(int capacity) {
if (capacity < 1) {
throw new IllegalArgumentException(String.valueOf(capacity));
}
this.capacity = capacity;
this.nodeMap = new HashMap<>();
//初始化头节点和尾节点,利用哨兵模式减少判断头结点和尾节点为空的代码
Node headNode = new Node(null, null);
Node tailNode = new Node(null, null);
headNode.next = tailNode;
tailNode.pre = headNode;
this.head = headNode;
this.tail = tailNode;
}
public void put(k key, v value) {
Node<k, v> node = nodeMap.get(key);
if (node == null) {
if (count >= capacity) {
//先移除一个节点
removeNode();
}
node = new Node<>(key, value);
//添加节点
addNode(node);
} else {
//移动节点到头节点
moveNodeToHead(node);
}
}
public Node<k, v> get(k key) {
Node<k, v> node = nodeMap.get(key);
if (node != null) {
moveNodeToHead(node);
}
return node;
}
private void removeNode() {
Node node = tail.pre;
//从链表里面移除
removeFromList(node);
nodeMap.remove(node.key);
count--;
}
private void removeFromList(Node<k, v> node) {
Node pre = node.pre;
Node next = node.next;
pre.next = next;
next.pre = pre;
node.next = null;
node.pre = null;
}
private void addNode(Node<k, v> node) {
//添加节点到头部
addToHead(node);
nodeMap.put(node.key, node);
count++;
}
private void addToHead(Node<k, v> node) {
Node next = head.next;
next.pre = node;
node.next = next;
node.pre = head;
head.next = node;
}
public void moveNodeToHead(Node<k, v> node) {
//从链表里面移除
removeFromList(node);
//添加节点到头部
addToHead(node);
}
class Node<k, v> {
k key;
v value;
Node pre;
Node next;
public Node(k key, v value) {
this.key = key;
this.value = value;
}
}
}
-
LRU在Redis中的实现:
Redis使用的是近似LRU算法,它跟常规的LRU算法还不太一样。近似LRU算法通过随机采样法淘汰数据,每次随机出5(默认)个key,从里面淘汰掉最近最少使用的key。
可以通过maxmemory-samples参数修改采样数量:例:maxmemory-samples 10 maxmenory-samples配置的越大,淘汰的结果越接近于严格的LRU算法
Redis为了实现近似LRU算法,给每个key增加了一个额外增加了一个24bit的字段,用来存储该key最后一次被访问的时间。
Redis 3.0中对于LRU替换算法的优化,在只维护一个eviction pool带来的少量开销情况下,对算法效率的提升是比较明显的,效率的提升带来的是访问命中率的提升。
LRU时钟的粒度从秒级提升为毫秒级
使用新的API来获取LRU替换时的采样样本
默认的LRU采样样本数从3提升为5
使用eviction pool来选取需要淘汰的key
在Redis 2.8中每次选取淘汰样本时,都是调用dictGetRandomKey(一个函数)来随机获取一个key,会根据maxmemory-samples配置的大小,多次调用。这个流程在Redis3.0中被优化为一次调用获取指定数量的key,且不需要每次都调用随机函数。
dictGetSomeKeys会随机从db的某个起始位置开始,连续获取指定数量的key,需要注意的是,如果db对应的字典正在做rehash,可能需要从两个hashtable来获取key。如果需要根据某种分布来随机获取字典里面的key,这种采样方式可能是不合适的,但是如果只是为了随机获取一系列key来作为LRU算法的淘汰样本,这种方式是可行的。
采样性能的提升带来的好处就是,我们可以在不牺牲淘汰算法性能的情况下,提高采样的样本数,让Redis的近似LRU算法更接近于严格LRU算法,所以目前Redis把超过maxmemory后默认的采样样本数从3个提升到5个。
还有最重要的改进是,选取要淘汰key的流程。之前是每次随机选取maxmemory-samples个key,然后比较它们的idle时间,idle时间最久的key会被淘汰掉。在Redis 3.0中增加了一个eviction pool的结构,eviction pool是一个数组,保存了之前随机选取的key及它们的idle时间,数组里面的key按idle时间升序排序,当内存满了需要淘汰数据时,会调用dictGetSomeKeys选取指定的数目的key,然后更新到eviction pool里面,如果新选取的key的idle时间比eviction pool里面idle时间最小的key还要小,那么就不会把它插入到eviction pool里面。
最后淘汰掉eviction pool里面的最后一个元素所对应的key,这样的选取淘汰key的方式的好处是:假设说新随机选取的key的访问时间可能比历史随机选取的key的访问时间还要新,但是在Redis 2.8中,新选取的key会被淘汰掉,这和LRU算法利用的访问局部性原理是相违背的,在Redis 3.0中,这种情况被避免了。
此外,如果某个历史选取的key的idle时间相对来说比较久,但是本次淘汰并没有被选中,因为出现了idle时间更久的key,那么在使用eviction pool的情况下,这种idle时间比较久的key淘汰概率增大了,因为它在eviction pool里面被保存下来,参与下轮淘汰,这个思路和访问局部性原理是契合的。
和Redis 2.8相比,改进的效果我们可以引用一下上篇文章《Redis作为LRU Cache的实现》中第一张图的下半部分,
我们可以看到在前面1/2需要淘汰的key里面(浅灰色的点),Redis 3.0残留下来的key明显比Redis 2.8少了很多,而且后面新插入的1/2的key里面(绿色的点),Redis 3.0没有一个淘汰的key。
(2) 何为LFU算法
LFU算法是Redis4.0里面新加的一种淘汰策略。它的全称是Least Frequently Used,它的核心思想是根据key的最近被访问的频率进行淘汰,很少被访问的优先被淘汰,被访问的多的则被留下来。
解决LRU算法的不足:
LFU算法能更好的表示一个key被访问的热度。假如你使用的是LRU算法,一个key很久没有被访问到,只刚刚是偶尔被访问了一次,那么它就被认为是热点数据,不会被淘汰,而有些key将来是很有可能被访问到的则被淘汰了。如果使用LFU算法则不会出现这种情况,因为使用一次并不会使一个key成为热点数据。
/**
*Redis存储的value对象
*/
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /*在LRU算法中,24 bits的lru是用来记录LRU time的。
在LFU中也可以使用这个字段,不过是分成16 bits与8bits使用,
高16bits的用来记录最近一次计数器降低的,时间ldt( Last decr time),单位是分钟,
低8bits记录计数器数值counter(LOG_C)。*/
int refcount;
void *ptr;
} robj;
LFU配置
Redis4.0之后为maxmemory_policy淘汰策略添加了两个LFU模式:
volatile-lfu:对有过期时间的key采用LFU淘汰算法
allkeys-lfu:对全部key采用LFU淘汰算法
lfu-log-factor 10 #可以调整计数器counter的增长速度,lfu-log-factor越大,counter增长的越慢。
lfu-decay-time 1 #是一个以分钟为单位的数值,可以调整counter的减少速度
Redis实现LFU算法是基于key的最近访问时间和访问次数综合考虑的,当一个KEY长时间没有被访问的话,这个KEY的访问次数会相应的减少,访问key时会自动增加次数。其实现源码如下:
//LFU算法更新策略
void updateLFU(robj *val) {
unsigned long counter = LFUDecrAndReturn(val); #调用减少次数的函数
counter = LFULogIncr(counter); #调用增加次数的函数
val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}
//减少次数函数
unsigned long LFUDecrAndReturn(robj *o) {
unsigned long ldt = o->lru >> 8; #获取最近一次的记录时间
unsigned long counter = o->lru & 255; #获取最近一次记录的访问次数
#根据配置的lfu_decay_time计算应该降低多少,其中LFUTimeElapsed用来计算当前时间与ldt的差值
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
#已过去n个lfu_decay_time,则将counter减少n,counter - num_periods。
if (num_periods)
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter;
}
/**
*增加次数函数
*counter并不是简单的访问一次就+1,而是采用了一个0-1之间的p因子控制增长。
*counter最大值为255。取一个0-1之间的随机数r与p比较,当r<p时,才增加counter,这和
*比特币中控制产出的策略类似。p取决于当前counter值与lfu_log_factor因子,counter值
*与lfu_log_factor因子越大,p越小,r<p的概率也越小,counter增长的概率也就越小。
*/
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
double r = (double)rand()/RAND_MAX;
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;
return counter;
}
//增长情况如下,可见counter增长与访问次数呈现对数增长的趋势,随着访问次数越来越大,counter增长的越来越慢。
+--------+------------+------------+------------+------------+------------+
| factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits |
+--------+------------+------------+------------+------------+------------+
| 0 | 104 | 255 | 255 | 255 | 255 |
+--------+------------+------------+------------+------------+------------+
| 1 | 18 | 49 | 255 | 255 | 255 |
+--------+------------+------------+------------+------------+------------+
| 10 | 10 | 18 | 142 | 255 | 255 |
+--------+------------+------------+------------+------------+------------+
| 100 | 8 | 11 | 49 | 143 | 255 |
+--------+------------+------------+------------+------------+------------+
新生key策略
当创建新对象的时候,对象的counter如果为0,很容易就会被淘汰掉,还需要为新生key设置一个初始counter,初始化为LFU_INIT_VAL,默认5。
pool筛选池算法和LRU一致,这里不再赘述
计算idle时有所不同:
使用了 255-LFUDecrAndReturn(o) 当做排序的依据,即根据key的访问次数倒序排列。
四. 总结
在选用Redis作为缓存数据库时,需要提前预估缓存的数据的使用量,为避免当使用内存超过系统内存是导致的Redis无法写入数据的问题,需预先配置好Redis的最大使用内存,同时还应配置Redis的缓存淘汰策略,在缓存数据使用量超限时,自动删除就缓存数据,腾出内存空间供新的缓存数据使用。
Redis的缓存有volatile-lru,allkeys-lru,volatile-lfu,allkeys-lfu,volatile-random, allkeys-random, volatile-ttl,noeviction(默认策略)8种淘汰策略。
Redis缓存淘汰算法有LRU和LFU两种,LRU算法是根据key的最新访问时间来作为是否淘汰的条件的,这种算法的缺点是无法识别热点key。LFU算法是根据key的访问次数作为是否淘汰的条件,LFU算法是基于key的最近访问时间和访问次数综合考虑的,通过访问次数的最新记录时间来衡量当前访问次数是否需要减少,当访问key是自动增加访问次数,访问次数的减少和增加都有相应的控制因子,用于控制减少或者增减的速度。