文档:集群教程

Valkey 通过一种名为 Valkey Cluster 的部署拓扑进行水平扩展。本主题将教你如何在生产环境中设置、测试和操作 Valkey Cluster。你将从终端用户的角度了解 Valkey Cluster 的可用性和一致性特性。

如果你计划运行生产 Valkey Cluster 部署或想更好地理解 Valkey Cluster 内部工作原理,请查阅Valkey Cluster 规范

Valkey Cluster 101

Valkey Cluster 提供了一种运行 Valkey 安装的方式,其中数据会自动在多个 Valkey 节点之间进行分片。Valkey Cluster 还在分区期间提供一定程度的可用性——在实际操作中,这意味着当某些节点出现故障或无法通信时,能够继续操作。然而,在发生更大规模故障时(例如,当大多数主节点不可用时),集群将变得不可用。

因此,通过 Valkey Cluster,你可以获得以下能力:

  • 自动将数据集分割到多个节点中。
  • 当部分节点出现故障或无法与集群其余部分通信时,仍能继续操作。

Valkey Cluster TCP 端口

每个 Valkey Cluster 节点需要两个开放的 TCP 连接:一个用于服务客户端的 Valkey TCP 端口(例如 6379),以及第二个端口,称为集群总线端口。默认情况下,集群总线端口通过在数据端口上加 10000 设置(例如 16379);但是,你可以在 cluster-port 配置中覆盖此设置。

集群总线是节点到节点的通信通道,它使用二进制协议,由于带宽和处理时间较少,更适合在节点之间交换信息。节点使用集群总线进行故障检测、配置更新、故障转移授权等。客户端绝不应尝试与集群总线端口通信,而应使用 Valkey 命令端口。但是,请确保在防火墙中打开这两个端口,否则 Valkey 集群节点将无法通信。

为了使 Valkey Cluster 正常工作,每个节点你需要:

  1. 客户端通信端口(通常为 6379),用于与客户端通信,并必须向所有需要访问集群的客户端以及所有使用客户端端口进行键迁移的其他集群节点开放。
  2. 集群总线端口必须可以从所有其他集群节点访问。

如果你不打开这两个 TCP 端口,你的集群将无法正常工作。

Valkey Cluster 和 Docker

目前,Valkey Cluster 不支持 NAT 环境,也通常不支持 IP 地址或 TCP 端口被重新映射的环境。

Docker 使用一种称为端口映射的技术:在 Docker 容器内运行的程序可能会以与程序认为正在使用的端口不同的端口暴露。这对于在同一服务器上同时运行多个使用相同端口的容器非常有用。

要使 Docker 与 Valkey Cluster 兼容,你需要使用 Docker 的主机网络模式。有关更多信息,请参阅 Docker 文档中的 --net=host 选项。

Valkey Cluster 数据分片

Valkey Cluster 不使用一致性哈希,而是采用一种不同的分片形式,其中每个键在概念上都属于我们称之为哈希槽的部分。

Valkey Cluster 中有 16384 个哈希槽,要计算给定键的哈希槽,我们只需将键的 CRC16 取模 16384。

Valkey Cluster 中的每个节点都负责一部分哈希槽,因此,例如,你可能有一个包含 3 个节点的集群,其中:

  • 节点 A 包含哈希槽 0 到 5500。
  • 节点 B 包含哈希槽 5501 到 11000。
  • 节点 C 包含哈希槽 11001 到 16383。

这使得添加和移除集群节点变得容易。例如,如果我想添加一个新节点 D,我需要将一些哈希槽从节点 A、B、C 移动到 D。类似地,如果我想从集群中移除节点 A,我只需将 A 服务的所有哈希槽移动到 B 和 C。一旦节点 A 为空,我就可以将其从集群中完全移除。

将哈希槽从一个节点移动到另一个节点不需要停止任何操作;因此,添加和移除节点,或更改节点所持有的哈希槽百分比,无需停机。

Valkey Cluster 支持多键操作,只要单个命令执行(或整个事务,或 Lua 脚本执行)中涉及的所有键都属于同一个哈希槽。用户可以通过使用名为哈希标签的特性,强制多个键属于同一个哈希槽。

哈希标签在 Valkey Cluster 规范中有详细说明,但其要点是,如果键中存在一个位于 {} 括号之间的子字符串,则只有该字符串内部的内容会被哈希。例如,键 user:{123}:profileuser:{123}:account 保证在同一个哈希槽中,因为它们共享相同的哈希标签。因此,你可以在同一个多键操作中对这两个键进行操作。

Valkey Cluster 主从模型

为了在部分主节点发生故障或无法与大多数节点通信时保持可用,Valkey Cluster 使用主从模型,其中每个哈希槽有 1 个(主节点本身)到 N 个副本(N-1 个附加副本节点)。

在我们包含节点 A、B、C 的示例集群中,如果节点 B 发生故障,集群将无法继续运行,因为我们不再有办法服务范围 5501-11000 中的哈希槽。

然而,当集群创建时(或稍后),我们为每个主节点添加一个副本节点,这样最终的集群由 A、B、C 三个主节点,以及 A1、B1、C1 三个副本节点组成。这样,如果节点 B 发生故障,系统就可以继续运行。

节点 B1 复制 B,如果 B 发生故障,集群将把节点 B1 提升为新的主节点,并继续正常运行。

然而,请注意,如果节点 B 和 B1 同时发生故障,Valkey Cluster 将无法继续运行。

Valkey Cluster 一致性保证

Valkey Cluster 不保证强一致性。在实际操作中,这意味着在某些条件下,Valkey Cluster 可能会丢失系统已向客户端确认的写入。

