redis数据过期、内存淘汰、持久化

young 580 2021-12-27

整理于《redis设计与实现》一书

数据库

服务器中的数据库

redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构的db数组中,db数组的每个项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库

struct redisServer{
    // ...
    // 一个数组,保存着服务器中的所有数据库
    redisDb *db;
    // ...
};

在初始化服务器时,程序会根据服务器状态的dbnum属性来决定应该创建多少个数据库

struct redisServer {
    // ...
    // 服务器的数据库数量
    int dbnum;
    // ...
};

dbnum属性的值由服务器配置的database选项决定,默认情况下,该选项的值为16,所以Redis服务器默认会创建16个数据库
redisServerdbnum.png

切换数据库

每个Redis客户端都有自己的目标数据库,每当客户端执行数据库写命令或者读命令的时候,目标数据库就会成为这些命令的操作对象。

默认情况下,redis客户端的目标数据库是0号数据库,客户端可以通过SELECT命令来切换目标数据库

redis> SET msg "hello world"
OK
redis> GET msg
"hello world"
redis> SELECT 2
OK
redis[2]> GET msg
(nil)
redis[2]> SET msg "another world"
OK
redis[2]> GET msg
"another world"

在服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库,这个属性是一个指向redisDb结构的指针

typedef struct redisClient{
    // ...
    // 记录客户端当前正在使用的数据库
    redisDb *db;
    // ...
} redisClient;

redisClient.db指针指向redisServer.db数组的其中一个元素,而被指向的元素就是客户端的目标数据库
redisServerselect.png

通过修改redisClient.db指针,让它指向服务器中的不同数据库,从而实现切换目标数据库的功能,这就是SELECT命令的实现原理

目前Redis仍然没有可以返回客户端目前数据库的指令。

虽然redis-cli客户端会在输入符旁边提示当前使用的目标数据库,但是如果在其他语言的客户端中执行redis命令,并且该客户端没有想redis-cli一样显示目标数据库的号码,那么需要注意误操作非目前数据库的情况,最好在执行命令前,先执行SELECT命令,显示的切换到指定的数据库,再执行命令。

数据库键空间

redis是一个键值对数据库服务器,服务器中额定每个数据库都由一个redis.h/redisDb结构表示,其中,redisDb结构的dict字典保存了数据库中的所有键值对,将这个字典称为键空间(key space)

typedef struct redisDb{
	// ...
    // 数据库键空间,保存着数据库中的所有键值对
    dict *dict;
    // ...
} redisDb;

键空间和用户缩减的数据库是直接对应的

  • 键空间的键也就是数据库的键,每个键都是一个字符串对象
  • 键空间的值也就是数据库的值,每个值可以是任意一种Redis对象
redis> SET message "hello world"
OK
redis> RPUSH alphabet "a" "b" "c"
(integer)3
redis> HSET book name "Redis in Action"
(integer) 1
redis> HSET book author "Josiah L. Carlson"
(integer) 1
redis> HSET book publisher "Manning"
(integer) 1

keyspace.png

  • alphabet是一个列表建,键的名字是一个包含字符串“alphabet”的字符串对象,键的值则是一个包含三个元素的列表对象
  • book是一个哈希表键,键的名字是一个包含字符串“book”的字符串对象,键的值则是一个包含三个键值对的哈希表对象
  • message是一个字符串键,键的名字是一个包含字符串“message”的字符串键,键额值则是一个包含字符串“hello world”的字符串对象

因为数据库的键空间是一个字典,所以所有针对数据库的操作,实际上都是通过对键空间字典进行操作来实现的

添加新键

添加一个新键值对到数据库,时间上就是将一个新键值对添加到键空间的字典里,其中键为字符串对象,值为任意一种类型的redis对象

删除键

删除数据库中的一个键,实际上就是在键空间里面删除键对应的键值对对象

更新键

对一个数据库键进行更新,实际上就是对键空间里面键所对应的值对象进行更新,根据值对象的类型不同,更新的具体方法也会不同。

对键取值

对一个数据库键进行取值,时间上就是在键空间中取出键所对应的值对象,根据值对象类型不同,具体的取值方法也会不同

其他键空间操作

除了上述的几种操作外,还有很多针对数据库本身的redis命令,也是对键空间进行处理来完成的。

比如,清空整个数据库的FLUSHDB命令,就是通过删除键空间中的所有键值对来实现的;用于随机返回数据库中某个键的RANDOMKEY命令,就是通过在键空间中随机返回一个键来实现的。

用于返回数据库键数量的DBSIZE命令,是通过返回键空间中包含的键值对数量来实现的。EXISTS、RENAME、KEYS等,都是通过对键空间进行操作来实现的。

读写键空间时的维护操作

当使用redis命令对数据库进行读写时,服务器不仅会对键空间执行指定的读写操作,还会执行一些额外的维护操作,包括:

  • 在读取一个键之后(读写操作都要对键进行读取),服务器会根据键是否存在来更新服务器的键空间命中(hit)次数或键空间不命中(miss)次数,这两个值可以在INFO STATS命令的keyspaec_hits属性和keyspace_misses属性中查看
  • 在读取一个键之后,服务器还会更新键的LRU(最后一次使用)时间,这个值可以用于计算键的空闲时间,使用OBJECT IDLETIME命令可以查看键key的空闲时间
  • 如果服务器在读取一个键时,发现该键已经过期,那么服务器会先删除这个过期键,才回执行余下的操作
  • 如果客户端使用WATCH命令监视了某个键,那么服务器在对被监视的键进行修改后,会将这个键标记为脏(dirty),从而让事务程序注意到这键已经被修改了
  • 服务器每次修改一个键之后,都会对脏(dirty)键计数器的值增加1,这个计数器会触发服务器的持久化以及复制操作
  • 如果服务器开启了数据库通知功能,那么在对键进行修改后,服务器就会按照配置发送相应的数据库通知

