C++实现负载均衡:在线判题系统设计与实战

发布时间:2026/8/31 14:04:47
C++实现负载均衡:在线判题系统设计与实战 简介这是一套面向高校计算机专业学生、算法竞赛备赛者及后端系统开发者的技术实践资源聚焦于高性能在线编程评测系统的架构设计与C工程实现。资源以负载均衡为核心能力解决多用户并发提交代码时的资源调度、沙箱隔离与低延迟反馈等关键问题适用于OJ平台二次开发、分布式系统课程设计及高并发服务学习场景。压缩包共52个文件含9个JSON配置与协议定义、9个CPP核心模块如Reactor事件循环、任务分发器、7个H头文件含Protobuf接口声明辅以CMake构建脚本与测试用例整体仅69KB轻量但结构完整。目前已有71人学习下载读者可直接获取基于Lars Reactor模型的C OJ主干代码、Contacts协议缓冲区定义、沙箱执行逻辑框架及配套编译部署流程快速理解高可用在线评测系统的模块划分、通信协议设计与负载策略落地细节。 我最早是被在线编程系统这种项目吸引的。它其实就是一个简化版的OJOnline Judge系统用户提交代码、服务端编译运行、比对输出结果再把判定结果返回给用户。听起来简单但一旦要考虑同一时间很多人同时提交、多个判题节点怎么调度这件事整个系统的复杂度就上来了。而这个项目标题里的基于C和负载均衡恰好就是两个最值得做的点一个是C在服务端和算法性能上的硬功夫另一个是分布式系统里最经典的问题。这篇文章我会从整体设计、环境搭建、核心模块实现到问题排查把我自己实际做这个项目踩过的坑和验证过的方案完整拆出来给正在学习C、或者想搞懂负载均衡原理、又或者正在准备C后端面试的朋友一份可以直接落地的参考。1. 项目整体设计与思路拆解1.1 在线编程系统的核心链路先把这个系统到底要做什么说清楚。一个在线编程系统最常见的流程是用户在前端页面看到题目和代码编辑器写完后点提交后端收到代码后把它交给判题节点去编译、运行、比对结果最后把编译错误答案正确运行超时这类结果返回给前端。也就是一条完整链路提交请求 - 网关/入口服务 - 负载均衡器 - 某个判题节点 - 编译运行 - 结果回传 - 前端展示。这里最难的部分恰恰不是编译运行本身而是并发和兜底。如果只有单个判题进程用户同时提交50份代码这个进程就要排队执行不仅慢而且一旦它因为某段恶意代码崩溃整个提交服务就瘫痪。所以在设计上必须引入多个判题节点由一个调度端统一分发请求这正是负载均衡的核心作用。1.2 为什么负载均衡是这个系统的关键位置负载均衡本质上就是把请求按某种策略分发到多个后端服务上。在实际项目中它要解决的问题有三类避免单点过载某个节点已经快跑满CPU了就不能再把新任务塞给它。保证可用性某个节点挂了请求要能自动转到其他健康节点。保证合理分配同类型任务尽量均匀散开不让一个节点闲死、另一个节点忙死。说到负载均衡很多人第一时间想到Nginx。Nginx当然可以配置简单的反向代理和轮询分发我刚开始做这个项目的时候也考虑过直接用Nginx。但Nginx配置偏向静态转发要想实现加权轮询、最少连接数这些可感知、可观察的策略就得配一堆参数而且对C开发者来说用C手写一个轻量负载均衡器对理解原理更有帮助。你可以把它当作一个为了学习而故意造的小轮子。1.3 C在系统里的位置和选型理由这个系统里C其实承担了三个角色负载均衡编排层接收外部提交请求根据策略转发到判题节点。判题节点核心编译用户代码、运行进程、限制时间和内存、比对输出。这部分对性能和系统资源控制要求最高。通用工具层比如队列、哈希、字符串处理、信号量、网络IO等。为什么选C因为判题过程里我们需要精细地管住另一个进程限制它只能用多少内存、只能跑多少秒、只能访问哪些系统调用。Java和Python也可以做类似的事但C在创建进程、监控子进程状态、使用共享内存和系统级资源限制上更直接更接近操作系统的底层机制。不过要说明一下系统入口服务和前端交互倒不一定非用C不可。项目里我为了统一语言服务层也用的是C配合一个简单的HTTP解析器就够了。如果你想简化可以把这层换成Java/Go/Python不影响整体负载均衡逻辑。2. 本地开发环境与工程结构准备2.1 VSCode下配置C开发环境的完整步骤既然是C项目第一步就是把本地编译环境弄干净。我用的是VSCode加MinGW-w64Windows或者GLinux配合CMake管理工程。VSCode配置C/C环境有几个关键点安装C/C插件也就是ms-vscode.cpptools它是语法提示、调试和IntelliSense的基础。配置编译器路径。在Windows下用MinGW安装完后记得把.../mingw64/bin加入环境变量PATH然后命令行里输入g --version能正常输出就说明没问题。顺手配置Code Runner或自己写task.json。我更喜欢用CMake因为工程文件多了以后g一条条编译不现实。下面是我常用的tasks.json核心配置{ version: 2.0.0, tasks: [ { label: cmake-build, type: shell, command: cmake -B build cmake --build build, group: { kind: build, isDefault: true } } ] }这里有个细节cmake -B build的意思是让CMake在build目录下生成构建文件而不是把一堆中间文件全堆在源码目录里。这点对后来工程扩充很重要不然编译产物和源代码混在一起找问题找得想哭。2.2 工程目录怎么组织才算合理这个项目的目录结构我建议这样规划online-judge-system/ ├── CMakeLists.txt ├── include/ │ ├── load_balancer.h │ ├── judge_worker.h │ └── utils.h ├── src/ │ ├── load_balancer.cpp │ ├── judge_worker.cpp │ └── main.cpp ├── workers/ │ └── worker_config.json └── tests/ └── simple_test.sh一层是负载均衡器模块一层是判题节点模块再加上公共工具类。目录清晰了代码才好维护。很多人刚开始写项目喜欢所有代码都塞到main.cpp里这样到后期调试的时候你会连是负载均衡转发问题还是判题节点问题都分不清。2.3 同一个项目怎么起多个服务来测负载均衡这是很多新手问得最多的问题我一个项目怎么在同一台电脑上模拟出多个服务端其实很简单程序是同一个但你可以在不同终端、用不同启动参数把它跑成多个实例。以判题节点为例我编译出来一个judge_worker可执行文件启动时给它传一个端口号参数不同终端传不同端口就能得到监听不同端口的多个节点。./judge_worker --port 9001 --name workerA ./judge_worker --port 9002 --name workerB ./judge_worker --port 9003 --name workerC这样三个判题节点就起来了。注意它们的端口不能一样否则第二个进程绑定端口时会报Address already in use。整个过程不需要多台服务器也不需要搞虚拟机一个终端窗口一个进程日志分开打非常直观。3. 核心模块实现负载均衡器与判题服务3.1 负载均衡器的C实现从轮询到加权到最少连接负载均衡器的核心就是维护一个后端节点列表收到请求时按策略选一个节点出来把请求转发过去。这里我用最简单的方式实现负载均衡器监听一个端口用HTTP协议和外界交互后端判题节点也用HTTP接口暴露执行判题和健康检查两个入口。先看一个最基础的结构体struct WorkerNode { std::string name; std::string host; int port; int weight; // 权重默认1 int current_conns; // 当前连接数 int fail_count; // 连续失败次数 bool healthy; };接下来是实现三种策略轮询Round Robin是最直白的策略维护一个round_robin_index每次请求来了都用(index) % worker_count选出节点。优点是公平、实现简单缺点是完全不感知后端实际负载。加权轮询Weighted Round Robin解决了不同节点能力不一致的问题。比如workerA是8核权重设为2workerB是4核权重设为1那就让A每轮被选中两次。实现上可以用一个current_weight累加的办法也就是平滑加权轮询。这个算法Nginx和很多网关都在用尽量掌握。int selectByWeight(std::vectorWorkerNode nodes) { int total 0; for (auto n : nodes) { if (!n.healthy) continue; n.current_weight n.weight; total n.weight; } int best -1; for (auto n : nodes) { if (!n.healthy) continue; if (best -1 || n.current_weight nodes[best].current_weight) { best n - nodes[0]; } } if (best ! -1) { nodes[best].current_weight - total; } return best; }注意这里每次选择完都要把current_weight减去总权重这个操作是为了防止某个高权重节点被连续选中实现平滑的效果。这个细节我在面试的时候被问到过背后其实是把权重分布折算到时间轴上防止抖动。最少连接Least Connections更贴近真实负载情况。选择current_conns最小的节点转发成功后把它加一处理完后减一。这个策略特别适合每次判题耗时差异大的场景有的用户提交的代码是简单求和几十毫秒就完了有的则是复杂递归可能跑几百毫秒如果只用轮询遇到后面这种情况其他节点早就闲下来了这个节点还忙不过完。3.2 判题节点服务如何实现判题节点的职责是接收代码和测试用例编译运行比对结果。完整的判题要考虑很多系统安全问题这里我把核心逻辑说透。首先是接收请求。我会用C写一个极简的HTTP Listener基于socket和pthread。有读者可能问干嘛不用现成的C Web框架因为学习项目不引入过重依赖自己实现一遍更清楚HTTP路由解析是怎么回事。关键代码片段如下void handleJudgeRequest(int client_fd, const std::string body) { // body 形如: codexxxstdinyyy std::string code extractParam(body, code); std::string input extractParam(body, input); std::string compileResult compile(code); if (compileResult ! OK) { sendResponse(client_fd, compileResult); return; } auto result runAndCheck(input); sendResponse(client_fd, result); }编译这里其实是个大坑。生产环境绝不会让你在判题节点上直接调system(g ...)因为这样用户代码可以夹杂shell命令非常危险。正确做法是使用fork()创建子进程在子进程里用execlp(g, ...)执行编译并且设置沙箱限制。pid_t pid fork(); if (pid 0) { // 子进程 freopen(user_code.cpp, w, stdout); execlp(g, g, user_code.cpp, -o, user_prog, -O2, nullptr); _exit(1); } else { // 父进程等待带超时 int status; waitpid(pid, status, WNOHANG); }运行阶段需要限制子进程的时间。我通常用alarm()或者setrlimit()来做前者是到了指定秒数发信号杀掉子进程后者是限制资源使用量。简单点就先在编译前建一个专门的临时目录把所有文件放在里面避免用户代码读写系统其他文件。3.3 请求分发与结果回收的完整流程把负载均衡器和判题节点连起来完整流程是这样的客户端向负载均衡器发送Post请求请求体里带着用户代码和题目数据。负载均衡器调用选择策略挑出一个健康的判题节点。负载均衡器作为HTTP客户端把请求转发给该节点。节点执行编译、运行、比对把判定结果返回给负载均衡器。负载均衡器把结果原样返回给客户端。这个流程里有两个容易出的问题请求超时和节点失败。如果某个判题节点因为用户代码死循环卡住了负载均衡器不能无限等下去。我给负载均衡器加了一个请求超时控制比如5秒。如果节点响应超时就让该节点fail_count连续失败三次就把它标记为不健康从候选列表里摘掉。摘掉之后是不是就不管了不行。还需要一个后台线程做健康检查每隔几秒向不健康节点发一个/health请求如果恢复了就把它重新放回列表。这个机制才能让系统真正活起来。健康检查的C伪代码如下void healthCheckLoop(std::vectorWorkerNode nodes) { while (running) { for (auto n : nodes) { if (!n.healthy) { bool ok ping(n.host, n.port); if (ok) { n.healthy true; n.fail_count 0; std::cout [health] n.name recovered\n; } } } std::this_thread::sleep_for(std::chrono::seconds(3)); } }这里要注意一个小细节健康检查的频率不能太频繁否则节点需要分心处理健康检查请求但也不能太慢不然故障恢复得不够快。3秒一次是我调下来比较合适的结果。3.4 完整测试用脚本模拟并发提交观察负载均衡效果系统写完以后我最喜欢干的一件事就是写个测试脚本模拟并发请求看请求到底落到了哪些节点上。我用一个简单的bash脚本配合curl做测试for i in $(seq 1 20); do curl -s -X POST http://localhost:8080/submit \ -H Content-Type: application/json \ -d {\code\:\int main(){return 0;}\,\problem_id\:1001} done wait然后在每个判题节点的终端里观察日志。如果是轮询策略你会看到workerA、workerB、workerC各接收到6-7个请求基本均匀。如果是加权轮询并且A权重为2你会看到A大概接收10个B和C各接收5个。这个实验效果特别直观比看一堆配置文档更让人有成就感。我建议你测试的时候打印出每个节点收到的请求数这样一比就知道策略有没有生效。4. 常见问题与排查技巧实录4.1 多服务启动与端口占用问题我在测试时最常遇到的第一个问题就是端口占用。启动第二个worker的时候报错显示端口已经被占用了。排查思路是用netstat -ano | findstr 端口查看哪个进程占用了端口。确认上一个worker是不是没关干净。CtrlC有时不会杀掉子进程用ps aux | grep judge_worker找出来kill掉。这个坑看起来小但在你写启动脚本的时候特别烦。建议直接在启动脚本里加一个pkill -f judge_worker || true防止重复启动。4.2 请求全部打到一个节点上有朋友问我配了轮询策略为什么所有请求都到了同一个节点排查下来往往是因为负载均衡器没有真正连接后端而是转发时候选列表里其他节点都因为健康检查失败被摘掉了。还有个常见原因是我把weight配置错了某个节点权重设成100其他是1那当然几乎都打过去。用加权策略时先把权重都设成1做对照实验确认代码逻辑没有问题再调权重。4.3 判题节点超时和队列积压另外一个问题是如果某个判题节点处理不过来请求会在队列里积压最终导致负载均衡器等待超时。解决办法有两个方向判题节点内部用线程池限制最大并发数超出的请求直接排队负载均衡器在转发时增加最大排队时间的判断超过就快速失败返回提示信息。我比较推荐在判题节点内部实现一个简单的任务队列限制同时最多运行2个编译任务。多余的请求先放进队列而不是直接全线程往上冲。这样即使来了20个请求节点也不会因为线程过多导致资源耗尽。4.4 C开发中的常见坑位字符串处理与内存安全这个项目里C代码量不小我在排查的时候发现几个高频C问题std::string的拼接和查找在循环里千万不要用反复拼接大字符串性能会很差。我一开始解析HTTP body的时候用body chunk;结果并发一高延迟直线上升改成std::ostringstream后才好转。注意区分substr的边界很多人会把substr(start, count)的第二个参数理解为结束位置实际上它是长度这个错误会直接导致请求参数解析不对。处理子进程的时候不要在主进程里和子进程共享同一个临时文件句柄该close()的地方一定要close()否则文件被占着后续编译可能报错。用waitpid等待子进程时如果用了WNOHANG要处理子进程可能还没退出的情况不然父进程会把还没出的状态当成失败。4.5 排查问题时的三分法整个项目调试下来我逐渐总结了一套排查问题的顺序。先看入口再看分发最后看执行。入口请求有没有到达负载均衡器分发负载均衡器有没有正确选择节点有没有报节点不可用执行判题节点有没有收到请求编译/运行阶段有没有报错这个顺序能帮你快速定位大约90%的问题。很多人一上来就翻代码找某个变量为什么不对其实先打日志、分层确认效率高得多。4.6 关于C面试和八股文的一点结合这个项目做完以后我发现自己对很多C八股文的记忆都从背变成了理解。比如面试经常问的进程和线程的区别如何避免僵尸进程信号量怎么用在这个项目里都是真实碰到的问题。你要控制一个判题子进程就必须处理僵尸进程你要做请求队列就必然要考虑线程安全问题。所以这个项目不仅是练手还能反哺面试。如果你打算把它写进简历建议强调三个点一是自己实现了加权轮询和最少连接等负载均衡策略并对比了不同策略在并发场景下的表现二是通过进程控制和系统资源限制完成了判题沙箱的雏形三是通过健康检查和失败摘除机制提升了系统的可用性。5. 总结与一些个人的实践体会这个项目从零到一搭完我最大的感受是负载均衡不是一个加个中间件就能说清的事它背后包含了对可用性、性能、容错的综合理解。用C在本地把负载均衡器、判题节点、健康检查这套流程跑通之后再去看Nginx配置、看Spring Cloud的负载均衡源码、看各种RPC框架的任务调度都会觉得顺眼很多。实际测试的时候我习惯把三个节点分别叫nodeA、nodeB、nodeC然后强制停掉nodeB再发起请求。看着负载均衡器自动把请求全部转发给nodeA和nodeCnodeB恢复后再自动加回来那个过程比读多少篇图文教程都管用。你亲手把系统搞挂再救活一次才算真正理解什么叫高可用。如果后续还打算继续扩展这个项目我个人比较推荐的路线有三个一是加数据库把题目和提交记录存起来二是引入消息队列解决峰值时的大量提交积压三是把判题节点的沙箱做得更完备比如改用Linux的namespace、cgroup做资源隔离这些都是生产级OJ真正用的方案。最后再分享一个小技巧你在开发的时候不妨给所有节点统一加上启动参数--verbose日志级别拉满然后专门拿一个终端窗口滚动输出。这样调试负载均衡策略的时候每个请求去了哪里、花了多久、有没有失败都一目了然。这个小习惯帮我省下了一大半的排查时间。本文还有配套的精品资源点击获取