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\nLinux 用\n有人输出末尾带空格有人输出末尾多空行直接字符串 equals 对比 灾难。3.3 我的解决方案采用三段式对比策略逐级降级privateIntegercompareOutput(StringuserOutput,BooleanisRemoveEOLBlank,JSONObjecttestcaseInfo){// 第一层如果题目配置了「去除行尾空白」if(isRemoveEOLBlank){StringuserOutputMd5md5(rtrim(userOutput));if(userOutputMd5.equals(testcaseInfo.getStr(EOFStrippedOutputMd5))){returnSTATUS_ACCEPTED;// 去空白后一致 - AC}}else{// 第二层原样 MD5 对比性能优先StringuserOutputMd5md5(userOutput);if(userOutputMd5.equals(testcaseInfo.getStr(outputMd5))){returnSTATUS_ACCEPTED;}}// 第三层去掉所有空白字符再比——如果一致说明是 PE格式错误StringstrippedMd5md5(userOutput.replaceAll(\\s,));if(strippedMd5.equals(testcaseInfo.getStr(allStrippedOutputMd5))){returnSTATUS_PRESENTATION_ERROR;// 格式错误但内容对}returnSTATUS_WRONG_ANSWER;}关键细节用MD5而不是直接字符串对比因为测试数据可能是大文件MD5 对比内存友好、速度飞快。还有个rtrim方法专门处理行尾多余空白privatefinalstaticPatternEOL_PATTERNPattern.compile([^\\S\\n](?\\n));protectedStringrtrim(Stringvalue){if(valuenull)returnnull;returnEOL_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/C1x编译型执行快Java2xJVM 启动 JIT 预热Python2x解释执行Go1x编译型接近 C关键代码在LanguageConfigLoader里每个语言都有自己的配置文件{language:Java,srcName:Main.java,compileCommand:javac Main.java,runCommand:java Main,runEnvs:[LANGen_US.UTF-8]}核心思路语言配置和判题逻辑解耦。想加一门新语言写个配置就行不用改判题引擎。五、第三个坑沙箱通信——判着判着 HTTP 就超时了5.1 问题描述这个坑最隐蔽。上线后监控发现每天凌晨总有几道题莫名其妙报 System Error重启就好了。查了三天才发现是沙箱通信超时。5.2 问题根因我们的判题引擎和沙箱是通过HTTP REST通信的别问为什么不用 gRPC历史遗留privatestaticfinalStringSANDBOX_BASE_URLhttp://192.168.1.100:5050;static{SimpleClientHttpRequestFactoryrequestFactorynewSimpleClientHttpRequestFactory();requestFactory.setConnectTimeout(20000);// 连接超时 20srequestFactory.setReadTimeout(180000);// 读取超时 3 分钟restTemplatenewRestTemplate(requestFactory);}问题在哪沙箱只有一台所有判题请求全走同一个 IP。高峰期并发一上来沙箱排队处理HTTP 读超时readTimeout 180s看起来很宽裕但如果用户代码写了死循环或者超大型输出沙箱卡住这边等的线程也全部挂住没有重试机制超时直接抛RestClientResponseException判题结果直接 System Error更坑的是我们之前用了while(true) Thread.sleep(10)来轮询线程池结果CPU 虽然不飙高但大量线程在 WAITING 状态GC 压力巨大privateJSONObjectSubmitTask2ThreadPool(FutureTaskJSONObjectfutureTask){threadPool.submit(futureTask);while(true){if(futureTask.isDone()!futureTask.isCancelled()){returnfutureTask.get();}else{Thread.sleep(10);// 轮询}}}5.3 改造方案方案 A加超时熔断// 用 CompletableFuture 替换 FutureTask设置超时CompletableFutureJSONObjectfutureCompletableFuture.supplyAsync(()-{returnsandboxRun.execute(cmd);},threadPool);try{returnfuture.get(problem.getTimeLimit()5000,TimeUnit.MILLISECONDS);}catch(TimeoutExceptione){future.cancel(true);// 返回 TLE 而不是 System ErrorreturnbuildTleResult();}方案 B沙箱加节点 负载均衡我们把硬编码的192.168.1.100:5050改成了 Nacos 服务发现判题引擎启动时注册沙箱也注册为服务。现在多个沙箱节点可以水平扩展了// 通过 Nacos 获取可用沙箱列表轮询分配ListStringsandboxesdiscoveryClient.getInstances(hoj-sandbox);StringsandboxUrlloadBalancer.choose(sandboxes);方案 C线程池调优把无界while(true)轮询改成了CompletableFuture回调 有限等待配合合理的线程池参数ThreadPoolExecutorexecutornewThreadPoolExecutor(4,// core16,// max60L,TimeUnit.SECONDS,newLinkedBlockingQueue(200),// 有界队列防 OOMnewThreadPoolExecutor.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 开源判题引擎改造项目地址见评论区。
接手一套「判题机」系统,我被输出对比搞崩了3次
发布时间:2026/6/8 21:41:48
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\nLinux 用\n有人输出末尾带空格有人输出末尾多空行直接字符串 equals 对比 灾难。3.3 我的解决方案采用三段式对比策略逐级降级privateIntegercompareOutput(StringuserOutput,BooleanisRemoveEOLBlank,JSONObjecttestcaseInfo){// 第一层如果题目配置了「去除行尾空白」if(isRemoveEOLBlank){StringuserOutputMd5md5(rtrim(userOutput));if(userOutputMd5.equals(testcaseInfo.getStr(EOFStrippedOutputMd5))){returnSTATUS_ACCEPTED;// 去空白后一致 - AC}}else{// 第二层原样 MD5 对比性能优先StringuserOutputMd5md5(userOutput);if(userOutputMd5.equals(testcaseInfo.getStr(outputMd5))){returnSTATUS_ACCEPTED;}}// 第三层去掉所有空白字符再比——如果一致说明是 PE格式错误StringstrippedMd5md5(userOutput.replaceAll(\\s,));if(strippedMd5.equals(testcaseInfo.getStr(allStrippedOutputMd5))){returnSTATUS_PRESENTATION_ERROR;// 格式错误但内容对}returnSTATUS_WRONG_ANSWER;}关键细节用MD5而不是直接字符串对比因为测试数据可能是大文件MD5 对比内存友好、速度飞快。还有个rtrim方法专门处理行尾多余空白privatefinalstaticPatternEOL_PATTERNPattern.compile([^\\S\\n](?\\n));protectedStringrtrim(Stringvalue){if(valuenull)returnnull;returnEOL_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/C1x编译型执行快Java2xJVM 启动 JIT 预热Python2x解释执行Go1x编译型接近 C关键代码在LanguageConfigLoader里每个语言都有自己的配置文件{language:Java,srcName:Main.java,compileCommand:javac Main.java,runCommand:java Main,runEnvs:[LANGen_US.UTF-8]}核心思路语言配置和判题逻辑解耦。想加一门新语言写个配置就行不用改判题引擎。五、第三个坑沙箱通信——判着判着 HTTP 就超时了5.1 问题描述这个坑最隐蔽。上线后监控发现每天凌晨总有几道题莫名其妙报 System Error重启就好了。查了三天才发现是沙箱通信超时。5.2 问题根因我们的判题引擎和沙箱是通过HTTP REST通信的别问为什么不用 gRPC历史遗留privatestaticfinalStringSANDBOX_BASE_URLhttp://192.168.1.100:5050;static{SimpleClientHttpRequestFactoryrequestFactorynewSimpleClientHttpRequestFactory();requestFactory.setConnectTimeout(20000);// 连接超时 20srequestFactory.setReadTimeout(180000);// 读取超时 3 分钟restTemplatenewRestTemplate(requestFactory);}问题在哪沙箱只有一台所有判题请求全走同一个 IP。高峰期并发一上来沙箱排队处理HTTP 读超时readTimeout 180s看起来很宽裕但如果用户代码写了死循环或者超大型输出沙箱卡住这边等的线程也全部挂住没有重试机制超时直接抛RestClientResponseException判题结果直接 System Error更坑的是我们之前用了while(true) Thread.sleep(10)来轮询线程池结果CPU 虽然不飙高但大量线程在 WAITING 状态GC 压力巨大privateJSONObjectSubmitTask2ThreadPool(FutureTaskJSONObjectfutureTask){threadPool.submit(futureTask);while(true){if(futureTask.isDone()!futureTask.isCancelled()){returnfutureTask.get();}else{Thread.sleep(10);// 轮询}}}5.3 改造方案方案 A加超时熔断// 用 CompletableFuture 替换 FutureTask设置超时CompletableFutureJSONObjectfutureCompletableFuture.supplyAsync(()-{returnsandboxRun.execute(cmd);},threadPool);try{returnfuture.get(problem.getTimeLimit()5000,TimeUnit.MILLISECONDS);}catch(TimeoutExceptione){future.cancel(true);// 返回 TLE 而不是 System ErrorreturnbuildTleResult();}方案 B沙箱加节点 负载均衡我们把硬编码的192.168.1.100:5050改成了 Nacos 服务发现判题引擎启动时注册沙箱也注册为服务。现在多个沙箱节点可以水平扩展了// 通过 Nacos 获取可用沙箱列表轮询分配ListStringsandboxesdiscoveryClient.getInstances(hoj-sandbox);StringsandboxUrlloadBalancer.choose(sandboxes);方案 C线程池调优把无界while(true)轮询改成了CompletableFuture回调 有限等待配合合理的线程池参数ThreadPoolExecutorexecutornewThreadPoolExecutor(4,// core16,// max60L,TimeUnit.SECONDS,newLinkedBlockingQueue(200),// 有界队列防 OOMnewThreadPoolExecutor.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 开源判题引擎改造项目地址见评论区。