Valkey Cluster 可能丢失写入的第一个原因是它使用异步复制。这意味着在写入期间会发生以下情况:

  • 你的客户端写入主节点 B。
  • 主节点 B 向你的客户端回复 OK。
  • 主节点 B 将写入传播到其副本 B1、B2 和 B3。

如你所见,B 在回复客户端之前不等待 B1、B2、B3 的确认,因为这将给 Valkey 带来令人望而却步的延迟惩罚。因此,如果你的客户端写入内容,B 确认写入,但在能够将写入发送到其副本之前崩溃,其中一个副本(未接收到写入)可能会被提升为主节点,从而永远丢失该写入。

这与大多数配置为每秒将数据刷新到磁盘的数据库发生的情况非常相似,因此,由于你过去与不涉及分布式系统的传统数据库系统打交道的经验,你已经能够理解这种情况。类似地,你可以通过强制数据库在回复客户端之前将数据刷新到磁盘来提高一致性,但这通常会导致性能低得令人望而却步。这相当于 Valkey Cluster 中的同步复制。

基本上,需要在性能和一致性之间进行权衡。

Valkey Cluster 在绝对必要时支持同步写入,通过 WAIT 命令实现。这使得写入丢失的可能性大大降低。但是,请注意,即使使用同步复制,Valkey Cluster 也不实现强一致性:在更复杂的故障场景下,总是可能出现未收到写入的副本被选为主节点的情况。

Valkey Cluster 还会丢失写入的另一个显著场景发生在网络分区期间,其中客户端与少数实例(包括至少一个主节点)隔离。

以我们包含 A、B、C、A1、B1、C1 六个节点的集群为例,其中有 3 个主节点和 3 个副本。还有一个客户端,我们称之为 Z1。

分区发生后,分区的一侧可能有 A、C、A1、B1、C1,而另一侧有 B 和 Z1。

Z1 仍然能够写入 B,B 将接受其写入。如果分区在很短的时间内恢复,集群将正常继续。但是,如果分区持续足够长的时间,使得 B1 在分区的大多数侧被提升为主节点,则 Z1 在此期间发送给 B 的写入将丢失。

注意: Z1 能够发送到 B 的写入量有一个最大窗口:如果足够的时间过去,使得分区的大多数侧选出了一个副本作为主节点,则少数侧的每个主节点都将停止接受写入。

这段时间是 Valkey Cluster 中一个非常重要的配置指令,被称为节点超时

节点超时过后,一个主节点被认为是故障的,可以由其一个副本替换。类似地,如果主节点在节点超时时间内无法感知大多数其他主节点,它将进入错误状态并停止接受写入。

Valkey Cluster 配置参数

我们即将创建一个示例集群部署。在继续之前,让我们介绍 Valkey Cluster 在 valkey.conf 文件中引入的配置参数。

  • cluster-enabled <yes/no>: 如果为 yes,则在特定的 Valkey 实例中启用 Valkey Cluster 支持。否则,实例像往常一样以独立实例启动。
  • cluster-config-file <filename>: 请注意,尽管此选项的名称如此,但这并不是一个用户可编辑的配置文件,而是 Valkey Cluster 节点在每次配置更改时自动持久化集群配置(基本上是状态)的文件,以便在启动时能够重新读取。该文件列出了集群中的其他节点、它们的状态、持久变量等。通常,该文件会因接收到某些消息而被重写并刷新到磁盘。
  • cluster-node-timeout <milliseconds>: Valkey Cluster 节点可以不可用的最长时间,超过此时间则被视为故障。如果主节点在指定时间内无法访问,则其副本将对其进行故障转移。此参数控制 Valkey Cluster 中的其他重要事项。值得注意的是,任何在指定时间内无法达到大多数主节点的节点,都将停止接受查询。
  • cluster-replica-validity-factor <factor>: 如果设置为零,副本将始终认为自己有效,因此将始终尝试对主节点进行故障转移,无论主节点与副本之间的链接断开连接了多长时间。如果该值为正,则最大断开时间将计算为节点超时值乘以此选项提供的值,如果节点是副本,则如果与主节点的链接断开连接的时间超过指定时间,它将不会尝试启动故障转移。例如,如果节点超时设置为 5 秒,有效性因子设置为 10,则与主节点断开连接超过 50 秒的副本将不会尝试对其主节点进行故障转移。请注意,任何非零值都可能导致在主节点故障后 Valkey Cluster 不可用,如果没有任何副本能够对其进行故障转移。在这种情况下,集群将仅在原始主节点重新加入集群时才恢复可用。
  • cluster-migration-barrier <count>: 主节点将保持连接的副本的最小数量,以便另一个副本迁移到一个不再受任何副本覆盖的主节点。有关副本迁移的更多信息,请参阅本教程中相应的章节。
  • cluster-require-full-coverage <yes/no>: 如果此项设置为 yes(默认值),则如果部分键空间未被任何节点覆盖,集群将停止接受写入。如果选项设置为 no,即使只能处理部分键的请求,集群仍将提供查询服务。
  • cluster-allow-reads-when-down <yes/no>: 如果此项设置为 no(默认值),Valkey Cluster 中的节点在集群被标记为故障时(无论是节点无法达到多数主节点还是未满足完全覆盖)将停止服务所有流量。这可以防止从不了解集群更改的节点读取可能不一致的数据。此选项可以设置为 yes,以允许在故障状态下从节点读取,这对于希望优先考虑读取可用性但仍希望防止不一致写入的应用程序很有用。它也可以用于只使用一个或两个分片的 Valkey Cluster,因为它允许节点在主节点故障但无法自动故障转移时继续服务写入。

创建和使用 Valkey Cluster

要创建和使用 Valkey Cluster,请按照以下步骤操作:

但首先,请熟悉创建集群的要求。

创建 Valkey Cluster 的要求

