zhongdeming428/Blog

Redis 进阶

Opened this issue · 0 comments

参考:《Redis 设计与实现》,文中的代码都来自于这本书中,由于时间比较久了,所以看新版本的代码会有出入,可以找旧版本的代码进行查看。

1 数据结构

根据使用场景的不同,我将 Redis 的数据结构分为外部数据结构和内部数据结构,其中外部数据结构是我们通过 redis-cli 访问 redis-server 时使用到的各种数据结构,比如 List、Hash、Set 等,内部数据结构是指实现这些外部数据结构时,在程序内部实际使用到的一些数据结构。

1.1 外部数据结构

外部数据结构包括:string、list、set、hash、sorted set(忽略了 bloom filter 等高级数据结构)。

其中:

  1. string 是简单的字符串类型值。
  2. list 是有序列表。
  3. set 是集合。
  4. hash 是哈希字典。
  5. sorted set 是有序集合。

关于这些数据结构的使用方法,参考 菜鸟教程

1.2 内部数据结构

在 Redis 的内部,实现了以下数据结构:

  1. SDS(simple dynamic string)简单动态字符串。
  2. LinkedList 双向链表。
  3. Dict 字典。
  4. SkipList 跳表。
  5. IntSet 整数集合。
  6. ZipList 压缩列表。

这些数据结构是外部数据结构的基础。

1.2.1 SDS

Redis 使用 C 语言编写,为了避开 C 语言原生字符串的一些坑,Redis 自己实现了一个简单动态字符串结构来存放字符串,以其获得以下收益:

  1. 更快的字符串长度获取,复杂度为 O(1),原生字符串为 O(N)。
  2. 避免缓冲区溢出。
  3. 避免多余的内存分配
  4. ……

SDS 的基本结构为:

struct sdshdr {
	// 已使用空间大小,对应字符串长度,单位:byte
	int len;

	// 未使用空间大小,单位:byte
	int free;

	// 保存字符串的字符数组
	char buf[];
};

结构示例:

image

Redis 内部的许多 string 变量都通过 SDS 存储,它是 string 类型值的底层数据结构之一(也可能是 int 类型)。

1.2.2 LinkedList

Redis 内部实现的双向链表,是 List 结构的底层数据结构。

链表节点的基本结构:

typedef struct listNode {
	// 前置节点
	struct listNode *prev;
	// 后置节点
	struct listNode *next;
	// 节点的值
	void *value;
} listNode;

链表的数据结构:

typedef struct list {
	// 表头节点
	listNode *head;
	// 表尾节点
	listNode *tail;
	// 链表所包含的节点数量
	unsigned long len;
	// 节点值复制函数
	void *(*dup) (void *ptr) ;
	//节点值释放函数
	void (*free) (void *ptr) ;
	// 节点值对比函数
	int (*match) (void *ptr,void *key) ;
} list;

双向链表数据结构图示:

image

1.2.3 Dict

Dict 是 Redis 内部最重要的数据结构,除了用来实现 Hash 字典和 Set 集合之外,Redis 最基本的键值对数据库也是基于 Dict 构建的。Dict 底层又依赖 dictht(hash table) 来实现。

dictht 基础数据结构如下:

typedef struct dictht {
	// 哈希表数组
	dictEntry **table;
	// 哈希表大小
	unsigned long size;
	//哈希表大小掩码,用于计算索引值
	// 总是等于size-1
	unsigned long sizemask;
	// 该哈希表已有节点的数量
	unsigned long used;
} dictht;

图示如下:

image

其中 table 是一个 dictEntry* 类型的数组,size 代表数组的大小,sizemask 代表用于通过 hash 计算数组索引的掩码,used 代表数组中已经存在的元素个数。

dictEntry 类型如下:

typedef struct dictEntry {
	// 键
	void *key;
	// 值
	union {
		void *val;
		uint64_tu64;
		int64_ts64;
	} v;
	// 指向下个哈希表节点,形成链表
	struct dictEntry *next;
} dictEntry;

key 保存键值对中的键,v 保存键值对中的值。

next 指针的作用是在哈希碰撞时,多个存储在同一索引的 dictEntry 对象以链表形式进行存储。

image

Dict 结构如下:

typedef struct dict {
	// 类型特定函数
	dictType *type;
	// 私有数据
	void *privdata;
	// 哈希表
	dictht ht[2];
	// rehash索引
	// 当 rehash 不在进行时,值为 -1
	int rehashidx; /* rehashing not in progress" if rehashidx == -1 */
} dict;

