跳过正文

Raft Cluster valkey-cli Tooling Compatibility

作者
杨全烨
系统软件:操作系统、网络与分布式系统。
目录

也是非常有趣的话题,最近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)里,拓扑变更大致是:

  1. 各节点本地改 slot 表
  2. 通过 gossip 传播
  3. 用 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 代码里把它们包裹在了一个事务块里:

  1. 发送 MULTI(开启事务,这个叫事物是因为我一次会执行多条命令)
  2. 发送 CLUSTER ADDSLOTS 0 1 2 ...
  3. 发送 CLUSTER SET-CONFIG-EPOCH 1
  4. 发送 EXEC(一次性执行) 在这个阶段,节点处理这些命令是完全同步且纯内存的操作。 主线程一口气执行完,中间不发生任何网络 I/O 等待,所以非常丝滑。

就是你发送给了一个机器来解决这个问题,这个机器的主线程不需要等待同步,自己一修改拓扑,之后gossip自己就会传播这个拓扑。 比如你想要达到的状态需要很多的命令,如果不使用事务,就可能会引入很多的中间状态。

2. V2 的新矛盾:为什么 Raft 拒绝了 MULTI/EXEC
#

引入 Raft(Cluster V2)之后,拓扑变更是大事,不能单方面决定了。

因为Raft需要等待多数派回复,我们不能等这个卡死。拓扑修改成了大事,不能是leader随便修改的。

当节点收到 CLUSTER ADDSLOTS 时,它不能直接在内存里改完就返回。它必须:

  1. 把这个槽位变更作为一个提案(Proposal)写入自己的 Raft 日志。
  2. 通过网络把日志广播给其他 Follower 节点。
  3. 挂起(Block)当前客户端的请求,等待多数派(Quorum)返回确认。
  4. 收到多数派确认后,应用到内存状态机,最后向客户端返回 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才行。 这就是核心的矛盾点。