Weighted Round Robin 不能直接用 time.Sleep 控制平滑性,因为这会导致调度器阻塞、无法响应实时权重变更且不满足并发安全;真正的平滑是通过动态维护 currentWeight 实现概率密度均匀分布,而非依赖时间间隔硬控。

为什么 WeightedRoundRobin 不能直接用 time.Sleep 控制平滑性
很多人一想到“平滑”,就立刻在轮询逻辑里加 time.Sleep 或者用 time.Ticker 做定时分发——这会导致整个调度器阻塞,无法响应实时权重变更,也无法并发安全地处理请求。真正的平滑,是让高权重节点被选中的概率密度更均匀,而不是靠时间间隔硬控。
核心思路是:把每个后端看作一个带“剩余权重”的参与者,每次选择时先按权重放大(比如 ×100),再累加当前余数,选出最大值后扣减对应权重并归一化余数。这就是经典的 SmoothWeightedRoundRobin(SWRR)算法。
-
weight是静态配置值,但参与调度的是动态的currentWeight - 每次选节点后必须更新其
currentWeight:减去总权重和,再加回自身原始weight - 所有
currentWeight初始值应为 0,否则第一次调度会偏斜
如何用 Go 实现线程安全的 SmoothWeightedRoundRobin 调度器
Go 的 sync/atomic 可以高效更新整数字段,但 currentWeight 是 float64,原子操作受限;更稳妥的做法是用 sync.RWMutex 保护整个状态数组,或把每个后端封装成独立结构体,用 sync.Mutex 锁粒度控制到单个节点。
下面是一个最小可行实现的关键片段:
type Backend struct {
Addr string
Weight int64
mu sync.Mutex
currentW int64 // 注意:这里用 int64 避免浮点精度问题,权重放大后取整
}
func (b *Backend) NextWeight(totalWeight int64) int64 {
b.mu.Lock()
defer b.mu.Unlock()
b.currentW += b.Weight
return b.currentW
}
func (b *Backend) SelectAndDec(totalWeight int64) bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.currentW
- 避免用
float64存currentWeight:浮点误差会在多次迭代后导致权重漂移 - 把
Weight放大 100 倍再转int64,可保留两位小数精度且无误差 - 不要在
SelectAndDec中返回Addr,只做状态更新;选中逻辑由上层聚合判断
select 循环里调用 NextWeight 为什么容易漏掉并发竞争
常见错误是这样写:
for _, b := range backends {
w := b.NextWeight(total)
if w > maxW {
maxW = w
selected = b
}
}
selected.SelectAndDec(total)
问题在于:多个 goroutine 同时执行这段代码时,NextWeight 和 SelectAndDec 不是一次原子操作,中间可能插入其他请求,导致同一个 Backend 被重复选中或跳过。
- 必须把“读当前权值 → 比较 → 更新”三步合并为一次临界区操作
- 推荐改用函数式接口:
lb.Pick() *Backend,内部用单个sync.Mutex锁住整个调度逻辑 - 如果吞吐量极高(>10k QPS),可考虑分片锁(sharded mutex),但绝大多数场景没必要
权重动态更新时 currentWeight 归零会破坏平滑性吗
会,而且很严重。假设某后端权重从 5 突然调到 10,若直接重置其 currentW = 0,它接下来至少要等 5 轮才能追上其他节点,期间几乎不会被选中。
正确做法是做“软重置”:
- 记录旧权重和新权重的比值,按比例缩放当前
currentW(例如:旧权5→新权10,则currentW *= 2) - 若旧权重为 0(刚加入),则设
currentW = 新权重 / 2,避免冷启动突增流量 - 权重变为 0 时,不立即剔除,而是设
currentW = math.MinInt64,确保永不被选中,等待连接自然耗尽
平滑不是数学意义上的连续函数,而是业务意义上的流量分布可控——这点最容易被监控图表误导:看到曲线“看起来平”,不代表下游没被打爆。