要创建集群,首先需要运行几个处于集群模式的空 Valkey 实例。

至少,在 valkey.conf 文件中设置以下指令:

port 7000
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes

要启用集群模式,请将 cluster-enabled 指令设置为 yes。每个实例还包含一个文件路径,该文件存储此节点的配置,默认是 nodes.conf。此文件从不手动修改;它由 Valkey Cluster 实例在启动时自动生成,并在需要时更新。

请注意,按预期工作的最小集群必须至少包含三个主节点。对于部署,我们强烈建议使用六节点集群,其中包含三个主节点和三个副本。

你可以在本地通过创建以每个目录中将运行的实例端口号命名的以下目录来测试此功能。

例如:

mkdir cluster-test
cd cluster-test
mkdir 7000 7001 7002 7003 7004 7005

在 7000 到 7005 的每个目录中创建一个 valkey.conf 文件。作为配置文件的模板,只需使用上面的小示例,但请确保根据目录名称将端口号 7000 替换为正确的端口号。

你可以按如下方式启动每个实例,每个实例在单独的终端标签页中运行:

cd 7000
valkey-server ./valkey.conf

你将从日志中看到,每个节点都为自己分配了一个新的 ID:

[82462] 26 Nov 11:56:55.329 * No cluster configuration found, I'm 97a3a64667477371c4479320d683e4c8db5858b1

此 ID 将由该特定实例永久使用,以便该实例在集群上下文中拥有唯一的名称。每个节点都使用这些 ID 记住其他节点,而不是通过 IP 或端口。IP 地址和端口可能会更改,但节点的唯一标识符在其整个生命周期中永远不会更改。我们称此标识符为节点 ID

创建 Valkey Cluster

现在我们有了一些正在运行的实例,你需要通过向节点写入一些有意义的配置来创建你的集群。

你可以手动配置和执行单个实例,也可以使用 create-cluster 脚本。让我们回顾一下如何手动操作。

要创建集群,运行:

valkey-cli --cluster create 127.0.0.1:7000 127.0.0.1:7001 \
127.0.0.1:7002 127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 \
--cluster-replicas 1

这里使用的命令是 create,因为我们想创建一个新集群。选项 --cluster-replicas 1 表示我们希望为每个创建的主节点创建一个副本。

其他参数是我要用于创建新集群的实例地址列表。

valkey-cli 将提出一个配置。输入 yes 接受建议的配置。集群将被配置并加入,这意味着实例将被引导以相互通信。最后,如果一切顺利,你将看到类似以下的消息:

[OK] All 16384 slots covered

这意味着至少有一个主实例正在服务 16384 个可用槽中的每一个。

如果你不想像上面解释的那样手动配置和执行单个实例来创建 Valkey Cluster,有一个更简单的系统(但你不会学到同样多的操作细节)。

在 Valkey 发布包中找到 utils/create-cluster 目录。里面有一个名为 create-cluster 的脚本(与它所包含的目录同名),它是一个简单的 bash 脚本。为了启动一个包含 3 个主节点和 3 个副本的 6 节点集群,只需输入以下命令:

  1. create-cluster start
  2. create-cluster create

在第 2 步中,当 valkey-cli 工具要求你接受集群布局时,请回答 yes

你现在可以与集群交互,默认情况下,第一个节点将在端口 30001 启动。完成后,使用以下命令停止集群:

  1. create-cluster stop

请阅读此目录中的 README 文件,了解有关如何运行脚本的更多信息。

与集群交互

要连接到 Valkey Cluster,你需要一个支持集群的 Valkey 客户端。请参阅你选择的客户端的文档以确定其集群支持情况。

你也可以使用 valkey-cli 命令行工具测试你的 Valkey Cluster:

$ valkey-cli -c -p 7000
127.0.0.1:7000> set foo bar
-> Redirected to slot [12182] located at 127.0.0.1:7002
OK
127.0.0.1:7002> set hello world
-> Redirected to slot [866] located at 127.0.0.1:7000
OK
127.0.0.1:7000> get foo
-> Redirected to slot [12182] located at 127.0.0.1:7002
"bar"
127.0.0.1:7002> get hello
-> Redirected to slot [866] located at 127.0.0.1:7000
"world"

注意: 如果你使用脚本创建了集群,你的节点可能会侦听不同的端口,默认从 30001 开始。

valkey-cli 的集群支持非常基础,因此它总是利用 Valkey Cluster 节点能够将客户端重定向到正确节点的事实。一个严谨的客户端能够做得更好,它会缓存哈希槽和节点地址之间的映射,直接使用正确的连接到正确的节点。只有当集群配置发生变化时,例如在故障转移之后或系统管理员通过添加或删除节点更改集群布局之后,映射才会刷新。

使用 redis-rb-cluster 编写示例应用

在继续展示如何操作 Valkey Cluster(例如执行故障转移或重新分片)之前,我们需要创建一个示例应用程序,或者至少能够理解简单 Valkey Cluster 客户端交互的语义。

通过这种方式,我们可以运行一个示例,同时尝试让节点发生故障或启动重新分片,以查看 Valkey Cluster 在实际条件下的表现。在没有写入集群的情况下查看会发生什么并不是很有帮助。

本节解释了 redis-rb-cluster 的一些基本用法,并展示了两个示例。第一个如下,是 redis-rb-cluster 分发包中的 example.rb 文件:

require './cluster'

if ARGV.length != 2
    startup_nodes = [
        {:host => "127.0.0.1", :port => 7000},
        {:host => "127.0.0.1", :port => 7001}
    ]
else
    startup_nodes = [
        {:host => ARGV[0], :port => ARGV[1].to_i]: ]
end

rc = RedisCluster.new(startup_nodes,32,:timeout => 0.1)

last = false