其中的 ht 字段就依赖了上文提到的 dictht 结构,type 字段会针对不同的字典元素提供不同的工具函数集合(比如 hash 计算函数……)。

ht 字段之所以长度为 2,是因为两个 dictht 结构一个用于提供数据存储服务,一个用于在 rehash(重新散列)过程中存放数据。当没有在 rehash 过程中时,rehashidx 的值为 -1。

一个完整的 dict 结构如下:

image

dict 的 hash 计算方式:

// 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key) ;
// 使用哈希表的 sizemask 属性和哈希值,计算出索引值
// 根据情况不同,ht[x] 可以是ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;

计算出 index 值之后,就可以在 dictht 结构的 table 字段上进行位置索引了。

当使用 dict 结构作为 Redis 的全局键值对数据库实现基础时,hash 算法为 MurmurHash 2。

1.2.4 SkipList

跳表是有序集合的底层实现结构之一。

跳表的结构示意图如下:

image

  • header 指向跳表的表头节点。
  • tail 指向跳表的表尾节点。
  • level 表示跳表所有节点中层级的最大值(不包括表头节点)。
  • length 表示跳表中节点个数(不包括表头节点)。

跳表节点的数据结构如下:

typedef struct zskiplistNode {
	// 层
	struct zskiplistLevel {
    	// 前进指针
    	struct zskiplistNode *forward;
    	// 跨度
    	unsigned int span;
	} level[] ;
  // 后退指针
  struct zskiplistNode *backward;
  // 分值
  double score;
  // 成员对象
  robj *obj;
} zskiplistNode;

其中:

  • level 数组代表图中的 L1、L2……等节点。
  • backward 代表图中的 BW 节点,是一个指向头部方向的指针。
  • score 代表当前节点的积分值,会依赖这个值对节点进行排序。
  • obj 表示当前节点的值,它在当前跳表中必须是唯一的。

1.2.5 IntSet

IntSet 是集合(Set)的底层数据结构之一。

typedef struct intset {
  // 编码方式
  uint32_t encoding;
  // 集合包含的元素数量
  uint32_t length;
  // 保存元素的数组.
  int8_t contents[];
} intset;

encoding 表示当前数组元素的类型,虽然 contents 数组元素的类型为 int8_t,但是实际上它可以存储多种类型的元素,这取决于 encoding 的值。

contents 数组存储集合元素的值,且其中的元素是从小到大有序排列的。

length 代表 contents 数组的长度。

当新加入的元素数据长度大于当前数组元素的长度时,会进行“升级”操作,将数组中所有元素都进行类型转换,且移出这个长度太大的元素之后,数组也不会进行降级操作。

1.2.6 ZipList

压缩列表是 List 和 Hash 的底层数据结构之一,压缩列表是 Redis 为了减少内存使用而创建的类型。

压缩列表是一块连续的内存,其中可以存储不定长度的的元素,元素可以是字节数组或者整数数字。

压缩列表数据结构如下:

zlbytes zltail zllen entry1 entry2 ... entryN zlend

各个部分的含义:

  1. zlbytes 代表整个压缩列表占用的内存字节数。
  2. zltail 代表压缩列表尾节点距离起始地址有多少字节。
  3. zllen 记录压缩列表的元素个数。
  4. entryX 表示列表中的元素个数,长度不定。
  5. zlend 用于标记压缩列表的末端。

由于压缩列表的特殊性(entry 节点会标记前一个节点的长度且标记位长度不定),会存在连锁更新的问题,但是这种问题出现的几率不大,所以一般不会对性能造成影响。

1.2.7 redisObject

redisObject 是 Redis 内部用来统一表示键和值的数据结构,其定义如下:

typedef struct redisObject {
	// 类型
	unsigned type:4;
	// 编码
	unsigned encoding: 4;
	// 指向底层实现数据结构的指针
	void *ptr;
	// ...
} robj;

其中 type 表示不同的对象类型,比如 List、String、Hash、Set 或者 ZSet。

encoding 表示不同的编码,表示用来实现 type 这些数据结构的内部数据结构。

通过 type 命令可以查看 key 对应的 value 的数据结构(Hash、List、Set 等),通过 object encoding 命令可以查看 key 对应的 value 的数据结构的底层数据结构(SDS、IntSet 等)。

1.3 结构映射关系

内外部数据结构的基本映射关系如下:

image

2 线程模型

image

