Commit Graph

2 Commits

Author SHA1 Message Date
7d5bca5b72 feat(cluster): 负载均衡迁移 —— 定向投递 + 两阶段提交
承接 875e580(只落决策逻辑)。本提交补齐运行时:周期检查、迟滞判定、
两阶段迁移,以及让迁移任务**定向**到指定节点。

## 为什么必须是定向的(核心安全点)

loadSnapshot() 的 Forwards 来自 ForwardsOwnedBy,所以旧 owner 一旦被摘掉条目,
它立刻变成最闲节点。而普通创建任务是"任何最闲者认领"—— 于是它会把刚迁走的
转发**当场抢回**,迁移变成"只重启一次 worker"的空操作。

故 Task 新增 Migrate + Target 字段,runCommands 的选择循环照 Restart 的范式
处理:Target == 自己才认领,否则 `continue` 让任务随环前进。

## 为什么必须两阶段(不能一次发 revoke + submit)

1. 旧 owner 变最闲后会把未定向的任务抢回(上面那条);
2. PendingList 按 `t<ID>` **字符串**排序(`t10 < t5`),同轮内两个任务的先后
   不可控。

Balancer 因此是显式状态机:phase1 发布 revoke 并等待旧条目真正消失,确认后才
发布 phase2 的定向 migrate。两个超时兜底:revoke 超时则放弃;migrate 超时
(target 没认领)则退回**无定向** SubmitTask,让"任何最闲者"接手 —— 转发不会
被丢,只是延迟。这与环本身的行为一致(实测:只要还有更闲的节点,pending 任务
最终会被认领;只有"全员都更忙"时才滞留)。

## 两个被测试逼出来的设计修正

- **绝对下限 minForwards=2**:只有"超均值倍数"在小集群失真——3 节点 1 条转发时
  owner Score≈105 而两台空闲≈5,20 倍于均值会触发迁移,但那是完全正常的分布,
  迁它只会断一条活跃连接。倍数负责看形状,下限负责"有没有东西值得搬"。
- **Migrate 也要绕过 duplicate-claim 守卫**,否则旧条目还在时目标节点会拒绝
  认领(这条守卫正是为了防止同一 remotePort 注册两次 → frps 报
  "proxy already exists")。

## 测试(本项目第六次假绿的教训)

新增 6 条,全绿。做双向验证时发现
TestMigrateTaskIsAddressedNotClaimedByLightest **删掉定向分支后仍然 PASS**,
查因:`loadSnapshot()每次 OnToken 都用 Handler.RuntimeLoad() 刷新 mem/net,
我手工设的 State.Nodes[].Load 被静默覆盖 ⇒ n2/n3 始终平手 ⇒ "最闲者"恰好
就是 target,定向性从未被验证。

改为**用三个 Handler 的不同 RuntimeLoad 制造真实差异**(n3 最闲、n2 是较重的
target)后,删掉定向分支如期变红:

    n3 (the LIGHTEST node) claimed 1 migrate task(s); the task is addressed
    to n2 and must ignore load entirely

⇒ 这条纪律再次生效:测试必须制造出能让错误实现暴露的**不对称**,否则"恰好通过"
比"失败"更危险。

go build / go vet / go test ./... 全绿,gofmt 干净。
2026-10-06 11:50:53 +08:00
875e5805eb feat(cluster): 负载均衡迁移的决策逻辑(planBalance)
用户提出「加负载均衡算法,定期重新分配任务」。本提交只落**决策**部分:
给定当前环快照,算出「该不该迁、迁哪条、迁给谁」。运行时接线未包含在内。

## 决策规则(刻意保守)

- **阈值**:某节点 Score 超过均值 minOverRatio 倍,且与最闲节点相差**至少一条转发的量**
  (Score 里 Forwards 权重 100,所以差 <100 视为噪声)——避免为 1% 的差异重启 worker
- **一次一条**:负载抖动时不会引发一批 worker 同时重启(每次迁移都断活跃连接)
- **不动停用的转发**:迁走 disabled 的转发等于把用户明确关掉的东西重新拉起来
- **不动 offline 节点**:否则会迁到一台不存在的机器上
- **完全均衡时返回"无事可做"**:这就是阈值触发的意义——健康环零开销

## 尚未实现(本提交不含)

1. **持续时间判定**:阈值命中需持续 N 分钟才动手,避免瞬时尖峰触发迁移。
   当前 planBalance 是无状态的,时间维度留给调用方的 BalanceTracker。
2. **两阶段提交**:这是**安全性关键**,不能简单地把 RevokeTask 与 SubmitTask
   一起发出去。实测 PendingList 按 `t<ID>` 字符串排序(`t10 < t5`),同轮内
   顺序不可控;且旧 owner 停掉 worker 后自己的 Load 立刻下降,很可能又成为
   最闲节点而把刚迁走的转发重新认领回去。正确做法是先确认旧条目已消失,
   再提交新任务。
3. **周期性检查与 API/UI 暴露**。

## 测试

4 条,均针对 planBalance:搬走超载节点的一条转发 / 不搬停用转发 / 均衡时不动 /
不选 offline 节点。构造 seed 时经 JSON 往返让每个 State 持有独立 Nodes 切片
(AdoptState 是值赋值但 Nodes 是切片,共享底层数组会造成假绿——这个坑本项目
已踩过多次)。
2026-10-06 11:14:25 +08:00