Redis 是 k-v 数据库,我们可以设置 Redis 中缓存的 key 的过期时间。那么 Redis 缓存失效(key 过期)的故事要从 EXPIRE 这个命令说起,EXPIRE 命令允许用户为某个 key 指定其过期时间,当 key 超过这个时间后,我们应该就访问不到这个值了。接下来我们继续深入探究这个问题,Redis 缓存失效机制是如何实现的呢?也就是当 key 超过过期时间后 Redis 如何处理 key 呢?
过期策略通常有以下三种:
- 定时过期:每个设置过期时间的 key 都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的 CPU 资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
- 惰性过期:只有当访问一个 key 时,才会判断该 key 是否已过期,过期则清除。该策略可以最大化地节省 CPU 资源,却对内存非常不友好。极端情况可能出现大量的过期 key 没有再次被访问,从而不会被清除,占用大量内存。
- 定期过期:每隔一定的时间,会扫描一定数量的数据库的 expires 字典中一定数量的 key,并清除其中已过期的 key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得 CPU 和内存资源达到最优的平衡效果。(expires 字典会保存所有设置了过期时间的 key 的过期时间数据,其中,key 是指向键空间中的某个键的指针,value 是该键的毫秒精度的 UNIX 时间戳表示的过期时间。键空间是指该 Redis 集群中保存的所有键。)
Redis 中同时使用了惰性过期和定期过期两种过期策略。
这篇文章主要在分析 Redis 源码的基础上站在 Redis 设计者的角度去思考 Redis 缓存失效的相关问题。
一、Redis Key过期机制
通常 Redis key 创建时没有设置相关过期时间。他们会一直存在,除非使用显示的命令移除,例如,使用 DEL 命令。EXPIRE 一类命令能关联到一个有额外内存开销的 key。当 key 执行过期操作时,Redis 会确保按照规定时间删除他们。key 的过期时间和永久有效性可以通过 EXPIRE 和 PERSIST 命令(或者其他相关命令)来进行更新或者删除过期时间。
为给定 key 设置生存时间,当 key 过期时(生存时间为 0),它会被自动删除。在 Redis 中,带有生存时间的 key 被称为『易失的』(volatile)。生存时间可以通过使用 DEL 命令来删除整个 key 来移除,或者被 SET 和 GET/SET 命令覆写(overwrite),这意味着,如果一个命令只是修改(alter)一个带生存时间的key的值而不是用一个新的 key 值来代替(replace)它的话,那么生存时间不会被改变。比如说,对一个 key 执行 INCR 命令,对一个列表进行 LPUSH 命令,或者对一个哈希表执行 HSET 命令,这类操作都不会修改 key 本身的生存时间。
另一方面,如果使用 RENAME 对一个 key 进行改名,那么改名后的 key 的生存时间和改名前一样。RENAME 命令的另一种可能是,尝试将一个带生存时间的 key 改名成另一个带生存时间的 another_key,这时旧的 another_key(以及它的生存时间)会被删除,然后旧的 key 会改名为 another_key,因此,新的 another_key 的生存时间也和原本的 key 一样。
使用 PERSIST 命令可以在不删除 key 的情况下,移除 key 的生存时间,让 key 重新成为一个『持久的』(persistent) key 。
1 2 3 4 5 6 7 8 9 10 |
redis> <span class="operator"><span class="keyword">SET</span> cache_page <span class="string">"www.google.com"</span> OK redis> EXPIRE cache_page <span class="number">30</span> # 设置过期时间为<span class="number">30</span>秒 (<span class="keyword">integer</span>) <span class="number">1</span> redis> TTL cache_page # 查看剩余生存时间 (<span class="keyword">integer</span>) <span class="number">23</span> redis> EXPIRE cache_page <span class="number">30000</span> # 更新过期时间 (<span class="keyword">integer</span>) <span class="number">1</span> redis> TTL cache_page (<span class="keyword">integer</span>) <span class="number">29996</span></span> |
NOTE
在小于 2.1.3 的 Redis 版本里,只能对 key 设置一次 expire。Redis 2.1.3 和之后的版本里,可以多次对 key 使用 expire 命令,更新 key 的 expire time。
Redis 术语里面,把设置了 expire time 的 key 叫做:volatile keys,意思就是不稳定的 key。
如果对 key 使用 set 或 del 命令,那么也会移除 expire time。尤其是 set 命令,这个在编写程序的时候需要注意一下。
Redis 2.1.3 之前的老版本里,如果对 volatile keys 做相关写入操作(LPUSH,LSET),和其他一些触发修改 value 的操作时,Redis 会删除该 key。
到此为止我们大概明白了什么是缓存失效机制以及缓存失效机制的一些应用场景,接下来我们继续深入探究这个问题,Redis缓存失效机制是如何实现的呢?
二、Redis key过期淘汰机制
Redis 服务器使用的是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务器可以很好地在合理使用 CPU 时间和避免浪费内存空间之间取得平衡。
1. 惰性过期机制
惰性过期机制即当客户端请求操作某个 key 的时候,Redis 会对客户端请求操作的 key 进行有效期检查,如果 key 过期才进行相应的处理,惰性过期机制也叫消极失效机制。我们看看 t_string 组件下面对 get 请求处理的服务端端执行堆栈:
1 2 3 4 5 |
getCommand -> getGenericCommand -> lookupKeyReadOrReply -> lookupKeyRead -> expireIfNeeded |
关键的地方是 expireIfNeed 函数,Redis 对 key 的 get 操作之前会判断 key 关联的值是否失效。expireIfNeeded 函数就像一个过滤器,它可以在命令真正执行之前,过滤掉过期的输入键,从而避免命令接触到过期键。
这里先插入一个小插曲,我们看看 Redis 中实际存储值的地方是什么样子的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
typedef struct redisDb { // 数据库键空间,保存着数据库中的所有键值对 dict *dict; /* The keyspace for this DB */ // 键的过期时间,字典的键为键,字典的值为过期事件 UNIX 时间戳 dict *expires; /* Timeout of keys with a timeout set */ // 正处于阻塞状态的键 dict *blocking_keys; /* Keys with clients waiting for data (BLPOP) */ // 可以解除阻塞的键 dict *ready_keys; /* Blocked keys that received a PUSH */ // 正在被 WATCH 命令监视的键 dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */ // 数据库号码 int id; // 数据库的键的平均 TTL ,统计信息 long long avg_ttl; /* Average TTL, just for stats */ } redisDb; |
上面是 Redis 中定义的一个结构体,dict 是一个 Redis 实现的一个字典,也就是每个 DB 会包括上面的五个字段,我们这里只关心两个字典,一个是 dict,一个是 expires:
- dict 是用来存储正常数据的,比如我们执行了 set key “hahaha”,这个数据就存储在 dict 中。
- expires 使用来存储关联了过期时间的 key,比如我们在上面的基础之上有执行的 expire key 1,这个时候就会在 expires 中添加一条记录。
回过头来看看 expireIfNeeded 函数的流程,大致如下:
- 如果输入键已经过期,那么 expireIfNeeded 函数将输入键从数据库中删除。
- 如果输入键未过期,那么 expireIfNeeded 函数不做动作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
/* * 检查 key 是否已经过期,如果是的话,将它从数据库中删除。 * 返回 0 表示键没有过期时间,或者键未过期。 * 返回 1 表示键已经因为过期而被删除了。*/ int expireIfNeeded(redisDb *db, robj, *key) { // 取出键的过期时间 mstime_t when = getExpire(db, key); mstime_t now; // 没有过期时间 if (when<0) return 0; /* No expire for this key */ /* Don't expire anything while loading. It will be done later. */ // 如果服务器正在进行载入,那么不进行任何过期检查 if (server.loading) return 0; /* If we are in the context of a Lua script, we claim that time is * blocked to when the Lua script started. This way a key can expire * only the first time it is accessed and not in the middle of the * script execution, making propagation to slaves / AOF consistent. * See issue #1525 on Github for more information. */ now = server.lua_caller ? server.lua_time_start :mstime(); /* If we are running in the context of a slave, return ASAP: * the slave key expiration is controlled by the master that will * send us synthesized DEL operations for expired keys. * * Still we try to return the right information to the caller, * that is, 0 if we think the key should be still valid, 1 if * we think the key is expired at this time. */ // 当服务器运行在 replication 模式时 // 附属节点并不主动删除 key // 它只返回一个逻辑上正确的返回值 // 真正的删除操作要等待主节点发来删除命令时才执行 // 从而保证数据的同步 if (server.masterhost != NULL) return now > when; // 运行到这里,表示键带有过期时间,并且服务器为主节点 /* Return when this key has not expired */ // 如果未过期,返回 0 if (now <= when) return 0; /* Delete the key */ server.stat_expiredkeys++; // 向 AOF 文件和附属节点传播过期信息 propagateExpire(db,key); // 发送事件通知 notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED, "expired",key,db->id);) // 将过期键从数据库中删除 return dbDelete(db,key); } |
代码的注释已经很清晰了,总结一下:
- 从 expires 中查找 key 的过期时间,如果不存在说明对应 key 没有设置过期时间,直接返回 0。
- 如果是 slave 机器,则直接返回,因为 Redis 为了保证数据一致性且实现简单,将缓存失效的主动权交给 master 机器,slave 机器没有权限将 key 失效。
- 如果当前是 master 机器,且 key 过期,则 master 会做两件重要的事情:
- 将删除命令写入 AOF 文件。
- 通知 slave 当前 key 失效,可以删除了。
- master 从本地的字典中将 key 对应的值删除。
因为每个被访问的键都可能因为过期而被 expireIfNeeded 函数删除,所以每个命令的实现函数都必须能同时处理键存在以及键不存在这两种情况:
- expireIfNeeded 函数返回 0,意味当键存在时,命令按照键存在的情况执行。
- expireIfNeeded 函数返回 1,意味当键因为过期而被 expireIfNeeded 函数删除时,命令按照键不存在的情况执行。
2. 定期过期机制
定期过期机制也叫主动失效机制,即服务端定期的去检查失效的缓存,如果失效则进行相应的操作。
我们都知道 Redis 是单线程的,基于事件驱动的,Redis 中有个 EventLoop,EventLoop 负责对两类事件进行处理:
- 一类是 IO 事件,这类事件是从底层的多路复用器分离出来的。
- 一类是定时事件,这类事件主要用来对某个任务的定时执行。
看起来 Redis 的 EventLoop 一方面对网络 I/O 事件处理,一方面还可以做一些小任务。
为什么讲到 Redis 的单线程模型,因为 Redis 的主动失效机制逻辑是被当做一个定时任务来由主线程执行的,相关代码如下:
1 2 3 4 |
if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) { redisPanic("Can't create the serverCron time event."); exit(1); } |
serverCron 就是这个定时任务的函数指针,adCreateTimeEvent 将 serverCron 任务注册到 EventLoop 上面,并设置初始的执行时间是 1 毫秒之后。接下来,我们想知道的东西都在 serverCron 里面了。serverCron 做的事情有点多,我们只关心和本篇内容相关的部分,也就是缓存失效是怎么实现的,我认为看代码做什么事情,调用堆栈还是比较直观的:
1 2 3 4 5 6 |
aeProcessEvents ->processTimeEvents ->serverCron -> databasesCron -> activeExpireCycle -> activeExpireCycleTryExpire |
EventLoop 通过对定时任务的处理,周期性触发对 serverCron 逻辑的执行,最终执行 key 过期处理的逻辑,也就是调用 activeExpireCycle 函数。值得一提的是,activeExpireCycle 逻辑只能由 master 线程来做。它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的 expires 字典中随机检查一部分键的过期时间,并删除其中的过期键。
activeExpireCycle 函数的工作模式可以总结如下:
- 函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。
- 全局变量 current_db 会记录当前 activeExpireCycle 函数检查的进度,并在下一次 activeExpireCycle 函数调用时,接着上一次的进度进行处理。比如说,如果当前 activeExpireCycle 函数在遍历 10 号数据库时返回了,那么下次 activeExpireCycle 函数执行时,将从 11 号数据库开始查找并删除过期键。
- 随着 activeExpireCycle 函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将 current_db 变量重置为 0,然后再次开始新一轮的检查工作。
另外,serverCron 周期性操作函数每 100ms 执行一次,还包括检查服务器状态,比如检查 “save N M” 是否满足条件,如果满足就执行 BGSAVE;当然也包括 AOF 重写检查等。
三、计算并返回剩余生存时间
TTL 命令以秒为单位返回键的剩余生存时间,而 PTTL 命令则以毫秒为单位返回键的剩余生存时间:
1 2 3 4 5 6 7 8 |
127.0.0.1:6379> rpush alphabet a b c (integer) 3 127.0.0.1:6379> pexpireat alphabet 1563354600000 (integer) 1 127.0.0.1:6379> ttl alphabet (integer) 106 127.0.0.1:6379> pttl alphabet (integer) 102971 |
TTL 和 PTTL 两个命令都是通过计算键的过期时间和当前时间之间的差来实现的,以下是这两个命令的伪代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
def PTTl(key): # 键不存在于数据库,返回 -2 if key not in redisDb.dict: return -2 # 尝试取得键的过期时间 # 如果键没有设置过期时间,那么 expire_time_in_ms 将为 None expire_time_in_ms = redisDb.expires.get(key) # 键没有设置过期时间,返回 -1 if expire_time_in_ms is None: return -1 # 获得当前时间 now_ms = get_current_unix_timestamp_in_ms() # 过期时间减去当前时间,得出的差就是键的剩余生存时间 return(expire_time_in_ms - now_ms) def TTL(key): # 获取以毫秒为单位的剩余生存时间 ttl_in_ms = PTTL(key) #处理返回值为-2和-1的情况 if ttl_in_ms < 0: return ttl_in_ms else: # 将毫秒转换为秒 return ms_to_sec(ttl_in_ms) |
Keys 的过期时间使用 Unix 时间戳存储(从 Redis 2.6 开始以毫秒为单位)。这意味着即使 Redis 实例不可用,时间也是一直在流逝的。
即使正在运行的实例也会检查计算机的时钟,例如如果你设置了一个 key 的有效期是 1000 秒,然后设置你的计算机时间为未来 2000 秒,这时 key 会立即失效,而不是等 1000 秒之后。
四、AOF、RDB和复制功能对过期键的处理
生成 RDB 文件
在执行 SAVE 命令或者 BGSAVE 命令创建一个新的 RDB 文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的 RDB 文件中。
例如,如果数据库中包含三个键 k1、k2、k3,并且 k2 已经过期,那么当执行 SAVE 命令或者 BGSAVE 命令时,程序只会将 k1 和 k3 的数据保存到 RDB 文件中,而 k2 则会被忽略。
因此,数据库中包含过期键不会对生成新的 RDB 文件造成影响。
载入 RDB 文件
在启动 Redis 服务器时,如果服务器开启了 RDB 功能,那么服务器将对 RDB 文件进行载入:
- 如果服务器以主服务器模式运行,那么在载入 RDB 文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期键则会被忽略,所以过期键对载入 RDB 文件的主服务器不会造成影响。
- 如果服务器以从服务器模式运行,那么在载入 RDB 文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的数据库就会被清空,所以一般来讲,过期键对载入 RDB 文件的从服务器也不会造成影响。
例如,如果数据库中包含三个键 k1、k2、k3,并且 k2 已经过期,那么当服务器启动时:
- 如果服务器以主服务器模式运行,那么程序只会将 k1 和 k3 载入到数据库,k2 会被忽略。
- 如果服务器以从服务器模式运行,那么 k1、k2 和 k3 都会被载入到数据库。
AOF 文件写入
当服务器以 AOF 持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么 AOF 文件不会因为这个过期键而产生任何影响。当过期键被惰性删除或者定期删除之后,程序会向 AOF 文件追加(append)一条 DEL 命令,来显式地记录该键已被删除。
例如,如果客户端使用 GET message 命令,试图访问过期的 message 键,那么服务器将执行以下三个动作:
- 从数据库中删除 message 键。
- 追加一条 DEL message 命令到 AOF 文件。
- 向执行 GET 命令的客户端返回空回复。
AOF 文件重写
和生成 RDB 文件时类似,在执行 AOF 重写的过程中,程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的 AOF 文件中。
例如,如果数据库中包含三个键 k1、k2、k3,并且 k2 已经过期,那么在进行重写工作时,程序只会对 k1 和 k3 进行重写,而 k2 则会被忽略。因此,数据库中包含过期键不会对 AOF 重写造成影响。
Redis 复制
从上面惰性删除和定期删除的源码阅读中,我们可以发现,从库对于主库的过期键是不能主动进行删除的。如果一个主库创建的过期键值对,已经过期了,主库在进行定期删除的时候,没有及时的删除掉,这时候从库请求了这个键值对,当执行惰性删除的时候,因为是主库创建的键值对,这时候是不能在从库中删除的,那么是不是就意味着从库会读取到已经过期的数据呢?
当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制。从节点不会主动删除过期键,从节点会等待主节点触发键过期。当主节点触发键过期时,主节点会同步一个 del 命令给所有的从节点。因为是主节点驱动删除的,所以从节点会获取到已经过期的键值对。从节点需要根据自己本地的逻辑时钟来判断 Key 是否过期,从而实现数据集合的一致性读操作。
我们知道 Redis 中的过期策略是惰性删除和定期删除,所以每个键值的操作,都会使用惰性删除来检查是否过期,然后判断是否可以进行删除:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
// 当访问到 key 的时候,会调用这个函数,因为有的 key 虽然已经过期了,但是还可能存在于内存中 // key 仍然有效,函数返回值为0,否则,如果 key 过期,函数返回1。 int expireIfNeeded(redisDb *db, robj *key) { // 检查 key 是否过期 if (!keyIsExpired(db,key)) return 0; // 从库的过期是主库控制的,是不会进行删除操作的 // 上面已经判断过是否到期了,所以这里的 key 肯定设计过期的 key ,不过如果是主节点创建的 key 从节点就不删除,只会返回已经过期了 if (server.masterhost != NULL) return 1; ... /* Delete the key */ // 删除 key deleteExpiredKeyAndPropagate(db,key); return 1; } // https://github.com/redis/redis/blob/6.2/src/db.c#L1485 /* Check if the key is expired. */ int keyIsExpired(redisDb *db, robj *key) { // 过期时间 mstime_t when = getExpire(db,key); mstime_t now; // 没有过期 if (when < 0) return 0; /* No expire for this key */ /* Don't expire anything while loading. It will be done later. */ if (server.loading) return 0; // lua 脚本执行的过程中不过期 if (server.lua_caller) { now = server.lua_time_snapshot; } // 如果我们正在执行一个命令,我们仍然希望使用一个不会改变的引用时间:在这种情况下,我们只使用缓存的时间,我们在每次调用call()函数之前更新。 // 这样我们就避免了RPOPLPUSH之类的命令,这些命令可能会重新打开相同的键多次,如果下次调用会看到键过期,则会使已经打开的对象在下次调用中失效,而第一次调用没有。 else if (server.fixed_time_expire > 0) { now = server.mstime; } // 其他情况下,获取最新的时间 else { now = mstime(); } // 判断是否过期了 return now > when; } // 返回指定 key 的过期时间,如果没有过期则返回-1 long long getExpire(redisDb *db, robj *key) { dictEntry *de; /* No expire? return ASAP */ if (dictSize(db->expires) == 0 || (de = dictFind(db->expires,key->ptr)) == NULL) return -1; /* The entry was found in the expire dict, this means it should also * be present in the main dict (safety check). */ serverAssertWithInfo(NULL,key,dictFind(db->dict,key->ptr) != NULL); return dictGetSignedIntegerVal(de); } |
上面的惰性删除,对于主节点创建的过期 key ,虽然不能进行删除的操作,但是可以进行过期时间的判断,所以如果主库创建的过期键,如果主库没有及时进行删除,这时候从库可以通过惰性删除来判断键值对的是否过期,避免读取到过期的内容。
五、遗留问题
Redis 对缓存失效的处理机制大概分为两种,一种是客户端访问 key 的时候消极的处理,一种是主线程定期的积极地去执行缓存失效清理逻辑,上面文章对于一些细节还没有展开介绍,但是对于 Redis 缓存失效实现机制这个话题,本文留下几个问题:
- Redis 缓存失效逻辑为什么只有 master 才能操作?
- 上面提到如果客户端访问的是 slave,slave 并不会清理失效缓存,那么这次客户端岂不是获取了失效的缓存?
- 上面介绍的两种缓存失效机制各有什么优缺点?Redis 设计者为什么这么设计?
- 服务端对客户端的请求处理是单线程的,单线程又要去处理失效的缓存,是不是会影响 Redis 本身的服务能力?
Redis 的内存淘汰策略的选取并不会影响过期的 key 的处理。内存淘汰策略用于处理内存不足时的需要申请额外空间的数据;过期策略用于处理过期的缓存数据。
<参考>
https://www.jianshu.com/p/7badc549f316
https://mp.weixin.qq.com/s/XzwhwTVipu6aBQnvZoS1Ew