负载均衡中的轮询调度是最简单的请求分发策略,但一旦引入权重,实现细节就变得复杂起来。一个直观的做法是:先计算出所有权重的最小公倍数或者按比例展开成一个数组,然后按顺序轮流取用。例如节点A权重为5,节点B权重为1,那么展开后的序列就是A A A A A B,循环这个序列即可。然而这种方式存在两个明显问题:第一,如果权重值很大,展开后的数组会占用不必要的内存;第二,请求分布会出现“扎堆”现象,5个连续的A请求之后才轮到B,B节点会长时间空闲,A节点则可能被瞬时流量压垮。为了解决请求分配不平滑的问题,Nginx等负载均衡器采用了平滑加权轮询算法,该算法不需要展开数组,只用几个变量就能在每一轮中选出合适的节点,并且让请求序列在统计意义上接近权重比例,同时避免了连续集中访问。

下面我们先用代码展示一个基础版本的加权轮询实现,再分析它为什么不够平滑,最后给出改进后的平滑加权轮询算法以及C++中的完整状态保持逻辑。所有代码都可以直接放入一个cpp文件编译执行,为了便于观察,示例中的节点数量和权重值都设置得比较小。
基础加权轮询的实现与缺陷
最直接的加权轮询实现是根据权重值复制节点条目,维护一个索引指针,每次请求时将指针向后移动一位,到达末尾后回到开头。假设有三个节点A、B、C,权重分别为5、1、1,那么复制后的列表就是[A, A, A, A, A, B, C],长度为7。请求顺序就是A、A、A、A、A、B、C,循环往复。这个实现代码非常简单:
#include <iostream>
#include <vector>
#include <string>
struct Node {
std::string name;
int weight;
};
int main() {
std::vector<Node> nodes = {
{"A", 5},
{"B", 1},
{"C", 1}
};
std::vector<std::string> expanded;
for (const auto& n : nodes) {
for (int i = 0; i < n.weight; ++i) {
expanded.push_back(n.name);
}
}
size_t index = 0;
for (int i = 0; i < 14; ++i) {
std::cout << expanded[index] << " ";
index = (index + 1) % expanded.size();
}
std::cout << std::endl;
return 0;
}
输出结果是 A A A A A B C A A A A A B C,可以明显看到A节点连续出现了5次,B和C只出现一次。如果A节点处理能力足够强,这种分配也许能接受;但在实际系统中,连续的5个请求可能造成A节点短时负载过高,而B、C节点却空闲。另外,如果权重动态变化,比如A的权重从5调整为3,就需要重新构建整个展开数组,维护成本较高。本质原因是这种方法把权重直接映射成了请求次数,而没有考虑时间上的分散性。
为了改进分配均匀度,有人提出了“最大公约数”或“动态步长”等变体,但最优雅的解决方案还是平滑加权轮询。它不需要展开数组,每个节点只需维护一个“当前权重”状态值,每次选择后更新状态,就能在保持权重比例的前提下让请求尽可能交替分布。
平滑加权轮询的核心原理与状态更新
平滑加权轮询算法的思想来自Nginx,它维护两个与节点相关的状态:静态权重(weight)和当前有效权重(current_weight)。算法每一轮执行以下三个步骤:第一步,遍历所有节点,将每个节点的current_weight加上其静态weight;第二步,选出current_weight最大的节点作为本次处理的节点;第三步,被选中的节点的current_weight减去所有节点静态weight的总和。举个例子:节点A、B、C的静态权重分别为5、1、1,初始current_weight都为0。
轮次1: current_weight: A=5, B=1, C=1 -> 选A, A的current_weight变为5-7=-2 轮次2: current_weight: A=-2+5=3, B=1+1=2, C=1+1=2 -> 选A, A变为3-7=-4 轮次3: current_weight: A=-4+5=1, B=2+1=3, C=2+1=3 -> 选B, B变为3-7=-4 轮次4: current_weight: A=1+5=6, B=-4+1=-3, C=3+1=4 -> 选A, A变为6-7=-1 轮次5: current_weight: A=-1+5=4, B=-3+1=-2, C=4+1=5 -> 选C, C变为5-7=-2 轮次6: current_weight: A=4+5=9, B=-2+1=-1, C=-2+1=-1 -> 选A, A变为9-7=2 轮次7: current_weight: A=2+5=7, B=-1+1=0, C=-1+1=0 -> 选A, A变为7-7=0
这7轮的选择结果依次是 A、A、B、A、C、A、A,对比基础加权轮询的 A A A A A B C,可以看到连续的A最多只有两次,B和C穿插在A之间,分布更加均匀。在整个循环中,A出现了5次,B和C各1次,完美符合权重比例。关键在于current_weight的“加减”机制:每个节点不断累加自己的权重,而每次被选中后减去总权重,相当于在长期运行中保持平衡,同时也让本轮得分最高的节点胜出。
状态保持是该算法能被工程化使用的核心。每个节点的current_weight需要跨请求保存,不能每次请求重新初始化。在C++中,可以把这些状态封装在一个类里,使用成员变量存储每个节点的current_weight,并提供Next()方法返回下一个被选中的节点。当节点列表或权重发生变化时,需要谨慎处理current_weight的重置或调整,否则可能导致调度结果偏离预期。
C++类设计与权重动态调整处理
下面给出一个完整的C++实现,类名为SmoothWeightedRoundRobin,支持添加节点、设置权重、获取下一个节点以及动态修改权重。节点信息用结构体Server表示,包含名称、静态权重和当前权重。每次调用Next()时,按照算法更新所有节点的当前权重并选出最大值对应的节点。
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <stdexcept>
struct Server {
std::string name;
int weight; // 静态权重,保持不变
int current_weight; // 动态当前权重,跨请求保持
Server(const std::string& n, int w) : name(n), weight(w), current_weight(0) {}
};
class SmoothWeightedRoundRobin {
public:
void AddServer(const std::string& name, int weight) {
if (weight <= 0) {
throw std::invalid_argument("weight must be positive");
}
servers_.push_back(Server(name, weight));
}
// 动态调整某个节点的权重,并重置当前权重避免状态跳变
void UpdateWeight(const std::string& name, int new_weight) {
if (new_weight <= 0) {
throw std::invalid_argument("weight must be positive");
}
for (auto& s : servers_) {
if (s.name == name) {
s.weight = new_weight;
s.current_weight = 0; // 简单重置,生产环境可做平滑过渡
return;
}
}
throw std::runtime_error("server not found");
}
// 获取下一个节点,并更新内部状态
const Server& Next() {
if (servers_.empty()) {
throw std::runtime_error("no servers available");
}
int total_weight = 0;
for (const auto& s : servers_) {
total_weight += s.weight;
}
Server* best = nullptr;
for (auto& s : servers_) {
s.current_weight += s.weight; // 每轮累加静态权重
if (best == nullptr || s.current_weight > best->current_weight) {
best = &s;
}
}
// 被选中的节点减去总权重
best->current_weight -= total_weight;
return *best;
}
// 打印当前所有节点的状态,便于调试
void PrintState() const {
for (const auto& s : servers_) {
std::cout << s.name << "(w=" << s.weight
<< ", cw=" << s.current_weight << ") ";
}
std::cout << std::endl;
}
private:
std::vector<Server> servers_;
};
int main() {
SmoothWeightedRoundRobin swrr;
swrr.AddServer("A", 5);
swrr.AddServer("B", 1);
swrr.AddServer("C", 1);
std::cout << "初始调度序列:" << std::endl;
for (int i = 0; i < 14; ++i) {
const Server& selected = swrr.Next();
std::cout << selected.name << " ";
}
std::cout << std::endl;
std::cout << "调整A的权重为2后的调度序列:" << std::endl;
swrr.UpdateWeight("A", 2);
for (int i = 0; i < 12; ++i) {
const Server& selected = swrr.Next();
std::cout << selected.name << " ";
}
std::cout << std::endl;
return 0;
}
运行这段代码,第一段输出为 A A B A C A A A A B A C A A,第二段输出(调整A权重为2后)为 A B C A A B C A A B C A。可以看到调整权重后,状态被重置,调度序列在新的权重比例下重新变得平滑。需要注意的是,简单地将current_weight重置为0会让所有节点的历史状态消失,如果权重调整不频繁,这种方式可以接受。但如果需要更加平滑地过渡,可以保留current_weight并做归一化处理,例如让所有节点的current_weight按比例缩放,以避免某个节点因为历史积累过高而持续被选中。
状态保持的另一个重要方面是并发安全。如果在多线程环境下使用这个调度器,Next()方法中的状态更新和选择操作需要加锁保护,否则多个线程同时修改current_weight会导致数据竞争。一个简单的做法是在类内部使用std::mutex,在Next()和UpdateWeight()中加锁。这部分逻辑可以根据实际需求添加,本文的示例为了清晰省略了锁。
算法复杂度与工程实践中的注意事项
从时间复杂度来看,每一轮平滑加权轮询需要遍历所有节点两次:第一次计算总权重,第二次累加current_weight并找出最大值。假设有N个节点,每次调用的时间复杂度是O(N)。当节点数量较少(例如几十个)时,这个开销可以忽略不计;但当节点数量达到数千甚至上万时,每请求遍历两次可能成为性能瓶颈。对于大规模场景,可以考虑使用最大堆等数据结构来维护current_weight的最大值,但实现复杂度会明显上升。在常见的反向代理或微服务网关中,节点数量通常不会超过几百,所以O(N)的遍历完全足够。
另一个需要关注的是权重总和溢出问题。如果节点数量很多且权重值很大,total_weight可能超出int范围,导致current_weight计算溢出。在C++中可以使用long long类型存储权重和current_weight,或者使用double进行归一化处理。不过对于绝大多数业务系统,int范围已经足够,只有在极端配置下才需要升级类型。
动态调整权重时,如果简单重置current_weight,可能会造成短时间的调度倾斜。例如一个节点原本权重很高,累积了大量正的current_weight,突然把它的权重降得很低,重置后它可能连续多轮不被选中,这也许正是期望的行为,也可能不是。工程中可以根据业务需求选择重置策略:完全重置、按比例缩放、或者设定一个过渡期逐渐修改权重。这些都属于状态保持的延伸,核心思想是确保调度结果尽可能平滑且可预测。
最后,实际项目中往往需要结合健康检查、失败重试等机制。被选中的节点如果请求失败,可能需要临时降低其权重或者将其移出调度列表。这些操作都会影响状态保持,建议在更新权重或移除节点时,同时调整current_weight,避免出现长期不合理的调度结果。