while not last
    begin
        last = rc.get("__last__")
        last = 0 if !last
    rescue => e
        puts "error #{e.to_s}"
        sleep 1
    end
end

((last.to_i+1)..1000000000).each{|x|
    begin
        rc.set("foo#{x}",x)
        puts rc.get("foo#{x}")
        rc.set("__last__",x)
    rescue => e
        puts "error #{e.to_s}"
    end
    sleep 0.1
}

该应用程序做了一件非常简单的事情,它将形式为 foo<number> 的键一个接一个地设置为 number。因此,如果你运行该程序,结果是以下命令流:

  • SET foo0 0
  • SET foo1 1
  • SET foo2 2
  • 等等...

该程序看起来比通常应有的更复杂,因为它旨在屏幕上显示错误而不是通过异常退出,因此与集群执行的每个操作都包裹在 begin rescue 块中。

第 14 行是程序中第一个有趣的行。它创建了 Valkey Cluster 对象,参数包括启动节点列表、该对象允许与不同节点建立的最大连接数,以及操作被视为失败的超时时间。

启动节点无需是集群中的所有节点。重要的是至少有一个节点可达。另请注意,redis-rb-cluster 在能够连接到第一个节点后会立即更新此启动节点列表。你应期望任何其他认真的客户端都有这种行为。

现在我们已经将 Valkey Cluster 对象实例存储在 rc 变量中,我们准备像使用普通 Valkey 对象实例一样使用该对象。

这正是第 18 到 26 行发生的情况:当我们重新启动示例时,我们不想从 foo0 重新开始,因此我们将计数器存储在 Valkey 本身中。上面的代码旨在读取此计数器,或者如果计数器不存在,则将其赋值为零。

但请注意它是一个 while 循环,因为我们希望即使集群宕机并返回错误也能一遍又一遍地尝试。正常的应用程序无需如此小心。

第 28 行到 37 行之间是主循环的开始,其中设置键或显示错误。

注意循环末尾的 sleep 调用。在你的测试中,如果你想尽可能快地写入集群,可以移除 sleep(当然,这是在没有真正并行性的忙碌循环中,所以你通常会在最佳条件下获得 1 万次操作/秒)。

通常,写入速度会放慢,以便示例应用程序更容易被人理解。

启动应用程序会产生以下输出:

ruby ./example.rb
1
2
3
4
5
6
7
8
9
^C (I stopped the program here)

这不是一个非常有趣的程序,我们稍后会使用一个更好的,但我们已经可以看到在程序运行时重新分片期间会发生什么。

对集群进行重新分片

现在我们准备尝试集群重新分片。为此,请保持 example.rb 程序运行,以便你可以看到它对正在运行的程序是否有影响。另外,如果你想在重新分片期间获得更严重的写入负载,你可能需要注释掉 sleep 调用。

重新分片基本上意味着将哈希槽从一组节点移动到另一组节点。与集群创建一样,它通过 valkey-cli 工具完成。

要启动重新分片,只需输入:

valkey-cli --cluster reshard 127.0.0.1:7000

你只需要指定一个节点,valkey-cli 会自动找到其他节点。

目前 valkey-cli 只能在管理员支持下进行重新分片,你不能简单地说将 5% 的槽从这个节点移动到另一个节点(但这实现起来相当简单)。所以它会从提问开始。第一个问题是你想要进行多大程度的重新分片:

How many slots do you want to move (from 1 to 16384)?

我们可以尝试重新分片 1000 个哈希槽,如果示例在没有 sleep 调用的情况下仍在运行,这应该已经包含相当数量的键。

然后 valkey-cli 需要知道重新分片的目标,即接收哈希槽的节点。我将使用第一个主节点,即 127.0.0.1:7000,但我需要指定该实例的节点 ID。valkey-cli 已经在一个列表中打印了它,但如果需要,我总是可以使用以下命令找到节点的 ID:

$ valkey-cli -p 7000 cluster nodes | grep myself
97a3a64667477371c4479320d683e4c8db5858b1 :0 myself,master - 0 0 0 connected 0-5460

好的,我的目标节点是 97a3a64667477371c4479320d683e4c8db5858b1。

现在你将被问到你想从哪些节点获取这些键。我只需输入 all,以便从所有其他主节点中获取一些哈希槽。

在最终确认后,你将看到 valkey-cli 将从一个节点移动到另一个节点的每个槽的消息,并且每移动一个实际的键,就会打印一个点。

在重新分片进行时,你应该能够看到你的示例程序运行不受影响。如果需要,你可以在重新分片期间多次停止和重新启动它。

重新分片结束后,你可以使用以下命令测试集群的健康状况:

valkey-cli --cluster check 127.0.0.1:7000

所有槽位将照常覆盖,但这次 127.0.0.1:7000 上的主节点将拥有更多哈希槽,大约 6461 个。

重新分片可以自动执行,无需手动交互式输入参数。这可以通过类似以下的命令行实现:

valkey-cli --cluster reshard <host>:<port> --cluster-from <node-id> --cluster-to <node-id> --cluster-slots <number of slots> --cluster-yes

这允许在您需要频繁重新分片时构建一些自动化,但是目前 valkey-cli 无法自动重新平衡集群,检查集群节点之间的键分布并智能地移动槽。此功能将在未来添加。

--cluster-yes 选项指示集群管理器自动回答命令提示符的“是”,允许其在非交互模式下运行。请注意,此选项也可以通过设置 REDISCLI_CLUSTER_YES 环境变量来激活。

一个更有趣的示例应用程序

我们早期编写的示例应用程序并不是很好。它以一种简单的方式写入集群,甚至没有检查写入的内容是否正确。

从我们的角度来看,接收写入的集群可能总是将键 foo 写入 42 到每个操作中,我们根本不会注意到。