上方是 Redis 的线程模型,当多个客户端连接请求被接受时,会基于 IO 多路复用模型(事件驱动模型)进行监听,当事件发生时,连接对象会被依次放入队列中,然后一个一个派发给文件事件分派器。

文件事件分派器接受到连接对象之后,会根据事件类型将连接对象分配给不同的处理器,不同的处理器负责处理不同的时间(读、写、连接等)。

Redis 实现了 IO 多路复用的各种模型,在底层调用 select、evport、kqueue、epoll,最终在运行时根据不同的环境选择最优的实现:

/* Include the best multiplexing layer supported by this system.
 * The following should be ordered by performances, descending. */
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
  #ifdef HAVE_EPOLL
  #include "ae_epoll.c"
  #else
      #ifdef HAVE_KQUEUE
      #include "ae_kqueue.c"
      #else
      #include "ae_select.c"
      #endif
  #endif
#endif

源码中通过上面这段代码选择不同的 IO 多路复用模型。

Redis 6.0 以上会用多线程处理网络 IO 部分以解决单线程无法满足网络 IO 速度的问题。

3 Redis 集群

Redis 支持三种集群模式,分别是主从复制、哨兵模式和 Cluster 模式。

3.1 主从复制

Redis 通过数据持久化机制确保了在故障时也能将数据保存在磁盘上,但是如果机器磁盘故障,则持久化机制也无法确保数据不丢失。为了避免单机服务故障导致的数据丢失问题,Redis 支持在多个服务之间进行主从同步。

通过 SLAVEOF 命令可以将当前访问的 Redis 服务设置为指定 IP 和 端口对应的 Redis 服务的 Slave 服务,Slave 服务会从 Master 服务同步数据以确保主从服务之间的数据一致性。

当一个 Redis 被指定为另一个 Redis 服务的从服务后,数据同步也就开始了。数据同步步骤如下:

  1. 从服务向主服务发送 SYNC 命令。
  2. 收到 SYNC 命令后,主服务开始执行 BGSAVE 命令通过子进程开始备份数据到 RDB 文件,并使用缓冲区记录从这一刻开始主服务执行的所有写命令。
  3. 当主服务的 BGSAVE 命令执行完毕后,主服务会将 RDB 文件发送给从服务,从服务下载这个 RDB 文件之后会将文件中的数据恢复到自己的内存中。
  4. 主服务将缓冲区中记录的所有写命令记录发送给从服务,从服务接受这些命令记录之后依次执行,从而让自身的数据与主服务达到一致的状态。

以上的一致状态只是暂时的,当主服务的数据发生变化后,仍然需要和从服务进行同步。所以为了让主从服务保持动态的数据一致性,每次主服务的数据发生变化后,都会将自己执行的导致数据发生变化的写命令广播给从服务,从服务执行对应命令之后,主从服务的数据便又达到了一致。

以上步骤适用于 Redis 2.8 之前的版本,因为这种同步机制对于部分重同步场景并不友好,所以在 Redis 2.8 版本在上面的同步机制的基础之上完善了部分重同步模式,并使用 PSYNC 命令代替了 SYNC 命令;在完全重同步的场景下,PSYNC 和 SYNC 命令的执行方式基本一致,区别在于部分重同步。

部分重同步由以下三个部分构成:

  • 主服务的复制偏移量(replication offset)和从服务的复制偏移量。
  • 主服务的复制积压缓冲区(replication backlog)。
  • 服务的运行 ID(run ID)。

复制偏移量 :主从服务都会维护一个自己的复制偏移量,主服务每向从服务传递 N 个字节的数据,就会将自己的复制偏移量加上 N,从服务每次接受到主服务的 N 个字节的数据,就会将自身的复制偏移量加上 N。这样只要判断主从服务的偏移量是否一致就可以轻易知道主从服务的数据是否一致。

复制积压缓冲区 :复制积压缓冲区是由主服务维护的一个长度固定的先进先出队列,大小默认为 1 MB。当主服务进行命令广播时,不仅会将写命令发送给从服务,而且会将命令写入复制积压缓冲区,所以复制积压缓冲区中会保存着最近执行的鞋命令。

当从服务断线之后重新连接上主服务时,会将自身的复制偏移量发送给主服务,主服务会根据从服务的复制偏移量来确定执行完全重同步还是部分重同步:

  1. 如果从服务复制偏移量之后的数据仍然存在于复制积压缓冲区中,则执行部分重同步,主服务发送 +CONTINUE 回复给从服务,然后将复制积压缓冲区中从服务复制偏移量之后的写命令发送给从服务,从服务执行完这些命令以后达到数据一致。
  2. 如果从服务复制偏移量之后的数据已经不存在于主服务的复制积压缓冲区中,那么执行完全重同步操作。

