Redis

目录

一. 持久化

RDB

AOF

RDB 与 AOF 优缺点

RDB 优点

RDB 缺点

AOF 优点

AOF缺点

二. 事务

简介

命令

三. 主从复制

部署

删改主节点

nagle 算法

安全性

主从切换

拓扑结构

一主一从

⼀主多从结构

树形主从结构

原理

同步过程

全量复制

部分复制

心跳

四. 哨兵

docker 部署

创建 /root/redis/docker-compose.yml

创建 /root/redis-sentinel/docker-compose.yml

创建 /root/redis-sentinel/sentinel1.conf 、/root/redis-sentinel/sentinel2.conf 、 /root/redis-sentinel/sentinel3.conf

流程

API

五. 集群

数据分片算法

哈希求余

⼀致性哈希算法

哈希槽分区算法(Redis 使用)

限制

集群搭建(基于 docker)

shell 脚本批量创建节点配置文件(generate.sh)

docker-compose.yml

构建集群

节点握手

分配槽

分配从节点

节点通信

通信流程

Gossip 消息

节点选择

主节点宕机

故障判定

故障迁移

集群扩容

缓存

收益与成本

收益

成本

缓存更新策略

定期生成

实时⽣成

算法剔除

超时剔除

主动更新

缓存粒度说明

缓存预热(Cache preheating)

缓存穿透(Cache penetration)

缓存空对象

布隆过滤器拦截

缓存雪崩(Cache avalanche)

分布式锁

实现

过期时间

校验 id

引⼊ lua

引⼊ watch dog (看⻔狗)

引⼊ Redlock 算法


 

一. 持久化

简介

之前我们提到过,虽然 Redis 是将数据存储在内存之中的,而内存中的数据容易丢失(进程退出或系统重启等)。但 Redis 也可以将数据存储在磁盘之中来作为备份避免数据的丢失。Redis 持久化有两种策略,分别为 RDB 和 AOF。

RDB

RDB持久化是把当前的进程数据生成快照(RDB 文件,二进制文件,存储在工作目录 dir 中)保存到硬盘的过程。

在这个过程中,我们需要将当前进程的所有数据都拷贝到硬盘当中,因此,频繁的生成快照是不可行的,因此,RDB 持久化进行的是定期拷贝。

而RDB 持久化的定期拷贝是由 Redis 的配置文件(redis.conf)决定的。在配置文件中,存在 save 的相关配置,例如 save m n ,表示在 m 秒内修改过 n 次数据,则执行拷贝操作,而save "" 表示不进行自动生成快照。除此之外,如果执行systemctl stop redis 命令或是 systemctl restart redis 命令等停止或重启 Redis,也会自动执行 save 拷贝。

除此之外,还可以通过命令手动执行快照的生成。

save 命令:在当前线程执行拷贝操作,会阻塞 Redis 的其他操作,尤其是当数据量较大的时候。

bgsave 命令:Redis 通过 fork 函数创建子进程(若是当前存在子进程正在执行拷贝,则直接返回当前的 bgsave 命令)(fork 函数会拷贝父进程的 pcb、虚拟地址空间、文件描述符表等,便于进行拷贝,同时由于 fork 函数的拷贝为写时拷贝,因此不会消耗太多的资源),在子进程内进行拷贝操作,在子进程拷贝结束之后,将新创建的 RDB 文件替换原来的文件,并通过信号通知父进程,同时销毁子进程。

AOF

AOF 持久化是以独立日志的方式记录每次的命令,在重载的时候通过执行每条命令来实现重构。

使用 AOF 持久化需要在配置文件中将 appendonly 参数设置为 yes (默认为 no )。

在每次执行命令时,都会将其存储在缓存之中,因此 AOF 持久化为实时拷贝。在一段时间后将缓存中的命令数据传输到硬盘当中(可以在配置文件中的 appendfsync 参数进行配置,分为 always: 每次存储命令都将缓存中的命令数据进行传输、everysec:每一秒传输一次缓存、no:随着操作系统对缓存的操作进行传输。默认为 everysec)。

由于 AOF 持久化存储的是命令,相较于 RDB 持久化来说占用的空间更大,为了节省空间且加快重新加载的速度,Redis 提供了重写机制。重写机制可以将冗余的命令通过将当前的数据转换为新的命令进行压缩,例如将已经被删除的数据相关的命令去除,或是将修改一个数据的命令整合为在创建时直接赋值。

由于我们需要将当前的数据转换为新的命令并存储到硬盘当中,因此我们需要像 RDB 一样遍历所有的数据,因此也会有阻塞的问题,因此重写也是发生在 fork 创建的子进程当中。但与 RDB 不同的是,RDB 在进行备份的过程中所新发生的命令不予理会,只是将 fork 命令执行时的数据进行备份,新的命令在下一次进行备份。但 AOF 为实时拷贝,因此,在 AOF 进行重写的过程中,原本备存储在命令缓存区的新的命令会被额外存储到一个临时的缓存当中,原本的缓存区依旧会将命令传输到磁盘当中(为了避免在重写的过程中服务器挂掉),而当旧数据被备份结束后,将临时缓存中的新的命令增添到新创建的 aof 文件中,之后替代原本的 aof 文件(redis 7.0 版本之后会用一个增量 aof 文件替代临时的缓存,在重写结束后将其中的内容拷贝到重写的 aof 文件之中并将增量 aof 文件删除)。

而若是配置文件中的 aof-use-rdb-preamble 参数为 yes ,则会使用混合持久化,即在 AOF 执行重写的过程中将数据以 RDB 文件的形式(二进制)存储在 AOF 文件中,而在新的命令被备份到硬盘中后再使用文本形式。

类似于 RDB ,当正在执行重写时,新的重写命令会被直接返回。而若是在生成 RDB 快照,则会在 RDB 之后进行重写。

我们可以通过 bgrewriteaof 命令来手动进行重写,也可以通过配置文件中的 auto-aof-rewrite-min-size 和 auto-aof-rewrite-percentage 参数来进行自动重写(当前 aof 文件大小大于 auto-aof-rewrite-min-size 并且当前 aof 文件大小与上次重写后 aof 文件大小的比值大于等于 auto-aof-rewrite-percentage)。

当进行重启加载时,首先判断 appendonly 参数的状态来决定采用哪种持久化方案,而若是aof文件不存在的情况下会去执行RDB持久化的判断。

RDB 与 AOF 优缺点

RDB 优点

RDB非常适合灾难恢复,它是一个可以传输到远程数据中心或AmazonS3(可能是加密的)的单个紧凑文件。

RDB最大限度地提高了Redis的性能,因为Redis父进程要想持久化,唯一需要做的工作就是派生一个子进程来完成其余的工作。父进程永远不会执行磁盘I/O或类似操作。

与AOF相比,RDB允许使用大数据集更快地重新启动。

在副本上,RDB支持重新启动和故障切换后的部分重新同步。


RDB 缺点

如果Redis因任何原因在没有正确关闭的情况下停止工作,由于 RDB 无法做到实时持久化,因此会丢失一定量的数据。

RDB经常需要使用 fork 函数,以便使用子进程在磁盘上持久化。如果数据集很大,fork 函数可能会很耗时,相较而言 AOF 使用 fork 函数频率较低(仅在重写时使用)。

新老版本的rdb文件格式不同,存在老版本无法兼容新版本的问题。

AOF 优点

可以有不同的fsync策略:完全不进行fsync、每秒进行fsync和每次查询进行fsync。使用每秒fsync的默认策略,写入性能仍然很好。fsync是使用后台线程执行的,当没有进行fsync时,主线程将努力执行写入操作,因此您只能损失一秒钟的写入操作。

AOF日志是一个仅追加的日志,因此在停电时不会出现查找或损坏问题。即使由于某种原因(磁盘已满或其他原因),日志以半写命令结束,redis check aof 工具也能很容易地修复它。

Redis能够在AOF过大时自动在后台重写AOF。重写是完全安全的。 

AOF以易于理解和解析的格式包含所有操作的日志。可以轻松导出AOF文件。例如,即使使用FLUSHALL命令刷新了所有内容,只要在此期间没有执行日志重写,仍然可以通过停止服务器、删除最新命令并重新启动Redis来保存数据集。

AOF缺点

对于同一数据集,AOF文件通常比等效的RDB文件大。

根据具体的fsync策略,AOF可能比RDB慢。一般来说,将fsync设置为每秒一次,性能仍然很高,禁用fsync后,即使在高负载下,它也应该与RDB一样快。尽管如此,即使在巨大的写负载的情况下,RDB也能够提供更多关于最大延迟的保证。

如果在重写过程中有对数据库的写入,AOF可能会使用大量内存(这些写入被缓冲在内存中)。