因此,在 redis-rb-cluster 仓库中,有一个更有趣的应用程序,名为 consistency-test.rb。它使用一组计数器,默认为 1000 个,并发送 INCR 命令来增加计数器。

然而,该应用程序除了写入之外,还做了另外两件事:

  • 当使用 INCR 更新计数器时,应用程序会记住该写入。
  • 它在每次写入之前还会读取一个随机计数器,并检查该值是否符合预期,将其与内存中的值进行比较。

这意味着这个应用程序是一个简单的一致性检查器,它能够告诉你集群是否丢失了某些写入,或者是否接受了我们未收到确认的写入。在第一种情况下,我们会看到计数器的值小于我们记住的值,而在第二种情况下,值会更大。

运行 consistency-test 应用程序会每秒产生一行输出:

$ ruby consistency-test.rb
925 R (0 err) | 925 W (0 err) |
5030 R (0 err) | 5030 W (0 err) |
9261 R (0 err) | 9261 W (0 err) |
13517 R (0 err) | 13517 W (0 err) |
17780 R (0 err) | 17780 W (0 err) |
22025 R (0 err) | 22025 W (0 err) |
25818 R (0 err) | 25818 W (0 err) |

该行显示执行的操作和操作的数量,以及错误数量(由于系统不可用导致查询未被接受的错误)。

如果发现不一致,则会在输出中添加新行。例如,当程序运行时我手动重置计数器时,就会发生这种情况:

$ valkey-cli -h 127.0.0.1 -p 7000 set key_217 0
OK

(in the other tab I see...)

94774 R (0 err) | 94774 W (0 err) |
98821 R (0 err) | 98821 W (0 err) |
102886 R (0 err) | 102886 W (0 err) | 114 lost |
107046 R (0 err) | 107046 W (0 err) | 114 lost |

当我将计数器设置为 0 时,实际值为 114,因此程序报告丢失了 114 次写入(集群未记住的 INCR 命令)。

这个程序作为一个测试用例更有趣,所以我们将用它来测试 Valkey Cluster 的故障转移。

测试故障转移

要触发故障转移,我们能做的最简单的事情(也是分布式系统中可能发生的最语义简单的故障)是使单个进程崩溃,在我们的例子中是一个单个主节点。

注意: 在此测试期间,您应打开一个标签页并运行一致性测试应用程序。

我们可以识别一个主节点并使用以下命令使其崩溃:

$ valkey-cli -p 7000 cluster nodes | grep master
3e3a6cb0d9a9a87168e266b0a0b24026c0aae3f0 127.0.0.1:7001 master - 0 1385482984082 0 connected 5960-10921
2938205e12de373867bf38f1ca29d31d0ddb3e46 127.0.0.1:7002 master - 0 1385482983582 0 connected 11423-16383
97a3a64667477371c4479320d683e4c8db5858b1 :0 myself,master - 0 0 0 connected 0-5959 10922-11422

好的,所以 7000、7001 和 7002 是主节点。让我们使用 DEBUG SEGFAULT 命令使节点 7002 崩溃:

$ valkey-cli -p 7002 debug segfault
Error: Server closed the connection

现在我们可以查看一致性测试的输出,看看它报告了什么。

18849 R (0 err) | 18849 W (0 err) |
23151 R (0 err) | 23151 W (0 err) |
27302 R (0 err) | 27302 W (0 err) |

... many error warnings here ...

29659 R (578 err) | 29660 W (577 err) |
33749 R (578 err) | 33750 W (577 err) |
37918 R (578 err) | 37919 W (577 err) |
42077 R (578 err) | 42078 W (577 err) |

如你所见,在故障转移期间,系统无法接受 578 次读取和 577 次写入,但数据库中没有产生不一致。这听起来可能出乎意料,因为在本教程的第一部分中,我们提到 Valkey Cluster 在故障转移期间可能因为使用异步复制而丢失写入。我们没有说的是,这种情况发生的可能性很小,因为 Valkey 几乎同时将回复发送给客户端和将命令复制给副本,因此丢失数据的窗口非常小。然而,难以触发并不意味着不可能,所以这不改变 Valkey Cluster 提供的一致性保证。

我们现在可以检查故障转移后集群的设置(请注意,在此期间我已重新启动崩溃的实例,以便它重新加入集群作为副本):

$ valkey-cli -p 7000 cluster nodes
3fc783611028b1707fd65345e763befb36454d73 127.0.0.1:7004 slave 3e3a6cb0d9a9a87168e266b0a0b24026c0aae3f0 0 1385503418521 0 connected
a211e242fc6b22a9427fed61285e85892fa04e08 127.0.0.1:7003 slave 97a3a64667477371c4479320d683e4c8db5858b1 0 1385503419023 0 connected
97a3a64667477371c4479320d683e4c8db5858b1 :0 myself,master - 0 0 0 connected 0-5959 10922-11422
3c3a0c74aae0b56170ccb03a76b60cfe7dc1912e 127.0.0.1:7005 master - 0 1385503419023 3 connected 11423-16383
3e3a6cb0d9a9a87168e266b0a0b24026c0aae3f0 127.0.0.1:7001 master - 0 1385503417005 0 connected 5960-10921
2938205e12de373867bf38f1ca29d31d0ddb3e46 127.0.0.1:7002 slave 3c3a0c74aae0b56170ccb03a76b60cfe7dc1912e 0 1385503418016 3 connected

现在主节点运行在端口 7000、7001 和 7005 上。之前作为主节点的 Valkey 实例(运行在端口 7002 上)现在是 7005 的副本。

