咱们的课程已经更新 9 讲了,这段时间,我收到了很多留言。很多同学都认真地回答了课后思考题,有些回答甚至可以说是标准答案。另外,还有很多同学针对 Redis 的基本原理和关键机制,提出了非常好的问题,值得好好讨论一下。

今天,我就和你聊一聊课后题答案,并且挑选一些典型问题,集中进行一次讲解,希望可以解决你的困惑。

课后思考题答案

第 1 讲

问题:和跟 Redis 相比,SimpleKV 还缺少什么?

@曾轼麟、@Kaito 同学给出的答案都非常棒。他们从数据结构到功能扩展,从内存效率到事务性,从高可用集群再到高可扩展集群,对 SimpleKV 和 Redis 进行了详细的对比。而且,他们还从运维使用的角度进行了分析。我先分享一下两位同学的答案。

@曾轼麟同学:

  1. 数据结构:缺乏广泛的数据结构支持,比如支持范围查询的 SkipList 和 Stream 等数据结构。
  2. 高可用:缺乏哨兵或者 master-slave 模式的高可用设计;
  3. 内存安全性:缺乏内存过载时的 key 淘汰算法的支持;
  4. 内存利用率:没有充分对数据结构进行优化,提高内存利用率,例如使用压缩性的数据结构;

@Kaito 同学:

SimpleKV 所缺少的有:丰富的数据类型、支持数据压缩、过期机制、数据淘汰策略、主从复制、集群化、高可用集群等,另外,还可以增加统计模块、通知模块、调试模块、元数据查询等辅助功能。

我也给个答案总结。还记得我在【开篇词】讲过的“两大维度”“三大主线”吗?这里我们也可以借助这个框架进行分析,如下表所示。此外,在表格最后,我还从键值数据库开发和运维的辅助工具上,对 SimpleKV 和 Redis 做了对比。

10 第1~9讲课后思考题答案及常见问题答疑.webp

第 2 讲

问题:整数数组和压缩列表作为底层数据结构的优势是什么?

整数数组和压缩列表的设计,充分体现了 Redis“又快又省”特点中的“省”,也就是节省内存空间。整数数组和压缩列表都是在内存中分配一块地址连续的空间,然后把集合中的元素一个接一个地放在这块空间内,非常紧凑。因为元素是挨个连续放置的,我们不用再通过额外的指针把元素串接起来,这就避免了额外指针带来的空间开销。

我画一张图,展示下这两个结构的内存布局。整数数组和压缩列表中的 entry 都是实际的集合元素,它们一个挨一个保存,非常节省内存空间。

10 第1~9讲课后思考题答案及常见问题答疑-1.webp

Redis 之所以采用不同的数据结构,其实是在性能和内存使用效率之间进行的平衡。

第 3 讲

问题:Redis 基本 IO 模型中还有哪些潜在的性能瓶颈?

这个问题是希望你能进一步理解阻塞操作对 Redis 单线程性能的影响。在 Redis 基本 IO 模型中,主要是主线程在执行操作,任何耗时的操作,例如 bigkey、全量返回等操作,都是潜在的性能瓶颈。

第 8 讲

问题 1:5 个哨兵实例的集群,quorum 值设为 2。在运行过程中,如果有 3 个哨兵实例都发生故障了,此时,Redis 主库如果有故障,还能正确地判断主库“客观下线”吗?如果可以的话,还能进行主从库自动切换吗?

因为判定主库“客观下线”的依据是,认为主库“主观下线”的哨兵个数要大于等于 quorum 值,现在还剩 2 个哨兵实例,个数正好等于 quorum 值,所以还能正常判断主库是否处于“客观下线”状态。如果一个哨兵想要执行主从切换,就要获到半数以上的哨兵投票赞成,也就是至少需要 3 个哨兵投票赞成。但是,现在只有 2 个哨兵了,所以就无法进行主从切换了。

问题 2:哨兵实例是不是越多越好呢?如果同时调大 down-after-milliseconds 值,对减少误判是不是也有好处?

哨兵实例越多,误判率会越低,但是在判定主库下线和选举 Leader 时,实例需要拿到的赞成票数也越多,等待所有哨兵投完票的时间可能也会相应增加,主从库切换的时间也会变长,客户端容易堆积较多的请求操作,可能会导致客户端请求溢出,从而造成请求丢失。如果业务层对 Redis 的操作有响应时间要求,就可能会因为新主库一直没有选定,新操作无法执行而发生超时报警。

调大 down-after-milliseconds 后,可能会导致这样的情况:主库实际已经发生故障了,但是哨兵过了很长时间才判断出来,这就会影响到 Redis 对业务的可用性。

典型问题讲解

接下来,我再讲一些代表性问题,包括 Redis rehash 的时机和执行机制,主线程、子进程和后台线程的联系和区别,写时复制的底层实现原理,以及 replication buffer 和 repl_backlog_buffer 的区别。

问题 1:rehash 的触发时机和渐进式执行机制

我发现,很多同学对 Redis 的哈希表数据结构都很感兴趣,尤其是哈希表的 rehash 操作,所以,我再集中回答两个问题。

1.Redis 什么时候做 rehash?

Redis 会使用装载因子(load factor)来判断是否需要做 rehash。装载因子的计算方式是,哈希表中所有 entry 的个数除以哈希表的哈希桶个数。Redis 会根据装载因子的两种情况,来触发 rehash 操作:

  • 装载因子 ≥1,同时,哈希表被允许进行 rehash;
  • 装载因子 ≥5。