在重写过程中到达的所有写入命令都会被写入磁盘两次。

Redis可以在的末尾冻结对新AOF文件的写入和fsyncing这些写入命令重写。


二. 事务

简介

与 MySQL 中的事务类似,都是将多个操作打包在一起执行(事务队列)。

但与 MySQL 不同的是,Redis 的事务较为简单。

  • 不具有原子性:MySQL 中的事务中的某一条命令执行失败时,会执行回滚机制,而 Redis 不会。
  • 不具有一致性:不涉及“约束”,也没有回滚。MySQL 的⼀致性体现的是运⾏事务前和运⾏后,结果都是合理有效的,不会出现中间⾮法状态。而 Redis 中当某条命令执行失败时,可能会出现不一致的情况
  • 不具有持久性:Redis 为内存数据库,而 Redis 的持久化与事务无关。
  • 不具有隔离性:Redis 为单线程,不需要进行隔离。

命令

 MULTI

开启一个事务,成功则返回 OK

每次添加⼀个操作,都会提⽰ "QUEUED"

EXEC

执行事务。

DISCARD

放弃当前事务,直接清空事务队列。

而当服务器重启时,也类似于 DISCARD 放弃事务。

WATCH
UNWATCH

WATCH 命令监视某个键值对。当在事务执行之前事务所操作的键值对被其他客户端或以其他形式所修改,则事务中对该键值对的操作不做处理。UNWATCH 命令取消对键值对的监视。

该命令实是通过类似于乐观锁的形式来进行操作。该命令赋予被监视的键值对一个版本号,当该键值对被修改后,版本号进行自增操作。而当事务执行时,若是被监视的键值对的当前的版本号与初始的版本号不同时,表明该键值对被修改。


三. 主从复制

如果 Redis 只在一个服务器中部署,当该服务器挂掉之后,会直接中断 Redis 的服务,而且,一台服务器的网络带宽等是有限的,限制了性能和并发量等。为了解决这个单点问题,通常会把数据复制多个副本部署到其他服务器,满⾜故障恢复和负载均衡等需求。Redis 存在三种部署方式:主从模式、主从+哨兵模式、集群模式。

在主从模式中,从节点可以视为主节点的副本(从节点也可以有从节点),主节点与从节点的关系为一对多,主节点上对数据做的任何修改都会同步到从节点上(会有较小的延迟),而 Redis 的主从模式不允许从节点对数据进行修改,只允许在从节点上进行数据的读取。因此,主从模式对 Redis 并发量的影响只体现在读取数据上,Redis 可以将读取任务分配给从节点,但修改数据的任务依旧只能由主节点执行。同样,从高可用性上来说,若是从节点挂掉,对 Redis 的服务影响不大,但若是主节点挂掉,就只能够进行数据的读取,而无法修改。

Redis 默认使用异步复制,低延迟、高性能。从节点会与主节点异步确认它们定期接收的数据量。因此,主节点不会每次都等待从节点处理命令,但如果需要知道从节点已经处理了什么命令。允许进行同步复制。

部署

Redis 的主从模式除了能部署在多台服务器之中,当然也可以部署在一台服务器的不同端口中(--port命令或是修改配置文件),但这种方式并没有太大的意义,当服务器挂掉后,所有的主从节点都不能使用(也可以使用 docker 容器)。若是想要使用主从模式,需要使用 slaveof 来配置,有三种方式:

  • 在配置⽂件中加⼊ slaveof {masterHost} {masterPort} 随 Redis 启动⽣效。
  •  在 redis-server 启动命令时加⼊ --slaveof {masterHost} {masterPort} ⽣效。
  • 直接使⽤ redis 命令:slaveof {masterHost} {masterPort} ⽣效。

之后,就可以启动主从节点。

可以通过 netstat -nlpt 确保 Redis 均已正确启动。

前面的三条为三个不同端口的 Redis 节点,而后面四条分别表示两个从节点与主节点之间的 tcp 链接。

启动之后就实现了主从复制。

我们可以通过 info replication 命令来查看复制的相关状态。

  • role:表示主从节点。
  • connected_slaves:从节点的数量。
  • slave:从节点的信息,offset 偏移量用于与主节点之间确定同步数据的进度,每当主节点受到修改数据的命令,主节点中的 offset 会进行自增操作(命令的字节数),而当从节点同步该条中节点中的修改数据的命令,则会使从节点中的 offset 进行同样的自增操作。若是从节点将主节点的所有数据进行了同步,则主从节点中的 offset 相同。
  • master_replid:主节点的 replication ID ,用于标识主节点。
  • master_replid2:若是主节点挂掉等情况,需要重新为从节点分配主节点或是将从节点变为主节点,则将原本的master_replid 赋值给 master_replid2 作为保存,以防原本的主节点重启以恢复原本的主从关系。
  • second_repl_offset:类似于 master_replid2 的作用。
  • repl_backlog_active:积压缓冲区是否启用。
  • repl_backlog_size:积压缓冲区的最大长度。
  • repl_backlog_first_byte_offset:积压缓冲区的起始偏移量,计算当前缓冲区可⽤范围。
  • repl_backlog_histlen:积压缓冲区的已保存数据的有效⻓度。

  • master_link_status:up / down。
  • master_last_io_seconds_ago:距离上次与主节点同步的时间(s)。
  • master_sync_in_progress:是否正在进行同步。

删改主节点

从节点通过 slaveof no one 命令可以断开与主节点复制关系,断开之后从节点变为主节点,而原本的数据依旧保存,并且可以对数据进行修改。

从节点通过 slaveof {newMasterIp} {newMasterPort} 命令还可以实现切主操作,将当前从节点的数据源切换到另⼀个主节点(断开原本主从关系、建立新的主从关系、删除原本的数据、同步新主节点中的数据)。

nagle 算法

通过修改配置文件中的 repl-disable-tcp--nodelay参数 (yes / no)来开启或关闭 tcp 的 nagle 算法(通过捎带应答来节省网络带宽,但会增大同步的延迟)。

安全性

主节点通过修改 / 添加 requirepass 参数进⾏密码验证,从节点的 masterauth 参数需要与主节点密码保持⼀致。这时,所有的从节点访问必须使⽤ auth 命令实⾏校验。

主从切换

主从模式中存在监控程序监控主节点的状态,当主节点出现故障,通过报警程序通知运维人员进行主从切换,首先在从节点中选取一个节点执行 slaveof no one 命令作为新的主节点,其他的从节点通过 slaveof {newMasterIp} {newMasterPort} 命令进行切主操作,同时更新应用方的主节点信息,若是原本的主节点恢复,则可以作为新的主节点的从节点恢复进入主从模式。

拓扑结构

一主一从

⽤于主节点出现宕机时从节点提供故障转移⽀持。

当应⽤写命令并发量较⾼且需要持久化时,可以只在从节点上开启 AOF,这样既可以保证数据安全性同时也避免了持久化对主节点的性能⼲扰。

但需要注意的是,当主节点关闭持久化功能时,如果主节点宕机要避免⾃动重启操作(因为主节点没有开启 AOF 文件,会丢失数据并将从节点中的数据给覆盖。需要手动重启并获取从节点的 AOF 文件以恢复数据)。

⼀主多从结构

⼀主多从结构使得应⽤端可以利⽤多个从节点实现读写分离。对于读⽐重较⼤的场景,可以把读命令负载均衡到不同的从节点上来分担压⼒。同时⼀些耗时的读命令可以指定⼀台专⻔的从节点执⾏,避免破坏整体的稳定性。对于写并发量较⾼的场景,多个从节点会导致主节点写命令的多次发送从⽽加重主节点的负载。

树形主从结构

树形主从结构(分层结构)使得从节点不但可以复制主节点数据,同时可以作为其他从节点的主节点继续向下层复制。通过引⼊复制中间层,与⼀主多从结构不同的是,可以有效降低主节点负载和需要传送给从节点的数据量,但会增加从节点复制的延迟。

原理

  • 保存主节点信息:例如master_host、master_port、master_link_status
  • 主从建立 socket 连接:如果从节点⽆法建⽴连接,会⽆限重试直到连接成功或者⽤⼾停⽌主从复制。
  • 发送 ping 命令:返回 pong 以确定主节点和 socket 连接的可用性,如果回复超时,从节点会断开 TCP 连接,等待定时任务下次重新建⽴连接。
  • 权限验证:验证安全性(密码)。
  • 同步数据集
  • 命令持续复制

同步过程

Redis 使⽤ psync 命令完成主从数据同步(自动执行),同步过程分为:全量复制和部分复制。

PSYNC replicationid offset

首先,从节点发送 psync 命令给主节点

