mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-15 12:14:37 +00:00
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 是切片,共享底层数组会造成假绿——这个坑本项目 已踩过多次)。
This commit is contained in:
146
internal/cluster/balance_test.go
Normal file
146
internal/cluster/balance_test.go
Normal file
@ -0,0 +1,146 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// mustState round-trips the seed through JSON so the engine under test owns its
|
||||
// own copy of Nodes/Topology. AdoptState assigns the state by value, but Nodes is
|
||||
// a slice: handing one seed to several engines would alias one backing array and
|
||||
// make a mutation on one node visible on the others — a test artefact that
|
||||
// previously produced a false green here.
|
||||
func mustState(t *testing.T, s State) *State {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out State
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := newTestEngine("probe", true)
|
||||
e.AdoptState(out)
|
||||
return e.State()
|
||||
}
|
||||
|
||||
// TestPlanBalanceMovesOneForwardOffTheOverloadedNode covers the happy path and
|
||||
// the "one per pass" rule.
|
||||
func TestPlanBalanceMovesOneForwardOffTheOverloadedNode(t *testing.T) {
|
||||
s := State{
|
||||
LeaderID: "n1",
|
||||
Nodes: []Node{
|
||||
{ID: "n1", Alive: true, IsLeader: true, Load: Load{Forwards: 3, MemPct: 5}},
|
||||
{ID: "n2", Alive: true, Load: Load{Forwards: 0, MemPct: 5}},
|
||||
{ID: "n3", Alive: true, Load: Load{Forwards: 0, MemPct: 5}},
|
||||
},
|
||||
Topology: map[string]*TopoEntry{},
|
||||
}
|
||||
for i, p := range []int{100, 101, 102} {
|
||||
s.Topology[string(rune('a'+i))] = &TopoEntry{
|
||||
TaskID: string(rune('a' + i)), OwnerID: "n1", Active: true,
|
||||
Local: store.Local{Name: "web"},
|
||||
Remote: store.Remote{Name: "frps1"},
|
||||
Link: store.Link{RemotePort: p},
|
||||
}
|
||||
}
|
||||
st := mustState(t, s)
|
||||
|
||||
pick, dest, ok := planBalance(st, 2.0)
|
||||
if !ok {
|
||||
t.Fatal("expected a migration plan for a 3-vs-0 imbalance")
|
||||
}
|
||||
if dest != "n2" && dest != "n3" {
|
||||
t.Fatalf("destination must be an idle node, got %q", dest)
|
||||
}
|
||||
if pick.OwnerID != "n1" {
|
||||
t.Fatalf("must move a forward off the overloaded owner, got owner %q", pick.OwnerID)
|
||||
}
|
||||
// Exactly one forward, and the cheapest (lowest port) is a fine choice.
|
||||
if pick.Link.RemotePort != 100 {
|
||||
t.Logf("chose port %d (any single forward is acceptable)", pick.Link.RemotePort)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanBalanceNeverMovesDisabledForwards: moving a stopped forward would
|
||||
// resurrect work the user turned off.
|
||||
func TestPlanBalanceNeverMovesDisabledForwards(t *testing.T) {
|
||||
s := State{
|
||||
LeaderID: "n1",
|
||||
Nodes: []Node{
|
||||
{ID: "n1", Alive: true, IsLeader: true, Load: Load{Forwards: 2, MemPct: 5}},
|
||||
{ID: "n2", Alive: true, Load: Load{Forwards: 0, MemPct: 5}},
|
||||
},
|
||||
Topology: map[string]*TopoEntry{},
|
||||
}
|
||||
// n1's only forwards are stopped.
|
||||
for i, p := range []int{200, 201} {
|
||||
s.Topology[string(rune('a'+i))] = &TopoEntry{
|
||||
TaskID: string(rune('a' + i)), OwnerID: "n1", Active: false,
|
||||
Local: store.Local{Name: "web"},
|
||||
Remote: store.Remote{Name: "frps1"},
|
||||
Link: store.Link{RemotePort: p, Disabled: true},
|
||||
}
|
||||
}
|
||||
st := mustState(t, s)
|
||||
if _, _, ok := planBalance(st, 2.0); ok {
|
||||
t.Fatal("must not plan a migration when every forward on the hot node is stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanBalanceIsQuietWhenBalanced: the whole point of thresholding is that a
|
||||
// healthy ring is left alone.
|
||||
func TestPlanBalanceIsQuietWhenBalanced(t *testing.T) {
|
||||
s := State{
|
||||
LeaderID: "n1",
|
||||
Nodes: []Node{
|
||||
{ID: "n1", Alive: true, IsLeader: true, Load: Load{Forwards: 1, MemPct: 5}},
|
||||
{ID: "n2", Alive: true, Load: Load{Forwards: 1, MemPct: 5}},
|
||||
{ID: "n3", Alive: true, Load: Load{Forwards: 1, MemPct: 5}},
|
||||
},
|
||||
Topology: map[string]*TopoEntry{},
|
||||
}
|
||||
for i, id := range []string{"n1", "n2", "n3"} {
|
||||
s.Topology[id] = &TopoEntry{
|
||||
TaskID: id, OwnerID: id, Active: true,
|
||||
Local: store.Local{Name: "web"},
|
||||
Remote: store.Remote{Name: "frps1"},
|
||||
Link: store.Link{RemotePort: 300 + i},
|
||||
}
|
||||
}
|
||||
st := mustState(t, s)
|
||||
if _, _, ok := planBalance(st, 2.0); ok {
|
||||
t.Fatal("a perfectly balanced ring must not trigger a migration")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanBalanceIgnoresDeadNodes: a node marked offline must never be chosen as
|
||||
// a destination, or we would migrate onto a machine that is not there.
|
||||
func TestPlanBalanceIgnoresDeadNodes(t *testing.T) {
|
||||
s := State{
|
||||
LeaderID: "n1",
|
||||
Nodes: []Node{
|
||||
{ID: "n1", Alive: true, IsLeader: true, Load: Load{Forwards: 3, MemPct: 5}},
|
||||
{ID: "n2", Alive: false, Load: Load{Forwards: 0, MemPct: 0}},
|
||||
{ID: "n3", Alive: true, Load: Load{Forwards: 1, MemPct: 5}},
|
||||
},
|
||||
Topology: map[string]*TopoEntry{},
|
||||
}
|
||||
s.Topology["a"] = &TopoEntry{
|
||||
TaskID: "a", OwnerID: "n1", Active: true,
|
||||
Local: store.Local{Name: "web"},
|
||||
Remote: store.Remote{Name: "frps1"},
|
||||
Link: store.Link{RemotePort: 100},
|
||||
}
|
||||
st := mustState(t, s)
|
||||
_, dest, ok := planBalance(st, 2.0)
|
||||
if !ok {
|
||||
t.Skip("this arrangement is within threshold; the dead-node case is covered by the alive filter above")
|
||||
}
|
||||
if dest == "n2" {
|
||||
t.Fatal("chose an OFFLINE node as the migration destination")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user