一、前言
Redis 是一个内存数据结构存储系统,主要用于高速缓存、消息队列等场景。由于 Redis 将所有数据存储在内存中,因此内存管理和优化非常重要。
当 Redis 的内存使用达到其配置的最大限制时,就需要采取措施来释放内存,否则 Redis 将无法接受新的写入请求,从而影响应用的性能。
二、删除策略
Redis 对于过期的 key,有两种删除策略:
-
定期删除
-
惰性删除
Redis 本身是一个典型的 key-value 内存存储数据库,因此所有的 key、value 都保存在 Dict 结构中
不过在其 database 结构体中,有两个 Dict:一个用来记录 key-value;另一个用来记录 key-TTL
typedef struct redisDb{
dict *dict; // 存放所有 key 及 value 的地方,也被称为 keyspace
dict *expires; // 存放每一个 key 及其对应的 TTL 存活时间,只包含设置了 TTL 的 key
dict *blocking_keys; // Keys with clients waiting for data (BLPOP)
dict *ready_keys; // Blocked keys that received a PUSH
dict *watched_keys; // WATCHED keys for MULTI/EXEC CAS
int id; // Database ID,0~15
long long avg_ttl; // 记录平均 TTL 时长
unsigned long expires_cursor; // expire 检查时在 dict 中抽样的索引位置
list *defrag_later; // 等待碎片整理的 key 列表
} redisDb;
1、定期删除:通过一个定时任务,周期性的抽样部分过期的 key,然后执行删除
执行周期有两种:
-
Redis 服务初始化函数 initServer() 中设置定时任务,按照 server.hz 的频率来执行过期 key 清理,模式为
SLOW
-
Redis 的每个事件循环前会调用 beforeSleep() 函数,执行过期 key 清理,模式为
FAST
SLOW 模式规则:
-
执行频率受
server.hz
影响,默认为10,即每秒执行10次,每个执行周期 100ms
-
执行清理耗时不超过一次执行周期的25%,默认 slow 模式耗时不超过 25ms
-
逐个遍历 db,逐个遍历 db 中的 bucket,抽取 20 个 key 判断是否过期(注意这里是随机抽取的),如果没达到时间上限(25ms)并且过期 key 比例大于 25%,再进行一次抽样,否则结束
FAST 模式规则(过期 key 比例小于25%不执行 ):
-
执行频率受
beforeSleep()
调用频率影响,但两次 FAST 模式间隔不低于 2ms
-
执行清理耗时不超过1ms
-
逐个遍历 db,逐个遍历 db 中的 bucket,抽取 20 个 key 判断是否过期(注意这里是随机抽取的),如果没达到时间上限(1ms)并且过期 key 比例大于 25%,再进行一次抽样,否则结束
Q:为什么要随机呢?
A:假如 Redis 存了几十万个 key ,每隔 100ms 就遍历所有的设置过期时间的 key 的话,就会给 CPU 带来很大的负载。
2、惰性删除:并不是在 TTL 到期后就立刻删除,而是在访问一个 key 的时候,检查该 key 的存活时间,如果已经过期才执行删除
// 查找一个 key 执行写操作
robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags){
// 检查 key 是否过期
expireIfNeeded(db, key);
return loopupKey(db, key, flags);
}
// 查找一个 key 执行读操作
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags){
robj *val;
// 检查 key 是否过期
if(expirIfNeeded(db, key) == 1){
// ...
}
return NULL;
}
int expireIfNeeded(redisDb *db, robj *key){
// 判断是否过期,如果未过期直接结束并返回 0
if(!keyIsExpired(db, key)) return 0;
// ...
// 删除过期 key
deleteExpiredKeyAndPropagate(db, key);
return 1;
}
三、淘汰策略
已经有定期删除和惰性删除机制了,为什么还需要淘汰策略呢?
1、内存限制:
Redis 默认情况下会尽可能多地使用系统可用的内存。然而,在生产环境中,通常会对 Redis 实例设置最大内存限制(通过 maxmemory
配置项),以防止 Redis 占用过多的系统内存资源,影响其他服务的运行。
2、避免 OOM 错误:
当 Redis 达到最大内存限制时,如果没有适当的内存淘汰机制,Redis 可能会因为内存不足而拒绝新的写入操作,甚至导致操作系统出现 Out Of Memory (OOM) 错误。内存淘汰策略可以在内存达到上限时自动释放不再需要的数据,从而避免这种情况发生。
3、保证服务可用性:
内存淘汰策略可以在内存紧张的情况下,通过牺牲部分数据的持久性来保证 Redis 服务的基本可用性。例如,在内存不足时,可以选择性地删除最近最少使用的键(LRU),从而为新的数据腾出空间。
同时为了在不牺牲一致性的情况下获得正确行为。
当 key 过期时,DEL 操作将同时在 AOF 文件中合成并获取所有附加的从节点,此时过期的这个处理过程集中到主节点中,还没有一致性错误的可能性
但是,存在以下特殊情况,虽然连接到主节点的从节点不会独立过期 key(会等待来自 master 的 DEL),但它们仍将使用数据集中现有过期的完整状态,因此,当切换 slave 作为 master 时,它将能够独立过期 key,完全充当 master
可是,很多过期 key,还没及时去查,定期删除也漏掉了,大量过期 key 堆积内存,Redis 内存殆耗尽!因此还需有内存淘汰机制!
内存淘汰:就是当 Redis 内存使用达到设置的上限时,主动挑选部分 key 删除以释放更多内存的流程
Redis 会在处理客户端命令的方法 processCommand() 中尝试做内存淘汰
int processCommand(client *c){
// 如果服务器设置了 server.maxmemory 属性,并且并未有执行 Lua 脚本
if(server.maxmemory && !server.lua_timedout){
// 尝试进行内存淘汰 performEvictions
int out_of_memory = (performEvictions() == EVICT_FAIL);
// ...
if(out_of_memory && reject_cmd_on_oom){
rejectCommand(c, shared.oomerr);
return C_OK;
}
// ...
}
}
Redis 支持 8 种不同策略来选择要删除的 key:
-
noeviction
: 不淘汰任何 key,但是内存满时不允许写入新数据,默认就是这种策略 -
volatile-ttl
: 对设置了 TTL 的 key,比较 key 的剩余 TTL 值,TTL 越小越先被淘汰 -
allkeys-random
:对全体 key,随机进行淘汰,也就是直接从 db->dict 中随机挑选 -
volatile-random
:对设置了 TTL 的 key ,随机进行淘汰,也就是从 db->expires 中随机挑选 -
allkeys-lru
: 对全体 key,基于 LRU 算法进行淘汰 -
volatile-lru
: 对设置了 TTL 的 key,基于 LRU 算法进行淘汰
【后面两种是在 Redis 4.0 之后加入的】
-
allkeys-lfu
: 对全体 key,基于 LFU 算法进行淘汰 -
volatile-lfu
: 对设置了 TTL 的 key,基于 LFU 算法进行淘汰
内存淘汰策略可以通过配置文件来修改,redis.conf 对应的配置项是 maxmemory-policy 修改对应的值就行,默认是 noeviction
其中主要的算法有两种:LRU 和 LFU
以下是 Redis 4.0 的配置文件:
需要修改配置文件,可以直接编辑 redis.conf 文件,也可以使用命令对其进行修改
config set maxmemory-policy allkeys-lru
四、算法简介
1、LRU
LRU(Least Recently Used,即最近最少使用)会将最不常用的数据筛选出来,保留最近频繁使用的数据。
LRU 会把所有数据组成一个链表:
-
链表头部称为 MRU 代表
最近最常使用
的数据; -
链表尾部称为 LRU 代表
最近最不常使用
的数据;
采用 LRU 可以淘汰最近不常使用的数据,但是直接使用也会存在一些问题:
LRU 算法在实现过程中使用链表
管理所有缓存的数据,这会给 Redis 带来额外的开销,而且,当有数据访问时就会有链表移动操作
,进而降低 Redis 的性能。
于是,Redis 对 LRU 的实现进行了一些改变:
-
记录每个 key 最近一次被访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)
-
在第一次淘汰数据时,会先随机选择 N 个数据作为一个候选集合,然后淘汰 lru 值最小的(N 可以通过
config set maxmemory-samples 100
命令来配置) -
后续再淘汰数据时,会挑选数据进入候选集合,进入集合的条件是:它的 lru 小于候选集合中最小的 lru
-
如果候选集合中数据个数达到了
maxmemory-samples
,Redis 就会将 lru 值小的数据淘汰出去
2、LFU
LFU(Least Frequently Used,即最不经常使用)基于数据访问次数来淘汰数据,在 Redis 4.0 时添加进来。
它在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。
LRU 使用了 RedisObject 中的 lru 字段记录时间戳,lru 是 24bit 的,LFU 将 lru 拆分为两部分:
-
ldt 值:lru 字段的前 16bit,表示数据的访问时间戳
-
counter 值:lru 字段的后 8bit,表示数据的访问次数
使用 LFU 策略淘汰缓存时,会把访问次数最低的数据淘汰,如果访问次数相同,再根据访问的时间,将访问时间戳最小的淘汰。
为什么 Redis 有了 LRU 还需要 LFU 呢?
在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。
当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。
由于 LRU 是基于访问时间的,如果系统对大量数据进行单次查询,这些数据的 lru 值就很大,使用 LFU 算法就不容易被淘汰。
RedisObject 源代码:
typedef struct redisObject {
unsigned tyep:4; // 对象类型
unsigned encoding:4; // 编码方式
/**
* LRU:以秒为单位记录最近一次访问时间,长度 24 bit
* LFU:高 16 位以分钟为单位记录最近一次访问时间,低 8 位记录逻辑访问次数
* counter 仅有 8 位很容易就溢出了,技巧是用一个逻辑计数器,给予概率的对数计数器,而不是一个普通的递增计数器
**/
unsigned lru:LRU_BITS;
int refcount; // 引用计数,计数为 0 则可以回收
void *ptr; // 数据指针,指向真实数据
} robj;
LFU 的数据结构:
struct entry {
/* Field that the LFU Redis implementation will have (we have
* 24 bits of total space in the object->lru field). */
uint8_t counter; /* Logarithmic counter. */
uint16_t decrtime; /* (Reduced precision) time of last decrement. */
/* Fields only useful for visualization. */
uint64_t hits; /* Number of real accesses. */
time_t ctime; /* Key creation time. */
};
LFU 的访问次数之所以叫做逻辑访问次数
,是因为并不是每次 key 被访问都计数,而是通过运算:
-
如果计数器已经是最大值 255,则不再递增
-
生产一个随机数 r,介于 0~1 之间
-
计算了当前计数器值与初始值之间的差值
baseval
-
如果
baseval
小于 0,则将其设置为 0 -
limit
计算了递增的概率,其值随着baseval
的增大而减小 -
如果生成的随机数
r
小于limit
,则计数器递增 1 -
返回最终的计数器值
#define COUNTER_INIT_VAL 5
/* Increment a couter logaritmically: the greatest is its value, the
* less likely is that the counter is really incremented.
* The maximum value of the counter is saturated at 255. */
uint8_t log_incr(uint8_t counter) {
if (counter == 255) return counter;
double r = (double)rand()/RAND_MAX;
double baseval = counter-COUNTER_INIT_VAL;
if (baseval < 0) baseval = 0;
double limit = 1.0/(baseval*10+1);
if (r < limit) counter++;
return counter;
}
对应的概率分布计算公式为:
其中 COUNTER_INIT_VAL
默认为 5,其实简单说就是,越大的数,递增的概率越低
严格按照 LFU 算法,时间越久的 key,counter 越有可能越大,被剔除的可能性就越小
counter 只增长不衰减就无法区分热点 key,为了解决这个问题,redis 提供了衰减因子 server.lfu_decay_time
,其单位为分钟
计算方法也很简单,如果一个 key 长时间没有访问那么他的计数器 counter 就要减少,减少的值由衰减因子来控制
/* Simulate an access to an entry. */
void access_entry(struct entry *e) {
e->counter = log_incr(e->counter);
e->hits++;
}
五、实践
选择合适的内存淘汰策略需要根据实际应用场景和业务需求来决定。以下是一些建议:
-
数据完整性要求高:选择
noeviction
策略。 -
缓存场景,希望保留最新、最常使用的数据:选择
allkeys-lru
或volatile-lru
策略。 -
数据新鲜度要求不高,需要快速响应:选择
allkeys-random
或volatile-random
策略。 -
希望优先删除即将过期的数据:选择
volatile-ttl
策略。 -
希望删除最不频繁使用的数据:选择
allkeys-lfu
或volatile-lfu
策略。
通过合理选择和配置内存淘汰策略,可以确保 Redis 在内存受限的情况下仍能提供稳定的服务。
-
noeviction
-
描述:当 Redis 达到最大内存限制时,任何写入操作都会返回错误,并拒绝执行。
-
适用场景:适用于严格要求数据完整性的场景,不允许丢失任何数据。
-
-
volatile-ttl
-
描述:当 Redis 达到最大内存限制时,对设置了 TTL 的 key,比较 key 的剩余 TTL 值,TTL 越小越先被淘汰。
-
适用场景:适用于希望优先删除即将过期的数据。
-
-
allkeys-random
-
描述:当 Redis 达到最大内存限制时,对全体 key,随机进行淘汰。
-
适用场景:适用于对数据的新鲜度要求不高,但需要快速响应的场景。
-
-
volatile-random
-
描述:当 Redis 达到最大内存限制时,对设置了 TTL 的 key,随机进行淘汰。
-
适用场景:适用于希望优先删除即将过期的数据,但对数据的新鲜度要求不高。
-
-
allkeys-lru
-
描述:当 Redis 达到最大内存限制时,对全体 key,基于 LRU(最近最少使用)算法进行淘汰。
-
适用场景:适用于缓存场景,希望删除最近最少使用的数据。
-
-
volatile-lru
-
描述:当 Redis 达到最大内存限制时,对设置了 TTL 的 key,基于 LRU 算法进行淘汰。
-
适用场景:适用于希望优先删除即将过期的数据,保留持久的数据。
-
-
allkeys-lfu
-
描述:当 Redis 达到最大内存限制时,对全体 key,基于 LFU(最不频繁使用)算法进行淘汰。
-
适用场景:适用于希望删除最不频繁使用的数据。
-
-
volatile-lfu
-
描述:当 Redis 达到最大内存限制时,对设置了 TTL 的 key,基于 LFU 算法进行淘汰。
-
适用场景:适用于希望优先删除即将过期的数据,并且希望删除最不频繁使用的数据。
-
参考文章:
一 叶 知 秋,奥 妙 玄 心