如果 replicationid 设为 ? 并且 offset 设为 -1 (默认值)表示尝试进⾏全量复制,通常用于从节点首次连接主节点。

如果 replicationid 、offset 设为了具体的数值(从 offset 偏移量处进行复制),则是尝试进⾏部分复制。通常用于主节点因为网络抖动等原因与从节点断开后重新连接,此时从节点可能依旧保留了断开之前的数据。

之后,主节点根据 psync 参数和⾃⾝数据情况决定响应结果,如果回复 +FULLRESYNC replid offset,则从节点需要进⾏全量复制流程;如果回复 +CONTINEU,从节点进⾏部分复制流程;如果回复 -ERR,说明 Redis 主节点版本过低(Redis 2.8),不⽀持 psync 命令。从节点可以使⽤ sync 命令进⾏全量复制。

全量复制

3.  从节点接收主节点的运⾏信息进⾏保存。

4. 主节点执⾏ bgsave 进⾏ RDB ⽂件的持久化(RDB 文件节省空间)。

5. 从节点发送 RDB ⽂件给从节点,从节点保存 RDB 数据到本地硬盘,接受完 RDB 后从节点打印相关日志。

6. 主节点将从⽣成 RDB 到接收完成期间执⾏的写命令,写⼊缓冲区中,等从节点保完 RDB ⽂件后,主节点再将缓冲区内的数据补发给从节点,补发的数据仍然按照 RDB 的⼆进制格式追加写⼊到收到的 RDB ⽂件中,保持主从⼀致性。

但 client-output-buffer-limit slave 配置决定了缓冲区的数据量限制,默认为 256MB 64MB 60 ,表示60秒内缓冲区消耗大于64MB或是直接超过256MB,则主节点直接关闭复制客户端的连接,造成同步失败。

7. 从节点清空⾃⾝原有旧数据。

8. 从节点加载 RDB ⽂件得到与主节点⼀致的数据。

9. 如果从节点加载 RDB 完成之后,并且开启了 AOF 持久化功能,它会进⾏ bgrewriteaof 操作(在加载 RDB 文件时会加载大量的命令,需要进行重写来清除冗余的命令以节省空间),得到最近的 AOF ⽂件。

默认情况下是主节点将 RDB 文件保存保存到磁盘、发给从节点、从节点加载数据,但也可以通过配置 repl-diskless-sync 参数(yes / no)执行无磁盘操作,主节点直接将生成的 RDB 数据输送给从节点,从节点直接加载数据。无磁盘操作适用于主节点磁盘性能差但网络带宽较充裕的情况下。

部分复制

1. 当主从节点之间出现⽹络中断时,如果超过 repl-timeout 时间,主节点会认为从节点故障并终端复制连接。

2. 主从连接中断期间主节点依然响应命令,但这些复制命令都因⽹络中断⽆法及时发送给从节点,所以暂时将这些命令滞留在复制积压缓冲区中。(缓冲区本质上是先进先出的定⻓队列,所以能实现保存最近已复制数据的功能,⽤于部分复制和复制命令丢失的数据补救。如果当前从节点需要的数据,已经超出了主节点的积压缓冲区的范围,则⽆法进⾏部分复制,只能全量复制了。)

3. 当主从节点⽹络恢复后,从节点再次连上主节点。

4. 从节点将之前保存的 replicationId 和 offset 偏移量作为 psync 的参数发送给主节点,请求进⾏部分复制。

5. 主节点接到 psync 请求后,进⾏必要的验证。随后根据 offset 去复制积压缓冲区查找合适的数据,并响应 +CONTINUE 给从节点。

6. 主节点将需要从节点同步的数据发送给从节点,最终完成⼀致性。

心跳

主从节点在建立复制之后,它们之间维护着长连接并彼此发动心跳命令。

主节点默认每隔10秒对从节点发送 ping 命令,判断从节点的存活性和连接状态,可通过参数 repl-ping-slave-period 控制发送频率。

从节点在主线程中每隔1秒发送 replconf ack {offset} 命令,给主节点上报自身当前的复制偏移量来检查复制数据是否丢失并借此检测主从节点网络状态。


四. 哨兵

Redis的主从复制模式下,⼀旦主节点由于故障不能提供服务,如果主节点难以快速的恢复,需要⼈⼯进⾏主从切换。同时⼤量的客⼾端需要被通知切换到新的主节点上,会消耗大量的时间和资源。于是 Redis 从 2.8 开始提供了 Redis Sentinel(哨兵)加个来解决这个问题,除开之前所涉及到的主从节点(redis-server 进程),新增了哨兵节点(redis-sentinel 进程)。哨兵会自动识别主节点的故障并自动执行主从切换。

当主节点出现故障时,Redis Sentinel 能⾃动完成故障发现和故障转移,并通知应⽤⽅,从⽽实现真正的⾼可⽤。

Redis Sentinel 是⼀个分布式架构,其中包含多个 Sentinel 节点(多个是为了避免 Sentinel 节点挂掉或是出现误判情况,一般为奇数个)和主从节点,每个 Sentinel 节点会对所有的主从节点和其余 Sentinel 节点进⾏监控(心跳包),当它发现节点不可达时,会对节点做下线表⽰。如果下线的是主节点,会和其他的 Sentinel 节点进⾏ “协商”,当⼤多数 Sentinel 节点都对主节点不可达,它们会 “选举” 出⼀个领导节点来完成主从切换的⼯作。整个过程是完全⾃动的,不需要⼈⼯介⼊。整体的架构如图所⽰。

docker 部署

除开 docker 本体的安装,还需要安装 docker-compose

# ubuntu
apt install docker-compose
# centos
yum install docker-compose

docker 允许用户通过一个 yml 文件(以缩进表示层级结构,存储键值对形式的配置,每个目录下只能有一个 yml 文件)来定义一组相关联的容器作为一个项目。

之后获取 redis 的镜像

docker pull redis:5.0.9

首先要通过 docker-compose 创建主从节点的项目(一主两从)

创建 /root/redis/docker-compose.yml

version: '3.7'
services:
  master:
    image: 'redis:5.0.9'
    container_name: redis-master
    restart: always
    command: redis-server --appendonly yes
    ports:
      - 6379:6379
  slave1:
    image: 'redis:5.0.9'
    container_name: redis-slave1
    restart: always
    command: redis-server --appendonly yes --slaveof redis-master 6379
    ports:
      - 6380:6379
  slave2:
    image: 'redis:5.0.9'
    container_name: redis-slave2
    restart: always
    command: redis-server --appendonly yes --slaveof redis-master 6379
    ports:
      - 6381:6379
  • services:当前项目的名称,由我们自己设定,master、slave1、slave2同理,但容器有他自己的名字 container_name
  • commands:进程启动的命令,可以将其中的 ip 地址替换为容器名称(可自动解析为 ip 地址)
  • ports:容器的端口映射
docker-compose up -d
docker-compose down 

启动所有容器 / 停⽌并删除刚才创建好的容器,需要在 yml 文件所在目录执行,-d 表示在后台运行

之后创建哨兵节点的项目(三个)

创建 /root/redis-sentinel/docker-compose.yml

version: '3.7'
services:
  sentinel1:
    image: 'redis:5.0.9'
    container_name: redis-sentinel-1
    restart: always
    command: redis-sentinel /etc/redis/sentinel.conf
    volumes:
      - ./sentinel1.conf:/etc/redis/sentinel.conf
    ports:
      - 26379:26379
  sentinel2:
    image: 'redis:5.0.9'
    container_name: redis-sentinel-2
    restart: always
    command: redis-sentinel /etc/redis/sentinel.conf
    volumes:
      - ./sentinel2.conf:/etc/redis/sentinel.conf
    ports:
      - 26380:26379
  sentinel3:
    image: 'redis:5.0.9'
    container_name: redis-sentinel-3
    restart: always
    command: redis-sentinel /etc/redis/sentinel.conf
    volumes:
      - ./sentinel3.conf:/etc/redis/sentinel.conf
    ports:
      - 26381:26379
networks:
  default:
    external:
      name: redis-data_default
  • command:redis-sentinel 命令只需要哨兵节点的配置文件即可。
  • volumes:哨兵节点在运行的时候会改变配置文件的内容(配置文件中只有主节点的相关信息,在运行后会发现到其他哨兵节点并通过主节点连接到从节点,将这些节点的信息存储到配置文件中),因此不能拿一个配置文件为多个哨兵节点配置。因此需要对各自的配置文件进行映射。
  • name:docker-compose 通过 yml 文件创建的项目各自在各自的局域网内,项目内部的容器之间可以进行网络传输,但项目与项目之间无法进行网络传输。而哨兵节点的配置文件设计到了 redis-server ,因此,需要该配置使得两个项目之间可以进行网络传输送。

