参考《Redis 设计与实现》 (基于redis3.0.0) 作者:黄健宏
学习redis3.2.13
数据库切换
数据库键空间与过期字典
键空间
增删查改
读写时的维护操作
过期字典
键的生存时间与过期时间
相关命令
过期键的判定
过期键删除策略介绍
三种删除策略
三种删除策略的优缺点
redis过期键删除策略
惰性删除策略的实现
定期删除策略的实现
aof、rdb及复制功能对过期键的处理
rdb文件对过期键的处理
aof文件对过期键的处理
复制功能对过期键的处理
数据库创建
redis服务器的数据库保存在服务器状态结构server.h/redisServer的db数组中,数据库的个数由redisServer的dbnum决定
struct redisServer {
/* General */
//...
redisDb *db;
//...
/* Configuration */
int dbnum; /* Total number of configured DBs */
//...
在函数initServer中,redisServer.db数组被创建
void initServer(void) {
//略
server.db = zmalloc(sizeof(redisDb)*server.dbnum);
//略
}
server.dbnum在函数initServerConfig中被赋值
server.dbnum = CONFIG_DEFAULT_DBNUM; //#define CONFIG_DEFAULT_DBNUM 16
数据库切换
每个redis客户端都有属于自己操作的数据库,默认情况下,0号数据库是默认目标数据库,可以通过select命令来切换目标数据库,使用方式为select index,index为数据库下标
数据库键空间与过期字典
数据库中键空间与超时结构定义如下:
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
dict *expires; /* Timeout of keys with a timeout set */
//...
} redisDb;
键空间
redisDb中的成员dict被称为数据库的键空间,它是一个字典,保存了该数据库的键值对,键是字符串对象,值可以是字符串对象、列表对象、哈希对象、集合对象、有序集合对象中的一种。
增删查改
涉及到对数据库键值对的 增删改查时,都是在字典API以及存储的值对象API上操作
读写时的维护操作
- 查找键后,根据是否找到对应的值来增加命中与错过的次数,在info stats命令中可以查询到两个次数
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
robj *val;
//检查key并在其过期时将其"删除"(服务器是主节点删除key并返回已删除,否则仅返回已删除)
if (expireIfNeeded(db,key) == 1) {
//key过期了,并且服务器是master,返回null表示key不存在
if (server.masterhost == NULL) return NULL;
//key过期了,服务器是slave,同时,调用者不是master,命令时只读的,返回null表示key不存在(返回null来保持与master的一致性视图)
if (server.current_client &&
server.current_client != server.master &&
server.current_client->cmd &&
server.current_client->cmd->flags & CMD_READONLY)
{
return NULL;
}
}
//寻找key对应的val
val = lookupKey(db,key,flags);
if (val == NULL)
server.stat_keyspace_misses++;
else
server.stat_keyspace_hits++;
return val;
}
- 在lookupKey某个key的val后,lookupKey函数内会根据实际情况更新val的lru时间(最后一次使用时间)
robj *lookupKey(redisDb *db, robj *key, int flags) {
dictEntry *de = dictFind(db->dict,key->ptr);
if (de) {
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. */
//不存在持久化子进程并且lookup标志不是“不修改lru时间”则更新val的lru
if (server.rdb_child_pid == -1 &&
server.aof_child_pid == -1 &&
!(flags & LOOKUP_NOTOUCH))
{
val->lru = LRU_CLOCK();
}
return val;
} else {
return NULL;
}
}
过期字典
过期字典是redisDb中的成员expires,它存储的是键空间中键与它的过期时间,过期字典中的键是一个指针,指向键空间中的某个键对象,过期字典中的值是long long整数,保存了键对应的过期时间(毫秒精度的unix时间戳)
键的生存时间与过期时间
相关命令
名词 | 名词解释 |
---|---|
生存时间 | 键存活时间:经过制定的生存时间后,服务器会删除生存时间为0的键 |
过期时间 | unix时间戳,制定键被删除的时间点 |
命令 | 命令解释 |
---|---|
expire key ttl | 将键key的生存时间设置为ttl秒,成功返回1,key不存在或设置失败返回0 |
pexpire key ttl | 将键key的生存时间设置为ttl毫秒,成功返回1,key不存在或设置失败返回0 |
expireat key timesramp | 将key的过期时间设置为timrestamp(秒),成功返回1,key不存在或设置失败返回0 |
pexpireat key timesramp | 将key的过期时间设置为timrestamp(毫秒),成功返回1,key不存在或设置失败返回0 |
ttl key | 返回key在当前时间的剩余生存时间(秒),key不存在返回-2,没有过期时间返回-1 |
pttl key | 返回key在当前时间的剩余生存时间(毫秒),key不存在返回-2,没有过期时间返回-1 |
persist key | 去掉key的过期时间,成功返回1,key不存在或没有设置生存时间返回0 |
过期键的判定
- 检查给定键是否存在于过期字典:如果存在,那么取得键的过期时间。
- 检查当前UNIX时间戳是否大于键的过期时间:如果是的话,那么键已经过期;否则的话,键未过期。
过期键删除策略介绍
三种删除策略
- 定时删除:在设置键的过期时间的同时,创建一个定时任务,当键达到过期时间时,立即执行对键的删除操作
- 惰性删除:放任键过期不管,但在每次从键空间获取键时,都检查取得的键是否过期,如果过期的话,就删除该键,如果没有过期,就返回该键
- 定期删除:每隔一点时间,程序就对数据库进行一次检查,删除里面的过期键,至于要删除多少过期键,以及要检查多少个数据库,则由算法决定
三种删除策略的优缺点
- 定时删除
优点:内存最友好:保证了尽快删除过期键,释放其占用的内存
缺点:CPU最不友好:过期键较多时,删除操作会占用大量cpu时间,cpu时间紧张时触发删除任务则会影响服务器响应时间与吞吐量
redis未使用此删除策略 - 惰性删除
优点:CPU最友好:在从键空间访问查找指定key时,仅对当前key进行判断与删除
缺点:内存最不友好:不访问到的的过期key永远得不到删除,占用的内存得不到释放 - 定期删除
优点:是前两种策略整合和折中: - 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响。
- 定时删除策略有效地减少了因为过期键带来的内存浪费。
缺点:难以确定删除操作执行的时长与频率: - 删除操作执行得太频繁,或者执行时间太长,定期删除策略就会退化成为定时删除策略
- 相反,则和惰性删除策略一样
因此使用定期删除策略,需要根据服务器的情况合理地设置删除操作的执行时长和执行频率。
redis过期键删除策略
redis服务器选用了三种策略中的惰性删除和定期删除策略,两种策略配合使用,取得了CPU使用时间与避免内存浪费之间的平衡
惰性删除策略的实现
惰性删除策略由函数expireIfNeeded实现,所有读写数据库的redis命令都会在执行前,调用该函数对输入键进行过期检查操作,避免命令接触到过期键
所有读写数据库的命令,都会在键空间中查找指定键,查找时,根据命令的操作类型,会调用到以下两种lookupkey之一,两种lookupkey中直接或间接调用了expireIfNeeded函数进行过期键检查
robj *lookupKeyRead(redisDb *db, robj *key) {
return lookupKeyReadWithFlags(db,key,LOOKUP_NONE); //内部调用了expireIfNeeded,见上文
}
robj *lookupKeyWrite(redisDb *db, robj *key) {
expireIfNeeded(db,key);
return lookupKey(db,key,LOOKUP_NONE);
}
expireIfNeeded函数实现如下:
int expireIfNeeded(redisDb *db, robj *key) {
mstime_t when = getExpire(db,key); //取出key的过期时间
mstime_t now;
if (when < 0) return 0; //key没有被设置过期时间
//载入时不进行过期检查
if (server.loading) return 0;
//得到当前时间,跑lua脚本时以脚本启动时间,否则使用系统时间
now = server.lua_caller ? server.lua_time_start : mstime();
//服务器是slave,不进行key的实际删除,返回逻辑上的过期与否状态(当master发过来删除命令才真正对键进行删 //除,以保持数据的一致), 返回 0 未过期 1已过期
if (server.masterhost != NULL) return now > when;
//服务器是master,但key没过期,不操作
if (now <= when) return 0;
//对过期key进行删除
server.stat_expiredkeys++;
propagateExpire(db,key); //向slave及aof文件传播key过期消息(删除命令)
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",key,db->id);
return dbDelete(db,key);
}
定期删除策略的实现
过期键的定期删除策略由activeExpireCycle函数实现,此函数被进入事件循环前调用的函数beforeSleep,以及周期性执行的函数databasesCron调用,调用条件均为服务器是master,同样是因为slave的对键删除的取决于master的删除命令
void databasesCron(void) {
if (server.active_expire_enabled && server.masterhost == NULL)
activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);//
//...
}
void beforeSleep(struct aeEventLoop *eventLoop) {
// ...
if (server.active_expire_enabled && server.masterhost == NULL)
activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);//
//...
}
activeExpireCycle函数增量式的对全部数据库进行检查,每次检查都有一个超时时间,在超时时间内会尽量检查多个数据库,对每个库按过期条件进行多次检查,每次抽取最多20个键进行检查,每1次进行超时判断,超时则退出,函数下次被调用时,从紧邻的下一个数据库开始新的检查,检查完最后一个后重新由第1个开始,如此循环
void activeExpireCycle(int type) {
//用于记录前次执行情况的静态局部变量,本次执行接上次后继续
static unsigned int current_db = 0; //即将要进行过期处理的数据库下标,为前次处理相邻的后一个数据库的下标
static int timelimit_exit = 0; //前次执行是否超时
static long long last_fast_cycle = 0; //前次快速模式执行的启动时间
int j, iteration = 0;
int dbs_per_call = CRON_DBS_PER_CALL; //#define CRON_DBS_PER_CALL 16
long long start = ustime(), timelimit;
//客户端暂停时不执行
if (clientsArePaused()) return;
//符合快速模式
if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
if (!timelimit_exit) return; //前次执行没有超时,本次不执行
if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return; //前次执行时间距本次执行间隔不够,本次不执行
last_fast_cycle = start; //符合快速模式执行条件,记录开始时间
}
//数据库个数小于CRON_DBS_PER_CALL或前次执行超时(前次超时,过期键过多),检查所有数据库,尽量减少过期键占用的空间
if (dbs_per_call > server.dbnum || timelimit_exit)
dbs_per_call = server.dbnum;
//计算执行的时间上限(微妙) ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC为25 本函数的cpu执行时间占比
//server.hz是serverCron()函数每一秒的调用频率(本函数在serverCron的子函数中被调用,故可看做本函数的调用频率)
timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
timelimit_exit = 0;
if (timelimit <= 0) timelimit = 1;
//快速模式 的执行时间上限(微妙)
if (type == ACTIVE_EXPIRE_CYCLE_FAST)
timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; //#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000
//对多个数据库进行检查
for (j = 0; j < dbs_per_call; j++) {
int expired;
redisDb *db = server.db+(current_db % server.dbnum);
//先增加下标来保证,即使本次activeExpireCycle()执行超时返回了,下次执行能从下个数据库开始,以在每个数据库上均匀分配时间
current_db++;
/* Continue to expire if at the end of the cycle more than 25%
* of the keys were expired. */
do {
unsigned long num, slots;
long long now, ttl_sum;
int ttl_samples;
//当前数据库,过期字典大小为0,跳过当前数据库,检查下一数据库
if ((num = dictSize(db->expires)) == 0) {
db->avg_ttl = 0;
break;
}
slots = dictSlots(db->expires);
now = mstime();
//过期字典是空,同时过期字典发生过扩容,但过期字典的使用率负载因子小于0.1,跳过当前数据库,检查下一数据库
//负载因子小于0.1时,dictGetRandomKey函数内部可能会循环太多次,代价太昂贵,跳过当前数据库,检查下一数据库
if (num && 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;
ttl_sum = 0;
ttl_samples = 0;
//每次最多检查20个过期键
if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP) //#define ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 20
num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;
//检查num个键的过期情况,并记录在expired
while (num--) {
dictEntry *de;
long long ttl;
if ((de = dictGetRandomKey(db->expires)) == NULL) break; //随机抽取一个设置了过期时间的键
ttl = dictGetSignedIntegerVal(de)-now; //计算生存时间
//函数内判断过期情况,并执行过期键的删除、传播过期消息(slaves、aof)、发出通知
if (activeExpireCycleTryExpire(db,de,now)) expired++; //键如果过期累加过期键个数
if (ttl > 0) {
/* We want the average TTL of keys yet not expired. */
ttl_sum += ttl;
ttl_samples++;
}
}
/* Update the average TTL stats for this database. */
if (ttl_samples) {
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%. */
if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50);
}
iteration++; //遍历次数
if ((iteration & 0xf) == 0) { //每遍历16次,进行一次超时检查
long long elapsed = ustime()-start;
latencyAddSampleIfNeeded("expire-cycle",elapsed/1000);
if (elapsed > timelimit) timelimit_exit = 1;
}
if (timelimit_exit) return; //超时则退出,等待activeExpireCycle函数下一次执行
//过期键大于5个(最大随机抽取的25%),继续检查
} while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
}
}
aof、rdb及复制功能对过期键的处理
rdb文件对过期键的处理
生成rdb文件
创建新rdb文件时,数据库中的键会被检查,过期的键不会被存入新创建的rdb文件中
载入rdb文件
启动redis服务器时,在开启rdb功能的情况下,进行rdb文件载入:
- 服务器是master,只载入未过期的键
- 服务器是slave,载入rdb文件中的全部键,进行主从数据同步时,slave的数据库会被清空,所以载入了过期键也不会造成影响
aof文件对过期键的处理
aof文件写入
过期键被删除后,会向aof文件追加del命令,显示记录该键被删除
aof重写
执行aof重写时,会对键进行检查,已过期的键不会被保存到重写后的aof文件中
复制功能对过期键的处理
当服务器运行在复制模式下时,slave的过期键删除动作由master控制(代码参见expireIfNeeded):
- master因惰性或定期删除操作,删除一个过期键后,会显示地向所有slave发送del命令,告知slave删除该过期键
- slave在执行客户端的读命令时,碰到过期键也不会删除,只会返回逻辑上的没有,客户端不会读到过期键(这是与3.0版本不同之处),只有当收到master发送的del命令,才删除过期键(见上文的函数lookupKeyRead、lookupKeyWrite调用链),
由master控制slave统一删除,目的是保证主从数据的一致性