设置键的生存时间或过期时间

通过EXPIRE命令护着PEXPIRE命令,客户端可以以秒或者毫秒的精度为数据库中的某个键设置生存时间(Time To Live,TTL),在经过指定的秒数或者毫秒数之后,服务器会自动删除生存空间为0的键。

redis> SET key value
OK
redis>EXPIRE key 5
(integer) 1
redis> GET key // 5秒之内
“value”
reids> GET key // 5秒之后
(nil)

注意

SETEX命令可以在设置一个字符串键的同时为键设置过期时间,因为这个命令是一个类型限定命令(只能用于字符串),原理与EXPIRE一致

与EXPIRE命令和PEXPIRE命令类似,客户端可以通过EXPIREAT命令或者PEXPIREAT命令,以秒或者毫秒的精度给数据库中的某个键设置过期时间。
过期时间是一个UNIX时间戳,当键的过期时间来临时,服务器就会自动从数据库中删除这个键

TTL命令和PTTL命令接受一个带有生存时间或者过期时间的键,返回这个键的剩余生存时间,也就是返回距离这个键被服务器自动删除还有多长时间

设置过期时间

Redis有四种不同的命令可以用于设置键的生存时间或者过期时间

  • EXPIRE <key> <ttl> 命令用于将键key的生存时间设置为ttl秒
  • PEXPIRE <key> <ttl> 命令用于将键key的生存时间设置为ttl毫秒
  • EXPIREAT <key> <timestamp>命令用于将键key的过期时间设置为timestamp所指定的秒数时间戳
  • PEXPIREAT <key> <timestamp>命令用于将键key的过期时间设置为timestamp所指定的毫秒数时间戳

虽然有多种不同单位和不同形式的设置命令,但实际上EXPIRE、PEXPIRE、EXPIREAT三个命令都是使用PEXPIREAT命令来实现的。

无论客户端执行的以上哪个命令,经过转换后,最终的执行效果都和执行PEXPIREAT命令一样。

EXPIRE命令可以转换成PEXPIRE命令

def EXPIRE(key,ttl_in_sec):
	# 将TTL从秒转换为毫秒
	ttl_in_ms = sec_to_ms(ttl_in_sec)
    	PEXPIRE(key,ttl_in_ms)

PEXPIRE转换成PEXPIREAT

def PEXPIRE(key,ttl_in_ms):
	# 获取以毫秒计算的当前UNIX时间戳
	now_ms = get_current_unix_timestamp_in_ms()
    	# 当前时间加上TTL,得出毫秒格式的键过期时间
    	PEXPIREAT(key,now_ms+ttl_in_ms)

EXPIREAT转换成PEXPIREAT

def EXPIREAT(key,expire_time_in_sec):
	# 将过期时间从秒转换为毫秒
	expire_time_in_ms = sec_to_ms(expire_time_in_sec)
	PEXPIREAT(key,expire_time_in_ms)

保存过期时间

redisDb结构的expires字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典

  • 过期字典的键是一个指针,这个指针指向键空间中的某个对象(某个数据库键)
  • 过期字典的值是一个long long类型的整数,这个整数保存了键所指向的数据库键的过期时间,一个毫秒精度的UNIX时间戳
typedef struct redisDb {
    // ...
    // 过期字典,保存着键的过期时间
    dict *expires;
    // ...
} redisDb;

键空间保存了数据库中的所有键值对,过期字典中保存数据库键的过期时间。

为了方便展示,图中键空间和过期字典中重复出现了两次alphabet键对象和book键对象。实际中,键空间的键和过期字典的键都指向同一个键对象,所以不会出现任何重复对象,也不会浪费任何空间。
expiresdict.png

当客户端执行PEXPIREAT命令或其他三个会转换为PEXPIREAT命令的命令)为一个数据库键设置过期时间时,服务器会在数据库的过期字典中关联给定的数据库键和过期时间。

PEXPIREAT命令的伪代码定义

def PEXPIREAT(key,expire_time_in_ms):
	# 如果给定的键不存在于键空间,那么不能设置过期时间
	if key not in redisDb.dict:
        return 0
	# 在过期字典中关联键和过期时间
	redisDb.expires[key] = expire_time_in_ms
	# 过期时间设置成功
	return 1            	

移除过期时间

PERSIST命令可以移除一个键的过期时间

redis> PEXPIREAT message 1391234400000
(integer) 1
redis> TTL message
(intger) 13893281
redis> PERSIST message
(integer) 1
redis> TTL message
(integer) -1

PERSIST命令就是PEXPIREAT命令的反操作:PERSISIT命令在过期字典中查找给定的键,并且解除键和值(过期时间)在过期字典中的关联

PERSIST命令的伪代码