而将主从节点与哨兵节点的配置分为两个 yml 文件的原因是观察⽇志⽅便,且为了确保 redis 主从节点启动之后才启动 redis-sentinel。如果先启动 redis-sentinel 的话,可能触发额外的选举过程,混淆视听。

创建 /root/redis-sentinel/sentinel1.conf 、/root/redis-sentinel/sentinel2.conf 、 /root/redis-sentinel/sentinel3.conf

bind 0.0.0.0
port 26379
sentinel monitor redis-master redis-master 6379 2
sentinel down-after-milliseconds redis-master 1000
sentinel monitor <master-name> <ip> <prot> <quorum>

quorum 为法定票数,是指在至少需要法定票数个哨兵节点识别到某个节点不可达时才能确定该节点出现问题。同时,至少要有 max(quorum, num(sentinels)/2 +1) 个哨兵节点参加选举才能选出哨兵节点

sentinel down-after-milliseconds <master-name> <times>

哨兵节点在监视其他节点时如果⼼跳包在指定的时间内还没回来,则视为该节点不可达。times 越小,发现故障的时间越短,但误判率越高,times 越大则发现故障的时间越长,但误判率较低。

只有上述两个配置是必需的, 而且我们可以通过多个 sentinel monitor

sentinel down-after-milliseconds 来监视多个主节点。

sentinel parallel-syncs <master-name> <nums>

当从节点完成主从替换后,限制 nums 以内个其他的从节点同时同步新的主节点的数据。

setinel failover-timeout <master-name> <times>

当从节点进行主从替换时,若是  slaveof no one 命令一直失败,时间超出 times ,则故障转移失败。当主从替换之后,会对新的主节点执行 info 命令,同样,失败的时间超出 times 则失败。

当其他从节点去同步新的主节点的数据时,若时间超出 times(除去复制本身消耗的时间),则故障转移失败,但依旧会完成所有从节点的同步。

当故障转移失败后,下次故障转移会发生在 2*times 后。

sentinel parallel-syncs setinel failover-timeout 会在哨兵节点发现其他节点后被删除

sentinel auth-pass <master-name> <password>

当主节点配置了密码时哨兵节点来确定密码。

sentinel notification-script <master-name> <script-path>
sentinel client-reconfig-script <master-name> <script-path>

 在故障转移期间发生一些警告级事件(-sdown、-odown 等)时 / 在故障转移结束后,触发对应路径的脚本。 

sentinel known-slave <master-name> <ip> <slave-port>

在发现从节点后自动添加该参数

sentinel known-sentinel <master-name> <ip> <santinel-port> <run id>

在发现其他哨兵节点后自动添加该参数

流程

每隔10s,每个哨兵节点都会向主节点和从节点发送 info 命令获取最新的拓扑结构,当有新的从节点加入可以感知出来,当节点不可达或故障转移之后,可以实时更新节点拓扑结构。

每隔2s,每个哨兵节点会向数据节点的 __sentinel__:hello 频道发送对于主节点的判断以及当前从节点的信息(sentinel port、sentinel runid、sentinel version、master name、master ip、master port、master version),同时该哨兵节点通过订阅频道来获取其他哨兵节点对主节点的判断、交换主节点的状态或是获取新加入的哨兵节点。

每隔1s,每个哨兵节点会向所有主从节点和其他哨兵节点发送一条 ping 命令进行心跳检测

若是主节点不可用,我们就需要进行故障发现和故障转移。我们可以通过 docker-compose logs 命令(yml 文件所在目录)查看这个过程中哨兵节点的日志

其中 sdown 为主观下线,当单个哨兵节点发送的 ping 命令经过 down-after-milliseconds 没有受到相应,则表示该主节点的不可达。

odown 为客观下线,当出现主观下线后,哨兵节点会通过 sentinel is master-down-by-addr 命令向其他哨兵节点询问对主节点是否可达的判断,当识别到主节点不可达的哨兵节点数量达到法定票数,确定主节点的不可达,3 / 2 分别表示识别到主节点不可达的哨兵节点数量和法定票数。

之后就是使用 Raft 算法推举领导节点(vote-for-leader)。每个哨兵节点都给其他所有哨兵节点通过 sentinel is master-down-by-addr 命令发起⼀个"拉票请求",收到拉票请求的节点,会回复⼀个"投票响应"。响应的结果有两种可能,投 or 不投,一个节点只能投一票,包括为自己投票。在⼀轮投票完成之后,如果发现得票大于等于max(quorum, num(sentinels)/2 +1) 的节点,⾃动成为 leader,如果出现平票的情况或是不存在得票达到要求的节点,就重新再投⼀次。更早进行拉票的节点更有可能成为领导节点。
在当前日志中,f7 哨兵节点为自己投票,4d 和 2a 都为 4d 投票,因此 4d 节点成为领导节点。

而之后就是将从节点 0.4 作为新的主节点通过 slaveof no one 命令替代原本不可达的主节点 0.2(switch-master)。

在选择替换的从节点时,首先过滤掉 “ 不健康 ” 的从节点(5s 内没有回复哨兵节点的 ping 命令或与主节点失连超过down-after-milliseconds * 10),之后通过各个节点的优先级(slave-priority)来决定。若存在优先级最高(数值小)的多个节点,则通过各个节点的 offset 判断,offset 更大的从节点所同步的数据更新。若是 offset 也相同,则选取 run id(随机生成)最小的节点。

最后通过 slaveof {newMasterIp} {newMasterPort} 命令进行切主操作(slave)。

API

sentinel masters

展示所有被监视的主节点的信息:name、ip、port

sentinel master <master-name>
sentinal get-master-addr-by-name <master-name>

展示 name 为 master-name 的主节点的信息(包括 name / 不包括)

sentinel slaves <master-name>

展示 name 为 master-name 的主节点的从节点的信息

sentinel sentinels <master-name>

 展示 name 为 master-name 的主节点的哨兵节点(不包括当前节点)的信息 

sentinel reset <pattern>

对符合 pattern(通配符风格)的主节点的配置进行重置,包括清除主节点的相关状态(包括故障转移)、重新发现从节点和哨兵节点。返回重置成功的数量。

sentinel failover <master-name>

强制执行故障转移,并在转移完成后将结果同步给该主节点的其他哨兵节点使其更新其配置。

sentinel ckquorum <master-name>

检查该主节点的哨兵节点数目是否达到 quorum

sentinel flushconfig

将该哨兵节点的配置同步给磁盘中

sentinel remove <master-name>

取消该哨兵节点对该主节点对应的主从结构的监视

sentinel monitor <master-name> <ip> <prot> <quorum>

作用等用于配置文件中的作用,监视主节点。 

sentinel set <master-name>

动态修改该哨兵节点的配置 

sentinel is master-down-by-addr <ip> <port> <current_epoch> <runid>

current_epoch:当前配置纪元

runid:若 runid 为“ * ”时,表示该哨兵节点直接交换对主节点下线的判断。若 runid 为当前哨兵节点的 runid 时,作用是向其他哨兵节点发起⼀个"拉票请求"。


五. 集群

哨兵节点解决了主从结构的高可用问题,而集群通过引入多组主从结构(分片)分别存储部分数据来则解决了主从结构的储存空间不足的问题。

数据分片算法

我们需要数据分片算法来判断每个数据应该存储在那个分片上。有三种⽐较主流的实现⽅式。

哈希求余

首先针对 Redis 中键值对的 key 使用 md5 算法,将其转换为一个定长的 16 进制数,之后将其对分片数量求余,并将求余的结果作为所存储的分片的序号。

  • md5 算法所转换的数字是定长的
  • md5 算法所转换的数字是分散的
  • md5 算法的转换是不可逆的(可以通过打表的方式概率还原)

由于 md5 算法的以上特点,哈希求余可以将数据均匀的分配给每个分片

但若是我们需要对分片进行扩容,需要对每个数据重新进行求余计算,很大概率会与原本的结果不同,因此,我们就需要进行大量的数据转移,开销极大。因此需要重新建立一个新的集群来进行扩容后的数据分配,以保证原本集群的正常运行,但这样更会加大开销。

⼀致性哈希算法

依旧是需要通过算法将 key 转换为定长的 16 进制数。假设分片的数量为N,将定长 16 进制的数据范围平均分为 N 份,每一份分别存储在各个分片中。

当我们需要对分片进行扩容时(假设扩容一个),我们将一个分片所占的数据范围分为两份,将一份分给新的分片。这样就只需要将所分出来的一份数据范围中的数据进行数据转移,数据转移的数量大大减少,但会出现数据倾斜的问题,各个节点所占的数据范围相差较大,导致各个节点的数据量相差较大。我们可以通过将分片数量扩容为两倍来使每个分片都将自己的数据范围分为两份。但这样需要大量的服务器来支持。