服务运行 ID :每个 Redis 服务都有自己的运行 ID(run ID),这个 run ID 在服务启动时生成,是一个 40 位的 16 进制字符串。初次复制时,主服务会将这个 ID 发送给从服务,从服务会将主服务的 ID 保存起来,当从服务断线之后重新连接上主服务时,会对比这个 run ID,如果和重连之后的主服务 ID 一致,则说明重连上的是之前的主服务,可以执行部分重同步;如果和之前保存的 run ID 不一致,则说明重连上了一台新的主服务,需要执行完全重同步。

PSYNC 命令执行流程图:

image

主从复制的缺点:

  • Redis不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的IP才能恢复( 也就是要人工介入 );
  • 主机宕机,宕机前有部分数据未能及时同步到从机,切换 IP 后还会引入数据不一致的问题,降低了系统的可用性;
  • 如果多个 Slave 断线了,需要重启的时候,尽量不要在同一时间段进行重启。因为只要 Slave 启动,就会发送 sync 请求和主机全量同步,当多个 Slave 重启的时候,可能会导致 Master IO 剧增从而宕机。
  • Redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂;

3.2 哨兵机制

主从复制确保了数据的安全性,但是当主服务出现故障时,我们需要手动切换主从服务,这也是主从复制的一个缺陷,所以 Redis 官方支持了哨兵机制,哨兵机制基于主从复制模式,在其基础之上加上了哨兵进程来确保服务的可用性。

哨兵机制通过建立独立的 Sentinel 进程来监视主从服务,可以通过 redis-sentinel 或者 redis-server 命令启动一个 Sentinel 进程。

默认情况下,Sentinel 进程会每十秒一次通过 INFO 命令获取主从服务的服务信息(可以自己执行 INFO 命令查看输出信息),每两秒一次广播自身信息和主服务信息到 sentinel:hello 频道,每秒一次向所有建立了连接的主从服务节点和 Sentinel 节点发送 PING 命令获取实例的在线状态。所以一个 Sentinel 进程会监视所有的 Redis 服务节点和所有的其他 Sentinel 节点。

127.0.0.1:6379> info
# Server
redis_version:7.0.0
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:fa9ffba7836907da
redis_mode:standalone
os:Darwin 20.5.0 x86_64
arch_bits:64
monotonic_clock:POSIX clock_gettime
multiplexing_api:kqueue
atomicvar_api:c11-builtin
gcc_version:4.2.1
process_id:34740
process_supervised:no
run_id:8498e3274597f9400dae5b8f29e7c17e22fada0d
tcp_port:6379
server_time_usec:1656382962663522
uptime_in_seconds:929101
uptime_in_days:10
hz:10
configured_hz:10
lru_clock:12215794
executable:/Users/demingzhong/Documents/Work/entry-task/redis-server
config_file:
io_threads_active:0

# Clients
connected_clients:1
cluster_connections:0
maxclients:10000
client_recent_max_input_buffer:16896
client_recent_max_output_buffer:0
blocked_clients:0
tracking_clients:0
clients_in_timeout_table:0

# Memory
used_memory:1176176
used_memory_human:1.12M
used_memory_rss:1552384
used_memory_rss_human:1.48M
used_memory_peak:36041344
used_memory_peak_human:34.37M
used_memory_peak_perc:3.26%
used_memory_overhead:1098072
used_memory_startup:1079184
used_memory_dataset:78104
used_memory_dataset_perc:80.53%
allocator_allocated:1173360
allocator_active:1520640
allocator_resident:1520640
total_system_memory:17179869184
total_system_memory_human:16.00G
used_memory_lua:31744
used_memory_vm_eval:31744
used_memory_lua_human:31.00K
used_memory_scripts_eval:0
number_of_cached_scripts:0
number_of_functions:0
number_of_libraries:0
used_memory_vm_functions:32768
used_memory_vm_total:64512
used_memory_vm_total_human:63.00K
used_memory_functions:216
used_memory_scripts:216
used_memory_scripts_human:216B
maxmemory:0
maxmemory_human:0B
maxmemory_policy:noeviction
allocator_frag_ratio:1.30
allocator_frag_bytes:347280
allocator_rss_ratio:1.00
allocator_rss_bytes:0
rss_overhead_ratio:1.02
rss_overhead_bytes:31744
mem_fragmentation_ratio:1.32
mem_fragmentation_bytes:379024
mem_not_counted_for_evict:16
mem_replication_backlog:0
mem_total_replication_buffers:0
mem_clients_slaves:0
mem_clients_normal:18656
mem_cluster_links:0
mem_aof_buffer:16
mem_allocator:libc
active_defrag_running:0
lazyfree_pending_objects:0
lazyfreed_objects:0

