Redis如何基于复制机制实现高可用和故障转移。
Redis的主从复制也是基于复制功能开发的, 配置和使用都很简单。它可以让Redis从服务器拥有和主服务器一致的完整数据。当连接断开时, 服务器会自动重连, 无论主服务器发生什么故障, 从服务器总是尝试获取精准的副本拷贝。
该能力主要依靠以下三种机制:
- 在主从机器连接良好的情况下, 主服务器会将客户端的写入事件、键过期事件、删除事件以及其他会引起数据变化事件都通过发送命令流的方式发到从服务器进行重做, 以保证主从数据一致。
- 当主从机器连接断开的情况下, 无论是网络故障还是主从服务器检测到连接超时, 从服务器都会尝试进行重连,并进行部分重新同步, 也就是在连接断开期间没有获取到的那些命令流。
- 当副本无法获取一个部分同步时(前面的命令还没传递就被后面的命令覆盖了), 它会向主服务器申请一个全量同步。全量同步是一个更加复杂的过程, 主服务器需要创建一个全量数据的快照并将快照发送到副本,然后持续发送那些会更改数据内容的命令流给从服务器。
Redis默认使用异步复制模式, 这种低延迟、高性能的复制方式适用于大多数Redis使用场景。然而, 从服务器会周期性地对收到的数据进行异步确认,所以主服务器不需要每次等待从服务器执行完命令, 但如果需要的话, 主服务器是有办法知道哪台从服务器已经处理了哪些命令。这个特性可以支持进行同步复制。
客户端可以使用 WAIT 命令进行特定数据的同步复制。但是 WAIT 命令只能确保它能同步其它Redis实例中已确认的指定编号的副本, 它并不能将一组Redis实例变成一个具有强一致性的CP系统: 已被确认的写入仍然可能在故障转移期间丢失, 这和Redis具体的持久化配置有关系。尽管如此, WAIT 也极大降低了故障事件发生后数据丢失的概率,仅限于某些难以触发的失败场景。
你可以查看Redis哨兵或者Redis集群文档,以了解更多关于高可用和故障转移的信息。本文剩下的部分主要介绍Redis基础复制的基本特性。
Redis复制的重要特性
- Redis使用异步复制, 副本节点会向主节点上报已经处理的数据。
- 一个master节点可以有多个副本。
- 副本也可以接受其它副本的连接请求。一个master可以被多个副本连接, 一个副本也可以被二级副本连接。从Redis4.0版本开始, 所有子副本也将接收副本从主节点接收到的、完全一样的复制流。
- 在master节点侧,主从复制是非阻塞的。这意味着当一个或多个副本进行初始化复制或部分重新同步时,master节点会持续处理查询请求。
- 副本侧的复制也是非阻塞的。 当副本正在执行初始化复制时, 你可以配置redis.conf让它使用旧版本数据来处理查询请求。 你也可以配置当复制流已经关闭时, 直接给客户端返回一个错误。但是, 当初始化同步完成后, 老的数据必须被删除, 新的数据也必须被加载。在这个简短的时间窗口内,复制将被阻塞(当数据量很大时,停顿可能长达数秒)。自Redis4.0以后, 你可以对redis进行配置, 老数据的删除可以在一个异步线程里面进行, 然而, 加载新的初始化数据依然会在主线程执行, 并且会阻塞同步。
- 复制功能可以用来进行水平扩容, 生成多个副本来处理只读请求( 可以把O(N)这样的耗时操作转到副本执行), 也可以仅仅为了提高数据的安全性和可用性。
- 你可以使用复制功能来避免master节点将数据库写入磁盘的损耗: 一个典型的使用技巧是配置主节点的redis.conf, 不写磁盘,只用内存, 然后配置一个副本连接,进行间歇保存或者开启AOF。但是, 务必要谨慎处理此配置, 因为master重启后,会以一个空数据集开始: 如果副本尝试进行同步, 副本的数据也会被清空。
主节点关闭数据持久化后的安全性
在进行Redis复制功能的配置中, 强烈建议开启主从节点的持久化功能。当无法开启持久化时(例如硬盘太差,开启持久化会导致高延迟), redis实例应该配置在重启后,不能进行自动启动。
为了更好理解为什么设置自动重启但关闭持久化的主节点是危险的,查看下面这几种主从节点数据都被擦除的事故场景:
- 我们启动了node A来扮演master, 关闭了持久化功能, node B和node C是node A的副本。
- Node A 崩溃了, 但是它有自动重启机制, 然后被自动重启了。因为持久化关闭了,所以A以一个空数据集的姿态启动了。
- B和C会从A进行数据复制, 但是A是空的,所以B和C会清空各自的数据。
当使用Redis哨兵模式来做高可用时, 关闭主节点的持久化并且配置自动重启也是危险的。master节点可能重启地很快, 以至于哨兵都没有感知到, 这样就会导致上面说的故障。
数据安全一直都很重要,所以当使用复制功能时, 如果主节点关闭了持久化,那么一定也要禁止自动重启。
Redis复制工作原理
每一个master节点都有一个复制ID, 这是一个很大的伪随机字符串,用于标识一个给定数据集的历史。每个主节点都会维护一个偏移量, 当新的更新请求产生需要通过复制流发送给副本的字节时,偏移量就会增加。即使当前没有副本连接,偏移量也会增加,所以每对复制ID,offset标识了主节点的一个特定版本。
Replication ID, offset
当副本连接到主节点时, 它们会使用 PSYNC 命令发送他们当前复制ID和处理完的偏移量。这样,主节点就可以只发送副本需要的增量部分。然而,如果主节点的backlog缓冲不足或者副本记录的复制ID已经过时了,就会产生一个全量同步: 这种情况下,副本会得到主节点的一个完整拷贝。
译注: 如果RDB生成太慢,backlog先满了, 新的命令就会被放到内存缓冲区。当RDB文件生成完了,先发送RDB到副本,然后发送backlog到副本,然后发送内存缓冲区命令到副本。内存缓冲区也满了怎么办? 走Redis的内存清理策略。
全量同步细节如下:
主节点启动一个后台保存进程来产生RDB文件。与此同时, 它也会将客户端提交的写命令进行缓存。当后台保存进程处理完毕,主节点会将这个RDB文件发送给副本, 副本会先将这个文件保存到磁盘,然后加载到内存。接着主节点会将所有缓存的写命令发送给副本。这会用一个命令流来完成, 也遵循redis自身的传输协议。
你可以自己通过telnet命令尝试。当redis正在工作时,连接到redis的端口,然后发出 SYNC 命令。你会先看到批量传输,然后主节点收到的每条命令都会重新投递到telnet会话。SYNC 实际上是一个旧的协议并且Redis已不再使用, 为了向后兼容,依然被保留了, 它不允许进行部分重新同步,所以现在都是用PSYNC命令来替代。
前面说过, 当主从同步连接因为某种原因断开时, 副本可以进行自动重连。当主节点同时收到多个副本的同步请求,它会启动一个后台保存,以满足所有请求。
译注: 生成RDB需要10秒, A第0秒请求全量同步,B第7秒请求,C第11秒请求,A和B会用同一个RDB文件,C会单独再申请一个RDB文件。
解读复制ID
前面说过, 如果两个Redis实例拥有相同的复制ID和偏移量,他们就拥有完全一样的数据。理解复制ID真实的含义以及为什么Redis实际上有两个复制ID: 主复制ID和二级复制ID, 是很有用的。
复制ID的基本作用就是标识一个数据集的历史。每当一个实例重启并成为主节点或者一个副节点升级成为一个主节点,这个实例都会生成一个新的复制ID。连接到该主节点的副本在建立连接后就会继承这个复制ID。所以当两个实例拥有相同的复制ID也意味着他们拥有相同的数据,只不过可能在不同的时间节点。偏移量可以作为逻辑时间来理解,对于相同的复制ID,谁的偏移量大,谁的数据就更新。
对于实例, 如果A和B两个实例拥有相同的复制ID,但是A的偏移量是1000,B的偏移量是1023,这意味着A还有部分命令没有执行到。也意味着A在执行少量命令后, 可能可以达到B的数据状态。
Redis实例拥有两个复制ID是因为副本可以被提升为主节点。 在某个故障转移后, 升级为主节点的副本依然需要记住它过去的副本ID, 因为这个副本ID是前任主节点的。通过这种方式,其他副本和新主节点进行同步时, 他们会尝试用旧的副本ID执行部分重新同步。这可以正常工作, 因为当副本升级为主节点时, 它会将次ID设置为主ID,并记住ID切换时的偏移量值。然后它会生成一个新的随机复制ID, 因为一个新的历史开启了。当有新的副本进行连接时, master会比较副本ID和偏移量offset, 不仅要和复制ID比还要和二级ID比较(会比较记录的那个offset来确保安全)。简而言之, 双ID可以让集群在重新选主无需全量同步即可正常同步。
译注: 当副本第一次启动时, 主副ID都是空的,第一次psync后,拿到master的复制ID,然后设置到自己的主、副ID。 当master发生故障后,该副本将当前主ID的值设置到副ID上,重新生成一个随机数放到主ID,并记录当前offset, 这样就算切换完成了。 当发生新同步时,如果请求offset在记录offset之前,会比较与副ID是否一致,否则直接比较与主ID是否一致。
如果你想知道为什么故障转移后,副本升级为主节点后需要更改复制ID, 这是因为老的主节点可能还在正常工作,只不过因为网络分区导致无法被感知到。保持相同的复制ID将违反任意两个实例ID拥有相同的复制ID和偏移量意味着他们拥有相同数据集这个原则。
无盘复制
正常情况下,一个全量同步需要在磁盘上生成一个RDB文件,然后把这个RDB文件从磁盘上重新读取发送给副本。
对于master来说, 如果磁盘IO速度很低,这将是一个非常吃力的操作。Redis 2.8.18是第一个支持无盘复制的版本。它会启动一个子进程,直接通过网络将RDB发给副本, 而不是使用磁盘作为中间存储。
译注: 还是会生成RDB文件, 只不过直接放到了内存, 副本接收也是存放到内存。
配置
配置Redis复制非常简单, 只需要在副本的配置文件中添加以下行:
replicaof 192.168.1.1 6379
当然你需要把 192.168.1.1 6379 替换成你自己的主节点IP地址(或者主机名)和端口。另外,你可以直接执行 REPLICAOF 命令,master会和副本进行一次同步。
主服务器还有一些参数用于调整主服务器所维护的复制积压缓冲区(replication backlog)的大小,该缓冲区用于执行部分重同步操作。更多相关信息可以参见Redis发行版中提供的示例配置文件redis.conf。
无盘复制可以通过设置 repl-diskless-sync 来开启。当进行全量同步时, master在准备把RDB文件发送给副本前,可以使用 repl-diskless-sync-delay 参数来设定一个延迟时间, 如果这段时间有新的同步申请, 会直接用这个生成好的RDB文件。请参考Redis发行版中提供的的示例 redis.conf 文件,以获取更多详细信息。
只读副本
从Redis 2.6开始, 副本开始支持并默认开启只读模式。这可以通过配置redis.conf中的 replica-read-only 参数来进行控制, 也可以在运行中使用 CONFIG SET 命令来控制。
只读副本会拒绝全部的写请求, 因此也不会因为误操作导致往一个副本写入数据。这个特性并不是意图把副本实例暴露到外网或者更广泛的存在不可信机器的网络, 因为像 DEBUG 或者 CONFIG 这样的管理命令仍然是可用的。 Security 这里详细介绍了如何加固一台Redis实例。
你可能好奇为什么副本的只读设置会被撤销并且执行了写操作, 这是历史原因造成的。副本写入数据可能会导致主副节点数据不一致, 所以不建议给副本开启写权限。在理解可写副本在什么场景可能会产生问题前, 我们得先理解副本是如何工作的。主节点数据变动的同步是通过向副本传递常规的Redis命令来实现。当主节点上的一个key失效了, 一个DEL命令将会被传递到副本。如果主节点上存在一个key, 但是在副本上该key已经被删除或过期或有不同的类型, 那么从主节点传过来的DEL, INCR或RPOP命令的执行结果会将与预期不同。传递到副本的命令可能会失败也可能会产生不同的输出结果。为了尽可能降低风险(如果你坚持使用可写副本),我们建议你遵循下面的以下建议:
- 如果key在主节点存在,就不要在副本进行写入操作。(如果你不能控制所有向主节点写入的客户端, 这一点就很难保证)
- 在对运行中的实例集进行升级时,不要将实例配置为可写副本作为中间步骤。 更通俗的来说, 如果你想保证数据一致的话, 就不要将可能升级为主节点的副本设置为可写状态。
从历史上看, 可写副本在某些场景下被认为是合理的。到了Redis 7.0版本, 这些历史操作方式都已经过时了, 可以通过其他方式来完成相同操作。
- 使用SUNIONSTORE 和 ZINTERSTORE 命令计算慢Set或者有序Set并且将结果保存到临时本地keys。现在我们建议直接返回结果而不用保存, 使用 SUNION 和 ZINTER 命令。
- 使用了 SORT 命令(因为它有一些选项可能会产生写操作, 所以这个命令不能被只读副本执行)。现在推荐使用 SORT_RO 命令, 它是一个只读命令。
- EVAL 和 EVALSHA 命令也不能被认为是只读命令,因为lua脚本可能会调用写命令。现在推荐使用 EVAL_RO 和 EVALSHA_RO 命令, lua脚本在这两个命令里面只能调用只读命令。
尽管副本如果正在重新同步或者副本实例重启,会对丢弃对副本的写入请求,但不能保证他们会自动同步。
在Redis4.0版本之前, 可写副本无法对设置了过期时间的key进行过期清理。这意味着如果你使用 EXPIRE 或其他设置最大生存时间的命令来操作一个key, 这个key将产生泄露, 尽管你通过读命令无法直接访问到这个key,但是通过统计key的数量还是能感知到它的存在,并且它依然会占用内存。Redis 4.0 RC3版本之后,副本就有清理带过期时间key的能力,就和主节点一样, 除了写入大于63号数据库的键之外(Redis默认只有16个数据库)。但是请注意, 及时在4.0版本之后, 对一个可能存在主节点的key使用 EXPIRE 命令, 可能导致复本和主节点之间的数据不一致。
另外要注意到的是从Redis 4.0版本开始, 副本的写入仅仅只影响自身,并不会传递到子副本。子副本总是接收与顶级主节点发送给中间副本完全相同的复制流。例如像下面这种设置中:
A ---> B ---> C
即使B是可写的, C也看不到B的写入命令而是与主节点A拥有完全相同的数据集。
设置副本连接主节点的认证
如果你的主节点通过 requirepass 命令设置了一个密码, 在所有同步操作中配置副本使用该密码是非常简单的。
在一个运行副本实例上, 使用 redis-cli 命令然后输入:
config set masterauth <password>
如果需要永久生效, 在配置文件里添加下面行:
masterauth <password>
只有在有N个副本连接时, 才允许写
从Redis 2.8版本开始, 你可以配置只有当至少N个副本与主节点处于连接状态时,才接受写请求。
然而, 因为Redis使用异步复制, 所以没法确保副本实际上收到了给定的写操作,因此总会有数据丢失的窗口。
这个特性的工作原理如下:
- 副本每秒向主节点发送一次心跳, 确认已经处理的复制流量。
- 主节点会记录每个副本上次心跳时间。
- 用户可以配置至少需要多少个延迟不超过某个最大值的副本数。
如果有至少N个副本连接并且心跳延迟小于M秒, 写请求才会被接受。
你可以认为这是一个尽最大努力来确保数据安全的机制, 虽然一个写操作的数据一致性不能被保证, 但至少数据丢失的时间窗口被限制在一个给定的秒数内。通俗来说就是有限的数据丢失比无限的数据丢失要更好。
如果上面两个条件不满足, 主节点会返回一个错误,写请求也不会被接受。
有两个配置参数来配置这功能:
- min-replicas-to-write <副本数量>
- min-replicas-max-lag <心跳延迟秒数>
更多信息,请查看Redis发行版源码里面提供的 redis.conf 配置文件 。
Redis副本如何处理key过期
Redis的键过期功能允许key有一个生存时间(也就是TTL)。 这样一个功能需要实例可以计时, 然而Redis副本可以正确复制带有过期时间的键, 即使这些键是通过Lua脚本更改的。
为了实现这样一个功能,Redis不能依靠主从节点拥有同步的时钟,因为这是无法解决的问题,会导致竞争条件和数据集的分歧,Redis使用下面三种技术来保证键自动过期功能正常:
- 副本不会主动让key过期,他们会等待主节点让这些key过期。当主节点淘汰一个key时(ttl过期或者基于LRU算法主动淘汰), 它会生成一个 DEL 命令, 并传递给所有副本。
- 因为由主节点来驱动key过期, 所以在副本的内存中有可能存在已经逻辑过期的key, 因为主节点生成的 DEL 命令不一定有那么及时。为了解决这个问题, 副本会在读操作上使用本地逻辑时钟来报告这个key是否存在,不会和数据一致性产生冲突(DEL命令很快也会到达)。使用这种方式, 副本就避免了查到实际存在但过期的key。在实际使用中, 使用副本实例进行扩展的HTML片段,可以避免返回已经超过了期望生存时间的项目。
- 在Lua脚本执行期间没有不会进行key过期处理。当Lua脚本运行时, 从概念上讲, 主服务器上的时间是冻结的,所以在脚本运行期间一个key要么一直存在,要么一直不存在。这就避免在脚本运行期间产生key过期事件,并且为了保证在数据集产生相同的影响,把相同的脚本在副本上执行是有必要的。
一旦副本升级为主节点,就会开始独立的进行key过期处理,不需要老的master提供任何帮助。
在Docker和NAT中配置复制
当使用Docker或者基于端口转发的其他容器或者使用了网络地址转换(NAT), Redis复制需要做一些额外的关注, 尤其是使用了Redis哨兵或者其他通过扫描主服务器 INFO 或 ROLE 输出 来发现副本地址的系统。
这个问题是,在主节点上执行的 ROLE 命令以及 INFO 命令输出的复制部分,会显示副本使用的连接到主节点的IP地址, 在使用NAT转发的环境中,这个地址可能会与副本的逻辑地址不一样。(客户端应该使用这个逻辑地址连接副本)。
类似的, 副本也会列出redis.conf里面配置的监听端口, 如果这个端口被重新映射了,这个端口可能与转发的端口不同。
为了修复这两个问题, 从Redis 3.2.2 开始, 可以强制副本向主节点上报一个特定的IP和端口. 这两个配置命令如下所示:
replica-announce-ip 5.5.5.5
replica-announce-port 1234
这两个指令在Redis最近的发行版本中的示例redis.conf中有文档说明。
INFO 和 ROLE 命令
有两个命令可以提供大量关于当前主从复制的参数信息。 其中一个命令是 INFO, 如果这个命令和 replication参数组合成 INFO replication 命令, 只会展示副本相关的信息。另外一个更加计算机友好的命令是 ROLE, 它提供主从实例的复制状态,以及他们的复制偏移量, 还有连接的副本列表等信息。
重启和故障转移后的部分同步
从Redis 4.0版本后, 当一个副本在故障转移后升级为了主节点后, 它仍然可以为其他副本提供部分重新同步能力。为了达到这个目的, 副本会记住老的复制ID和来自前任master的复制偏移量,所以才能提供部分backlog给连接的副本即使他们请求中的复制ID是老的ID。
然而晋升的副本会拥有一个新的复制ID, 因为它开创了一个不同于过去的数据历史。举个例子, 老的master可能会又恢复了,然后可以继续处理一段时间的写请求, 这样使用同一个复制ID就会引起冲突,因复制ID和offset标识了一个唯一的数据集。
另外,当副本正常关机或重启后, 也可以把重新启动后与master进行重新同步的信息保存到RDB文件里面。这点在节点升级的时候很有用。如果需要升级时, 最好在从服务器使用SHUTDOWN 命令执行保存和退出操作。
如果一个副本使用了AOF文件来进行重启了,则没法进行一个部分同步。但是副本可以在关闭之前将数据保存到RDB文件里面, 这样重启后, 还能继续用AOF。
副本最大内存
默认情况下, 副本会忽略 maxmemory 参数(除非因为故障转移或手动操作晋升为master)。这意味着key的过期清理操作会由主节点来进行, 如果主节点的key被清理了,就会发送DEL命令到副本。
这种行为可以确保主从节点的数据保持一致,这通常也是你想要的。然而,如果你的副本是可写入的或者你想要对副本进行不一样的内存设置,并且你能确保副本的全部写入都是幂等的,你可以更改这个默认设置(但必须理解你在做什么)。
请注意, 因为副本默认不会驱逐数据, 它最终使用的内存可能会比通过maxmemory设置的更多(因为某些缓冲区可能更大或者某些数据结构需要占用更多内存等)。确保监控副本并且确保在master的内存占用达到maxmemory设置之前, 副本不会内存溢出。
如果要更改这种行为, 你可以设置副本不要忽略 maxmemory 设置。 配置语法如下:
replica-ignore-maxmemory no