- 文档
- 教程
- 知识库
【免费下载链接】InterviewGuide
🔥🔥「InterviewGuide」是阿秀从校园->职场多年计算机自学过程的记录以及学弟学妹们计算机校招&秋招经验总结文章的汇总,包括但不限于C/C++ 、Golang、JavaScript、Vue、操作系统、数据结构、计算机网络、MySQL、Redis等学习总结,坚持学习,持续成长!
本文是 InterviewGuide(阿秀的学习笔记)面试八股文 Redis 系列的进阶篇(第 21~40 题)。围绕 Redis 在线上高并发场景中的核心问题展开:并发竞争 Key、缓存与数据库双写一致性、主从复制的完整原理、哨兵(sentinel)高可用与故障转移、Redis Cluster 的 Gossip 协议与 hash slot 寻址,以及生产环境 Redis 的典型部署形态。读完本文后,你可以系统性地回答“Redis 的高并发和高可用如何保证”“缓存和数据库如何保持一致”“主备切换会丢哪些数据”这类高频面试问题,并理解每个机制背后的参数、流程与取舍。
本系列的第一部分(Redis 01~20 题,覆盖 Redis 是什么、五种底层数据结构等基础知识)位于 04-02-01-Redis.md,建议配合阅读。
一、如何解决 Redis 的并发竞争 Key 问题
所谓 Redis 的并发竞争 Key 的问题,也就是多个系统同时对一个 key 进行操作,但最后执行的顺序和期望的顺序不同,从而导致结果不同。
推荐方案是分布式锁(ZooKeeper 和 Redis 都可以实现分布式锁)。需要注意的是:如果不存在 Redis 的并发竞争 Key 问题,不要使用分布式锁,这样会影响性能。
基于 ZooKeeper 临时有序节点可以实现的分布式锁,大致思想为:
- 每个客户端对某个方法加锁时,在 ZooKeeper 上与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点;
- 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的那一个;
- 释放锁时,只需将这个瞬时节点删除即可;
- 这种方式同时可以避免服务宕机导致的锁无法释放而产生的死锁问题——完成业务流程后删除对应的子节点即释放锁。
在工程实践中,当然是以可靠性为主,所以首推 ZooKeeper。
二、如何保证缓存与数据库双写时的数据一致性
只要你用缓存,就可能会涉及到缓存与数据库双存储双写;只要是双写,就一定会有数据一致性的问题,那么如何解决?
首先,如果你的系统不是严格要求“缓存 + 数据库”必须一致的话,缓存可以稍微跟数据库偶尔有不一致的情况。如果业务上无法接受这种不一致,就不要做这个方案,最好将读请求和写请求串行化,串到一个内存队列里去,这样就能保证一定不会出现不一致的情况。代价是:串行化之后,系统吞吐量会大幅度降低,需要用比正常情况下多几倍的机器去支撑线上请求。
最经典的缓存 + 数据库读写模式,就是预留缓存模式(Cache Aside Pattern):
- 读的时候,先读缓存;缓存没有的话,就读数据库,取出数据后放入缓存,同时返回响应;
- 更新的时候,先删除缓存,然后再更新数据库,这样读的时候就会发现缓存中没有数据,从而直接去数据库中拿最新数据。
互联网公司非常喜欢问这道题,因为缓存在互联网公司中使用非常频繁。在高并发业务场景下,数据库的性能瓶颈往往来自用户并发访问过大,所以一般使用 Redis 做一个缓冲操作,让请求先访问 Redis 而不是直接访问 MySQL 等数据库,从而减少网络请求的延迟响应。
三、数据为什么会出现不一致?
这类不一致问题主要发生在并发读写访问时,缓存操作和数据库操作相互交叉执行。
3.1 单库情况下的不一致
假设同一时刻发生了并发读写请求,例如 A(写)、B(读)2 个请求:
时序如下:
- A 请求发送一个写操作到服务端,第一步会淘汰 cache,然后因为各种原因卡住了,不再执行后面的业务(例如大量的业务操作、调用其他服务处理消耗了 1s);
- B 请求发送一个读操作,读 cache,因为 cache 已被淘汰,所以为空;
- B 请求继续读 DB,读出一个旧数据(脏数据),并写入 cache;
- A 请求终于执行完全,把新数据写入 DB。
总结:因为写操作最后才把数据入 DB、且没有同步,cache 里一直保持着脏数据。
脏数据是指源系统中的数据不在给定的范围内、或对实际业务毫无意义,或是数据格式非法,以及在源系统中存在不规范的编码和含糊的业务逻辑。
3.2 主从同步、读写分离情况下的不一致
- A 请求发送一个写操作到服务端,第一步淘汰 cache;
- A 请求写主数据库,写入最新数据;
- B 请求发送一个读操作,读 cache,因为 cache 已淘汰,所以为空;
- B 请求继续读 DB,读的是从库,此时主从同步还没完成,读出旧数据(脏数据),脏数据入 cache;
- 最后数据库主从同步完成。
总结:这种情况下请求 A 和请求 B 的操作时序没有问题,是主从同步的时延(假设 1s)问题,导致读请求读取从库读到脏数据,造成数据不一致。
根本原因:
- 单库下,逻辑处理中消耗 1s,可能读到旧数据入缓存;
- 主从 + 读写分离下,在 1s 的主从同步时延中,从库的旧数据入了缓存。
四、常见的缓存一致性优化方案
4.1 缓存双淘汰法
- 先淘汰缓存;
- 再写数据库;
- 往消息总线(ESB)发送一个淘汰消息,发送后立即返回。写请求的处理时间几乎没有增加。
这个方法淘汰了缓存两次,因此被称为“缓存双淘汰法”。在消息总线下游,有一个异步淘汰缓存的消费者,在拿到淘汰消息后1s 后再次淘汰缓存。这样,即使在一秒内有脏数据入缓存,也能够被淘汰掉。
4.2 异步淘汰缓存(binlog 方案)
上述步骤都是在业务线里执行。另一种思路是:新增一个线下的读取 binlog、异步淘汰缓存的模块,读取 binlog 的总数据,然后进行异步淘汰。
这里提供一个具体思路:
1. 总体思路:MySQL binlog 增量发布订阅消费 + 消息队列 + 增量数据更新到 Redis。
- 读请求走 Redis:热数据基本都在 Redis;
- 写请求走 MySQL:增删改都操作 MySQL;
- 更新 Redis 数据:MySQL 的数据操作产生 binlog,用来更新到 Redis。
2. Redis 更新:数据操作主要分为两块——
- 全量:将全部数据一次写入到 Redis;
- 增量:实时更新,指的是 MySQL 的 update、insert、delete 变更数据。
这样一旦 MySQL 中产生了新的写入、更新、删除等操作,就可以把 binlog 相关的消息推送至 Redis,Redis 再根据 binlog 中的记录对自身进行更新,就无需再在业务线中去操作缓存内容。
五、Redis 的高并发是如何保证的:主从架构与 replication
Redis 的主从架构模式是实现高并发的主要依赖,很多项目只需要一主多从就可以实现其所需的功能。通常使用单主用来写入数据,单机几万 QPS;多从一般是查询数据,多个从实例可以提供每秒 10w 的 QPS。
一些项目需要在实现高并发的同时尽可能多地容纳大量数据,这时需要使用 Redis 集群。使用 Redis 集群之后,可以提供每秒几十万的读写并发。
单机的 Redis 能够承载的 QPS 大概在上万到几万不等。对缓存来说,一般用来支撑读高并发,因此架构做成主从(master-slave)架构:一主多从,主负责写,并将数据复制到其它 slave 节点;从节点负责读,所有读请求全部走从节点。这样也能很轻松地实现水平扩容,支撑读高并发。
可以概括为:Redis replication → 主从架构 → 读写分离 → 水平扩容支撑读高并发。
5.1 Redis replication 的核心机制
- Redis 采用异步方式复制数据到 slave 节点;不过 Redis 2.8 开始,slave node 会周期性地确认自己每次复制的数据量;
- 一个 master node 可以配置多个 slave node;
- slave node 也可以连接其他的 slave node;
- slave node 做复制的时候,不会 block master node的正常工作;
- slave node 在做复制的时候,也不会 block 对自己的查询操作,它会用旧的数据集来提供服务;但复制完成时需要删除旧数据集、加载新数据集,这个时候会暂停对外服务;
- slave node 主要用来横向扩容、做读写分离,扩容的 slave node 可以提高读的吞吐量。
注意:如果采用了主从架构,建议必须开启master node 的持久化。不建议用 slave node 作为 master node 的数据热备——如果关掉 master 的持久化,master 宕机重启时数据可能是空的,经过复制后 slave node 的数据也可能全丢。另外,master 的各种备份方案也需要做:万一本地的所有文件丢失,从备份中挑选一份 RDB 去恢复 master,这样才能确保启动的时候是有数据的。即使采用了高可用机制、slave node 可以自动接管 master node,但也可能 sentinel 还没检测到 master failure 时 master node 就自动重启了,同样可能导致所有 slave node 数据被清空。
5.2 主从复制的核心原理
当启动一个 slave node 时,它会发送一个PSYNC命令给 master node。如果这是 slave node初次连接到 master node,会触发一次full resynchronization(全量复制):master 启动一个后台线程开始生成一份RDB快照文件,同时把从客户端新收到的所有写命令缓存在内存中。
RDB文件生成完毕后,master 将其发送给 slave;slave 会先写入本地磁盘,再从本地磁盘加载到内存中。接着 master 会把内存中缓存的写命令发送到 slave,slave 也会同步这些数据。
如果 slave node 跟 master node 之间网络故障断开连接,会自动重连;连接之后 master node 仅会复制给 slave部分缺少的数据。
5.3 主从复制的断点续传
从 Redis 2.8 开始,支持主从复制的断点续传:如果主从复制过程中网络连接断掉了,可以接着上次复制的地方继续复制,而不是从头开始复制一份。
master node 会在内存中维护一个backlog;master 和 slave 都会保存一个replica offset和一个master run id,offset 就保存在 backlog 中。
如果 master 和 slave 网络连接断掉了,slave 会让 master 从上次 replica offset 开始继续复制;如果没有找到对应的 offset,就会执行一次resynchronization。
如果根据 host+ip 定位 master node 是不靠谱的:如果 master node 重启或者数据出现了变化,slave node 应该根据不同的run id来区分。
5.4 无磁盘化复制
master 可以在内存中直接创建RDB然后发送给 slave,不再自己本地落地磁盘。只需要在配置文件中开启:
repl-diskless-sync yes # 等待 5s 后再开始复制,因为要等更多 slave 重新连接过来 repl-diskless-sync-delay 55.5 过期 key 处理
slave不会过期 key,只会等待 master 过期 key。如果 master 过期了一个 key,或者通过 LRU 淘汰了一个 key,那么会模拟一条 del 命令发送给 slave。
5.6 复制的完整流程
slave node 启动时,会在自己本地保存 master node 的信息(包括 master 的host和ip),但复制流程还没开始。slave node 内部有个定时任务,每秒检查是否有新的 master node 要连接和复制,如果发现,就跟 master node 建立 socket 网络连接,然后 slave node 发送ping命令给 master node。
如果 master 设置了requirepass,那么 slave node 必须发送masterauth的口令过去进行认证。master node第一次执行全量复制,将所有数据发给 slave node;后续 master node 持续将写命令异步复制给 slave node。
全量复制要点:
- master 执行
bgsave,在本地生成一份 RDB 快照文件; - master node 将 RDB 快照文件发送给 slave node。如果 RDB 复制时间超过 60 秒(
repl-timeout),slave node 会认为复制失败,可以适当调大这个参数(对千兆网卡的机器,一般每秒传输 100MB,6G 文件很可能超过 60s); - master node 在生成 RDB 时,会把所有新的写命令缓存在内存中,等 slave node 保存了 RDB 之后,再将新的写命令复制给 slave node;
- 如果在复制期间,内存缓冲区持续消耗超过 64MB,或者一次性超过 256MB,那么停止复制、复制失败:
client-output-buffer-limit slave 256MB 64MB 60- slave node 接收到 RDB 之后,清空自己的旧数据,然后重新加载 RDB 到自己的内存中,同时基于旧的数据版本对外提供服务;
- 如果 slave node 开启了 AOF,那么会立即执行
BGREWRITEAOF重写 AOF。
增量复制要点:
- 如果全量复制过程中 master-slave 网络连接断掉,slave 重新连接 master 时会触发增量复制;
- master 直接从自己的 backlog 中获取部分丢失的数据发送给 slave node,默认backlog 就是 1MB;
- master 就是根据 slave 发送的
psync中的 offset 来从 backlog 中获取数据的。
heartbeat:主从节点互相都会发送 heartbeat 信息。master 默认每隔10 秒发送一次 heartbeat;slave node 每隔1 秒发送一个 heartbeat。
异步复制:master 每次接收到写命令之后,先在内部写入数据,然后异步发送给 slave node。
六、Redis 如何才能做到高可用:failover 故障转移
如果系统在 365 天内有 99.99% 的时间都可以对外提供服务,就说系统是高可用的。
一个 slave 挂掉,不会影响可用性,还有其它 slave 在提供相同数据下的对外查询服务。但如果master node 死掉了:没法写数据了,写缓存全部失效,slave node 也没有 master 给它们复制数据了,系统相当于不可用。
Redis 的高可用架构叫做failover故障转移(主备切换):master node 在故障时自动检测,并将某个 slave node 自动切换为 master node 的过程,实现了 Redis 主从架构下的高可用。
七、Redis 基于哨兵集群实现高可用
7.1 哨兵(sentinel)的介绍
哨兵是 Redis 集群架构中非常重要的一个组件,主要有以下功能:
- 集群监控:负责监控 Redis master 和 slave 进程是否正常工作;
- 消息通知:如果某个 Redis 实例有故障,哨兵负责发送消息作为报警通知给管理员;
- 故障转移:如果 master node 挂掉了,会自动转移到 slave node 上;
- 配置中心:如果故障转移发生了,通知 client 客户端新的 master 地址。
哨兵用于实现 Redis 集群的高可用,本身也是分布式的,作为一个哨兵集群运行、互相协同工作:
- 故障转移时,判断一个 master node 是否宕机了,需要大部分的哨兵都同意才行,涉及到分布式选举问题;
- 即使部分哨兵节点挂掉了,哨兵集群仍能正常工作——如果作为高可用机制重要组成部分的故障转移系统本身是单点的,那就很糟糕了。
7.2 哨兵的核心知识
- 哨兵至少需要 3 个实例,来保证自己的健壮性;
- 哨兵 + Redis 主从的部署架构,是不保证数据零丢失的,只能保证 Redis 集群的高可用性;
- 对于哨兵 + Redis 主从这种复杂的部署架构,尽量在测试环境和生产环境都进行充足的测试和演练。
哨兵集群必须部署 2 个以上节点。如果哨兵集群仅仅部署了 2 个哨兵实例,quorum = 1:
+----+ +----+ | M1 |---------| R1 | | S1 | | S2 | +----+ +----+配置quorum=1:如果 master 宕机,s1 和 s2 中只要有 1 个哨兵认为 master 宕机了,就可以进行切换,同时 s1 和 s2 会选举出一个哨兵来执行故障转移。但是这个时候需要majority(大多数哨兵都在运行):
2 个哨兵,majority=2 3 个哨兵,majority=2 4 个哨兵,majority=2 5 个哨兵,majority=3 ...如果此时仅仅是 M1 进程宕机了、哨兵 s1 正常运行,那么故障转移是 OK 的;但如果整个 M1 和 S1 所在的机器宕机了,哨兵只剩 1 个,此时没有 majority 允许执行故障转移——虽然另一台机器上还有一个 R1,但故障转移不会执行。
经典的 3 节点哨兵集群是这样的:
+----+ | M1 | | S1 | +----+ | +----+ | +----+ | R2 |----+----| R3 | | S2 | | S3 | +----+ +----+配置quorum=2:如果 M1 所在机器宕机了,三个哨兵还剩下 2 个,S2 和 S3 可以一致认为 master 宕机了,然后选举出一个来执行故障转移;同时 3 个哨兵的 majority 是 2,剩下的 2 个哨兵运行着,就可以允许执行故障转移。
八、Redis 哨兵主备切换的数据丢失问题
8.1 导致数据丢失的两种情况
(1)异步复制导致的数据丢失
因为 master → slave 的复制是异步的,可能有部分数据还没复制到 slave,master 就宕机了,此时这部分数据就丢失了。
(2)脑裂(split-brain)导致的数据丢失
脑裂是指某个 master 所在机器突然脱离了正常的网络,跟其他 slave 机器不能连接,但实际上 master 还在运行。此时哨兵可能就会认为master 宕机了,然后开启选举,将其他 slave 切换成了 master。这个时候集群里就有了两个 master,即所谓的脑裂。
此时虽然某个 slave 被切换成了 master,但 client 可能还没来得及切换到新的 master,还在继续向旧 master 写数据。因此旧 master 再次恢复时,会被作为 slave 挂到新的 master 上,自己的数据会被清空、重新从新 master 复制数据;而新 master 并没有后来 client 写入的数据——这部分数据也就丢失了。
8.2 数据丢失问题的解决方案
进行如下配置:
min-slaves-to-write 1 min-slaves-max-lag 10表示要求至少有 1 个 slave,数据复制和同步的延迟不能超过 10 秒。一旦所有 slave 的数据复制和同步延迟都超过了 10 秒钟,master 就不会再接收任何请求了。
- 减少异步复制的数据丢失:有了
min-slaves-max-lag这个配置,就可以确保一旦 slave 复制数据和 ack 延时太长,就认为 master 宕机后损失的数据可能太多,从而拒绝写请求——把 master 宕机时由于部分数据未同步到 slave 导致的数据丢失降低到可控范围内; - 减少脑裂的数据丢失:如果一个 master 出现脑裂、跟其他 slave 丢了连接,上面两个配置可以确保:如果不能继续给指定数量的 slave 发送数据,且 slave 超过 10 秒没有给自己 ack 消息,就直接拒绝客户端的写请求。因此在脑裂场景下,最多丢失 10 秒的数据。
九、sdown 和 odown 转换机制
- sdown(主观宕机):一个哨兵如果自己觉得一个 master 宕机了,就是主观宕机;
- odown(客观宕机):如果 quorum 数量的哨兵都觉得一个 master 宕机了,就是客观宕机。
sdown 的达成条件很简单:如果一个哨兵 ping 一个 master,超过了is-master-down-after-milliseconds指定的毫秒数之后,就主观认为 master 宕机了。
如果一个哨兵在指定时间内,收到了quorum 数量的其它哨兵也认为那个 master 是 sdown 的,那么就认为是 odown 了。
十、哨兵集群的自动发现机制
哨兵互相之间的发现,是通过 Redis 的pub/sub系统实现的:每个哨兵都会往__sentinel__:hello这个 channel 里发送一个消息,所有其他哨兵都可以消费到这个消息,并感知到其他哨兵的存在。
每隔2 秒钟,每个哨兵都会往自己监控的某个 master + slaves 对应的__sentinel__:hellochannel 里发送一个消息,内容是自己的 host、ip 和 runid,以及对这个 master 的监控配置。每个哨兵也会去监听自己监控的每个 master + slaves 对应的__sentinel__:hellochannel,感知到同样在监听这个 master + slaves 的其他哨兵的存在,并互相交换对 master 的监控配置、互相同步。
10.1 slave 配置的自动纠正
哨兵会负责自动纠正 slave 的一些配置:
- 如果 slave 要成为潜在的 master 候选人,哨兵会确保 slave 在复制现有 master 的数据;
- 如果 slave 连接到了一个错误的 master 上(比如故障转移之后),哨兵会确保它们连接到正确的 master 上。
10.2 slave → master 选举算法
如果一个 master 被认为 odown 了,而且 majority 数量的哨兵都允许主备切换,那么某个哨兵就会执行主备切换操作。此时首先要选举一个 slave,会考虑 slave 的以下信息:
- 跟 master 断开连接的时长;
- slave 优先级;
- 复制 offset;
- run id。
如果一个 slave 跟 master 断开连接的时间已经超过了down-after-milliseconds的 10 倍,外加 master 宕机的时长,那么 slave 就被认为不适合选举为 master:
(down-after-milliseconds * 10) + milliseconds_since_master_is_in_SDOWN_state接下来会对 slave 进行排序:
- 按slave 优先级排序,slave priority 越低,优先级越高;
- 如果 slave priority 相同,看replica offset:哪个 slave 复制了越多的数据(offset 越靠后),优先级越高;
- 如果上面两个条件都相同,选择一个run id 较小的 slave。
10.3 quorum 和 majority
每次一个哨兵要做主备切换,首先需要quorum数量的哨兵认为 odown,然后选举出一个哨兵来做切换,这个哨兵还需要得到majority哨兵的授权,才能正式执行切换。
- 如果 quorum < majority:比如 5 个哨兵,majority 就是 3,quorum 设置为 2,那么 3 个哨兵授权就可以执行切换;
- 如果 quorum >= majority:必须 quorum 数量的哨兵都授权。比如 5 个哨兵、quorum 是 5,那么必须 5 个哨兵都同意授权才能执行切换。
10.4 configuration epoch 与 configuration 传播
哨兵会对一套 Redis master + slaves 进行监控,有相应的监控配置。
执行切换的那个哨兵,会从要切换到的新 master(slave → master)那里得到一个configuration epoch,这就是一个 version 号,每次切换的 version 号都必须是唯一的。
如果第一个选举出的哨兵切换失败了,其他哨兵会等待failover-timeout时间后接替继续执行切换,此时会重新获取一个新的 configuration epoch 作为新的 version 号。
哨兵完成切换之后,会在自己本地更新生成最新的 master 配置,然后通过前面说的pub/sub消息机制同步给其他哨兵。之前的 version 号在这里就很重要了:各种消息都是通过一个 channel 去发布和监听的,一个哨兵完成一次新的切换之后,新的 master 配置是跟着新的 version 号的;其他哨兵都是根据版本号的大小来更新自己的 master 配置。
十一、Redis 集群模式的工作原理
11.1 基本通信原理:集中式 vs Gossip
集群元数据的维护有两种方式:集中式、Gossip 协议。Redis Cluster 节点间采用Gossip 协议进行通信。
集中式是将集群元数据(节点信息、故障等等)集中存储在某个节点上。典型代表是大数据领域的storm:它是分布式的大数据实时计算引擎,采用集中式的元数据存储结构,底层基于 ZooKeeper(分布式协调中间件)对所有元数据进行存储维护。
Gossip则是所有节点都持有一份元数据,不同节点如果出现元数据变更,就不断将元数据发送给其它节点,让其它节点也进行元数据变更。
两者的取舍:
- 集中式的好处在于元数据的读取和更新时效性非常好,一旦元数据出现变更就立即更新到集中存储中,其它节点读取时即可感知;不好在于所有元数据更新压力全部集中在一个地方,元数据存储可能有压力;
- Gossip 的好处在于元数据更新比较分散,不是集中在一个地方,更新请求会陆陆续续打到所有节点上去更新,降低了压力;不好在于元数据更新有延时,可能导致集群中的一些操作滞后。
节点间通信约定:
- 10000 端口:每个节点都有一个专门用于节点间通信的端口,就是自己提供服务的端口号 + 10000。比如服务端口是 7001,那么用于节点间通信的就是17001端口。每个节点每隔一段时间都会往另外几个节点发送
ping消息,其它节点接收到ping之后返回pong; - 交换的信息:包括故障信息、节点的增加和删除、hash slot 信息等等。
11.2 Gossip 协议的消息类型
Gossip 协议包含多种消息:ping、pong、meet、fail等等。
- meet:某个节点发送 meet 给新加入的节点,让新节点加入集群,然后新节点开始与其它节点通信。比如
Redis-trib.rb add-node,其实内部就是发送了一个 gossip meet 消息给新加入的节点,通知那个节点去加入集群; - ping:每个节点都会频繁给其它节点发送 ping,其中包含自己的状态和自己维护的集群元数据,互相通过 ping 交换元数据;
- pong:返回 ping 和 meet,包含自己的状态和其它信息,也用于信息广播和更新;
- fail:某个节点判断另一个节点 fail 之后,就发送 fail 给其它节点,通知它们某个节点宕机了。
ping 消息深入:ping 时要携带一些元数据,如果很频繁,可能会加重网络负担。每个节点每秒会执行10 次ping,每次选择5 个最久没有通信的其它节点;当然如果发现某个节点通信延时达到了cluster_node_timeout / 2,那么立即发送 ping,避免数据交换延时过长。
比如两个节点之间都 10 分钟没有交换数据了,整个集群就处于严重的元数据不一致状态。所以cluster_node_timeout可以调节:如果调得比较大,会降低 ping 的频率。
每次 ping 会带上自己节点的信息,以及1/10 其它节点的信息发送出去进行交换:至少包含3个其它节点的信息,最多包含总节点数减 2个其它节点的信息。
11.3 分布式寻址算法
常见的分布式寻址方案有:hash 算法(大量缓存重建)、一致性 hash 算法(自动缓存迁移)+ 虚拟节点(自动负载均衡)、Redis Cluster 的 hash slot 算法。
(1)hash 算法
来了一个 key,首先计算 hash 值,然后对节点数取模,打在不同的 master 节点上。一旦某个 master 节点宕机,所有请求过来都会基于最新的剩余 master 节点数去取模尝试取数据,这会导致大部分请求全部无法拿到有效的缓存,大量流量涌入数据库——即缓存雪崩式的重建问题。
(2)一致性 hash 算法
一致性 hash 算法将整个 hash 值空间组织成一个虚拟的圆环,整个空间按顺时针方向组织,然后将各个 master 节点(使用服务器的 ip 或主机名)进行 hash,确定每个节点在哈希环上的位置。
来了一个 key,首先计算 hash 值并确定此数据在环上的位置,从此位置沿环顺时针“行走”,遇到的第一个 master 节点就是 key 所在位置。
如果一个节点挂了,受影响的数据仅仅是此节点到环空间前一个节点(沿逆时针方向遇到的第一个节点)之间的数据,其它不受影响;增加一个节点也同理。
但一致性 hash 算法在节点太少时,容易因为节点分布不均匀而造成缓存热点问题。为了解决热点问题,一致性 hash 引入了虚拟节点机制:对每一个节点计算多个 hash,每个计算结果位置都放置一个虚拟节点,从而实现数据的均匀分布和负载均衡。
(3)Redis Cluster 的 hash slot 算法
Redis Cluster 有固定的16384个 hash slot:对每个 key 计算CRC16值,然后对16384取模,即可获取 key 对应的 hash slot。
Redis Cluster 中每个 master 都会持有部分 slot,比如有 3 个 master,每个 master 可能持有 5000 多个 hash slot。hash slot 让 node 的增加和移除变得很简单:
- 增加一个 master,就将其它 master 的 hash slot 移动部分过去;
- 减少一个 master,就将它的 hash slot 移动到其他 master 上去。
移动 hash slot 的成本非常低。客户端的 api 可以让指定的数据走同一个 hash slot,通过hash tag来实现。
任何一台机器宕机,其它节点都不受影响,因为 key 找的是 hash slot,不是机器。
11.4 Redis Cluster 的高可用与主备切换原理
Redis Cluster 的高可用原理几乎跟哨兵类似。
判断节点宕机:如果一个节点认为另外一个节点宕机,就是pfail(主观宕机);如果多个节点都认为另外一个节点宕机了,就是fail(客观宕机),跟哨兵的 sdown/odown 原理几乎一样。在cluster-node-timeout内某个节点一直没有返回pong,就被认为pfail。如果一个节点认为某个节点pfail了,会在 gossip ping 消息中ping给其它节点,如果超过半数的节点都认为pfail了,就会变成fail。
从节点过滤:对宕机的 master node,从其所有的 slave node 中选择一个切换成 master。检查每个 slave node 与 master node 断开连接的时间,如果超过了cluster-node-timeout * cluster-slave-validity-factor,那么就没有资格切换成 master。
从节点选举:每个从节点都根据自己对 master 复制数据的 offset 来设置一个选举时间——offset 越大(复制数据越多)的从节点,选举时间越靠前,优先进行选举。所有的 master node 开始 slave 选举投票,给要进行选举的 slave 投票,如果大部分 master node(N/2 + 1)都投票给了某个从节点,选举通过,那个从节点就可以切换成 master。
与哨兵比较:整个流程跟哨兵非常类似。Redis Cluster 功能强大,直接集成了 replication 和 sentinel 的功能。
十二、深入理解缓存与数据库双写一致性
12.1 Cache Aside Pattern
最经典的缓存 + 数据库读写模式:
- 读的时候,先读缓存;缓存没有的话,就读数据库,取出数据后放入缓存,同时返回响应;
- 更新的时候,先更新数据库,然后再删除缓存。
为什么是删除缓存,而不是更新缓存?
原因很简单:在很多复杂点的缓存场景中,缓存并不单单是数据库中直接取出来的值。比如可能更新了某个表的一个字段,而其对应的缓存需要查询另外两个表的数据并进行运算,才能计算出缓存的最新值。另外,更新缓存的代价有时候是很高的——是不是每次修改数据库的时候,都一定要将其对应的缓存更新一份?对于比较复杂的缓存数据计算场景就不是这样了:如果你频繁修改一个缓存涉及的多个表,缓存也频繁更新,但问题在于这个缓存到底会不会被频繁访问到?
举个栗子:一个缓存涉及的表的字段在 1 分钟内修改了 20 次甚至 100 次,那么缓存就更新 20 次、100 次;但这个缓存在 1 分钟内只被读取了 1 次,存在大量的冷数据。实际上,如果只是删除缓存的话,那么 1 分钟内这个缓存不过重新计算一次而已,开销大幅度降低,用到缓存才去算缓存。
其实“删除缓存而不是更新缓存”就是一个lazy 计算的思想:不要每次都重新做复杂的计算(不管它会不会用到),而是让它到需要被使用的时候再重新计算。像 MyBatis、Hibernate 都有懒加载思想:查询一个部门时,部门带了一个员工 list,没有必要每次查询部门都把里面的 1000 个员工数据也同时查出来——因为 80% 的情况下,查这个部门就只是要访问部门信息。只有在真的要访问里面的员工时,才会去数据库查询这 1000 个员工。
12.2 最初级的缓存不一致问题及解决方案
问题:先更新数据库,再删除缓存。如果删除缓存失败了,数据库中是新数据、缓存中是旧数据,数据就出现了不一致。
解决思路 1:先删除缓存,再更新数据库。如果数据库更新失败了,那么数据库中是旧数据、缓存中是空的,那么数据不会不一致——因为读的时候缓存没有,所以会去读数据库中的旧数据,然后更新到缓存中。
解决思路 2:延时双删。依旧是先更新数据库,再删除缓存,唯一不同的是,把删除的动作在不久之后再执行一次,比如 5s 之后:
public void set(key, value) { putToDb(key, value); deleteFromRedis(key); // ... a few seconds later deleteFromRedis(key); }删除的动作可以有多种选择:
- 使用
DelayQueue:会随着 JVM 进程的死亡丢失更新; - 放在
MQ中:但编码复杂度会增加。
总之,需要综合各种因素做设计,选择一个最合理的解决方案。
12.3 比较复杂的数据不一致问题分析
数据发生了变更,先删除了缓存,然后要去修改数据库,此时还没修改完。一个请求过来去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。随后数据变更的程序完成了数据库的修改——数据库和缓存中的数据不一样了……
为什么上亿流量高并发场景下缓存会出现这个问题?
只有在对一个数据并发地读写时才可能出现这种问题。如果并发量很低(比如每天访问量只有 1 万次),很少会出现这种不一致场景。但如果每天是上亿的流量、每秒并发读几万,每秒只要有数据更新的请求,就可能会出现上述数据库 + 缓存不一致的情况。
解决方案:
更新数据时,根据数据的唯一标识将操作路由之后,发送到一个 JVM 内部队列中。读取数据时,如果发现数据不在缓存中,那么将“读取数据 + 更新缓存”的操作也根据唯一标识路由之后,发送到同一个 JVM 内部队列中。一个队列对应一个工作线程,每个工作线程串行拿到对应的操作,一条一条地执行。
这样一来,一个数据变更操作先删除缓存、再去更新数据库但还没完成;此时如果一个读请求过来没读到缓存,可以先把缓存更新请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成。
这里有一个优化点:一个队列中多个更新缓存请求串在一起是没意义的,因此可以做过滤——如果发现队列中已经有一个更新缓存的请求,就不用再放更新请求进去了,直接等待前面的更新操作请求完成即可。
待那个队列对应的工作线程完成了上一个操作的数据库修改之后,才会去执行下一个操作(缓存更新),此时会从数据库中读取最新的值写入缓存。如果请求还在等待时间范围内,不断轮询发现可以取到值了,就直接返回;如果请求等待时间超过一定时长,那么这一次直接从数据库中读取当前的旧值。
高并发场景下该方案要注意的问题:
- 读请求长时阻塞:由于读请求进行了非常轻度的异步化,一定要注意读超时问题,每个读请求必须在超时时间范围内返回。该方案最大的风险点在于数据更新很频繁,导致队列中积压了大量更新操作,读请求发生大量超时,最后导致大量请求直接走数据库。务必通过一些模拟真实的测试,看看更新数据的频率是怎样的;
- 队列积压的压测算例:如果一个内存队列里积压了 100 个商品的库存修改操作,每个库存修改操作耗费 10ms,那么最后一个商品的读请求可能要等待 10 × 100 = 1000ms = 1s 后才能得到数据,导致读请求长时阻塞。一定要根据实际业务系统的运行情况做压力测试、模拟线上环境,看最繁忙时内存队列可能挤压多少更新操作。如果读请求在 200ms 内返回,计算过后哪怕最繁忙时积压 10 个更新操作、最多等待 200ms,那还可以接受;
- 积压特别多就加机器:让每个机器上部署的服务实例处理更少的数据,每个内存队列中积压的更新操作就会越少;
- 实际测算:一般数据的写频率是很低的,读高并发、读缓存架构的项目写请求非常少,每秒 QPS 能到几百就不错了。例如一秒有 500 个写操作,分成 5 个时间片,每 200ms 100 个写操作,放到 20 个内存队列中,每个队列可能就积压 5 个写操作;每个写操作性能测试后一般在 20ms 左右完成,那么针对每个内存队列数据的读请求最多 hang 一会儿,200ms 以内肯定能返回。单机支撑写 QPS 几百没问题;如果写 QPS 扩大 10 倍,就扩容 10 倍的机器,每台 20 个队列;
- 读请求并发量过高:还必须做好压力测试,确保恰巧碰上上述情况时,大量读请求在几十毫秒的延时 hang 在服务上,看服务能不能扛住、需要多少机器才能扛住最大极限的峰值。但因为并不是所有数据都在同一时间更新、缓存也不会同一时间失效,所以每次可能只有少数数据的缓存失效,对应读请求的并发量也不会特别大;
- 多服务实例部署的请求路由:如果服务部署了多个实例,必须保证执行数据更新操作的请求、以及执行缓存更新操作的请求,都通过 Nginx 服务器路由到相同的服务实例上。比如对同一个商品的读写请求全部路由到同一台机器上——可以自己按某个请求参数做 hash 路由,也可以用 Nginx 的 hash 路由功能;
- 热点商品的路由倾斜:万一某个商品的读写请求特别高,全部打到相同机器的相同队列里,可能造成某台机器压力过大。由于只有在商品数据更新时才会清空缓存、才会导致读写并发,所以要根据业务系统去看:如果更新频率不是太高,这个问题的影响并不是特别大,但的确可能某些机器负载会高一些。
十三、Redis 的并发竞争问题与 CAS 方案
这也是线上非常常见的问题:多客户端同时并发写一个 key,可能本来应该先到的数据后到了,导致数据版本错了;或者多客户端同时获取一个 key、修改值之后再写回去,只要顺序错了,数据就错了。而且 Redis 自己就有天然解决这个问题的CAS 类的乐观锁方案。
某个时刻多个系统实例都去更新某个 key,可以基于 ZooKeeper 实现分布式锁:每个系统通过 ZooKeeper 获取分布式锁,确保同一时间只能有一个系统实例在操作某个 key,别人都不允许读和写(见第一节配图)。
另一个思路是时间戳 CAS:你要写入缓存的数据都是从 MySQL 里查出来的,都得写入 MySQL 中,写入 MySQL 时必须保存一个时间戳;从 MySQL 查出来的时候,时间戳也一起查出来。每次要写之前先判断一下:当前这个 value 的时间戳是否比缓存里的 value 的时间戳要新?如果是,可以写;否则不能用旧的数据覆盖新的数据。
十四、生产环境中的 Redis 是怎么部署的
这个问题主要想看看你了解不了解你们公司 Redis 生产集群的部署架构。如果你不了解,那确实很失职——你的 Redis 是主从架构还是集群架构?用了哪种集群方案?有没有做高可用保证?有没有开启持久化机制确保可以进行数据恢复?线上 Redis 给几个 G 的内存?设置了哪些参数?压测后你们 Redis 集群承载多少 QPS?
一个典型的回答示例:
- 架构:Redis Cluster,10 台机器:5 台部署 Redis 主实例,另外 5 台部署从实例,每个主实例挂一个从实例。5 个节点对外提供读写服务,每个节点的读写高峰 QPS 可能达到每秒 5 万,5 台机器最多 25 万读写请求每秒;
- 机器配置:32G 内存 + 8 核 CPU + 1T 磁盘,但分配给 Redis 进程的是10G 内存。一般线上生产环境 Redis 内存尽量不要超过 10G,超过 10G 可能会有问题(fork 子进程等场景受影响);
- 容量:5 台机器对外提供读写,一共有 50G 内存。往内存里写的是商品数据,每条数据 10KB:100 条数据 1MB,10 万条数据 1G。常驻内存的是 200 万条商品数据,占用内存 20G,仅仅不到总内存的 50%。目前高峰期每秒 3500 左右的请求量;
- 高可用:因为每个主实例都挂了一个从实例,所以是高可用的——任何一个主实例宕机都会自动故障迁移,Redis 从实例会自动变成主实例继续提供读写服务;
- 团队:其实大型公司会有基础架构的 team 负责缓存集群的运维。
小结与延伸阅读
本文覆盖的 Redis 进阶面试题可以归纳为四条主线:
- 一致性:缓存与数据库双写的 Cache Aside Pattern、并发读写导致脏数据的两种时序(单库逻辑延迟、主从同步时延)、缓存双淘汰法与 binlog 异步淘汰、串行化内存队列方案及压测要点;
- 高并发:一主多从读写分离、replication 核心机制、全量/增量复制、断点续传(backlog + offset + run id)、无磁盘化复制、过期 key 的模拟删除;
- 高可用:failover 主备切换、哨兵集群(quorum/majority、sdown/odown、
__sentinel__:hello自动发现、slave 选举算法、configuration epoch 与配置传播)、脑裂与异步复制丢数据及min-slaves-to-write/min-slaves-max-lag缓解手段; - 集群:Gossip 协议与集中式元数据的取舍、10000 端口的节点间通信、ping 元数据交换策略、hash / 一致性 hash / hash slot(16384,CRC16 取模)寻址方案对比、Cluster 的 pfail/fail 判定与从节点选举(N/2 + 1 投票)。
本笔记的参考文献(按原文档列出)包括:《Redis 设计与实现》、《Redis 实战》、极客时间《Redis 核心技术与实战》等公开技术资料,可结合仓库中同目录下的前篇 Redis 基础 01~20 与操作系统、MySQL 等相邻八股文文档(如 04-01-01-MySQL.md)一起系统复习。
- 文档
- 教程
- 知识库
【免费下载链接】InterviewGuide
🔥🔥「InterviewGuide」是阿秀从校园->职场多年计算机自学过程的记录以及学弟学妹们计算机校招&秋招经验总结文章的汇总,包括但不限于C/C++ 、Golang、JavaScript、Vue、操作系统、数据结构、计算机网络、MySQL、Redis等学习总结,坚持学习,持续成长!
相关推荐
如何快速部署Qwen2-0.5B-Instruct-openmind:5步完成本地AI模型搭建
如何快速部署Qwen2 0.5B Instruct openmind:5步完成本地AI模型搭建 Qwen2 0.5B Instruct openmind是一款轻
Docker-GitLab Redis集群部署终极指南:主从复制与哨兵高可用配置
Docker GitLab Redis集群部署终极指南:主从复制与哨兵高可用配置 想要构建企业级的GitLab代码托管平台吗?Redis集群的高可用配置是确保系
运维云原生如何快速构建高可用Redis集群:Jeecg-Boot主从复制与哨兵模式完整指南
如何快速构建高可用Redis集群:Jeecg Boot主从复制与哨兵模式完整指南 Jeecg Boot作为一款领先的AI低代码平台,不仅支持零代码快速搭建业务系
低代码后端前端AI 应用大模型RAG工作流自动化
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考