def PERSIST(key):
	# 如果键不存在,或者键没有设置过期时间,那么直接返回
	if key not in redisDb.expires:
		retuen 0
	# 移除过期字典中给定键的键值对关联
	redisDb.expires.remove(key)
	# 键的过期时间移除成功
	return 1

计算并返回剩余生存时间

TTL命令以秒为单位返回键的剩余生存时间,而PTTL则是以毫秒为单位。

TTL和PTTL两个命令都是通过计算键的过期时间和当前时间之间的差来实现的

伪代码:

def PTTL(key)
    # 键不存在于数据库
    if key not in redisDb.dict:
		return -2
	# 尝试取得键的过期时间
	# 如果键没有设置过期时间,那么expire_time_in_ms将为None
	expire_time_in_ms = redisDb.expires.get(key)
	# 键没有设置过期时间
	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)            
	if ttl_in_ms < 0:
		return ttl_in_ms
	else:
		# 将毫秒转换为秒
		return ms_to_sec(ttl_in_ms)   

过期键的判定

通过过期字典,程序可以用以下步骤检查一个给定键是否过期

  1. 检查给定键是否存在于过期字典,如果存在则获取键的过期时间
  2. 检查当前UNIX时间戳是否大于键的过期时间,如果大于则键已经过期,否则键未过期

伪代码

def is_expired(key):
	# 取得键的过期时间
	expire_time_in_ms = redisDb.expires.get(key)
	# 键没有设置过期时间
	if expire_time_in_ms is None:
		return False
	# 取得当前时间的UNIX时间戳
	now_ms = get_current_unix_timestamp_in_ms()
	# 检查当前时间是否大于键的过期时间
	if now_ms > expire_time_is_ms:
		# 是,键已过期
		return True
	else:
		# 否,未过期
		return False

实现过期键判断的另一种方法就是使用TTL命令或者PTTL命令,返回值大于等于0则说明未过期。

实际中Redis检查键是否过期的方法与is_expired函数所描述的方法一致,因为直接访问字典比执行一个命令稍微快一些。

过期键删除策略

如果一个键过期了,那么它什么时候会被删除

Redis有三种不同的删除策略

  • 定时删除:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作
  • 惰性删除:放任键过期不管,但是每次从键空间获取键时,都检查键是否过期,如果过期,就删除该键,否则返回该键
  • 定期删除:每隔一段时间,程序对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及检查多少个数据库,则有算法决定

三种策略中,第一种和第三种为主动删除策略,第二种为被动删除策略

定时删除

定时删除策略对内存最友好,通过使用定时器,保证过期键会尽可能快的被删除,并释放过期键所占用的空间。

但是定时删除策略对CPU时间最不友好,在过期键较多的情况下,删除过期键可能会占用相当一部分CPU时间,在内存不紧张但是CPU时间非常紧张的情况下,会对服务器的响应时间和吞吐量造成影响。

此外,创建一个定时器需要用到redis服务器中的时间事件,而当前时间事件的实现方式为无序链表,查找一个事件的时间复杂度为O(N),并不能高效的处理大量时间事件

惰性删除

惰性删除策略对CPU时间最友好,程序只会在取出键时才对键进行过期检查,这可以保存删除过期键的操作只会在非做不可的情况下进行,并且删除目标仅限于当前处理的键,这个策略不会再删除其他无关的过期键上话费任何CPU时间。

但是惰性删除策略对内存最不友好,如果一个键已经过期,而这个键仍然保留在数据库中,那么只要这个键不被删除,它所占用的内存空间就不会释放。

使用惰性删除时,如果数据库中有非常多的过期键,而这些过期键又恰好没有被访问到的话,那么他们永远不会被删除(除非手动执行FLUSHDB),我们甚至将这种情况看做是一种内存泄露,无用的垃圾数据占用了大量的内存,而服务器却不会自己去释放它们。

定期删除

上面两种方式在单一使用的时候都有明显的缺陷:

  • 定时删除占用太多CPU时间,影响服务器的响应时间和吞吐量
  • 惰性删除浪费太多内存,有内存泄露风险

定期删除策略是前两种策略的一种整合和折中:

  • 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响
  • 通过定期删除过期键,也减少了过期键带来的内存浪费

定期删除策略的难顶是确定删除执行的时长和频率

  • 如果执行的太频繁,或者执行时间过长,定期删除策略就会退化成定时删除策略,将CPU时间过多的消耗在删除过期键上
  • 如果删除执行的太少,或者时间太短,定期删除策略就会和惰性删除策略一样,出现浪费内存的情况

如果采用定期删除策略的话,服务器必须根据情况合理的设置执行时长和频率

Redis的过期键删除策略

惰性删除策略的实现

惰性删除策略由db.c/exporeIfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded函数对输入键进行检查:

  • 如果输入键已经过期,那么expireIfNeeded函数将输入键从数据库中删除
  • 如果输入键未过期,那么expireIfNeeded函数不做动作

expireIfNeeded函数就像一个过滤器,他可以在命令执行真正的命令之前,过滤掉过期的输入键,从而避免命令接触到过期键

因为每个访问的键都可能因为过期而被expireIfNeeded函数删除,所以每个命令的函数都必须能同时处理键存在以及键不存在两种情况:

  • 当键存在时,命令安装键存在的情况执行
  • 当键不存在或者键因为过期而被expireIfNeeded函数删除时,命令按照键不存在的情况执行

