过了第一版简单实现的阶段之后,我其实一直不太愿意把"在线OJ"这种项目往数据库方向上引,总觉得判题系统的核心在沙箱和执行器,数据库顶多是个存账号和题目的地方。直到有一次给一个课程设计做压力演示,单机版的OJ在30人同时提交时直接卡死——页面转圈、判题队列越积越长、MySQL连接数被打满,这时候我才意识到,自己之前对"数据库版"这三个字的理解太浅了。
这次重新设计的负载均衡在线OJ(数据库版),不是简单地把题目和提交记录放进数据库,而是把判题状态、任务分配、去重控制、幂等处理全部收编到数据层来统一管理。整套系统跑下来,体感上和原来最大的区别是:负载均衡不再只是一个Nginx轮询配置,而是和数据库的事务、锁、唯一约束协同工作,真正做到了多实例判题时不重判、不漏判、不乱抢任务。
这篇文章就把我这次从架构设计到落地部署的完整思路、踩坑过程和实测结果写出来,尤其是几个想明白之后觉得特别值钱的点:提交表的状态机怎么设计、判题机拿任务时怎么用数据库锁避免两个实例同时处理同一个提交、负载均衡层和数据库之间的边界到底画在哪。准备做在线OJ、刷题网站或者任何"任务分发+结果回写"系统的朋友,应该都能从这里找到点可复用的经验。
1. 从单机版到数据库版:那次卡死让我彻底改写了架构
1.1 单机版的硬伤:不是算法引擎不够快,而是所有东西都挤在一起
我之前那个单机版OJ,代码结构走的是最经典的路线:Spring Boot做Web服务,提交代码后直接在当前进程里开线程去编译运行。听起来没毛病,题目量不大、用户量不大,单机其实能扛得住。但当提交量上来以后,问题就暴露得很明显。
最要命的一个场景是:判题模块是同步执行的。用户点提交,接口会一直等到编译完成、测试用例全部跑完,才把结果返回给前端。虽然我用了一个简单的内存队列把它改成了异步,但进程一重启,内存队列里的任务就全部丢了,用户看到的永远是"正在判题中",实际上后面的worker早就没了。更麻烦的是,我把判题需要的临时文件也写到本机磁盘,部署了第二个实例之后才发现,两个节点的判题结果互相看不到,数据库里面存了一堆不同节点写的半截数据。
那次演示卡死的原因,我现在复盘下来其实特别简单:内存队列没有消费者保护机制,多个大代码文件同时编译,把服务器的CPU和内存占满了,数据库连接也被慢查询拖垮,整个应用直接雪崩。这不是加一台服务器就能解决的,得把"任务要可靠地存下来"、"任务要能被多个worker领取"这件事从代码层面搬到数据层面去设计。
1.2 为什么要叫"数据库版":本质是把任务分发和一致性交给数据层
很多人听到"数据库版"以为就是把题目、用户这些数据从文件或者本地变量改成数据库存储,其实这只是最表层的一层。这次重新设计,我真正想做到的是:提交记录一旦进入系统,它就是一个不可变的数据事实,谁处理它、处理到什么状态、处理结果是什么,全部通过数据库来记录和协调。
于是我把提交任务从"进程内存里的一个对象"改成了数据库里的一行记录,这一行记录带状态字段,从pending、judging、success、failed到system_error。多个判题worker实例启动后,循环去数据库查询处于pending状态的提交,然后尝试把状态从pending改成judging。这个修改如果成功,就相当于这个任务被当前worker"抢"到了,其他worker即便同时看到了pending状态也改不动,因为数据库的行锁和更新条件会拦住它们。
这就是数据库版和队列版的本质差异。如果只是引入RabbitMQ或者Redis队列,任务也是可靠的,但很多课程设计项目里并不会有独立的消息中间件给你用。用数据库本身的锁机制来实现一个简单的任务队列,反而更贴近"一个后端应用+一个MySQL"就能跑起来的最小化部署架构,对这个项目的定位来说刚好。
1.3 我对这个项目的最终定位和实现指标
先把这个项目的规格说清楚,方便后面理解我做的设计取舍。系统面向的是一个300人左右的课程实验场景,题目量大约120道,用例总量控制在500组以内。提交量按一次作业高峰期算,大概十分钟内有400到600次提交,平峰时每分钟只有几次。
技术栈上面,我选了Spring Boot 2.7做应用框架,Spring Cloud OpenFeign做服务之间的接口调用,Nginx做外部流量入口的负载均衡,MySQL 8.0做核心数据库,判题沙箱还是用容器隔离的方式执行用户代码。整个系统可以横向扩展出三个web实例和三个判题worker实例,它们共享同一个MySQL数据库。Redis在这个版本里是可选的,我没有让它承担任何核心状态,只用来做热点题目排行的缓存,就算Redis挂了也不影响判题主流程。
这个架构的好处是,扩展worker实例时不需要额外部署任何队列中间件,只要新起一个Java进程连接同一个数据库,它就能自动开始领任务。缺点也不是没有,数据库会成为新的瓶颈,所以后面我在连接池参数、事务隔离级别、索引设计上下了很大功夫。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 架构的轮廓:负载均衡、应用层、数据库到底怎么划分职责
2.1 一次提交请求从进入到出结果的完整链路
一个用户从前端页面点"提交代码",请求先打到Nginx,Nginx根据负载均衡策略转发给某一个可用的应用实例。应用实例做登录校验、参数校验、语言白名单检查,然后把代码和题目ID、用户ID组装成一条submission记录,插入数据库。
插入成功后,应用实例不直接去调用判题模块,而是立即返回给前端"提交成功,判题中"。前端之后通过轮询或WebSocket查询这条submission的状态。判题worker实例在后台通过一个轮询循环,不断扫描数据库中待处理的submission,拿到任务后就执行编译和运行,把结果更新到数据库里。如果此时用户的请求恰好轮询到另一个应用实例,这个实例直接查数据库就能看到最新的判题结果,不需要任何本地缓存和会话同步。
这就是"无状态应用层"的实践要点。应用实例之间不需要互相知道对方存在,唯一的真相来源是数据库。负载均衡可以随便把请求分到任何实例上,因为每个实例处理用户请求时都只和数据库打交道。用户登录会话我是用JWT存在前端的,后端不存session,所以session同步的问题也顺带绕开了。
2.2 负载均衡到底在哪一层做,分别解决什么问题
这个项目里其实有两层负载均衡,很多人会搞混。第一层是前端入口的负载均衡,也就是Nginx做的事,负责把用户的HTTP请求分散到多个Spring Boot应用实例上,解决的是"Web服务扛不住高并发请求"的问题。第二层是判题任务的负载均衡,由worker自己的抢任务机制来实现,解决的是"判题计算能力不够"的问题。
有人可能会问,为什么不直接在Nginx里把判题worker也算进去,用HTTP转发把判题任务发给worker?我也考虑过这个方案,但很快否掉了。因为判题worker执行的编译和运行是长耗时操作,一个C++程序可能跑几百毫秒,Java或Python可能到秒级,如果用HTTP同步调用,Nginx的默认超时根本不够用,也很容易把连接池占满。用数据库抢任务的方式,worker和Web应用完全解耦,Web应用只负责写数据和查结果,worker取走任务后无论跑多久,都不会阻塞任何用户的HTTP请求。
Spring Cloud OpenFeign在这套架构里的角色很有意思。它是给服务之间做声明式HTTP客户端的,内部集