在第一种情况下,如果装载因子等于 1,同时我们假设,所有键值对是平均分布在哈希表的各个桶中的,那么,此时,哈希表可以不用链式哈希,因为一个哈希桶正好保存了一个键值对。

但是,如果此时再有新的数据写入,哈希表就要使用链式哈希了,这会对查询性能产生影响。在进行 RDB 生成和 AOF 重写时,哈希表的 rehash 是被禁止的,这是为了避免对 RDB 和 AOF 重写造成影响。如果此时,Redis 没有在生成 RDB 和重写 AOF,那么,就可以进行 rehash。否则的话,再有数据写入时,哈希表就要开始使用查询较慢的链式哈希了。

在第二种情况下,也就是装载因子大于等于 5 时,就表明当前保存的数据量已经远远大于哈希桶的个数,哈希桶里会有大量的链式哈希存在,性能会受到严重影响,此时,就立马开始做 rehash。

刚刚说的是触发 rehash 的情况,如果装载因子小于 1,或者装载因子大于 1 但是小于 5,同时哈希表暂时不被允许进行 rehash(例如,实例正在生成 RDB 或者重写 AOF),此时,哈希表是不会进行 rehash 操作的。

2. 采用渐进式 hash 时,如果实例暂时没有收到新请求,是不是就不做 rehash 了?

其实不是的。Redis 会执行定时任务,定时任务中就包含了 rehash 操作。所谓的定时任务,就是按照一定频率(例如每 100ms/ 次)执行的任务。

在 rehash 被触发后,即使没有收到新请求,Redis 也会定时执行一次 rehash 操作,而且,每次执行时长不会超过 1ms,以免对其他任务造成影响。

问题 2:主线程、子进程和后台线程的联系与区别

我在课程中提到了主线程、主进程、子进程、子线程和后台线程这几个词,有些同学可能会有疑惑,我再帮你总结下它们的区别。

首先,我来解释一下进程和线程的区别。

从操作系统的角度来看,进程一般是指资源分配单元,例如一个进程拥有自己的堆、栈、虚存空间(页表)、文件描述符等;而线程一般是指 CPU 进行调度和执行的实体。

了解了进程和线程的区别后,我们再来看下什么是主进程和主线程。

如果一个进程启动后,没有再创建额外的线程,那么,这样的进程一般称为主进程或主线程。

举个例子,下面是我写的一个 C 程序片段,main 函数会直接调用一个 worker 函数,函数 worker 就是执行一个 for 循环计算。下面这个程序运行后,它自己就是一个主进程,同时也是个主线程。

int counter = 0;
void *worker() {
   for (int i=0;i<10;i++) {
      counter++;
   }
   return NULL;
}
 
int main(int argc, char *argv[]) {
   worker();
}

和这段代码类似,Redis 启动以后,本身就是一个进程,它会接收客户端发送的请求,并处理读写操作请求。而且,接收请求和处理请求操作是 Redis 的主要工作,Redis 没有再依赖于其他线程,所以,我一般把完成这个主要工作的 Redis 进程,称为主进程或主线程。

在主线程中,我们还可以使用 fork 创建子进程,或是使用 pthread_create 创建线程。下面我先介绍下 Redis 中用 fork 创建的子进程有哪些。

  • 创建 RDB 的后台子进程,同时由它负责在主从同步时传输 RDB 给从库;
  • 通过无盘复制方式传输 RDB 的子进程;
  • bgrewriteaof 子进程。

然后,我们再看下 Redis 使用的线程。从 4.0 版本开始,Redis 也开始使用 pthread_create 创建线程,这些线程在创建后,一般会自行执行一些任务,例如执行异步删除任务。相对于完成主要工作的主线程来说,我们一般可以称这些线程为后台线程。关于 Redis 后台线程的具体执行机制,我会在第 16 讲具体介绍。

为了帮助你更好地理解,我画了一张图,展示了它们的区别。

10 第1~9讲课后思考题答案及常见问题答疑-2.webp

问题 3:写时复制的底层实现机制

Redis 在使用 RDB 方式进行持久化时,会用到写时复制机制。我在第 5 节课讲写时复制的时候,着重介绍了写时复制的效果:bgsave 子进程相当于复制了原始数据,而主线程仍然可以修改原来的数据。

今天,我再具体讲一讲写时复制的底层实现机制。

对 Redis 来说,主线程 fork 出 bgsave 子进程后,bgsave 子进程实际是复制了主线程的页表。这些页表中,就保存了在执行 bgsave 命令时,主线程的所有数据块在内存中的物理地址。这样一来,bgsave 子进程生成 RDB 时,就可以根据页表读取这些数据,再写入磁盘中。如果此时,主线程接收到了新写或修改操作,那么,主线程会使用写时复制机制。具体来说,写时复制就是指,主线程在有写操作时,才会把这个新写或修改后的数据写入到一个新的物理地址中,并修改自己的页表映射。

我来借助下图中的例子,具体展示一下写时复制的底层机制。

bgsave 子进程复制主线程的页表以后,假如主线程需要修改虚页 7 里的数据,那么,主线程就需要新分配一个物理页(假设是物理页 53),然后把修改后的虚页 7 里的数据写到物理页 53 上,而虚页 7 里原来的数据仍然保存在物理页 33 上。这个时候,虚页 7 到物理页 33 的映射关系,仍然保留在 bgsave 子进程中。所以,bgsave 子进程可以无误地把虚页 7 的原始数据写入 RDB 文件。

10 第1~9讲课后思考题答案及常见问题答疑-3.webp