过了第一版简单实现的阶段之后,我其实一直不太愿意把"在线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客户端的,内部集成负载均衡能力,但在我这个项目里,主要服务和判题worker之间不通过OpenFeign直接调用,只是在管理端后台查询判题机负载情况时,会通过OpenFeign加上负载均衡去请求某个实例的监控端点。这个问题后面我会专门讲清楚,因为很多新手会把OpenFeign的负载均衡和Nginx的负载均衡当成一回事,其实它们解决的问题层级不一样。
2.3 为什么Redis不是必需项:数据库版的首要原则是"少一个组件少一类故障"
在设计初期,我在技术方案里写了一个Redis队列的方案,任务提交后先推送到Redis的list里,worker通过BRPOP阻塞读取。这个方案性能无疑是最好的,判题吞吐量可以做到很高,但我最后删掉了这个方案,原因很现实:这个项目的运行环境里Redis不够稳定,有时候会因为内存不足被系统杀掉,一旦Redis不可用,整个判题入口就全部瘫了。
数据库版方案的出发点就是不做强依赖的中间件。MySQL是肯定要有的,因为题目和用户数据必须持久化,那我干脆让任务队列也在MySQL里实现。MySQL的可靠性比自建Redis高得多,而且有主从备份的话,判题任务也不会因为单库宕机就全部丢失。缺点就是任务扫描的效率低一些,但对于每分钟几十到几百提交的规模来说完全够用。
我在代码里写worker任务扫描的时候,用了SELECT ... FOR UPDATE SKIP LOCKED这个特性,这是MySQL 8.0提供的行级锁跳过机制。多个worker同时查询pending任务时,每个worker只会拿到属于自己的那一批,不会两个人拿到同一个任务,而且不需要额外引入分布式锁组件。这是我这个架构里比较核心的一个细节,后面会展开说。
3. 数据库设计实操:建表、状态机和并发控制
3.1 核心表结构设计:从ER图到能扛住并发的建表SQL
这次数据库设计我用了很长时间,尤其是submission表,它承担的功能太多,既是用户提交的记录,又是判题任务队列,还是判题结果存储。我先把最终版本的建表SQL放出来,再说几个关键的设计决策。
sql复制CREATE TABLE `t_user` (
`user_id` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(64) NOT NULL,
`password_hash` VARCHAR(128) NOT NULL,
`role` TINYINT NOT NULL DEFAULT 2 COMMENT '1-admin,2-user',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `t_problem` (
`problem_id` BIGINT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`description` MEDIUMTEXT,
`input_desc` TEXT,
`output_desc` TEXT,
`time_limit_ms` INT NOT NULL DEFAULT 1000,
`memory_limit_mb` INT NOT NULL DEFAULT 256,
`difficulty` TINYINT NOT NULL DEFAULT 1,
`created_by` BIGINT DEFAULT NULL,
PRIMARY KEY (`problem_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `t_submission` (
`submission_id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`problem_id` BIGINT NOT NULL,
`contest_id` BIGINT DEFAULT NULL,
`language` VARCHAR(20) NOT NULL,
`source_code` MEDIUMTEXT NOT NULL,
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-pending,1-judging,2-accepted,3-wrong-answer,4-time-limit,5-memory-limit,6-runtime-error,7-compile-error,8-system-error',
`pass_rate` DECIMAL(5,4) DEFAULT NULL,
`run_time_ms` INT DEFAULT NULL,
`run_memory_kb` INT DEFAULT NULL,
`compile_log` TEXT,
`judge_host` VARCHAR(64) DEFAULT NULL,
`judge_message` VARCHAR(500) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`judged_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`submission_id`),
KEY `idx_status_created` (`status`, `created_at`),
KEY `idx_user_problem` (`user_id`, `problem_id`),
KEY `idx_contest` (`contest_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `t_test_case` (
`case_id` BIGINT NOT NULL AUTO_INCREMENT,
`problem_id` BIGINT NOT NULL,
`input_data` MEDIUMTEXT,
`expected_output` MEDIUMTEXT,
`is_sample` TINYINT NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`case_id`),
KEY `idx_problem` (`problem_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
submission表是整张表里最值得琢磨的。状态字段我用了TINYINT而不是VARCHAR,最直接的原因是存储空间小,而且索引比较的时候更高效。0到8这九个状态码需要统一在代码的枚举里维护,数据库注释里也写了一行,不会出现靠猜的情况。
judge_host字段是后来加的,它记录的是哪个判题worker实例处理了这条提交。这个字段在排查问题时特别好用,如果某个提交在worker A上执行出错,在worker B上却正常,我可以直接对比是节点环境差异还是代码本身的随机性问题。另外在管理后台展示"每个判题节点今天的处理量"时,一条SQL就能统计出来,不用额外搞日志收集。
3.2 状态机设计:为什么不能从judging跳回到pending
submission的生命周期如果画成图,那就是:pending -> judging -> 终态。终态包括accepted、wrong answer、compile error等,一旦进入终态就不会再变化。这个状态机看起来简单,但正因为简单所以不容易出错。
我第一次设计的时候还加了一个"重新判题"的功能,管理端可以选中一条提交,把状态从accepted改成pending,触发重新判题。这个需求本身没问题,问题在于并发窗口:如果用户正在查看旧结果,管理员同时触发重判,前端就可能先看到accepted,再刷新看到pending,再刷新变成wrong answer,体验虽然奇怪但还说得过去。更怕的是,某个worker已经锁住了这条记录在跑,管理员把状态改回pending之后,另一个worker又抢到这条提交开始判,两个worker同时改一行记录的情况就出现了。
为了避免这种并发管理操作,我最后没有用update语句直接改状态,而是封装了一个管理端的重判服务。服务内部先查询这条submission当前状态,只有当它处于终态时,才允许更新为pending,并且更新的时候在where条件里带上旧状态作为乐观锁校验。如果有worker在这期间抢先改成了judging,那update的影响行数就是0,服务就会提示"该提交正在判题中,请勿重复操作"。
3.3 唯一约束和幂等控制:重复点击提交按钮引发的数据问题
用户连续点提交按钮,前端虽然做了按钮loading禁用,但总有人通过脚本绕过,或者网络重试导致同一条请求被发送两遍。如果没有后端兜底,数据库就会出现两条一模一样的提交,判题资源白白浪费,而且用户看到的提交历史也是重复的。
我的解决方案不算复杂,在插入submission前,先用一个request_id字段做唯一约束。前端在用户点击提交时生成一个UUID作为request_id,在提交请求体里带上。后端插入之前在同一个事务里先查一下有没有这个request_id的记录,如果有就直接返回那条已有提交的信息,否则才真正插入新记录。
sql复制CREATE TABLE `t_submission` (
...
`request_id` VARCHAR(64) NOT NULL,
...
UNIQUE KEY `uk_request_id` (`request_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里要注意的是,单纯先查再插也还是有竞态窗口,所以最保险的还是数据库层面唯一的uk_request_id。后端在插入时如果捕获到DuplicateKeyException,就说明是重复请求,查出已有记录返回给前端即可。这个方案看起来很简单,但解决的是用户能直接感知到的"我明明只提交了一次,为什么历史里多了好几条"的问题。做完之后,测试同学拿脚本并发点了几十次提交,重复数据一条都没有。
3.4 判题worker如何通过行锁安全地"抢"到任务,而不是靠运气
判题worker从数据库里领任务时,最难处理的问题是多个worker同时看到同一条pending记录。如果都用普通的SELECT,那么两个worker可能会拿同一条提交去判,造成计算资源浪费,而且结果回写时后写的会覆盖先写的,状态之间打架。
我用的办法是MySQL 8.0支持的SELECT ... FOR UPDATE SKIP LOCKED。简单解释一下:FOR UPDATE会给查询出来的行加排他锁,其他事务想改这些行会阻塞;SKIP LOCKED则是告诉MySQL,如果某行已经被别的事务锁了,就跳过它,去拿下一行没被锁的。这样多个worker像是排队从货架上拿货,谁拿到就是谁的。
我写了一个专门的Mapper方法:
xml复制<select id="fetchPendingSubmission" resultType="Submission">
SELECT *
FROM t_submission
WHERE status = 0
ORDER BY submission_id ASC
LIMIT 5
FOR UPDATE SKIP LOCKED
</select>
worker拿到这个查询结果后,在同一个事务里把这几条记录的状态更新成judging,同时设置judge_host为当前节点的主机名,然后提交事务。事务提交后锁被释放,其他worker再来查询时只能看到后面新进来的pending记录或者处于其他状态的任务,不会重复处理。
这个方案有一个小代价:被SKIP LOCKED跳过的任务不会立即被其他worker捡走,可能在第一批worker判完手里的任务、再次扫描时才会被处理掉。实际运行下来,任务在pending状态停留的时间从原来的平均30秒降到了不到1秒,几十个提交几乎瞬时就被三个worker瓜分完了。
4. 负载均衡的实现细节:Nginx与OpenFeign的分工
4.1 Nginx入口负载均衡实战配置:从IP_HASH到least_conn的调整
Nginx配置看着简单,但不同的负载均衡算法对体验的影响很大。一开始我图省事用了默认的轮询,结果发现用户上传代码的请求如果是大包体,A实例处理第一个请求时B实例处理第二个请求,就会导致同一个用户的会话被路由到不同的实例上。
虽然应用是无状态的,但有一个例外:文件上传的临时文件路径。如果代码特别长,前端可能用分片上传,两次分片请求落到了不同实例上,那第二个实例是找不到第一个实例写入的临时文件的。后来我把上传接口改成了内存接收后再写数据库,绕开了实例本地文件的问题,但为了保险,Nginx还是改成了least_conn算法。因为实例之间的性能不是完全一样的,有的机器CPU强一些,连接数少的节点应该优先分配新请求。
nginx复制upstream oj_backend {
least_conn;
server 10.0.0.11:8080 max_fails=3 fail_timeout=30s;
server 10.0.0.12:8080 max_fails=3 fail_timeout=30s;
server 10.0.0.13:8080 max_fails=3 fail_timeout=30s;
}
server {
listen 80;
server_name oj.example.com;
client_max_body_size 10m;
location /api/ {
proxy_pass http://oj_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
这里有个细节得注意,client_max_body_size不能省,不然大代码提交会直接返回413错误。我一开始没配置,测了一个2MB左右的项目文件上传,前端报错之后排查了半天才发现是Nginx默认1MB的限制。判题场景的代码虽然一般就几十KB,但万一以后支持上传测试数据文件,这个坑还是会踩。
4.2 OpenFeign内部的负载均衡是不是真的在"分发请求"
OpenFeign内部集成负载均衡这个说法不太准确,准确说应该是集成了Ribbon或者Spring Cloud LoadBalancer作为客户端负载均衡组件。当服务A通过OpenFeign调用服务B时,请求会从A所在进程发出去,OpenFeign从注册中心拿所有B实例的地址列表,然后按照负载均衡规则选一个地址来发HTTP请求。
这和Nginx的区别在于,Nginx是集中式负载均衡,所有流量都经过它中转;而OpenFeign的负载均衡发生在调用方进程内部,一个服务实例可以直接调用另一个服务实例,不需要经过额外的代理节点。
在我这个项目里,判题worker不需要被Web应用通过OpenFeign直接调用,所以我并没有给worker注册到Nacos或者Eureka。三个worker启动的时候只做一件事:连数据库、循环抢任务。它们之间不需要互相发现。管理端后台要看worker的运行状态时,我用了另一个方案,worker在心跳表里更新自己的心跳时间,管理端查数据库即可。
所以我加了OpenFeign依赖,但实际只在管理端服务需要调取某一个特定应用实例的JVM监控指标时用了它。比如管理员想查看节点A的实时内存占用,OpenFeign就带上负载均衡从注册中心拿到A的地址去请求。这个场景下OpenFeign的负载均衡能力确实用上了,但它服务的对象是管理端和业务实例之间的横向调用,而不是判题任务的分发。
4.3 实战中如何配置合理的超时和重试:不要让网关替用户无限等待
配置OpenFeign时有几个参数必须手动调,否则默认行为有一堆坑。首先是connectTimeout和readTimeout,OpenFeign默认连接超时是10秒,读超时是60秒,但判题场景里如果直接通过OpenFeign调用判题结果接口,60秒可能不够,因为判题本身有时就超过60秒。
不过再想想,其实不应该用HTTP同步调用来等判题结果,异步轮询才是正道。判题接口应该立刻返回,之后由前端轮询数据库状态。我把管理端的几个OpenFeign调用都设置成连接超时3秒、读超时5秒,因为管理端查询的都是监控类接口,超过这个时间还查不到就说明目标节点大概率有问题了。
重试策略也很关键。如果OpenFeign默认开启了重试,当某个实例请求失败后会自动尝试下一个实例,这本来挺好,但如果你调用的接口不是幂等的,比如"创建题目"或者"触发重新判题",重试可能导致数据被重复插入。我在Ribbon配置里关闭了对POST请求的重试,只允许GET请求做有限重试。
yaml复制ribbon:
MaxAutoRetries: 0
MaxAutoRetriesNextServer: 0
OkToRetryOnAllOperations: false
这两个0的意思是,同一个实例上不重试,换下一个实例也不重试。考虑到我的服务接口基本都有幂等设计,其实打开重试也问题不大,但保守起见还是不重试,保持行为最简单直接。
5. 判题并发控制:一次死锁问题的完整排查链路
5.1 现象:并发冲高后日志里频繁出现Deadlock
系统上线后的第二轮压测里,我把并发提交线程数从10个提到50个。刚开始跑得很顺,半分钟后日志里开始频繁抛出这么一段异常:
code复制Deadlock found when trying to get lock; try restarting transaction
一开始我以为是SELECT ... FOR UPDATE SKIP LOCKED和某个update语句之间产生了锁竞争,但仔细看异常堆栈,它发生在submission状态更新时,而且伴随的是t_submission和t_test_case两张表的同时更新。这让我有点意外,因为worker在处理单个提交时,判题逻辑里除了更新submission记录和读取test_case之外,并没有在同一个事务里去改test_case表的数据。
再往下查,发现在管理端后台有一个"题目一键录入"功能,它在一个事务里先把旧测试用例全部删除,再批量插入新测试用例,最后更新题目信息。而worker在判题时是先查题目表和测试用例表,再更新submission表。问题就出在这里:管理端的删除测试用例操作锁住了t_test_case表的某些行,worker查询时如果这些行刚好被锁住,就会等待;与此同时,管理端事务后面又因为其他逻辑要去更新submission表,submission表里正好有worker事务锁住的行。两个事务互相等对方释放锁,死锁就这么产生了。
5.2 排查过程:从异常日志到MySQL状态报告再到表字段
排查死锁问题,不能只靠猜。第一步是先把业务日志里所有相关操作的代码路径梳理出来,确认哪些SQL在同一个事务里执行、按什么顺序执行。然后我登录到MySQL命令行,执行了SHOW ENGINE INNODB STATUS,这个命令会输出最近一次死锁的详细信息,包括两个事务持有哪些锁、在等哪一行。
从输出能看到,事务1先对t_test_case表加锁,再去更新t_submission表;事务2先锁住了t_submission表的一行,再去读t_test_case表的索引范围。两个事务加锁的顺序正好相反,这几乎就是教科书级别的锁顺序相反导致的死锁。
还需要说明的是,InnoDB的锁不止加在具体的行上,还会加在索引上。t_submission表上的idx_status_created索引让worker扫描pending状态时可能锁住这段索引范围,而管理端按problem_id重判时用的是idx_user_problem索引路径,两条索引路径对同一行的加锁顺序也可能不一致。这类问题光看SQL很难发现,必须结合EXPLAIN看实际走了哪个索引。
5.3 修复方案:缩小事务边界 + 统一加锁顺序 + 必要的重试兜底
修复死锁有三个层面,我层层都做了。第一层是缩小事务边界,worker领取任务时,SELECT ... FOR UPDATE SKIP LOCKED和状态更新必须在一个事务里,因为要保证从锁定到标记judging之间没有其他worker插进来。但之后的判题过程,也就是编译、运行、对比输出,这个漫长时间段绝对不能放在同一个事务里,否则每行submission上的锁要持有好几秒,并发一高就完蛋。
我把事务切成了两个:事务A只负责领取任务并更新为judging,然后立刻提交;事务B在判题结束之后把结果更新回数据库。这样每把锁的持有时间从原来的几秒缩短到了几十毫秒,死锁概率直线下降。
第二层是统一加锁顺序。管理端修改题目和测试用例时,我让它在任何事务里都先操作t_submission相关的更新,再操作t_test_case,或者干脆把两个操作拆成两个独立事务。最终我选择了后者,管理端先删除旧测试用例提交事务,再插入新测试用例提交事务,最后更新题目标题等信息。虽然管理端操作变成多次事务后如果中途出错可能需要人工补偿,但这一步牺牲换取的是核心判题链路完全不受后台编辑影响。
第三层是重试兜底。即便做了前两层,数据库在极端情况下还是可能抛出死锁,不建议完全无视这个异常。我封装了一个retryOnDeadlock的注解,用Spring的AOP捕捉死锁异常后,对方法进行最多三次重试。重试前会随机sleep几十毫秒,避免多个事务立即重新竞争同一批锁。
为了验证修复效果,我重新跑了一次50并发压测,连续跑了20分钟,死锁日志一条都没有出现。后来我又故意模拟管理端反复编辑题目和worker大量判题同时发生的场景,也只出现了一个死锁,而且被重试机制自动消化了。
6. 压测数据、部署细节和几个值得反复确认的配置
6.1 压测方法与结果:数据库连接池到底设多大才算合理
压测不能只看平均响应时间,更要看系统的吞吐量上限和瓶颈位置。我用了JMeter模拟用户提交代码,三种题型的代码混合在一起,C++、Java、Python各占一部分,并发数从10、30、50依次增加,每组跑5分钟。
压测期间我用Prometheus监控了三个关键指标:应用实例的CPU、MySQL的活跃连接数、pending状态任务的积压数。结果非常清晰:50并发时,应用实例CPU占用只有40%左右,但MySQL的活跃连接数一度飙到80以上,接近我设置的连接池最大值100,而pending任务积压在高峰期最多到了六七十条,说明系统的瓶颈从Web层转移到了数据库层。
数据库连接池的设置有个常见的误区,不是越大越好。每个连接在MySQL端都是一个线程,如果连接池设置100,MySQL允许的最大连接数是200,那当多个应用实例同时占用100个连接时,管理端口和备份工具都会连不上数据库。我最终把每实例连接池设成50,三个实例加起来150,同时把MySQL的max_connections调到300,留出一定余量。
HikariCP的参数我也做了调整,maximumPoolSize=50,minimumIdle=10,connectionTimeout=30000。最关键的是maxLifetime要小于MySQL的wait_timeout,我设成180000毫秒,也就是三分钟轮换一次连接,避免MySQL主动断开空闲连接后应用还在使用旧连接导致的CommunicationsException。
6.2 判题执行与事务提交的时序细节:别把长任务关在事务里
这是我在代码评审时盯着看最久的一个问题。网上很多OJ教程写的伪代码是:开启事务 -> 查询submission -> 编译代码 -> 执行测试用例 -> 更新结果 -> 提交事务。这个逻辑没错,但只适用于判题本身就几十毫秒的极端简单场景。真实场景下,C++代码编译可能要两秒,Java启动JVM可能要三秒,如果每个测试用例都要跑,最长能到十来秒。在这段时间里,事务一直开着,数据库连接一直被占用,锁一直不释放。
我把代码调整成上面说的两段式事务结构后,顺手还处理了一个隐藏的脏数据问题:worker在事务A把状态更新成judging后提交,紧接着用户或者管理员如果想取消这条判题,数据库显示的状态是judging,但也可能此刻worker进程已经崩了,这条submission会永远停在judging状态,没人处理。我加了一个定时任务,每两分钟扫描一次状态为judging但心跳时间超过五分钟的记录,强制把它们重置回pending,让其他worker可以重新领取。这个机制有点像领导检查团队工作,发现有人接单超过约定时间没交付,就取消指派重新派单。
6.3 部署过程中的几个坑:时区、字符集、文件句柄
部署阶段踩过的坑不算多,但每个都值得记录下来。第一个是MySQL连接的时区问题。服务器系统时区是UTC,应用默认时区是中国标准时间,插入的created_at看起来正好,但查询当天数据时按本地时间一换算,边界就差了八小时。我最终在JDBC连接串里显式设置了serverTimezone=Asia/Shanghai,这样从连接到会话统一走中国标准时间,不会再出幺蛾子。
第二个是字符集。建表时所有表我都指定了CHARSET=utf8mb4,但测试用例里如果包含emoji或者特殊符号,连接层假如没设置characterEncoding=utf8,插入时可能出现乱码或者报错。我在Spring Boot的数据源配置里加了characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true,从源头杜绝了字符集不一致。
第三个是Linux文件句柄限制。判题沙箱运行用户代码时会频繁创建临时文件和删除临时文件,如果操作系统的ulimit -n默认是1024,高并发时很容易出现Too many open files错误。我把这个值调到了65535,同时确保每个判题worker启动脚本里也执行了同样的设置。
6.4 压测数据汇总:数据库版和旧版对比,值得注意的收益
说了这么多理论,放一组压测数据出来更有说服力。旧版单机OJ在30并发提交时,响应时间从提交到出结果平均为18秒,失败率约11%,应用进程崩溃两次。新版数据库版架构跑在相同压力下,三个worker实例挂载同一个MySQL数据库,平均判题耗时只有4.2秒,失败率降到了0.3%,主要失败是用户代码本身编译问题,没有出现系统层面的错误。
单看这个数字会有点误导。新版之所以快,并不全是加了两台机器那么简单,更关键的是旧版同步判题把Web请求线程和CPU、IO混在一起,而新版把判题挪到独立的worker进程里,数据库连接、临时文件、进程数都可以单独做资源配置。负载均衡加数据库,不是简单的"多加几个服务,请求分流",而是让每个实例只需要做好一件事,然后让数据库来保证整体的一致性。
7. 一些个人收尾的经验:课程设计、面试和真正上线之间的距离
最后的最后,说点我在这个项目里感悟最深的东西。很多人做在线OJ,会把大部分精力放在沙箱逃逸防护和编译参数上,这当然重要,但从一个工程项目的角度看,真正决定系统能不能稳定跑起来的是数据流设计。你写了一个再完美的判题器,如果提交任务在系统重启后会丢,如果两个判题实例会重复处理同一条提交,如果数据库在高并发下会死锁,那整个平台依然是不可用的。
数据库版OJ给我最大的价值,是逼着我从数据一致性的视角重新审视了整个系统。判题不再是一段"调用编译器然后返回结果"的本地函数,而是一条在生产环境里要面对并发、失败、重试的完整数据链路。想清楚"谁负责写入初始状态""谁负责推进状态变化""状态变更失败怎么办"这三个问题,不仅这个项目做出来了,之后做任何异步任务系统都能较快上手。
如果你也想做类似的系统,我建议先从小规模开始,把submission表的状态机调对,把SELECT ... FOR UPDATE SKIP LOCKED的行为吃透,再把Nginx、多实例部署、OpenFeign一个个加进去,每一步都跑通后再进入下一环。不要一上来就堆Kafka、Redis、K8s那一套,对于课程设计和中等规模的OJ场景,一个MySQL加上合理的并发控制,已经完全够用了,而且它反而是你理解分布式系统问题最好的教材。