expireIfNeeded.png

定期删除策略的实现

过期键的定期删除策略由redis.c/activeExpireCycle函数实现,每当Redis的服务器周期性操作redis.c/serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定的时间内分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键

伪代码

# 默认每次检查的数据库数量
DEFAULT_DB_NUMBERS = 16
# 默认每个数据库检查的键数量
DEFAULT_KEY_NUMBERS = 20
# 全局遍历,记录检查进度
current_db = 0
def activeExpireCycle():
	# 初始化要检查的数据库数量
	# 如果服务器的数据库数量比DEFAULT_DB_NUMBNERS小,那么以服务器的数据库数量为准
	if server.dbnum < DEFAULT_DB_NUMBERS:
        db_numbers = server.dbnum
	else:
		db_numbers = DEFAULT_DB_NUMBERS
	# 遍历各个数据库
	for i in range(db_numbers):
		# 如果current_db的值等于服务器的数据库数量
		# 这表示检查程序已经遍历了服务器的所有数据库一次
		# 将current_db 重置为0,开始新的一轮遍历
		if current_db == server.dbnum:
			current_db = 0
		# 获取当前要处理的数据库
		redisDb = server.db[current_db]
		# 将数据库索引增1,指向下一个要处理的数据库
		current_db +=1
		# 检查数据库键
		for j in range(DEFAULT_KEY_NUMBERS):
			# 如果数据库中没有一个键带有过期时间,那么跳过这个数据库
			if redisDb.expires.size() == 0: break
            # 随机获取一个带有过期键时间的键
            key_with_ttl = redisDb.expires.get_random_key()
            # 检查键是否过期,如果过期就删除它
            if is_expired(key_with_ttl):
				delelte_key(key_with_ttl)
			# 已达到时间上限,停止处理
			if reach_time_limit(): return                                      

activeExpireCycle函数的工作模式:

  • 函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键
  • 全局遍历current_db会记录当前activeExpireCycle函数检查的进度,并在下一次activeExpireCycle函数调用时,接着上一次的进度进行处理。比如当初函数在遍历10号数据库时返回了,那么下次函数执行时,将从11号数据库开始查出并删除过期键。
  • 随着函数的不断执行,服务器中所有数据库都会被检查一遍,这时将current_db变量重置为0,然后再次开始新一轮的检测

AOF、RDB和复制功能对过期键的处理

生成RDB文件

在执行SAVE命令或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中

因此数据库中包含过期键不会对新生成的RDB文件造成影响

载入RDB文件

在启动Redis服务器时,如果服务器开启了RDB功能,那么服务器将对RDB文件进行载入:

  • 如果服务器以主服务器模式运行,那么在载入RDB文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期键则会被忽略,所以过期键对载入RDB文件的主服务器不会造成影响
  • 如果服务器以从服务器模式运行,那么在载入RDB文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的数据库就会被清空,所以一般来讲,过期键对载入RDB文件的从服务器也不会造成影响

AOF文件写入

当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个键而产生任何影响。

当过期键被惰性删除或者定期删除之后,程序会想AOF文件追加一条DEL命令,来显式的记录该键已被删除。

AOF重写

和生成RDB文件时类型,在执行重写AOF重写的过程中,程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中。

因此数据库中包含过期键不会对AOF重写造成影响。

复制

当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制

  • 主服务器在删除一个过期键之后,会显式的向所有从服务器发送一条DEL命令,告知从服务器删除这个过期键
  • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期键一样来处理过期键
  • 从服务器只有在接到主服务器发来的DEL命令之后,才会删除过期键

通过由主服务器来控制从服务器统一删除过期键,可以保证主从服务器数据的一致性,也正是这个原因,当一个过期键仍然存在于主服务器的数据库时,这个过期键在从服务器里的复制品也会继续存在

数据库通知

客户端可以通过订阅给定的频道或者模式,来获知数据库中键的变化,以及数据库中命令的执行情况,

127.0.0.1:6379> SUBSCRIBE _ _keyspace@0_ _:message
Reading messages... (press Ctrl-C to quit)
1) "subscribe"  //订阅信息
2) "__keyspace@0__:message"
3) (integer) 1
1) "message"    //执行SET命令
2) "_ _keyspace@0_ _:message"
3) "set"
1) "message"    //执行EXPIRE命令
2) "_ _keyspace@0_ _:message"
3) "expire"
1) "message"    //执行DEL命令
2) "_ _keyspace@0_ _:message"
3) "del"

根据发回的通知显示,先后共有SET、EXPIRE、DEL三个命令对键message进行了操作。

这一类关注“某个键执行了什么命令”的通知陈伟键空间通知,除此之外,还有另一类称为键事件通知的通知,他们关注的是“某个命令被什么键执行了”

127.0.0.1:6379> SUBSCRIBE _ _keyevent@0_ _:del
Reading messages... (press Ctrl-C to quit)
1) "subscribe"  //订阅信息
2) "_ _keyevent@0_ _:del"
3) (integer) 1
1) "message"    //键key执行了DEL命令
2) "_ _keyevent@0_ _:del"
3) "key"
1) "message"    //键number执行了DEL命令
2) "_ _keyevent@0_ _:del"
3) "number"
1) "message"    //键message执行了DEL命令
2) "_ _keyevent@0_ _:del"
3) "message"

