笔者在线上使用redis缓存的时候发现即使某些查询已经设置了无过期时间的缓存,但是查询仍然非常耗时。经过排查,发现缓存确实已经不存在,导致了缓存击穿,查询直接访问了mysql数据库。因为我们用的是公司公共的redis缓存服务器,在和运维人员交流后发现可能是redis的内存淘汰策略引起。
1. 什么是内存淘汰
redis是基于内存的key-value数据库,内存是有限的宝贵资源,当内存耗尽的时候,redis有如下5种处理方式来应对
No-eviction
在该策略下,如果继续向redis中添加数据,那么redis会直接返回错误
Allkeys-lru
从所有的key中使用LRU算法进行淘汰
Volatile-lru
从设置了过期时间的key中使用LRU算法进行淘汰
Allkeys-random
从所有key中随机淘汰数据
Volatile-random
从设置了过期时间的key中随机淘汰
Volatile-ttl
从设置了过期时间的key中,选择剩余存活时间最短的key进行淘汰
除上述6种淘汰策略外,Redis 4.0新增了两种淘汰策略
Volatile-lfu
从设置了过期时间的key中选择key进行淘汰
Allkeys-lfu
从所有的key中使用lfu进行淘汰
2. 内存淘汰算法选择
上述总共谈到了8种内存淘汰策略,但是如何选择呢?
从缓存类型来说,其中名称中带volatile的策略确定了被淘汰的缓存仅从设置了过期时间的key中选择,如果没有任何key被设置过期时间,那么Volatile-lru,Volatile-random,Volatile-lfu表现得就分别和allkeys-lru,allkeys-random,allkey-lfu算法相同。
如果缓存有明显的热点分布,那么应该选择lru类型算法,如果缓存被访问几率相同,那么应该选择随机淘汰算法。
在使用中,lru和lfu效果差不多,而且lru可能更有优势一些,笔者在下文详细的对比了lru和lfu
3. 配置使用
我们可以通过redis.conf文件配置如下两个参数
maxmemory 10mb
maxmemory-policy allkeys-random
如果maxmemory被设置为0则代表无限制redis的内存使用,但是这种方案可能会导致redis耗尽内存从而造成磁盘数据交换,这种情况下可能会造成频繁的访问中断,redis也失去了高性能的优势。Redis上述的两个参数也支持运行情况下修改,比如使用如下命令将内存使用限制为100MB,
CONFIG SET maxmemory 100MB
4. LRU
LRU即最近最久未使用算法,它利用局部性原理管理数据,它根据历史访问记录淘汰数据,如果数据最近被访问过,那么将来被访问的几率也越高。
4.1 使用java实现lru
利用java提供LinkedHashMap我们可以非常轻松实现一个指定大小的lru缓存
public class LruCache extends LinkedHashMap {
private final int capacity;
public LruCache(int capacity) {
super((int) (Math.ceil(capacity / 0.75) + 1), 0.75f, true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
//将当前缓存中最旧的元素打印出来
System.out.println(eldest.getKey() + ":" + eldest.getValue());
return this.size() > capacity;
}
}
LinkedHashMap实现LRU缓存的原理是什么呢?LinkedHashMap是基于HashMap实现的。因为HashMap是无序的,所以需要维护一个数据最近被访问的顺序队列,如果某个数据节点被访问,那么我们需要将该数据节点移到队列的尾部。
如果某个节点key1长时间不被访问,那么它就会位于队首,在移除最近没有访问的节点的时候,移除队首节点元素即可。
因此我们需要额外的数据结构,LinkedHashMap使用了链表维护访问顺序,因为节点在队列的位置经常被移动,所以LinkedHashMap使用了双向链表。双向链表节点如下所示
static class Entry extends HashMap.Node {
Entry before, after;
Entry(int hash, K key, V value, Node next) {
super(hash, key, value, next);
}
}
如果我们每向缓存中添加一个键值对,就需要增加一个Node节点。如果使用java语言维护缓存key,一个缓存节点需要耗费多少内存呢?
首先Node对象Markword需要占用16个字节,变量before和after分别占用了8个字节,父类中还有如下四个变量
final int hash; //4个字节
final K key; //8个字节
V value; // 8个字节
Node next; //8个字节
所以为了维护键值对缓存,总共需要60个字节,如果redis实例维护1000万个key,那么总共需要耗费内存572MB内存。即时考虑指针压缩,仍然需要300MB以上内存。
所以如果维护一个严格的LRU算法链表,需要消耗非常多的内存和CPU资源,并且redis是单线程设计的,一旦因为维护key造成阻塞,redis性能就急剧下降,所以在Redis中使用近似LRU算法 N Key In LRU
4.2 N Key In LRU算法
Redis的每个key对象都有一个时间戳字段,它记录的是key最近一次被访问的时间戳,Redis采用的N Key In LRU算法,指的是随机取出若干个key,然后针对这n个key执行lru算法,淘汰掉最近最久未使用的key,redis对n默认的配置是5(在redis配置文件redis.conf具体的参数名为maxmemory-samples),maxmemory-samples参数越大,那么redis的N Key In LRU算法就越接近lru算法,但是maxmemory-samples越大,那么就越消耗CPU
5. LFU算法
从redis4.0开始,redis实现了新的内存淘汰算法LFU,LFU也称为最近最少使用算法,它也是基于局部性原理:如果一个数据在最近一段时间内使用次数最少,那么在将来一段时间内被使用的可能性也很小。
5.1 使用java实现lfu算法
笔者使用java实现了一个简单的lfu算法。核心的put方法如下
public void put(K key, V value) {
//首先判断是否有剩余空间
if (this.data.size() >= this.capacity) {
Map.Entry> old = Collections.min(this.data.entrySet(), (o1, o2) -> {
final CacheValue v1 = o1.getValue();
final CacheValue v2 = o2.getValue();
if (v1.getCount() != v2.getCount()) {
return v1.getCount() - v2.getCount();
}
return (int) (v1.getTime() - v2.getTime());
});
System.out.println("remove:" + old.getKey() + "," + old.getValue());
this.data.remove(old.getKey());
}
CacheValue old = this.data.get(key);
if (old != null) {
old.incCount();
} else {
CacheValue cacheValue = new CacheValue<>(value);
this.data.put(key, cacheValue);
}
}
LFU和LRU不同之处在于LRU关注于访问时间,而LFU关注于访问频次。和lru相比,笔者没有想到lfu的优点,但是lfu的缺点还是比较明显的。Lfu的缺陷是,如果某些缓存在某段时间内访问非常频繁,这些数据就会立即成为热点数据,但是这些数据都是暂时的高频访问数据,之后长期不访问,在lfu算法下,这些数据都不会被淘汰。比如秒杀场景,在秒杀时,秒杀商品都是高频访问的key,但是秒杀结束后,这些key都可能不会再被访问,但是在lfu算法下,它很长时间都不会被淘汰。上述笔者写的代码也存在一个非常大的问题,就是排序遍历取全部缓存节点访问频次最少的一个key,如果缓存不多还好,如果达到了千万级别缓存,排序遍历这些key的成本是非常大的,Redis作者在实现lfu算法的时候也有考虑到上述问题,
5.2 redis lfu原理
redis作者是如何实现lfu的呢?
实现lfu需要两个关键的字段,一个是key创建时间戳,另一个是访问总数。为了复用字段,redis复用了key的时间戳字段,将时间戳字段一分为二,高16位用于存储分钟级别的时间戳,低八位用于记录访问总数counter值,八位二进制最大值为255。但是key高并发情况下,1000以上的qps也不足为奇。为了将访问次数缩放到255以内,redis引入了server.lfu_log_factor配置值,通过这个配置值,即使是千万级别的访问量,redis也能将其缩放到255以内,redis是通过如下这个方法实现缩放counter值,。
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
//取一随机小数
double r = (double)rand()/RAND_MAX;
//counter减去初始值5,设置初始值的目的是防止key刚被放入就被淘汰
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
//server.lfu_log_factor默认为10
double p = 1.0/(baseval*server.lfu_log_factor+1);
// counter越大,则p越小,counter获得增长的机会也越小
if (r < p) counter++;
return counter;
}
如何避免临时高频访问的key常驻内存呢?redis采用了一种策略,它会让key的访问次数随着时间衰减。
unsigned long LFUDecrAndReturn(robj *o) {
//分钟时间戳
unsigned long ldt = o->lru >> 8;
//当前counter值
unsigned long counter = o->lru & 255;
// 默认每经过一分钟counter衰减1
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
if (num_periods)
//计算衰减后的值
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter;
}
为了避免排序过程,redis采用了如下的设计方案。
redis新增了pool机制, redis每次都将随机选择的10个key放在pool中,但是随机选择的key的时间戳必须小于pool中最小的key的时间戳才会继续放入,直到pool放满了,如果有新的key需要放入,那么需要将池中最大的一个时间戳的key取出。
6. 键过期删除策略
6.1 删除方案
定时器
如果key被设置了过期时间,那么便为key创建一个定时器,在缓存键到期的时候,触发一个任务,将该key对应的缓存删除。虽然能保证key对应的键在到达指定的时间时删除并释放缓存占用的内存,但是这种处理方案会创建非常多的定时器,定时器本身会占用非常多的资源,如果遇上缓存雪崩,定时任务在同一时间点被触发,那么在这一时间点会占用非常多的CPU资源
惰性删除
Key过期的时候,不会立即删除缓存。而是当缓存被访问的时候,先检查key是否到期,若到期则删除,同时向用户返回nil。这种处理方式能尽量减少CPU的占用,但是如果有大量的key过期,并且这些缓存永远都不会被用户访问的情况下,会存在内存回收的问题,那么内存浪费严重。
定期删除
创建守护线程,守护线程每隔一段时间对key进行一次扫描,发现过期的key则将缓存删除。这种处理方案也许是内存和CPU之间比较好的平衡,它既不会像定时器方案一样创建大量的定时器占用内存和CPU,也不会像惰性删除那样存在内存泄露的潜在问题。但是这种方案存在最大的问题是用户仍然可能会访问到过期的缓存,并且这种方案难点在于守护线程触发频率的选择上,如果频率太高,并且存在数百万甚至上千万key的时候,那么守护线程占用CPU时间也会特别长可能会影响用户查询,如果执行频率太低,失效的缓存会占用太多的内存。
6.2 Redis删除方案
上述的三种删除方案各有各的好处,为了尽量回收内存,同时减少CPU占用,Redis采用了定时删除+惰性删除的联合方案。一方面采用后台线程定期删除失效的缓存key,另一方面为了避免用户查询到失效的缓存,用户在查询缓存的时候首先需要检查key是否仍然有效,如果失效就删除缓存
上文中涉及的java代码如下https://github.com/lan1tian/redisgithub.com
参考文章【Redis源码分析】Redis中的LRU算法实现segmentfault.com