哈希槽分区算法(Redis 使用)

该算法存在 2^14 个槽位(slots),将这些槽位平均分配给每个分片(不需要连续)。类似于哈希求余,将 key 转换为数字对 2^14 求余来决定分配给哪个槽位所对应的分片。每个分⽚的节点使⽤位图来表⽰⾃⼰持有哪些槽位。

节点之间通过⼼跳包通信。⼼跳包中包含了该节点持有哪些 slots。表⽰ 2^14 个 slots,需要的位图⼤⼩是 2KB。如果给定的 slots 数更多了,此时就需要消耗更多的空间表⽰了。这对于内存来说不算什么,但是在频繁的⽹络⼼跳包中,还是⼀个不⼩的开销的。

另⼀⽅⾯,Redis 集群⼀般不建议超过 1000 个分⽚。所以 2^14 对于最⼤ 1000 个分⽚来说是⾜够⽤的,同时也会使对应的槽位配置位图体积不⾄于很⼤。

这种方法解耦了数据与节点之间的关系,如果需要进行扩容,则每个分片都将自己的部分槽位分给新的分片,以保持每个分片所持有的槽位数量几乎一致。和一致性哈希算法类似,在扩容时只需要将分配给新分片的数据进行数据转移即可,而且也不会出现数据倾斜的问题。

节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。

支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景。

限制

  1. 若是一条命令中涉及到处于多个分片的多条数据,则会出现报错。而且,事务中多条命令所涉及到的数据也必须处于同一个分片当中。
  2. key 作为数据分区的最小粒度,不能将一个大的键值对象(hash、list ...)映射到不同的节点上。
  3. 不支持多数据库空间,单机下的 Redis 可以支持 16 个数据库,集群模式下只能使用一个数据库空间。
  4. 复制结构只支持一层,即不支持树状主从结构。

集群搭建(基于 docker)

shell 脚本批量创建节点配置文件(generate.sh)

for port in $(seq 1 9); \
do \
mkdir -p redis${port}/
touch redis${port}/redis.conf
cat << EOF > redis${port}/redis.conf
port 6379
bind 0.0.0.0
protected-mode no
appendonly yes
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
cluster-announce-ip 172.30.0.10${port}
cluster-announce-port 6379
cluster-announce-bus-port 16379
EOF
done

for port in $(seq 10 11); \
do \
mkdir -p redis${port}/
touch redis${port}/redis.conf
cat << EOF > redis${port}/redis.conf
port 6379
bind 0.0.0.0
protected-mode no
appendonly yes
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
cluster-announce-ip 172.30.0.1${port}
cluster-announce-port 6379
cluster-announce-bus-port 16379
EOF
done

前半部分为初始创建集群时的主从节点,而后半部分的两个节点用于扩容使用

  • for 是一个基于范围的循环,即 port 变量在 1 到 9 、10 到 11 中循环。seq 命令则是生成 [1, 9] 和  [10, 11]。最后的 \ 为续行符,因为 shell 默认要求代码写在一行之内,所以需要使用续行符来进行换行。
  • 在 for 循环内,由 do 和 done 表示代码块,即 for 循环的起始与结束。
  • 循环内的代码便是创建目录和 redis 的配置文件并书写该配置文件。
  • ${port} 表示获取 port 变量并将其作为命令的一部分。
  • cluster-enabled:集群的开关
  • cluster-config-file:生成集群节点
  • cluster-node-timeout:节点失联的超时时间
  • cluster-announce-ip:节点 ip
  • cluster-announce-port:节点业务端口
  • cluster-announce-bus-port:节点总线端⼝,用于与集群管理的交互
bash generate.sh

执行 shell 脚本

docker-compose.yml

version: '3.7'
  networks:
    mynet:
      ipam:
        config:
          - subnet: 172.30.0.0/24

services:
  redis1:
    image: 'redis:5.0.9'
    container_name: redis1
    restart: always
    volumes:
      - ./redis1/:/etc/redis/
    ports:
      - 6371:6379
      - 16371:16379
    command:
      redis-server /etc/redis/redis.conf
    networks:
      mynet:
        ipv4_address: 172.30.0.101

  redis2:
    image: 'redis:5.0.9'
    container_name: redis2
    restart: always
    volumes:
      - ./redis2/:/etc/redis/
    ports:
      - 6372:6379
      - 16372:16379
    command:
      redis-server /etc/redis/redis.conf
    networks:
      mynet:
        ipv4_address: 172.30.0.102

  redis3:
    image: 'redis:5.0.9'
    container_name: redis3
    restart: always
    volumes:
      - ./redis3/:/etc/redis/
    ports:
      - 6373:6379
      - 16373:16379
    command:
      redis-server /etc/redis/redis.conf
    networks:
      mynet:
        ipv4_address: 172.30.0.103

  redis4:
    image: 'redis:5.0.9'
    container_name: redis4
    restart: always
    volumes:
      - ./redis4/:/etc/redis/
    ports:
      - 6374:6379
      - 16374:16379
    command:
      redis-server /etc/redis/redis.conf
    networks:
      mynet:
        ipv4_address: 172.30.0.104

  redis5:
    image: 'redis:5.0.9'
    container_name: redis5
    restart: always
    volumes:
      - ./redis5/:/etc/redis/
    ports:
      - 6375:6379
      - 16375:16379
    command:
      redis-server /etc/redis/redis.conf
    networks:
      mynet:
        ipv4_address: 172.30.0.105

  redis6:
    image: 'redis:5.0.9'
    container_name: redis6
    restart: always
    volumes:
      - ./redis6/:/etc/redis/
    ports:
      - 6376:6379
      - 16376:16379
    command:
      redis-server /etc/redis/redis.conf
    networks:
      mynet:
        ipv4_address: 172.30.0.106

  redis7:
    image: 'redis:5.0.9'
    container_name: redis7
    restart: always
    volumes:
      - ./redis7/:/etc/redis/
    ports:
      - 6377:6379
      - 16377:16379
    command:
      redis-server /etc/redis/redis.conf
    networks:
      mynet:
        ipv4_address: 172.30.0.107

  redis8:
    image: 'redis:5.0.9'
    container_name: redis8
    restart: always
    volumes:
      - ./redis8/:/etc/redis/
    ports:
      - 6378:6379
      - 16378:16379
    command:
      redis-server /etc/redis/redis.conf
    networks:
      mynet:
        ipv4_address: 172.30.0.108

  redis9:
    image: 'redis:5.0.9'
    container_name: redis9
    restart: always
    volumes:
      - ./redis9/:/etc/redis/
    ports:
      - 6379:6379
      - 16379:16379
    command:
      redis-server /etc/redis/redis.conf
    networks:
      mynet:
        ipv4_address: 172.30.0.109

  redis10:
    image: 'redis:5.0.9'
    container_name: redis10
    restart: always
    volumes:
      - ./redis10/:/etc/redis/
    ports:
      - 6380:6379
      - 16380:16379
    command:
      redis-server /etc/redis/redis.conf
    networks:
      mynet:
        ipv4_address: 172.30.0.110

  redis11:
    image: 'redis:5.0.9'
    container_name: redis11
    restart: always
    volumes:
      - ./redis11/:/etc/redis/
    ports:
      - 6381:6379
      - 16381:16379
    command:
      redis-server /etc/redis/redis.conf
    networks:
      mynet:
        ipv4_address: 172.30.0.111
  • subnet 通过子网掩码的方式设立网段, /24 表示前 24 位为网络号,后 8 位为主机号。在后面为每个节点设置静态 ip 时,网络号要与网段中的相同,而主节号随意(101 - 111)

docker-compose up -d

启动容器

构建集群

redis-cli --cluster create 172.30.0.101:6379 172.30.0.102:6379 
172.30.0.103:6379 172.30.0.104:6379 172.30.0.105:6379 
172.30.0.106:6379 172.30.0.107:6379 172.30.0.108:6379 
172.30.0.109:6379 --cluster-replicas 2
  • --cluster create:构建集群,后面接 ip 和端口号
  • --cluster-replicas:主从结构,每个主节点分配两个从节点(所有节点都是从现有节点中获取,即九个节点分为三个分片)

在构建的过程中,发生了以下的操作

节点握手

节点握手是指一批运行在集群模式下的节点通过 Gossip 协议彼此通信,达到感知对方的过程。节点握手是集群彼此通信的第一步,由客户端发起命令:cluster meet {ip} {port}