根据发回的通知显示,key、number、message三个键先后执行了DEL命令

服务器配置的notify-keyspace-events选项决定了服务器所发送通知的类型

  • 想让服务器发送所有类型的键空间通知和键事件通知,可以将选项的值设置为AKE
  • 想让服务器发送所有类型的键空间通知,可以将选项的值设置为AK
  • 想让服务器发送所有类型的键事件通知,可以将选项的值设置为AE
  • 想让服务器只发送和字符串键有关的键空间通知,可以将选项设置为K$
  • 想让服务器只发送和列表键有关的键通知,可以将选项的值设置为EL

发送通知

发送数据库通知的功能是由notify.c/nofityKeyspaceEvent函数实现的

void notifyKeyspaceEvent(int type,char *event,robj *key,int dbid);

函数的type参数是当前想要发送的通知的类型,程序会根据这个值来判断通知是否就是服务器配置notify-keyspace-events选项所选定的通知类型,从而决定是否发送通知

event、keys、dbid分别是时间的名称,产生时间的键,以及产生事件的数据库好吗,函数会根据type参数以及这三个参数来构建事件通知的内容,以及接收通知的频道名

每当一个redis命令需要发送数据库通知的时候,该命令的实现函数就会调用notifyKeyspaceEvent函数,并向函数传递传递该命令所引发的事件的相关信息。

例如SADD命令的实现函数saddCommand的其中一部分

void saddCommand(redisClient *c){
    // ...
    // 如果至少有一个元素被成功添加,那么执行一下程序
    if(added){
        // ...
        // 发送事件通知
        notifyKeyspaceEvent(REDIS_NOTIFY_SET,"sadd",c->argv[1],c->db->id);
    }
    // ...
}

当SADD命令至少成功地向集合添加了一个集合元素之后,命令就会发送通知,该通知的类型为REDIS_NOTIFY_SET(表示这是一个集合键通知),名称为sadd(表示这是执行SADD命令产生的通知)

发送通知的实现

伪代码

def notifyKeyspaceEvent(type,event,key,dbid):
	# 如果给定的通知不是服务器允许发送额定通知,那么直接返回
	if not(server.notify_ekeyspace_events & type):
		return
	# 发送键空间通知
	if server.notify_keyspace_events & REDIS_NOTIFY_KEYSPACE:
		# 将通知发送给频道_keyspace@<dbid>_:<key>
		# 内容为键所发送的事件<event>
		# 构建频道名称
		chan = "_keyspace@{dbid}_:{key}".format(dbid=dbid,key=key)
		# 发送键事件通知
		pubsubPublishMessage(chan,event)            
	# 发送键事件通知
	if server.notify_keyspace_events & REDIS_NOTIFY_KEYEVENT:
		# 将通知发送给频道_keyevent@<dbid>_:<event>
		# 内容为键所发送的事件<key>
		# 构建频道名称
		chan = "_keyevent@{dbid}_:{event}".format(dbid=dbid,event=event)
		# 发送键事件通知
		pubsubPublishMessage(chan,key)    	            

RDB持久化

redis是一个键值对数据库服务器,服务器中通常包含着任意个非空数据库,而每个非空数据库中可以包含任意个键值对,为了方便起见,将服务器中的非空数据库以及他们的键值对统称为数据库状态。

因为redis是内存数据库,他将自己的数据库状态存储在内存里,所以如果不想办法将存储在内存中的数据库状态保存到磁盘中,那么一旦服务器程序退出,服务器中的数据库状态也会消失不见。

为了解决这个状态,redis提供了RDB持久化功能,这个功能可以将redis在内存中的数据库状态保存到磁盘里,避免数据意外丢失。

RDB持久化既可以手动指定,也可以根据服务器配置选项定期执行,该功能可以将某个时间点上的数据库状态保存到一个RDB文件中。

RDB持久化功能所生成的RDB文件时一个经过压缩的二进制文件,通过该文件,可以还原生成RDB文件时的数据库状态。

RDB文件的创建与载入

有两个Redis命令可以用于生成RDB文件,一个是SAVE,一个是BGSAVE

SAVE命令会阻塞redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能进行任何命令请求。

BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求

创建RDB文件的实际工作由rdb.c/rdbaSave函数完成,SAVE命令和BGSAVE命令会以不同的方式调用这个函数

伪代码

def SAVE():
	# 创建RDB文件
	rdbSave()

def BGSAVE():
	# 创建子进程
	pid = fork()
	if pid == 0:
		# 子进程负责创建RDB文件
		rdbSave()
		# 完成之后向父进程发送信号
		signal_parent()
	elif pid > 0:
		# 父进程继续处理命令请求,并通过轮询等待子进程的信号
		handle_request_and_wait_signal()
	else:
		# 处理出错情况
		handle_fork_error()

和使用SAVE命令或者BGSAVE命令创建RDB文件不同,RDB文件的载入工具是在服务器启动时自动执行的,所以Redis并没有专门用于载入RDB文件的命令,只要Redis服务器在启动时检测到RDB文件存在,他就会自动载入RDB文件。

因为AOF文件的更新频率通常比RDB文件的更新频率高,所以

  • 如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态
  • 只有在AOF持久化功能关闭时,服务器才会使用RDB文件来还原数据库状态

SAVE命令执行时的服务器状态

