数据库
本章将对 Redis 数据库的构造和实现进行讨论。
除了说明数据库是如何储存数据对象之外,本章还会讨论键的过期信息是如何保存,而 Redis 又是如何删除过期键的。
数据库的结构
Redis 中的每个数据库,都由一个 redis.h/redisDb
结构表示:
typedef struct redisDb {
// 保存着数据库以整数表示的号码
int id;
// 保存着数据库中的所有键值对数据
// 这个属性也被称为键空间(key space)
dict *dict;
// 保存着键的过期信息
dict *expires;
// 实现列表阻塞原语,如 BLPOP
// 在列表类型一章有详细的讨论
dict *blocking_keys;
dict *ready_keys;
// 用于实现 WATCH 命令
// 在事务章节有详细的讨论
dict *watched_keys;
} redisDb;
下文将详细讨论 id
、 dict
和 expires
三个属性,
以及针对这三个属性所执行的数据库操作。
数据库的切换
redisDb
结构的 id
域保存着数据库的号码。
这个号码很容易让人将它和切换数据库的 SELECT 命令联系在一起,
但是,
实际上,
id
属性并不是用来实现 SELECT 命令,
而是给 Redis 内部程序使用的。
当 Redis 服务器初始化时,
它会创建出 redis.h/REDIS_DEFAULT_DBNUM
个数据库,
并将所有数据库保存到 redis.h/redisServer.db
数组中,
每个数据库的 id
为从 0
到 REDIS_DEFAULT_DBNUM - 1
的值。
当执行 SELECT number
命令时,程序直接使用 redisServer.db[number]
来切换数据库。
但是,
一些内部程序,
比如 AOF 程序、复制程序和 RDB 程序,
需要知道当前数据库的号码,
如果没有 id
域的话,
程序就只能在当前使用的数据库的指针,
和 redisServer.db
数组中所有数据库的指针进行对比,
以此来弄清楚自己正在使用的是那个数据库。
以下伪代码描述了这个对比过程:
def PSEUDO_GET_CURRENT_DB_NUMBER(current_db_pointer):
i = 0
for db_pointer in redisServer.db:
if db_pointer == current_db_pointer:
break
i += 1
return i
有了 id
域的话,
程序就可以通过读取 id
域来了解自己正在使用的是哪个数据库,
这样就不用对比指针那么麻烦了。
数据库键空间
因为 Redis 是一个键值对数据库(key-value pairs database),
所以它的数据库本身也是一个字典(俗称 key space):
在 redisDb
结构的 dict
属性中,保存着数据库的所有键值对数据。
下图展示了一个包含 number
、 book
、 message
三个键的数据库 ——
其中 number
键是一个列表,列表中包含三个整数值;
book
键是一个哈希表,表中包含三个键值对;
而 message
键则指向另一个字符串:
键空间的操作
因为数据库本身是一个字典,
所以对数据库的操作基本上都是对字典的操作,
加上以下一些维护操作:
- 更新键的命中率和不命中率,这个值可以用 INFO 命令查看;
- 更新键的 LRU 时间,这个值可以用 OBJECT 命令来查看;
- 删除过期键(稍后会详细说明);
- 如果键被修改了的话,那么将键设为脏(用于事务监视),并将服务器设为脏(等待 RDB 保存);
- 将对键的修改发送到 AOF 文件和附属节点,保持数据库状态的一致;
作为例子,以下几个小节会展示键的添加、删除、更新、取值等几个主要操作。
添加新键
添加一个新键对到数据库,
实际上就是将一个新的键值对添加到键空间字典中,
其中键为字符串对象,
而值则是任意一种 Redis 类型值对象。
举个例子,如果数据库的目前状态如下图所示(和前面展示的数据库状态图一样):
那么在客户端执行 SET date 2013.2.1
命令之后,数据库更新为下图状态:
删除键
删除数据库中的一个键,
实际上就是删除字典空间中对应的键对象和值对象。
举个例子,如果数据库的目前状态如下图所示(和前面展示的数据库状态图一样):
那么在客户端执行 DEL message
命令之后,数据库更新为下图状态:
更新键
当对一个已存在于数据库的键执行更新操作时,
数据库释放键原来的值对象,
然后将指针指向新的值对象。
举个例子,如果数据库的目前状态如下图所示(和前面展示的数据库状态图一样):
那么在客户端执行 SET message "blah blah"
命令之后,数据库更新为下图状态:
取值
在数据库中取值实际上就是在字典空间中取值,
再加上一些额外的类型检查:
- 键不存在,返回空回复;
- 键存在,且类型正确,按照通讯协议返回值对象;
- 键存在,但类型不正确,返回类型错误。
举个例子,如果数据库的目前状态如下图所示(和前面展示的数据库状态图一样):
当客户端执行 GET message
时,服务器返回 "hello moto"
。
当客户端执行 GET not-exists-key
时,服务器返回空回复。
当服务器执行 GET book
时,服务器返回类型错误。
其他操作
除了上面展示的键值操作之外,还有很多针对数据库本身的命令,也是通过对键空间进行处理来完成的:
等等。
键的过期时间
在前面的内容中,
我们讨论了很多涉及数据库本身、以及对数据库中的键值对进行处理的操作,
但是,
关于数据库如何保存键的过期时间,
以及如何处理过期键这一问题,
我们还没有讨论到。
通过 EXPIRE 、 PEXPIRE 、 EXPIREAT 和 PEXPIREAT 四个命令,
客户端可以给某个存在的键设置过期时间,
当键的过期时间到达时,
键就不再可用:
redis> SETEX key 5 value
OK
redis> GET key
"value"
redis> GET key // 5 秒过后
(nil)
命令 TTL 和 PTTL 则用于返回给定键距离过期还有多长时间:
redis> SETEX key 10086 value
OK
redis> TTL key
(integer) 10082
redis> PTTL key
(integer) 10068998
在接下来的内容中,
我们将探讨和键的过期时间相关的问题:
比如键的过期时间是如何保存的,
而过期键又是如何被删除的,
等等。
过期时间的保存
在数据库中,
所有键的过期时间都被保存在 redisDb
结构的 expires
字典里:
typedef struct redisDb {
// ...
dict *expires;
// ...
} redisDb;
expires
字典的键是一个指向 dict
字典(键空间)里某个键的指针,
而字典的值则是键所指向的数据库键的到期时间,
这个值以 long long
类型表示。
下图展示了一个含有三个键的数据库,其中 number
和 book
两个键带有过期时间:
Note
为了展示的方便,
图中重复出现了两次 number
键和 book
键。
在实际中,
键空间字典的键和过期时间字典的键都指向同一个字符串对象,
所以不会浪费任何空间。
设置生存时间
Redis 有四个命令可以设置键的生存时间(可以存活多久)和过期时间(什么时候到期):
虽然有那么多种不同单位和不同形式的设置方式,
但是 expires
字典的值只保存“以毫秒为单位的过期 UNIX 时间戳”,
这就是说,
通过进行转换,
所有命令的效果最后都和 PEXPIREAT 命令的效果一样。
举个例子,从 EXPIRE 命令到 PEXPIREAT 命令的转换可以用伪代码表示如下:
def EXPIRE(key, sec):
# 将 TTL 从秒转换为毫秒
ms = sec_to_ms(sec)
# 获取以毫秒计算的当前 UNIX 时间戳
ts_in_ms = get_current_unix_timestamp_in_ms()
# 毫秒 TTL 加上毫秒时间戳,就是 key 到期的时间戳
PEXPIREAT(ms + ts_in_ms, key)
其他函数的转换方式也是类似的。
作为例子,
下图展示了一个 expires
字典示例,
字典中 number
键的过期时间是 2013 年 2 月 10 日(农历新年),
而 book
键的过期时间则是 2013 年 2 月 14 日(情人节):
这两个键的过期时间可能是用以上四个命令的任意一个设置的,
但它们都以统一的格式被保存在 expires
字典中。
过期键的判定
通过 expires
字典,
可以用以下步骤检查某个键是否过期:
- 检查键是否存在于
expires
字典:如果存在,那么取出键的过期时间;
- 检查当前 UNIX 时间戳是否大于键的过期时间:如果是的话,那么键已经过期;否则,键未过期。
可以用伪代码来描述这一过程:
def is_expired(key):
# 取出键的过期时间
key_expire_time = expires.get(key)
# 如果过期时间不为空,并且当前时间戳大于过期时间,那么键已经过期
if expire_time is not None and current_timestamp() > key_expire_time:
return True
# 否则,键未过期或没有设置过期时间
return False
过期键的清除
我们知道了过期时间保存在 expires
字典里,
又知道了该如何判定一个键是否过期,
现在剩下的问题是,
如果一个键是过期的,
那它什么时候会被删除?
这个问题有三种可能的答案:
- 定时删除:在设置键的过期时间时,创建一个定时事件,当过期时间到达时,由事件处理器自动执行键的删除操作。
- 惰性删除:放任键过期不管,但是在每次从
dict
字典中取出键值时,要检查键是否过期,如果过期的话,就删除它,并返回空;如果没过期,就返回键值。
- 定期删除:每隔一段时间,对
expires
字典进行检查,删除里面的过期键。
定时删除
定时删除策略对内存是最友好的:
因为它保证过期键会在第一时间被删除,
过期键所消耗的内存会立即被释放。
这种策略的缺点是,
它对 CPU 时间是最不友好的:
因为删除操作可能会占用大量的 CPU 时间 ——
在内存不紧张、但是 CPU 时间非常紧张的时候
(比如说,进行交集计算或排序的时候),
将 CPU 时间花在删除那些和当前任务无关的过期键上,
这种做法毫无疑问会是低效的。
除此之外,
目前 Redis 事件处理器对时间事件的实现方式 —— 无序链表,
查找一个时间复杂度为 \(O(N)\) —— 并不适合用来处理大量时间事件。
惰性删除
惰性删除对 CPU 时间来说是最友好的:
它只会在取出键时进行检查,
这可以保证删除操作只会在非做不可的情况下进行 ——
并且删除的目标仅限于当前处理的键,
这个策略不会在删除其他无关的过期键上花费任何 CPU 时间。
惰性删除的缺点是,
它对内存是最不友好的:
如果一个键已经过期,
而这个键又仍然保留在数据库中,
那么 dict
字典和 expires
字典都需要继续保存这个键的信息,
只要这个过期键不被删除,
它占用的内存就不会被释放。
在使用惰性删除策略时,
如果数据库中有非常多的过期键,
但这些过期键又正好没有被访问的话,
那么它们就永远也不会被删除(除非用户手动执行),
这对于性能非常依赖于内存大小的 Redis 来说,
肯定不是一个好消息。
举个例子,
对于一些按时间点来更新的数据,
比如日志(log),
在某个时间点之后,
对它们的访问就会大大减少,
如果大量的这些过期数据积压在数据库里面,
用户以为它们已经过期了(已经被删除了),
但实际上这些键却没有真正的被删除(内存也没有被释放),
那结果肯定是非常糟糕。
定期删除
从上面对定时删除和惰性删除的讨论来看,
这两种删除方式在单一使用时都有明显的缺陷:
定时删除占用太多 CPU 时间,
惰性删除浪费太多内存。
定期删除是这两种策略的一种折中:
- 它每隔一段时间执行一次删除操作,并通过限制删除操作执行的时长和频率,籍此来减少删除操作对 CPU 时间的影响。
- 另一方面,通过定期删除过期键,它有效地减少了因惰性删除而带来的内存浪费。
Redis 使用的策略
Redis 使用的过期键删除策略是惰性删除加上定期删除,
这两个策略相互配合,可以很好地在合理利用 CPU 时间和节约内存空间之间取得平衡。
因为前面已经说了这两个策略的概念了,下面两节就来探讨这两个策略在 Redis 中的具体实现。
过期键的惰性删除策略
实现过期键惰性删除策略的核心是 db.c/expireIfNeeded
函数 ——
所有命令在读取或写入数据库之前,程序都会调用 expireIfNeeded
对输入键进行检查,
并将过期键删除:
比如说, GET
命令的执行流程可以用下图来表示:
expireIfNeeded
的作用是,
如果输入键已经过期的话,
那么将键、键的值、键保存在 expires
字典中的过期时间都删除掉。
用伪代码描述的 expireIfNeeded
定义如下:
def expireIfNeeded(key):
# 对过期键执行以下操作 。。。
if key.is_expired():
# 从键空间中删除键值对
db.dict.remove(key)
# 删除键的过期时间
db.expires.remove(key)
# 将删除命令传播到 AOF 文件和附属节点
propagateDelKeyToAofAndReplication(key)
过期键的定期删除策略
对过期键的定期删除由 redis.c/activeExpireCycle
函执行:
每当 Redis 的例行处理程序 serverCron
执行时,
activeExpireCycle
都会被调用 ——
这个函数在规定的时间限制内,
尽可能地遍历各个数据库的 expires
字典,
随机地检查一部分键的过期时间,
并删除其中的过期键。
整个过程可以用伪代码描述如下:
def activeExpireCycle():
# 遍历数据库(不一定能全部都遍历完,看时间是否足够)
for db in server.db:
# MAX_KEY_PER_DB 是一个 DB 最大能处理的 key 个数
# 它保证时间不会全部用在个别的 DB 上(避免饥饿)
i = 0
while (i < MAX_KEY_PER_DB):
# 数据库为空,跳出 while ,处理下个 DB
if db.is_empty(): break
# 随机取出一个带 TTL 的键
key_with_ttl = db.expires.get_random_key()
# 检查键是否过期,如果是的话,将它删除
if is_expired(key_with_ttl):
db.deleteExpiredKey(key_with_ttl)
# 当执行时间到达上限,函数就返回,不再继续
# 这确保删除操作不会占用太多的 CPU 时间
if reach_time_limit(): return
i += 1
过期键对 AOF 、RDB 和复制的影响
前面的内容讨论了过期键对 CPU 时间和内存的影响,现在,是时候说说过期键在 RDB 文件、 AOF 文件、 AOF 重写以及复制中的影响了:
过期键会被保存在更新后的 RDB 文件、 AOF 文件或者重写后的 AOF 文件里面吗?
附属节点会会如何处理过期键?处理的方式和主节点一样吗?
以上这些问题就是本节要解答的。
更新后的 RDB 文件
在创建新的 RDB 文件时,程序会对键进行检查,过期的键不会被写入到更新后的 RDB 文件中。
因此,过期键对更新后的 RDB 文件没有影响。
AOF 文件
在键已经过期,但是还没有被惰性删除或者定期删除之前,这个键不会产生任何影响,AOF 文件也不会因为这个键而被修改。
当过期键被惰性删除、或者定期删除之后,程序会向 AOF 文件追加一条 DEL
命令,来显式地记录该键已被删除。
举个例子,
如果客户端使用 GET message
试图访问 message
键的值,
但 message
已经过期了,
那么服务器执行以下三个动作:
- 从数据库中删除
message
;
- 追加一条
DEL message
命令到 AOF 文件;
- 向客户端返回
NIL
。
AOF 重写
和 RDB 文件类似,
当进行 AOF 重写时,
程序会对键进行检查,
过期的键不会被保存到重写后的 AOF 文件。
因此,过期键对重写后的 AOF 文件没有影响。
复制
当服务器带有附属节点时,
过期键的删除由主节点统一控制:
- 如果服务器是主节点,那么它在删除一个过期键之后,会显式地向所有附属节点发送一个
DEL
命令。
- 如果服务器是附属节点,那么当它碰到一个过期键的时候,它会向程序返回键已过期的回复,但并不真正的删除过期键。因为程序只根据键是否已经过期、而不是键是否已经被删除来决定执行流程,所以这种处理并不影响命令的正确执行结果。当接到从主节点发来的
DEL
命令之后,附属节点才会真正的将过期键删除掉。
附属节点不自主对键进行删除是为了和主节点的数据保持绝对一致,
因为这个原因,
当一个过期键还存在于主节点时,这个键在所有附属节点的副本也不会被删除。
这种处理机制对那些使用大量附属节点,并且带有大量过期键的应用来说,可能会造成一部分内存不能立即被释放,但是,因为过期键通常很快会被主节点发现并删除,所以这实际上也算不上什么大问题。
数据库空间的收缩和扩展
因为数据库空间是由字典来实现的,
所以数据库空间的扩展/收缩规则和字典的扩展/收缩规则完全一样,
具体的信息可以参考《字典》章节。
因为对字典进行收缩的时机是由使用字典的程序决定的,
所以 Redis 使用 redis.c/tryResizeHashTables
函数来检查数据库所使用的字典是否需要进行收缩:
每次 redis.c/serverCron
函数运行的时候,
这个函数都会被调用。
tryResizeHashTables
函数的完整定义如下:
/*
* 对服务器中的所有数据库键空间字典、以及过期时间字典进行检查,
* 看是否需要对这些字典进行收缩。
*
* 如果字典的使用空间比率低于 REDIS_HT_MINFILL
* 那么将字典的大小缩小,让 USED/BUCKETS 的比率 <= 1
*/
void tryResizeHashTables(void) {
int j;
for (j = 0; j < server.dbnum; j++) {
// 缩小键空间字典
if (htNeedsResize(server.db[j].dict))
dictResize(server.db[j].dict);
// 缩小过期时间字典
if (htNeedsResize(server.db[j].expires))
dictResize(server.db[j].expires);
}
}
小结
- 数据库主要由
dict
和 expires
两个字典构成,其中 dict
保存键值对,而 expires
则保存键的过期时间。
- 数据库的键总是一个字符串对象,而值可以是任意一种 Redis 数据类型,包括字符串、哈希、集合、列表和有序集。
expires
的某个键和 dict
的某个键共同指向同一个字符串对象,而 expires
键的值则是该键以毫秒计算的 UNIX 过期时间戳。
- Redis 使用惰性删除和定期删除两种策略来删除过期的键。
- 更新后的 RDB 文件和重写后的 AOF 文件都不会保留已经过期的键。
- 当一个过期键被删除之后,程序会追加一条新的
DEL
命令到现有 AOF 文件末尾。
- 当主节点删除一个过期键之后,它会显式地发送一条
DEL
命令到所有附属节点。
- 附属节点即使发现过期键,也不会自作主张地删除它,而是等待主节点发来
DEL
命令,这样可以保证主节点和附属节点的数据总是一致的。
- 数据库的
dict
字典和 expires
字典的扩展策略和普通字典一样。它们的收缩策略是:当节点的填充百分比不足 10% 时,将可用节点数量减少至大于等于当前已用节点数量。
RDB
在运行情况下,
Redis 以数据结构的形式将数据维持在内存中,
为了让这些数据在 Redis 重启之后仍然可用,
Redis 分别提供了 RDB 和 AOF 两种持久化模式。
在 Redis 运行时,
RDB 程序将当前内存中的数据库快照保存到磁盘文件中,
在 Redis 重启动时,
RDB 程序可以通过载入 RDB 文件来还原数据库的状态。
RDB 功能最核心的是 rdbSave
和 rdbLoad
两个函数,
前者用于生成 RDB 文件到磁盘,
而后者则用于将 RDB 文件中的数据重新载入到内存中:
本章先介绍 SAVE 和 BGSAVE 命令的实现,
以及 rdbSave
和 rdbLoad
两个函数的运行机制,
然后以图表的方式,
分部分来介绍 RDB 文件的组织形式。
因为本章涉及 RDB 运行的相关机制,
如果还没了解过 RDB 功能的话,
请先阅读 Redis 官网上的 persistence 手册 。
保存
rdbSave
函数负责将内存中的数据库数据以 RDB 格式保存到磁盘中,
如果 RDB 文件已存在,
那么新的 RDB 文件将替换已有的 RDB 文件。
在保存 RDB 文件期间,
主进程会被阻塞,
直到保存完成为止。
SAVE 和 BGSAVE 两个命令都会调用 rdbSave
函数,但它们调用的方式各有不同:
- SAVE 直接调用
rdbSave
,阻塞 Redis 主进程,直到保存完成为止。在主进程阻塞期间,服务器不能处理客户端的任何请求。
- BGSAVE 则
fork
出一个子进程,子进程负责调用 rdbSave
,并在保存完成之后向主进程发送信号,通知保存已完成。因为 rdbSave
在子进程被调用,所以 Redis 服务器在 BGSAVE 执行期间仍然可以继续处理客户端的请求。
通过伪代码来描述这两个命令,可以很容易地看出它们之间的区别:
def SAVE():
rdbSave()
def BGSAVE():
pid = fork()
if pid == 0:
# 子进程保存 RDB
rdbSave()
elif pid > 0:
# 父进程继续处理请求,并等待子进程的完成信号
handle_request()
else:
# pid == -1
# 处理 fork 错误
handle_fork_error()
SAVE 、 BGSAVE 、 AOF 写入和 BGREWRITEAOF
除了了解 RDB 文件的保存方式之外,
我们可能还想知道,
两个 RDB 保存命令能否同时使用?
它们和 AOF 保存工作是否冲突?
本节就来解答这些问题。
载入
当 Redis 服务器启动时,
rdbLoad
函数就会被执行,
它读取 RDB 文件,
并将文件中的数据库数据载入到内存中。
在载入期间,
服务器每载入 1000 个键就处理一次所有已到达的请求,
不过只有 PUBLISH
、 SUBSCRIBE
、 PSUBSCRIBE
、 UNSUBSCRIBE
、 PUNSUBSCRIBE
五个命令的请求会被正确地处理,
其他命令一律返回错误。
等到载入完成之后,
服务器才会开始正常处理所有命令。
Note
发布与订阅功能和其他数据库功能是完全隔离的,前者不写入也不读取数据库,所以在服务器载入期间,订阅与发布功能仍然可以正常使用,而不必担心对载入数据的完整性产生影响。
另外,
因为 AOF 文件的保存频率通常要高于 RDB 文件保存的频率,
所以一般来说,
AOF 文件中的数据会比 RDB 文件中的数据要新。
因此,
如果服务器在启动时,
打开了 AOF 功能,
那么程序优先使用 AOF 文件来还原数据。
只有在 AOF 功能未打开的情况下,
Redis 才会使用 RDB 文件来还原数据。
RDB 文件结构
前面介绍了保存和读取 RDB 文件的两个函数,现在,是时候介绍 RDB 文件本身了。
一个 RDB 文件可以分为以下几个部分:
+-------+-------------+-----------+-----------------+-----+-----------+
| REDIS | RDB-VERSION | SELECT-DB | KEY-VALUE-PAIRS | EOF | CHECK-SUM |
+-------+-------------+-----------+-----------------+-----+-----------+
|<-------- DB-DATA ---------->|
以下的几个小节将分别对这几个部分的保存和读入规则进行介绍。
REDIS
文件的最开头保存着 REDIS
五个字符,标识着一个 RDB 文件的开始。
在读入文件的时候,程序可以通过检查一个文件的前五个字节,来快速地判断该文件是否有可能是 RDB 文件。
RDB-VERSION
一个四字节长的以字符表示的整数,记录了该文件所使用的 RDB 版本号。
目前的 RDB 文件版本为 0006
。
因为不同版本的 RDB 文件互不兼容,所以在读入程序时,需要根据版本来选择不同的读入方式。
DB-DATA
这个部分在一个 RDB 文件中会出现任意多次,每个 DB-DATA
部分保存着服务器上一个非空数据库的所有数据。
SELECT-DB
这域保存着跟在后面的键值对所属的数据库号码。
在读入 RDB 文件时,程序会根据这个域的值来切换数据库,确保数据被还原到正确的数据库上。
KEY-VALUE-PAIRS
因为空的数据库不会被保存到 RDB 文件,所以这个部分至少会包含一个键值对的数据。
每个键值对的数据使用以下结构来保存:
+----------------------+---------------+-----+-------+
| OPTIONAL-EXPIRE-TIME | TYPE-OF-VALUE | KEY | VALUE |
+----------------------+---------------+-----+-------+
OPTIONAL-EXPIRE-TIME
域是可选的,如果键没有设置过期时间,那么这个域就不会出现;
反之,如果这个域出现的话,那么它记录着键的过期时间,在当前版本的 RDB 中,过期时间是一个以毫秒为单位的 UNIX 时间戳。
KEY
域保存着键,格式和 REDIS_ENCODING_RAW
编码的字符串对象一样(见下文)。
TYPE-OF-VALUE
域记录着 VALUE
域的值所使用的编码,
根据这个域的指示,
程序会使用不同的方式来保存和读取 VALUE
的值。
Note
下文提到的编码在《对象处理机制》章节介绍过,如果忘记了可以回去重温下。
保存 VALUE
的详细格式如下:
REDIS_ENCODING_INT
编码的 REDIS_STRING
类型对象:
如果值可以表示为 8
位、 16
位或 32
位有符号整数,那么直接以整数类型的形式来保存它们:
+---------+
| integer |
+---------+
比如说,整数 8
可以用 8
位序列 00001000
保存。
当读入这类值时,程序按指定的长度读入字节数据,然后将数据转换回整数类型。
另一方面,如果值不能被表示为最高 32
位的有符号整数,那么说明这是一个 long long
类型的值,在 RDB 文件中,这种类型的值以字符序列的形式保存。
一个字符序列由两部分组成:
+-----+---------+
| LEN | CONTENT |
+-----+---------+
其中, CONTENT
域保存了字符内容,而 LEN
则保存了以字节为单位的字符长度。
当进行载入时,读入器先读入 LEN
,创建一个长度等于 LEN
的字符串对象,然后再从文件中读取 LEN
字节数据,并将这些数据设置为字符串对象的值。
REDIS_ENCODING_RAW
编码的 REDIS_STRING
类型值有三种保存方式:
如果值可以表示为 8
位、 16
位或 32
位长的有符号整数,那么用整数类型的形式来保存它们。
如果字符串长度大于 20
,并且服务器开启了 LZF 压缩功能 ,那么对字符串进行压缩,并保存压缩之后的数据。
经过 LZF 压缩的字符串会被保存为以下结构:
+----------+----------------+--------------------+
| LZF-FLAG | COMPRESSED-LEN | COMPRESSED-CONTENT |
+----------+----------------+--------------------+
LZF-FLAG
告知读入器,后面跟着的是被 LZF 算法压缩过的数据。
COMPRESSED-CONTENT
是被压缩后的数据, COMPRESSED-LEN
则是该数据的字节长度。
在其他情况下,程序直接以普通字节序列的方式来保存字符串。比如说,对于一个长度为 20
字节的字符串,需要使用 20
字节的空间来保存它。
这种字符串被保存为以下结构:
+-----+---------+
| LEN | CONTENT |
+-----+---------+
LEN
为字符串的字节长度, CONTENT
为字符串。
当进行载入时,读入器先检测字符串保存的方式,再根据不同的保存方式,用不同的方法取出内容,并将内容保存到新建的字符串对象当中。
REDIS_ENCODING_LINKEDLIST
编码的 REDIS_LIST
类型值保存为以下结构:
+-----------+--------------+--------------+-----+--------------+
| NODE-SIZE | NODE-VALUE-1 | NODE-VALUE-2 | ... | NODE-VALUE-N |
+-----------+--------------+--------------+-----+--------------+
其中 NODE-SIZE
保存链表节点数量,后面跟着 NODE-SIZE
个节点值。节点值的保存方式和字符串的保存方式一样。
当进行载入时,读入器读取节点的数量,创建一个新的链表,然后一直执行以下步骤,直到指定节点数量满足为止:
- 读取字符串表示的节点值
- 将包含节点值的新节点添加到链表中
REDIS_ENCODING_HT
编码的 REDIS_SET
类型值保存为以下结构:
+----------+-----------+-----------+-----+-----------+
| SET-SIZE | ELEMENT-1 | ELEMENT-2 | ... | ELEMENT-N |
+----------+-----------+-----------+-----+-----------+
SET-SIZE
记录了集合元素的数量,后面跟着多个元素值。元素值的保存方式和字符串的保存方式一样。
载入时,读入器先读入集合元素的数量 SET-SIZE
,再连续读入 SET-SIZE
个字符串,并将这些字符串作为新元素添加至新创建的集合。
REDIS_ENCODING_SKIPLIST
编码的 REDIS_ZSET
类型值保存为以下结构:
+--------------+-------+---------+-------+---------+-----+-------+---------+
| ELEMENT-SIZE | MEB-1 | SCORE-1 | MEB-2 | SCORE-2 | ... | MEB-N | SCORE-N |
+--------------+-------+---------+-------+---------+-----+-------+---------+
其中 ELEMENT-SIZE
为有序集元素的数量, MEB-i
为第 i
个有序集元素的成员, SCORE-i
为第 i
个有序集元素的分值。
当进行载入时,读入器读取有序集元素数量,创建一个新的有序集,然后一直执行以下步骤,直到指定元素数量满足为止:
- 读入字符串形式保存的成员
member
- 读入字符串形式保存的分值
score
,并将它转换为浮点数
- 添加
member
为成员、 score
为分值的新元素到有序集
REDIS_ENCODING_HT
编码的 REDIS_HASH
类型值保存为以下结构:
+-----------+-------+---------+-------+---------+-----+-------+---------+
| HASH-SIZE | KEY-1 | VALUE-1 | KEY-2 | VALUE-2 | ... | KEY-N | VALUE-N |
+-----------+-------+---------+-------+---------+-----+-------+---------+
HASH-SIZE
是哈希表包含的键值对的数量, KEY-i
和 VALUE-i
分别是哈希表的键和值。
载入时,程序先创建一个新的哈希表,然后读入 HASH-SIZE
,再执行以下步骤 HASH-SIZE
次:
- 读入一个字符串
- 再读入另一个字符串
- 将第一个读入的字符串作为键,第二个读入的字符串作为值,插入到新建立的哈希中。
REDIS_LIST
类型、 REDIS_HASH
类型和 REDIS_ZSET
类型都使用了 REDIS_ENCODING_ZIPLIST
编码, ziplist
在 RDB 中的保存方式如下:
+-----+---------+
| LEN | ZIPLIST |
+-----+---------+
载入时,读入器先读入 ziplist
的字节长,再根据该字节长读入数据,最后将数据还原成一个 ziplist
。
REDIS_ENCODING_INTSET
编码的 REDIS_SET
类型值保存为以下结构:
+-----+--------+
| LEN | INTSET |
+-----+--------+
载入时,读入器先读入 intset
的字节长度,再根据长度读入数据,最后将数据还原成 intset
。
EOF
标志着数据库内容的结尾(不是文件的结尾),值为 rdb.h/EDIS_RDB_OPCODE_EOF
(255
)。
CHECK-SUM
RDB 文件所有内容的校验和,
一个 uint_64t
类型值。
REDIS 在写入 RDB 文件时将校验和保存在 RDB 文件的末尾,
当读取时,
根据它的值对内容进行校验。
如果这个域的值为 0
,
那么表示 Redis 关闭了校验和功能。
小结
rdbSave
会将数据库数据保存到 RDB 文件,并在保存完成之前阻塞调用者。
SAVE 命令直接调用 rdbSave
,阻塞 Redis 主进程; BGSAVE 用子进程调用 rdbSave
,主进程仍可继续处理命令请求。
SAVE 执行期间, AOF 写入可以在后台线程进行, BGREWRITEAOF 可以在子进程进行,所以这三种操作可以同时进行。
为了避免产生竞争条件, BGSAVE 执行时, SAVE 命令不能执行。
为了避免性能问题, BGSAVE 和 BGREWRITEAOF 不能同时执行。
调用 rdbLoad
函数载入 RDB 文件时,不能进行任何和数据库相关的操作,不过订阅与发布方面的命令可以正常执行,因为它们和数据库不相关联。
RDB 文件的组织方式如下:
+-------+-------------+-----------+-----------------+-----+-----------+
| REDIS | RDB-VERSION | SELECT-DB | KEY-VALUE-PAIRS | EOF | CHECK-SUM |
+-------+-------------+-----------+-----------------+-----+-----------+
|<-------- DB-DATA ---------->|
键值对在 RDB 文件中的组织方式如下:
+----------------------+---------------+-----+-------+
| OPTIONAL-EXPIRE-TIME | TYPE-OF-VALUE | KEY | VALUE |
+----------------------+---------------+-----+-------+
RDB 文件使用不同的格式来保存不同类型的值。
AOF
Redis 分别提供了 RDB 和 AOF 两种持久化机制:
- RDB 将数据库的快照(snapshot)以二进制的方式保存到磁盘中。
- AOF 则以协议文本的方式,将所有对数据库进行过写入的命令(及其参数)记录到 AOF 文件,以此达到记录数据库状态的目的。
本章首先介绍 AOF 功能的运作机制,
了解命令是如何被保存到 AOF 文件里的,
观察不同的 AOF 保存模式对数据的安全性、以及 Redis 性能的影响。
之后会介绍从 AOF 文件中恢复数据库状态的方法,以及该方法背后的实现机制。
最后还会介绍对 AOF 进行重写以调整文件体积的方法,
并研究这种方法是如何在不改变数据库状态的前提下进行的。
因为本章涉及 AOF 运行的相关机制,
如果还没了解过 AOF 功能的话,
请先阅读 Redis 持久化手册中关于 AOF 的部分 。
AOF 命令同步
Redis 将所有对数据库进行过写入的命令(及其参数)记录到 AOF 文件,
以此达到记录数据库状态的目的,
为了方便起见,
我们称呼这种记录过程为同步。
举个例子,
如果执行以下命令:
redis> RPUSH list 1 2 3 4
(integer) 4
redis> LRANGE list 0 -1
1) "1"
2) "2"
3) "3"
4) "4"
redis> KEYS *
1) "list"
redis> RPOP list
"4"
redis> LPOP list
"1"
redis> LPUSH list 1
(integer) 3
redis> LRANGE list 0 -1
1) "1"
2) "2"
3) "3"
那么其中四条对数据库有修改的写入命令就会被同步到 AOF 文件中:
RPUSH list 1 2 3 4
RPOP list
LPOP list
LPUSH list 1
为了处理的方便,
AOF 文件使用网络通讯协议的格式来保存这些命令。
比如说,
上面列举的四个命令在 AOF 文件中就实际保存如下:
*2
$6
SELECT
$1
0
*6
$5
RPUSH
$4
list
$1
1
$1
2
$1
3
$1
4
*2
$4
RPOP
$4
list
*2
$4
LPOP
$4
list
*3
$5
LPUSH
$4
list
$1
1
除了 SELECT 命令是 AOF 程序自己加上去的之外,
其他命令都是之前我们在终端里执行的命令。
同步命令到 AOF 文件的整个过程可以分为三个阶段:
- 命令传播:Redis 将执行完的命令、命令的参数、命令的参数个数等信息发送到 AOF 程序中。
- 缓存追加:AOF 程序根据接收到的命令数据,将命令转换为网络通讯协议的格式,然后将协议内容追加到服务器的 AOF 缓存中。
- 文件写入和保存:AOF 缓存中的内容被写入到 AOF 文件末尾,如果设定的 AOF 保存条件被满足的话,
fsync
函数或者 fdatasync
函数会被调用,将写入的内容真正地保存到磁盘中。
以下几个小节将详细地介绍这三个步骤。
命令传播
当一个 Redis 客户端需要执行命令时,
它通过网络连接,
将协议文本发送给 Redis 服务器。
比如说,
要执行命令 SET KEY VALUE
,
客户端将向服务器发送文本 "*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n"
。
服务器在接到客户端的请求之后,
它会根据协议文本的内容,
选择适当的命令函数,
并将各个参数从字符串文本转换为 Redis 字符串对象(StringObject
)。
比如说,
针对上面的 SET 命令例子,
Redis 将客户端的命令指针指向实现 SET 命令的 setCommand
函数,
并创建三个 Redis 字符串对象,
分别保存 SET
、 KEY
和 VALUE
三个参数(命令也算作参数)。
每当命令函数成功执行之后,
命令参数都会被传播到 AOF 程序,
以及 REPLICATION 程序(本节不讨论这个,列在这里只是为了完整性的考虑)。
这个执行并传播命令的过程可以用以下伪代码表示:
if (execRedisCommand(cmd, argv, argc) == EXEC_SUCCESS):
if aof_is_turn_on():
# 传播命令到 AOF 程序
propagate_aof(cmd, argv, argc)
if replication_is_turn_on():
# 传播命令到 REPLICATION 程序
propagate_replication(cmd, argv, argc)
以下是该过程的流程图:
缓存追加
当命令被传播到 AOF 程序之后,
程序会根据命令以及命令的参数,
将命令从字符串对象转换回原来的协议文本。
比如说,
如果 AOF 程序接受到的三个参数分别保存着 SET
、 KEY
和 VALUE
三个字符串,
那么它将生成协议文本 "*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n"
。
协议文本生成之后,
它会被追加到 redis.h/redisServer
结构的 aof_buf
末尾。
redisServer
结构维持着 Redis 服务器的状态,
aof_buf
域则保存着所有等待写入到 AOF 文件的协议文本:
struct redisServer {
// 其他域...
sds aof_buf;
// 其他域...
};
至此,
追加命令到缓存的步骤执行完毕。
综合起来,整个缓存追加过程可以分为以下三步:
- 接受命令、命令的参数、以及参数的个数、所使用的数据库等信息。
- 将命令还原成 Redis 网络通讯协议。
- 将协议文本追加到
aof_buf
末尾。
文件写入和保存
每当服务器常规任务函数被执行、
或者事件处理器被执行时,
aof.c/flushAppendOnlyFile
函数都会被调用,
这个函数执行以下两个工作:
WRITE:根据条件,将 aof_buf
中的缓存写入到 AOF 文件。
SAVE:根据条件,调用 fsync
或 fdatasync
函数,将 AOF 文件保存到磁盘中。
两个步骤都需要根据一定的条件来执行,
而这些条件由 AOF 所使用的保存模式来决定,
以下小节就来介绍 AOF 所使用的三种保存模式,
以及在这些模式下,
步骤 WRITE 和 SAVE 的调用条件。
AOF 保存模式
Redis 目前支持三种 AOF 保存模式,它们分别是:
AOF_FSYNC_NO
:不保存。
AOF_FSYNC_EVERYSEC
:每一秒钟保存一次。
AOF_FSYNC_ALWAYS
:每执行一个命令保存一次。
以下三个小节将分别讨论这三种保存模式。
不保存
在这种模式下,
每次调用 flushAppendOnlyFile
函数,
WRITE 都会被执行,
但 SAVE 会被略过。
在这种模式下, SAVE 只会在以下任意一种情况中被执行:
- Redis 被关闭
- AOF 功能被关闭
- 系统的写缓存被刷新(可能是缓存已经被写满,或者定期保存操作被执行)
这三种情况下的 SAVE 操作都会引起 Redis 主进程阻塞。
每一秒钟保存一次
在这种模式中,
SAVE 原则上每隔一秒钟就会执行一次,
因为 SAVE 操作是由后台子线程调用的,
所以它不会引起服务器主进程阻塞。
注意,
在上一句的说明里面使用了词语“原则上”,
在实际运行中,
程序在这种模式下对 fsync
或 fdatasync
的调用并不是每秒一次,
它和调用 flushAppendOnlyFile
函数时 Redis 所处的状态有关。
每当 flushAppendOnlyFile
函数被调用时,
可能会出现以下四种情况:
子线程正在执行 SAVE ,并且:
- 这个 SAVE 的执行时间未超过 2 秒,那么程序直接返回,并不执行 WRITE 或新的 SAVE 。
- 这个 SAVE 已经执行超过 2 秒,那么程序执行 WRITE ,但不执行新的 SAVE 。注意,因为这时 WRITE 的写入必须等待子线程先完成(旧的) SAVE ,因此这里 WRITE 会比平时阻塞更长时间。
子线程没有在执行 SAVE ,并且:
- 上次成功执行 SAVE 距今不超过 1 秒,那么程序执行 WRITE ,但不执行 SAVE 。
- 上次成功执行 SAVE 距今已经超过 1 秒,那么程序执行 WRITE 和 SAVE 。
可以用流程图表示这四种情况:
根据以上说明可以知道,
在“每一秒钟保存一次”模式下,
如果在情况 1 中发生故障停机,
那么用户最多损失小于 2 秒内所产生的所有数据。
如果在情况 2 中发生故障停机,
那么用户损失的数据是可以超过 2 秒的。
Redis 官网上所说的,
AOF 在“每一秒钟保存一次”时发生故障,
只丢失 1 秒钟数据的说法,
实际上并不准确。
每执行一个命令保存一次
在这种模式下,每次执行完一个命令之后, WRITE 和 SAVE 都会被执行。
另外,因为 SAVE 是由 Redis 主进程执行的,所以在 SAVE 执行期间,主进程会被阻塞,不能接受命令请求。
AOF 保存模式对性能和安全性的影响
在上一个小节,
我们简短地描述了三种 AOF 保存模式的工作方式,
现在,
是时候研究一下这三个模式在安全性和性能方面的区别了。
对于三种 AOF 保存模式,
它们对服务器主进程的阻塞情况如下:
- 不保存(
AOF_FSYNC_NO
):写入和保存都由主进程执行,两个操作都会阻塞主进程。
- 每一秒钟保存一次(
AOF_FSYNC_EVERYSEC
):写入操作由主进程执行,阻塞主进程。保存操作由子线程执行,不直接阻塞主进程,但保存操作完成的快慢会影响写入操作的阻塞时长。
- 每执行一个命令保存一次(
AOF_FSYNC_ALWAYS
):和模式 1 一样。
因为阻塞操作会让 Redis 主进程无法持续处理请求,
所以一般说来,
阻塞操作执行得越少、完成得越快,
Redis 的性能就越好。
模式 1 的保存操作只会在AOF 关闭或 Redis 关闭时执行,
或者由操作系统触发,
在一般情况下,
这种模式只需要为写入阻塞,
因此它的写入性能要比后面两种模式要高,
当然,
这种性能的提高是以降低安全性为代价的:
在这种模式下,
如果运行的中途发生停机,
那么丢失数据的数量由操作系统的缓存冲洗策略决定。
模式 2 在性能方面要优于模式 3 ,
并且在通常情况下,
这种模式最多丢失不多于 2 秒的数据,
所以它的安全性要高于模式 1 ,
这是一种兼顾性能和安全性的保存方案。
模式 3 的安全性是最高的,
但性能也是最差的,
因为服务器必须阻塞直到命令信息被写入并保存到磁盘之后,
才能继续处理请求。
综合起来,三种 AOF 模式的操作特性可以总结如下:
模式 |
WRITE 是否阻塞? |
SAVE 是否阻塞? |
停机时丢失的数据量 |
AOF_FSYNC_NO |
阻塞 |
阻塞 |
操作系统最后一次对 AOF 文件触发 SAVE 操作之后的数据。 |
AOF_FSYNC_EVERYSEC |
阻塞 |
不阻塞 |
一般情况下不超过 2 秒钟的数据。 |
AOF_FSYNC_ALWAYS |
阻塞 |
阻塞 |
最多只丢失一个命令的数据。 |
AOF 文件的读取和数据还原
AOF 文件保存了 Redis 的数据库状态,
而文件里面包含的都是符合 Redis 通讯协议格式的命令文本。
这也就是说,
只要根据 AOF 文件里的协议,
重新执行一遍里面指示的所有命令,
就可以还原 Redis 的数据库状态了。
Redis 读取 AOF 文件并还原数据库的详细步骤如下:
- 创建一个不带网络连接的伪客户端(fake client)。
- 读取 AOF 所保存的文本,并根据内容还原出命令、命令的参数以及命令的个数。
- 根据命令、命令的参数和命令的个数,使用伪客户端执行该命令。
- 执行 2 和 3 ,直到 AOF 文件中的所有命令执行完毕。
完成第 4 步之后,
AOF 文件所保存的数据库就会被完整地还原出来。
注意,
因为 Redis 的命令只能在客户端的上下文中被执行,
而 AOF 还原时所使用的命令来自于 AOF 文件,
而不是网络,
所以程序使用了一个没有网络连接的伪客户端来执行命令。
伪客户端执行命令的效果,
和带网络连接的客户端执行命令的效果,
完全一样。
整个读取和还原过程可以用以下伪代码表示:
def READ_AND_LOAD_AOF():
# 打开并读取 AOF 文件
file = open(aof_file_name)
while file.is_not_reach_eof():
# 读入一条协议文本格式的 Redis 命令
cmd_in_text = file.read_next_command_in_protocol_format()
# 根据文本命令,查找命令函数,并创建参数和参数个数等对象
cmd, argv, argc = text_to_command(cmd_in_text)
# 执行命令
execRedisCommand(cmd, argv, argc)
# 关闭文件
file.close()
作为例子,
以下是一个简短的 AOF 文件的内容:
*2
$6
SELECT
$1
0
*3
$3
SET
$3
key
$5
value
*8
$5
RPUSH
$4
list
$1
1
$1
2
$1
3
$1
4
$1
5
$1
6
当程序读入这个 AOF 文件时,
它首先执行 SELECT 0
命令 ——
这个 SELECT
命令是由 AOF 写入程序自动生成的,
它确保程序可以将数据还原到正确的数据库上。
然后执行后面的 SET key value
和 RPUSH 1 2 3 4
命令,
还原 key
和 list
两个键的数据。
Note
为了避免对数据的完整性产生影响,
在服务器载入数据的过程中,
只有和数据库无关的订阅与发布功能可以正常使用,
其他命令一律返回错误。
AOF 重写
AOF 文件通过同步 Redis 服务器所执行的命令,
从而实现了数据库状态的记录,
但是,
这种同步方式会造成一个问题:
随着运行时间的流逝,
AOF 文件会变得越来越大。
举个例子,
如果服务器执行了以下命令:
RPUSH list 1 2 3 4 // [1, 2, 3, 4]
RPOP list // [1, 2, 3]
LPOP list // [2, 3]
LPUSH list 1 // [1, 2, 3]
那么光是记录 list
键的状态,
AOF 文件就需要保存四条命令。
另一方面,
有些被频繁操作的键,
对它们所调用的命令可能有成百上千、甚至上万条,
如果这样被频繁操作的键有很多的话,
AOF 文件的体积就会急速膨胀,
对 Redis 、甚至整个系统的造成影响。
为了解决以上的问题,
Redis 需要对 AOF 文件进行重写(rewrite):
创建一个新的 AOF 文件来代替原有的 AOF 文件,
新 AOF 文件和原有 AOF 文件保存的数据库状态完全一样,
但新 AOF 文件的体积小于等于原有 AOF 文件的体积。
以下就来介绍 AOF 重写的实现方式。
AOF 重写的实现
所谓的“重写”其实是一个有歧义的词语,
实际上,
AOF 重写并不需要对原有的 AOF 文件进行任何写入和读取,
它针对的是数据库中键的当前值。
考虑这样一个情况,
如果服务器对键 list
执行了以下四条命令:
RPUSH list 1 2 3 4 // [1, 2, 3, 4]
RPOP list // [1, 2, 3]
LPOP list // [2, 3]
LPUSH list 1 // [1, 2, 3]
那么当前列表键 list
在数据库中的值就为 [1, 2, 3]
。
如果我们要保存这个列表的当前状态,
并且尽量减少所使用的命令数,
那么最简单的方式不是去 AOF 文件上分析前面执行的四条命令,
而是直接读取 list
键在数据库的当前值,
然后用一条 RPUSH 1 2 3
命令来代替前面的四条命令。
再考虑这样一个例子,
如果服务器对集合键 animal
执行了以下命令:
SADD animal cat // {cat}
SADD animal dog panda tiger // {cat, dog, panda, tiger}
SREM animal cat // {dog, panda, tiger}
SADD animal cat lion // {cat, lion, dog, panda, tiger}
那么使用一条 SADD animal cat lion dog panda tiger
命令,
就可以还原 animal
集合的状态,
这比之前的四条命令调用要大大减少。
除了列表和集合之外,
字符串、有序集、哈希表等键也可以用类似的方法来保存状态,
并且保存这些状态所使用的命令数量,
比起之前建立这些键的状态所使用命令的数量要大大减少。
根据键的类型,
使用适当的写入命令来重现键的当前值,
这就是 AOF 重写的实现原理。
整个重写过程可以用伪代码表示如下:
def AOF_REWRITE(tmp_tile_name):
f = create(tmp_tile_name)
# 遍历所有数据库
for db in redisServer.db:
# 如果数据库为空,那么跳过这个数据库
if db.is_empty(): continue
# 写入 SELECT 命令,用于切换数据库
f.write_command("SELECT " + db.number)
# 遍历所有键
for key in db:
# 如果键带有过期时间,并且已经过期,那么跳过这个键
if key.have_expire_time() and key.is_expired(): continue
if key.type == String:
# 用 SET key value 命令来保存字符串键
value = get_value_from_string(key)
f.write_command("SET " + key + value)
elif key.type == List:
# 用 RPUSH key item1 item2 ... itemN 命令来保存列表键
item1, item2, ..., itemN = get_item_from_list(key)
f.write_command("RPUSH " + key + item1 + item2 + ... + itemN)
elif key.type == Set:
# 用 SADD key member1 member2 ... memberN 命令来保存集合键
member1, member2, ..., memberN = get_member_from_set(key)
f.write_command("SADD " + key + member1 + member2 + ... + memberN)
elif key.type == Hash:
# 用 HMSET key field1 value1 field2 value2 ... fieldN valueN 命令来保存哈希键
field1, value1, field2, value2, ..., fieldN, valueN =\
get_field_and_value_from_hash(key)
f.write_command("HMSET " + key + field1 + value1 + field2 + value2 +\
... + fieldN + valueN)
elif key.type == SortedSet:
# 用 ZADD key score1 member1 score2 member2 ... scoreN memberN
# 命令来保存有序集键
score1, member1, score2, member2, ..., scoreN, memberN = \
get_score_and_member_from_sorted_set(key)
f.write_command("ZADD " + key + score1 + member1 + score2 + member2 +\
... + scoreN + memberN)
else:
raise_type_error()
# 如果键带有过期时间,那么用 EXPIREAT key time 命令来保存键的过期时间
if key.have_expire_time():
f.write_command("EXPIREAT " + key + key.expire_time_in_unix_timestamp())
# 关闭文件
f.close()
AOF 后台重写
上一节展示的 AOF 重写程序可以很好地完成创建一个新 AOF 文件的任务,
但是,
在执行这个程序的时候,
调用者线程会被阻塞。
很明显,
作为一种辅佐性的维护手段,
Redis 不希望 AOF 重写造成服务器无法处理请求,
所以 Redis 决定将 AOF 重写程序放到(后台)子进程里执行,
这样处理的最大好处是:
- 子进程进行 AOF 重写期间,主进程可以继续处理命令请求。
- 子进程带有主进程的数据副本,使用子进程而不是线程,可以在避免锁的情况下,保证数据的安全性。
不过,
使用子进程也有一个问题需要解决:
因为子进程在进行 AOF 重写期间,
主进程还需要继续处理命令,
而新的命令可能对现有的数据进行修改,
这会让当前数据库的数据和重写后的 AOF 文件中的数据不一致。
为了解决这个问题,
Redis 增加了一个 AOF 重写缓存,
这个缓存在 fork 出子进程之后开始启用,
Redis 主进程在接到新的写命令之后,
除了会将这个写命令的协议内容追加到现有的 AOF 文件之外,
还会追加到这个缓存中:
换言之,
当子进程在执行 AOF 重写时,
主进程需要执行以下三个工作:
- 处理命令请求。
- 将写命令追加到现有的 AOF 文件中。
- 将写命令追加到 AOF 重写缓存中。
这样一来可以保证:
- 现有的 AOF 功能会继续执行,即使在 AOF 重写期间发生停机,也不会有任何数据丢失。
- 所有对数据库进行修改的命令都会被记录到 AOF 重写缓存中。
当子进程完成 AOF 重写之后,
它会向父进程发送一个完成信号,
父进程在接到完成信号之后,
会调用一个信号处理函数,
并完成以下工作:
- 将 AOF 重写缓存中的内容全部写入到新 AOF 文件中。
- 对新的 AOF 文件进行改名,覆盖原有的 AOF 文件。
当步骤 1 执行完毕之后,
现有 AOF 文件、新 AOF 文件和数据库三者的状态就完全一致了。
当步骤 2 执行完毕之后,
程序就完成了新旧两个 AOF 文件的交替。
这个信号处理函数执行完毕之后,
主进程就可以继续像往常一样接受命令请求了。
在整个 AOF 后台重写过程中,
只有最后的写入缓存和改名操作会造成主进程阻塞,
在其他时候,
AOF 后台重写都不会对主进程造成阻塞,
这将 AOF 重写对性能造成的影响降到了最低。
以上就是 AOF 后台重写,
也即是 BGREWRITEAOF 命令的工作原理。
AOF 后台重写的触发条件
AOF 重写可以由用户通过调用 BGREWRITEAOF 手动触发。
另外,
服务器在 AOF 功能开启的情况下,
会维持以下三个变量:
- 记录当前 AOF 文件大小的变量
aof_current_size
。
- 记录最后一次 AOF 重写之后, AOF 文件大小的变量
aof_rewrite_base_size
。
- 增长百分比变量
aof_rewrite_perc
。
每次当 serverCron
函数执行时,
它都会检查以下条件是否全部满足,
如果是的话,
就会触发自动的 AOF 重写:
- 没有 BGSAVE 命令在进行。
- 没有 BGREWRITEAOF 在进行。
- 当前 AOF 文件大小大于
server.aof_rewrite_min_size
(默认值为 1 MB)。
- 当前 AOF 文件大小和最后一次 AOF 重写后的大小之间的比率大于等于指定的增长百分比。
默认情况下,
增长百分比为 100%
,
也即是说,
如果前面三个条件都已经满足,
并且当前 AOF 文件大小比最后一次 AOF 重写时的大小要大一倍的话,
那么触发自动 AOF 重写。
小结
- AOF 文件通过保存所有修改数据库的命令来记录数据库的状态。
- AOF 文件中的所有命令都以 Redis 通讯协议的格式保存。
- 不同的 AOF 保存模式对数据的安全性、以及 Redis 的性能有很大的影响。
- AOF 重写的目的是用更小的体积来保存数据库状态,整个重写过程基本上不影响 Redis 主进程处理命令请求。
- AOF 重写是一个有歧义的名字,实际的重写工作是针对数据库的当前值来进行的,程序既不读写、也不使用原有的 AOF 文件。
- AOF 可以由用户手动触发,也可以由服务器自动触发。
事件
事件是 Redis 服务器的核心,
它处理两项重要的任务:
- 处理文件事件:在多个客户端中实现多路复用,接受它们发来的命令请求,并将命令的执行结果返回给客户端。
- 时间事件:实现服务器常规操作(server cron job)。
本文以下内容就来介绍这两种事件,
以及它们背后的运作模式。
文件事件
Redis 服务器通过在多个客户端之间进行多路复用,
从而实现高效的命令请求处理:
多个客户端通过套接字连接到 Redis 服务器中,
但只有在套接字可以无阻塞地进行读或者写时,
服务器才会和这些客户端进行交互。
Redis 将这类因为对套接字进行多路复用而产生的事件称为文件事件(file event),
文件事件可以分为读事件和写事件两类。
读事件
读事件标志着客户端命令请求的发送状态。
当一个新的客户端连接到服务器时,
服务器会给为该客户端绑定读事件,
直到客户端断开连接之后,
这个读事件才会被移除。
读事件在整个网络连接的生命期内,
都会在等待和就绪两种状态之间切换:
- 当客户端只是连接到服务器,但并没有向服务器发送命令时,该客户端的读事件就处于等待状态。
- 当客户端给服务器发送命令请求,并且请求已到达时(相应的套接字可以无阻塞地执行读操作),该客户端的读事件处于就绪状态。
作为例子,
下图展示了三个已连接到服务器、但并没有发送命令的客户端:
这三个客户端的状态如下表:
客户端 |
读事件状态 |
命令发送状态 |
客户端 X |
等待 |
未发送 |
客户端 Y |
等待 |
未发送 |
客户端 Z |
等待 |
未发送 |
之后,
当客户端 X 向服务器发送命令请求,
并且命令请求已到达时,
客户端 X 的读事件状态变为就绪:
这时,
三个客户端的状态如下表(只有客户端 X 的状态被更新了):
客户端 |
读事件状态 |
命令发送状态 |
客户端 X |
就绪 |
已发送,并且已到达 |
客户端 Y |
等待 |
未发送 |
客户端 Z |
等待 |
未发送 |
当事件处理器被执行时,
就绪的文件事件会被识别到,
相应的命令请求会被发送到命令执行器,
并对命令进行求值。
写事件
写事件标志着客户端对命令结果的接收状态。
和客户端自始至终都关联着读事件不同,
服务器只会在有命令结果要传回给客户端时,
才会为客户端关联写事件,
并且在命令结果传送完毕之后,
客户端和写事件的关联就会被移除。
一个写事件会在两种状态之间切换:
- 当服务器有命令结果需要返回给客户端,但客户端还未能执行无阻塞写,那么写事件处于等待状态。
- 当服务器有命令结果需要返回给客户端,并且客户端可以进行无阻塞写,那么写事件处于就绪状态。
当客户端向服务器发送命令请求,
并且请求被接受并执行之后,
服务器就需要将保存在缓存内的命令执行结果返回给客户端,
这时服务器就会为客户端关联写事件。
作为例子,
下图展示了三个连接到服务器的客户端,
其中服务器正等待客户端 X 变得可写,
从而将命令的执行结果返回给它:
此时三个客户端的事件状态分别如下表:
客户端 |
读事件状态 |
写事件状态 |
客户端 X |
等待 |
等待 |
客户端 Y |
等待 |
无 |
客户端 Z |
等待 |
无 |
当客户端 X 的套接字可以进行无阻塞写操作时,
写事件就绪,
服务器将保存在缓存内的命令执行结果返回给客户端:
此时三个客户端的事件状态分别如下表(只有客户端 X 的状态被更新了):
客户端 |
读事件状态 |
写事件状态 |
客户端 X |
等待 |
已就绪 |
客户端 Y |
等待 |
无 |
客户端 Z |
等待 |
无 |
当命令执行结果被传送回客户端之后,
客户端和写事件之间的关联会被解除(只剩下读事件),
至此,
返回命令执行结果的动作执行完毕:
Note
同时关联写事件和读事件
前面提到过,读事件只有在客户端断开和服务器的连接时,才会被移除。
这也就是说,当客户端关联写事件的时候,实际上它在同时关联读/写两种事件。
因为在同一次文件事件处理器的调用中,
单个客户端只能执行其中一种事件(要么读,要么写,但不能又读又写),
当出现读事件和写事件同时就绪的情况时,
事件处理器优先处理读事件。
这也就是说,
当服务器有命令结果要返回客户端,
而客户端又有新命令请求进入时,
服务器先处理新命令请求。
时间事件
时间事件记录着那些要在指定时间点运行的事件,
多个时间事件以无序链表的形式保存在服务器状态中。
每个时间事件主要由三个属性组成:
when
:以毫秒格式的 UNIX 时间戳为单位,记录了应该在什么时间点执行事件处理函数。
timeProc
:事件处理函数。
next
指向下一个时间事件,形成链表。
根据 timeProc
函数的返回值,可以将时间事件划分为两类:
- 如果事件处理函数返回
ae.h/AE_NOMORE
,那么这个事件为单次执行事件:该事件会在指定的时间被处理一次,之后该事件就会被删除,不再执行。
- 如果事件处理函数返回一个非
AE_NOMORE
的整数值,那么这个事件为循环执行事件:该事件会在指定的时间被处理,之后它会按照事件处理函数的返回值,更新事件的 when
属性,让这个事件在之后的某个时间点再次运行,并以这种方式一直更新并运行下去。
可以用伪代码来表示这两种事件的处理方式:
def handle_time_event(server, time_event):
# 执行事件处理器,并获取返回值
# 返回值可以是 AE_NOMORE ,或者一个表示毫秒数的非符整数值
retval = time_event.timeProc()
if retval == AE_NOMORE:
# 如果返回 AE_NOMORE ,那么将事件从链表中删除,不再执行
server.time_event_linked_list.delete(time_event)
else:
# 否则,更新事件的 when 属性
# 让它在当前时间之后的 retval 毫秒之后再次运行
time_event.when = unix_ts_in_ms() + retval
当时间事件处理器被执行时,
它遍历所有链表中的时间事件,
检查它们的到达事件(when
属性),
并执行其中的已到达事件:
def process_time_event(server):
# 遍历时间事件链表
for time_event in server.time_event_linked_list:
# 检查事件是否已经到达
if time_event.when <= unix_ts_in_ms():
# 处理已到达事件
handle_time_event(server, time_event)
Note
无序链表并不影响时间事件处理器的性能
在目前的版本中,
正常模式下的 Redis 只带有 serverCron
一个时间事件,
而在 benchmark 模式下,
Redis 也只使用两个时间事件。
在这种情况下,
程序几乎是将无序链表退化成一个指针来使用,
所以使用无序链表来保存时间事件,
并不影响事件处理器的性能。
时间事件应用实例:服务器常规操作
对于持续运行的服务器来说,
服务器需要定期对自身的资源和状态进行必要的检查和整理,
从而让服务器维持在一个健康稳定的状态,
这类操作被统称为常规操作(cron job)。
在 Redis 中,
常规操作由 redis.c/serverCron
实现,
它主要执行以下操作:
- 更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。
- 清理数据库中的过期键值对。
- 对不合理的数据库进行大小调整。
- 关闭和清理连接失效的客户端。
- 尝试进行 AOF 或 RDB 持久化操作。
- 如果服务器是主节点的话,对附属节点进行定期同步。
- 如果处于集群模式的话,对集群进行定期同步和连接测试。
Redis 将 serverCron
作为时间事件来运行,
从而确保它每隔一段时间就会自动运行一次,
又因为 serverCron
需要在 Redis 服务器运行期间一直定期运行,
所以它是一个循环时间事件:
serverCron
会一直定期执行,直到服务器关闭为止。
在 Redis 2.6 版本中,
程序规定 serverCron
每秒运行 10
次,
平均每 100
毫秒运行一次。
从 Redis 2.8 开始,
用户可以通过修改 hz
选项来调整 serverCron
的每秒执行次数,
具体信息请参考 redis.conf
文件中关于 hz
选项的说明。
事件的执行与调度
既然 Redis 里面既有文件事件,
又有时间事件,
那么如何调度这两种事件就成了一个关键问题。
简单地说,
Redis 里面的两种事件呈合作关系,
它们之间包含以下三种属性:
- 一种事件会等待另一种事件执行完毕之后,才开始执行,事件之间不会出现抢占。
- 事件处理器先处理文件事件(处理命令请求),再执行时间事件(调用
serverCron
)
- 文件事件的等待时间(类
poll
函数的最大阻塞时间),由距离到达时间最短的时间事件决定。
这些属性表明,
实际处理时间事件的时间,
通常会比时间事件所预定的时间要晚,
至于延迟的时间有多长,
取决于时间事件执行之前,
执行文件事件所消耗的时间。
比如说,
以下图表就展示了,
虽然时间事件 TE 1
预定在 t1
时间执行,
但因为文件事件 FE 1
正在运行,
所以 TE 1
的执行被延迟了:
t1
|
V
time -----------------+------------------->|
| FE 1 | TE 1 |
|<------>|
TE 1
delay
time
另外,
对于像 serverCron
这类循环执行的时间事件来说,
如果事件处理器的返回值是 t
,
那么 Redis 只保证:
- 如果两次执行时间事件处理器之间的时间间隔大于等于
t
, 那么这个时间事件至少会被处理一次。
- 而并不是说, 每隔
t
时间, 就一定要执行一次事件 —— 这对于不使用抢占调度的 Redis 事件处理器来说,也是不可能做到的
举个例子,
虽然 serverCron
(sC
)设定的间隔为 10
毫秒,
但它并不是像如下那样每隔 10
毫秒就运行一次:
time ----------------------------------------------------->|
|<---- 10 ms ---->|<---- 10 ms ---->|<---- 10 ms ---->|
| FE 1 | FE 2 | sC 1 | FE 3 | sC 2 | FE 4 |
^ ^ ^ ^ ^
| | | | |
file event time event | time event |
handler handler | handler |
run run | run |
file event file event
handler handler
run run
在实际中,
serverCron
的运行方式更可能是这样子的:
time ----------------------------------------------------------------------->|
|<---- 10 ms ---->|<---- 10 ms ---->|<---- 10 ms ---->|<---- 10 ms ---->|
| FE 1 | FE 2 | sC 1 | FE 3 | FE 4 | FE 5 | sC 2 |
|<-------- 15 ms -------->| |<------- 12 ms ------->|
>= 10 ms >= 10 ms
^ ^ ^ ^
| | | |
file event time event | time event
handler handler | handler
run run | run
file event
handler
run
根据情况,
如果处理文件事件耗费了非常多的时间,
serverCron
被推迟到一两秒之后才能执行,
也是有可能的。
整个事件处理器程序可以用以下伪代码描述:
def process_event():
# 获取执行时间最接近现在的一个时间事件
te = get_nearest_time_event(server.time_event_linked_list)
# 检查该事件的执行时间和现在时间之差
# 如果值 <= 0 ,那么说明至少有一个时间事件已到达
# 如果值 > 0 ,那么说明目前没有任何时间事件到达
nearest_te_remaind_ms = te.when - now_in_ms()
if nearest_te_remaind_ms <= 0:
# 如果有时间事件已经到达
# 那么调用不阻塞的文件事件等待函数
poll(timeout=None)
else:
# 如果时间事件还没到达
# 那么阻塞的最大时间不超过 te 的到达时间
poll(timeout=nearest_te_remaind_ms)
# 处理已就绪文件事件
process_file_events()
# 处理已到达时间事件
process_time_event()
通过这段代码,
可以清晰地看出:
- 到达时间最近的时间事件,决定了
poll
的最大阻塞时长。
- 文件事件先于时间事件处理。
将这个事件处理函数置于一个循环中,
加上初始化和清理函数,
这就构成了 Redis 服务器的主函数调用:
def redis_main():
# 初始化服务器
init_server()
# 一直处理事件,直到服务器关闭为止
while server_is_not_shutdown():
process_event()
# 清理服务器
clean_server()
小结
- Redis 的事件分为时间事件和文件事件两类。
- 文件事件分为读事件和写事件两类:读事件实现了命令请求的接收,写事件实现了命令结果的返回。
- 时间事件分为单次执行事件和循环执行事件,服务器常规操作
serverCron
就是循环事件。
- 文件事件和时间事件之间是合作关系:一种事件会等待另一种事件完成之后再执行,不会出现抢占情况。
- 时间事件的实际执行时间通常会比预定时间晚一些。
服务器与客户端
前面的章节介绍了所有 Redis 的重要功能组件:
数据结构、数据类型、事务、Lua 环境、事件处理、数据库、持久化,
等等,
但是我们还没有对 Redis 服务器本身做任何介绍。
不过,
服务器本身并没有多少需要介绍的新东西,
因为服务器除了维持服务器状态之外,
最重要的就是将前面介绍过的各个功能模块组合起来,
而这些功能模块在前面的章节里已经介绍过了,
所以本章将焦点放在服务器的初始化过程,
以及服务器对命令的处理过程上。
本章首先介绍服务器的初始化操作,
观察一个 Redis 服务器从启动到可以接受客户端连接,
需要经过什么步骤。
然后介绍客户端是如何连接到服务器的,
而服务器又是如何维持多个客户端的不同状态的。
文章最后将介绍命令从发送到处理的整个过程,
并列举了一个 SET
命令的执行过程作为例子。
初始化服务器
从启动 Redis 服务器,
到服务器可以接受外来客户端的网络连接这段时间,
Redis 需要执行一系列初始化操作。
整个初始化过程可以分为以下六个步骤:
- 初始化服务器全局状态。
- 载入配置文件。
- 创建 daemon 进程。
- 初始化服务器功能模块。
- 载入数据。
- 开始事件循环。
以下各个小节将介绍 Redis 服务器初始化的各个步骤。
1. 初始化服务器全局状态
redis.h/redisServer
结构记录了和服务器相关的所有数据,
这个结构主要包含以下信息:
- 服务器中的所有数据库。
- 命令表:在执行命令时,根据字符来查找相应命令的实现函数。
- 事件状态。
- 服务器的网络连接信息:套接字地址、端口,以及套接字描述符。
- 所有已连接客户端的信息。
- Lua 脚本的运行环境及相关选项。
- 实现订阅与发布(pub/sub)功能所需的数据结构。
- 日志(log)和慢查询日志(slowlog)的选项和相关信息。
- 数据持久化(AOF 和 RDB)的配置和状态。
- 服务器配置选项:比如要创建多少个数据库,是否将服务器进程作为 daemon 进程来运行,最大连接多少个客户端,压缩结构(zip structure)的实体数量,等等。
- 统计信息:比如键有多少次命令、不命中,服务器的运行时间,内存占用,等等。
Note
为了简洁起见,上面只列出了单机情况下的 Redis 服务器信息,不包含 SENTINEL 、 MONITOR 、 CLUSTER 等功能的信息。
在这一步,
程序创建一个 redisServer
结构的实例变量 server
用作服务器的全局状态,
并将 server
的各个属性初始化为默认值。
当 server
变量的初始化完成之后,
程序进入服务器初始化的下一步:
读入配置文件。
2. 载入配置文件
在初始化服务器的上一步中,
程序为 server
变量(也即是服务器状态)的各个属性设置了默认值,
但这些默认值有时候并不是最合适的:
- 用户可能想使用 AOF 持久化,而不是默认的 RDB 持久化。
- 用户可能想用其他端口来运行 Redis ,以避免端口冲突。
- 用户可能不想使用默认的 16 个数据库,而是分配更多或更少数量的数据库。
- 用户可能想对默认的内存限制措施和回收策略做调整。
等等。
为了让使用者按自己的要求配置服务器,
Redis 允许用户在运行服务器时,
提供相应的配置文件(config file)或者显式的选项(option),
Redis 在初始化完 server
变量之后,
会读入配置文件和选项,
然后根据这些配置来对 server
变量的属性值做相应的修改:
如果单纯执行 redis-server
命令,那么服务器以默认的配置来运行 Redis 。
另一方面, 如果给 Redis 服务器送入一个配置文件, 那么 Redis 将按配置文件的设置来更新服务器的状态。
比如说, 通过命令 redis-server /etc/my-redis.conf
, Redis 会根据 my-redis.conf
文件的内容来对服务器状态做相应的修改。
除此之外, 还可以显式地给服务器传入选项, 直接修改服务器配置。
举个例子, 通过命令 redis-server --port 10086
, 可以让 Redis 服务器端口变更为 10086
。
当然, 同时使用配置文件和显式选项也是可以的, 如果文件和选项有冲突的地方, 那么优先使用选项所指定的配置值。
举个例子, 如果运行命令 redis-server /etc/my-redis.conf --port 10086
, 并且 my-redis.conf
也指定了 port
选项, 那么服务器将优先使用 --port 10086
(实际上是选项指定的值覆盖了配置文件中的值)。
3. 创建 daemon 进程
Redis 默认以 daemon 进程的方式运行。
当服务器初始化进行到这一步时,
程序将创建 daemon 进程来运行 Redis ,
并创建相应的 pid 文件。
4. 初始化服务器功能模块
在这一步,
初始化程序完成两件事:
- 为
server
变量的数据结构子属性分配内存。
- 初始化这些数据结构。
为数据结构分配内存,
并初始化这些数据结构,
等同于对相应的功能进行初始化。
比如说,
当为订阅与发布所需的链表分配内存之后,
订阅与发布功能就处于就绪状态,
随时可以为 Redis 所用了。
在这一步,
程序完成的主要动作如下:
- 初始化 Redis 进程的信号功能。
- 初始化日志功能。
- 初始化客户端功能。
- 初始化共享对象。
- 初始化事件功能。
- 初始化数据库。
- 初始化网络连接。
- 初始化订阅与发布功能。
- 初始化各个统计变量。
- 关联服务器常规操作(cron job)到时间事件,关联客户端应答处理器到文件事件。
- 如果 AOF 功能已打开,那么打开或创建 AOF 文件。
- 设置内存限制。
- 初始化 Lua 脚本环境。
- 初始化慢查询功能。
- 初始化后台操作线程。
完成这一步之后,
服务器打印出 Redis 的 ASCII LOGO 、服务器版本等信息,
表示所有功能模块已经就绪,
可以等待被使用了:
_._
_.-``__ ''-._
_.-`` `. `_. ''-._ Redis 2.9.7 (7a47887b/1) 32 bit
.-`` .-```. ```\/ _.,_ ''-._
( ' , .-` | `, ) Running in stand alone mode
|`-._`-...-` __...-.``-._|'` _.-'| Port: 6379
| `-._ `._ / _.-' | PID: 6717
`-._ `-._ `-./ _.-' _.-'
|`-._`-._ `-.__.-' _.-'_.-'|
| `-._`-._ _.-'_.-' | http://redis.io
`-._ `-._`-.__.-'_.-' _.-'
|`-._`-._ `-.__.-' _.-'_.-'|
| `-._`-._ _.-'_.-' |
`-._ `-._`-.__.-'_.-' _.-'
`-._ `-.__.-' _.-'
`-._ _.-'
`-.__.-'
虽然所有功能已经就绪,
但这时服务器的数据库还是一片空白,
程序还需要将服务器上一次执行时记录的数据载入到当前服务器中,
服务器的初始化才算真正完成。
5. 载入数据
在这一步,
程序需要将持久化在 RDB 或者 AOF 文件里的数据,
载入到服务器进程里面。
如果服务器有启用 AOF 功能的话,
那么使用 AOF 文件来还原数据;
否则,
程序使用 RDB 文件来还原数据。
当执行完这一步时,
服务器打印出一段载入完成信息:
[6717] 22 Feb 11:59:14.830 * DB loaded from disk: 0.068 seconds
6. 开始事件循环
到了这一步,
服务器的初始化已经完成,
程序打开事件循环,
开始接受客户端连接。
以下是服务器在这一步打印的信息:
[6717] 22 Feb 11:59:14.830 * The server is now ready to accept connections on port 6379
以下是初始化完成之后,
服务器状态和各个模块之间的关系图:
客户端连接到服务器
当 Redis 服务器完成初始化之后,
它就准备好可以接受外来客户端的连接了。
当一个客户端通过套接字函数 connect
到服务器时,
服务器执行以下步骤:
- 服务器通过文件事件无阻塞地
accept
客户端连接,并返回一个套接字描述符 fd
。
- 服务器为
fd
创建一个对应的 redis.h/redisClient
结构实例,并将该实例加入到服务器的已连接客户端的链表中。
- 服务器在事件处理器为该
fd
关联读文件事件。
完成这三步之后,服务器就可以等待客户端发来命令请求了。
Redis 以多路复用的方式来处理多个客户端,
为了让多个客户端之间独立分开、不互相干扰,
服务器为每个已连接客户端维持一个 redisClient
结构,
从而单独保存该客户端的状态信息。
redisClient
结构主要包含以下信息:
- 套接字描述符。
- 客户端正在使用的数据库指针和数据库号码。
- 客户端的查询缓冲(query buffer)和回复缓存(reply buffer)。
- 一个指向命令函数的指针,以及字符串形式的命令、命令参数和命令个数,这些属性会在命令执行时使用。
- 客户端状态:记录了客户端是否处于 SLAVE 、 MONITOR 或者事务状态。
- 实现事务功能(比如 MULTI 和 WATCH)所需的数据结构。
- 实现阻塞功能(比如 BLPOP 和 BRPOPLPUSH)所需的数据结构。
- 实现订阅与发布功能(比如 PUBLISH 和 SUBSCRIBE)所需的数据结构。
- 统计数据和选项:客户端创建的时间,客户端和服务器最后交互的时间,缓存的大小,等等。
Note
为了简洁起见,上面列出的客户端结构信息不包含复制(replication)的相关属性。
命令的请求、处理和结果返回
当客户端连上服务器之后,
客户端就可以向服务器发送命令请求了。
从客户端发送命令请求,
到命令被服务器处理、并将结果返回客户端,
整个过程有以下步骤:
- 客户端通过套接字向服务器传送命令协议数据。
- 服务器通过读事件来处理传入数据,并将数据保存在客户端对应
redisClient
结构的查询缓存中。
- 根据客户端查询缓存中的内容,程序从命令表中查找相应命令的实现函数。
- 程序执行命令的实现函数,修改服务器的全局状态
server
变量,并将命令的执行结果保存到客户端 redisClient
结构的回复缓存中,然后为该客户端的 fd
关联写事件。
- 当客户端
fd
的写事件就绪时,将回复缓存中的命令结果传回给客户端。至此,命令执行完毕。
命令请求实例: SET 的执行过程
为了更直观地理解命令执行的整个过程,
我们用一个实际执行 SET 命令的例子来讲解命令执行的过程。
假设现在客户端 C1 是连接到服务器 S 的一个客户端,
当用户执行命令 SET YEAR 2013
时,
客户端调用写入函数,
将协议内容 *3\r\n$3\r\nSET\r\n$4\r\nYEAR\r\n$4\r\n2013\r\n"
写入连接到服务器的套接字中。
当 S 的文件事件处理器执行时,
它会察觉到 C1 所对应的读事件已经就绪,
于是它将协议文本读入,
并保存在查询缓存。
通过对查询缓存进行分析(parse),
服务器在命令表中查找 SET
字符串所对应的命令实现函数,
最终定位到 t_string.c/setCommand
函数,
另外,
两个命令参数 YEAR
和 2013
也会以字符串的形式保存在客户端结构中。
接着,
程序将客户端、要执行的命令、命令参数等送入命令执行器:
执行器调用 setCommand
函数,
将数据库中 YEAR
键的值修改为 2013
,
然后将命令的执行结果保存在客户端的回复缓存中,
并为客户端 fd
关联写事件,
用于将结果回写给客户端。
因为 YEAR
键的修改,
其他和数据库命名空间相关程序,
比如 AOF 、REPLICATION 还有事务安全性检查(是否修改了被 WATCH
监视的键?)也会被触发,
当这些后续程序也执行完毕之后,
命令执行器退出,
服务器其他程序(比如时间事件处理器)继续运行。
当 C1 对应的写事件就绪时,
程序就会将保存在客户端结构回复缓存中的数据回写给客户端,
当客户端接收到数据之后,
它就将结果打印出来,
显示给用户看。
以上就是 SET YEAR 2013
命令执行的整个过程。
小结
- 服务器经过初始化之后,才能开始接受命令。
- 服务器初始化可以分为六个步骤:
- 初始化服务器全局状态。
- 载入配置文件。
- 创建 daemon 进程。
- 初始化服务器功能模块。
- 载入数据。
- 开始事件循环。
- 服务器为每个已连接的客户端维持一个客户端结构,这个结构保存了这个客户端的所有状态信息。
- 客户端向服务器发送命令,服务器接受命令然后将命令传给命令执行器,执行器执行给定命令的实现函数,执行完成之后,将结果保存在缓存,最后回传给客户端。