cluster meet 命令是一个异步命令,执行后立刻返回,内部发起与目标节点进行握手通信。

  1. 节点 6379 本地创建 6380 节点信息对象,并发送 meet 消息
  2. 节点 6380 接收到 meet 消息后,保存 6379 节点信息并回复 pong 消息
  3. 之后两节点彼此定期通过 ping / pong 消息进行正常的节点通信

握手通信结束后,两节点就处于同一集群之后,之后分别执行 cluster meet 命令让其他节点也加入到集群之中。(在集群中的任意节点执行该命令加入新的节点,握手状态会通过消息在集群内传播,其他节点会自动发现新节点并发起握手流程)

在握手之后由于槽位还没有被分配,所以集群处于下线状态,所有数据读写都被禁止。

分配槽

通过 cluster addslots 命令为节点分配槽

redis-cli -h 127.30.0.101 -p 6379 cluster addslots {0...5461}
redis-cli -h 127.30.0.102 -p 6379 cluster addslots {5462...10922}
redis-cli -h 127.30.0.103 -p 6379 cluster addslots {10923...16383}

在分配好槽位之后,集群进入在线状态。

分配从节点

使用 cluster replicate {nodeId} 命令

我们可以通过日志可以查看主从节点和分片的详细信息

  • Master[ i ] -> Slots:为第 i 号主节点分配对应的槽位
  • Adding replica:将前一个 ip 对应的节点作为后一个节点的从节点,即105、106为101节点的从节点,107、108为102节点的从节点,109、104为103节点的从节点
  • M:主节点,run id + ip + port + slots
  • S:从节点,run id + ip + port + master-replid

当我们输入 yes 后才会真正创建集群。

cluster nodes

查看整个集群的情况

redis-cli -c

会⾃动把对其他节点中对键值对的请求重定向到对应节点。当我们在从节点上执行写操作时,也会自动重定向到其主节点上。

节点通信

在分布式存储中需要提供维护节点元数据(节点负责哪些数据,是否出现故障等状态信息)信息的机制,Redis 集群采用 P2P 的 Gossip(流言)协议。Gossip 协议的主要职责就是信息交换 ,其工作原理就是节点彼此不断通信交换信息。

通信流程

  1. 集群中的每个节点都会单独开辟一个 TCP 通道,用于节点之间彼此通信(通信端口号)
  2. 与哨兵类似,每秒钟每个节点都会给⼀些随机的节点发起 ping 消息。
  3. 接受到 ping 消息的节点用 pong 命令作为响应,ping 和 pong 除了 message type 属性之外的其他部分都是⼀样的,这⾥包含了集群的配置信息(该节点的id、该节点从属于哪个分⽚、是主节点还是从节点、从属于谁、持有哪些 slots 的位图等)。

当节点出故障、新节点加入、主从角色变化、槽信息变更等事件发生时,通过不断地 ping / pong 消息通信,经过一段时间后所有的节点都会知道整个集群全部节点的最新状态,从而达到集群状态同步的目的。

Gossip 消息

常用的 Gossip 消息可分为 ping 消息、pong 消息、meet 消息、fail 消息等

  • meet 消息:用于通知新节点加入,消息发送者通知接收者加入到当前集群,meet 消息通信正常完成后,接受节点会加入到集群中并进行周期性的 ping、pong 消息交换
  • ping 消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送 ping 消息,用于检测节点是否在线和交换彼此状态信息,ping 消息发送封装了自身节点和部分其他节点的状态数据
  • pong 消息:当接收到 meet 、ping 消息时,作为响应消息回复给发送方确认消息正常通信,pong 消息内部封装了自身状态数据,节点也可以向集群内广播自身的 pong 消息来通知整个集群对自身状态进行更新
  • fail 消息:当节点判断集群内另一个节点下线时,会向集群内广播一个 fail 消息,其他节点接收到 fail 消息之后把对应节点更新为下线状态       

                                                                                                                                                            所有的消息格式划分为消息头和消息体,消息头包含发送节点自身状态数据,接受节点根据消息头就可以获取到发送节点的相关数据 。消息头在 Redis 内部采用 clusterMsg 结构声明

typedef struct {
    char sig[4]; /*信号标示*/
    uint32_t totlen; /*消息总长度*/
    uint16_t ver; /*协议版本*/
    uint16_t type; /*消息类型,用于区分meet,ping,pong等消息*/
    uint16_t count; /*消息体包含的节点数量,仅用于meet,ping,ping消息类型*/
    uint64_t currentEpoch; /*当前发送节点的配置纪元*/
    uint64_t configEpoch; /*主节点/从节点的主节点配置纪元*/
    uint64_t offset; /*复制偏移量*/
    char sender[CLUSTER NAMELEN]; /*发送节点的nodeId */
    unsigned char myslots[CLUSTER_ SLOTS/8];/*发送节点负责的槽信息*/
    char slaveof[CLUSTER NAMELEN]; /*如果发送节点是从节点,记录对应主节点的nodeId */                        
    uint16_t port; /*端口号*/
    uint16_t flags; /*发送节点标识,区分主从角色,是否下线等*/
    unsigned char state; /*发送节点所处的集群状态*/
    unsigned char mflags[3];/*消息标识*/
    union clusterMsgData data /*消息正文*/;
} clusterMsg;

 消息体在 Redis 内部采用 clusterMsgData 结构声明

union clusterMsgData {
    /* ping,meet,pong 消息体 */
    struct {
        /* gossip 消息结构数组 */
        clusterMsgDataGossip gossip[1];
    } ping;
    /* FAIL 消息体 */
    struct {
        clusterMsgDataFail about;
    } fail;
//...
};

消息体 clusterMsgData 定义发送消息的数据,其中 ping、meet、pong 都采用 clusterMsgDataGossip 数组作为消息体数据,实际消息类型使用消息头的 type 属性区分。每个消息体包含该节点的多个 clusterMsgDataGossip 结构数据,用于信息交换

typedef struct (
    char nodename [CLUSTER_ NAMELEN] ;/*节点的nodeId */
    uint32_t ping_ sent; /*最后一次向该节点发送ping消息时间*/
    uint32_t pong_ received; /*最后一次接收该节点pong消息时间*/
    char ip[NET IP_ STR_ LEN]; /* IP */
    uint16_t port;/*port*/
    uint16_t flags; /*该节点标识,*/
} clusterMsgDataGossip;

当接收到 ping 、meet 消息时,接收节点会解析消息内容并根据自身的识别情况做出对应处理

  • 解析消息头:消息头包含了发送节点的信息,如果发送节点时新节点且消息是 meet 类型,则加入到本地节点列表,如果是已知节点,则尝试更新发送节点的状态,如槽映射关系,主从角色等状态。
  • 解析消息体:如果消息体的 clusterMsgDataGossip 数组包含的节点时新节点,则尝试发起与新节点的 meet 握手流程,如果是已知节点,则根据 cluster MsgDataGossip 中的 flags 字段判断该节点是否下线,用于故障转移。
  • 消息处理完后回复 pong 消息,内容同样包含消息头和消息体,发送节点接受到回复的 pong 消息后,采用类似的流程解析处理消息并更新与接收节点最后通信时间,完成一次消息通信。

节点选择

Gossip 协议内部需要频繁地进行节点信息交换,而ping / pong 消息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担。Redis 集群内节点通信采用固定频率(定时任务每秒执行10次)。因此节点每次选择需要通信的节点列表变得非常重要。通信节点选择过多的话信息交换及时但成本高,通信节点选择过少会降低成本但会影响故障判定、新节点发现等需求的速度。通信节点规则:

集群内每个节点维护定时任务默认每秒执行10次,每秒会随机选取5个节点找出最久没有通信的节点发送ping消息,用于保证Gossip信息交换的随机性。每100毫秒都会扫描本地节点列表,如果发现节点最近一次接受 pong 消息的时间大于 clusternodetimeout / 2,则立刻发送 ping 消息,防止该节点信息太长时间未更新。根据以上规则得出每个节点每秒需要发送ping消息的数量= 1+10*num(node . pong_received >cluster node timeout / 2)。当我们的带宽资源紧张时,可以适当调大 cluster node timeout 参数来降低带宽占用率。但不能过度调大该参数,会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度。因此需要根据业务容忍度和资源消耗进行平衡。同时整个集群消息总交换量也跟节点数成正比。

主节点宕机

docker stop redis1

在停止 redis1 节点后,cluster nodes 查看整个集群的情况