# Persistence
loading:0
async_loading:0
current_cow_peak:0
current_cow_size:0
current_cow_size_age:0
current_fork_perc:0.00
current_save_keys_processed:0
current_save_keys_total:0
rdb_changes_since_last_save:0
rdb_bgsave_in_progress:0
rdb_last_save_time:1655453922
rdb_last_bgsave_status:ok
rdb_last_bgsave_time_sec:0
rdb_current_bgsave_time_sec:-1
rdb_saves:1
rdb_last_cow_size:0
rdb_last_load_keys_expired:0
rdb_last_load_keys_loaded:0
aof_enabled:1
aof_rewrite_in_progress:0
aof_rewrite_scheduled:0
aof_last_rewrite_time_sec:-1
aof_current_rewrite_time_sec:-1
aof_last_bgrewrite_status:ok
aof_rewrites:0
aof_rewrites_consecutive_failures:0
aof_last_write_status:ok
aof_last_cow_size:0
module_fork_in_progress:0
module_fork_last_cow_size:0
aof_current_size:17244769
aof_base_size:0
aof_pending_rewrite:0
aof_buffer_length:0
aof_pending_bio_fsync:0
aof_delayed_fsync:0

# Stats
total_connections_received:238463
total_commands_processed:800001
instantaneous_ops_per_sec:0
total_net_input_bytes:62894787
total_net_output_bytes:128695850
instantaneous_input_kbps:0.02
instantaneous_output_kbps:102.37
rejected_connections:0
sync_full:0
sync_partial_ok:0
sync_partial_err:0
expired_keys:50000
expired_stale_perc:0.00
expired_time_cap_reached_count:13
expire_cycle_cpu_milliseconds:15674
evicted_keys:0
evicted_clients:0
total_eviction_exceeded_time:0
current_eviction_exceeded_time:0
keyspace_hits:700000
keyspace_misses:50000
pubsub_channels:0
pubsub_patterns:0
latest_fork_usec:2061
total_forks:1
migrate_cached_sockets:0
slave_expires_tracked_keys:0
active_defrag_hits:0
active_defrag_misses:0
active_defrag_key_hits:0
active_defrag_key_misses:0
total_active_defrag_time:0
current_active_defrag_time:0
tracking_total_keys:0
tracking_total_items:0
tracking_total_prefixes:0
unexpected_error_replies:0
total_error_replies:0
dump_payload_sanitizations:0
total_reads_processed:1038464
total_writes_processed:800003
io_threaded_reads_processed:0
io_threaded_writes_processed:0
reply_buffer_shrinks:238437
reply_buffer_expands:0

# Replication
role:master
connected_slaves:0
master_failover_state:no-failover
master_replid:e52d0f34dc7f54da5a820f4dab0ca94b37d5a6ef
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:0
second_repl_offset:-1
repl_backlog_active:0
repl_backlog_size:1048576
repl_backlog_first_byte_offset:0
repl_backlog_histlen:0

# CPU
used_cpu_sys:252.761991
used_cpu_user:225.125363
used_cpu_sys_children:0.095378
used_cpu_user_children:0.212289

# Modules

# Errorstats

# Cluster
cluster_enabled:0

# Keyspace
127.0.0.1:6379>

两个 Sentinel 节点监视三个 Redis 节点的示意图:

image

可以看到 Sentinel 节点之间也是互相监视的。

主观下线

Sentinel 配置文件中的 down-after-milliseconds 选项指定了 Sentinel 判断实例进入主观下线所需的时间长度:如果一个实例在 down-after-milliseconds 毫秒内,连续向 Sentinel 返回无效回复,那么 Sentinel 会修改这个实例所对应的实例结构,在结构的 flags 属性中打开 SRI_ S_ DOWN 标识,以此来表示这个实例已经进人主观下线状态。

客观下线

当 Sentinel 将一个主服务判定为主观下线之后,会询问集群中的其他 Sentinel 节点这个主服务是否已经下线(is-master-down-by-addr 命令),当它从其他 Sentinel 节点收集到足够多的关于这个主服务主观下线的消息之后,就会执行客观下线操作。

