负载均衡式在线评测系统(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 weight=3; server 10.0.0.12:8080 weight=2; server 10.0.0.13:8080 weight=2 max_fails=3 fail_timeout=30s; 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_fails=3 fail_timeout=30s就是干这个的:连续失败 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 缓存:判题结果先写到 Redis,Web 层消费后落库。这样数据库的读写在高峰时可以平滑错峰,再也没有被“同时大批量写”打爆过。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| 用户代码本地跑不超时,OJ 上超时 | cgroup 时间统计与墙钟统计混淆;宿主机负载过高 | 改用 cgroup cpu.stat 统计;检查宿主机的 CPU steal 指标 |
| 测试用例缓存占用大量磁盘 | 题目更新频繁,缓存目录不清理 | 设置 LRU 清理策略,限制缓存目录大小 |
| 某台判题机负载极高,任务仍被派入 | 心跳上报间隔太长,调度器看不到最新状态 | 缩短心跳到 3 秒;上报时附加本地队列长度 |
| 提交代码后一直显示“排队中” | 所有判题机过载,调度器触发保护 | 增加判题机节点;检查是否有死循环任务占满沙箱 |
| 评测结果偶尔丢失 | Redis 中结果未消费,进程崩溃 | 增加消费端的持久化,消费成功后标记;做好幂等处理 |
| 内存限制形同虚设 | 只有 memory.max,没有限制共享内存和进程数 | 补充 pids.max;限制 /dev/shm 大小;禁用 mlock |
6. 集群演进的技术选型思考
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的提交。这三发炮弹如果能被系统稳稳接住且不影响其他任务正常评测,那你的沙箱和调度就算合格了。等这套测试全通过,再面对真实的用户流量,你心里就有底了。