Java判题引擎从0到1,那些让我头皮发麻的坑和最终方案
一、背景:这不是 LeetCode,这是我做的判题机
事情是这样的——公司要搞一个在线编程平台(类似牛客网 + OJ),用户写代码提交,系统自动判断对错。听起来不复杂对吧?不就是「用户代码跑一下,输出和标准答案比一比」?
太天真了。
我接手的是一个叫 HOJ 的开源判题引擎改造项目,Java 技术栈,Spring Cloud + MyBatis-Plus + Docker 沙箱。表面看架构还行,真正跑起来才知道这玩意儿的坑有多深。今天就把我在**判题机(Judge Server)**上踩过的三个大坑全盘托出,希望能帮到正在做或者准备做类似系统的兄弟。
二、核心难题:判题机到底是什么?
先给不熟悉的同学科普一下:判题机就是自动评判用户代码对错的系统。整个流程大概是:
用户提交代码 → 编译 → 跑测试数据 → 对比输出 → 返回结果(AC/WATLE…)
我们要支持的场景:
- 普通判题(Default):用户程序输出 vs 标准输出,文本对比
- 特殊判题(SPJ):答案不唯一,用一个辅助程序来判断(比如输出 3.14 和 3.14159 都算对)
- 交互判题(Interactive):用户程序和评测程序来回对话
- OI 赛制:部分正确也给分,还有 subtask 分组
- 多语言支持:Java、Python、Go、C++ 一个不能少
听起来就是几条 if-else 的事?年轻人你把握不住。
三、第一个坑:输出对比——一个空格引发的血案
3.1 问题描述
上线第一天就被用户炸了:
“我本地运行明明是 AC 的,提交上去就 WA!”
查了一晚上日志,发现是一个行尾空格的问题。用户输出 "hello\\n",标准答案是 "hello",就多了一个换行符,判题机直接给了 Wrong Answer。
3.2 为什么这么坑?
判题机的核心逻辑是对比「用户程序输出」和「标准答案输出」。但不同语言、不同平台的换行风格不一样:
- Windows 用 \\r\\n
- Linux 用 \\n
- 有人输出末尾带空格
- 有人输出末尾多空行
直接字符串 equals 对比 = 灾难。
3.3 我的解决方案
采用三段式对比策略,逐级降级:
private Integer compareOutput(String userOutput, Boolean isRemoveEOLBlank, JSONObject testcaseInfo) {
// 第一层:如果题目配置了「去除行尾空白」
if (isRemoveEOLBlank) {
String userOutputMd5 = md5(rtrim(userOutput));
if (userOutputMd5.equals(testcaseInfo.getStr("EOFStrippedOutputMd5"))) {
return STATUS_ACCEPTED; // 去空白后一致 -> AC
}
} else {
// 第二层:原样 MD5 对比(性能优先)
String userOutputMd5 = md5(userOutput);
if (userOutputMd5.equals(testcaseInfo.getStr("outputMd5"))) {
return STATUS_ACCEPTED;
}
}
// 第三层:去掉所有空白字符再比——如果一致说明是 PE(格式错误)
String strippedMd5 = md5(userOutput.replaceAll("\\\\s+", ""));
if (strippedMd5.equals(testcaseInfo.getStr("allStrippedOutputMd5"))) {
return STATUS_PRESENTATION_ERROR; // 格式错误,但内容对
}
return STATUS_WRONG_ANSWER;
}
关键细节:用 MD5 而不是直接字符串对比,因为测试数据可能是大文件,MD5 对比内存友好、速度飞快。
还有个 rtrim 方法,专门处理行尾多余空白:
private final static Pattern EOL_PATTERN = Pattern.compile("[^\\\\S\\\\n]+(?=\\\\n)");
protected String rtrim(String value) {
if (value == null) return null;
return EOL_PATTERN.matcher(StrUtil.trimEnd(value)).replaceAll("");
}
教训:判题输出对比不能只做「等于」,要做三层降级:精确匹配 → 去行尾空白匹配 → 去所有空白匹配。
四、第二个坑:多语言时间倍率——凭啥 Java 比 C++ 多一倍时间?
4.1 问题描述
另一个被用户追着骂的场景:
“同样的算法,我用 C++ 提交 TLE,用 Java 提交就 AC,这不公平!”
仔细一想,这不叫不公平,这叫没做语言差异化配置。
4.2 问题在哪里
C/C++ 编译型语言执行效率高,而 Java/Python 这种带虚拟机/解释型的语言,同样的逻辑要慢得多。如果所有语言都共用同一个时间限制,那用 C++ 的人血亏。
看我们当时的代码:
// C 和 C++ 为一倍时间和空间,其它语言为 2 倍
if (!语言是.c文件 && !语言是.cpp文件) {
problem.setTimeLimit(problem.getTimeLimit() * 2);
problem.setMemoryLimit(problem.getMemoryLimit() * 2);
}
是的,就是这么粗暴——非 C 系语言一律翻倍。
4.3 背后的设计思想
这个看似粗暴的方案其实有道理:
| C/C++ | 1x | 编译型,执行快 |
| Java | 2x | JVM 启动 + JIT 预热 |
| Python | 2x | 解释执行 |
| Go | 1x | 编译型,接近 C |
关键代码在 LanguageConfigLoader 里,每个语言都有自己的配置文件:
{
"language": "Java",
"srcName": "Main.java",
"compileCommand": "javac Main.java",
"runCommand": "java Main",
"runEnvs": ["LANG=en_US.UTF-8"]
}
核心思路:语言配置和判题逻辑解耦。想加一门新语言?写个配置就行,不用改判题引擎。
五、第三个坑:沙箱通信——判着判着 HTTP 就超时了
5.1 问题描述
这个坑最隐蔽。上线后监控发现,每天凌晨总有几道题莫名其妙报 System Error,重启就好了。查了三天才发现是沙箱通信超时。
5.2 问题根因
我们的判题引擎和沙箱是通过 HTTP REST 通信的(别问为什么不用 gRPC,历史遗留):
private static final String SANDBOX_BASE_URL = "http://192.168.1.100:5050";
static {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(20000); // 连接超时 20s
requestFactory.setReadTimeout(180000); // 读取超时 3 分钟
restTemplate = new RestTemplate(requestFactory);
}
问题在哪?
更坑的是,我们之前用了 while(true) + Thread.sleep(10) 来轮询线程池结果,CPU 虽然不飙高,但大量线程在 WAITING 状态,GC 压力巨大:
private JSONObject SubmitTask2ThreadPool(FutureTask<JSONObject> futureTask) {
threadPool.submit(futureTask);
while (true) {
if (futureTask.isDone() && !futureTask.isCancelled()) {
return futureTask.get();
} else {
Thread.sleep(10); // 轮询
}
}
}
5.3 改造方案
方案 A:加超时熔断
// 用 CompletableFuture 替换 FutureTask,设置超时
CompletableFuture<JSONObject> future = CompletableFuture.supplyAsync(() -> {
return sandboxRun.execute(cmd);
}, threadPool);
try {
return future.get(problem.getTimeLimit() + 5000, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
// 返回 TLE 而不是 System Error
return buildTleResult();
}
方案 B:沙箱加节点 + 负载均衡
我们把硬编码的 192.168.1.100:5050 改成了 Nacos 服务发现,判题引擎启动时注册,沙箱也注册为服务。现在多个沙箱节点可以水平扩展了:
// 通过 Nacos 获取可用沙箱列表,轮询分配
List<String> sandboxes = discoveryClient.getInstances("hoj-sandbox");
String sandboxUrl = loadBalancer.choose(sandboxes);
方案 C:线程池调优
把无界 while(true) 轮询改成了 CompletableFuture 回调 + 有限等待,配合合理的线程池参数:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // core
16, // max
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200), // 有界队列,防 OOM
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:调用者线程自己跑
);
六、彩蛋坑:特殊判题(SPJ)的版本管理
这个问题比较小众但特别恶心——SPJ 评测器和用户代码一起更新了,但缓存在磁盘上的旧 SPJ 还在,导致新旧版本混用。
解决方式很简单:SPJ 编译后写一个 version 文件,每次判题前对比版本号,不一致就重新编译:
// 版本变动也需要重新编译
if (!currentVersion.equals(recordSpjVersion)) {
Compiler.compileSpj(...); // 重新编译
fileWriter.write(currentVersion); // 写入新版本号
}
这个细节看起来不起眼,但没有它,SPJ 题目修改后永远判不对。
七、总结避坑清单
做判题机这一年,总结出几个血泪教训:
做判题机难的不是算法,而是那些边界情况——行尾多了个空格、沙箱偶尔超时、Java 比 C++ 慢了那么一点点。
如果你也在做类似的项目,希望这篇文章能帮你少踩几个坑。有什么问题欢迎评论区交流,觉得有用点个赞支持一下~
文章涉及的代码基于 HOJ 开源判题引擎改造,项目地址见评论区。


