简介编辑距离LD算法通过计算字符串间最少增删改次数衡量相似度是文本匹配与代码查重的基础技术。在自动判分场景中它能容忍变量改名、空格换行等差异结合归一化将相似度映射为百分制分数比逐字符比对更符合编程题的实际评分逻辑。基于SSM框架SpringSpringMVCMyBatis与MySQL构建在线考试系统时将LD算法用于编程题自动评分既能提升判分准确性又能降低实现复杂度尤其适合课程设计与毕业设计实践。文章以SSM分层架构为起点逐步讲解数据表设计、LD算法的Java实现、异步判分流程并剖析中文乱码、Session失效、长代码超时等常见陷阱帮助开发者快速落地一个稳定、可解释的自动判分模块。1. 基于 SSM 的在线考试系统为什么编程题判分会选择 LD 算法一个多数人想不到的事实是在线考试系统里编程题自动判分很少靠“逐字符比对答案”而是先接受一个前提——学生把变量名从sum改成total、把 for 循环里的i改成j甚至多换了几行空行这道题的答案仍然应该是满分。SSM JSP MySQL 恰好是承载这套逻辑最稳的组合Spring 管对象、SpringMVC 管请求、MyBatis 管数据库JSP 渲染考试页面而 LDLevenshtein Distance编辑距离算法负责在“内容不完全一致”的两段代码之间算出相似程度再映射成百分制分数。这套系统特别适合毕业设计、课程设计和项目开发练手技术栈老练但不陈旧每个模块都能独立讲解判分逻辑又有算法深度答辩时能讲的东西远多于普通的增删改查。下文按“架构 → 算法 → 流程 → 踩坑 → 调优”的顺序把整条落地路径拆开讲清楚。2. 先从 SSM 与 MySQL 把地基打好项目分层与最小可运行配置2.1 为什么 Controller、Service、Mapper 的边界要先定死SSM 项目最常见的翻车点不是代码写不出来而是分层混乱有人在 Controller 里直接写 JDBC有人把 LD 算法塞进 JSP 的% %里还有人把判分逻辑和 MyBatis 查询揉在一个方法里。毕业设计答辩时老师几乎必问“你为什么要这样分层”。先把边界定死后面所有功能都顺着边界长出来会轻松很多。我一般固定遵循四层方案Controller 层只做三件事接收参数、调用 Service、返回视图。不写任何业务判断。Service 层放业务规则判分逻辑、考试状态校验、成绩计算。这是核心单独拆成一个包。Mapper 层DAO只做 SQL 映射不写循环、不写 if 判断。实体层POJO与数据库表字段一一对应不掺额外逻辑。这样做的直接好处是LD 算法模块可以独立写、独立测不需要启动 Tomcat 就能跑通调试成本大幅降低。即便以后想把 JSP 换成 VueService 和 Mapper 层也能原封不动复用。2.2 用 Maven 快速生成骨架给出 pom.xml 关键依赖与 Spring 配置顺序新建一个 Maven war 包项目依赖只保留最必要的一组spring-webmvc、mybatis、mybatis-spring、mysql-connector-java、jstl、jackson-databind可选用于 AJAX、junit。下面是一个能跑通的最小依赖集合dependencies !-- Spring MVC -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.31/version /dependency !-- MyBatis 核心 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.14/version /dependency !-- MyBatis 与 Spring 整合 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.1.2/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- JSTL 标签库 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency /dependencies注意Spring 版本建议用 5.x 而不要用 6.x。Spring 6 最低要求 JDK 17而很多毕设环境还在 JDK 8一旦版本选错启动时直接报UnsupportedClassVersionError。MySQL 驱动同理8.0 驱动兼容 MySQL 5.7 和 8.0但连接串写法有差异后面会讲到。Spring 的配置顺序大约如下先加载数据源再创建 SqlSessionFactoryBean然后扫描 Mapper 接口最后开启事务管理。顺序错了会出现BeanCreationException。核心配置如下!-- 数据源 -- bean iddataSource classorg.springframework.jdbc.datasource.DriverManagerDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/exam_system?useUnicodetrueamp;characterEncodingutf8amp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword value你的密码/ /bean !-- SqlSessionFactory -- bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean !-- 扫描 Mapper 接口 -- bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.exam.mapper/ /bean这里最容易被忽略的是url里的amp;。在 XML 里写字符必须转义成amp;否则 XML 解析直接失败。useUnicodetrue和characterEncodingutf8是中文不乱码的第一道防线serverTimezoneAsia/Shanghai是为了避免 MySQL 8.0 与 JDK 时区不一致导致的SQLException。2.3 数据库表设计试题表、考试记录表与成绩表的关系在线考试系统的表设计我建议最少七张表但核心五张必须一次建对。五张表分别是用户表、试卷表、编程题表、考试记录表、代码作答表。它们的关系是一份试卷exam含多道题question一次考试exam_record对应一个学生和一份试卷一个考试记录含多份代码作答code_answer。下面是核心建表 SQL去掉了非关键字段以保证篇幅CREATE TABLE exam ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT 试卷标题, duration_minutes INT NOT NULL COMMENT 考试时长分钟, start_time DATETIME NOT NULL COMMENT 开考时间, end_time DATETIME NOT NULL COMMENT 截止时间, total_score INT DEFAULT 100, status INT DEFAULT 0 COMMENT 0未开始 1进行中 2已结束 ); CREATE TABLE exam_question ( id INT PRIMARY KEY AUTO_INCREMENT, exam_id INT NOT NULL, question_title VARCHAR(200) NOT NULL COMMENT 题目描述, base_score INT NOT NULL COMMENT 基础分, reference_answer TEXT NOT NULL COMMENT 参考答案代码, keyword_weight DOUBLE DEFAULT 0.6 COMMENT LD相似度权重, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_exam (exam_id) ); CREATE TABLE exam_record ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, exam_id INT NOT NULL, start_time DATETIME NOT NULL, submit_time DATETIME NULL, status INT DEFAULT 0 COMMENT 0作答中 1已提交 2超时自动交卷, final_score DOUBLE DEFAULT -1 COMMENT -1表示未判分 ); CREATE TABLE code_answer ( id INT PRIMARY KEY AUTO_INCREMENT, record_id INT NOT NULL, question_id INT NOT NULL, student_code TEXT NOT NULL COMMENT 学生提交代码, score DOUBLE DEFAULT -1 COMMENT 本题得分, similarity DOUBLE DEFAULT -1 COMMENT LD相似度 );字段设计的几个关键点final_score初始值用 -1 而不是 0这样能区分“还没判分”和“真的考了 0 分”similarity单独存下来答辩时能展示“相似度多少分”的日志老师会觉得你的系统有过程可查keyword_weight预留权重字段给第 6 章的评分映射用。3. 编程题自动判分的核心LDLevenshtein算法的评分实现3.1 编辑距离是什么几次增删改能把两段代码变得一模一样LD 算法衡量的是从字符串 A 变换到字符串 B最少需要多少次“插入、删除、替换”操作。这个次数叫编辑距离。比如int a 1;与int b 1;的编辑距离是 1把a替换成b。在考试系统里我们并不直接拿“距离”当分数因为距离数值受代码长度影响太大——两段 200 行的代码相差 20 处和两段 20 行的代码相差 5 处前者可能更接近。为了跨长度可比要归一化$$相似度 1 - \frac{LD距离}{两段代码中较长的长度}$$相似度落在[0, 1]区间0 代表完全不同1 代表完全一致。当学生答案与参考答案的实际内容越接近相似度越接近 1。这套算法之所以适合编程题判分是因为它能容忍空格、换行、注释和变量名的差异同时又不会像“包含匹配”那样只按子串打分。这也解释了为什么大多数毕设级别的在线考试系统选它——实现成本低效果直观答辩时还能现场画矩阵图。3.2 在 Java 里实现 LD 算法带中文注释的判分核心代码用二维数组实现 LD 算法是教科书做法逻辑清晰但内存占满。实际落地时我一般用滚动数组一维把空间复杂度从 O(m×n) 降到 O(n)。下面是完整可用的判分核心类包含预处理、距离计算和归一化三个部分package com.example.exam.service.impl; /** * 编程题判分核心LD 算法实现 * 使用滚动数组优化空间避免两段超长代码导致内存溢出 */ public class SimilarityScorer { /** * 计算两段代码的相似度返回 [0,1] 之间的浮点数 * * param studentCode 学生提交的代码 * param referenceCode 参考答案代码 * return 相似度1.0 表示完全一致 */ public double computeSimilarity(String studentCode, String referenceCode) { if (studentCode null || referenceCode null) { return 0.0; } // 预处理去除首尾空白、统一换行符、转小写类名转小写不严谨但毕设够用 String s1 preProcess(studentCode); String s2 preProcess(referenceCode); // 如果其中一段为空直接判 0 分 if (s1.isEmpty() || s2.isEmpty()) { return s1.equals(s2) ? 1.0 : 0.0; } int len1 s1.length(); int len2 s2.length(); int maxLen Math.max(len1, len2); // 长度差超过 50%基本不可能是同一道题的答案提前返回省 CPU if (Math.abs(len1 - len2) * 1.0 / maxLen 0.5) { return 0.0; } // 滚动数组prev 存上一行curr 存当前行 int[] prev new int[len2 1]; int[] curr new int[len2 1]; // 初始化 prev空串变成 s2 需要 len2 次插入 for (int j 0; j len2; j) { prev[j] j; } for (int i 1; i len1; i) { curr[0] i; // s1 的前 i 个字符变成空串需要 i 次删除 char c1 s1.charAt(i - 1); for (int j 1; j len2; j) { char c2 s2.charAt(j - 1); if (c1 c2) { curr[j] prev[j - 1]; // 字符相等不需要额外操作 } else { // 取三种操作的最小值删除、插入、替换 int del prev[j] 1; int ins curr[j - 1] 1; int rep prev[j - 1] 1; curr[j] Math.min(del, Math.min(ins, rep)); } } // 交换 prev 和 curr复用数组 int[] tmp prev; prev curr; curr tmp; } int distance prev[len2]; // 归一化相似度 return 1.0 - (double) distance / maxLen; } /** * 预处理统一小写、换行符、去掉首尾空格 * 注意不去除所有空格因为空格差异是允许的但完全去除会破坏关键字 */ private String preProcess(String code) { return code .trim() .toLowerCase() .replace(\r\n, \n) .replace(\r, \n); } }这段代码有几个参数和边界值得说Math.abs(len1 - len2) * 1.0 / maxLen 0.5是一个兜底优化。如果学生只写了三行参考答案三十行直接返回 0.0省掉一次 O(n×m) 的矩阵计算。这个 0.5 阈值可以按题目难度调整压轴题建议调到 0.3防止“随便写几行拿同情分”。curr[0] i是滚动数组最容易漏写的一行。没有这一行第一列永远没有初值距离计算结果会错乱。想验证的话可以在纸上画一下二维表格观察 i 行 j 列的取值依赖。字符相同时直接取prev[j-1]不做1这是 LD 的核心——相等就是最优子结构不需要额外代价。3.3 不能只靠编辑距离与词法分析、AST 方案的取舍LD 算法并非万能。它把代码当成纯文本去比较完全没有“代码结构”概念。一个典型误杀例子学生把整个函数用递归重写逻辑完全正确但文本与参考实现相差巨大LD 相似度可能只有 0.3。反之如果学生照着参考答案抄了前 20 行后面全是错误代码LD 相似度依然可能到 0.6。所以在做方案选型时你必须清楚 LD 的边界方案可解释性实现成本误判情况字符串精确比较高极低变量改名、换行全误判LD 编辑距离高低重构写法误判关键词特征统计中中依赖关键词权重设定词法分析Token 流 LD中较高能识别变量改名但重构仍误判AST 结构比较低最高最准但毕设工期撑不住如果是毕业设计我强烈建议 LD 关键词统计的组合而不是一上来就做 AST。AST 需要引入语法分析工具判分逻辑要遍历语法树节点写一个只支持 Java 子集的比较器就得三五百行还要处理语法错误代码学生写一半的代码根本生成不了 AST复杂度失控。而 LD 方案用 100 行左右就能搞定判分规则的粒度也足够答辩时讲清楚。4. 考试流程的完整闭环从题目发布、自动组卷到交卷判分4.1 带截止时间的考试会话为什么不能只靠前端倒计时考试系统的业务复杂度不在 CRUD而在状态流转。一次考试的完整状态是未开始 → 进行中 → 已提交 / 超时自动交卷 → 已判分。最容易被忽略的是“超时自动交卷”这个环节如果只在页面用 JavaScript 计时用户关掉浏览器再打开计时器失效考试永远不结束数据库里一堆“僵尸记录”。我一般这样做考试开始时间以服务端数据库记录为准前端倒计时只是展示每次学生提交作答时服务端都检查当前时间与exam_record.start_time的差值是否超过duration_minutes同时用 SpringScheduled定时任务每小时扫一次超时未提交的记录强制置为“超时自动交卷”状态。/** * 超时检查扫描所有进行中的考试记录标记超时 */ Scheduled(fixedRate 60000) // 每 60 秒执行一次 public void autoSubmitExpiredExams() { ListExamRecord liveRecords examRecordMapper.findByStatus(0); // 0 作答中 long now System.currentTimeMillis(); for (ExamRecord rec : liveRecords) { boolean expired now - rec.getStartTime().getTime() rec.getDurationMinutes() * 60 * 1000L; if (expired) { rec.setStatus(2); // 超时自动交卷 examRecordMapper.updateStatus(rec); // 触发异步判分 asyncScoringService.scoreRecord(rec.getId()); } } }fixedRate 60000表示上次执行完成后过 60 秒再执行下一次。这个频率对考试系统足够不需要更短否则每个学生都在提交时触发一次全表扫描数据库压力会放大。更精确的做法是只查当前进行中且未过期的考试把过滤条件写在 SQL 里而不是把durationMinutes查出来再在 Java 里算。4.2 题目发布与作答提交防止重复提交和二次判分学生点击“提交代码”时后端要做的不只是 insert而是先检查状态PostMapping(/exam/submit) public String submitAnswer(HttpSession session, RequestParam Integer recordId, RequestParam Integer questionId, RequestParam String code) { ExamRecord record examRecordMapper.selectById(recordId); if (record null || record.getStudentId() ! getCurrentUserId(session)) { return error; // 记录不存在或不是本人 } if (record.getStatus() 1 || record.getStatus() 2) { return submitted; // 已交卷拒绝再次提交 } CodeAnswer answer examRecordMapper.findAnswer(recordId, questionId); if (answer null) { answer new CodeAnswer(); answer.setRecordId(recordId); answer.setQuestionId(questionId); answer.setStudentCode(code); examRecordMapper.insertAnswer(answer); } else { // 允许在考试中反复修改只更新代码不重新判分 answer.setStudentCode(code); examRecordMapper.updateAnswer(answer); } return ok; }这段逻辑的关键不是代码本身而是“重复提交”的语义。学生改代码是允许的但一旦点击“交卷”就进入只读状态。判分只在交卷后触发而不是每次保存代码都触发否则一次考试有几十个学生同时保存判分服务会被打爆。具体做法是在submitExam接口里修改状态后再把判分任务丢给异步线程池。4.3 触发自动判分怎么选定时任务、异步线程池还是消息队列判分模块是 IO 密集 CPU 密集的混合任务。每份代码都要跑一次 LD还要写分数回库。如果直接在 HTTP 请求里同步判分Tomcat 线程被卡住体验极差。我一般在 Service 层引入一个线程池private final ExecutorService scorerPool Executors.newFixedThreadPool(4); public void scoreRecordAsync(Integer recordId) { scorerPool.submit(() - { // 1. 查出该记录下所有 code_answer // 2. 对每道题计算 similarity // 3. 按权重汇总成 final_score // 4. 写回 exam_record }); }代码本身不复杂真正要搞明白的是线程池参数。newFixedThreadPool(4)表示核心线程和最大线程都是 4队列用的是无界LinkedBlockingQueue。在毕设并发量不超过几十人的场景下这个配置够了。但不要直接写成newFixedThreadPool(4)就完事因为你有可能会出现“判分任务堆积”此时没有线程在跑代码但队列在涨内存压力变大。所以更稳妥的做法是先把判分改成事务方法用Async(scorerExecutor)注解交给 Spring 管理同时在配置里定义队列容量Bean(scorerExecutor) public Executor scorerExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return executor; }CallerRunsPolicy的意思是队列满了之后新任务不丢弃而是由提交线程自己执行。这样保证不会丢判分任务代价是 HTTP 响应变慢。对考试系统来说判分任务不能丢比响应速度更重要所以选这个策略。如果你用的是自己的线程池而不是 Spring 的Async可以直接给ThreadPoolExecutor传入这个拒绝策略效果完全一样。5. 毕设答辩前必须排查的五个坑中文乱码、Session 失效、LD 慢与判分误杀5.1 中文乱码页面显示正常但存进 MySQL 全是问号这个问题几乎每个 SSM 项目都会遇到。表现是页面上 JSP 显示“操作系统”正常但打开数据库看到“”或者反过来从数据库查出内容到页面显示乱码。原因有三层任何一层没配好都会出问题浏览器请求编码、服务器接收编码、数据库连接编码。解决顺序从下往上MySQL 表字符集建表时指定utf8mb4JDBC 连接串加characterEncodingutf8Spring 的字符编码过滤器必须放在 web.xml 最前面。filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mappingforceEncodingtrue很关键。不设置时如果请求里已经有Content-Type: text/html;charsetISO-8859-1过滤器会认为已经指定了编码然后跳过。设成 true 会强制覆盖为 UTF-8。三个地方都对齐后再观察如果还有乱码优先怀疑 MySQL 驱动版本与数据库服务端版本不匹配而不是继续调代码。5.2 Session 过期导致判分记录消失学生考试到一半Tomcat 默认 Session 超时时间是 30 分钟恰好考试时长也是 30 分钟就会出现一种阴间现象学生提交试卷时Session 已经失效后端拿不到 userId接口直接返回 401学生以为交卷成功了实际记录状态还是“作答中”。解决思路不要让判断身份完全依赖 HttpSession。存一份“当前考试 token”在 Cookie 里token 对应数据库中的exam_record.id判分和查询成绩时直接用 recordId 定位而不是再查 Session。如果项目时间紧至少把 Tomcat 的 Session 超时时间调到考试时长的两倍。session-config session-timeout120/session-timeout /session-config但这不是根治方案。根治是“身份 考试记录”双绑定Session 只做登录态一旦登录后再去哪个考试页面、交哪份卷都通过数据库里的 recordId 来锁定你的判分逻辑与 Session 生命周期彻底解耦。5.3 判分误杀学生只是改了变量名相似度从 1.0 跌到 0.3这是 LD 方案最大的坑。学生把int average total / count;写成int avg total / cnt;在 LD 眼里这是四次替换距离拉大相似度会掉一大截。但功能上完全等价。解决方法是先做标识符归一化预处理。在算 LD 之前把代码里的变量名替换为占位符比如用正则把所有形如[a-zA-Z_][a-zA-Z0-9_]*的标识符统一换成VAR再计算相似度。注意保留 Java 关键字比如if、for、while不能替换。正则不好做关键词排除可以先用一个静态集合保存关键字列表匹配到关键字就跳过。private static final SetString JAVA_KEYWORDS Set.of( abstract, if, else, for, while, return, new, class, interface, int, double, public, private, protected, static, void, boolean, null, true, false ); private String normalizeIdentifiers(String code) { StringBuilder sb new StringBuilder(); String[] tokens code.split((?[^a-zA-Z0-9_])|(?[^a-zA-Z0-9_])); for (String token : tokens) { if (token.matches([a-zA-Z_][a-zA-Z0-9_]*) !JAVA_KEYWORDS.contains(token)) { sb.append(VAR); } else { sb.append(token); } } return sb.toString(); }关键的改动是在computeSimilarity里先调用normalizeIdentifiers再做预处理。这样变量名差异不参与距离计算相似度会回到 0.9 以上。但还需要注意一个副作用如果学生代码里类名、包名都被替换成VAR不同题的区分度反而下降。所以这套预处理只适合局部变量多的短代码段长代码建议只对每个独立语句做变量抽取不整段归一化具体粒度需要自己压几组学生代码来试。5.4 长代码把 CPU 打满LD 的 O(n×m) 在考试系统里被放大LD 算法的时间复杂度是 O(n×m)。参考答案 500 字符学生写了 5000 字符的超长代码一次计算就要跑 2500 万次基本操作。一个班级 50 人、10 道题判分全部跑完会持续几十秒Tomcat 所在机器 CPU 飙高别的请求跟着卡死。我处理这个问题的方式有三层按建议优先级排列第一层是章节 3.2 里写的长度差提前返回长度差超过 50% 直接判 0第二层是限制作答代码的最大长度前端编辑框限制 10000 字符后端再校验一次超长直接拒绝提交同时记录日志第三层是给 LD 计算加超时控制用 Future 包装超过 2 秒没算完就取消并记 0.5 分而不是让判分线程无限卡死。ExecutorService single Executors.newSingleThreadExecutor(); FutureDouble future single.submit(() - scorer.computeSimilarity(s1, s2)); try { double sim future.get(2000, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { future.cancel(true); sim 0.5; // 超时代码记中间分并记录 for 人工复核 }这里有个坑future.cancel(true)并不能真正中断正在执行的computeSimilarity只是把当前线程标记为中断。好在滚动数组实现里没有Thread.sleep这类检查中断的代码所以它实际会继续跑完然后结果被丢弃。这虽然浪费了 CPU但至少 HTTP 请求能在 2 秒内返回不会阻塞整个考试页面。真正要根治还是得在算法层面考虑分段比较这个属于进阶优化。5.5 判分回写失败MySQL 等待超时和连接池耗尽考试系统在高峰期可能出现的情况是所有连接都被查询占用判分线程拿不到连接SQL 一直阻塞等待最后报Connection is not available, request timed out after 30000ms。这个问题的根因通常是数据库连接池配置不当。如果项目直接用的DriverManagerDataSource每次获取连接都新建物理连接且没有空闲回收——这种配置一旦超过几十个并发就必炸。需要换成 HikariCP 或 Druid并设置合理参数。以下是 Druid 的典型配置bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property nameurl valuejdbc:mysql://localhost:3306/exam_system?useUnicodetrueamp;characterEncodingutf8amp;serverTimezoneAsia/Shanghai/ property nameinitialSize value5/ property nameminIdle value5/ property namemaxActive value20/ property namemaxWait value30000/ property nametestWhileIdle valuetrue/ property namevalidationQuery valueSELECT 1/ /beanminIdle5保证最低保留 5 个空闲连接maxActive20限制峰值最大 20 个连接maxWait30000是拿连接的最大等待时间。如果超出 30 秒拿不到连接会抛超时。这里真正要留意的是testWhileIdle与validationQuery必须一起配置否则空闲连接被 MySQL 服务端wait_timeout干掉之后连接池不知道仍然把死连接借出去报Communications link failure的几率非常高。曾经有学生的系统就是这种问题白天考试正常中午休息一小时后再打开页面第一次查询必报错刷新一次又正常了典型的连接池空闲回收不生效。6. 给 LD 判分加上“容错护甲”把归一化相似度映射成高质量百分制相似度0.85直接乘以 100 得到 85 分听起来合理但如果学生代码只提交了参考答案的一半相似度可能达到 0.7乘出来 70 分虚高。为了避免“半份答案拿高分”这种答辩时容易被质问的情况推荐用三段式映射相似度低于 0.4 直接记 0 分0.4 到 0.75 区间按原始相似度乘 0.6 权重折减大于等于 0.75 的按照接近 1 的程度线性放大。可以用一个简单的分段函数来代替直接乘法public double toScore(double similarity, double fullScore) { if (similarity 0.4) return 0.0; if (similarity 0.75) { // 0.4~0.75 区间线性映射到 0~60% 满分 return (similarity - 0.4) / 0.35 * fullScore * 0.6; } // 0.75~1.0 区间线性映射到 60%~100% 满分鼓励完整作答 return fullScore * (0.6 (similarity - 0.75) / 0.25 * 0.4); }这么做的好处是相似度 0.7 的学生只能拿满分的 51% 左右以 10 分题为例约 5 分而不是虚高的 7 分只有真正写到 0.9 相似度以上才能拿到 8 分以上。至于 0.4 和 0.75 这两个阈值不是拍脑袋定的我会拿上一年学生的真实代码跑一遍批量判分然后把人工打分和 LD 得分画成散点图找交叉点。这是一个值得养成的习惯每次拿到一组新学生代码先跑回归再把阈值微调一遍把判分结果保存成 CSV 回头检查。最后说个教训我最早做这套系统时把判分逻辑和页面渲染完全耦合在一起每个人交卷都同步算分结果考试进行到一半服务器 CPU 直接 100%页面全部卡死。后来把判分丢进异步队列、加上预处理归一化学生才真正能同时在线考试。现在每做一个考试类项目都会先写一小段测试代码模拟 20 份作答跑通全流程再继续写业务这个习惯让后续的坑少了一大半。希望这套方案能帮你的毕业设计或者课程设计顺利交差少走我之前走过的弯路。本文还有配套的精品资源点击获取