也是非常有趣的话题,最近valkey引入了Raft算法。
这个 PR 解决的是:Raft 集群里,服务端对拓扑变更命令的执行模型变了, 但
valkey-cli --cluster fix还在用旧的 gossip 事务模型发命令,两者不兼容。
https://github.com/valkey-io/valkey/issues/3856 可以来学习一下。
start #
首先切换的最新的上游分支:
# 拉取上游最新改动
git fetch upstream
# 基于上游的 cluster-v2 分支,在本地创建一个同名分支并切换过去
git checkout -b cluster-v2 upstream/cluster-v2
# 你还可以在自己的仓库内部保存一下进度
git push -u origin cluster-v2
因为整体难度比较大,或者涉及的东西比较多,我们需要进行学习,一些背景知识的扩充。
首先是特性:主从复制原理:
| 主题 | 学什么 | 和 Cluster V2 的关系 |
|---|---|---|
| 主从复制(Replication) | REPLICAOF、复制流、offset、INFO replication |
每个 shard 里仍是 1 个 primary + N 个 replica;failover 换的是 shard 的 primary,不是 Raft leader |
| 复制故障切换(Sentinel,可选但推荐) | Sentinel 监控主、投票选新主、客户端怎么发现新主 | 帮助理解「数据面选主」;#384 里提到 V2 想和 Sentinel 概念合并,但实现不同 |
| 异步 vs 同步复制(概念) | 默认异步可能丢尾部写;sync 要 quorum ack | 理解为什么还要有 #3869 sync replication;Raft 只保证拓扑一致,不保证数据不丢 |
然后你肯定要理解Raft-Extended Version 1-5 详解算法,这样才能进一步理解整个feature.
这是我们需要解决的Raft Cluster: valkey-cli --cluster Tooling Compatibility
Workflow #
这是我们第一次和社区进行合作开发. 先进行这个feature分支的同步操作:
git fetch upstream
git checkout cluster-v2
git merge upstream/cluster-v2 # 或 git rebase upstream/cluster-v2
直接reset到upstream的最新代码:
# 1. 退出当前 rebase,恢复 rebase 前状态
git rebase --abort
# 2. 确认 upstream 最新
git fetch upstream
# 3. 将本地 cluster-v2 硬重置到 upstream(本地无独有 commit 时安全)
git checkout cluster-v2
git reset --hard upstream/cluster-v2
然后我们建一个新的功能分支:
git checkout -b cli-raft-fix-3867 # 之后就在这个上面进行开发
保证我们远程和origin仓库和upstream也是保持一致的:
git push --force-with-lease origin cluster-v2
先理解一下背景 #
--cluster fix 在修什么?
#
valkey-cli --cluster fix 用来修复集群拓扑异常,常见场景包括:
- 某个 slot 没有 owner(uncovered)
- 多个节点同时声称拥有同一个 slot
- slot 卡在 migrating / importing 中间状态(half-migration)
核心操作之一是:把某个 slot 的 ownership 明确赋给某个 primary,
即调用 clusterManagerSetSlotOwner():
CLUSTER DELSLOTS <slot> # 从当前节点摘掉(可能已是 unassigned,可忽略)
CLUSTER ADDSLOTS <slot> # 正式赋给目标节点
CLUSTER SETSLOT <slot> STABLE # 可选,清掉 migrating/importing 状态
我给某个主机发这些命令就能达到这样的效果。
fix 里多个路径都会走到这个函数,例如 clusterManagerFixOpenSlot()、clusterManagerFixSlotsCoverage() 等。
这里就是处理有些slot没被覆盖的情况。
Gossip 时代:为什么用 MULTI/EXEC #
所谓的“事务”。
在 gossip 集群(cluster-protocol gossip,即传统 Redis Cluster)里,拓扑变更大致是:
- 各节点本地改 slot 表
- 通过 gossip 传播
- 用 config epoch 解决冲突
valkey-cli 希望这几步原子地完成,避免中间态被其他操作看到,所以 clusterManagerSetSlotOwnerGossip() 走事务:
static int clusterManagerSetSlotOwnerGossip(clusterManagerNode *owner, int slot, int do_clear) {
// 发送事务,然后执行。
int success = clusterManagerStartTransaction(owner);
if (!success) return 0;
clusterManagerDelSlot(owner, slot, 1);
clusterManagerAddSlot(owner, slot);
if (do_clear) clusterManagerClearSlotStatus(owner, slot);
clusterManagerBumpEpoch(owner);
success = clusterManagerExecTransaction(owner, clusterManagerOnSetOwnerErr);
return success;
}
实际上对应的命令行序列就是这样:
比如你想要修改节点,会先进行积压,然后受到EXEC的时候一口气执行,不想要中间态。
MULTI
CLUSTER DELSLOTS 609 → QUEUED
CLUSTER ADDSLOTS 609 → QUEUED
CLUSTER SETSLOT 609 STABLE → QUEUED(可选)
CLUSTER BUMPEPOCH → QUEUED
EXEC → 一次性执行,返回数组
在 gossip 下,CLUSTER ADDSLOTS / DELSLOTS 是同步命令:
处理完立刻 +OK,不 block 客户端。MULTI/EXEC 在这里是合理优化。
1. V1 的旧玩法:为什么以前要用 MULTI/EXEC?
#
在目前的 Gossip 架构下,当你使用 valkey-cli --cluster create 或者 rebalance 去组建/调整集群时,CLI 工具需要向特定的节点发送一连串的拓扑修改命令(比如分配一堆槽位、设置 Epoch 等)。
为了保证这一批配置命令的原子性(要么全成功,要么全失败),valkey-cli 在 C 代码里把它们包裹在了一个事务块里:
- 发送
MULTI(开启事务,这个叫事物是因为我一次会执行多条命令) - 发送
CLUSTER ADDSLOTS 0 1 2 ... - 发送
CLUSTER SET-CONFIG-EPOCH 1 - 发送
EXEC(一次性执行) 在这个阶段,节点处理这些命令是完全同步且纯内存的操作。 主线程一口气执行完,中间不发生任何网络 I/O 等待,所以非常丝滑。
就是你发送给了一个机器来解决这个问题,这个机器的主线程不需要等待同步,自己一修改拓扑,之后gossip自己就会传播这个拓扑。 比如你想要达到的状态需要很多的命令,如果不使用事务,就可能会引入很多的中间状态。
2. V2 的新矛盾:为什么 Raft 拒绝了 MULTI/EXEC?
#
引入 Raft(Cluster V2)之后,拓扑变更是大事,不能单方面决定了。
因为Raft需要等待多数派回复,我们不能等这个卡死。拓扑修改成了大事,不能是leader随便修改的。
当节点收到 CLUSTER ADDSLOTS 时,它不能直接在内存里改完就返回。它必须:
- 把这个槽位变更作为一个提案(Proposal)写入自己的 Raft 日志。
- 通过网络把日志广播给其他 Follower 节点。
- 挂起(Block)当前客户端的请求,等待多数派(Quorum)返回确认。
- 收到多数派确认后,应用到内存状态机,最后向客户端返回
OK。
在 Valkey 的底层,这种需要挂起等待异步事件(比如网络回包)的操作,被标记为 BLOCKED_ASYNC。
核心冲突点来了: Valkey 的核心事件循环是单线程的。
MULTI/EXEC 的语义是“独占主线程,一口气执行完所有命令,期间绝对不交出 CPU 控制权”。
Gossip可以直接执行,然后之后再慢慢达成共识,但是Raft需要等待执行完毕才行。
如果允许一个 BLOCKED_ASYNC 的命令在 MULTI 块里执行,
它就会在等待 Raft 网络共识的过程中,
把整个 Valkey 节点的主线程冻结(Freeze)住,
其他所有客户端的请求全都会被卡死。
为了防止这种灾难,Valkey 服务端在底层直接写死了防御逻辑:
任何被标记为 BLOCKED_ASYNC 的命令,严禁出现在 MULTI/EXEC 块中。如果出现,直接报错拒绝。
想法就是不使用事务了,而是就一条一条的执行, Raft的这边leader在commit日志了之后,再去回复client,期间client轮询即可。
那我们先复现问题 #
先编译:
make -j$(nproc) # 多线程进行编译
然后在client侧我们在这条连接上面执行命令:
~/Project/valkey cli-raft-fix-3867 ❯ ./src/valkey-cli -p 7000
127.0.0.1:7000> MULTI
OK
127.0.0.1:7000(TX)> CLUSTER ADDSLOTS 0
QUEUED
127.0.0.1:7000(TX)> EXEC
1) (error) ERR This cluster command is not allowed inside MULTI # 目前MULTI操作是不支持的
然后进行测试验证:
# Raft(验证 #3867)
./runtest --cluster-raft --single tests/unit/cluster/half-migrated-slot.tcl # 这个就一定会报错
# Gossip 回归
./runtest --single tests/unit/cluster/half-migrated-slot.tcl # 这个可以实现结束之后进行回归测试
那么现在可以认为复现完成了。
flowchart TB
subgraph MULTI["MULTI/EXEC 模型"]
Q[收到命令] --> Queue[只入队 queueMultiCommand]
Queue --> R1[立刻回复 QUEUED]
EXEC[EXEC] --> Loop[同步 for 循环 call]
Loop --> Assert[assert blocked == 0]
Assert --> Array[按顺序写进 EXEC 数组回复]
end
subgraph Raft["Raft 拓扑命令模型"]
C[收到命令] --> Propose[立刻 clusterRaftPropose,Raft是不能被阻塞的,否则整个集群的状态机都会被直接积压住,这样会出现问题]
Propose --> Block[blockClientAsync - 不回复]
Block --> Wait[等 Raft commit]
Wait --> CB[回调 addReply OK]
CB --> Unblock[unblockClientAsync]
end
MULTI -.->|同一命令不能同时满足| Raft
Raft不能等,设计上就是一拿到命令就需要立刻进行proposal才行。 这就是核心的矛盾点。