资讯动态

负载均衡式在线评测系统高并发架构全解析

发布时间:2026/10/18 9:28:00 来源:尧图企业网站定制
负载均衡式在线评测系统Load-Balanced Online OJ技术全景指南做在线评测系统Online Judge简称 OJ这事儿说难不难说简单也真不简单。尤其是当你的用户从十几个同班同学变成几百人同时刷题再变成上千人同时参加一场比赛的时候原本那套“单机跑到底”的架构会瞬间露出原形判题队列堵成肠梗阻、CPU 被打满、数据库连接报错、甚至整个服务直接宕机。我做过的模拟项目X就是从一台裸机扛不住并发开始一路演进到带负载均衡的集群架构。这篇博文就把我踩过的坑、比对过的方案、最终落地的架构细节一次性讲透希望对打算做 OJ 或者正在被高并发折磨的你有点帮助。这个内容适合什么人看两类人。第一类是自己维护 OJ 平台的开发者或高校实验室管理员想搞清楚怎么让系统抗住选课季和竞赛周的高峰流量第二类是对后端架构感兴趣、想拿“评测系统”这个真实业务场景练手的同学。我会从负载均衡的核心思路讲起拆解整体链路再落到判题沙箱、调度算法、资源限制这些硬核细节上最后附上我实操中遇到的典型问题和排查方法。全程不整虚的都是可以直接照着干的东西。1. 项目背景为什么单机 OJ 撑不住负载均衡到底解决什么问题1.1 传统单机 OJ 的并发瓶颈先描述一下大多数 OJ 的初始形态一个 Web 服务比如跑在 Tomcat 或 Flask/Gunicorn 上、一个数据库MySQL/PostgreSQL、一台跑判题脚本的机器。用户提交代码后Web 进程把提交记录写进数据库判题进程轮询数据库拿到任务编译、运行、比对结果再回写。这在几十人、上百人规模下完全没问题。可是当用户量上来之后瓶颈会同时出现在几个地方。第一是 CPU 密集型的判题任务。评测一个程序尤其是跑大量测试用例的 ACM 风格题目需要反复执行用户代码这是极其吃 CPU 的活。单台机器的 CPU 核心数就那么多判题任务一多队列就会无限拉长。用户提交一道题等两三分钟才出结果体感极差。第二是 Web 服务的连接数。每个浏览器端的长连接、轮询请求都会占用 Web 服务线程或协程。单机进程能同时维持的连接数是有上限的一旦超过新用户直接无法访问表现为页面打不开、提交按钮点了没反应。第三是数据库的压力。OJ 系统里用户提交记录、排行榜、题目状态这些数据读写频率极高。单库单表在百万级提交记录下简单查询都会变慢。更麻烦的是所有判题结果都要回写数据库多台机器各自直连同一个库连接数也会被打满。负载均衡要解决的核心问题就是“把流量分散到多台机器上”让整个系统不再是单一瓶颈点。它不是一个单一技术而是一整套架构思路——从用户请求进来到最终判题结果返回每一层都可以做负载分散。1.2 负载均衡在 OJ 场景下的特殊性普通的 Web 站点做负载均衡通常就是 Nginx 后面挂多个无状态应用实例加个 Redis 存 Session 就完事了。但 OJ 系统有个独特之处判题任务是典型的 CPU 密集型长任务而且非常看重资源隔离和安全性。你不能随便把一个用户提交的代码丢到集群任意一台机器上跑——万一这段代码是个死循环或者试图读取服务器文件那就是灾难。所以 OJ 的负载均衡需要分成两层看。一层是“接入层负载均衡”处理浏览器请求的分发这个和普通 Web 应用没有本质区别Nginx 或者云负载均衡器都能干。另一层是更关键的“判题任务调度层”它要解决的是当一个用户提交了代码到底该把这份代码交给集群里的哪一台判题机执行这不能靠 Nginx 那种简单的轮询策略因为每台判题机当前的 CPU 负载、内存余量、正在跑的评测任务数都是动态变化的。我最终采用的方案是双重的用 Nginx 做 Web 接入层的负载均衡用自研的调度服务配合 Redis 任务队列做判题任务的分配。Web 请求量上去加 Web 节点判题队列压满加判题机。两者独立扩容互不拖累。这套设计的好处很明显成本可控、扩展灵活。最小的落地形态只需要两台机器一台跑 Web 调度器一台跑判题机压力上来之后逐步加到五台、十台架构不需要变动只改配置。2. 总体架构拆解从请求入口到判题沙箱的完整链路2.1 四层职责划分一个负载均衡式 OJ 系统我从逻辑上把它拆成四层每一层的职责非常清晰接入层Web 网关负责接收用户的 HTTP 请求包括登录、浏览题目、提交代码、查询提交状态。这一层不处理判题逻辑只做请求分发和静态资源服务。实测中用 Nginx 做反向代理后面挂多个 Web 应用实例。业务层Web 应用处理具体的业务逻辑比如用户认证、题目 CRUD、提交记录的落库、调用调度服务。多个实例之间无状态通过共享数据库和 Redis 来保持一致。调度层任务分发中心这是负载均衡式 OJ 和普通集群系统的核心差异点。调度服务监听 Redis 里的待判任务队列根据各判题机的实时负载状态把任务派发给最合适的那一台判题机。判题层沙箱集群真正执行用户代码的机器。每台判题机上运行多个沙箱化判题进程通过心跳机制向调度器上报自己的负载情况。判题完成后结果回写数据库并通知 Web 层。在这个架构里负载均衡主要体现在接入层和调度层这两个位置。接入层的负载均衡解决的是“很多人同时访问网站怎么办”调度层的负载均衡解决的是“很多代码要同时评测怎么分配机器才能最快跑完”。2.2 判题机集群的角色定位判题机是整个系统中技术含量最高的部分也是最难扩展的部分。它不像 Web 服务那样随便加一个节点就能分担流量判题机的加入涉及资源隔离、安全沙箱、编译环境的一致性等一堆问题。我采用的判题机方案是基于容器的沙箱隔离每台机器上预装好统一的编译环境。判题机上跑着两类进程一类是常驻的 Agent 进程负责和调度器通信接收任务、上报状态另一类是临时创建的隔离进程负责真正执行用户代码。这种方式比直接在宿主机上跑用户代码安全得多也比每台机器初始化一整套虚拟机轻量太多。判题机的负载信息我通过 Agent 每三秒上报一次当前正在运行的评测任务数、最近一分钟的 CPU 平均负载、内存使用率。调度器拿到这些信息才能做精确的决策。2.3 存储层的选型数据库与缓存的取舍只要系统一上集群存储层就必须重新设计。原先单机版里 Web 服务直连数据库的做法在集群模式下必须先拆分职责。我的做法是Web 业务直接读写 MySQL 主库Redis 用来缓存热数据比如排行榜、题目的测试用例元信息、用户 Session。提交记录是增长最快的表必须做分表。我是按提交记录 ID 的区间分表避免单表过大导致查询缓慢。判题结果最初的写入路径是判题机完成评测后直接写 MySQL。后来发现高并发时判题机写库和 Web 读库互相争抢连接就改成判题机先把结果写到 Redis由 Web 层异步消费写入 MySQL。这算是比较经典的消息队列削峰思路的轻量实现。存储层的注意点千万不能让判题机直连主库写数据这是我在架构演进中最先踩到的坑。判题任务并发一高数据库连接池瞬间被打爆连带正常的 Web 访问全部超时。3. 负载均衡策略与实现细节3.1 接入层的 Nginx 配置与上游节点管理接入层的负载均衡我直接用的 Nginx原因无它稳定、可控、团队里每个人都会配。上游配置核心就几行upstream oj_web_cluster { least_conn; server 10.0.0.11:8080 weight3; server 10.0.0.12:8080 weight2; server 10.0.0.13:8080 weight2 max_fails3 fail_timeout30s; keepalive 32; } server { listen 80; server_name oj.example.internal; location / { proxy_pass http://oj_web_cluster; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_http_version 1.1; proxy_set_header Connection ; } }这里要注意几个选择。负载均衡算法我用的least_conn即最少连接数而不是默认的轮询。为什么因为 OJ 的 Web 请求里有一部分是浏览器向服务端轮询判题状态的长连接这类请求非常耗时但不怎么消耗 CPU。如果机械地轮询某些服务器可能被长连接占满而其他服务器却在空闲。最少连接数能更真实地反映当前每台机器的负担。keepalive 32很多人会忽略。这是 Nginx 对上游服务器的长连接配置不加的话每次代理请求都要重新发起 TCP 连接。在提交高峰期这会造成大量 TIME_WAIT 状态的连接极端情况下会耗尽端口。实测加上这个配置后Nginx 到后端的连接复用率大幅提升整体吞吐量有明显改善。Web 服务的某个节点如果挂掉Nginx 能不能自动摘掉它max_fails3 fail_timeout30s就是干这个的连续失败 3 次就把该节点标记为不可用30 秒后再尝试。这样某个 Web 实例 OOM 或者代码崩溃不会拖垮整个系统。3.2 判题任务调度算法我为什么没选 P2P 模式最早设计调度层时我对比过两种方案。第一种是 P2P 模式即判题机主动去数据库捞任务。每台判题机启动时绑定若干题目分类轮询数据库里有没有自己负责的待判任务。这个方案实现最简单但问题很明显热点题目的判题机忙死冷门题目的判题机闲死。而且轮询数据库本身就是一种低效行为任务一多数据库查询压力剧增。真正最终放弃它是因为无法做全局最优的负载均衡——每台机器只看到了自己那部分任务看不到外面还有多少任务在排队。第二种是集中调度模式也是我最终采用的方案。用户提交代码后Web 层把任务写入 Redis 的一个有序集合score 是提交时间戳。调度服务从有序集合里取任务根据各判题机的心跳数据计算出“谁最空闲”把任务推给对方。这个方案多了一个调度服务组件但是换来了精确的全局调度能力。调度服务的核心逻辑是给每台判题机打分分越低优先分配任务。打分公式大致是score (当前运行任务数 / 机器CPU核心数) * 5 (最近1分钟CPU平均负载 / CPU核心数) * 3 (内存使用率) * 2为什么这样加权运行任务数直接反映了队列积压情况权重最高。CPU 平均负载反映的是机器是否还有其他进程在抢资源权重次之。内存使用率在这个场景里通常不是最大瓶颈所以权重最低。这套打分规则在我实测中表现相当均衡没有出现一台机器忙到冒烟、另一台却闲到数星星的情况。3.3 动态权重与过载保护机制负载均衡策略不能只有正向分配还要有反向保护。我在调度器里预埋了“过载保护”逻辑如果一台判题机的最近 1 分钟 CPU 平均负载超过核心数的 1.5 倍调度器会优先跳过它把新任务派发给其他机器如果所有判题机都过载了调度器就把新任务留在队列里不再下发同时触发 Web 层的排队提示。这里有个容易忽略的细节判题机接收了新任务后会先把任务放进自己本地的队列按顺序执行。但如果本地队列太长用户体验还是会差用户等待结果时间变成“本地队列时间 实际判题时间”。所以我的 Agent 在上报负载的时候除了上报正在运行的任务数还上报了本地等待队列的长度。调度器派发任务时如果某台机器的本地队列已经超过 3 个任务即使它的 CPU 还很空闲也不会优先派给它——因为那 3 个任务已经占用了它在用户感知层面的带宽了。这套机制上线后高峰期用户平均等待判题的时间控制在了 10 秒以内而之前单机版动不动就是一两分钟。4. 判题核心环节的实践要点4.1 沙箱隔离与安全边界设计判题系统最让人睡不着觉的就是用户代码可能干坏事。用户提交的代码是要在服务器上真实运行的如果沙箱做得不够严实恶意代码可以直接读取服务器的环境变量、扫描内网、甚至尝试写文件占满磁盘。我见过很多因为沙箱不当导致整个 OJ 被打穿的案例所以在安全这块我宁可过度设计。我的沙箱做了三层隔离。第一层是系统调用过滤用 seccomp 限制用户程序能够使用的系统调用。比如禁用socket、fork、execve程序只能老老实实做计算和标准 IO。第二层是资源限制用 cgroup 限定 CPU、内存、进程数。第三层是文件系统隔离把用户程序放进一个只读的根文件系统它只能看到自己的可执行文件和数据文件其余一律不可见。实际执行用户代码时流程是这样的判题机 Agent 收到评测任务从消息里取出代码和题目 ID。把代码写入临时目录。创建隔离容器挂载只读根文件系统设置 seccomp 规则和 cgroup 限额。编译代码这一步同样在隔离环境内完成编译超时直接判编译错误。在容器内运行编译产物把每个测试用例的输入文件依次喂给程序收集输出。比对输出与标准答案得出该用例的判定结果。4.2 CPU 与内存限制的精细控制资源限制是整个判题准确性的关键也是坑最多的环节。做 ACM 竞赛评测要求你的判题机和标准环境的时间口径一致否则可能出现“本地跑 1 秒OJ 上跑了 1.2 秒被判超时”的惨剧。CPU 时间限制我用 cgroup 的cpu.max来控制限制的是 CPU 时间而不是墙钟时间。为什么要区分这两者因为一个多线程程序可能占用 4 个核心跑 3 秒累计 CPU 时间 12 秒但墙钟时间只有 3 秒。如果不限制线程数一个恶意程序开 100 个线程疯狂死循环可以把整台判题机的 CPU 打满影响其他评测任务。内存限制同样要细致。直接限制容器的memory.max只能限制物理内存用户程序如果申请了大量虚拟内存虽然不触发物理内存超限但会占用地址空间也可能出问题。所以我把物理内存上限和虚拟内存上限都做了限制物理内存 2 倍范围内的虚拟内存可以正常申请超过就直接判内存超限。还有个细节mlock这类系统调用可以锁定内存页如果不对齐限制用户程序可以 lock 住大量内存导致系统无法回收。这个调用我也在 seccomp 规则里禁掉了。4.3 测试用例与结果判定不只是比对字符串测试用例管理是一个常被低估的模块。一个题目的测试用例从一两组数据到几十组数据都有每组数据可能是几十 MB 的输入文件。如果每次判题都从数据库读测试用例文件IO 开销会非常大。我的做法是把测试用例在判题机上做本地缓存用题目 ID 做索引。第一次评测这个题时从对象存储拉取测试用例包并解压到本地目录后续评测直接读本地文件。题目更新后通过调度器广播一个“清理缓存”的消息判题机删除本地缓存下次重新拉取。结果判定这块很多新手会直接把程序输出和标准答案做全字符串比对这在小数据量下没问题但在浮点数答案、数据范围大的场景下会出现误判。比如答案是 0.3333333333用户输出 0.333333全字符串比对就判 wrong answer但按照题目要求的精度这应该算正确。所以我实现了两种比较模式strict模式和special judge模式。严格模式就是逐字符比对特判模式使用题目配置的特判程序通常是自动评测程序把用户输出和标准答案一起喂给特判程序由它判断是否通过。特判程序也在沙箱里执行防止特判程序本身的 bug 影响评测结果。5. 从零搭建过程中的踩坑实录5.1 问题一判题超时误判原来是时钟口径不一致第一次上线集群时用户反馈有些代码在本地 1 秒跑完丢到 OJ 上却超时了。排查了很久最后定位到是 CPU 时间统计的口径问题。我在容器里用getrusage获取程序运行时间这统计的是程序自身 CPU 时间。但判题机宿主机上还跑着 Agent、日志收集等一堆进程它们也会占用 CPU 时间。某些情况下用户程序的 CPU 时间被计算得很虚高导致误判。解决办法是把时间统计挪到 cgroup 层直接读取cpu.stat里的usage_usec字段这才是容器内所有进程真实的 CPU 消耗和宿主机其他进程彻底隔离。调整之后计时准确了很多再也没出过这种莫名其妙的超时投诉。5.2 问题二内存限制不生效程序照样吃满内存另一件奇怪的事某次压测一个申请大数组的程序明明在容器里设置了memory.max却依然能吃满整台机器的物理内存。翻内核文档才发现我设置的memory.max只对 page cache 和匿名页生效但如果程序用了 tmpfs 或者共享内存映射一类机制这部分内存不算在 cgroup 的内存统计里。用户程序通过mmap映射文件时文件页也未必计入限额。最终解决方案是在容器启动命令里同时设置多个参数内存控制、交换分区限制、以及进程数限制。最关键的是把/dev/shm这种 tmpfs 默认大小从容器本身一半改成了较小的固定值堵住通过共享内存吃内存的口子。5.3 问题三数据库连接池被打满Web 直接瘫痪进入集群模式后我遇到的最严重一次事故是某场比赛进行到一半所有 Web 请求全部超时。盯监控发现 MySQL 连接数已经飙到上千完全超过max_connections。原因很简单判题机写完评测结果后需要回写提交状态通知 Web 层。我最初是让判题机的 Agent 直接连 MySQL 写结果而 Web 层也在同时大量读写连接池的争夺直接导致雪崩。修这个问题的方案分两步。第一步是立竿见影的先止血把判题机对数据库的直接写入改走调度器由调度器统一写判题机和数据库彻底解耦。第二步是前置 Redis 缓存判题结果先写到 RedisWeb 层消费后落库。这样数据库的读写在高峰时可以平滑错峰再也没有被“同时大批量写”打爆过。5.4 常见问题速查表现象可能原因排查与解决办法用户代码本地跑不超时OJ 上超时cgroup 时间统计与墙钟统计混淆宿主机负载过高改用 cgroup cpu.stat 统计检查宿主机的 CPU steal 指标测试用例缓存占用大量磁盘题目更新频繁缓存目录不清理设置 LRU 清理策略限制缓存目录大小某台判题机负载极高任务仍被派入心跳上报间隔太长调度器看不到最新状态缩短心跳到 3 秒上报时附加本地队列长度提交代码后一直显示“排队中”所有判题机过载调度器触发保护增加判题机节点检查是否有死循环任务占满沙箱评测结果偶尔丢失Redis 中结果未消费进程崩溃增加消费端的持久化消费成功后标记做好幂等处理内存限制形同虚设只有 memory.max没有限制共享内存和进程数补充 pids.max限制 /dev/shm 大小禁用 mlock6. 集群演进的技术选型思考6.1 为什么选 Redis 而不是消息队列调度器的任务队列我用的是 Redis 的 list 和 ordered set而不是直接上消息中间件。核心考虑是部署成本和学习成本。一个团队对 Redis 的运维能力远强于对消息中间件的掌控力而且 OJ 的判题任务并没有严格的“至少一次/精确一次”投递语义需求——偶尔一个任务因为调度器重启而重复下发判题机只需要按提交 ID 做幂等处理即可。Redis 足够胜任。如果你没有经验的顾虑用专业的消息队列当然也行性能会更好还自带消费者重平衡机制。但对大多数高校实验室、小型技术团队来说Redis 是性价比最高的选择。6.2 扩容路径与容量规划负载均衡式架构的好处之一就是扩容路径非常清晰。我把扩容场景分成两类。Web 接入层的扩容如果高峰期页面响应变慢优先加一台 Web 节点在 Nginx 上游节点列表里加一行配置reload 一下即可。这个不需要动任何代码。判题层的扩容如果判题队列积压通常就是加判题机。新判题机启动后先初始化编译环境然后启动 Agent向调度器注册。调度器发现新节点后会自动开始派发任务给它。从启动到接单大约需要 5 分钟因为要预装编译环境和拉取基础镜像。这里有一个经验不要在比赛前十分钟才加机器。判题机拉取基础镜像、缓存测试用例都需要时间如果比赛已经开始了新判题机还没准备好调度器会把任务派给旧机器造成暂时的不均衡。我习惯在预期大流量到来前至少提前一个晚上把所有新节点准备好并且把最热门的一批题目缓存好测试用例。按一台标准判题机8 核 16G估算高峰时大约能同时跑 5-6 个评测任务。如果你期望的 QPS 是“每秒钟产生 10 个评测请求”那大概需要 3-4 台判题机才能保证队列不积压。按这个比例规划配合 CPU 负载的动态调度系统的冗余度是足够的。7. 写在最后的个人体会做这个负载均衡 OJ 系统最大的收获不是把技术栈铺得多宽而是真正理解了“架构是权衡出来的”这句话。单机版并不是一无是处它够简单、够稳但对于真实的使用场景来说扛不住峰值流量就是最大的不稳定。集群化、负载均衡化的代价是多了一堆组件——调度器、Redis、Agent、心跳机制——但它们换来的是整个系统在压力下依然能稳稳地按时给出评测结果。如果你也在维护一个类似的评测系统我的建议很简单不要一上来就追求微服务。先把单机的评测做好了把沙箱做严实了把资源限制做精确了然后在单机开始吃力的时候再轻量地引入集群和负载均衡每次只加一块拼图。这种方式迭代出来的系统你对它每一个环节的理解都是完整的出了问题也能一眼定位。最后分享一个开源自测的小技巧压测之前先写一个会死循环的提交再写一个申请超大内存的提交最后写一个尝试读/etc/passwd的提交。这三发炮弹如果能被系统稳稳接住且不影响其他任务正常评测那你的沙箱和调度就算合格了。等这套测试全通过再面对真实的用户流量你心里就有底了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