结合源码看Redis过期策略
注意事项
笔者所看的源码是redis稳定版 6.2 版本的
常用的过期策略
1. 定时过期,主动过期
这个策略是需要一个过时器,对每一个key都设计一个定时器。
优点:对内存友好,但是严重消耗CPU,对CPU非常不友好,这个redis没有采用
为什么redis不采用这个过期策略呢?
想想, 每一个key都要一个定时器,是不是特别消耗CPU,而redis又是一个特别注重吞吐量的数据库,这样过期势必会大大的降低吞吐。
2. 惰性过期
只有来访问的时候才知道是否过期。就像从冰箱拿东西,只有去拿检查的时候,才知道是否过期。
redis的过期策略之一
int expireIfNeeded(redisDb *db, robj *key) {
// 如果没有过期,直接返回
if (!keyIsExpired(db,key)) return 0;
/* If we are running in the context of a slave, instead of
* evicting the expired key from the database, we 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. */
// 如果是从节点
if (server.masterhost != NULL) return 1;
/* If clients are paused, we keep the current dataset constant,
* but return to the client what we believe is the right state. Typically,
* at the end of the pause we will properly expire the key OR we will
* have failed over and the new primary will send us the expire. */
// 正在选取主节点的时候不允许过期
if (checkClientPauseTimeoutAndReturnIfPaused()) return 1;
/* Delete the key */
// 过期的key加一
server.stat_expiredkeys++;
// 传播到从服务器和AOF文件,
propagateExpire(db,key,server.lazyfree_lazy_expire);
// 发送事件
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",key,db->id);
// 如果服务器在惰性删除开启,异步做key的删除,如果没有开启,就同步做删除
int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
dbSyncDelete(db,key);
// 如果删除成功就发送通知
if (retval) signalModifiedKey(NULL,db,key);
return retval;
}
结合上面的代码可以知道:
- 从节点不能主动删除过期的key
- 在选取主节点的时候不能删除过期的key
- 一旦判断可以删除,那么redis主节点在删除之前,会发起删除通知到slave从节点
- 如果服务器开启懒过期,那么会异步删除过期的key。
- 如果删除成功就会通知客户端的监听者
3. 定期过期
每隔一段时间扫描,然后把过期的key删除。
/* Try to expire a few timed out keys. The algorithm used is adaptive and
* will use few CPU cycles if there are few expiring keys, otherwise
* it will get more aggressive to avoid that too much memory is used by
* keys that can be removed from the keyspace.、
*
* 尝试让几个超时的密钥失效。使用的算法是自适应的,并将使用少量的CPU周期,如果有少量过期的键,否则它将采取更积极的措施,以避免过多的内存被可以从键空间中删除的键使用。
*
* Every expire cycle tests multiple databases: the next call will start
* again from the next db. No more than CRON_DBS_PER_CALL databases are
* tested at every iteration.
*
* 每个过期周期测试多个数据库:下一个调用将从下一个数据库再次启动。每次迭代都只测试CRON_DBS_PER_CALL数据库。
*
* The function can perform more or less work, depending on the "type"
* argument. It can execute a "fast cycle" or a "slow cycle". The slow
* cycle is the main way we collect expired cycles: this happens with
* the "server.hz" frequency (usually 10 hertz).
*
* 函数可以执行更多或更少的工作,这取决于“type”参数。它可以执行 “快周期”或“慢周期”。慢周期是我们收集过期周期的主要方式:这种情况发生在“server.hz”上。频率(通常为10赫兹)。
*
* However the slow cycle can exit for timeout, since it used too much time.
* For this reason the function is also invoked to perform a fast cycle
* at every event loop cycle, in the beforeSleep() function. The fast cycle
* will try to perform less work, but will do it much more often.
*
* 然而,慢周期可能超时退出,因为它使用了太多的时间。因此,在beforeSleep()函数中,也会在每个事件循环周期调用该函数来执行快速循环。快速循环将尝试执行更少的工作,但将做得更频繁。
*
* The following are the details of the two expire cycles and their stop
* conditions:
*
* If type is ACTIVE_EXPIRE_CYCLE_FAST the function will try to run a
* "fast" expire cycle that takes no longer than ACTIVE_EXPIRE_CYCLE_FAST_DURATION
* microseconds, and is not repeated again before the same amount of time.
* The cycle will also refuse to run at all if the latest slow cycle did not
* terminate because of a time limit condition.
*
* If type is ACTIVE_EXPIRE_CYCLE_SLOW, that normal expire cycle is
* executed, where the time limit is a percentage of the REDIS_HZ period
* as specified by the ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC define. In the
* fast cycle, the check of every database is interrupted once the number
* of already expired keys in the database is estimated to be lower than
* a given percentage, in order to avoid doing too much work to gain too
* little memory.
*
* The configured expire "effort" will modify the baseline parameters in
* order to do more work in both the fast and slow expire cycles.
*
* 以下是两个到期周期及其停止条件的详细信息:
*
* 如果type为 ACTIVE_EXPIRE_CYCLE_FAST,
* 则该函数将尝试运行不超过 ACTIVE_EXPIRE_CYCLE_FAST_DURATION 微秒的“快速”过期周期,并且在相同的时间量之前不会再次重复。如果最新的慢速周期,则该周期也将完全拒绝运行 由于时间限制条件未终止。
*
* 如果type为 ACTIVE_EXPIRE_CYCLE_SLOW,
* 则执行该正常的过期周期,其中时间限制是由ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC定义的REDIS_HZ周期的百分比。
* 在快速周期中,一旦数据库中已经过期的键的数量估计低于给定的百分比,就会中断对每个数据库的检查,以避免避免进行过多的工作来获得太少的内存。
*
* 配置的到期“effort”将修改基线参数,以便在快速和缓慢的到期周期中进行更多工作。
*/
#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* Keys for each DB loop. 每个DB循环的键,就是每次取20个 */
#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* Microseconds. 快速过期周期 1000微妙 */
#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* Max % of CPU to use. 正常过期周期占用CPU时间百分比 */
#define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* % of stale keys after which we do extra efforts. 主动失效周期可接受的阶段 过时密钥的百分比,在此之后我们会付出额外的工作量 */
void activeExpireCycle(int type) {
/* Adjust the running parameters according to the configured expire
* effort. The default effort is 1, and the maximum configurable effort
* is 10.
* 根据配置的失效时间调整运行参数。 默认工作量为1,最大可配置工作量为10。
*/
unsigned long
effort = server.active_expire_effort - 1, /* Rescale from 0 to 9. */
// 每次循环取的键数 值为 20 + 20 / 4 * 努力程度
// (20 到 25 之间, 这取决于 server.active_expire_effort 的配置的值为多少)
// 因为 server.active_expire_effort 默认为1 ,所以 config_keys_per_loop 默认为 20
config_keys_per_loop = ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP +
ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP / 4 * effort,
// 快速过期周期 值为 1000 + 1000 / 4 * 努力程度
// (1000 微秒 到 1250 微秒之间, 这取决于 server.active_expire_effort 的配置的值为多少)
// 因为 server.active_expire_effort 默认为1 ,所以 config_cycle_fast_duration 默认为 1000
config_cycle_fast_duration = ACTIVE_EXPIRE_CYCLE_FAST_DURATION +
ACTIVE_EXPIRE_CYCLE_FAST_DURATION / 4 * effort,
// 正常周期 CPU 占比百分比 值为 25 + 2 * 额外工作量(25 到 43, 这取决于 server.active_expire_effort 的配置的值为多少)
// 默认 25
config_cycle_slow_time_perc = ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC +
2 * effort,
// 回收阈值 值为 10 - 努力程度 (1- 10 这取决于 server.active_expire_effort 的配置的值为多少 默认10)
config_cycle_acceptable_stale = ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE -
effort;
/* This function has some global state in order to continue the work
* incrementally across calls.
* 这个函数有一些全局状态,以便在调用之间增量地继续工作。*/
static unsigned int current_db = 0; /* Next DB to test. 下一个DB偏移量 */
static int timelimit_exit = 0; /* Time limit hit in previous call? 前一次调用是否超过了 时间限制*/
static long long last_fast_cycle = 0; /* When last fast cycle ran.上一次快速周期结束时间 */
int j, iteration = 0;
// CRON_DBS_PER_CALL 每次定期收集调用需要扫描数据库总数 值为16
int dbs_per_call = CRON_DBS_PER_CALL;
// start 为开始时间 timelimit 是回收过期key时间限制,elapsed 已过多少时间
long long start = ustime(), timelimit, elapsed;
/* When clients are paused the dataset should be static not just from the
* POV of clients not being able to write, but also from the POV of
* expires and evictions of keys not being performed.
*
* 当客户端暂停时,数据集应该是静态的,不仅是因为客户端无法写入的POV,而且还应该是由于过期的POV和未执行密钥退出而导致的。
* 这个是选取主节点的过程,删除过期的键是不允许的
*/
if (checkClientPauseTimeoutAndReturnIfPaused()) return;
// 如果是快速过期删除模式
if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
/* Don't start a fast cycle if the previous cycle did not exit
* for time limit, unless the percentage of estimated stale keys is
* too high. Also never repeat a fast cycle for the same period
* as the fast cycle total duration itself.
* 如果前一个周期没有在时间限制内退出,则不要开始快速周期,除非估计的陈旧key的百分比过高。
* 也不要在与快速循环总持续时间本身相同的时间段内重复快速循环。*/
// stat_expired_stale_perc 是可能已过期的key的百分比 如果小于回收阈值 直接返回
if (!timelimit_exit &&
server.stat_expired_stale_perc < config_cycle_acceptable_stale)
return;
// 如果开始时间小于上一个 快速周期结束时间
if (start < last_fast_cycle + (long long) config_cycle_fast_duration * 2)
return;
// 如果可以回收,那么上一次结束时间就是开始时间,
last_fast_cycle = start;
}
/* We usually should test CRON_DBS_PER_CALL per iteration, with
* two exceptions:
*
* 1) Don't test more DBs than we have.
* 2) If last time we hit the time limit, we want to scan all DBs
* in this iteration, as there is work to do in some DB and we don't want
* expired keys to use memory for too much time.
* 通常,我们应该在每次迭代中测试CRON_DBS_PER_CALL,但有两个例外:
* 1)不要测试比我们更多的数据库。
* 2)如果上次我们达到了时间限制,则我们希望扫描此迭代中的所有数据库,因为某些数据库中有工作要做,并且我们不希望过期的键占用过多的内存。*/
// 如果允许调用的数据库数大于16 或者 前一次已经命中
if (dbs_per_call > server.dbnum || timelimit_exit)
// 赋值
dbs_per_call = server.dbnum;
/* We can use at max 'config_cycle_slow_time_perc' percentage of CPU
* time per iteration. Since this function gets called with a frequency of
* server.hz times per second, the following is the max amount of
* microseconds we can spend in this function.
* 我们可以使用每次迭代的最大CPU时间百分比“ config_cycle_slow_time_perc”。 由于此函数以每秒server.hz次的频率被调用,因此以下是我们可以在此函数中花费的最大微秒数。*/
// 允许回收的最大时间 值为 默认25% * 1百万微妙(1s) / 服务器赫兹 (默认10)
// 所以下面也有默认值 默认为 25000 微妙
timelimit = config_cycle_slow_time_perc * 1000000 / server.hz / 100;
// 是否超过时间限制
timelimit_exit = 0;
// 校验过期回收时间限制
if (timelimit <= 0) timelimit = 1;
if (type == ACTIVE_EXPIRE_CYCLE_FAST)
// 如果是快速收集模式, 收集时间限制在一毫秒
timelimit = config_cycle_fast_duration; /* in microseconds. */
/* Accumulate some global stats as we expire keys, to have some idea
* about the number of keys that are already logically expired, but still
* existing inside the database.
* 随着key的过期,累积一些全局统计信息,以了解已在逻辑上过期但仍存在于数据库中的密钥的数量。*/
// 总共抽取的样本数量
long total_sampled = 0;
// 总的过期key数量
long total_expired = 0;
// 循环扫描库,但是前提是这个库前一次没有命中
for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
/* Expired and checked in a single loop. */
// 这个库每一次采样 选取的样本,和这个库每一次采样过期的key数
unsigned long expired, sampled;
// 获取当前db
redisDb *db = server.db + (current_db % server.dbnum);
/* Increment the DB now so we are sure if we run out of time
* in the current DB we'll restart from the next. This allows to
* distribute the time evenly across DBs.
* 现在增加数据库,因此我们确定如果当前数据库中的时间用完了,我们将从下一个数据库重新启动。 这样可以将时间平均分配到各个DB。*/
current_db++;
/* Continue to expire if at the end of the cycle there are still
* a big percentage of keys to expire, compared to the number of keys
* we scanned. The percentage, stored in config_cycle_acceptable_stale
* is not fixed, but depends on the Redis configured "expire effort".
*
* 如果在周期结束时,与我们扫描的密钥数量相比,仍有很大一部分密钥要过期,请继续过期。
* 存储在 config_cycle_acceptable_stale 中的百分比不是固定的,而是取决于Redis配置的“expire effort”。*/
do {
unsigned long num, slots;
long long now, ttl_sum;
int ttl_samples;
iteration++;
/* If there is nothing to expire try next DB ASAP. */
// 如果这个db过期hash表里没有什么要收集的,把库里的平均收集时间设置为0,结束循环,直接扫下一个DB
// num是已经使用的槽
if ((num = dictSize(db->expires)) == 0) {
// 这个数据库的平均收集时间为0
db->avg_ttl = 0;
break;
}
// 过期字典里的槽大小
slots = dictSlots(db->expires);
// 现在时间
now = mstime();
/* When there are less than 1% filled slots, sampling the key
* space is expensive, so stop here waiting for better times...
* The dictionary will be resized asap.
* 当少于1%的已填充插槽时,对密钥空间进行采样非常昂贵,因此请在这里等待更好的时间...字典将尽快调整大小。*/
// 如果槽数大于初始化的hash表大小并且过期字典已经使用的槽很少,也直接扫下一个DB
// 很少 有用的槽乘以一百比上 槽数都小于1
if (slots > DICT_HT_INITIAL_SIZE &&
(num * 100 / slots < 1))
break;
/* The main collection cycle. Sample random keys among keys
* with an expire set, checking for expired ones.
* 主要的收集周期。 在具有过期集的键中对随机键进行采样,检查是否有过期键。*/
expired = 0;
sampled = 0;
// 生存时间总和
ttl_sum = 0;
// 生存样本数
ttl_samples = 0;
// num 已经使用的槽 设置为 20 或者小于20
if (num > config_keys_per_loop)
num = config_keys_per_loop;
/* Here we access the low level representation of the hash table
* for speed concerns: this makes this code coupled with dict.c,
* but it hardly changed in ten years.
*
* Note that certain places of the hash table may be empty,
* so we want also a stop condition about the number of
* buckets that we scanned. However scanning for free buckets
* is very fast: we are in the cache line scanning a sequential
* array of NULL pointers, so we can scan a lot more buckets
* than keys in the same time.
*
* 在这里,出于速度方面的考虑,我们访问哈希表的低级表示形式:这使该代码与dict.c结合在一起,但十年来几乎没有改变。
*
* 注意,哈希表的某些位置可能为空,因此我们还需要一个关于我们扫描的存储桶数量的停止条件。
* 但是,扫描空闲存储桶的速度非常快:我们正在高速缓存行中扫描NULL指针的顺序数组,因此我们可以同时扫描比键更多的存储桶。*/
long max_buckets = num * 20;
long checked_buckets = 0;
// 如果本次样本数小于要取的数,已经检查的桶小于最大可扫描的桶数,循环
while (sampled < num && checked_buckets < max_buckets) {
for (int table = 0; table < 2; table++) {
// 第一个table 并且过期字典没有再hash 直接跳出循环
// 每一个字典有了两张hash table
// ht[0]指向旧哈希表, ht[1]指向扩容后的新哈希表. 这个就是说,这个字典没有新hash表,所以不用扫描
if (table == 1 && !dictIsRehashing(db->expires)) break;
// idx是游标 下面语句就是取 每一个table 每一个桶对应的链表
unsigned long idx = db->expires_cursor;
idx &= db->expires->ht[table].sizemask;
dictEntry *de = db->expires->ht[table].table[idx];
// 生存时间
long long ttl;
/* Scan the current bucket of the current table. */
checked_buckets++;
// 循环 桶对应的链表
while (de) {
/* Get the next entry now since this entry may get
* deleted. */
dictEntry *e = de;
de = de->next;
// 生存时间是key 可以生存到的时间 减去 现在时间
ttl = dictGetSignedIntegerVal(e) - now;
// 判断并回收key 过期数加1
if (activeExpireCycleTryExpire(db, e, now)) expired++;
if (ttl > 0) {
// 如果 key 没有过期
/* We want the average TTL of keys yet
* not expired. */
// 总共生存时间累加
ttl_sum += ttl;
// 生存的样本数加一
ttl_samples++;
}
// 样本数加一
sampled++;
}
}
// 游标移动
db->expires_cursor++;
}
total_expired += expired;
total_sampled += sampled;
/* Update the average TTL stats for this database. */
// 如果生存的样本大于0
if (ttl_samples) {
// 计算 平均生存时间 设置db 平均生存时间
long long avg_ttl = ttl_sum / ttl_samples;
/* Do a simple running average with a few samples.
* We just use the current estimate with a weight of 2%
* and the previous estimate with a weight of 98%.
* 用几个样本做一个简单的移动平均数。 我们仅使用权重为2%的当前估计值和权重为98%的先前估计值。*/
if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
db->avg_ttl = (db->avg_ttl / 50) * 49 + (avg_ttl / 50);
}
/* We can't block forever here even if there are many keys to
* expire. So after a given amount of milliseconds return to the
* caller waiting for the other active expire cycle.
* 即使有很多要过期的密钥,我们也不能在这里永远阻止。 因此,在给定的毫秒数后,返回到调用方,等待另一个活动的到期周期。*/
// 判断有木有扫描完所有db
if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
// 消耗的时间
elapsed = ustime() - start;
// 如果消耗的时间 大于限制时间,直接退出
if (elapsed > timelimit) {
timelimit_exit = 1;
// 服务器过早退出过期循环数加1
server.stat_expired_time_cap_reached_count++;
break;
}
}
/* We don't repeat the cycle for the current database if there are
* an acceptable amount of stale keys (logically expired but yet
* not reclaimed). 如果存在可接受数量的过期key(逻辑上已过期但尚未回收),我们将不为当前数据库重复该周期。*/
// 本次扫描的样本为0(这个数据库没有数据), 或者过期数大于10% 就一直扫描
} while (sampled == 0 ||
(expired * 100 / sampled) > config_cycle_acceptable_stale);
}
// 已经过了多少时间
elapsed = ustime() - start;
// 设置服务器累计使用的微妙数
server.stat_expire_cycle_time_used += elapsed;
// 仅当经过时间>= 达到配置的阈值时,才添加样本。潜在的添加样本
latencyAddSampleIfNeeded("expire-cycle", elapsed / 1000);
/* Update our estimate of keys existing but yet to be expired.
* Running average with this sample accounting for 5%. */
double current_perc;
if (total_sampled) {
// 计算本次扫描秒的key百分比
current_perc = (double) total_expired / total_sampled;
} else
current_perc = 0;
// 设置 可能已过期的key的百分比
server.stat_expired_stale_perc = (current_perc * 0.05) +
(server.stat_expired_stale_perc * 0.95);
}
大致的步骤
- 16 个库挨个扫描
- 每一个库,取20个(默认)设置了过期时间的 key。
- 检查删除20个里面过期的key。
- 如果每次过期的大于所取样本的10%(默认)或 没有过期(样本数为0)的,那么循环第一步 和 第二步。
终止扫描的条件
全局扫描秒终止条件:
- 使用的时间大于限制时间(默认是25ms / 25000微妙),如果是快速过期 时间更短(默认 1ms),这个终止是立即终止,不循环库了。
- 每一个库都扫描完了。
每一个库终止条件:
- 取的样本不为0,并且过期比例小于10% 就不扫描了
需要注意的是。
- 定期扫描发生的时候会在 redis 哈希扩容时触发
- 定期扫描会在 aeMain-主服务器循环 和 processEventsWhileBlocked-在RDB / AOF加载期间处理客户端 时调用。
为什么要上限25毫秒和10%的阈值
前文说过了,redis是高吞吐量的,不限制过期回收的时间,那么redis单线程吞吐量势必会大大降低。
定期扫描 25毫秒的限制和10%阈值的结合,可以分散过期处理的压力。过期的键没有10%,代表没有继续清理的必要。
关于异步删除
异步删除,是先unlink 某一个key,然后后台线程在慢慢的删除。unlink之后,客户端就读不出key的值了。