可以看到,原本的主节点 101 变为了 fail,表示该节点宕机,而作为 101 的从节点的 106 变为了主节点。如果我们重启 redis1(docker start redis1

101 节点变为了从节点,我们也可以使⽤ cluster failover 进⾏集群恢复,也就是把 101 重新设定成 master(登录到 101 上执⾏)。

故障判定

集群中的所有节点,都会周期性的使⽤⼼跳包进⾏通信。

当节点 A 给节点 B 发起 ping 消息,如果通信正常将接受到 pong 消息,节点 A 更新最近一次与节点 B 的通信时间。但B不能如期回应的时候,此时 A 就会尝试重置和 B 的 tcp 连接,看能否连接成功。如果仍然连接失败,通信时间超过 cluster-node-timeout ,A 就会把 B 设为 PFAIL 状态(相当于主观下线)。

每个节点的 clusterState 结构:

typedef struct clusterState {
    clusterNode *myself;/* 自身节点 */
    dict *nodes;/* 当前集群内所有节点的字典集合,key为节点ID, value 为对应节点ClusterNode结构 */
} clusterState;

typedef struct clusterNode {
    int flags;/* 当前节点状态,如:主从角色,是否下线等 */
    mstime_t ping_sent; /* 最后一次与该节点发送 ping 消息的时间 */
    mstime_ t pong_ received; /*最后一次接收到该节点pong消息的时间*/
    ...
} clusterNode;

PFAIL 状态就设置在 flags 当中

CLUSTER_NODE_MASTER 1 /* 当前为主节点 */
CLUSTER_NODE_SLAVE 2 /* 当前为从节点 */
CLUSTER_NODE_PFAIL 4 /* 主观下线状态 */
CLUSTER_NODE_FAIL 8/* 客观下线状态 */
CLUSTER_ NODE_MYSELF 16 /* 表示自身节点 */
CLUSTER_ NODE_HANDSHAKE 32 /* 握手状态,未与其他节点进行消息通信 */
CLUSTER_NODE_NOADDR 64 /* 无地址节点,用于第一次meet通信未完成或者通信失败 */
CLUSTER_NODE_MEET 128 /* 需要接受meet消息的节点状态 */ 
CLUSTER_NODE_MIGRATE_TO 256 /* 该节点被选中为新的主节点状态 */

A 判定 B 为 PFAIL 之后,由于进行节点信息交换时除了自身的数据还会携带 1/10 的其他节点的数据,因此当接受节点发现消息体中含有主观下线的节点状态且发送节点为主节点时,会在本地找到故障节点的 ClusterNode 结构,保存到下线报告链表中。

struct clusterNode { /* 以为是主观下线的 clusterNode 结构 */
    list *fail_reports; /* 记录了所有其他节点对该节点的下线报告 */
    ...
};
typedef struct clusterNodeFailReport {
    struct clusterNode *node; /* 报告该节点为主观下线的节点 */
    mstime_t time; /* 最近收到下线报告的时间 */
} clusterNodeFailReport

每当下线报告列表被更新时(下线报告链表中不存在发送节点则更新,存在则更新 time),都会尝试进行客观下线,每个下线报告都存在有效期(server.cluster_node_timeout*2),每次在尝试触发客观下线时,都会检测下线报告是否过期,对于过期的下线报告将被删除。 而当下线报告列表中的节点数超过总集群分片个数的⼀半时,则尝试客观下线成功,A 就会把 B 标记成 FAIL (客观下线),并且把这个消息同步给其他节点(向集群广播一条 fail 消息),其他节点也会把 B 标记成 FAIL,同时故障节点的从节点会进行故障迁移。

而当某个分⽚所有的主节点和从节点都挂了或是超过半数的 master 节点都挂了,则会引起整个集群都宕机。

故障迁移

只有主节点出现故障后需要进行故障迁移,与哨兵类似,使用的都是 Raft 算法,但细节上略有不同。

  1. 从节点判定⾃⼰的参选资格,如果从节点和主节点已经太久(超过 cluster-node-time * cluster-slave-validity-factor,后一个参数默认为10)没通信(此时认为从节点的数据和主节点差异太⼤了),时间超过阈值,就失去竞选资格。
  2. 具有资格的节点,⽐如 C 和 D,就会先休眠⼀定时间。休眠时间 = 500ms 基础时间 +     [0,500ms] 随机时间+排名 * 1000ms。offset 的值越⼤,则排名越靠前(越⼩)。
  3.  ⽐如 C 的休眠时间到了,C 就会给其他所有集群中的节点,进⾏拉票操作(广播选举消息 FAILOVER_AUTH_REQUEST),但是只有主节点才有投票资格。
  4. 主节点就会把⾃⼰的票投给 C(回复 FAILOVER_AUTH_ACK) (每个主节点只有 1 票)。当 C 收到的票数超过主节点数⽬的 N / 2+1,C 会晋升成主节点。(C ⾃⼰负责执⾏ slaveof no one,并且让 D 执⾏ slaveof C)。如果在开始投票之后的 cluster-node-timeout * 2的时间内没有节点获取到足够数量,则重新投票。
  5.  同时,,C 还会把⾃⼰成为主节点的消息,同步给其他集群的节点。⼤家也都会更新⾃⼰保存的集群结构信息。

集群扩容

redis-cli --cluster add-node 172.30.0.110:6379 172.30.0.101:6379

将 110 节点作为主节点加入到集群中,101 节点是作为集群中的任意一个节点用来确认新节点所加入到哪个集群中。

此时 110 已经被作为主节点加入到集群中,但是不具有 slots 

redis-cli --cluster reshard 172.30.0.101:6379

重新分配 101 节点所处集群中的 slots

首先,我们需要输入要 move 的 slots 的数目(move 到扩容所产生的新的分片当中)

之后,输入所要 move 到的节点的 id

之后选择被 move 的 slots 的来源,all 表示从原本所有的分片中获取,也可以指定一个或几个节点,以 done 结尾。

最后列举 slots 移动的具体情况并确认是否重新划分槽位。

在搬运 key 的过程中,对于那些不需要搬运的 key,访问的时候是没有任何问题的,但是对于需要搬运的 key,进⾏访问可能会出现短暂的访问错误。因此为了提高可用性,可以建立一个新的集群来进行扩容后的数据分配。

redis-cli --cluster add-node 172.30.0.111:6379 172.30.0.101:6379 
--cluster-slave --cluster-master id [110节点的id]

为 110 节点添加从节点 111

 


 

缓存

缓存核⼼思路就是把⼀些常⽤的数据放到访问速度更快的地⽅,⽅便随时读取。Redis 就可以作为 MySQL 等磁盘数据库的缓存。

对于硬件的访问速度来说,通常情况下 CPU 寄存器 > 内存 > 硬盘 > ⽹络,最常见的是内存作为硬盘的缓存,硬盘也可以作为网络的缓存(浏览器缓存)

收益与成本

收益

  1. 加速读写,业务服务器先查询缓存,看想要的数据是否在缓存中存在,如果已经在缓存存在了,就直接返回。此时不必访问后端了。如果在缓存中不存在,再查询后端。

  2. 降低后端负载,帮助后端减少访问量和复杂计算(例如复杂的 SQL 语句)

成本

  1. 数据不一致性:缓存层和存储层的数据存在着一定时间窗口的不一致性
  2. 代码维护成本:需要同时处理缓存层和存储层的逻辑,增大了开发者维护代码的成本

缓存更新策略

由于访问速度越快的设备,成本越⾼,存储空间越⼩。因此,缓存内只存储热点数据(二八原则)。而热点数据是会一直变更的,因此,缓存内的数据需要被更新。

定期生成

每隔⼀定的周期(⽐如⼀天 / ⼀周 / ⼀个⽉),对于访问的数据频次进⾏统计(访问的数据会以日志的方式记录下来)。挑选出访问频次最⾼的前 N% 的数据。

这种方法实现起来较为简单,过程较为可控,方便排查问题,但是实时性较低,无法应对一些时效性较强的热点数据。

实时⽣成

先给缓存设定容量上限(可以通过 Redis 配置⽂件的 maxmemory 参数设定)。

之后,在客户端每次查询的过程中,如果 Redis 中存在所查询的数据,就直接返回。如果 Redis 中不存在,就从数据库查询,并把查到的结果同时也写⼊到 Redis 中。
如果缓存已经满了(达到上限),就触发缓存淘汰策略,把⼀些 “ 相对不那么热⻔ ” 的数据淘汰掉

算法剔除

FIFO(First In First Out)先进先出:把缓存中存在时间最久的 数据淘汰掉。

LRU (Least Recently Used)淘汰最久未使⽤的:记录每个 key 的最近访问时间。把最近访问时间最⽼的 key 淘汰掉。

LFU(Least Frequently Used)淘汰访问次数最少的:记录每个 key 最近⼀段时间的访问次数,把访问次数最少的淘汰掉。

Random 随机淘汰

Redis 内置的淘汰策略如下:

  • volatile-lru:当内存不⾜以容纳新写⼊数据时,从设置了过期时间的key中使⽤LRU(最近最
    少使⽤)算法进⾏淘汰。
  • allkeys-lru:当内存不⾜以容纳新写⼊数据时,从所有key中使⽤LRU(最近最少使⽤)算法进
    ⾏淘汰。
  • volatile-lfu:4.0版本新增,当内存不⾜以容纳新写⼊数据时,在过期的key中,使⽤LFU算法
    进⾏删除key。
  • allkeys-lfu:4.0版本新增,当内存不⾜以容纳新写⼊数据时,从所有key中使⽤LFU算法进⾏
    淘汰。
  • volatile-random:当内存不⾜以容纳新写⼊数据时,从设置了过期时间的key中,随机淘汰数
    据。
  • allkeys-random:当内存不⾜以容纳新写⼊数据时,从所有key中随机淘汰数据。
  • volatile-ttl:在设置了过期时间的key中,根据过期时间进⾏淘汰,越早过期的优先被淘汰
    (相当于 FIFO,只不过是局限于过期的 key)。
  • noeviction:默认策略,当内存不⾜以容纳新写⼊数据时,新写⼊操作会报错。

要清理哪些数据时由具体算法决定的,开发人员只能决定使用哪种算法,所以数据的一致性是最差的。

算法不需要开发人员自己来实现,通常只需要配置最大 maxmemory 和对应的策略即可,维护成本最低。

超时剔除

超时剔除通过缓存数据设置过期时间(expire 命令),让其在过期时间后自动删除。当有数据过期后,再从真实数据源获取热点数据,放到缓存并设置过期时间。

在一段时间窗口内会存在一致性问题,即缓存数据和真实数据源的数据不一致

只需要设置 expire 过期时间即可,维护成本不是很高。

主动更新

应用方对于数据的一致性要求高,需要在真实数据更新后,立即更新缓存数据,例如可以利用消息系统或其他方式通知缓存更新

一致性最高,但如果主动更新出现问题,该条数据可能会长时间不被更新。

维护成本较高,开发者需要自己来完成更新,并保证更新操作的正确性。


缓存粒度说明

在将 MySQL 的热点数据存储到缓存中时,可以选择是缓存全部属性还是缓存部分重要属性

在一致性上,缓存全部属性比部分属性更加通用,但实际上很长时间内应用只需要几个重要的属性

在空间占用上,缓存全部属性要比部分属性占用更多的空间,造成内存浪费,加大在数据传输中的网络带宽的消耗。

在代码维护上,全部数据的优势更加明显,而部分数据一旦要加新字段就需要修改业务代码,而且修改后通常还需要刷新缓存数据。

缓存预热(Cache preheating)

若是使用实时生成的缓存更新策略,当首次将 Redis 作为缓存时,或者 Redis ⼤批 key 失效之后,Redis 中是几乎不存在任何数据的。因此所有热点数据都需要直接访问 MySQL。

因此就需要提前把热点数据准备好,直接写⼊到 Redis 中,即我们可以在上述情况中执行一次定期生成。


缓存穿透(Cache penetration)

如果访问的 key 在 Redis 和数据库中都不存在,此时这样的 key 不会被放到缓存上并返回一个空结果,后续如果仍然在访问该 key,依然会访问到数据库,导致后端存储负荷加大。这种情况叫做缓存穿透。

缓存穿透出现的原因一般分为

  • 自身业务代码或数据出现问题,比如缺少必要的参数校验环节
  • 开发/运维误操作,不⼩⼼把部分数据从数据库上误删了
  • 一些恶意攻击、爬虫等造成大量空命中

解决缓存穿透问题的方法分为两个

缓存空对象

如果访问的 key 在 Redis 和数据库中都不存在,虽然仍会返回空结果,但我们可以在缓存中新建一个关于 key 的空对象。当我们再次访问该 key 时,会发现缓存中的空对象,并返回一个空结果。


但若是遭受到攻击,会有大量不同的 key 进行访问,会占据大量缓存的空间,因此,空对象可以设置一个较短的过期时间。

缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响,如果在空对象的过期时间内添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清楚掉缓存层中的空对象。

布隆过滤器拦截

在访问缓存层和存储层之前,将存在的 key 用布隆过滤器提前保存下来,作为第一层拦截。

这种方法适用于数据命中不高、数据相对固定、实时性低的场景,代码维护较为复杂,但缓存空间占用少。

缓存雪崩(Cache avalanche)

短时间内⼤量的 key 在缓存上失效等原因,导致数据库压⼒骤增,甚⾄直接宕机。就叫做缓存雪崩。

出现这种情况的原因主要有两个:Redis 宕机或是存在大量同一过期时间的数据

预防和解决缓存雪崩问题,可以从以下三个方面进行着手。

  1. 保证缓存层服务高可用性。 如果缓存层设计成高可用的,即使个别节点、个别机器、 甚至是机房宕掉,依然可以提供服务,例如前面介绍过的 Redis Sentinel 和 Redis Cluster 都实现了高可用。
  2. 依赖隔离组件为后端限流并降级。无论是缓存层还是存储层都会有出错的概率,可以将它们视同为资源。作为并发量较大的系统,假如有一个资源不可用,可能会造成线程全部阻塞(hang)在这个资源上,造成整个系统不可用。我们需要对重要的资源(例如Redis、MySQL、 HBase、外部接口)都进行隔离,让每种资源都单独运行在自己的线程池中,即使个别资源出现了问题,对其他服务没有影响。但是线程池如何管理。
  3. 提前演练。在项目上线前,演练缓存层宕掉后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定。


分布式锁

在⼀个分布式的系统中,也会涉及到多个节点访问同⼀个公共资源的情况。此时就需要通过分布式锁来做互斥控制,避免出现类似于 " 线程安全 " 的问题。

分布式锁本质上就是使⽤⼀个公共的服务器来记录加锁状态,这个公共的服务器可以是 Redis,也可以是其他组件。

实现

通过⼀个键值对来标识锁的状态。如果一个服务器访问公共资源,就需要先访问公共的服务器(Redis)并设置⼀个键值对(set)。如果这个操作设置成功,就视为当前没有节点对该公共资源加锁,就可以进⾏数据库的读写操作。操作完成之后,再把 Redis 上刚才的这个键值对给删除掉(del)。

如果设置的时候发现该⻋次的 key 已经存在了,则认为已经有其他服务器正在持有锁,此时该服务器就需要等待或者暂时放弃。

过期时间

而若是在加锁之后加锁的服务器宕机,则会导致创建的键值对无法自动删除,导致无法解锁。因此我们需要在加锁时为键值对赋予过期时间(setnx,加锁过程需要为原子性,所以选择该命令)。

校验 id

一个服务器加锁所创建的键值对是可以被其他服务器删除的,因此,我们需要加入校验 id 来保证加锁所创建的键值对只能被原本的服务器所删除。我们可以对每个服务器进行编号,当创建键值对的时候将这个编号存储在 value 之中。而当删除键值对的时候需要先判断 value 与执行删除操作的服务器的编号是否相同(get),相同再进行删除(del)。

引⼊ lua

在引入校验 id 的时候,当删除键值对的时候,get 与 del 不是原子性的,因此,若是加锁服务器存在多个线程执行解锁操作,也有可能出现问题。

我们可以使用 Lua 脚本来实现删除的原子性

引⼊ watch dog (看⻔狗)

由于加锁的键值对存在过期时间,因此当 Redis 客户端还在读写时若是达到了过期时间,则需要延长过期时间,watch dog,本质上是加锁的服务器上的⼀个单独的线程,通过这个线程来对锁过期时间进⾏ " 续约 " 。

引⼊ Redlock 算法

当一个服务器的 master 节点加锁的时候,写⼊键值对的过程刚刚完成,master 挂了,则 slave节点升级成了新的 master 节点。但是若是加锁用的键值对由尚未来得及同步给 slave ,则其他服务器仍然可以进⾏加锁。

Redis 引入 Redlock 算法来解决这个问题。

我们引⼊⼀组 Redis 节点。其中每⼀组 Redis 节点都包含⼀个主节点和若⼲从节点,并且组和组之间存储的数据都是⼀致的,相互之间是 " 备份 " 关系。加锁的时候,按照⼀定的顺序,写多个 master 节点。在写锁的时候需要设定操作的 " 超时时间 " 。如果 setnx 操作超过了超时时间还没有成功,就视为加锁失败。如果给某个节点加锁失败,就⽴即再尝试下⼀个节点。当加锁成功的节点数超过总节点数的⼀半,才视为加锁成功。

同理,释放锁的时候,也需要把所有节点都进⾏解锁操作。
 

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

finish_speech

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值