客观下线的条件:主观下线数量超过了配置中的 quorum 值。

故障转移

当一个主服务节点被客观下线时,对应的故障转移操作也就开始了。Sentinel 会首先选举出一个领头 Sentinel 节点,随后由这个领头 Sentinel 节点进行故障转移操作。故障转移操作步骤:

  1. 寻找一个合适的从服务节点作为新的主服务节点。
  2. 转移原来的从服务节点,让它们作为新的主服务的从服务。
  3. 将原来的主服务节点作为新的主服务节点的从服务,如果旧的主服务节点重新上线,它就会开始同步新的主服务节点的数据。

3.3 Cluster 模式

Sentinel 模式虽然解决了故障转移的问题,但是它仍然是基于主从复制模式的,这样会有一个缺陷就是集群中的每一个节点都存储了完整的所有数据,造成了存储空间的很大浪费。

为了解决这个问题,Redis 官方支持了 Cluster 模式,通过 CLUSTER MEET 命令即可创建一个 Redis 集群,Redis Cluster 基于分片存储数据。

一个 Redis 集群中可以有很多个 Redis 服务节点,每个节点都只存储一部分数据,它们会分配到不同的 key 去处理。为了达到这个目的,整个集群数据库被分成了 16384 个槽(slot),集群中的每个节点都需要分配一部分 slot 去处理,向集群中的节点发送 CLUSTER ADDSLOTS 即可添加当前节点要处理的 slot。

每个节点内部都有一个 slots 数组来存储 slots 的分配情况,其长度为 16384,每个数组元素都为 0 或者 1,1 代表对应的槽由当前节点处理,0 则反之。

当一条命令被发送给集群中的某个 Redis 节点时,流程如下:

image

如果对应的 key 不由当前的节点处理,则会返回 MOVED 错误,将客户端重定向到正确的节点进行处理。

计算 key 对应的 slot

def slot_number(key):
  return CRC16(key) & 16383

服务节点拿到一个 key 之后先基于 CRC16 算法计算校验和,然后映射到 0 - 16383 这个范围内。

4 持久化机制

Redis 基于内存读写数据,但是也提供了持久化机制以确保数据的一致性。

4.1 RDB 持久化

RDB 文件是一个经过压缩的二进制文件,里面存储 Redis 数据库中的数据。用户通过 SAVE 命令或者 BGSAVE 命令保存数据到 RDB 文件中。

SAVE 命令是一个同步执行命令,会阻塞主线程的执行,让 Redis 服务暂时不可用直到文件创建完毕,所以一般来说这个命令是不推荐使用的。

BGSAVE 命令可以从名字看出来是在后台进行保存,BGSAVE 命令会 fork 出来一个新的子进程,然后在子进程内部进行数据持久化操作:

if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) {
  int retval;

  /* Child */
  redisSetProcTitle("redis-rdb-bgsave");
  redisSetCpuAffinity(server.bgsave_cpulist);
  retval = rdbSave(req, filename,rsi);
  if (retval == C_OK) {
    sendChildCowInfo(CHILD_INFO_TYPE_RDB_COW_SIZE, "RDB");
  }
  exitFromChild((retval == C_OK) ? 0 : 1);
} else {
  /* Parent */
  if (childpid == -1) {
    server.lastbgsave_status = C_ERR;
    serverLog(LL_WARNING,"Can't save in background: fork: %s",
      strerror(errno));
    return C_ERR;
  }
  serverLog(LL_NOTICE,"Background saving started by pid %ld",(long) childpid);
  server.rdb_save_time_start = time(NULL);
  server.rdb_child_type = RDB_CHILD_TYPE_DISK;
  return C_OK;
}

保存好的 RDB 可以在服务下次启动时直接读入内存中:

image

4.2 AOF 持久化

除了 RDB 持久化方式之外,Redis 还支持 AOF(Append Only File) 持久化机制。

AOF 通过保存 Redis 服务执行的写命令来保存数据变更记录,Redis 的主线程是一个事件循环(类似于 JS 的 eventloop?),在每个循环的结束会调用 flushAppendOnlyFile() 方法进行数据的写入,这个方法的行为受配置文件中的 appendfsync 参数的影响。

