- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
本文基于 WuKongIM 仓库中
docs/superpowers/plans/2026-06-24-internalv2-dynamic-node-stage5.md及 5A–5E 五个子计划展开,聚焦集群控制面动态节点生命周期的最后一段:节点移除。文章先梳理 Stage 5 在整个动态节点生命周期中的定位与跨子阶段铁律,再逐层拆解 removed 墓碑状态、Channel 排水清单、网关排水模式、安全移除路由与黑盒冒烟验证,并结合当前仓库源码(internal/、pkg/controller、pkg/cluster)给出可复现的验证命令与关键实现证据。读完本文,你将掌握 WuKongIM 中“一个已激活且已排水的数据节点如何被安全标记为 removed”的完整调用链与 fail-closed 判定规则。
一、Stage 5 在动态节点生命周期中的定位
WuKongIM 的动态节点生命周期设计(见 动态节点生命周期设计文档)把节点归属(membership)与健康(health)彻底分离。生命周期状态机为:
joining -> active -> leaving -> removed健康状态独立于生命周期之外:
alive | suspect | downjoining:节点可出现在发现(discovery)中供对端连接,但绝不能被选为 SlotDesiredPeers或 Channel 副本;active:数据节点可接收新放置;leaving:节点保持可达以完成排水与读请求,但规划器和放置代码必须把它排除在新工作之外;removed:墓碑(tombstone)状态。V1 设计明确不做物理删除——cluster-state.json中保留节点身份,用removed状态防止陈旧的node_id被意外复用(设计文档第 5 节)。
整个内部动态节点实现被拆成 12 个有序阶段,其主索引见 internalv2 动态节点生命周期主索引。Stage 5 处于第 5 位,前置依赖 Stage 4 的缩容准备(leaving状态、不再放置保证、Slot 排水状态),是第一个允许写入removed的阶段。它的价值在于:把“未来可安全移除”从设计愿景变成可落地的 fail-closed 闸门,同时通过 5 个小的可评审子阶段(5A–5E)避免一个大分支带来的评审与回归风险。
Stage 5 索引文档给出的技术栈为:Go、ControllerV2 生命周期写、clusterv2 控制转发、internalv2 管理 usecase、manager HTTP 路由、manager 节点 RPC、网关准入控制、e2ev2 黑盒测试。在当前仓库中,这些包被整合为pkg/controller、pkg/cluster、internal/usecase/management、internal/access/manager、internal/access/node与test/e2e(历史计划文档使用internalv2/controllerv2/clusterv2命名,代码落地后统一为internal/、pkg/controller、pkg/cluster)。
二、Stage 5 的入口闸门与跨子阶段铁律
2.1 入口闸门(Entry Gate)
Stage 5 只有在 Stage 4 合入本地main且缩容准备保持健康时才允许开工。入口闸门命令(见 Stage 5 索引):
GOWORK=off go test ./internalv2/usecase/management -run 'TestScaleInStatus|TestAdvanceNodeScaleIn|TestMarkNodeLeaving' -count=1 GOWORK=off go test ./internalv2/access/manager -run 'TestManagerScaleIn' -count=1预期:两条命令均通过;leaving节点拒绝新放置,当运行时数据未知时缩容状态报告为不安全(unsafe)。
2.2 跨子阶段规则(Cross-Sub-Stage Rules)
这是理解 Stage 5 安全模型的钥匙,共 8 条,全部写入 Stage 5 索引:
removed只作为墓碑状态:Stage 5 不得从cluster-state.json物理删除节点身份;- 不得动态增删 Controller Raft 投票者:Controller 投票者变更属于 V1 之外的显式未来工作;
- 不得在 manager/usecase 代码里直接改动 Slot
DesiredPeers:Slot 排水仍是 Stage 3slot_replica_move任务的职责; - 数据未知时不得报告
safe_to_remove=true:Channel 清单、运行时摘要、网关排水状态、Slot 状态、任务状态或控制版本(control revision)任一未知即不安全; SafeToProceed与SafeToRemove必须分离:SafeToProceed表示 leaving 节点在 Slot/任务/Channel 层面没有已知阻塞,可以继续推进缩容;SafeToRemove额外要求网关处于排水模式且运行时计数为空;- 扫描必须受限:Channel 清单扫描是显式的状态/移除安全检查,不是默认节点列表工作;
- 一次分支只执行并合入一个子阶段,除非用户明确要求合并;
- 后续的 5B/5C/5D/5E 均有各自的入口闸门,串行推进。
2.3 子阶段编排一览
| 顺序 | 子阶段 | 计划文档 | 产物 |
|---|---|---|---|
| 5A | Removed 生命周期墓碑 | 5A 计划 | ControllerV2 与 clusterv2 可将已排水的 leaving 数据节点标记为removed |
| 5B | Channel 排水清单 | 5B 计划 | 缩容状态包含受限的 Channel leader/replica/ISR 阻塞项 |
| 5C | 网关排水模式 | 5C 计划 | Manager 可将节点网关置为排水模式,运行时状态报告排水阻塞 |
| 5D | 安全移除路由 | 5D 计划 | Manager 移除路由仅在safe_to_remove=true时调用MarkNodeRemoved |
| 5E | 移除冒烟与 FLOW 文档 | 5E 计划 | e2ev2 证明已排水节点移除;FLOW 文档描述完整路径 |
三、5A:为removed铺设控制面墓碑能力
3.1 目标与范围
5A 是纯控制面子阶段:向 ControllerV2、clusterv2/control 转发、根clusterv2.Node管理委托加入MarkNodeRemoved,但不暴露 manager HTTP 移除路由、不改变缩容安全状态。换句话说,这一层只提供“能写 removed”的底层原语,谁有资格调用由后续子阶段决定。
3.2 ControllerV2 状态转换语义
pkg/controller/runtime_node_lifecycle.go中实现了MarkNodeRemoved与buildMarkNodeRemoved,状态转换规则与 Stage 4 的MarkNodeLeaving形态一致:
- 拒绝
NodeID == 0; - 拒绝 Controller 投票者(返回
ErrNodeLifecycleConflict); - 已是
removed时返回 unchanged(幂等); - 拒绝非
leaving节点(返回ErrNodeLifecycleConflict); - 设置
JoinState = NodeJoinStateRemoved且Status = NodeStatusDown; - 节点不存在返回
ErrNodeLifecycleNotFound。
从 runtime_node_lifecycle.go 源码看,MarkNodeRemoved的流程是:ctxErr(ctx)校验 -> 读取LocalState->buildMarkNodeRemoved构造写入 -> 以ExpectedRevision提交KindUpsertNode命令 ->publishFromState-> 重读LocalState返回最终持久节点与版本号。提交过程还挂了 gofail 故障注入点(gofailMarkNodeRemovedPostCommitFault),为 Stage 8 的缩容故障注入预留了验证口。
3.3 测试矩阵与验证命令
5A 计划 给出了四类必须覆盖的行为:
- leaving 转 removed,且第二次调用幂等(
Changed=false、版本不变); - active 节点被拒(
ErrNodeLifecycleConflict); - Controller 投票者被拒(
ErrNodeLifecycleConflict); - 不存在的节点被拒(
ErrNodeLifecycleNotFound)。
GOWORK=off go test ./pkg/controllerv2 -run 'TestRuntimeMarkNodeRemoved|TestRuntimeMarkNodeLeaving' -count=13.4 clusterv2 控制转发链路
5A 在 clusterv2/control 层新增:
control.MarkNodeRemovedRequest/ResultDTO(JSON 字段用node_id等 snake_case 而非 Go 字段名);- 线协议动作
mark_node_removed; ControlWriteApplier.MarkNodeRemoved分发;Runtime.MarkNodeRemoved本地与转发双路径(非 Controller leader 时按既有 leader 转发模式转发);- 根
clusterv2.Node.MarkNodeRemoved委托(含checkForeground前台检查与ErrNotStarted保护)。
当前仓库中对应实现位于 pkg/cluster/control/node_lifecycle.go、codec.go、control_write.go 与 node_management.go,并配套完整的编解码、转发与处理器分发测试。
3.5 5A 退出闸门
GOWORK=off go test ./pkg/controllerv2 ./pkg/clusterv2/control ./pkg/clusterv2 -count=1 git diff --check rg -n "MarkNodeRemoved|mark_node_removed|NodeJoinStateRemoved" pkg/controllerv2 pkg/clusterv2预期:全部通过,且此时还不存在任何 manager HTTP 移除路由。
四、5B:Channel 排水清单——让缩容状态能证明“无 Channel 归属”
4.1 为什么需要 Channel 清单
节点移除不能无视 Channel。设计文档第 12 节要求 manager 缩容报告按物理 Slot 扫描权威的ChannelRuntimeMeta,统计目标节点上的 channel leader、channel replica、channel ISR 成员数以及涉及目标节点的活跃迁移任务。5B 的价值在于:把“目标不是任何 Channel 的 leader/replica/ISR”变成可证明的结论,而不是假设。
4.2 受限扫描实现
当前仓库 internal/usecase/management/channel_drain.go 中定义了扫描预算常量:
const ( DefaultChannelDrainScanLimit = 256 // 每个 Slot 元数据页默认大小 MaxChannelDrainScanLimit = 1024 // 操作者提供的页大小上限 DefaultChannelDrainScanMaxPages = 4096 // 每次 status/remove 检查的总页数默认预算 MaxChannelDrainScanMaxPages = 16384 // 总页数预算上限 )NodeChannelDrainInventory的执行规则(与 5B 计划一致,并已在仓库中落地为更完整的版本):
NodeID == 0返回ErrInvalidArgument;- app/cluster/
ChannelRuntimeMetaReader缺失时返回Unknown=true, Safe=false(fail-closed); - 按
sortedSnapshotSlotIDs(snapshot.Slots)顺序扫描 Slot; - 每页使用
normalizeChannelDrainLimit(req.PageLimit)限制大小; - 扫描出错返回
Unknown=true, Safe=false, LastError=err.Error(); - leader、replica、ISR 三种角色独立计数;
- 仅当清单已知且三种计数全为零时
Safe=true; - 额外引入游标停滞(cursor did not advance)与预算超限(scan budget exceeded)两类错误,防止扫描死循环或无限膨胀。
测试(channel_drain_test.go)覆盖:跨 Slot 计数、单 Slot 分页、reader 缺失 fail-closed、扫描失败 fail-closed。
4.3 并入缩容状态
NodeScaleInStatusResponse新增字段:BlockedByChannels、UnknownChannelInventory、ChannelLeaderCount、ChannelReplicaCount、ChannelISRCount。SafeToProceed的计算必须额外满足:
!response.BlockedByChannels && !response.UnknownChannelInventory && response.ChannelLeaderCount == 0 && response.ChannelReplicaCount == 0 && response.ChannelISRCount == 0此时不引入最终SafeToRemove——那要等 5C 加上运行时排水阻塞之后。
4.4 5B 退出闸门
GOWORK=off go test ./internalv2/usecase/management -run 'TestNodeChannelDrainInventory|TestScaleInStatus|TestAdvanceNodeScaleIn' -count=1 git diff --check预期:只要 Channel 清单未知或目标出现在 Channel 元数据中,SafeToProceed恒为 false。
五、5C:网关排水模式——从“缩容可推进”到“移除可安全”
5.1 运行时摘要的基础
Stage 4 已提供NodeRuntimeSummary,包含网关会话数、在线数、AcceptingNewSessions与Draining。5C 在NodeRuntimeSummary上补充PendingActivations(已接受但尚未成为 authority-active 的本地会话数),并由internalv2/app/runtime_summary.go从online.Snapshot().Pending填充。
5.2 排水写入路径:usecase -> 节点 RPC -> 基础设施适配
5C 新增GatewayDrainWriter接口与SetNodeDrainModeusecase。仓库实现SetNodeDrainMode的要点:
NodeID == 0返回ErrInvalidArgument;- 无 writer 时返回
ErrNodeScaleInUnavailable; - 返回映射后的完整排水摘要(
Draining、AcceptingNewSessions、GatewaySessions、ActiveOnline、ClosingOnline、TotalOnline、PendingActivations、Unknown)。
远端写入走 manager 连接 RPC:编解码层(internalv2/access/node/manager_connection_codec.go)新增set_drain_mode操作与Draining字段;RPC 层分发给managerConnections.SetNodeDrainMode;internalv2/infra/cluster/management_connections.go的适配器把远端调用路由到remote.SetManagerDrainMode。关键防御:本地写者managementGatewayDrainWriter只允许操作本地节点;目标为远端节点且远端 writer 缺失时返回Unknown=true与ErrNodeScaleInUnavailable,绝不把远端排水请求错误落到本地网关上(有专门的TestManagementGatewayDrainWriterFailsClosedWhenRemoteMissing测试证明)。
5.3 Manager HTTP 排水路由
POST /manager/nodes/:node_id/scale-in/drain请求体:
{"draining":true}要求权限cluster.node:w,调用SetNodeDrainMode,返回200 OK,字段包括draining、accepting_new_sessions、gateway_sessions、active_online、closing_online、total_online、pending_activations、unknown。路由注册可在 internal/access/manager/server.go 中看到:
nodeWrites.POST("/nodes/:node_id/scale-in/plan", s.handleNodeScaleInPlan) nodeWrites.POST("/nodes/:node_id/scale-in/start", s.handleNodeScaleInStart) nodeWrites.POST("/nodes/:node_id/scale-in/drain", s.handleNodeScaleInDrain) nodeWrites.POST("/nodes/:node_id/scale-in/remove", s.handleNodeScaleInRemove) nodeWrites.POST("/nodes/:node_id/scale-in/advance", s.handleNodeScaleInAdvance) nodes.GET("/nodes/:node_id/scale-in/status", s.handleNodeScaleInStatus)5.4SafeToRemove的完整定义
NodeScaleInStatusResponse新增SafeToRemove、BlockedByRuntimeDrain、RuntimeUnknown及运行时计数字段。SafeToRemove必须同时满足:
- Stage 5B 的
SafeToProceed == true; - Channel 清单已知且清零;
- 目标运行时摘要已知;
Draining == true;AcceptingNewSessions == false;GatewaySessions == 0;ActiveOnline == 0;ClosingOnline == 0;PendingActivations == 0。
源码 scale_in.go 中response.SafeToRemove = response.SafeToProceed && ...的合并计算即是对上述规则的直接落地。测试覆盖“运行时排水非空阻塞”与“Channel 与运行时排水全部清零后 SafeToRemove=true”两个方向。
5.5 5C 退出闸门
GOWORK=off go test ./internalv2/usecase/management ./internalv2/access/node ./internalv2/infra/cluster ./internalv2/access/manager ./internalv2/app -count=1 git diff --check预期:仅当 Slot、Channel、运行时排水三类阻塞全部清零时SafeToRemove才为 true。
六、5D:安全移除路由——MarkNodeRemoved的唯一入口
6.1 管理 usecase 的安全闸门
scale_in.go 中的MarkNodeRemovedusecase 是整条链路的核心守门员:
func (a *App) MarkNodeRemoved(ctx context.Context, req MarkNodeRemovedRequest) (resp MarkNodeRemovedResponse, err error) { // ...ctxErr、NodeID==0、writer 存在性校验 snapshot, err := a.cluster.LocalControlSnapshot(ctx) if err != nil { return MarkNodeRemovedResponse{}, err } if node, ok := findControlNode(snapshot, req.NodeID); ok && managerControlJoinState(node.JoinState) == control.NodeJoinStateRemoved { return a.markNodeRemoved(ctx, req.NodeID, 0) // 已 removed:幂等直通 } status := a.nodeScaleInStatusFromSnapshot(ctx, snapshot, req.NodeID, nil) if !status.SafeToRemove { return MarkNodeRemovedResponse{}, ErrNodeScaleInUnsafe } return a.markNodeRemoved(ctx, req.NodeID, status.StateRevision) }要点:
- 只有两条路径能调用底层 writer:节点已是
removed(幂等重放,携带StateRevision=0),或当前状态报告SafeToRemove=true(携带 status 计算出的StateRevision作为预期版本,防并发竞态); - 不安全时返回
ErrNodeScaleInUnsafe(internal/usecase/management: node scale-in unsafe),根本不触碰生命周期 writer; - 带观测埋点
observeNodeLifecycleAttempt,每次移除尝试都被审计。
6.2 基础设施适配与 manager 路由
internal/infra/cluster/management_node_lifecycle.go把管理 usecase 的MarkNodeRemoved适配为 clusterv2 调用(nil 适配器返回ErrNodeLifecycleUnavailable)。Manager 侧 scale_in_remove.go 实现handleNodeScaleInRemove:
- 解析
node_id; - 调用
s.management.MarkNodeRemoved; ErrNodeScaleInUnsafe映射为409 Conflict;Changed=true返回202 Accepted;Changed=false返回200 OK(幂等)。
响应 DTO:
type ManagerNodeScaleInRemoveResponse struct { Changed bool `json:"changed"` NodeID uint64 `json:"node_id"` JoinState string `json:"join_state"` Revision uint64 `json:"revision"` }状态 DTO 同时对外暴露完整的 Stage 5 阻塞字段(safe_to_remove、blocked_by_channels、unknown_channel_inventory、blocked_by_runtime_drain、runtime_unknown、gateway_draining、accepting_new_sessions、gateway_sessions、active_online、closing_online、total_online、pending_activations、channel_leader_count、channel_replica_count、channel_isr_count),方便运维侧观测与自动化。
6.3 5D 退出闸门
GOWORK=off go test ./internalv2/usecase/management ./internalv2/infra/cluster ./internalv2/access/manager ./internalv2/app -count=1 git diff --check rg -n "MarkNodeRemoved|ErrNodeScaleInUnsafe|safe_to_remove|scale-in/remove" internalv2预期:唯一能写removed的 manager 路由是/manager/nodes/:node_id/scale-in/remove,且必须经过SafeToRemove闸门;pkg/controller与pkg/cluster/control的生命周期写者保持为 usecase 调用的底层原语,而非运维入口。
七、5E:黑盒冒烟与 FLOW 文档收口
7.1 e2ev2 管理器客户端辅助方法
test/e2e/suite/manager_client.go 新增四类辅助方法:
MustStartScaleIn:POST/manager/nodes/:node_id/scale-in/start,把数据节点标记为leaving;MustSetScaleInDrain:POST/manager/nodes/:node_id/scale-in/drain,开启/关闭网关排水;NodeScaleInStatus/EventuallyScaleInSafeToRemove:轮询safe_to_remove直到为 true(超时则DumpDiagnostics辅助排障);MustRemoveScaleInNode:POST/manager/nodes/:node_id/scale-in/remove,执行最终移除。
7.2 冒烟测试的完整剧本
TestLeavingNodeCanBeRemovedAfterDrain(见 node_remove_drain_test.go)用黑盒方式串起整条链路:
- 启动三节点集群(开启 manager HTTP 与动态 join token);
- 用 seed join 拉起第四数据节点,等待
joining->readiness->active; - 断言激活不触发任何 Slot 变动(
SameSlotAssignments(before, afterActivate))——本次冒烟刻意不给 node 4 做 onboarding; MustStartScaleIn(4),等待leaving;MustSetScaleInDrain(4, true),断言Draining=true且AcceptingNewSessions=false;EventuallyScaleInSafeToRemove(4, 30s),断言SafeToRemove=true、BlockedBySlots=false、BlockedByChannels=false、BlockedByRuntimeDrain=false、GatewayDraining=true、GatewaySessions/ActiveOnline/ClosingOnline/PendingActivations全为零;MustRemoveScaleInNode(4),断言JoinState="removed",并轮询 manager 节点清单确认墓碑可见。
该冒烟的意义在于:它证明的是一个active、无 Slot 归属、无 Channel 归属、无网关会话、显式排水的节点的最终移除路径,且全程只走 manager HTTP,没有任何内部捷径。
7.3 FLOW 文档职责收口
5E 要求在各包 FLOW 文档中固化 Stage 5 的职责边界:
- Manager HTTP只拥有运维意图路由:start scale-in、set drain mode、read status、safe remove;
- Management usecase依据 Controller 快照、Slot 状态、Channel 运行时元数据清单、任务状态与运行时排水计数计算 fail-closed 安全性;
- 节点 RPC只转发远端网关排水写,不决策缩容安全;
- Infra cluster 适配器把管理生命周期写翻译为 clusterv2 节点管理调用;
- clusterv2通过 ControllerV2 控制写转发
MarkNodeRemoved,并保持removed为墓碑状态; - ControllerV2拥有权威生命周期转换,拒绝移除 active 数据节点与 Controller 投票者。
7.4 Stage 5 最终退出闸门
5A–5E 全部合入后运行:
GOWORK=off go test ./pkg/controllerv2 ./pkg/clusterv2/control ./pkg/clusterv2 ./internalv2/usecase/management ./internalv2/access/manager ./internalv2/access/node ./internalv2/infra/cluster ./internalv2/app -count=1 GOWORK=off go test -tags=e2e ./test/e2ev2/cluster/dynamic_node_join -run 'TestDynamicJoinFourthDataNode|TestSlotReplicaMoveKeepsSendAvailable|TestLeavingNodeCanBeRemovedAfterDrain' -count=1 -p=1 git diff --check最终路径确认:
rg -n "MarkNodeRemoved|mark_node_removed|SafeToRemove|NodeChannelDrainInventory|SetNodeDrainMode|set_drain_mode|scale-in/remove|removed" pkg internalv2 test/e2ev2预期结论:manager/operator 与internalv2的移除入口只能通过 Stage 5D 的安全移除 usecase 到达removed;pkg/controller与pkg/cluster/control暴露的是给 usecase 使用的底层生命周期写原语,不做排水安全决策。
八、总结:Stage 5 留给后续阶段的安全资产
Stage 5 为 WuKongIM 的运维面沉淀了四笔可复用资产:
- 墓碑状态原语:
removed写入能力(5A)与幂等语义,杜绝node_id误复用; - fail-closed 状态报告:Slot/任务/Channel/运行时四类阻塞全部并入
NodeScaleInStatusResponse(5B+5C),任何数据未知即不安全; - 唯一且受闸门保护的操作入口:
POST /manager/nodes/:node_id/scale-in/remove(5D),409/202/200 三态语义清晰; - 黑盒验证与文档:e2ev2 冒烟 + FLOW 职责收口(5E),把安全模型固化为人人可读的边界。
后续阶段在此基础上继续演进:Stage 6 扩展 e2ev2 负向与并发场景,Stage 8 为缩容/移除加入 gofail 故障注入,Stage 9 引入健康门控的放置/移除,Stage 11/12 则将移除能力产品化为wkcli node运维命令与诊断工具。如果你正在为 WuKongIM 集群规划缩容,Stage 5 的这套“先排水、再证明、后移除”的纪律就是最直接的落地范本。
- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
相关推荐
WuKongIM 动态节点缩容 Stage 5D:实现安全删除路由 scale-in/remove 与 SafeToRemove 门禁
WuKongIM 动态节点缩容 Stage 5D:实现安全删除路由 scale in/remove 与 SafeToRemove 门禁 本文基于 WuKongI
即时通讯后端WuKongIM 动态节点接入 Stage 3:用有界 staged slot_replica_move 任务安全迁移 Slot 副本
WuKongIM 动态节点接入 Stage 3:用有界 staged slot_replica_move 任务安全迁移 Slot 副本 本文围绕 WuKongI
即时通讯后端ts-morph 节点删除完全指南:使用 remove() 安全移除任意 AST 节点
ts morph 节点删除完全指南:使用 remove 安全移除任意 AST 节点 本文聚焦 ts morph 的节点删除(Removing)能力:如何通过统一
开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考