C++构建高并发在线判题系统:负载均衡、安全沙盒与分布式架构实战

C++构建高并发在线判题系统:负载均衡、安全沙盒与分布式架构实战 1. 项目概述一个能扛住高并发的在线判题系统做在线OJOnline Judge系统听起来像是力扣、牛客这些大平台的“玩具版”但当你真正动手用C去实现一个带负载均衡的版本时你会发现这远不止是“玩具”。它本质上是一个高并发、高可靠性的分布式任务处理系统。核心挑战在于当几百上千个用户同时提交代码时你的服务器如何不崩溃如何公平、高效地分配计算资源并快速给出判题结果。这背后涉及网络编程、进程控制、并发安全、状态同步等一系列硬核的C后端开发问题。我之所以选择C来实现而不是更“流行”的Java或Go有几个现实的考量。首先判题的核心——编译和运行用户代码尤其是C/C代码本身需要与操作系统底层紧密交互如fork、exec、setrlimit、ptrace等C在这方面有天然的优势性能损耗最小。其次整个系统的性能瓶颈往往在任务调度和进程管理上C的零成本抽象和高效的内存控制能让我们在有限的服务器资源下支撑更高的并发量。最后这是一个绝佳的练手项目能让你把操作系统、计算机网络、数据结构和算法的知识串起来直面真实的生产环境问题。这个项目适合有一定C基础至少熟悉STL、多线程对Linux系统编程和网络编程感兴趣想挑战后端高并发场景的开发者。通过它你不仅能学会如何搭建一个OJ更能深刻理解负载均衡、资源隔离、状态机设计这些在分布式系统中通用的核心思想。2. 系统核心架构与设计思路拆解一个负载均衡在线OJ不能只是一个单点的程序。我们需要把它拆解成多个松耦合的、各司其职的服务这样才能方便扩展和容错。经过多次迭代我最终采用的架构可以概括为“前后端分离 多服务协同”。2.1 整体架构模块划分整个系统主要由五大模块构成它们通过网络通信通常是HTTP或自定义TCP协议进行协作前端Web服务器负责提供用户交互界面。用户在这里浏览题目、提交代码、查看提交历史和排名。它本身不处理判题逻辑只作为一个“客户端”将用户请求转发给后端网关。技术上可以用任何你熟悉的框架比如Vue.js Nginx这部分不是C的重点但我们需要为其提供清晰的API接口。负载均衡器 / API网关这是系统的“交通枢纽”。所有从前端来的判题请求都先到达这里。它的核心职责是请求路由根据预设的策略如轮询、最小连接数等将判题请求分发给后端的多个判题机。健康检查定期探测后端判题机的状态是否存活、负载是否过高自动剔除故障节点保证服务可用性。流量整形与限流防止恶意用户提交海量代码耗尽系统资源。判题核心服务这是系统的“大脑”负责管理判题任务的生命周期。它接收来自网关的判题请求然后协调判题机执行。其主要功能包括任务队列管理将待判题的任务放入队列实现异步处理避免请求堆积。任务调度从队列中取出任务根据判题机的负载情况分配任务。状态维护与回调跟踪每个任务的状态等待、编译中、运行中、完成并在判题完成后将结果写回数据库并通知前端。判题机集群这是系统的“肌肉”是实际执行编译、运行、比对代码的“工人”。每个判题机都是一个独立的进程或容器运行在独立的沙盒环境中。一个集群由多台判题机组成从而实现水平扩展。这是C代码的核心所在包含了最复杂的逻辑进程创建、资源限制、安全沙盒、信号处理等。数据存储服务包括数据库和文件存储。数据库存储用户信息、题目描述、测试用例、提交记录、判题结果等结构化数据。常用MySQL或PostgreSQL。文件存储存储用户提交的源代码文件、题目预设的输入输出测试用例文件。可以用本地磁盘目录也可以接入对象存储服务。设计思路核心这个架构的关键在于“解耦”和“无状态”。判题机是无状态的它只负责接收任务、执行、返回结果不保存任何用户会话信息。所有状态都保存在数据库和任务队列中。这样任何一台判题机宕机负载均衡器都可以将新任务路由到其他健康的机器上新扩容的判题机也能立即投入工作实现了高可用和可扩展性。2.2 为什么选择“中心调度”而非“对等”模式你可能想过能不能让负载均衡器直接和判题机通信去掉中间的“判题核心服务”理论上可以但这会让负载均衡器的逻辑变得异常复杂。它不仅要管路由还要管任务队列、状态持久化、超时重试等。这违反了“单一职责原则”。“判题核心服务”的引入使得系统层次更清晰网关层专注网络流量治理。服务层专注业务逻辑和状态管理。执行层专注安全、稳定地运行代码。这种模式也更容易实现功能的扩展比如未来增加代码相似度检测、比赛实时排名计算等模块都可以作为独立服务接入到“判题核心服务”周围。3. 核心细节解析判题机的实现要点判题机是整个系统中最复杂、最易出错的部分。它的任务是在一个受控的、安全的环境下编译并运行用户提交的、可能是恶意的代码。3.1 安全沙盒隔离与限制这是判题机的生命线。绝对不能允许用户代码访问系统文件、发起网络请求、无限占用资源或攻击宿主机器。系统调用过滤使用ptrace或seccomp来限制子进程可以执行的系统调用。例如禁止fork,execve防止执行新程序禁止socket相关的调用防止网络通信禁止open写操作防止篡改文件。ptrace更灵活但性能开销大seccomp性能更好但配置相对固定。对于OJ场景seccomp通常是更优选择。// 示例使用 seccomp 禁止网络和文件创建 #include seccomp.h scmp_filter_ctx ctx seccomp_init(SCMP_ACT_ALLOW); // 默认允许所有 seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(socket), 0); seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(connect), 0); seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(open), 1, SCMP_A0(SCMP_CMP_MASKED_EQ, O_WRONLY|O_RDWR, O_WRONLY|O_RDWR)); seccomp_load(ctx); seccomp_release(ctx);资源限制使用setrlimit函数严格限制子进程的资源使用防止“炸弹代码”。CPU时间(RLIMIT_CPU)限制程序运行的最长时间超时即判TLE时间超限。内存(RLIMIT_AS或RLIMIT_DATA)限制虚拟内存或数据段大小超限判MLE内存超限。输出大小(RLIMIT_FSIZE)限制程序输出文件的大小防止刷屏攻击。进程数(RLIMIT_NPROC)限制子进程数量防止fork炸弹。#include sys/resource.h void set_limit(int resource, rlim_t limit) { struct rlimit rl; rl.rlim_cur limit; rl.rlim_max limit; if (setrlimit(resource, rl) ! 0) { // 处理错误通常意味着判题系统本身权限不足或设置失败 perror(setrlimit failed); _exit(1); } } // 在 fork 出的子进程中调用 set_limit(RLIMIT_CPU, 2); // 最多运行2秒 set_limit(RLIMIT_AS, 256 * 1024 * 1024); // 限制内存256MB运行环境隔离使用chroot或容器技术如 Docker将判题进程限制在一个独立的文件系统目录中该目录只包含必要的编译器、库文件和空的输入输出文件。这是最高级别的隔离但实现也最复杂。对于学习项目可以先用严格的资源限制和系统调用过滤。3.2 编译与运行流程判题机的工作流程是一个典型的状态机准备阶段收到任务后为本次判题创建一个唯一的工作目录如/tmp/judge_task_id将用户源代码例如main.cpp和题目的输入测试用例文件放入该目录。编译阶段fork()出一个子进程。在子进程中调用setrlimit设置编译的资源限制通常比运行限制宽松但也要防止恶意编译指令。使用exec系列函数调用编译器如g main.cpp -o main -O2 -stdc11。父进程使用waitpid等待子进程结束并收集其退出状态。如果编译失败退出状态非零则读取编译器输出的错误信息通常重定向到了文件将其作为判题结果Compile Error返回流程结束。运行阶段对每个测试用例再次fork()出一个子进程。在子进程中 a. 调用setrlimit设置严格的运行资源限制。 b. 调用seccomp加载安全规则。 c. 重定向标准输入(stdin)到测试用例输入文件。 d. 重定向标准输出(stdout)和标准错误(stderr)到临时输出文件。 e. 使用exec执行编译好的用户程序。父进程使用waitpid等待但需要处理超时。这里不能傻等通常的做法是 a. 在fork后父进程记录子进程PID和开始时间。 b. 使用一个单独的监控线程或者用alarm信号或者用select/poll等待管道更复杂来实现超时控制。 c. 如果超时则向子进程发送SIGKILL信号强制终止判为TLE。根据waitpid返回的状态判断结果正常退出 (WIFEXITED) 且返回值为0进入下一步“答案比对”。被信号终止 (WIFSIGNALED)如SIGSEGV是段错误 (Runtime Error)SIGXCPU是超时 (TLE但通常我们主动杀进程)。资源超限通过检查waitpid返回的状态和rusage结构体判断是MLE还是其他限制。答案比对将用户程序输出的临时文件与题目的标准答案文件进行比对。比对不是简单的字符串相等OJ通常支持全文对比完全一致。忽略行末空格和文末空行使用diff -b或diff -B的逻辑自行实现。Special Judge对于答案不唯一的题目需要编写一个校验程序来评判。清理阶段无论成功与否都必须删除临时的工作目录释放磁盘空间。这是一个容易忽略但至关重要的步骤否则服务器磁盘很快会被撑满。实操心得处理僵尸进程和资源泄漏这是判题机最常见的坑。一定要确保每个fork出来的子进程都被waitpid回收。如果父进程在子进程结束前异常退出子进程会变成僵尸进程。更严重的是如果超时杀进程的逻辑有bug可能导致子进程没被杀死一直在后台运行。我的经验是使用signal(SIGCHLD, SIG_IGN)忽略子进程结束信号让内核自动回收但这样你就无法获取子进程的详细状态了。更好的方法是在父进程设计一个稳健的信号处理函数或监控循环确保对所有子进程调用waitpid(-1, status, WNOHANG)进行非阻塞回收。在fork后子进程中可以再次fork一个孙进程来执行任务然后子进程立即退出。这样孙进程会被 init 进程接管避免僵尸进程但获取状态会更麻烦。这需要根据你的监控方案权衡。4. 负载均衡策略与任务队列的实现负载均衡器或网关如何决定把新任务发给哪台判题机判题核心服务如何管理海量的待处理任务这是保证系统吞吐量和响应速度的关键。4.1 负载均衡算法选型判题任务属于计算密集型、短时任务通常2-10秒。我对比了几种常见策略策略原理优点缺点适用场景轮询按顺序将请求依次分发给每台判题机。实现简单绝对公平。不考虑服务器实际负载可能把任务分给已经满载的机器。判题机性能完全一致且任务计算量均匀的场景。加权轮询给性能强的判题机更高的权重获得更多任务。能利用好高性能机器。配置静态无法应对动态负载变化。判题机集群硬件配置不一致时。最小连接数将新请求发给当前正在处理的任务数最少的判题机。动态感知负载分配相对均衡。“连接数”不等于CPU/内存负载。一个复杂任务可能占满CPU但只算一个连接。OJ场景推荐。能较好地反映判题机“忙碌”程度。最短响应时间将请求发给历史平均响应时间最短的机器。倾向于选择处理快的机器。需要收集和计算响应时间有延迟。对于突发慢查询不敏感。任务处理时间差异大且希望优化整体响应时间的场景。对于我们的OJ系统最小连接数策略是一个不错的起点。它实现起来不难且能基本反映负载。我们可以在判题机上暴露一个HTTP接口/status返回当前负载如正在判题的任务数、CPU负载、内存使用率。负载均衡器定期如每秒拉取所有判题机的状态并基于此做决策。4.2 任务队列的设计与选型判题核心服务必须有一个任务队列来缓冲瞬时的高并发提交。队列的设计直接影响系统的可靠性和性能。内存队列最简单用std::queue或std::deque加锁实现。优点是快。缺点是致命的服务一旦重启或崩溃所有排队中的任务全部丢失。绝对不适用于生产环境仅可用于原型验证。基于数据库的队列在数据库中建一张judge_tasks表用状态字段如PENDING,JUDGING,DONE和更新时间来实现队列。判题核心服务轮询查询statusPENDING的任务。优点是数据持久化不会丢任务。缺点是性能瓶颈在数据库频繁的轮询和锁竞争在高并发下会成为灾难。专业的消息队列这是生产级的解决方案。引入一个独立的中间件如Redis或RabbitMQ。Redis使用其List数据结构作为队列LPUSH生产任务BRPOP阻塞消费任务。性能极高且支持持久化可配置。还可以用Sorted Set实现优先级队列。RabbitMQ功能更强大的消息队列支持多种消息模式、ACK确认、持久化、集群等但比Redis重一些。我的选择是 Redis。理由如下性能足够对于OJ场景QPS每秒查询率不会像电商那么恐怖Redis单机足以应对。数据结构丰富除了做队列还可以用来缓存题目信息、用户会话如果前端需要以及存储判题机的实时状态用Hash一举多得。实现简单C有很好的Redis客户端库如hiredis接入非常方便。// 伪代码示例使用 hiredis 从 Redis 队列消费任务 #include hiredis/hiredis.h redisContext *c redisConnect(127.0.0.1, 6379); if (c ! NULL c-err) { // 处理连接错误 } // 生产者将任务JSON字符串推入队列 redisCommand(c, LPUSH judge_queue %s, task_json.c_str()); // 消费者阻塞式弹出任务 redisReply *reply (redisReply*)redisCommand(c, BRPOP judge_queue 0); if (reply-type REDIS_REPLY_ARRAY reply-elements 2) { std::string task_data reply-element[1]-str; // 解析 task_data, 开始判题流程 } freeReplyObject(reply);注意事项任务状态与幂等性使用消息队列后判题核心服务可能有多台实例同时消费任务。这就必须考虑并发安全和幂等性。状态竞争两个服务同时抢到同一个任务怎么办Redis 的BRPOP是原子的一个任务只会被一个消费者拿到。这是安全的。任务重复执行如果判题服务在处理任务时崩溃该任务可能既没有被标记完成也没有被放回队列导致丢失。或者网络问题导致生产者重复投递。因此任务本身要有一个全局唯一的ID在判题前先检查数据库看这个ID的任务是否已经被处理过幂等性检查。结果回写判题完成后将结果写回数据库时要用任务的唯一ID作为条件去更新避免覆盖其他实例写入的结果。5. 网络通信与API设计各个服务之间需要通过网络进行通信。设计一套简洁、高效的通信协议是项目联调成功的关键。5.1 协议选择HTTP/RESTful vs 自定义TCPHTTP/RESTful API这是目前最主流、最通用的方式。优点显而易见通用性强任何语言、任何平台都能方便地调用和调试用curl或 Postman。生态完善有成熟的C库如cpp-httplib,drogon,libhv快速搭建服务。易于监控方便接入日志、监控系统。缺点是有一定的头部开销但对于OJ系统这不是瓶颈。自定义TCP二进制协议可以设计得极其高效字节开销最小。但缺点更多开发调试复杂需要自己定义报文格式、编解码、粘包处理等。客户端和服务端耦合紧不易扩展。除非性能是压倒性需求否则不推荐。因此我强烈建议使用 HTTP/JSON 作为服务间通信协议。判题核心服务对外提供如下几个关键API端点POST /api/judge提交判题任务。// 请求体 { task_id: unique_uuid_123, user_id: 1001, problem_id: 2001, language: cpp, source_code: #include iostream..., time_limit: 1000, // ms memory_limit: 262144 // KB }GET /api/status/:task_id查询某个任务的判题状态和结果。POST /api/judge_machine/heartbeat判题机定时上报心跳和负载信息。GET /api/judge_machine/status负载均衡器获取所有判题机状态。5.2 使用现代C网络库以cpp-httplib为例过去用C写HTTP服务很麻烦现在有了像cpp-httplib这样的单头文件库事情变得简单多了。// 判题核心服务的简易HTTP服务器示例 #include httplib.h #include json/json.h // 使用 jsoncpp 库解析JSON int main() { httplib::Server svr; // 提交判题任务 svr.Post(/api/judge, [](const httplib::Request req, httplib::Response res) { Json::Value root; Json::Reader reader; if (!reader.parse(req.body, root)) { res.status 400; // Bad Request res.set_content(Invalid JSON, text/plain); return; } std::string task_id root[task_id].asString(); std::string source_code root[source_code].asString(); // 1. 参数校验 // 2. 将任务推入Redis队列 // 3. 立即返回告知任务已接受 Json::Value resp; resp[code] 0; resp[msg] Task accepted; resp[data][task_id] task_id; Json::StreamWriterBuilder writer; res.set_content(Json::writeString(writer, resp), application/json); }); // 查询任务状态 svr.Get(R(/api/status/([a-f0-9\-])), [](const httplib::Request req, httplib::Response res) { std::string task_id req.matches[1]; // 从数据库查询该 task_id 的状态和结果 // ... Json::Value resp; resp[code] 0; resp[data][status] Judging; // 或 Accepted, Compile Error等 resp[data][result] ...; // 返回JSON }); svr.listen(0.0.0.0, 8080); // 判题核心服务监听8080端口 return 0; }负载均衡器如Nginx的配置则负责将前端的请求转发到这个判题核心服务并实现负载均衡算法。# Nginx 负载均衡配置示例 (nginx.conf) http { upstream judge_core_servers { # 最小连接数策略 least_conn; server 192.168.1.101:8080; server 192.168.1.102:8080; server 192.168.1.103:8080; # 可以加权重: server 192.168.1.104:8080 weight3; } server { listen 80; server_name oj.yourdomain.com; location /api/judge { proxy_pass http://judge_core_servers; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /api/status/ { proxy_pass http://judge_core_servers; } # 静态前端文件服务 location / { root /path/to/your/frontend/dist; index index.html; } } }6. 数据库设计与关键业务逻辑数据是系统的基石。糟糕的数据库设计会让后续开发举步维艰。6.1 核心表结构设计这里列出最核心的几张表用户表 (users)CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password_hash CHAR(64) NOT NULL, -- 存储SHA256等哈希值切勿存明文 email VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );题目表 (problems)CREATE TABLE problems ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, description TEXT, -- 题目描述支持Markdown input_desc TEXT, -- 输入说明 output_desc TEXT, -- 输出说明 time_limit INT NOT NULL DEFAULT 1000, -- 毫秒 memory_limit INT NOT NULL DEFAULT 65536, -- KB difficulty ENUM(easy, medium, hard), is_public BOOLEAN DEFAULT TRUE, created_by INT, -- 关联出题人 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) );测试用例表 (test_cases)与题目是多对一关系。强烈建议将大型的输入输出文件存在磁盘或对象存储数据库中只存路径和元信息。CREATE TABLE test_cases ( id INT PRIMARY KEY AUTO_INCREMENT, problem_id INT NOT NULL, input_file_path VARCHAR(500), -- 输入文件路径 output_file_path VARCHAR(500), -- 输出文件路径 is_sample BOOLEAN DEFAULT FALSE, -- 是否是样例 order INT DEFAULT 0, -- 执行顺序 FOREIGN KEY (problem_id) REFERENCES problems(id) ON DELETE CASCADE );提交记录表 (submissions)这是最核心的业务表。CREATE TABLE submissions ( id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 可能很大用BIGINT task_id VARCHAR(36) UNIQUE, -- 对应判题任务ID user_id INT NOT NULL, problem_id INT NOT NULL, language VARCHAR(20) NOT NULL, -- cpp, java, python source_code TEXT, -- 或存文件路径 status ENUM(Pending, Judging, Accepted, Wrong Answer, Time Limit Exceeded, Memory Limit Exceeded, Runtime Error, Compile Error, System Error) DEFAULT Pending, time_used INT DEFAULT 0, -- 毫秒 memory_used INT DEFAULT 0, -- KB judge_info TEXT, -- 存储编译错误信息、错误详情等JSON submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_problem (user_id, problem_id), -- 常用查询 INDEX idx_status (status), -- 后台查看排队任务 FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (problem_id) REFERENCES problems(id) );6.2 判题结果回写与一致性判题机完成任务后需要将结果写回submissions表。这个过程要保证原子性和最终一致性。判题核心服务写库判题机将结果返回给判题核心服务由核心服务统一写数据库。这是最清晰的方式逻辑集中。判题机直连数据库写不推荐。这需要每台判题机都有数据库连接权限增加了安全风险也使得数据库连接数暴增。通过消息队列传递结果判题机将结果发到另一个结果队列由专门的结果处理服务消费并写库。这进一步解耦但系统复杂度也增加了。对于中小型项目采用第一种方式即可。关键代码逻辑如下// 在判题核心服务中处理判题机返回的结果 void handle_judge_result(const JudgeResult result) { // 1. 使用数据库事务 mysqlxx::Transaction trans(connection); try { // 2. 根据 task_id 更新 submissions 表 mysqlxx::Query query connection.query(); query UPDATE submissions SET status ?, time_used ?, memory_used ?, judge_info ? WHERE task_id ?; query result.status; query result.time_used; query result.memory_used; query result.judge_info_json; // 序列化后的JSON字符串 query result.task_id; query.execute(); // 3. 如果判题通过Accepted可能需要更新用户的解题数等统计信息 if (result.status Accepted) { update_user_solve_count(result.user_id, result.problem_id); } trans.commit(); // 提交事务 } catch (const std::exception e) { trans.rollback(); // 回滚事务 // 记录错误日志可能需要将任务重新放回队列或标记为系统错误 logger-error(Failed to update judge result for task {}: {}, result.task_id, e.what()); } }避坑技巧数据库连接管理判题核心服务需要频繁访问数据库。一定要使用连接池不要为每个HTTP请求都创建和销毁一个数据库连接这会在高并发下迅速拖垮数据库。可以使用mysql-connector-cpp自带的管理功能或者第三方库如sqlpp11配合连接池。同样Redis客户端也应该使用连接池。7. 部署、监控与性能调优系统开发完了怎么让它稳定跑起来怎么知道它运行得好不好7.1 服务部署与进程管理建议将每个服务负载均衡器、判题核心、判题机都部署为独立的systemd service。这能提供开机自启、自动重启、日志管理等功能。# /etc/systemd/system/judge-core.service [Unit] DescriptionOnline Judge Core Service Afternetwork.target redis.service mysql.service [Service] Typesimple Userjudge WorkingDirectory/opt/online-judge/core ExecStart/opt/online-judge/core/judge_core_server Restartalways RestartSec3 StandardOutputjournal StandardErrorjournal [Install] WantedBymulti-user.target判题机可以批量部署在多台机器上或者在同一台机器上启动多个实例绑定不同端口。使用Docker容器化部署是更优雅的方案能提供更好的环境隔离和资源限制。7.2 监控与日志没有监控的系统就是在“裸奔”。日志每个服务都要打日志。使用像spdlog这样的现代C日志库按级别info, warn, error输出到文件并做好日志轮转如每天一个文件保留7天。关键信息必须记录任务ID、用户ID、判题机ID、开始结束时间、资源使用量、错误详情。基础监控使用PrometheusGrafana。在判题核心服务和判题机中暴露一个/metrics端点提供Prometheus格式的指标数据。指标可以包括judge_requests_total总判题请求数。judge_requests_in_flight正在处理的请求数。judge_duration_seconds判题耗时分布直方图。judge_results_total{statusaccepted}按结果分类的计数。Grafana用来绘制漂亮的图表监控QPS、成功率、平均耗时、判题机负载等。告警设置告警规则。例如当判题队列积压超过100个任务或者判题失败率连续5分钟超过5%时通过邮件、钉钉、Slack等渠道告警。7.3 性能调优实战经验判题机瓶颈通常是编译阶段。尤其是C代码每次提交都从头编译g非常耗时。可以引入编译缓存比如ccache。为每个语言/编译器版本维护一个缓存目录能极大提升重复或相似代码的编译速度。数据库瓶颈submissions表会快速增长。需要定期归档旧数据或者做分表。对user_id,problem_id,status字段建立合适的联合索引能极大提升查询速度。文件IO瓶颈频繁读写测试用例和用户代码文件。考虑使用SSD硬盘或者将文件存储在内存文件系统如/dev/shm中。判题机的工作目录可以放在内存盘上判完即丢速度极快。网络瓶颈判题机和核心服务之间如果传输大的测试用例文件比如某些大数据题可能会成为瓶颈。可以考虑让判题机直接从共享存储如NFS或对象存储如MinIO读取测试用例而不是由核心服务推送。并发控制一台判题机不要同时运行太多任务否则会因CPU竞争导致每个任务都变慢。根据CPU核心数设置合理的并发度。例如4核机器可以设置同时判2-3个任务。我在实际部署中将判题机的工作目录挂载到内存盘并使用了ccache对于常见的算法题提交编译时间从平均1.5秒下降到了0.3秒以下整体判题吞吐量提升了近40%。同时为submissions表增加了(user_id, problem_id, status)的联合索引后用户查询自己某道题的提交记录页面响应时间从原来的上百毫秒降到了个位数毫秒。这些看似微小的优化累积起来对用户体验的提升是巨大的。