appendfsync 有以下可能的值:

  1. always :将 aof_buf 中的内容写入并同步到 AOF 文件当中。
  2. everysec :将 aof_buf 中的内容写入到 AOF 文件当中,如果上一次同步已经过去了一秒,则立即开始同步文件内容。
  3. no :将 aof_buf 中的内容写入到 AOF 文件当中,但是不进行同步,何时同步由 OS 决定。

这里的写入和同步是两个概念,操作系统会将对文件的操作缓存在 buffer 当中,并不会立即同步到磁盘文件上,如果想要立即同步可以调用系统函数 fsync 或者 fdatasync。

AOF 数据还原时,会建立一个伪客户端(用以执行命令),然后读取 AOF 中的命令一条一条的执行直到执行完毕。

5 实践操作

5.1 主从同步

通过 redis-server 在本地起三个服务,分别使用 6379、6380 和 6381 端口,然后在 6380 和 6381 端口的 Redis 服务分别执行 SLAVEOF localhost 6379 命令指定它们称为 6379 节点的从服务。

执行完毕之后查看 6379 的 INFO:

# Replication
role:master
connected_slaves:2
slave0:ip=127.0.0.1,port=6380,state=online,offset=0,lag=1
slave1:ip=127.0.0.1,port=6381,state=wait_bgsave,offset=0,lag=0
master_failover_state:no-failover
master_replid:4a7af4a743c0bc7a44d8d0096aec5826f202ab94
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:14
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:14

可以发现它已经有了两个从服务。

此时在主服务执行 SET name russ 添加字符串键,然后到从服务查询:GET name,即可得到刚刚设置好的值 “russ”。

向从服务发送 INFO 命令查看其节点状态:

# Replication
role:slave
master_host:localhost
master_port:6379
master_link_status:up
master_last_io_seconds_ago:6
master_sync_in_progress:0
slave_read_repl_offset:679
slave_repl_offset:679
slave_priority:100
slave_read_only:1
replica_announced:1
connected_slaves:0
master_failover_state:no-failover
master_replid:4a7af4a743c0bc7a44d8d0096aec5826f202ab94
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:679
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:679

输出了它的身份信息(从节点)以及对应的主节点信息。

5.2 Sentinel

基于 5.1 的基础继续进行实践,在本地新建一个 sentinel.conf 文件:

sentinel monitor redis-master 127.0.0.1 6379 1
sentinel down-after-milliseconds redis-master 1000
sentinel failover-timeout redis-master 180000
sentinel parallel-syncs redis-master 1

然后在命令行运行 redis-sentinel 命令:redis-sentinel sentinel.conf,运行完可以看到 sentinel 进程已经运行起来了。

在 5.1 案例中,6379 端口的服务是主服务,其他两个是从服务,所以现在我们直接让 6379 的服务下线(命令行按 Control + C 即可)。下线之后由于我们设置的主观下线超时时间是 1s,所以 Sentinel 马上就将 6379 的主节点标记为主观下线;又由于我们将客观下线的标准设置为 1 个主观下线,所以 Sentinel 直接认为这个服务已经客观下线了,所以立马开始故障转移,此时 6380 的从服务替换成为了主服务,其 INFO 如下:

# Replication
role:master
connected_slaves:1
slave0:ip=127.0.0.1,port=6381,state=online,offset=12652,lag=0
master_failover_state:no-failover
master_replid:d22e208251adf0822fad27ece0fac56781f3eec7
master_replid2:b2502e9696ba8111121f2c361b64f97c1b9ec261
master_repl_offset:12652
second_repl_offset:11232
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:12652

对应的 Sentinel 进程输出信息如下:

