四、数据库实现
在Redis中,服务器中所有的数据库都保存在redis.h/redisServer结构中的db数组中:
struct redisServer { // …… // 保存服务器中所有的数据库 redisDb *db; //…… // 决定服务器初始化时创建的数据库数量 // 默认16 int dbnum; }
默认配置下的服务器启动之后的状态如下:
对于redis的客户端而言,其结构中的db属性指向了当前正在操作的目标数据库,假设一个客户端的目标数据库为1,实例如下:
注:可以通过select指令进行数据库的切换(其实就是更改db的指向)。
数据库键空间
概念
Redis服务器中的每个数据库都由redis.h/redisDb结构表示,如下:
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 */ struct evictionPoolEntry *eviction_pool; /* Eviction pool of keys */ // 数据库号码 int id; /* Database ID */ // 数据库的键的平均 TTL ,统计信息 long long avg_ttl; /* Average TTL, just for stats */ } redisDb;
其中dict字典保存了该数据库的所有键值对,我们就将其称为键空间。
键空间和用户所见的数据库是直接对应的:
·键空间的键也就是数据库的键,每个键都是一个字符串对象。
·键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象在内的任意一种 Redis 对象。
例如下图就是一个拥有一个字符串对象、哈希对象、列表对象的数据库键空间的实例:
对一个数据库键进行添加、删除、更新、取值,其实都是先在键空间中取出键,而后再操作其对应的值对象。
另外,在读写键的时候,需要进行一些维护操作:
- 在读取一个键之后(读操作和写操作都要对键进行读取),服务器会根据键是否存在,以此来更新服务器的键空间命中(hit)次数或键空间不命中(miss)次数, 这两个值可以在 INFO stats 命令的 keyspace_hits 属性和 keyspace_misses 属性中查看。
- 在读取一个键之后,服务器会更新键的 LRU (最后一次使用)时间,这个值可以用于计算键的闲置时间,使用命令OBJECT idletime 命令可以查看键key的闲置时间。
- 如果服务器在读取一个键时,发现该键已经过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作。
- 如果有客户端使用WATCH命令监视了某个键,那么服务器在对被监视的键进行修改之后, 会将这个键标记为脏(dirty), 从而让事务程序注意到这个键已经被修改过。
- 服务器每次修改一个键之后,都会对脏(dirty)键计数器的值增一, 这个计数器会触发服务器的持久化以及复制操作执行。
- 如果服务器开启了数据库通知功能,那么在对键进行修改之后, 服务器将按配置发送相应的数据库通知。
生存/过期时间
数据库redisDb结构中的expire字典保存过期时间,故称其为过期字典。
一个数据库的实例展示如下:
注:过期字典中存储的对象跟键空间的对象是共享的!
expire(秒)/pexpire(毫秒)命令可以使得客户端以秒或者毫秒精度为某个键设置生存时间(TTL),经过指定时间之后,服务器就会删除生存时间为0的键。
expireat/pexpireat命令可以设置过期时间,即到达那个时刻就删除。
四种命令如下:
注:其实这四种命令都可以看成**pexpireat,**它们之间的关系如下:
它们的底层代码如下**:**
// 命令的第二个参数可能是绝对值,也可能是相对值。 //当执行 *AT 命令时, basetime 为 0 ,在其他情况下,它保存的就是当前的绝对时间。 //unit 用于指定 argv[2] (传入过期时间)的格式, //它可以是 UNIT_SECONDS 或 UNIT_MILLISECONDS , // basetime 参数则总是毫秒格式的。 void expireGenericCommand(redisClient *c, long long basetime, int unit) { robj *key = c->argv[1], *param = c->argv[2]; long long when; /* unix time in milliseconds when the key will expire. */ // 取出 when 参数 if (getLongLongFromObjectOrReply(c, param, &when, NULL) != REDIS_OK) return; // 如果传入的过期时间是以秒为单位的,那么将它转换为毫秒 if (unit == UNIT_SECONDS) when *= 1000; when += basetime; /* No key, return zero. */ // 取出键 if (lookupKeyRead(c->db,key) == NULL) { addReply(c,shared.czero); return; } /* 在载入数据时,或者服务器为附属节点时, * 即使 EXPIRE 的 TTL 为负数,或者 EXPIREAT 提供的时间戳已经过期, * 服务器也不会主动删除这个键,而是等待主节点发来显式的 DEL 命令。 * 程序会继续将(一个可能已经过期的 TTL)设置为键的过期时间, * 并且等待主节点发来 DEL 命令。 */ if (when <= mstime() && !server.loading && !server.masterhost) { // when 提供的时间已经过期,服务器为主节点,并且没在载入数据 robj *aux; redisAssertWithInfo(c,key,dbDelete(c->db,key)); server.dirty++; /* Replicate/AOF this as an explicit DEL. */ // 传播 DEL 命令 aux = createStringObject("DEL",3); rewriteClientCommandVector(c,2,aux,key); decrRefCount(aux); signalModifiedKey(c->db,key); notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"del",key,c->db->id); addReply(c, shared.cone); return; } else { // 设置键的过期时间 // 如果服务器为附属节点,或者服务器正在载入, // 那么这个 when 有可能已经过期的 setExpire(c->db,key,when); addReply(c,shared.cone); signalModifiedKey(c->db,key); notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"expire",key,c->db->id); server.dirty++; return; } }
另外,可以使用persist命令解除过期时间;TTL/PTTL返回键的剩余生存时间。
过期删除策略
有三种常见的删除策略:
·定时删除:在设置键过期时间时,创建一个定时器,使得当定时器时间来临时,立即删除键;
该方法对内存友好,因为可以保证尽快删除过期的键并释放内存;缺点就是对CPU不友好,因为频繁的删除键会占用CPU时间,可能会影响吞吐量和响应时间。另外,定时器事件的时间复杂度为O(N)。
·惰性删除:过期键并不立即删除,而是每次取键时看它过期没有,过期就删除,没有就返回该键;
这种方式对CPU友好,因为仅在需要该键时才会检查,但缺点就是对内存不友好,过多的过期键会占用大量的内存,甚至可以看成内存泄漏——因为有的键所占用内存可能一直不会释放。
·定时删除:两者的一种折中方式,隔一段时间对数据库检查,删除其中过期的键,检查策略是重点:检查间隔跟检查数量。
Redis采用的是惰性删除和定期删除策略。
惰性删除
源码在db.c/expireIfNeeded,Redis在读写所有键的时候会检查其输入键是否过期:
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 */ // 如果服务器正在进行载入,那么不进行任何过期检查 if (server.loading) return 0; now = server.lua_caller ? server.lua_time_start : mstime(); // 当服务器运行在 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); }
定期删除
源码在redis.c/activeExpireCycle,该函数会在规定时间内分多次遍历服务器的各个数据库,从expire字典中随机检查一部分键的过期时间,并删除其中的过期键:
void activeExpireCycle(int type) { // 静态变量,用来累积函数连续执行时的数据 static unsigned int current_db = 0; /* Last DB tested. */ static int timelimit_exit = 0; /* Time limit hit in previous call? */ static long long last_fast_cycle = 0; /* When last fast cycle ran. */ unsigned int j, iteration = 0; // 默认每次处理的数据库数量 unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL; // 函数开始的时间 long long start = ustime(), timelimit; // 快速模式 if (type == ACTIVE_EXPIRE_CYCLE_FAST) { // 如果上次函数没有触发 timelimit_exit ,那么不执行处理 if (!timelimit_exit) return; // 如果距离上次执行未够一定时间,那么不执行处理 if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return; // 运行到这里,说明执行快速处理,记录当前时间 last_fast_cycle = start; } /* 一般情况下,函数只处理 REDIS_DBCRON_DBS_PER_CALL 个数据库 * 除非: * 当前数据库的数量小于 REDIS_DBCRON_DBS_PER_CALL * 如果上次处理遇到了时间上限,那么这次需要对所有数据库进行扫描, * 这可以避免过多的过期键占用空间 */ if (dbs_per_call > server.dbnum || timelimit_exit) dbs_per_call = server.dbnum; // 函数处理的微秒时间上限 // ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 默认为 25 ,也即是 25 % 的 CPU 时间 timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERCrver.hz/100; timelimit_exit = 0; if (timelimit <= 0) timelimit = 1; // 如果是运行在快速模式之下 // 那么最多只能运行 FAST_DURATION 微秒 // 默认值为 1000 (微秒) if (type == ACTIVE_EXPIRE_CYCLE_FAST) timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* in microseconds. */ // 遍历数据库 for (j = 0; j < dbs_per_call; j++) { int expired; // 指向要处理的数据库 redisDb *db = server.db+(current_db % server.dbnum); // 为 DB 计数器加一,如果进入 do 循环之后因为超时而跳出 // 那么下次会直接从下个 DB 开始处理 current_db++; 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(); // 这个数据库的使用率低于 1% ,扫描起来太费力了(大部分都会 MISS) // 跳过,等待字典收缩程序运行 if (num && slots > DICT_HT_INITIAL_SIZE && (num*100ots < 1)) break; /* The main collection cycle. Sample random keys among keys * with an expire set, checking for expired ones. * * 样本计数器 */ // 已处理过期键计数器 expired = 0; // 键的总 TTL 计数器 ttl_sum = 0; // 总共处理的键计数器 ttl_samples = 0; // 每次最多只能检查 LOOKUPS_PER_LOOP 个键 if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP) num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP; // 开始遍历数据库 while (num--) { dictEntry *de; long long ttl; // 从 expires 中随机取出一个带过期时间的键 if ((de = dictGetRandomKey(db->expires)) == NULL) break; // 计算 TTL ttl = dictGetSignedIntegerVal(de)-now; // 如果键已经过期,那么删除它,并将 expired 计数器增一 if (activeExpireCycleTryExpire(db,de,now)) expired++; if (ttl < 0) ttl = 0; // 累积键的 TTL ttl_sum += ttl; // 累积处理键的个数 ttl_samples++; } /* Update the average TTL stats for this database. */ // 为这个数据库更新平均 TTL 统计数据 if (ttl_samples) { // 计算当前平均值 long long avg_ttl = ttl_suml_samples; // 如果这是第一次设置数据库平均 TTL ,那么进行初始化 if (db->avg_ttl == 0) db->avg_ttl = avg_ttl; /* Smooth the value averaging with the previous one. */ // 取数据库的上次平均 TTL 和今次平均 TTL 的平均值 db->avg_ttl = (db->avg_ttl+avg_ttl)/2; } // 我们不能用太长时间处理过期键, // 所以这个函数执行一定时间之后就要返回 // 更新遍历次数 iteration++; // 每遍历 16 次执行一次 if ((iteration & 0xf) == 0 && /* check once every 16 iterations. */ (ustime()-start) > timelimit) { // 如果遍历次数正好是 16 的倍数 // 并且遍历的时间超过了 timelimit // 那么断开 timelimit_exit timelimit_exit = 1; } // 已经超时了,返回 if (timelimit_exit) return; // 如果已删除的过期键占当前总数据库带过期时间的键数量的 25 % // 那么不再遍历 } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4); } }
AOF、RDB、复制对过期键的处理
·若是生成RDB文件,即SAVE、BGSAVE命令,在保存过程中会对数据库中的键进行检查,过期的键将不保存;
若是载入RDB文件,当以主服务器运行时,只会载入未过期的键;若以从服务器运行,所有键都会载入;
·若是写入AOF,某个键过期还未删除不会造成影响,只有当惰性删除/定期删除策略将其删除时,AOF会显式追加DEL命令,提示已删除;
若是载入AOF,会检查是否过期,过期的就不载入;
·若是复制操作时,从服务器不会在意该键时是过期还是不过期,它的过期删除操作由主服务器控制,也就是说当主服务器删除一个过期键后,它会通知所有从服务器删除;若是主服务器没有发送,那么从服务器依旧像是处理未过期键一样;这样做主要是为了保证主从服务器的数据一致性。
通知与订阅
Redis2.8版本中新增了数据库的通知功能:客户端可以订阅指定的频道/模式来获取数据库中键的变化及命令执行情况。
下面的指令展示了一个客户端订阅0号数据库中针对message键的命令(键空间通知:某个键执行了什么命令)
有一种是键事件通知(某个命令被什么键执行了),如下:
通知与订阅功能的源码文件为notify.c及pubsub.c,具体而言:
发布通知API:
// notify.c /* * type 参数表示该通知类型 * event 参数是一个字符串表示的事件名 * key 参数是一个 Redis 对象表示的键名 * dbid 参数为键所在的数据库ID */ void notifyKeyspaceEvent(int type, char *event, robj *key, int dbid) ;
每当一个Redis命令需要发送数据库通知时,该命令的实现函数就会调用notifyKeyspaceEvent函数,并向函数传递相关信息。例如执行SADD命令,其实现函数进行通知的部分代码如下:
void saddCommand(redisClient *c){ ... // 当至少有一个元素被成功添加 if(added){ ... // 传递该命令的相关信息 notifyKeyspaceEvent(REDIS_NOTIFY_SET,"sadd",c->argv[1].c->db->id); } ... }
Redis源码、面试指南(4)单机数据库、持久化、通知与订阅(中):https://developer.aliyun.com/article/1508243