CLUSTER NODES 命令的输出可能看起来令人望而生畏,但它实际上非常简单,由以下标记组成:

  • 节点 ID
  • ip:port
  • 标志:master、replica、myself、fail、...
  • 如果是副本,则为主节点的节点 ID
  • 上次待处理 PING 仍在等待回复的时间。
  • 上次收到 PONG 的时间。
  • 此节点的配置纪元(参见集群规范)。
  • 与此节点的链接状态。
  • 服务槽...

手动故障转移

有时,在不实际导致主节点出现任何问题的情况下强制执行故障转移会很有用。例如,为了升级其中一个主节点的 Valkey 进程,最好对其执行故障转移,将其转换为副本,同时最大限度地减少对可用性的影响。

Valkey Cluster 支持手动故障转移,使用 CLUSTER FAILOVER 命令,该命令必须在你希望对其进行故障转移的主节点的一个副本上执行。

手动故障转移是特殊的,比实际主节点故障导致的故障转移更安全。它们以一种在过程中避免数据丢失的方式发生,通过仅在系统确定新主节点处理了旧主节点的所有复制流后,才将客户端从原始主节点切换到新主节点。

当你执行手动故障转移时,你在副本日志中会看到以下内容:

# Manual failover user request accepted.
# Received replication offset for paused primary manual failover: 347540
# All primary replication stream processed, manual failover can start.
# Start of election delayed for 0 milliseconds (rank #0, offset 347540).
# Starting a failover election for epoch 7545.
# Failover election won: I'm the new primary.

在故障转移期间,向主节点发送写入命令的客户端会被阻塞。当主节点将其复制偏移量发送给副本时,副本会等待在其侧达到该偏移量。当复制偏移量达到后,故障转移开始,并且旧主节点会被告知配置切换。当切换完成后,旧主节点上的客户端会被解除阻塞,并被重定向到新的主节点。

注意: 要将副本提升为主节点,它必须首先被集群中大多数主节点识别为副本。否则,它无法赢得故障转移选举。如果副本刚刚添加到集群中(参见将新节点添加为副本),你可能需要等待一段时间才能发送 CLUSTER FAILOVER 命令,以确保集群中的主节点知道新副本。

添加新节点

添加新节点基本上是添加一个空节点,然后将一些数据移入其中(如果是新主节点),或者告诉它设置为已知节点的副本(如果是副本)。

我们将展示这两种情况,从添加新的主实例开始。

在这两种情况下,要执行的第一步是添加一个空节点

这很简单,只需在端口 7006 启动一个新节点(我们现有 6 个节点已使用 7000 到 7005),配置与现有节点相同,只是端口号不同。因此,为了符合我们之前节点使用的设置,你应该这样做:

  • 在你的终端应用程序中创建一个新标签页。
  • 进入 cluster-test 目录。
  • 创建一个名为 7006 的目录。
  • 在其中创建一个 valkey.conf 文件,类似于用于其他节点的文件,但使用 7006 作为端口号。
  • 最后使用 ../valkey-server ./valkey.conf 启动服务器。

此时服务器应该正在运行。

现在我们可以像往常一样使用 valkey-cli 将节点添加到现有集群中。

valkey-cli --cluster add-node 127.0.0.1:7006 127.0.0.1:7000

如你所见,我使用了 add-node 命令,将新节点的地址作为第一个参数,集群中随机现有节点的地址作为第二个参数。

实际上,valkey-cli 在这里帮助我们甚少,它只是向节点发送了一条 CLUSTER MEET 消息,这也可以手动完成。然而,valkey-cli 在操作之前还会检查集群的状态,因此即使你了解内部工作原理,也最好始终通过 valkey-cli 执行集群操作。

现在我们可以连接到新节点,看看它是否真的加入了集群:

valkey 127.0.0.1:7006> cluster nodes
3e3a6cb0d9a9a87168e266b0a0b24026c0aae3f0 127.0.0.1:7001 master - 0 1385543178575 0 connected 5960-10921
3fc783611028b1707fd65345e763befb36454d73 127.0.0.1:7004 slave 3e3a6cb0d9a9a87168e266b0a0b24026c0aae3f0 0 1385543179583 0 connected
f093c80dde814da99c5cf72a7dd01590792b783b :0 myself,master - 0 0 0 connected
2938205e12de373867bf38f1ca29d31d0ddb3e46 127.0.0.1:7002 slave 3c3a0c74aae0b56170ccb03a76b60cfe7dc1912e 0 1385543178072 3 connected
a211e242fc6b22a9427fed61285e85892fa04e08 127.0.0.1:7003 slave 97a3a64667477371c4479320d683e4c8db5858b1 0 1385543178575 0 connected
97a3a64667477371c4479320d683e4c8db5858b1 127.0.0.1:7000 master - 0 1385543179080 0 connected 0-5959 10922-11422
3c3a0c74aae0b56170ccb03a76b60cfe7dc1912e 127.0.0.1:7005 master - 0 1385543177568 3 connected 11423-16383

请注意,由于此节点已连接到集群,它已能够正确重定向客户端查询,并且通常来说是集群的一部分。然而,它与其他主节点相比有两个特殊之处:

  • 它没有数据,因为它没有分配的哈希槽。
  • 因为它是一个没有分配槽的主节点,所以在副本想要成为主节点时,它不参与选举过程。

现在可以使用 valkey-cli 的重新分片功能将哈希槽分配给该节点。由于我们已经在前面的章节中展示过,所以再次展示这一点基本上没有用,没有区别,它只是一个将空节点作为目标的重新分片。

将新节点添加为副本

添加新副本有两种方式。显而易见的方式是再次使用 valkey-cli,但带上 --cluster-replica 选项,像这样:

valkey-cli --cluster add-node 127.0.0.1:7006 127.0.0.1:7000 --cluster-replica