92545:X 28 Jun 2022 15:35:20.258 # Sentinel ID is 42508ec937b2116038b52bb35ebcd6518723ab95
92545:X 28 Jun 2022 15:35:20.258 # +monitor master redis-master 127.0.0.1 6379 quorum 1
92545:X 28 Jun 2022 15:35:37.020 # +sdown master redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:37.020 # +odown master redis-master 127.0.0.1 6379 #quorum 1/1
92545:X 28 Jun 2022 15:35:37.020 # +new-epoch 1
92545:X 28 Jun 2022 15:35:37.020 # +try-failover master redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:37.023 * Sentinel new configuration saved on disk
92545:X 28 Jun 2022 15:35:37.023 # +vote-for-leader 42508ec937b2116038b52bb35ebcd6518723ab95 1
92545:X 28 Jun 2022 15:35:37.023 # +elected-leader master redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:37.023 # +failover-state-select-slave master redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:37.123 # +selected-slave slave 127.0.0.1:6380 127.0.0.1 6380 @ redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:37.123 * +failover-state-send-slaveof-noone slave 127.0.0.1:6380 127.0.0.1 6380 @ redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:37.181 * +failover-state-wait-promotion slave 127.0.0.1:6380 127.0.0.1 6380 @ redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:38.041 * Sentinel new configuration saved on disk
92545:X 28 Jun 2022 15:35:38.041 # +promoted-slave slave 127.0.0.1:6380 127.0.0.1 6380 @ redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:38.041 # +failover-state-reconf-slaves master redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:38.102 * +slave-reconf-sent slave 127.0.0.1:6381 127.0.0.1 6381 @ redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:39.107 * +slave-reconf-inprog slave 127.0.0.1:6381 127.0.0.1 6381 @ redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:39.107 * +slave-reconf-done slave 127.0.0.1:6381 127.0.0.1 6381 @ redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:39.161 # +failover-end master redis-master 127.0.0.1 6379
92545:X 28 Jun 2022 15:35:39.161 # +switch-master redis-master 127.0.0.1 6379 127.0.0.1 6380
92545:X 28 Jun 2022 15:35:39.161 * +slave slave 127.0.0.1:6381 127.0.0.1 6381 @ redis-master 127.0.0.1 6380
92545:X 28 Jun 2022 15:35:39.161 * +slave slave 127.0.0.1:6379 127.0.0.1 6379 @ redis-master 127.0.0.1 6380
92545:X 28 Jun 2022 15:35:39.163 * Sentinel new configuration saved on disk
92545:X 28 Jun 2022 15:35:40.179 # +sdown slave 127.0.0.1:6379 127.0.0.1 6379 @ redis-master 127.0.0.1 6380

可以看到 Sentinel 将 6380 的服务替换成了主节点。

此时我们继续,将原来的 6379 服务启动起来模拟服务故障恢复的场景,运行 redis-server 命令,运行完之后,Sentinel 进程输出如下:

92545:X 28 Jun 2022 15:43:49.808 # -sdown slave 127.0.0.1:6379 127.0.0.1 6379 @ redis-master 127.0.0.1 6380
92545:X 28 Jun 2022 15:43:59.782 * +convert-to-slave slave 127.0.0.1:6379 127.0.0.1 6379 @ redis-master 127.0.0.1 6380

第一行说明 6379 的服务重新上线了,此时它是一个从服务;第二行将它转换成了从服务并且同步 6380 的数据。

此时查看 6379 端口服务的 INFO 信息:

# Replication
role:slave
master_host:127.0.0.1
master_port:6380
master_link_status:up
master_last_io_seconds_ago:1
master_sync_in_progress:0
slave_read_repl_offset:51106
slave_repl_offset:51106
slave_priority:100
slave_read_only:1
replica_announced:1
connected_slaves:0
master_failover_state:no-failover
master_replid:d22e208251adf0822fad27ece0fac56781f3eec7
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:51106
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:46054
repl_backlog_histlen:5053

确认了它是一个从节点,其主节点为 6380 的服务。

5.3 RDB

在客户端执行 BGSAVE 命令,服务端输出:

92740:S 28 Jun 2022 16:05:50.168 * Background saving started by pid 93770
93770:C 28 Jun 2022 16:05:50.170 * DB saved on disk
93770:C 28 Jun 2022 16:05:50.171 * Fork CoW for RDB: current 0 MB, peak 0 MB, average 0 MB
92740:S 28 Jun 2022 16:05:50.221 * Background saving terminated with success

第一行说明了负责生成 RDB 文件的子进程 pid,第二行表示同步成功,第三行应该是表示整个过程中 CoW 复制的数据量大小。

同步出来的 RDB 文件内容:

image

因为是压缩后的二进制编码数据,所以打开之后会有乱码,但是还是可以隐约看到存储数据的内容。

5.4 AOF

在命令行中启动 redis- server 时,带上 --appendonly yes,然后在客户端执行两条命令:

  1. SET name russ
  2. SET age 18

此时查看 aof 文件内容:

*2
$6
SELECT
$1
0
*3
$3
set
$4
name
$4
russ
*3
$3
set
$3
age
$2
18

可以发现除了第一条 SELECT 是系统自动执行的之外(选择 Redis 数据库),其他两条都是我执行之后记录到 aof 文件中的。

证实了 AOF 文件中存储的是执行的写命令,再次重启 redis-server,可以发现数据还在 Redis 中,没有因为退出而发生数据丢失。