SAVE命令执行时,redis服务器会被阻塞,所以当SAVE命令正在执行时,客户端发送的命令请求会被拒绝。

只有在服务器执行完SAVE命令、重新开始接受命令请求之后,客户端发送的命令才会被处理。

BGSAVE命令执行时的服务器状态

BGSAVE命令的保存工作是由子进程执行的,所以在子进程创建RDB文件的过程中,Redis服务器仍然可以继续处理客户端的命令请求,但是在BGSAVE执行期间,服务器处理SAVE、BGSAVE、BGREWRITEAOF命令的方式会和平时有所不同。

BGSAVE执行期间,客户端发送的SAVE命令会被服务器拒绝,服务器禁止SAVE命令和BGSAVE命令同时执行是为了避免父进程和子进程同时执行rdbSave调用,防止产生竞争条件。

在BGSAVE执行期间,客户端发送的BGSAVE命令会被服务器拒绝,因为同时执行两个BGSAVE命令也会产生竞争条件。

BGSAVE与BEREWRITEAOF命令不能同时执行

  • 如果BGSAVE正在执行,那么客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕之后执行
  • 如果BGREWRITEAOF命令正在执行,那么客户端发送的BGSAVE命令将会被拒绝

BGREWRITEAOF和BGSAVE两个命令都是由子进程执行,这两个命令都会执行大量的磁盘写入操作,同时执行会影响性能

RDB文件载入时的服务器状态

服务器在载入RDB文件时,会一直处于阻塞状态,直到载入工作完成为止。

自动间隔性保存

因为BGSAVE命令可以在不阻塞服务器进程的情况下执行,所以redis允许通过服务器配置的save选项,让服务器没隔一段时间自动执行一次BGSAVE命令。

用户可以通过save选项设置多个保存条件,只要其中任意一个条件被满足,服务器就会执行BGSAVE命令

save 900 1
save 300 10
save 60 10000

只要满足以下三个条件中的任意一个,BGSAVE就会执行

  • 服务器在900秒之内,至少对数据库进行了1次修改
  • 服务器在300秒之内,至少对数据库进行了10次修改
  • 服务器在60秒之内,至少为数据库进行了10000次修改

设置保存条件

redis服务器启动时,用户可以通过指定配置文件或者传入启动参数的方式设置save选项,如果没有主动设置save选项,服务器会为save选项设置默认条件

save 900 1
save 300 10
save 60 10000

接着,服务器程序会根据save选项设置的保存条件,设置服务器状态redisServer结构的saveparams属性

struct redisServer {
    // ...
    // 记录了保存条件的数组
    struct saveparam *saveparams;
    // ...
};

saveparams属性是一个数组,数组中的每个元素都是一个saveparam结构,每个saveparam结构都保存了一个save选项设置的保存条件

struct saveparam {
    // 秒数
    time_t seconds;
    // 修改数
    int changes;
};

dirty计数器和lastsave属性

除了saveparams数组之外,服务器状态还维护着一个dirty计数器,以及lastsave属性

  • dirty计数器记录距离上一次成功执行SAVE命令或者BGSAVE命令之后,服务器对数据库状态(服务器中所有数据)进行了多少次修改(包括写入、删除、更新等操作)
  • lastsave属性是一个UNIX时间戳,记录了服务器上一次成功执行SAVE或者BGSAVE命令的时间
struct redisServer {
    // ...
    // 修改计数器
    long long dirty;
    // 上一次执行保存的时间
    time_t lastsave;
    // ...
};

当服务器成功执行一个数据库修改命令之后,程序就会对dirty计数器进行更新:命令修改了多少次数据库,dirty计数器的值就会增加多少

例如执行 set message "hello"

那么dirty计数器的值加1

如果执行SADD database Redis MongoDB MariaDB

那么dirty计数器的值增加3

检查保存条件是否满足

Redis服务器周期性操作函数serverCron默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否已经满足,如果满足的话,就执行BGSAVE命令

伪代码

def serverCron():
	# ...
	for saveparam in server.saveparams:
		# 计算距离上一次执行保存操作有多少秒
		save_interval = unixtime_now()-server.lastsave
        # 如果数据库状态的修改次数超过条件所设置的次数,并且距离上次保存的时间超过条件所设置的时间
        # 执行保存操作
        if server.dirty >= saveparam.changes and save_interval > saveparam,seconds:
        BGSAVE()
	# ...      

BGSAVE执行完之后,dirty计数器被重置为0,lastsave属性更新为执行结束时间

RDB文件结构

todo

分析RDB文件

todo

AOF持久化

除了RDB持久化之外,Redis还提供了AOF持久化功能。RDB持久化是通过保存数据库中的键值对来记录数据库状态,AOF持久化是通过保存Redis服务器所执行的写命令来记录数据库状态。

被写入AOF文件的所有命令都是以Redis的命令请求协议格式保存的,因为Redis的命令请求协议是纯文本格式,所以可以直接打开一个AOF文件查看里面的内容。

服务器在启动时,可以通过载入和执行AOF文件中保存的命令来还原服务器关闭前额定数据库状态

AOF持久化的实现

AOF持久化功能的实现可以分为命令追加(append)、文件写入,文件同步(sync)三个步骤。

命令追加

当AOF持久化功能处于开启状态时,服务器在执行完一个写命令之后,会以协议格式被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾

struct redisServer {
    // ...
    // AOF缓冲区
    sds aof_buf;
    // ...
};

例如,客户端向服务器发送命令SET KEY VALUE

那么服务器在执行这个SET命令之后,会将一下协议内容追加到aof_buf缓冲区的末尾

*3\r\n\$3\r\nSET\r\n\$3\r\nKEY\r\n$5\r\nVALUE\r\n

以上就是AOF持久化的命令追加步骤的实现原理

AOF文件的写入与同步

Redis的服务器进程就是一个事件循环(loop),这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复,而时间事件则负责执行像serverCron函数这样需要定时运行的函数。

因为服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到aof_buf缓冲区里面,所以在服务器每次结束一个事件循环之前,它都会调用flushAppendOnlyFile函数,考虑是否需要将aof_buf缓冲区中的内容写入和保存到AOF文件里

伪代码

def eventLoop():
	while True:
		# 处理文件事件,接收命令请求以及发送命令回复
		# 处理命令请求时可能会有新内容被追加到aof_buf缓冲区中
		processFileEvent()
       		# 处理时间事件
		processTimeEvent()
		# 考虑是否要将aof_buf中的内容写入和保存到AOF文件里
		flushAppendOnlyFlie()            

flushAppendOnlyFlie函数的行为由服务器配置的appendfsync选项的值来决定

appendfsyns选项的值flushAppendOnlyFlie函数的行为
always将aof_buf缓冲区中的所有内容写入并同步到AOF文件
everysec将aof_buf缓冲区中额定所有内容写入到AOF文件,如果上次同步时间距离现在超多1秒钟,那么在此对AOF进行同步,并且这个同步操作是由一个线程专门负责执行的
no将aof_buf缓冲区中的所有内容写入到AOF文件,但并不对AOF文件进行同步,何时同步有操作系统来决定

如果用户没有主动设置appendfsyns选项的值,那么默认值为everysec

文件的写入与同步

为了提高文件的写入效率,在现代操作系统中,当用户调用write函数,将一些数据写入到文件时,操作系统通常会将写入数据暂时保存在一个内存缓存区里面,等到缓冲区被填满、或者超过了指定时限之后,才真正的将缓冲区中的数据写入到磁盘文件中。

这种做法虽然提高了效率,但是也为写入数据带来了安全问题,如果计算机发生停机,那么保存在内存缓冲区里的写入数据将会丢失。

为此系统提供了fsync和fdatasync两个同步函数,他们可以强制让系统立即将缓冲区中的数据写入到磁盘,确保写入数据的安全性。

flushAppendOnlyFlie函数被调用时,假设appendfsyns的值为everysec,并且距离上次同步时间已经超过1秒钟,那么服务器会先将aof_buf中的内存写入到AOF文件,在对AOF文件进行同步。

AOF持久化的效率和安全性

  • 当appendfsyns的值为always时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入AOF文件,并且同步AOF文件,所以always的效率是最慢的,但是也是最安全的,即使出现故障停机,AOF持久化也只会丢失一个事件循环中产生的命令数据,
  • 当appendfsyns的值为everysec时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内存写入AOF文件,并且每隔一秒就要在子进程中对AOF文件进行一次同步。效率上来说,everysec模式足够快,出现故障停机,数据库也只丢失一秒钟的命令数据
  • 当appendfsyns的值为no时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内存写入AOF文件,至于何时对AOF文件进行同步,则由操作系统控制。所以该模式下,AOF文件写入速度总是最快的,不过因为这种模式会在系统缓存中积累一段时间的写入数据,所以该模式的单次同步时长是最长的,出现故障时,将丢失上次同步之后的所有写入命令数据。

AOF文件的载入与数据还原

因为AOF文件里包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。

步骤如下:

  1. 创建一个不带网络连接的伪客户端(fake client):因为redis的命令执行在客户端上下文中执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络链接的客户端执行命令的效果完全一样
  2. 从AOF文件中分析并读取出一条写命令
  3. 使用伪客户端执行被读出的写命令
  4. 一直执行2和3步骤,直到AOF文件中的所有写命令都被处理完毕为止

AOF重写

AOF持久化保存了被执行的写命令来记录数据库状态,随着时间的流逝,AOF文件中的内容会越来越多,文件的体积也会越来越大,如果不加以控制,AOF文件可能会对redis服务器、甚至宿主机计算器造成影响,而且AOF还原数据库所需的时间会越来越长。

为了解决AOF文件体积膨胀的问题,Redis提供了AOF文件重写功能。

通过该功能,redis服务器可以创建一个新的AOF文件来替换现有的AOF文件,新旧两个AOF文件所保存的数据库状态相同,但是新AOF文件不会包含浪费任何空间的冗余命令,所以新AOF文件的体积通常比旧AOF文件体积小很多

AOF文件重写的实现

虽然这个功能的名称叫做“AOF文件重写”,但是AOF文件重写并不需要对现有的AOF文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的。

redis> RPUSH list "A" "B" 	// ["A","B"]
redis> RPUSH list "C"		// ["A","B","C"]
redis> RPUSH list "D" "E"	// ["A","B","C","D","E"]
redis> LPOP list		// ["B","C","D","E"]
redis> LPOP list		// ["C","D","E"]
redis> RPUSH list "F" "G" 	// ["C","D","E","F","G"]

