Redis缓存淘汰机制
当Redis内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换,这样会使得redis的性能急剧下降。
在生产环境中,是不允许redis出现交换行为的,为了限制最大使用内存,redis提供了配置参数maxmemory来限制内存超出期望大小。当实际内存超出maxmemory时,redis提供了几种可选策略来让用户自己决定该如何腾出新的空间继续提供读写服务。
有以下几种策略:
1.noeviction:继续读,停止写。这样做能够保证不丢失数据,但是会使得线上的业务无法继续进行。这是redis的默认淘汰策略。
2.volatile-lru:尝试淘汰设置了过期时间的key,而没有设置过期时间的key不会被淘汰。淘汰策略为LRU,即最少使用的key优先被淘汰,也就是优先淘汰最近最少使用的key。这样做可以保证需要持久化的数据不会突然丢失。
3.volatile-ttl:尝试淘汰设置了过期时间的key,只是淘汰的策略变为比较key的剩余寿命ttl的值,ttl越小越优先被淘汰。也就是优先淘汰快消亡的key。
4.volatile-random:尝试淘汰设置了过期时间的key,只是淘汰的策略变为随机淘汰,即淘汰过期key集合中随机的key。
5.allkeys-lru:区别于volatile-lru,这个策略是对全体key集合进行LRU策略淘汰,而不单单只是过期key集合。
6.allkeys-random:作用于全体key集合的随机淘汰。
显然,volatile-xxx策略只会针对带过期时间的key进行淘汰,而allkeys-xxx策略会对所有的key进行淘汰。
如果只是拿redis做缓存,应该使用allkeys-xxx策略,客户端写缓存时不需要携带过期时间。如果还想同时使用redis的持久化功能,那就使用volatile-xxx策略,这样可以保留没有设置过期时间的key,它们是永久的key。
1.LRU算法
实现LRU算法除了需要key/value字典之外,还需要附加一个链表,链表中的元素按照一定的顺序进行排列。当空间满的时候,将链尾元素弃掉。当字典的某个元素被访问时,它在链表中的位置会被移动到表头,所以链表的元素排列顺序就是元素被访问的时间顺序。
在链尾的元素就是最久没有用过的元素,链首的元素就是刚被用过的元素。
在知道了LRU算法的思想后,接下来可以简单实现一下LRU算法。
python:
这里利用python的OrderedDict来实现一个简单的LRU算法
from _collections import OrderedDict
class LRUDict:
def __init__(self, capacity):
self.capacity = capacity
self.items = OrderedDict()
def set_item(self, key, value):
#修改key对应的value
if key in self.items:
del self.items[key]
self.items[key] = value
#添加新的key
else:
self.items[key] = value
#链表满了,需要删除最久没有访问的key,即链尾元素
if len(self.items) > self.capacity:
self.items.popitem(last=False)
def get_item(self, key):
if key in self.items:
value = self.items[key]
#访问过后的元素置于链首
self.items.move_to_end(key)
return value
else:
return -1
if __name__ == '__main__':
d = LRUDict(10)
for i in range(15):
d.set_item(i, i)
print(d.items)
d.get_item(5)
d.set_item(15, 15)
print(d.items)
可以看到,对一个容量为10的LRU链表依次放入15个元素,前面5个会被删除。
这时,将最久未访问的key’5’访问一下,它就会跑到链首,这时再插入一个新key,由于链表已满且现在最久未访问的key变为了’6‘,因而key’6‘被删除。
Java:
利用Java的LinkedHashMap来实现一个简单的LRU算法
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
public class LRUDict {
LinkedHashMap<Integer, Integer> linkedHashMap = new LinkedHashMap<>();
private int capacity;
private int first_key;
public LRUDict(int capacity) {
this.capacity = capacity;
}
public void setValue(int key, int value) {
if (linkedHashMap.containsKey(key) == true) {
linkedHashMap.remove(key);
linkedHashMap.put(key, value);
} else {
linkedHashMap.put(key, value);
if (linkedHashMap.size() > this.capacity) {
int first_key = getHead(linkedHashMap).getKey();
linkedHashMap.remove(first_key);
}
}
}
public int getValue(int key) {
if (linkedHashMap.containsKey(key) == true) {
int value = linkedHashMap.get(key);
linkedHashMap.remove(key);
linkedHashMap.put(key, value);
return value;
} else {
return -1;
}
}
public <K, V> Map.Entry<K, V> getHead(LinkedHashMap<K, V> linkedHashMap) {
return linkedHashMap.entrySet().iterator().next();
}
public static void main(String[] args) {
LRUDict d = new LRUDict(10);
for(int i=0; i<15; i++) {
d.setValue(i, i);
}
System.out.println(d.linkedHashMap.toString());
d.getValue(5);
d.setValue(15, 15);
System.out.println(d.linkedHashMap.toString());
}
}
2.Redis使用的LRU算法
Redis使用的是一种近似LRU的算法,不使用LRU算法是因为其需要消耗大量的额外内存,需要对现有的数据结构进行较大改造。
近似LRU算法,在现有数据结构基础上使用随机采样法来淘汰元素,能够达到和LRU算法非常近似的效果。redis为实现近似LRU算法,给每个key增加一个额外的小字段,该小字段长度是24bit,记录最后一次被访问的时间戳。
LRU的淘汰处理方式只有懒惰处理,当redis执行写操作时,发现内存超过maxmemory,就会执行一次LRU淘汰算法。这个淘汰算法就是随机采样出x个key,然后淘汰掉旧的key,若是淘汰后内存还是超过maxmemory,就继续随机采样淘汰,直到内存低于maxmemory。
Redis3.0在算法中增加了淘汰池,进一步提升了近似LRU算法的效果。淘汰池是一个数组,在每一次淘汰循环中,新的随机得出的key列表会和淘汰池中的key列表进行融合,淘汰掉最旧的一个key之后,保留剩余较旧的key列表放入淘汰池中等待下一次循环。
3.LFU模式
Antirez在Redis4.0中引入了一个新的淘汰策略——LFU模式,全称为Least Frequently Used,表示按最近的访问频率进行淘汰。
它比LRU更加精确地表示一个key被访问的热度。如果一个key长时间不被访问,只是刚刚偶然被用户访问了以下,那么在LRU算法下,它是不容易被淘汰的,因为LRU算法认为这个key是很“热”的。而LFU需要追踪最近一段时间的访问频率,如果某个key只是偶然被访问一次,是不足以变的很“热”的,在LFU算法下,还是会被正常淘汰的,只有它最近被访问很多次才有机会被LFU算法认为是很“热”的。
(1)Redis对象的热度
Redis所有对象头结构中都有一个24bit的字段,这个字段用以记录对象的热度。
typedef struct redisObject {
unsigned type:4; //对象类型如zset、set、hash等
unsigned encoding:4, //对象编码如ziplist、intset、skiplist等
unsigned lru:24; //对象的热度
int refcount; //引用计数
void *ptr; //对象的body
}robj;
(2)LRU模式下的热度
在LRU模式下,lru字段存储的是Redis时钟server.lruclock。Redis时钟是一个24bit的整数,默认是Unix时间戳对224 取模的结果,大概97天清零一次。当某个key被访问一次,它的对象头结构的lru字段值就会被更新为server.lruclock。
默认redis时钟值每毫秒更新一次,在定时任务serverCron里面设置。如果server.lruclock没有对224 折返(对224 取模),它就是一直递增的,这意味着对象的lru字段不会超过server.lruclock的值。若是超过了,说明server.lruclock折返了。通过这个逻辑来精准计算对象多长时间没有被访问,也就是对象的空闲时间。
图左边为没有进行取模的空闲时间计算规则,右边为进行了折返的空闲时间计算规则。
//计算对象的空闲时间,也就是没有被访问的时间,返回结果为毫秒
unsigned long long estimateObjectIdleTime(robj *o) {
unsigned long long lruclock = LRU_CLOCK();
//获取Redis时钟,也就是server.lruclock的值
if(lruclock >= o->lru) {
//正常递增
return (lruclcok - o->lru) * LRU_CLOCK_RESOLUTION;
//LRU_CLOCK_RESOLUTION的默认值是1000
}else {
//发生了折返
return (lruclock + (LRU_CLOCK_MAX - o->lru)) * LRU_CLOCK_RESOLUTION;
//LRU_CLOCK_MAX值为2^24-1
}
}
有了对象的空闲时间,就可以相互之间进行比较谁新谁旧,随机LRU算法靠的就是比较对象的空闲时间来决定该淘汰谁。
上述操作中有一个lruclock,它是通过调用LRU_CLOCK方法获取的,下面具体看一下这个方法。
unsigned int LRU_CLOCK(void) {
if(1000 / server.hz <= LRU_CLOCK_RESOLUTION) {
atomicGet(server.lruclock, lruclock);
}else{
lruclock = getLRUClock();
}
return lruclock;
}
可以看到例如clock使用原子操作atomicGet来获取。这里是因为,Redis实际上并不是单线程,它背后还有几个异步线程默默工作,这几个线程也要访问Redis时钟,所以lruclock字段需要支持多线程读写,因而使用原子操作来保证其数据一致性。
(3)LFU模式下的热度
在LFU模式下,lru字段24bit用来存储两个值,分别是ldt(last decrement time)和logc(logistic counter)。
logc占8个bit,用来存储访问频次,由于8bit能表示的最大整数值为255,存储频次肯定远远不够,所以这8个bit存储的是频次的对数值,并且这个值还会随时间衰减,如果它的值比较小,就很容易被回收。为了确保新创建的对象不被回收,新对象的这8bit会被初始化为一个大于零的值LFU_INIT_VAL(默认为5)。
ldt占16bit,用来存储上一次logc的更新时间。因为只有16个bit,所以精度并不高。它取的是分钟时间戳对216 进行取模,大约每隔45天就会折返。
图显示了折返前后空闲时间的不同计算规则,精度为分钟级别。
//nowInMinutes
unsigned long LFUGetTimeInMinutes(void) {
return (server.unixtime/60) & 65535;
//server.unixtime为Redis缓存的系统时间戳
}
//idle_in_minutes
unsigned long LFUTimeElapsed(unsigned long ldt) {
unsigned long now = LFUGetTimeInMinutes();
if(now >= ldt)
return now-ldt; //正常比较
return 65535-ldt+now; //折返比较
}
ldt的值和LRU模式的lru字段不一样的地方是,ldt不是在对象被访问时更新的,而是在Redis的淘汰逻辑进行时进行更新,淘汰逻辑只会在内存达到maxmemory的设置时才会触发,在每一个指令的执行之前都会触发。每次淘汰都是采用随机策略,随机挑选若干个key,更新这个key的“热度”,淘汰掉“热度”最低的key。
ldt更新的同时也会一同衰减logc的值。衰减也有特定的算法,它将现有的logc值减去对象的空闲时间再除以一个衰减系数lfu_decay_time(默认为1),如果衰减系数的值大于1,那么就会衰减得比较慢。
//衰减logc
unsigned long LFUDecrAndReturn(robj *o) {
unsigned long ldt = o->lru >> 8; //前16bit
unsigned long counter = o->lru & 255; //后8bit为logc
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
//num_periods为即将衰减的数量
if(num_periods)
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter;
}
logc的更新和LRU模式的lru字段一样,都会在key每次被访问的时候更新,只不过更新不是简单的“+1”,而是采用概率法进行递增,因为logc存储的是访问计数的对数值。
//对数递增计数值
uint8_t LFULogIncr(uint8_t counter) {
if(counter == 255) //到最大值了,不能再增加了
return 255;
double baseval = counter - LFU_INIT_VAL; //减去新对象初始化的基数值,LFU_INIT_VAL默认为5
//baseval若是小于0,说明这个对象快不行了,不过本次incr将会延长它的寿命
if(baseval < 0)
baseval = 0;
//当前计数越大,想要"+1"就越困难
//当baseval特别大时,最大是255-5,p值会非常小,很难走到counter++这一步
//p就是counter通往"+1"权利的门缝,baseval越大,这个门缝越窄,通过也就越难
double p = 1.0/(baseval * server.lfu_log_factor+1);
//lfu_log_factor为困难系数,默认为10
//开始随机尝试能否从门缝中挤进去
double r = (double)rand()/RAND_MAX;
if(r<p)
counter++;
return counter;
}
可以注意到,在上面的操作中,都涉及到缓存系统时间戳。Redis之所以要缓存系统时间戳,是因为每一次获取系统时间戳都是一次系统调用,比较费时,Redis为单线程的,它承受不起,而对时间进行缓存的话,获取时间时就可以直接去缓存拿了。