请注意,这里的命令行与我们用于添加新主节点的命令行完全相同,因此我们没有指定要将副本添加到哪个主节点。在这种情况下,valkey-cli 会将新节点添加为副本,作为副本数量最少的主节点之一。

但是,你可以使用以下命令行精确指定你的新副本要指向哪个主节点:

valkey-cli --cluster add-node 127.0.0.1:7006 127.0.0.1:7000 --cluster-replica --cluster-master-id 3c3a0c74aae0b56170ccb03a76b60cfe7dc1912e

这样,我们将新的副本分配给特定的主节点。

更手动地将副本添加到特定主节点的方法是,先将新节点添加为空主节点,然后使用 CLUSTER REPLICATE 命令将其转换为副本。如果节点已添加为副本,但你希望将其移动到不同的主节点的副本,此方法也适用。

例如,为了给节点 127.0.0.1:7005 添加一个副本,该节点目前服务哈希槽范围 11423-16383,其节点 ID 为 3c3a0c74aae0b56170ccb03a76b60cfe7dc1912e,我需要做的就是连接到新节点(已添加为空主节点)并发送命令:

valkey 127.0.0.1:7006> cluster replicate 3c3a0c74aae0b56170ccb03a76b60cfe7dc1912e

就是这样。现在我们为这组哈希槽有了一个新的副本,集群中的所有其他节点都已知道(需要几秒钟来更新它们的配置)。我们可以用以下命令进行验证:

$ valkey-cli -p 7000 cluster nodes | grep slave | grep 3c3a0c74aae0b56170ccb03a76b60cfe7dc1912e
f093c80dde814da99c5cf72a7dd01590792b783b 127.0.0.1:7006 replica 3c3a0c74aae0b56170ccb03a76b60cfe7dc1912e 0 1385543617702 3 connected
2938205e12de373867bf38f1ca29d31d0ddb3e46 127.0.0.1:7002 replica 3c3a0c74aae0b56170ccb03a76b60cfe7dc1912e 0 1385543617198 3 connected

节点 3c3a0c... 现在有两个副本,运行在端口 7002(现有)和 7006(新增)上。

移除节点

要移除副本节点,只需使用 valkey-cli 的 del-node 命令:

valkey-cli --cluster del-node 127.0.0.1:7000 `<node-id>`

第一个参数是集群中的一个随机节点,第二个参数是你想要移除的节点的 ID。

你也可以用同样的方式移除主节点,但是要移除主节点,它必须是空的。如果主节点不为空,你需要先将数据从它重新分片到所有其他主节点。

移除主节点的另一种方法是,对其进行手动故障转移,使其变为其一个副本的从节点,然后在它变成新主节点的副本后移除该节点。显然,当你想要减少集群中实际的主节点数量时,这种方法没有帮助,在这种情况下,需要进行重新分片。

有一种特殊情况,您希望移除一个失败的节点。您不应使用 del-node 命令,因为它会尝试连接到所有节点,您将遇到“连接被拒绝”错误。相反,您可以使用 call 命令:

valkey-cli --cluster call 127.0.0.1:7000 cluster forget `<node-id>`

此命令将在每个节点上执行 CLUSTER FORGET 命令。

副本迁移

在 Valkey Cluster 中,你可以在任何时候使用此命令重新配置一个副本,使其与不同的主节点进行复制:

CLUSTER REPLICATE <master-node-id>

然而,在某些情况下,你希望副本能够自动地从一个主节点移动到另一个主节点,而无需系统管理员的帮助。副本的自动重新配置被称为副本迁移,它能够提高 Valkey Cluster 的可靠性。

注意: 你可以在Valkey Cluster 规范中阅读副本迁移的详细信息,这里我们只提供一些关于通用概念以及你应该如何从中受益的信息。

你可能希望在某些条件下让你的集群副本从一个主节点移动到另一个主节点的原因是,Valkey Cluster 对故障的抵抗力通常取决于连接到给定主节点的副本数量。

例如,一个每个主节点只有一个副本的集群,如果主节点及其副本同时发生故障,则无法继续操作,仅仅因为没有其他实例拥有该主节点服务的哈希槽的副本。然而,虽然网络分区很可能同时隔离多个节点,但许多其他类型的故障,如单个节点本地的硬件或软件故障,是一类非常显著的故障,不太可能同时发生,因此在你的集群中,每个主节点都有一个副本,副本可能在凌晨 4 点被杀死,主节点在凌晨 6 点被杀死。这仍然会导致集群无法继续运行。

为了提高系统的可靠性,我们可以选择为每个主节点添加额外的副本,但这代价高昂。副本迁移允许只为少数主节点添加更多副本。因此,你有 10 个主节点,每个主节点有 1 个副本,总共有 20 个实例。然而,你又添加了,例如,3 个实例作为某些主节点的副本,这样某些主节点将拥有一个以上的副本。

使用副本迁移,如果一个主节点没有副本,那么拥有多个副本的主节点的副本将迁移到这个“孤立”的主节点。因此,在你的副本在凌晨 4 点宕机后(如我们上面所举的例子),另一个副本将取代它的位置,当主节点在凌晨 5 点也发生故障时,仍然有一个副本可以被选举,从而使集群能够继续运行。

那么,关于副本迁移,你应该简要了解什么呢?

  • 集群会尝试从某个时刻副本数量最多的主节点迁移一个副本。
  • 要从副本迁移中受益,你只需在集群中为单个主节点添加几个额外的副本,无论哪个主节点都无关紧要。
  • 有一个配置参数控制副本迁移功能,称为 cluster-migration-barrier:你可以在 Valkey Cluster 提供的示例 valkey.conf 文件中阅读更多相关信息。

升级 Valkey Cluster 中的节点

升级副本节点很简单,因为你只需停止节点并使用更新的 Valkey 版本重新启动它。如果客户端使用副本节点进行读扩展,那么如果某个副本节点不可用,它们应该能够重新连接到不同的副本。

