
刷题系统从 0 到 1 的技术选型复盘为什么选这些组件一、深度引言与场景痛点当想刷题变成想造一个刷题平台新手程序员都有一个困惑市面上刷题平台那么多为什么要自己造一个事情的起因很朴素——现有的平台要么题目质量参差不齐要么判题结果反馈太慢要么缺少对个人学习轨迹的有效追踪。更关键的是在做项目复盘时你会发现面试官真正想听的不是我用了某个平台刷了 300 题而是我拆解了一个刷题系统的完整链路并且自己实现了核心模块。对于一个正在经历转正考察的后端实习生来说这个项目的价值不仅仅在于技术栈的实践更在于技术选型能力的展现。选型是后端工程师的核心能力之一——你需要在功能需求、性能指标、团队能力和维护成本之间找到平衡点。选错了组件后续的开发效率和系统稳定性都会受到持续影响。从这个角度出发我们来完整复盘一个在线判题系统Online Judge的技术选型过程。这个系统需要支持用户提交代码、服务端编译运行、判题结果返回、题目管理、用户进度追踪等核心功能。每个环节都有多种技术方案可选本文将逐一分析为什么选这个而不是那个。二、底层机制与原理深度剖析关键组件的选择逻辑技术选型不能凭直觉必须有量化的评估维度。我们采用了以下 5 个维度来评估每个候选组件成熟度社区活跃度、Issue 响应速度、大厂使用案例性能在预期负载下的吞吐量和延迟表现学习成本团队上手需要的时间可扩展性是否能支撑未来功能迭代运维复杂度部署、监控、故障排查的难易程度以下是我们核心模块的技术选型决策链路后端框架为什么用 Spring Boot 而不是 Go Gin这是一个反复被讨论的问题。我同时用 Java 和 Go 写过服务端代码最终选择 Spring Boot 的理由有三判题沙箱的进程管理需求Java 的ProcessBuilder配合Runtime.exec在子进程生命周期管理上生态更成熟。Go 的os/exec虽然简洁但在资源限制cgroup 挂载方面的社区方案还不够丰富。数据库 ORM 的成熟度JPA/Hibernate 在处理复杂的题库元数据关系题目-标签-测试用例-提交记录的多表关联时比 Go 的 GORM 有更完善的事务管理和懒加载策略。团队技术栈团队主力是 Java选择 Spring Boot 可以降低 code review 的摩擦成本。数据库为什么用 PostgreSQL 而不是 MySQL这道题的关键差别在于判题结果数据的存储需求判题结果中经常包含 JSON 格式的详细信息如每个测试用例的耗时、内存占用、错误堆栈。PostgreSQL 的JSONB 类型支持索引和高效查询而 MySQL 的 JSON 类型在 8.0 之前不支持索引。PostgreSQL 的窗口函数和 **CTE公共表表达式**能更优雅地实现用户最近 N 次提交的正确率趋势这类分析查询。三、生产级代码实现与最佳实践下面展示判题服务的核心骨架代码重点说明架构设计的意图// 判题服务入口 —— 使用策略模式解耦不同语言的判题逻辑 Service public class JudgeService { // 利用 Spring 的依赖注入将所有语言判题器注入到 Map 中 // 这样新增语言时只需添加一个新的 Bean无需修改此处代码 private final MapString, LanguageJudge judgeMap; public JudgeService(ListLanguageJudge judges) { this.judgeMap judges.stream() .collect(Collectors.toMap(LanguageJudge::getLanguage, Function.identity())); } public JudgeResult judge(Submission submission) { // 通过语言标识动态路由到对应的判题器 // 这样做的好处是每种语言的判题逻辑隔离互不影响 LanguageJudge judge judgeMap.get(submission.getLanguage()); if (judge null) { throw new UnsupportedLanguageException( 不支持的语言类型: submission.getLanguage() ); } // 1. 编译 —— 编译失败直接返回不进入执行阶段 CompileResult compileResult judge.compile(submission.getCode()); if (!compileResult.isSuccess()) { return JudgeResult.compileError(compileResult.getErrorMessage()); } // 2. 执行 —— 在沙箱中运行设置超时和内存限制 ListTestCase testCases loadTestCases(submission.getProblemId()); ListTestResult results new ArrayList(); for (TestCase tc : testCases) { // 每个测试用例独立执行防止前一个用例的状态污染后一个 TestResult tr judge.execute(compileResult.getBinaryPath(), tc); results.add(tr); if (!tr.isPassed()) { // 提前终止一旦失败就不继续执行后续用例节省资源 break; } } return JudgeResult.fromTestResults(results, compileResult.getCompileTime()); } }# 判题结果的多维分析查询 —— PostgreSQL JSONB 的应用 # 这段 SQL 统计用户最近 30 天每种题型的通过率 WITH recent_submissions AS ( SELECT user_id, problem_id, result-status as status, result-metrics-time_ms as time_ms, submitted_at FROM submissions WHERE submitted_at NOW() - INTERVAL 30 days AND user_id %(user_id)s ) SELECT p.difficulty, COUNT(*) as total, SUM(CASE WHEN rs.status ACCEPTED THEN 1 ELSE 0 END) as accepted, ROUND( SUM(CASE WHEN rs.status ACCEPTED THEN 1 ELSE 0 END)::decimal / COUNT(*) * 100, 1 ) as pass_rate FROM recent_submissions rs JOIN problems p ON rs.problem_id p.id GROUP BY p.difficulty ORDER BY p.difficulty; -- 解释这里的 JSONB 字段拆解避免了在应用层做二次过滤 -- 数据库层直接完成聚合大幅减少网络传输量四、边界分析与架构权衡任何技术选型都有代价以下是几个关键 trade-off 的复盘判题沙箱进程级隔离 vs 容器级隔离初期选型时有一个争论是每个提交启动一个 Docker 容器还是在宿主机上用seccomprlimit做进程级隔离方案隔离强度启动耗时资源开销运维复杂度Docker 容器强~500ms高低进程级隔离中~10ms低高最终选了进程级隔离 额外安全审计的方案因为启动 500ms 对于单次提交来说不可接受用户期待秒级反馈我们的用户群体是内部可控的恶意代码风险可控通过 cgroup 限制 CPU 和内存后进程级隔离的安全性已足够但长期来看如果系统对外开放必须迁移到容器方案。这个技术债已在 roadmap 中标出。同步判题 vs 异步判题另一个关键决策是判题模式。同步判题实现简单但会阻塞 HTTP 请求线程异步判题能提升吞吐但需要引入消息队列。我们选择了先同步后异步的渐进式演进策略V1 版本使用同步判题快速验证核心流程V2 版本引入 RabbitMQ 做异步解耦因为用户量上来后线程池频繁打满这个决策避免了过度设计的陷阱——在用户量还是个位数时引入消息队列不仅增加系统复杂度还会让调试变得困难。五、总结这次刷题系统的技术选型复盘让我对技术选型这件事有了更立体的认知。选型不是技术炫技而是在约束条件下寻找最优解。这个约束条件包括团队的技术栈、项目的当前阶段、功能的紧急程度以及未来的演进方向。三个最关键的经验是用数据说话所有选型决策都要有量化依据而不是我感觉这个更好先跑通再优化MVP 阶段选择最简单的方案在流量验证后再做架构升级技术债显式化每个妥协都要在文档中明确记录避免交接时的认知断层如果你也在从 0 开始搭建一个类似的系统建议先把核心链路跑通——能提交代码、能出结果、能存记录——然后再逐步引入异步判题、代码分析、智能推荐等高级特性。毕竟做得出来比做得好更重要。