Redis内存淘汰机制
- 为什么要有内存淘汰机制
- 首先我们从过期删除开始
- 定期删除
- 惰性删除
- 总结
- 淘汰策略
- Redis的8种内存淘汰策略
- LRU
- 标准LRU实现方式
- Redis的LRU实现
- LRU源码实现
- 为什么要使用近似LRU?
- LFU
- Redis如何实现LFU
- 实现源码:
- 减少counter,unsigned long LFUDecrAndReturn(robj *o)
- 递增counter —— uint8_t LFULogIncr(uint8_t counter)
- 降低 LFUDecrAndReturn
- 增长 LFULogIncr
- 新生KEY策略
为什么要有内存淘汰机制
Redis的数据已经设置了TTL,不是过期就已经删除了吗?
答:不一定,过期不一定会马上删除,redis并没有为每一个key设置定时器,而是采用了 懒惰删除 + 定期删除 的过期策略。
为什么还存在所谓的淘汰策略呢?
答:因为就算有过期删除,如果使劲的往redis里面加入没有设置过期时间的数据,内存的空间不是无限大的,必定会满,这个时候就要有内存淘汰策略了
首先我们从过期删除开始
关于过期删除策略,笔者的 链接: 结合源码看Redis过期策略. 已经说的很清楚,这里简要概括
定期删除
- 服务器会根据 hz 配置来设置服务器定期删除的频率,默认为10,也就是100ms就定期删除一次。
- 但是不能100ms全部都用来删除,而是分快速模式。快速模式只有1ms的时间收集,普通模式的就只有间隔时间的25%(默认25ms)。超过25ms就会马上停止。
- 每一个库循环取20个样本删除其中的过期的key,如果每次取样本都有2个(默认2个)以上的过期,那么就继续取样删除过期的key。如果某一次取样都只有不超过2个过期,那么就停止,继续扫描下一个库。
惰性删除
所谓惰性策略就是在客户端访问这个key的时候,redis对key的过期时间进行检查,如果过期了就立即删除,不会给你返回任何东西。
定期删除可能会导致很多过期key到了时间并没有被删除掉。所以就有了惰性删除。假如你的过期 key,靠定期删除没有被删除掉,还停留在内存里,除非你的系统去查一下那个 key,才会被redis给删除掉。这就是所谓的惰性删除,即当你主动去查过期的key时,如果发现key过期了,就立即进行删除,不返回任何东西.
总结
定期删除是集中处理,惰性删除是零散处理。
这样做的目的是分散过期压力、保证Redis的高吞吐。
淘汰策略
因为不管是定期采样删除还是惰性删除都不是一种完全精准的删除,就还是会存在key没有被删除掉的场景,所以就需要内存淘汰策略进行补充。
Redis的8种内存淘汰策略
策略 | 解释 |
noeviction | 当内存使用超过配置的时候会返回错误,不会驱逐任何键 |
allkeys-lru | 加入键的时候,如果过限,首先通过LRU算法驱逐最久没有使用的键 |
volatile-lru | 加入键的时候如果过限,首先从设置了过期时间的键集合中驱逐最久没有使用的键 |
allkeys-random | 加入键的时候如果过限,从所有key随机删除 |
volatile-random | 加入键的时候如果过限,从过期键的集合中随机驱逐 |
volatile-ttl | 从配置了过期时间的键中驱逐马上就要过期的键 |
volatile-lfu | 从所有配置了过期时间的键中驱逐使用频率最少的键 |
allkeys-lfu | 从所有键中驱逐使用频率最少的键 |
LRU
标准LRU实现方式
一般情况下,标准的LRU是一个队列,插队尾,要淘汰就淘汰队头。
- 新增key value的时候首先在链表结尾添加Node节点,如果超过LRU设置的阈值就淘汰队头的节点并删除掉HashMap中对应的节点。
- 修改key对应的值的时候先修改对应的Node中的值,然后把Node节点移动队尾。
- 访问key对应的值的时候把访问的Node节点移动到队尾即可。
Redis的LRU实现
重点一:
Redis维护了一个24位时钟,可以简单理解为当前系统的时间戳,每隔一定时间会更新这个时钟。这个是全局的时钟。
重点二:
每个key对象内部同样维护了一个24位的时钟,当新增key对象的时候会把系统的时钟赋值到这个内部对象时钟。这个时钟也是会变的,每次查询的时候都会把当前的时间赋值给对象的 lru。
重点三:
比如我现在要进行LRU,那么首先拿到当前的全局时钟,然后再找到内部时钟与全局时钟距离时间最久的key(差最大)进行淘汰。
重点四:
这里值得注意的是全局时钟只有24位,按秒为单位来表示才能存储194天,所以可能会出现key的时钟大于全局时钟的情况,如果这种情况出现那么就两个相加而不是相减来求最久的key。
struct redisServer {
pid_t pid;
char *configfile;
// 全局时钟
unsigned lruclock:LRU_BITS;
...
};
Redis对象
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
/* key对象内部时钟 */
unsigned lru:LRU_BITS;
int refcount;
void *ptr;
} robj;
Redis中的LRU与常规的LRU实现并不相同,常规LRU会准确的淘汰掉队头的元素,但是Redis的LRU并不维护队列,只是根据配置的策略要么从所有的key中随机选择N个(N可以配置)要么从所有的设置了过期时间的key中选出N个键,然后再从这N个键中选出最久没有使用的一个key进行淘汰。
下图是常规LRU淘汰策略与Redis随机样本取一键淘汰策略的对比,浅灰色表示已经删除的键,深灰色表示没有被删除的键,绿色表示新加入的键,越往上表示键加入的时间越久。从图中可以看出,在redis 3中,设置样本数为10的时候能够很准确的淘汰掉最久没有使用的键,与常规LRU基本持平。
LRU源码实现
/* Given an object returns the min number of milliseconds the object was never
* requested, using an approximated LRU algorithm.
* 获取时间差, 时间差越大越容易被淘汰*/
unsigned long long estimateObjectIdleTime(robj *o) {
// 获取 当前秒数的后24位
unsigned long long lruclock = LRU_CLOCK();
// 当前时间和对象的lru 对比
if (lruclock >= o->lru) {
// 返回时间差的毫秒数
return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
} else {
// 这里是因为24位存时间可能会得到当前时间和 redis obj小的问题
// 所以以这样的形式获取时间差
return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
LRU_CLOCK_RESOLUTION;
}
}
为什么要使用近似LRU?
- 性能问题,由于近似LRU算法只是最多随机采样N个key并对其进行排序,如果精准需要对所有key进行排序,这样近似LRU性能更高
- 内存占用问题,redis对内存要求很高,会尽量降低内存使用率,如果是抽样排序可以有效降低内存的占用
- 实际效果基本相等,如果请求符合长尾法则,那么真实LRU与Redis LRU之间表现基本无差异
- 在近似情况下提供可自配置的取样率来提升精准度,例如通过 CONFIG SET maxmemory-samples 指令可以设置取样数,取样数越高越精准,如果你的CPU和内存有足够,可以提高取样数看命中率来探测最佳的采样比例。
LFU
LFU——最近最少使用淘汰
LFU是在Redis4.0后出现的,LRU的最近最少使用实际上并不精确,考虑下面的情况,如果在 “|” 处删除,那么A距离的时间最久,但实际上A的使用频率要比B频繁,所以合理的淘汰策略应该是淘汰B。LFU就是为应对这种情况而生的。
下面是按照LRU策略回收:
key A 从诞生到删除
A–A--A–A--A–A--A–A--A–A--A–A--A|
key B 从诞生到删除
–B------B--------B--------B----------------B|
Redis如何实现LFU
实现源码:
首先我们看看LFU和LRU在哪里做文章
// DictEntry就是Redis 各个字典上存的元素
typedef struct dictEntry {
void *key; // 指向key
union {
void *val; // 指向 redisObject 的值
uint64_t u64;
int64_t s64; // 生存时间点,过了这个时间就过期了
double d;
} v;
struct dictEntry *next;
} dictEntry;
// 下面是redis的值
typedef struct redisObject {
unsigned type:4; // 类型
unsigned encoding:4; // 编码
// 这个24位字段就是LRU 和LFU 算法实现的关键属性
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
再看看redis是怎么查找key的把。
/* Low level key lookup API, not actually called directly from commands
* implementations that should instead rely on lookupKeyRead(),
* lookupKeyWrite() and lookupKeyReadWithFlags().
* 低级键查找API,实际上不是直接从应依赖于lookupKeyRead(),lookupKeyWrite()和 lookupKeyReadWithFlags()的命令实现中直接调用的*/
robj *lookupKey(redisDb *db, robj *key, int flags) {
// 找到数组库中对应的key的元素对象
dictEntry *de = dictFind(db->dict,key->ptr);
// 如果对象找得到
if (de) {
// 获取元素的 obj ,维护了时间和counter计数器
robj *val = dictGetVal(de);
/* Update the access time for the ageing algorithm.
* Don't do it if we have a saving child, as this will trigger
* a copy on write madness. 更新老化算法的访问时间。 如果存在子进程进行RDB保存,请不要这样做,因为会发生一个复制在写上的扳机。*/
// 没有活动的子进程或者 可以改变(flag 代表 是否允许改变 redisObj的信息)
if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){
// 如果内存回收策略是 LFU maxmemory_policy是配置的内存回收策略
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
// 更新LFU信息
updateLFU(val);
} else {
// 否则直接获取LRU时钟赋值给RedisObj的 24位lru
val->lru = LRU_CLOCK();
}
}
return val;
} else {
return NULL;
}
}
void updateLFU(robj *val)
/* Update LFU when an object is accessed.
* Firstly, decrement the counter if the decrement time is reached.
* Then logarithmically increment the counter, and update the access time.
*
* 访问对象时更新LFU。
* 首先,如果达到递减时间,则递减计数器。
* 然后对数递增计数器,并更新访问时间。*/
void updateLFU(robj *val) {
// 如果达到递减时间,则递减计数器
unsigned long counter = LFUDecrAndReturn(val);
// 递增时间
counter = LFULogIncr(counter);
// 设置lru 高8位为分钟数 第八位为counter
val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}
减少counter,unsigned long LFUDecrAndReturn(robj *o)
/* If the object decrement time is reached decrement the LFU counter but
* do not update LFU fields of the object, we update the access time
* and counter in an explicit way when the object is really accessed.
* And we will times halve the counter according to the times of
* elapsed time than server.lfu_decay_time.
* Return the object frequency counter.
*
* This function is used in order to scan the dataset for the best object
* to fit: as we check for the candidate, we incrementally decrement the
* counter of the scanned objects if needed.
* 如果达到了对象递减时间,则LFU计数器递减,但不更新对象的LFU字段,那么当真正访问对象时,我们将以显式方式更新访问时间和计数器。
* 并且我们将根据经过的时间将计数器的时间减半比server.lfu_decay_time。 返回对象频率计数器。
* 使用此函数是为了扫描数据集以找到最适合的对象:在检查候选对象时,如果需要,我们将递增减少扫描对象的计数器。*/
unsigned long LFUDecrAndReturn(robj *o) {
// 原对象的 lru右移8位得到 ldt 上次访问的分钟数时间
unsigned long ldt = o->lru >> 8;
// 原对象的 lru & 255 计算出一个8位的 counter,就是获取原counter
unsigned long counter = o->lru & 255;
// 判断 server.lfu_decay_time (LFU计数器衰减系数)
// 如果大于0 就根据 ldt 获取一个数除以 server.lfu_decay_time(LFU衰减时间)得到一个时间段数
// 如果为 0 时间段就是 0, server.lfu_decay_time 越大 num_periods 就越小,counter减的就慢
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
// 如果时间段数 > 0 并且时间段数小于原counter 那么新的 counter 就是原 counter - num_periods
// 如果时间段数 > 0 并且时间段数大于原counter ,counter就为0
if (num_periods)
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter;
}
unsigned long LFUTimeElapsed(unsigned long ldt)
/* Given an object last access time, compute the minimum number of minutes
* that elapsed since the last access. Handle overflow (ldt greater than
* the current 16 bits minutes time) considering the time as wrapping
* exactly once. */
unsigned long LFUTimeElapsed(unsigned long ldt) {
// 获取现在的分钟数
unsigned long now = LFUGetTimeInMinutes();
// 如果现在分钟时间 大于 上一次的分钟时间 直接返回时间段
if (now >= ldt) return now-ldt;
// 如果没有大于,就是如下的值,为什么要用65535 - ldt + now ?
// 因为只有16位表示分钟数,会遇到上一次的时间比当前时间的值大的情况,这个时候比较准确的时间差就是
// + 2的 16次方再减去上次的ldt
return 65535-ldt+now;
}
递增counter —— uint8_t LFULogIncr(uint8_t counter)
/* Logarithmically increment a counter. The greater is the current counter value
* the less likely is that it gets really implemented. Saturate it at 255. */
uint8_t LFULogIncr(uint8_t counter) {
// 如果counter 为255 直接返回 ,这个时候counter不能再增了。已经到最大值了
if (counter == 255) return 255;
// 获取一个随机的小数 r 这个数是小于1 的
// 为什么小于1, 因为rand() 函数是取 0 ~ RAND_MAX 的伪随机数
double r = (double)rand()/RAND_MAX;
// 根据递减后的counter - LFU初始化值获取 基本的baseval 最小为0, LFU_INIT_VAL值为5
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
// 计算一个p 值为 1 / (baseval * LFU 反对数因子 + 1)
// 所以这个 server.lfu_log_factor 值越大 counter 递增的几率越小
// server.lfu_log_factor 为 0 的话就一定命中
double p = 1.0/(baseval*server.lfu_log_factor+1);
// 如果随机命中几率,counter就自增,如果没有命中,保持原值
if (r < p) counter++;
return counter;
}
重点一:
LFU把原来的key对象的内部时钟的24位分成两部分,前16位还代表时钟,后8位代表一个计数器。
重点二:
16位的情况下如果还按照秒为单位就会导致不够用,所以一般这里以时钟为单位。
重点三:
而后8位表示当前key对象的访问频率,8位只能代表255,但是redis并没有采用线性上升的方式,而是通过一个复杂的公式,通过配置如下两个参数来调整数据的递增速度。
lfu-log-factor: 可以调整计数器counter的增长速度,lfu-log-factor 越大,counter 增长的越慢。因为redis LFU counter增长是随机了,命中了才增,不命中就不增。
lfu-decay-time: 是一个以分钟为单位的数值,可以调整 counter 的减少速度。因为 lfu-decay-time 是作为递减数的计算分母,值越大,要减的值越少。
所以这两个因素就对应到了 LFU 的 Counter 减少策略和增长策略,它们实现逻辑分别如下。
降低 LFUDecrAndReturn
1、先从高16位获取最近的降低时间 ldt(上一次更新LFU设置的时间) 以及低8位的计数器 counter 值
2、计算当前时间 now 与 ldt 的差值(now-ldt),当 ldt 大于now 时,那说明是过了一个周期,按照65535 - ldt + now计算(16位一个周期最大65535)
3、使用第2步计算的差值除以 lfu_decay_time,即LFUTimeElapsed(ldt) / server.lfu_decay_time,已过去n个lfu_decay_time,则将 counter 减少 n。
增长 LFULogIncr
1、获取 0 - 1 的随机数 r
2、计算 0 - 1 之间的控制因子 p,它的计算逻辑如下
//LFU_INIT_VAL默认为5
baseval = counter - LFU_INIT_VAL;
//计算控制因子
p = 1.0/(baseval*lfu_log_factor+1);
3、如果r小于p,counter增长1
p 取决于当前 counter 值与 lfu_log_factor 因子,counter 值与 lfu_log_factor 因子越大,p 越小,r 小于 p 的概率也越小,counter 增长的概率也就越小。增长情况如下图:
从左到右表示key的命中次数,从上到下表示影响因子,在影响因子为100的条件下,经过10M次命中才能把后8位值加满到255.
新生KEY策略
另外一个问题是,当创建新对象的时候,对象的counter如果为0,很容易就会被淘汰掉,还需要为新生key设置一个初始counter。counter会被初始化为LFU_INIT_VAL,默认5。