文章目录
一、置过期时间
Redis 有四个不同的命令可以设置键的生存时间
● EXPIRE : 用于键 key 的生存时间设置为 ttl 秒
● PEXPIRE :用于键 key 的生存时间设置为 ttl 毫秒
● EXPIREAT : 用于将键 key的过期时间设置为 timestamp 所指定的秒数时间戳
● PEXPIREAT : 用于将键 key的过期时间设置为 timestamp 所指定的毫秒数时间戳
虽然有多种不同的设置方式,但实际上都是通过使用 PEXPIREAT 命令来实现的(也就是其它命令转换为 PEXPIREAT 命令来实现)
二、Redis key 过期时间存储
Redis 中所有 key 的过期时间保存在一个叫做 expires 的字典中,expires 其中的键是一个指针,指向 Redis 中的某个键对象;expires 字典中的值是一个 long long 型整数,这个整数保存了键所指向 Redis 中的键的过期时间——毫秒级精度的 UNIX 时间戳。
可以参考一下以下图
其中 Redis 有三个键值对:book、msg、cookie,而 book、msg 有设置过期时间,cookie 没有设置过期时间。
Redis key过期的判断
- 检查给定的键是否存在于过期字典:如果存在,那么取得键的过期时间。
- 检查当前 UNIX 时间戳是否大于键的过期时间:如果是的话,那么键已经过期;否则,键未过期。
三、Redis的过期策略
1、惰性删除
设置键与过期时间后,程序只有在取出键的时候才对键进行过期检查,如果过期则执行删除操作
这个策略对 CPU 时间来说是最友好的,但是对内存是最不友好的,因为如果存在多个键,而之后不会对他们访问,则一直占用着内存不释放。
2、定期删除
Redis 默认每秒进行 10 次扫描,过期扫描不会遍历过期字典中所有的 key,而是采用一种简单的“贪心策略”
- 从过期中随机选取出 20 个 key
- 删除这 20 个 key 中已经过期的 key
- 如果过期的 key 的比例超过 1/4 ,那就重复回到 1 步骤继续选取……
同时,为了保证过期扫描不会出现循环过度而导致线程卡死的现象,算法增加了扫描的时间上限,默认不会超过 25ms。
3、从节点的过期策略
从节点不会进行过期扫描,从节点对过期的处理是被动的。通过主节点在 AOF 文件中添加 del 指令,同步到所有的从节点,从节点通过执行这条 del 指令来删除过期的 key。
4、懒惰删除(unlink)
这里的懒惰删除并不是指惰性删除,而是说 Redis4.0 后提供的一种对删除操作进行懒处理,讲删除操作丢给后台异步线程进行资源回收。
为什么需要用到懒惰删除呢?
del 指令会直接释放对象的内存,大部分情况下,del 操作都会很快,但是 del 一个大的对象,那么删除操作就会导致单线程卡顿。
为了解决这个问题,在 4.0 版本中引入了 unlink 指令,它能够对删除操作进行懒处理,丢给后台异步线程进行资源回收。原理类似于 JVM 中判断对象是否可回收,通过对象可达性进行分析,同样这里的是将对象到 ROOT 部分的联系给断开。
> unlink key
ok
主线程将对象的引用断开后,会将这个 key 的内存回收操作包装成一个任务,投递进一个异步的任务队列中,后台线程会从这个异步队列中取出任务,然后执行。
注意:并不是所有的 unlink 操作都会延后处理,如果对应的 key 所占用的内存很小,延后处理就没有必要了,这时 Redis 对 key 的内存立即回收,效果和 del 指令一样。
四、内存回收机制(过期删除策略和内存淘汰策略)
这里首先引申出一个问题
手动执行定时器,set 数据没有报错,但是 set 数据之后不生效。
1、为什么需要内存回收?
- 在Redis中,set指令可以指定key的过期时间,当过期时间到达以后,key就失效了;
- Redis是基于内存操作的,所有的数据都是保存在内存中,一台机器的内存是有限且很宝贵的。
基于以上两点,为了保证Redis能继续提供可靠的服务,Redis需要一种机制清理掉不常用的、无效的、多余的数据,失效后的数据需要及时清理,这就需要内存回收了。
2、过期删除策略
(1)、过期删除策略原理
为了大家听起来不会觉得疑惑,在正式介绍过期删除策略原理之前,先给大家介绍一点可能会用到的相关Redis基础知识。
RedisDb 结构体定义
我们知道,Redis是一个键值对数据库,对于每一个redis数据库,redis使用一个redisDb的结构体来保存,它的结构如下:
typedef struct redisDb {
dict *dict; /* 数据库的键空间,保存数据库中的所有键值对 */
dict *expires; /* 保存所有过期的键 */
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; /* 数据库ID字段,代表不同的数据库 */
long long avg_ttl; /* Average TTL, just for stats */
} redisDb;
从结构定义中我们可以发现,对于每一个Redis数据库,都会使用一个字典的数据结构来保存每一个键值对,dict的结构图如下:
以上就是过期策略实现时用到比较核心的数据结构。程序=数据结构+算法,介绍完数据结构以后,接下来继续看看处理的算法是怎样的。
expires属性
redisDb定义的第二个属性是expires,它的类型也是字典,Redis会把所有过期的键值对加入到expires,之后再通过定期删除来清理expires里面的值。加入expires的场景有:
1、set指定过期时间expire
如果设置key的时候指定了过期时间,Redis会将这个key直接加入到expires字典中,并将超时时间设置到该字典元素。
2、调用expire命令
显式指定某个key的过期时间
3、恢复或修改数据
从Redis持久化文件中恢复文件或者修改key,如果数据中的key已经设置了过期时间,就将这个key加入到expires字典中
以上这些操作都会将过期的key保存到expires。redis会定期从expires字典中清理过期的key。
Redis清理过期key的时机
1、Redis在启动的时候,会注册两种事件,一种是时间事件,另一种是文件事件。(可参考启动Redis的时候,Redis做了什么)时间事件主要是Redis处理后台操作的一类事件,比如客户端超时、删除过期key;文件事件是处理请求。
在时间事件中,redis注册的回调函数是serverCron,在定时任务回调函数中,通过调用databasesCron清理部分过期key。(这是定期删除的实现。)
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData){
…
/* Handle background operations on Redis databases. */
databasesCron();
...
}
2、每次访问key的时候,都会调用expireIfNeeded函数判断key是否过期,如果是,清理key。(这是惰性删除的实现。)
robj *lookupKeyRead(redisDb *db, robj *key) {
robj *val;
expireIfNeeded(db,key);
val = lookupKey(db,key);
...
return val;
}
3、每次事件循环执行时,主动清理部分过期key。(这也是惰性删除的实现。)
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
if (eventLoop->beforesleep != NULL)
eventLoop->beforesleep(eventLoop);
aeProcessEvents(eventLoop, AE_ALL_EVENTS);
}
}
void beforeSleep(struct aeEventLoop *eventLoop) {
...
/* Run a fast expire cycle (the called function will return
- ASAP if a fast cycle is not needed). */
if (server.active_expire_enabled && server.masterhost == NULL)
activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);
...
}
过期策略的实现
我们知道,Redis是以单线程运行的,在清理key是不能占用过多的时间和CPU,需要在尽量不影响正常的服务情况下,进行过期key的清理。过期清理的算法如下:
1、server.hz配置了serverCron任务的执行周期,默认是10,即CPU空闲时每秒执行十次。
2、每次清理过期key的时间不能超过CPU时间的25%:timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
比如,如果hz=1,一次清理的最大时间为250ms,hz=10,一次清理的最大时间为25ms。
3、如果是快速清理模式(在beforeSleep函数调用),则一次清理的最大时间是1ms。
4、依次遍历所有的DB。
5、从db的过期列表中随机取20个key,判断是否过期,如果过期,则清理。
6、如果有5个以上的key过期,则重复步骤5,否则继续处理下一个db
7、在清理过程中,如果达到CPU的25%时间,退出清理过程。
从实现的算法中可以看出,这只是基于概率的简单算法,且是随机的抽取,因此是无法删除所有的过期key,通过调高hz参数可以提升清理的频率,过期key可以更及时的被删除,但hz太高会增加CPU时间的消耗。
删除key(Redis4.0之后对于删除key的变化使用unlink进行删除)
Redis4.0以前,删除指令是del,del会直接释放对象的内存,大部分情况下,这个指令非常快,没有任何延迟的感觉。但是,如果删除的key是一个非常大的对象,比如一个包含了千万元素的hash,那么删除操作就会导致单线程卡顿,Redis的响应就慢了。为了解决这个问题,在Redis4.0版本引入了unlink指令,能对删除操作进行“懒”处理,将删除操作丢给后台线程,由后台线程来异步回收内存。
实际上,在判断key需要过期之后,真正删除key的过程是先广播expire事件到从库和AOF文件中,然后在根据redis的配置决定立即删除还是异步删除。
如果是立即删除,Redis会立即释放key和value占用的内存空间,否则,Redis会在另一个bio线程中释放需要延迟删除的空间。
总结
总的来说,Redis的过期删除策略是在启动时注册了serverCron函数,每一个时间时钟周期,都会抽取expires字典中的部分key进行清理,从而实现定期删除。另外,Redis会在访问key时判断key是否过期,如果过期了,就删除,以及每一次Redis访问事件到来时,beforeSleep都会调用activeExpireCycle函数,在1ms时间内主动清理部分key,这是惰性删除的实现。
Redis结合了定期删除和惰性删除,基本上能很好的处理过期数据的清理,但是实际上还是有点问题的,如果过期key较多,定期删除漏掉了一部分,而且也没有及时去查,即没有走惰性删除,那么就会有大量的过期key堆积在内存中,导致redis内存耗尽,当内存耗尽之后,有新的key到来会发生什么事呢?是直接抛弃还是其他措施呢?有什么办法可以接受更多的key?接下来我们就来看一下下面的内存淘汰机制。
3、内存淘汰机制
Redis的内存淘汰策略,是指内存达到maxmemory极限时,使用某种算法来决定清理掉哪些数据,以保证新数据的存入。
Redis的内存淘汰机制:
1、noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错。
2、allkeys-lru:当内存不足以容纳新写入数据时,在键空间(server.db[i].dict)中,移除最近最少使用的 key(这个是最常用的)。
3、allkeys-random:当内存不足以容纳新写入数据时,在键空间(server.db[i].dict)中,随机移除某个 key。
4、volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间(server.db[i].expires)中,移除最近最少使用的 key。
5、volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间(server.db[i].expires)中,随机移除某个 key。
6、volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间(server.db[i].expires)中,有更早过期时间的 key 优先移除。
在配置文件中,通过maxmemory-policy可以配置要使用哪一个淘汰机制。
(1)什么时候会采取内存淘汰机制?
Redis会在每一次处理命令的时候(processCommand函数调用freeMemoryIfNeeded)判断当前redis是否达到了内存的最大限制,如果达到限制,则使用对应的算法去处理需要删除的key。
int processCommand(client *c){
...
if (server.maxmemory) {
int retval = freeMemoryIfNeeded();
}
...
}
(2)LRU实现原理(volatile-lru)LRU与LFU深入分析
在淘汰key时,Redis默认最常用的是LRU算法(Latest Recently Used)。Redis通过在每一个redisObject保存lru属性来保存key最近的访问时间,在实现LRU算法时直接读取key的lru属性。
具体实现时,Redis遍历每一个db,从每一个db中随机抽取一批样本key,默认是3个key,再从这3个key中,删除最近最少使用的key。实现伪代码如下:
keys = getSomeKeys(dict, sample)
key = findSmallestIdle(keys)
remove(key)
3这个数字是配置文件中的maxmeory-samples字段,也是可以可以设置采样的大小,如果设置为10,那么效果会更好,不过也会耗费更多的CPU资源。
4、set没有报错,但是不生效
我们在了解完过期删除策略和内存淘汰机制之后就可以回想到之前的问题,set没有报错,但是在redis字典中不生效的问题。
1、设置的过期时间过短,比如,1s?
2、内存超过了最大限制,且设置的是noeviction或者allkeys-random。
因此,在遇到这种情况,首先看set的时候是否加了过期时间,且过期时间是否合理,如果过期时间较短,那么应该检查一下设计是否合理。
如果过期时间没问题,那就需要查看Redis的内存使用率,查看Redis的配置文件或者在Redis中使用info命令查看Redis的状态,maxmemory属性查看最大内存值。如果是0,则没有限制,此时是通过total_system_memory限制,对比used_memory与Redis最大内存,查看内存使用率。
如果当前的内存使用率较大,那么就需要查看是否有配置最大内存,如果有且内存超了,那么就可以初步判定是内存回收机制导致key设置不成功,还需要查看内存淘汰算法是否noeviction或者allkeys-random,如果是,则可以确认是redis的内存回收机制导致。如果内存没有超,或者内存淘汰算法不是上面的两者,则还需要看看key是否已经过期,通过ttl查看key的存活时间。如果运行了程序,set没有报错,则ttl应该马上更新,否则说明set失败,如果set失败了那么就应该查看操作的程序代码是否正确了。