redis 缓存过期默认时间_redis缓存过期机制

笔者在线上使用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/redis​github.com

参考文章【Redis源码分析】Redis中的LRU算法实现​segmentfault.com

你可能感兴趣的:(redis,缓存过期默认时间)