5.2 分配 Broker 与容量评估
上一节的调度器能起实例了,但它回避了一个问题:起在哪台机器?只有一台机器时 Create 随便调;一旦有一群节点,就必须有人决定「放哪儿」,并在所有节点都放不下时给出明确答复——排队还是失败。
这个角色是分配 Broker。它要同时解决三件事:一是「放哪儿」,判据必须覆盖 CPU、内存、连接数、实例槽位四个维度,只看一个维度会把机器开爆;二是「放不下怎么办」,答案是排队加超时,而不是无限重试;三是「并发分配不能超开」,所以选中节点的那一刻就要预留资源。
四个维度:CPU、内存、连接数、槽位
容量评估的第一原则是:四个维度里任何一个到顶,这台机器就再开不出实例。只看 CPU 的调度器会把连接数打满,只看内存的会无视 fd 上限。所以判据是「四个都不越界」,且要报出第一个不满足的维度——它是「为什么开不出来」的第一手线索。下面这段是 L1,go test ./internal/scheduler/ -run TestFits 可以直接跑:
// file: internal/scheduler/capacity.go
// tier: L1
// verify: go test ./internal/scheduler/ -run TestFits
package scheduler
// NodeMetrics 是一台机器当前的可观测负载。四个维度:CPU、内存、连接数、
// 实例槽位——任何一个到顶,这台机器就再开不出实例,所以判据必须是「四
// 个都不越界」,而不是只看 CPU。
type NodeMetrics struct {
Node NodeID
CPUCores float64 // 机器总核数
CPUUsed float64 // 已占用核数(最近窗口均值)
MemBytes uint64 // 机器总内存
MemUsed uint64 // 已占用内存
Conns int // 当前连接数
MaxConns int // 连接数上限(fd 上限与内存共同决定)
Slots int // 已开实例数
MaxSlots int // 实例槽位上限
Draining bool // 是否在下线中(不再接新实例)
}
// Demand 是一个实例的资源需求。
type Demand struct {
Kind Kind
CPUCores float64
MemBytes uint64
Conns int
Slots int
}
// Fits 判断节点能否再放下一个 demand;不能时返回第一个不满足的维度名,
// 这个维度名会原样进日志,是「为什么开不出来」的第一手线索。
func (n NodeMetrics) Fits(d Demand) (bool, string) {
switch {
case n.Draining:
return false, "draining"
case n.Slots+d.Slots > n.MaxSlots:
return false, "slots"
case n.Conns+d.Conns > n.MaxConns:
return false, "conns"
case n.CPUUsed+d.CPUCores > n.CPUCores:
return false, "cpu"
case n.MemUsed+d.MemBytes > n.MemBytes:
return false, "mem"
}
return true, ""
}
// Headroom 返回四个维度里「剩余比例」的最小值,越大越空闲。取最小值而
// 不是平均值,是为了避免把实例塞进一台 CPU 空闲、但槽位只剩一个的机器:
// 真正卡住分配的永远是那个最紧的维度。
func (n NodeMetrics) Headroom() float64 {
cpu := (n.CPUCores - n.CPUUsed) / n.CPUCores
mem := 1 - float64(n.MemUsed)/float64(n.MemBytes)
conn := float64(n.MaxConns-n.Conns) / float64(n.MaxConns)
slot := float64(n.MaxSlots-n.Slots) / float64(n.MaxSlots)
return minOf4(cpu, mem, conn, slot)
}
func minOf4(a, b, c, d float64) float64 {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
if d < m {
m = d
}
return m
}
Fits 返回第一个越界的维度名,Headroom 取四个维度里「剩余比例」的最小值。取最小值而不是平均值,是为了避免把实例塞进一台 CPU 很空闲、但槽位只剩一个的机器——真正卡住分配的永远是那个最紧的维度。
分配 Broker:放不下就排队,排队要有超时
Broker 用 Headroom 在能放下的节点里挑余量最大的那台,选中后立即把资源记进内存(预留),否则两次并发分配会同时选中同一台机器、把它的槽位开爆。放不下时不重试、不无限等待,而是进队列并挂一个 deadline;节点负载变化时 Update 触发 pump 唤醒队首。下面这段是 L1,go test ./internal/scheduler/ -run TestBroker:
// file: internal/scheduler/broker.go
// tier: L1
// verify: go test ./internal/scheduler/ -run TestBroker
package scheduler
import (
"context"
"errors"
"sync"
"time"
)
// ErrNoCapacity 表示在等待窗口内始终没有节点放得下这个需求。
var ErrNoCapacity = errors.New("no node has capacity")
// Broker 在候选节点里挑一台放下实例;当下没有节点放得下时,请求进入等待
// 队列,直到有节点腾出容量或超时。它同时是「放哪儿」和「放不下怎么办」的答案。
type Broker struct {
mu sync.Mutex
nodes map[NodeID]NodeMetrics
queue []*waiter
}
type waiter struct {
demand Demand
ready chan NodeID
}
func NewBroker() *Broker { return &Broker{nodes: make(map[NodeID]NodeMetrics)} }
// Update 上报一台节点的最新负载,然后尝试唤醒排队的请求。
func (b *Broker) Update(m NodeMetrics) {
b.mu.Lock()
b.nodes[m.Node] = m
b.mu.Unlock()
b.pump()
}
// Allocate 先立即选一台;选不到就进队列等,等到 wait 为止。
func (b *Broker) Allocate(ctx context.Context, d Demand, wait time.Duration) (NodeID, error) {
if node, ok := b.pick(d); ok {
return node, nil
}
w := &waiter{demand: d, ready: make(chan NodeID, 1)}
b.mu.Lock()
b.queue = append(b.queue, w)
b.mu.Unlock()
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case node := <-w.ready:
return node, nil
case <-timer.C:
b.drop(w)
return "", ErrNoCapacity
case <-ctx.Done():
b.drop(w)
return "", ctx.Err()
}
}
// pick 在能放下的节点里挑余量最大的那台,并立即预留资源——预留是关键,
// 否则两次并发分配会同时选中同一台机器,把它的槽位开爆。
func (b *Broker) pick(d Demand) (NodeID, bool) {
b.mu.Lock()
defer b.mu.Unlock()
var best NodeID
var bestH float64
found := false
for id, m := range b.nodes {
if ok, _ := m.Fits(d); !ok {
continue
}
if h := m.Headroom(); !found || h > bestH {
best, bestH, found = id, h, true
}
}
if !found {
return "", false
}
b.reserveLocked(best, d)
return best, true
}
func (b *Broker) reserveLocked(id NodeID, d Demand) {
m := b.nodes[id]
m.CPUUsed += d.CPUCores
m.MemUsed += d.MemBytes
m.Conns += d.Conns
m.Slots += d.Slots
b.nodes[id] = m
}
// pump 按 FIFO 依次满足队列里的请求;队首放不下就停在它这里,不越位——
// 先到先服务,避免后来的小请求把队首的大请求永远饿死。
func (b *Broker) pump() {
for {
b.mu.Lock()
if len(b.queue) == 0 {
b.mu.Unlock()
return
}
w := b.queue[0]
b.mu.Unlock()
node, ok := b.pick(w.demand)
if !ok {
return
}
b.drop(w)
w.ready <- node
}
}
func (b *Broker) drop(w *waiter) {
b.mu.Lock()
defer b.mu.Unlock()
for i, q := range b.queue {
if q == w {
b.queue = append(b.queue[:i], b.queue[i+1:]...)
return
}
}
}
pump 严格按 FIFO 处理:队首放不下就停在它这里,不跳过它去满足后面的小请求——否则一个需要 8 核的战场会永远排在队尾,被源源不断的小副本饿死。Allocate 的等待窗口到点返回 ErrNoCapacity,调用方据此给玩家一个「战场暂满,稍后重试」的提示,而不是挂在那里。
容量评估的基准数字
容量评估在热路径上:每次分配、每个候选节点都要跑一遍 Fits + Headroom。下面这段是 B,go test -run=^$ -bench . ./internal/scheduler/ 给出本机数字:
// file: internal/scheduler/bench_test.go
// tier: B
// verify: go test -run=^$ -bench . ./internal/scheduler/
package scheduler
import (
"strconv"
"testing"
)
func benchNodes(n int) []NodeMetrics {
out := make([]NodeMetrics, n)
for i := range out {
out[i] = NodeMetrics{
Node: NodeID("scene-" + strconv.Itoa(i)),
CPUCores: 16, CPUUsed: float64(i%12) + 1,
MemBytes: 64 << 30, MemUsed: uint64(i%40) << 30,
Conns: (i % 80) * 100, MaxConns: 10000,
Slots: i % 6, MaxSlots: 8,
}
}
return out
}
// BenchmarkFitsAndHeadroom 量「判断一个节点放不放得下」的成本。
func BenchmarkFitsAndHeadroom(b *testing.B) {
nodes := benchNodes(1000)
d := battleDemand()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m := nodes[i%len(nodes)]
if ok, _ := m.Fits(d); ok {
_ = m.Headroom()
}
}
}
// BenchmarkBrokerPick 量「在 1000 个候选节点里挑一台」的成本。
func BenchmarkBrokerPick(b *testing.B) {
nodes := benchNodes(1000)
d := battleDemand()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.StopTimer()
br := NewBroker()
for _, m := range nodes {
br.nodes[m.Node] = m
}
b.StartTimer()
_, _ = br.pick(d)
}
}
本机(Apple M1 Pro,Go 1.26.0)实测:单次 Fits + Headroom 约 10.9 ns、0 次分配;在 1000 个候选节点里挑一台(pick,含预留)约 41.9 微秒、0 次分配。一次分配决策在微秒级,相对一次实例启动的秒级开销可以忽略。这组数字是同机相对量级,绝对数值不可移植。
本节小结 / 实测结论
实测:TestBrokerPicksLeastLoaded 验证在能放下的节点里选余量最大的;TestBrokerReservesCapacity 验证预留生效、槽位不会被超开;TestBrokerQueueWakesOnCapacity 验证放不下时进队列、节点腾出容量后被唤醒。Broker 决定了「放哪儿」,但「实例崩溃后内存里的状态怎么办」还是空的——那是下一节。
阅读导航:上一节:5.1 实例调度器 · 下一节:5.3 崩溃恢复与状态重建 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。