news 2026/10/22 3:07:09

基于SSM的在线考试系统编程题判分:LD编辑距离算法详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于SSM的在线考试系统编程题判分:LD编辑距离算法详解

简介:编辑距离(LD)算法通过计算字符串间最少增删改次数衡量相似度,是文本匹配与代码查重的基础技术。在自动判分场景中,它能容忍变量改名、空格换行等差异,结合归一化将相似度映射为百分制分数,比逐字符比对更符合编程题的实际评分逻辑。基于SSM框架(Spring+SpringMVC+MyBatis)与MySQL构建在线考试系统时,将LD算法用于编程题自动评分,既能提升判分准确性,又能降低实现复杂度,尤其适合课程设计与毕业设计实践。文章以SSM分层架构为起点,逐步讲解数据表设计、LD算法的Java实现、异步判分流程,并剖析中文乱码、Session失效、长代码超时等常见陷阱,帮助开发者快速落地一个稳定、可解释的自动判分模块。

1. 基于 SSM 的在线考试系统:为什么编程题判分会选择 LD 算法

一个多数人想不到的事实是:在线考试系统里,编程题自动判分,很少靠“逐字符比对答案”,而是先接受一个前提——学生把变量名从sum改成total、把 for 循环里的i改成j,甚至多换了几行空行,这道题的答案仍然应该是满分。SSM + JSP + MySQL 恰好是承载这套逻辑最稳的组合:Spring 管对象、SpringMVC 管请求、MyBatis 管数据库,JSP 渲染考试页面,而 LD(Levenshtein 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 换成 Vue,Service 和 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> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.31</version> </dependency> <!-- MyBatis 核心 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.14</version> </dependency> <!-- MyBatis 与 Spring 整合 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- JSTL 标签库 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.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 id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/exam_system?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="你的密码"/> </bean> <!-- SqlSessionFactory --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <!-- 扫描 Mapper 接口 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.exam.mapper"/> </bean>

这里最容易被忽略的是url里的&amp;。在 XML 里写&字符必须转义成&amp;,否则 XML 解析直接失败。useUnicode=true和characterEncoding=utf8是中文不乱码的第一道防线,serverTimezone=Asia/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. 编程题自动判分的核心:LD(Levenshtein)算法的评分实现

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;同时用 Spring@Scheduled定时任务每小时扫一次超时未提交的记录,强制置为“超时自动交卷”状态。

/** * 超时检查:扫描所有进行中的考试记录,标记超时 */ @Scheduled(fixedRate = 60000) // 每 60 秒执行一次 public void autoSubmitExpiredExams() { List<ExamRecord> 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 表字符集建表时指定utf8mb4;JDBC 连接串加characterEncoding=utf8;Spring 的字符编码过滤器必须放在 web.xml 最前面。

<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

forceEncoding=true很关键。不设置时,如果请求里已经有Content-Type: text/html;charset=ISO-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-timeout>120</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 Set<String> 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(); Future<Double> 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 id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="url" value="jdbc:mysql://localhost:3306/exam_system?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=Asia/Shanghai"/> <property name="initialSize" value="5"/> <property name="minIdle" value="5"/> <property name="maxActive" value="20"/> <property name="maxWait" value="30000"/> <property name="testWhileIdle" value="true"/> <property name="validationQuery" value="SELECT 1"/> </bean>

minIdle=5保证最低保留 5 个空闲连接,maxActive=20限制峰值最大 20 个连接,maxWait=30000是拿连接的最大等待时间。如果超出 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 份作答,跑通全流程再继续写业务,这个习惯让后续的坑少了一大半。希望这套方案能帮你的毕业设计或者课程设计顺利交差,少走我之前走过的弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 3:07:08

GEO监测实战:从AI推荐权到品牌可见度的2026必修课

你试过对着某AI问答助手问一句“帮我推荐一个好用的项目管理软件”吗&#xff1f;多数人会得到一个列了三个名字、附上一段点评的答案。你的品牌如果在那个名单里&#xff0c;这一轮面向用户的“推荐权”就赢了&#xff1b;如果不在&#xff0c;你连被用户注意到的机会都没有。…

作者头像 李华
网站建设 2026/10/22 3:05:35

LangGraph 1.0 速通:用 StateGraph 构建状态驱动型智能体工作流

如果你最近在折腾智能体开发&#xff0c;大概率绕不开LangGraph这个名字。它1.0正式版出来之后&#xff0c;把早期版本里那些东拼西凑的API和示例收拢了一遍&#xff0c;整体思路清爽了不少。所谓速通指南&#xff0c;其实就是把一条路从起点完整走到终点&#xff1a;状态、节点…

作者头像 李华
网站建设 2026/10/22 3:05:35

Claude Code接入GLM Coding Plan:配置教程与实战体验

最近一段时间&#xff0c;AI编程工具圈子里最热门的话题之一&#xff0c;就是Claude Code这个强大的终端编程助手&#xff0c;以及各家模型厂商推出的订阅套餐。作为一个几乎每天都在跟代码打交道的开发者&#xff0c;我花了整整一周时间&#xff0c;把Claude Code接入GLM Codi…

作者头像 李华
网站建设 2026/10/22 3:05:35

Porto v7.2.8主题实战:从安装到性能优化的避坑指南

简介&#xff1a;Porto是一款定位多用途的WordPress商业主题&#xff0c;v7.2.8专为WooCommerce网店场景深度优化&#xff0c;同时覆盖商务、企业、博客等站点类型&#xff0c;适合需要快速搭建响应式网站、希望以可视化方式自由定制页面版式的中高级建站者。主题内置拖放式页面…

作者头像 李华
网站建设 2026/10/22 3:05:33

Agent Skills 实战指南:从入门到精通的完整路径与架构设计

直接说结论&#xff1a;Agent Skills 这个概念&#xff0c;正在把“做大模型应用”这件事从“炼丹”变成“搭积木”。我最近把几个线上项目的架构都往这个方向重构了一遍&#xff0c;最直观的感受是&#xff1a;以前调一个复杂 Agent 的行为&#xff0c;要靠改 System Prompt、…

作者头像 李华
网站建设 2026/10/22 3:04:48

从零手写Notepad_QT简单记事本:桌面开发最佳练手项目

简介&#xff1a;这是一份基于Qt框架实现的轻量级记事本源码项目&#xff0c;面向Qt初学者、C入门者以及需要简洁文本编辑工具的开发者。项目定位为无状态栏、无查找替换的基础记事本&#xff0c;界面干净&#xff0c;适合用来学习Qt窗口创建、用户输入处理与文件读写等核心操作…

作者头像 李华