mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-15 12:14:37 +00:00
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 干净。
This commit is contained in:
@ -406,6 +406,17 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
||||
}
|
||||
continue // owned elsewhere — ride to the owner
|
||||
}
|
||||
if t.Migrate {
|
||||
// Addressed to one node. The whole point is that "whoever is
|
||||
// lightest" is the WRONG answer here: the node we just revoked the
|
||||
// forward from is the one that became lightest, and it would claim
|
||||
// the task straight back.
|
||||
if t.Target == e.ID {
|
||||
target = t
|
||||
break
|
||||
}
|
||||
continue // ride to the target
|
||||
}
|
||||
if t.Restart {
|
||||
// Owner-directed, exactly like a revoke: only the node holding the
|
||||
// forward may act. Selecting it anywhere else would let a non-owner
|
||||
@ -538,7 +549,7 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
||||
// exists precisely to re-activate a forward whose entry is still present
|
||||
// (a stop keeps the entry as the flag's carrier), so applying the guard
|
||||
// would swallow every re-enable and leave the forward stopped forever.
|
||||
if !claimed.Restart {
|
||||
if !claimed.Restart && !claimed.Migrate {
|
||||
if owner := e.state.TopologyOwner(claimed); owner != "" {
|
||||
log.Printf("ring[%s] drop duplicate task %s: %s→%s:%d already owned by %s",
|
||||
e.ID, claimed.ID, claimed.Local.Name, claimed.Remote.Name,
|
||||
@ -546,6 +557,35 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if claimed.Migrate {
|
||||
// We are the designated destination. The forward must NOT already be
|
||||
// here: the two-phase migration guarantees the old entry was gone before
|
||||
// this task was published. If one somehow exists, drop the task rather
|
||||
// than creating a second topology entry for the same forward.
|
||||
if owner := e.state.TopologyOwner(claimed); owner != "" {
|
||||
log.Printf("ring[%s] drop migrate %s: already owned by %s", e.ID, claimed.ID, owner)
|
||||
continue
|
||||
}
|
||||
if e.Handler != nil {
|
||||
if err := e.Handler.Claim(ctx, claimed); err != nil {
|
||||
// Put it back so another cycle can retry — the forward is
|
||||
// currently owned by nobody, so losing this task would strand it.
|
||||
e.state.PendingTasks[claimed.ID] = claimed
|
||||
log.Printf("ring[%s] migrate %s failed: %v", e.ID, claimed.ID, err)
|
||||
break
|
||||
}
|
||||
}
|
||||
e.state.AddTopology(claimed, e.ID)
|
||||
if e.Log != nil {
|
||||
_, _ = e.Log.Append(e.ID, LogForwardAdd, map[string]any{
|
||||
"taskId": claimed.ID, "local": claimed.Local.Name, "remote": claimed.Remote.Name,
|
||||
"migratedFrom": e.state.TopologyOwner(claimed),
|
||||
})
|
||||
}
|
||||
log.Printf("ring[%s] migrated in %s: %s→%s:%d", e.ID, claimed.ID,
|
||||
claimed.Local.Name, claimed.Remote.Name, claimed.Link.RemotePort)
|
||||
continue
|
||||
}
|
||||
if claimed.Restart {
|
||||
// Only reached when this node owns the forward (or it is already gone
|
||||
// and we are the fallback consumer — see the selection loop).
|
||||
@ -960,6 +1000,30 @@ func (e *Engine) SubmitRestart(local store.Local, remote store.Remote, link stor
|
||||
return t
|
||||
}
|
||||
|
||||
// submitMigrate publishes a load-balancing task addressed to one specific node.
|
||||
//
|
||||
// It is addressed on purpose. A plain creation task is claimed by "whichever node
|
||||
// is currently lightest", and after a revoke the node we just took the forward
|
||||
// from IS the lightest — so an untargeted task would be claimed straight back by
|
||||
// the old owner and the migration would achieve nothing but a worker restart.
|
||||
func (e *Engine) submitMigrate(local store.Local, remote store.Remote, link store.Link, target string) *Task {
|
||||
link.Disabled = false
|
||||
t := &Task{
|
||||
ID: e.state.NextTaskID(),
|
||||
Local: local,
|
||||
Remote: remote,
|
||||
Link: link,
|
||||
Created: time.Now().Unix(),
|
||||
Migrate: true,
|
||||
Target: target,
|
||||
}
|
||||
if e.state.PendingTasks == nil {
|
||||
e.state.PendingTasks = map[string]*Task{}
|
||||
}
|
||||
e.state.PendingTasks[t.ID] = t
|
||||
return t
|
||||
}
|
||||
|
||||
// HasActiveTask reports whether a forward with this key is genuinely in flight
|
||||
// or running: a non-revocation pending task, or a topology entry that is not
|
||||
// marked disabled.
|
||||
|
||||
Reference in New Issue
Block a user