升级主节点则稍微复杂一些。建议的步骤是触发手动故障转移,将旧主节点转换为副本,然后进行升级。

可以通过对每个分片(一个主节点及其副本)重复以下步骤来执行集群中所有节点的完整滚动升级:

  1. 将一个或多个升级后的节点作为新副本添加到主节点。此步骤是可选的,但它确保在滚动升级期间副本数量不会受到影响。要添加新节点,请使用 CLUSTER MEETCLUSTER REPLICATE,或按照将新节点添加为副本中的描述使用 valkey-cli

    另一种方法是每次升级一个副本,并在升级期间减少在线副本的数量。

  2. 通过使用更新的 Valkey 版本重新启动旧副本,升级你想要保留的旧副本。如果你正在用新节点替换所有旧节点,则可以跳过此步骤。

  3. 选择一个已升级的副本作为新的主节点。等待此副本赶上主节点的复制偏移量。你可以使用 INFO REPLICATION 并检查 master_link_status:up 行是否存在。这表示与主节点的初始同步已完成。

    在初始完全同步之后,副本可能在复制方面仍然落后。向主节点和副本发送 INFO REPLICATION,并比较两个节点返回的 master_repl_offset 字段。如果偏移量匹配,则表示所有写入都已复制。但是,如果主节点收到持续的写入流,则偏移量可能永远不会相等。在此步骤中,你可以接受一个小的差异。通常等待几秒钟足以将差异最小化。

  4. 检查新副本是否被集群中的所有节点(或至少是集群中的主节点)所知晓。你可以向集群中的每个节点发送 CLUSTER NODES,并检查它们是否都知晓新节点。等待一段时间并根据需要重复检查。

  5. 通过向选定成为新主节点的副本节点发送 CLUSTER FAILOVER 来触发手动故障转移。有关更多信息,请参阅本文档中的手动故障转移部分。

  6. 等待故障转移完成。要检查,您可以使用 ROLEINFO REPLICATION(故障转移成功后显示 role:master)或 CLUSTER NODES 来验证集群状态在命令发送后不久是否已更改。

  7. 将旧主节点(现在是副本)停止服务,或升级并重新添加为副本。如果升级期间为了冗余而保留了额外的副本,请将其移除。

对每个分片(每个主节点及其副本)重复此序列,直到集群中的所有节点都已升级。

迁移到 Valkey Cluster

希望迁移到 Valkey Cluster 的用户可能只有一个主节点,或者可能已经使用预先存在的 sharding 设置,其中键使用某种内部算法或其客户端库或 Valkey 代理实现的 sharding 算法在 N 个节点之间进行拆分。

在这两种情况下,都可以轻松迁移到 Valkey Cluster,然而最重要的是应用程序是否以及如何使用多键操作。有三种不同的情况:

  1. 未使用多键操作、事务或涉及多键的 Lua 脚本。键是独立访问的(即使是通过分组多个命令的事务或 Lua 脚本访问,但这些命令只涉及同一个键)。
  2. 使用多键操作、事务或涉及多键的 Lua 脚本,但仅限于具有相同哈希标签的键,这意味着一起使用的键都具有相同的 {...} 子字符串。例如,以下多键操作是在相同哈希标签的上下文中定义的:SUNION {user:1000}.foo {user:1000}.bar
  3. 使用多键操作、事务或涉及多键的 Lua 脚本,其中键名没有显式或相同的哈希标签。

第三种情况 Valkey Cluster 不予处理:应用程序需要修改,以便不使用多键操作,或仅在相同哈希标签的上下文中使用它们。

情况 1 和 2 涵盖在内,因此我们将重点关注这两种情况,它们以相同的方式处理,因此在文档中将不作区分。

假设你已将现有数据集拆分为 N 个主节点(如果之前没有分片,则 N=1),那么需要以下步骤才能将数据集迁移到 Valkey Cluster:

  1. 停止你的客户端。目前无法自动将数据实时迁移到 Valkey Cluster。你可能需要在你的应用程序/环境中协调实时迁移。
  2. 使用 BGREWRITEAOF 命令为所有 N 个主节点生成一个仅追加文件,并等待 AOF 文件完全生成。
  3. 将你的 AOF 文件(从 aof-1 到 aof-N)保存到某个地方。此时,如果你愿意,可以停止旧实例(这很有用,因为在非虚拟化部署中,你经常需要重复使用相同的计算机)。
  4. 创建一个由 N 个主节点和零个副本组成的 Valkey Cluster。稍后你将添加副本。确保所有节点都使用追加文件进行持久化。
  5. 停止所有集群节点,用你预先存在的追加文件替换它们的追加文件,aof-1 用于第一个节点,aof-2 用于第二个节点,直到 aof-N。
  6. 使用新的 AOF 文件重新启动你的 Valkey Cluster 节点。它们会抱怨有键不应该存在,根据它们的配置。
  7. 使用 valkey-cli --cluster fix 命令来修复集群,以便根据每个节点是否拥有哈希槽的权限来迁移键。
  8. 最后使用 valkey-cli --cluster check 确保你的集群正常。
  9. 重新启动你的客户端,并修改为使用支持 Valkey Cluster 的客户端库。

还有另一种将数据从外部实例导入 Valkey Cluster 的方法,即使用 valkey-cli --cluster import 命令。

该命令将正在运行的实例的所有键(并从源实例中删除键)移动到指定的现有 Valkey Cluster 中。

注意: Valkey 项目除了为了向后兼容,不再使用“master”和“slave”这两个词。不幸的是,在这些命令中,这些词是协议的一部分,所以我们只有在这些 API 自然地被废弃时才能删除这些出现。

了解更多