欢迎光临
我们一直在努力

接手一套「判题机」系统,我被输出对比搞崩了3次

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);
}

问题在哪?

  • 沙箱只有一台,所有判题请求全走同一个 IP。高峰期并发一上来,沙箱排队处理,HTTP 读超时
  • readTimeout = 180s 看起来很宽裕,但如果用户代码写了死循环或者超大型输出,沙箱卡住,这边等的线程也全部挂住
  • 没有重试机制,超时直接抛 RestClientResponseException,判题结果直接 System Error
  • 更坑的是,我们之前用了 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 题目修改后永远判不对。

    七、总结避坑清单

    做判题机这一年,总结出几个血泪教训:

  • 输出对比有三层:精确匹配 → 去行尾空白 → 去所有空白。不要直接用 equals,用 MD5 + 多级降级。
  • 语言时间倍率不能一刀切。Java/Python 给 2 倍时间,不然没人用 C 以外的语言做题。
  • 沙箱必须做超时熔断。HTTP 通信不是可靠的,用 CompletableFuture 加超时兜底。
  • 判题引擎和沙箱要解耦。沙箱地址硬编码 = 等着运维半夜找你。用 Nacos / Consul 做服务发现。
  • SPJ 版本管理容易被忽略。判题程序的缓存一定要和题目版本绑定,否则改了题等于白改。
  • 线程池一定要有界。无界队列 + while(true) 轮询 = OOM 预备队。
  • 日志打到死。判题流程长、环节多,每个步骤都打日志(入参、出参、耗时),不然出了事连哪里崩的都不知道。

  • 做判题机难的不是算法,而是那些边界情况——行尾多了个空格、沙箱偶尔超时、Java 比 C++ 慢了那么一点点。

    如果你也在做类似的项目,希望这篇文章能帮你少踩几个坑。有什么问题欢迎评论区交流,觉得有用点个赞支持一下~

    文章涉及的代码基于 HOJ 开源判题引擎改造,项目地址见评论区。

    赞(0)
    未经允许不得转载:171主机测评 » 接手一套「判题机」系统,我被输出对比搞崩了3次
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址