简介:这是一份基于C++的负载均衡在线编程系统项目包,面向在线评测(OJ)平台开发者与C++后端技术学习者,重点解决多用户并发提交代码时的高效调度、服务器负载均衡与稳定评测问题。包内共52个文件,以C++源码(cpp/h/cc)为主,同时包含JSON配置、CMake/Makefile构建脚本、protobuf协议文件、测试用例和日志输出,压缩包仅69KB,小巧但涵盖项目完整工程骨架。目前已有73人学习。资源涵盖网络通信框架(lars_reactor风格)、protobuf消息封装、构建配置与测试代码等模块,可帮助读者理解在线编程系统的任务队列、负载均衡策略、进程调度及安全隔离等核心环节,也可作为课程设计或小型分布式服务架构的参考实现。对于希望深入掌握高并发服务端开发、协议设计与自动化评测的读者,这套代码具有不错的实践参考价值。
1. 基于C++的负载均衡在线编程系统:先搞懂它解决了什么
做在线判题(OJ)系统的人,迟早会被同一个问题卡住:用户提交一段代码,后端十几个判题节点,有的CPU飙到100%,有的闲着喝咖啡,你怎么把任务派出去?我见过不少项目把调度逻辑写死在每个worker里,结果加一台机器就要改配置,重编译,重上线。这个基于C++的负载均衡在线编程系统,核心就是把“该把任务给谁”这件事从判题逻辑里拆出来,做成一个独立的调度层。它的输入是用户提交的题目和代码,输出是“哪个判题节点执行这次评测”,中间涉及队列、心跳、策略选择、超时重试。适合自己搭OJ、做代码评测服务、或者想把任务分发模块独立出来的C++开发者。新手能通过它理解调度的完整链路,熟手可以直接拿它的策略部分移植进自己的项目。
2. 总体架构与消息模型:为什么调度层必须独立
2.1 在线编程系统里判题请求的流动路径
一个典型的在线编程系统,请求从浏览器到最终拿到判题结果,要经过四个环节:用户提交 -> 调度器分配 -> 判题节点执行 -> 结果回写。这个系统把调度器做成独立进程,worker启动后自己注册上来,调度器只负责“分活”,不碰编译和沙箱。这样做的理由很实际:判题节点的CPU、内存、并发数差异很大,老机器和新机器混跑是常态,如果调度规则写死在worker里,扩缩容时就没法动态调整。
流动路径是这样的:客户端把提交记录投递到入口服务,入口服务封装成一个TaskMessage,通过TCP或共享内存发给调度器。调度器维护一张在线worker表,根据当前策略选出一个worker,把任务转发过去,同时记录这次任务的状态。worker执行完毕后回传结果,调度器再把结果返回给入口服务。整个链路里,调度器是唯一的“决策点”,worker是无状态的执行者。
这套模型的好处是,你可以单独压测调度器,也可以单独替换worker实现。只要消息格式不变,worker换成Python脚本甚至Shell命令都行。
2.2 用C++设计调度器与Worker之间的消息结构
任务消息的字段,决定了系统能承载多少信息。常见必备字段有:任务ID、题目ID、用户ID、代码存储路径、时间限制、内存限制、语言类型、重试次数。这里我提供一套我自己在用的C++结构定义,可以用原生结构体加序列化函数,避免引入重量级框架。
// task_message.h struct TaskMessage { uint64_t task_id; // 全局唯一,用原子自增或雪花算法 int32_t problem_id; // 题目编号,worker据此拉取测试数据 char user_id[32]; // 用户标识 char code_path[256]; // 代码文件在共享存储中的路径 int32_t time_limit_ms; // 时间限制,毫秒 int32_t memory_limit_kb;// 内存限制KB int8_t language; // 0=C, 1=C++, 2=Python, 3=Java int8_t retry_count; // 已重试次数,超过阈值丢弃 uint64_t create_ts; // 创建时间戳,用于超时判断 }; // 序列化:直接按字节拷贝到buf,注意对齐 size_t encodeTaskMessage(const TaskMessage& msg, char* buf, size_t len) { if (len < sizeof(msg)) return 0; memcpy(buf, &msg, sizeof(msg)); return sizeof(msg); }这段代码用了固定长度数组而不是std::string,是为了做内存对齐和快速拷贝。实际传网络时,我会在前面加一个4字节的包头标明长度,避免粘包问题。task_id一定要保证全局递增,否则worker上报结果时你不知道它在说哪次提交。retry_count是关键字段,这个系统里调度器会在worker执行超时后自动重试,但最多两次,防止雪崩。
worker端收到消息后,解包同样可以依赖sizeof。但要注意,如果调度器和worker是不同编译器编译的,结构体对齐可能有差异。我一般会加一句静态断言static_assert来兜底。
// 两边编译时都加上,确保结构体定义一致 static_assert(sizeof(TaskMessage) == 32, "TaskMessage size changed!");这个断言的值是多少并不重要,重要的是两端保持一致。一旦有人加了字段而没同步,系统会直接编译报错,而不是运行到一半出诡异bug。相比JSON、Protobuf,这种原生结构的开销最小,适合高吞吐的调度场景。
3. 负载均衡策略选型与参数校准:从轮询到一致性哈希
3.1 三种策略的适用边界
不是所有场景都适合轮询。你需要根据worker的性能差异、任务耗时、是否带状态来决定策略。这个系统里实现了三种策略:普通轮询(Round Robin)、加权轮询(Weighted Round Robin)和一致性哈希(Consistent Hash)。
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 普通轮询 | 实现简单、无额外开销 | 慢节点会堆积任务 | worker性能相近、任务耗时均匀 |
| 加权轮询 | 可按CPU核数/内存配权重 | 权重调整需要经验值 | 新旧机器混跑、性能差异大 |
| 一致性哈希 | 同一用户/题目固定到同一组节点 | 负载可能不均、虚拟节点需调参 | 需要本地缓存或依赖多任务相关性的场景 |
在线编程场景里,任务之间没有强状态依赖——两个用户提交同一道题,跑在哪台机器上都不影响正确性。所以轮询和加权轮询是首选。但如果你的worker上有本地预编译的测试数据缓存,或者你的系统要支持“同一用户的多次提交落到同一台机器”的审计需求,那一致性哈希更合适。
3.2 一致性哈希的实现与虚拟节点参数
一致性哈希的经典实现是哈希环。把每个worker的IP加端口哈希到环上,再把任务的key也哈希到环上,顺时针找到第一个worker。但真实环境里worker节点少的时候(比如只有4台),哈希环会不均匀。解决方法是给每个物理节点生成多个虚拟节点。
// consistent_hash.h class ConsistentHash { public: void addNode(const std::string& node, int virtual_nodes) { for (int i = 0; i < virtual_nodes; ++i) { std::string vnode = node + "#" + std::to_string(i); uint32_t h = fnv32(vnode.c_str(), vnode.size()); ring_[h] = node; // 多个虚拟节点映射到同一个真实节点 } } void removeNode(const std::string& node) { for (int i = 0; i < virtual_nodes_; ++i) { std::string vnode = node + "#" + std::to_string(i); uint32_t h = fnv32(vnode.c_str(), vnode.size()); ring_.erase(h); } } std::string getNode(const std::string& key) { if (ring_.empty()) return ""; uint32_t h = fnv32(key.c_str(), key.size()); auto it = ring_.lower_bound(h); if (it == ring_.end()) it = ring_.begin(); return it->second; } private: std::map<uint32_t, std::string> ring_; int virtual_nodes_ = 100; // 虚拟节点数,我用100 };virtual_nodes_很关键。我一般默认给100,但这不是拍脑袋。当物理节点只有4个时,100个虚拟节点能保证每个节点的哈希区间占25%±2%;但如果节点数超过50,100个虚拟节点反而增加map内存和查找开销,我会降到50。这个值与节点数成反比关系。你可以这么调:先开100,压测看负载分布,如果某台节点的任务量超过平均值的1.2倍,就把虚拟节点加到200,通常都能抚平。
注意,这里用了std::map,每次查找是O(logN)。如果每秒上万次分配,map拷贝不是瓶颈,但你要避免在getNode里做字符串拼接。上面的addNode每次新增都生成“i”字符串,如果频繁加节点会有开销,但节点变化本身不频繁。
3.3 动态权重怎么调
加权轮询的权重,不能只看CPU核数。内存、磁盘io、当前负载都是变量。这个系统里我用的是“静态权重+动态惩罚”的做法:每台worker注册时上报初始权重,调度器每30秒根据worker的心跳数据计算一个惩罚系数。
// weight.cpp double dynamicWeight(int base_weight, int running_tasks, int max_tasks, double cpu_usage) { double load_factor = (double)running_tasks / (max_tasks + 1); double cpu_factor = cpu_usage / 100.0; // 负载和CPU都超过阈值,惩罚最多30% double penalty = std::min(1.0, std::max(load_factor, cpu_factor) * 0.3); return base_weight * (1.0 - penalty); }running_tasks是worker当前正在执行的任务数,max_tasks是worker能接受的最大并发数(通常和CPU核数相关)。为什么要按比例惩罚而不是直接剔除?因为在线编程任务平均耗时在几百毫秒到几秒,一个突发的高负载往往十几秒就过去了,直接剔除会导致调度器频繁摘节点、重连,系统抖动更严重。惩罚系数上限设为0.3,是保证一个权重为10的节点最多被削到7,不会被压到0,避免出现“权重全没了”的零节点。
4. 任务队列与超时控制:防止一个死节点拖垮全流程
4.1 基于锁和条件变量的任务队列
调度器收到任务后,不是立刻转发,而是放入一个待分配队列。这样做的目的是缓冲突发流量——用户点“提交”的时间分布极不均匀,考试时可能一秒来几百个提交,闲时一个都没有。队列做一个简单的有界队列,队满时入口阻塞,防止内存被冲爆。
// task_queue.h template <typename T> class BoundedQueue { public: explicit BoundedQueue(size_t cap) : capacity_(cap) {} void push(const T& item) { std::unique_lock<std::mutex> lock(mu_); not_full_.wait(lock, [this]() { return queue_.size() < capacity_; }); queue_.push_back(item); not_empty_.notify_one(); } T pop() { std::unique_lock<std::mutex> lock(mu_); not_empty_.wait(lock, [this]() { return !queue_.empty(); }); T item = queue_.front(); queue_.pop_front(); not_full_.notify_one(); return item; } private: std::deque<T> queue_; size_t capacity_; std::mutex mu_; std::condition_variable not_full_; std::condition_variable not_empty_; };这里用两个条件变量分别管理“不满”和“非空”,避免生产者消费者互相唤醒时的无效竞争。注意push里的wait用了条件变量自带的重载,传入一个lambda判断队列是否已满,这是C++11之后推荐写法——既能防止伪唤醒,也让逻辑清晰。cap怎么定?我一般设为预期并发提交数的2倍,比如每秒峰值1000次提交,每个任务平均处理1秒,那队列容量设为2000到3000就够,超出后让入口层直接返回“系统繁忙”。
4.2 心跳检测与摘除节点的实现
worker必须定期向调度器发送心跳包,包含当前任务数和最近一次任务的耗时。调度器如果超过3个心跳周期没收到某worker的心跳,就把它标记为“疑似下线”,移出可选节点列表。这里有个细节:不是立刻删除节点记录,而是进入“隔离列表”,观察一段时间再彻底删除。因为网络抖动很常见。
// heartbeat.cpp const int kHeartbeatTimeoutMs = 3000; const int kRestoreThresholdMs = 5000; void HeartbeatMonitor::onHeartbeat(const std::string& node, int running_count) { auto& info = nodes_[node]; info.last_seen_ms = now_ms(); info.running_count = running_count; // 如果之前被隔离,且最近5秒内连续两次心跳正常,恢复 if (info.is_isolated) { info.is_isolated = false; LOG(INFO) << "Node restored: " << node; } } void HeartbeatMonitor::checkTimeout() { int64_t now = now_ms(); for (auto& kv : nodes_) { if (now - kv.second.last_seen_ms > kHeartbeatTimeoutMs) { if (!kv.second.is_isolated) { kv.second.is_isolated = true; LOG(WARNING) << "Node isolated: " << kv.first; } } } }kHeartbeatTimeoutMs=3000表示3秒没心跳就隔离。那worker心跳发送间隔应该是多少?为了“3秒内至少能收到一次”,worker的心跳间隔我设为1秒,这样丢一两个包不会误判,连续丢超过3个才隔离。RestoreThresholdMs是防抖:节点恢复心跳后,在5秒内连续正常,才放回候选池。这5秒有什么用?如果worker只是短暂full GC或者被沙箱卡了一下,它恢复后立刻接任务可能还处于不稳定状态,观察5秒能滤掉这种情况。
4.3 超时重试的代价控制
任务从调度器发给worker后,调度器会等待worker的执行结果。这个等待不能无限期。每个任务有自己的时间限制(比如2秒),调度器在任务发出后启动一个定时器,超时后有两种处理:如果该任务的重试次数小于2,就重发到另一个worker;如果已经是第二次重试,则直接标记为“判题失败”,返回超时错误。重试还会给原worker发一个取消指令,虽然不一定来得及,但要发。
// retry.cpp bool dispatchWithRetry(const TaskMessage& task) { for (int attempt = 0; attempt <= kMaxRetry; ++attempt) { TaskMessage tmp = task; tmp.retry_count = attempt; std::string node = selector_->select(); if (node.empty()) return false; sendToWorker(node, tmp); bool ok = waitResult(task.task_id, tmp.time_limit_ms + 2000); if (ok) return true; LOG(ERROR) << "Task " << task.task_id << " failed on " << node; // 重试前把失败节点临时降权,避免同一个死节点被连续选中 selector_->penalize(node, 0.5); } return false; }注意waitResult的等待时间,我在原任务时限上加2000毫秒,这是给worker留出“拉取测试用例、初始化沙箱”的额外时间。如果不加这个余量,一个本身只会跑1秒的任务因为网络延迟被误判超时,重试成本更高。另一个代价控制手段是penalize(node, 0.5):当某节点导致一次超时,就把它的权重临时减半,下一次分配大概率会换节点。这个惩罚会随着节点后续正常完成任务而逐渐恢复,恢复到什么速度?我通常在每次心跳时对权重加0.1,直到回到初始值。
5. 避坑记录:并发、粘包、权重与心跳的四个翻车现场
5.1 现象:任务堆积在某个worker,其他节点一直空闲
某个新接入的worker上线后,调度器把任务都给了它,其他节点饿死。原因:加权轮询时,我一开始把新节点的权重设为一个很大的值,比如直接用cpu_cores * 10,结果新机器是32核,权重320,老机器8核权重80,比例悬殊,导致新节点瞬间被打满。解决:接入权重不要只看核数,先按当前worker的平均任务耗时为基准调平。我后来改成:新节点先按同型号旧节点的1.2倍权重接入,运行30分钟后再根据实际耗时自动校准。
5.2 现象:调度器进程CPU不高,但任务下发延迟越来越大
队列本身是好的,但分配线程和网络发送线程之间没有做背压。我在队列出队后直接调用sendToWorker,如果worker的TCP发送缓冲区满,send会在内核里阻塞,这把队列消费线程直接卡住了。原因:没有把“取出任务”与“发送任务”分离。解决:消费线程出队后只放入一个待发送缓冲队列,专门的IO线程去发送。IO线程发送失败就重新放回待发送队列,而不是阻塞分配逻辑。
5.3 现象:心跳隔几分钟就误判一次,节点反复隔离恢复
心跳时间为1秒,但调度器所在机器和worker之间因为网络排查、防火墙交火等导致偶尔丢包,一次丢包就可能连续超过3个心跳没收到。原因:单次心跳包太小,很容易被网络设备合并或丢弃。解决:不直接依赖“心跳包”,而是在心跳里携带服务端主动发起的“探活请求”。调度器每2秒主动向worker发一个PING,worker收到后立即回PONG,一轮PING-PONG失败才标记异常。这比只等worker上报更主动,能发现“worker进程活着但网络已不可用”的情况。
5.4 现象:两个任务结果互换,判题结果张冠李戴
我最初用TCP长连接直接传TaskMessage,没加长度头,结果一次发送2个任务时,worker端read到的数据可能包含半个任务,或者多出半个任务的字节,然后两个任务的时间限制也错乱。原因:TCP是字节流,不是消息流,send一次和recv一次不是一一对应的。解决:加4字节包头,高两位存版本号,低30位存消息长度;worker先读4字节,再读指定长度。下面是个简化的读取循环。
// tcp_recv.cpp bool recvExact(int fd, char* buf, size_t len) { size_t already = 0; while (already < len) { ssize_t n = recv(fd, buf + already, len - already, MSG_WAITALL); if (n <= 0) return false; // 连接断开 already += n; } return true; } bool recvMessage(int fd, TaskMessage& msg) { char header[4]; if (!recvExact(fd, header, 4)) return false; uint32_t len = (header[0] << 24) | (header[1] << 16) | (header[2] << 8) | header[3]; if (len != sizeof(TaskMessage)) { LOG(ERROR) << "Bad message length: " << len; return false; } char body[sizeof(TaskMessage)]; if (!recvExact(fd, body, sizeof(TaskMessage))) return false; memcpy(&msg, body, sizeof(TaskMessage)); return true; }MSG_WAITALL这个标志很好用,它会让recv一直等到读取完指定长度才返回,省掉写循环的麻烦。但要注意它不能保证网络不中断,所以要检查返回值。这里最关键的坑是:如果结构体对齐或字节序不一致,整个判断会错乱。所以我加了len != sizeof(TaskMessage)的直接校验,而不是盲信包头。
5.5 现象:worker执行失败后,任务被重复判题,用户成绩被覆盖
重试机制生效后,同一个任务在A节点超时,调度器发给B节点,但A节点其实还在后台执行,最后A和B都写结果,B的结果晚到覆盖了A,用户看到的是后到的那个。原因:A节点执行超时只是调度器“等不及了”,并不代表A节点放弃了任务。解决:任务状态机里增加“执行中+节点ID”的记录,worker回写结果时带上节点ID,调度器只接收当前“期望节点”的结果,其他节点的结果丢弃,并发送cancel帧尝试中断A节点。
6. 验证压测边界:用分发均衡度和尾延迟来验收
系统写完,不能只靠“跑起来没问题”就上线。我验证这套C++负载均衡调度器时,会做两个维度的检查:分发均衡度和尾延迟。分发均衡度用标准差判断——统计每个worker收到的任务数,计算标准差,如果标准差超过平均任务数的10%,说明策略没有生效。尾延迟则看P99——记录每个任务从入队到worker回传结果的耗时,超过10秒的比例必须为0。
压测脚本我直接用Python写简单模拟,因为调度的核心逻辑是策略器和队列,不涉及真实编译:
# load_test.py import socket, time, threading TOTAL = 10000 def submit(client_sock, start_barrier): # 模拟在线编程入口向调度器灌任务 for i in range(TOTAL): msg = b"task_%d" % i client_sock.send(msg) time.sleep(0.1) if __name__ == "__main__": sock = socket.create_connection(("127.0.0.1", 9000)) t = threading.Thread(target=submit, args=(sock, None)) t.start() t.join()真正稳定后,我会把头三个压测指标固定成脚本里打印的数字,每次改动调度器代码都要过一遍。从那以后,我每次调完权重或虚拟节点参数,都强制走一遍这个流程:先记录当前分发标准差,再改参数,再压测对比,没有变好就回滚。这不是形式主义,是防止“拍脑袋调参”让系统越调越差。希望帮到你。
本文还有配套的精品资源,点击获取