服务器为了保持当前list键的状态,必须在AOF文件中写入6条命令。

如果服务器需要用尽量少的命令来记录list键的状态,那么最简单的方法是直接从数据库中取list键的值,然后是一条RPUSH list "C" "D" "E" "F" "G"命令来代替保持在AOF文件中的6条命令,这样就可以将保存list所需要的6条命令减少为1条了。

伪代码

def aof_rewite(new_aof_file_name)
    # 创建新的AOF文件
    f = create_file(new_aof_file_name)
    # 遍历数据库
    for db in redisServer.db:
		# 忽略空数据库
		if db.is_empty(): continue
		# 写入SELECT命令,指定数据库编号
        f.write_command("SELECT" + db.id)
		# 遍历数据库中的所有键
       	for key in db:
			# 忽略已过期的键
			if key.is_expired(): continue
            # 根据键的类型对键进行重写
            if key.type == String:
				rewrite_string(key)
			elif key.type == List:
				rewrite_list(key)
			elif key.type == Hash:
				rewrite_hash(key)
			elif key.type == Set:
				rewrite_set(key)
			elif key.type == SortedSet:
				rewrite_sorted_set(key)
			# 如果键带有过期时间,那么过期时间也要被重写
			if key.have_expire_time():
				rewrite_expire_time(key)
		# 写入完毕,关闭文件
		f.close()
	def rewrite_string(key):
		# 使用GET命令获取字符串键的值
		value = GET(Key)
		# 使用SET命令重写字符串键
		f.write_command(SET,key,value)        
	def rewrite_list(key):
		# 使用LEANGE命令获取列表键包含的所有元素
		item1,item2,...,itemN = LRANGE(key,0,-1)
        # 使用RPUSH命令重写列表键
        f.write_command(RPUSH,key,item1,item2,...,itemN)
	def rewrite_hash(key):
		# 使用HGETALL命令获取哈希键值对包含的所有键值对
		field1,value1,field2,value2,...,fieldN,valueN = HGETALL(key)        
		# 使用HMSET 命令重写哈希键
		f.write_command(HMSET,key,field1,value1,field2,value2,...,fieldN,valueN) 		def rewrite_set(key)
		# 使用SMEMBERS命令后去集合键包含的所有元素
		elem1,elem2,...elemN = SMEMBERS(key)
		# 使用SADD命令重写集合键
		f.write_command(SADD,key,elem1,elem2,...elemN)
	def rewrite_sorted_set(key)
		# 使用ZRANGE命令获取有序集合键包含的所有元素
		member1,score1,member2,score2,...memberN,scoreN = ZRANGE(key,0,-1,"WITHSCORE")            
		# 使用ZADD命令重写有序集合
		f.write_command("ZADD",key,score1,member1,score2,member2,...,scoreN,memberN)
	def  rewrite_expire_time(key)           :
		# 获取毫秒精度的键过期时间戳
		timestamp = get_expire_time_in_unixstamp(key)
		# 使用PEXPIREAT命令重写键的过期时间
		f.rewrite_command(PEXPIREAT,key,timestamp)        

实际中,为了避免在执行命令时造成客户端输入缓冲区溢出,重写程序处理列表、哈希表、集合、有序集合这四种可能会带有多个元素的键时,会先检查键锁包含的元素数量,如果元素的数量超过了redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD常量的值,那么重写程序将使用多条命令来记录键的值,而不单单使用一条命令。

AOF后台重写

AOF重写程序aof_rewrite函数可以很好的完成创建一个新的AOF文件的任务,但是,这个函数会进行大量的写操作,所以调用这个函数的线程会被长时间阻塞,redis服务器使用单个线程来处理命令请求,所以如果由服务器直接调用aof_rewrite函数的话,那么在重写AOF文件期间,服务器将无法处理客户端发来的请求。

Redis将AOF重写程序放在子进程里执行,这样在子进程进行AOF重写期间,服务器进程可以继续处理命令请求;子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性。

子进程在进行AOF重写期间,服务器还要继续处理请求,新的请求可能会对现有数据库状态进行修改,从而使得服务器当前的数据库状态和写入后的AOF文件所保存的数据库状态不一致。

为了解决这种数据不一致的问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令后,会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区。

也就是子进程在执行AOF重写期间,服务器进行需要进行以下三个工作:

  1. 执行客户端发来的命令
  2. 将执行后的写命令追加到AOF缓冲区
  3. 将执行后的写命令追加到AOF重写缓冲区

这样一来,AOF缓冲区的内容会定期被写入和同步到AOF文件,对现有AOF文件的处理工作会如常进行,并且从创建子进程开始,服务器执行的所有写命令都会被记录到AOF重写缓冲区里面。

当子进程完成AOF重写工作之后,会向父进程发送一个信号,父进程在接到该信号后,会调用一个信号处理函数,并执行以下工作:

  1. 将AOF重写缓冲区中的所有内容写入到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致
  2. 对新的AOF文件进行重命名,原子的覆盖现有AOF文件,完成新旧AOF文件的替换

这个信号处理函数执行完毕之后,父进程就可以继续接受请求命令了。

整个AOF后台重写过程中,只有信号处理函数的执行会对服务器进程造成阻塞,在其他时候,AOF后台重写都不会阻塞父进程,这样就将AOF重写对服务器性能造成的影响降到了最低。