Struts+Hibernate在线考试系统源码解析:组卷判分与事务实践
发布时间:2026/10/14 0:54:50 锦皓数字建站

简介在线考试系统完整源码采用StrutsHibernate框架面向Java Web学习者、毕业设计开发者以及需要快速搭建考试类项目的技术人员。系统覆盖教师管理考生、上传试题、设定考试参数、发布在线考试以及考生登录答题、自动评分、成绩查看等完整流程同时提供MySQL数据表设计可作为理解Struts与Hibernate分层协作及ORM映射的实战参考。压缩包共119个文件以java源码、jsp页面、class编译文件为主辅以xml配置、js交互脚本、css样式以及jpg/gif图片资源整体仅341KB结构紧凑。已有1739人学习浏览适合具备Java基础、希望掌握Struts控制器流转与Hibernate持久化配置的开发者对照研读借助源码类与页面文件可快速梳理考试模块实现思路并迁移复用。1. 在线考试系统不是玩具StrutsHibernate 这组老架构能学到的东西能跑起来的在线考试系统比你自己从零写还难找能扛住一场真实考试的 StrutsHibernate 老项目就更少了。这份在线考试系统 zip 属于骨架完整、业务闭环的老架构源码登录权限、题库管理、随机组卷、在线答题、自动判分、成绩统计导出教科书里该有的模块都覆盖到了。它适合三类人——课程设计要找底子的学生、想拆老框架整合手法的初级 Java 开发、临时要搭内部培训考试平台的从业者。下面按我拆包的顺序把配置、业务、判分、避坑一次讲透。2. 骨架先行把 StrutsHibernate 的整合跑通再动业务拿到任何一套老框架源码我从不先打开业务页面而是先看两样东西版本组合和事务边界。StrutsHibernate 这套组合对版本非常敏感Struts 2.3 与 2.5 的行为差异、Hibernate 5.2 与 5.3 的 API 差异足以让一份原本完好的源码在本地环境上编译不过或者启动即崩。先把骨架立住后面题库、组卷、判分才不会踩在沙子上。提示下载源码后第一件事不是启动 Tomcat而是先核对 pom.xml 或 lib 目录里的 struts2-core 与 hibernate-core 版本版本不配对后面所有坑都会提前暴雷。2.1 依赖与版本先卡死这一组再谈其他老项目最常见的问题是依赖版本被 Maven 或者 IDE 自动升级导致原本的写法全部失效。我一般会把版本固定成下面这一组这也是这套源码所在的常见区间组件推荐版本备注Struts22.3.32主流中文教程与老代码都是这个分支Hibernate5.2.18.Final兼容 JPA 2.1buildSessionFactory()无参写法还能用MySQL 驱动5.1.49配 MySQL 5.7 稳定驱动类名还是com.mysql.jdbc.Driverhibernate-c3p05.2.18.Final连接池与 Hibernate 版本严格对齐不建议盲目升到 Struts 2.5 或 Hibernate 5.3Struts 2.5 之后通配符方法和动态方法调用默认收紧老教程里*映射那一套直接失效Hibernate 5.3 之后Configuration.buildSessionFactory()必须传StandardServiceRegistry参数网上大量老HibernateUtil代码会编译报错。版本定死至少能省掉一半的环境问题。properties struts2.version2.3.32/struts2.version hibernate.version5.2.18.Final/hibernate.version /properties dependencies dependency groupIdorg.apache.struts/groupId artifactIdstruts2-core/artifactId version${struts2.version}/version /dependency dependency groupIdorg.hibernate/groupId artifactIdhibernate-core/artifactId version${hibernate.version}/version /dependency dependency groupIdorg.hibernate/groupId artifactIdhibernate-c3p0/artifactId version${hibernate.version}/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency /dependencies逻辑说明struts2-core提供 MVC 控制器与拦截器hibernate-core提供 ORM 映射hibernate-c3p0负责连接池。这里把连接池单列出来是因为在线考试系统是典型的请求密集型应用每次请求都要拿数据库连接没有连接池直接裸连几分钟就会被拖垮。版本号用 Maven 属性统一管理后续想升驱动或组件只改一处即可。参数说明MySQL 驱动 5.1.49 对应的 JDBC URL 里驱动类名是com.mysql.jdbc.Driver如果你本机装的是 MySQL 8需要换成com.mysql.cj.jdbc.Driver以及mysql-connector-java8.x这是老项目迁移新数据库时最容易翻车的第一站。Hibernate 的hbm2ddl.auto我建议控制在update别用create否则每次启动都可能清空题库表那真的是没有后悔药。2.2 登录闭环Action 接参数、Hibernate 查库、Session 存状态登录是这套系统里第一个完整闭环也是拆框架整合逻辑最好的入口。StrutsHibernate 的协作方式很直接表单提交到 ActionAction 调 DAODAO 用 Hibernate 查库查完结果放 Session。先看struts.xml配置constant namestruts.devMode valuetrue / constant namestruts.i18n.encoding valueUTF-8 / package nameexam namespace/ extendsstruts-default action namelogin classcom.demo.action.LoginAction result namesuccess typeredirect/index.jsp/result result nameinput/login.jsp/result /action /package参数说明struts.devMode在调试期必须开部署时务必关掉否则异常堆栈会直接回给浏览器类名、包名、SQL 全部暴露。struts.i18n.encoding配 UTF-8 是中文不乱码的第一道闸。loginAction 的success用redirect而不是默认转发原因是防止考生按 F5 刷新时重复提交登录表单input则返回登录页并展示错误信息。public class LoginAction extends ActionSupport { private String username; private String password; private UserService userService new UserService(); Override public String execute() { if (username null || password null) { return INPUT; } User user userService.findByLogin(username.trim(), password); if (user null) { addActionError(用户名或密码不对); return INPUT; } MapString, Object session ActionContext.getContext().getSession(); session.put(LOGIN_USER, user); return SUCCESS; } public void setUsername(String username) { this.username username; } public void setPassword(String password) { this.password password; } }逻辑说明Struts2 里表单字段按 setter 注入所以 JSP 里nameusername的输入框会自动映射到 Action 的username属性不需要手动request.getParameter()。ActionContext.getContext().getSession()拿到的是容器管理的 Session Map跨请求可用登录用户存进去之后后面的拦截器、页面标签都能直接取。public User findByLogin(String username, String password) { Session session HibernateUtil.getSession(); Transaction tx null; try { tx session.beginTransaction(); User user (User) session.createQuery( from User u where u.username :uname and u.password :pwd) .setParameter(uname, username) .setParameter(pwd, password) .uniqueResult(); tx.commit(); return user; } catch (RuntimeException e) { if (tx ! null) { tx.rollback(); } throw e; } finally { session.close(); } }参数说明HQL 里用:uname和:pwd做参数绑定而不是字符串拼接 where u.username username 这是防 SQL 注入的关键写法很多老项目翻车就翻在这里。uniqueResult()在查询结果多于一条时会抛异常所以User表的username字段必须加唯一约束。密码这里只是演示真实项目至少做一层 MD5 加盐别把明文直接落库。2.3 事务边界Session 到底开在哪决定项目会不会半夜崩在线考试系统的请求特征是有读有写而且写入动作集中在交卷那一下。事务边界如果放得太碎每个 DAO 方法都自己openSession、close业务层想同时写成绩单和答案明细就需要手动拼事务如果放得太宽把 Session 挂在 Action 成员变量上并发请求会共享同一个 Session轻则数据串重则直接抛NonUniqueObjectException。public class HibernateUtil { private static final SessionFactory sessionFactory; static { try { Configuration cfg new Configuration().configure(); sessionFactory cfg.buildSessionFactory(); } catch (Throwable t) { throw new ExceptionInInitializerError(t); } } public static Session openSession() { return sessionFactory.openSession(); } }逻辑说明configure()默认读取 classpath 根目录下的hibernate.cfg.xml。SessionFactory是重量级、线程安全的对象整个应用只初始化一次如果每个请求都 new 一个连接池会被反复创建c3p0 配置直接失效这是老项目里连接池爆掉的重要前兆。openSession每次返回独立 Session用完必须close否则连接泄漏。property namehibernate.connection.driver_classcom.mysql.jdbc.Driver/property property namehibernate.connection.urljdbc:mysql://localhost:3306/exam?useUnicodetrueamp;characterEncodingutf8/property property namehibernate.connection.usernameroot/property property namehibernate.connection.password123456/property property namehibernate.dialectorg.hibernate.dialect.MySQL5Dialect/property property namehibernate.show_sqltrue/property property namehibernate.hbm2ddl.autoupdate/property property namehibernate.c3p0.max_size20/property property namehibernate.c3p0.min_size5/property property namehibernate.c3p0.timeout120/property参数说明JDBC URL 里的useUnicodetruecharacterEncodingutf8是中文不乱码的第二道闸注意 XML 里必须写成amp;。c3p0 的max_size是连接池上限别超过数据库max_connections的 70%timeout单位是秒超过 120 秒空闲连接会被回收。我一般会把show_sql在部署环境关掉这个开关一旦开着高峰期日志量能把磁盘写满。3. 题库与组卷把 CRUD 做成业务逻辑的三道关键选择题库模块看起来就是增删改查但组卷这个动作把难度拉高了几个等级题目必须按题型、难度筛选还要满足随机性同时不能拖垮数据库。这一步做不好前端页面再漂亮考试一开就是卡死现场。3.1 题库表设计题型、难度、状态三个字段一次定对在线考试系统的题库表核心字段其实就三个决策点题型怎么存、答案怎么存、题目怎么停用。先用 SQL 把表结构定下来CREATE TABLE t_question ( id INT PRIMARY KEY AUTO_INCREMENT, type TINYINT NOT NULL COMMENT 1单选 2多选 3判断, difficulty TINYINT DEFAULT 1 COMMENT 1易 2中 3难, content TEXT NOT NULL, option_a VARCHAR(200), option_b VARCHAR(200), option_c VARCHAR(200), option_d VARCHAR(200), answer VARCHAR(20) NOT NULL COMMENT 单选:A 多选:AB 判断:T/F, score INT DEFAULT 2, status TINYINT DEFAULT 1 COMMENT 0禁用 1启用, create_time DATETIME );字段说明type用 TINYINT 存 1/2/3而不是字符串枚举因为 Hibernate 做条件查询和排序时数字更高效。answer用 VARCHAR(20) 存纯字母或T/F这里有个容易犯的错——把判断题答案存成“对/错”或“√/×”看起来直观判分时却要多写一套转换逻辑字符串比对还容易出编码问题。status字段是给管理员下架错题用的组卷时必须带status1过滤否则刚录进去还没审核的题目会随机出现在试卷里。3.2 随机组卷先查 ID 再洗牌还是数据库 ORDER BY RAND()很多初版代码喜欢这么写String hql from Question q where q.status1 order by rand(); Query query session.createQuery(hql); query.setMaxResults(30); ListQuestion list query.list();这段代码在两百条数据时毫无感觉题库到三千条以上就开始明显卡顿。ORDER BY RAND()会要求 MySQL 对全表所有行生成随机数再排序数据量越大越线性恶化。更麻烦的是这种写法没法同时控制「单选 20 题、多选 10 题、判断 10 题」的比例只能抽完再在内存里过滤抽到数量不够又要重抽。我一般会拆成三步先查候选 ID、内存洗牌、再按 ID 回查详情。// 1. 按题型难度查候选 ID不加载完整对象 String hql select q.id from Question q where q.status1 and q.type1 order by q.id; ListInteger ids session.createQuery(hql).list(); // 2. 内存洗牌取前 needed 个 Collections.shuffle(ids); ListInteger picked ids.subList(0, Math.min(needed, ids.size())); // 3. 用 IN 查出完整题目 String hql2 from Question q where q.id in (:pickedList); Query query session.createQuery(hql2); query.setParameterList(pickedList, picked); ListQuestion questions query.list();逻辑说明第一步只查 ID不加载整行对象内存开销小第三步用setParameterList把选中的 ID 一次性带回题目详情避免了一条一条查的 N1 问题。这里有一个细节Hibernate 用IN查出来的 List 顺序不保证和picked的顺序一致试卷布局如果不要求题型内固定顺序可以忽略如果要求难易交替就在内存里把questions按 ID 塞进 Map再按picked顺序重排。参数说明needed由组卷规则决定比如单选题 20 题、多选题 10 题、判断题 10 题。数据库层给(status, type, difficulty)建联合索引这一步能把这套方案的耗时从秒级压到百毫秒级。至于有人说直接用SELECT id FROM t_question WHERE id FLOOR(RAND()*MAX(id))来随机我试过那个方案最大的问题是 ID 分布不均删除过题目之后新题的 ID 集中在尾部抽出来的题总是那几张新面孔。3.3 分页查询与批量导入POI 解析 Excel 的边界问题题库管理列表是典型的分页场景Hibernate 的Criteria写法比 HQL 拼接更直观int pageNo 1; int pageSize 20; Criteria c session.createCriteria(Question.class); c.add(Restrictions.eq(status, 1)); c.setFirstResult((pageNo - 1) * pageSize); c.setMaxResults(pageSize); ListQuestion pageData c.list(); // 总数单独查别用 list().size() Number total (Number) session.createQuery( select count(q) from Question q where q.status1).uniqueResult();参数说明setFirstResult从 0 开始计数所以页号减一后再乘页大小。总数必须单独发一条count查询如果你偷懒把list()结果拿来size()等于把全表几千条记录全部加载到内存只为显示一个页码这个习惯在考试系统这种请求密集场景下会很致命。Excel 批量导入是题库模块另一个高频功能POI 解析时最隐蔽的坑是数字单元格try (Workbook wb WorkbookFactory.create(inputStream)) { Sheet sheet wb.getSheetAt(0); DataFormatter fmt new DataFormatter(); for (int i 1; i sheet.getLastRowNum(); i) { Row r sheet.getRow(i); if (r null) { continue; } Question q new Question(); q.setType(Integer.parseInt(fmt.formatCellValue(r.getCell(0)))); q.setContent(fmt.formatCellValue(r.getCell(1))); q.setAnswer(fmt.formatCellValue(r.getCell(2)).toUpperCase()); session.save(q); if (i % 50 0) { session.flush(); session.clear(); } } }逻辑说明DataFormatter.formatCellValue能把数字单元格统一转成字符串不然 Excel 里题型列的「1」可能会被 POI 读成1.0Integer.parseInt直接抛异常。每 50 条flush加clear是 Hibernate 批量写入的保命操作——Session 是一级缓存攒着不刷的话导入几千道题时堆内存会被撑爆GC 都救不回来。边界问题补充Excel 答案列如果录入的是「A, B」或「A/B」导入时必须在校验层统一转成「AB」否则判分阶段字符串比对全错。我一般会在导入方法里加一个normalizeAnswer函数把逗号、顿号、空格全部清洗掉再落库这个动作能省掉后面一大半的数据修复工作。4. 在线答题与判分快照、比对与服务端兜底答题和判分是这套系统最核心的业务也是最容易写崩的部分。我拆过的考试系统源码里这里普遍存在两个想当然把试卷数据反复查库、把判分逻辑写在 JSP 里。前者拖垮性能后者直接把规则暴露给前端。4.1 答题会话设计试卷快照放 Session答案放哪进入考场时不是每次点击「下一题」都查一次数据库而是把组好的试卷整体放进 Session作为这场考试的快照。这个设计有两个直接好处一是考生答题过程中后台管理员即使误改、误删了题目考生手里的试卷和最终判分用的还是同一份数据二是省掉了大量重复查询考试期间的数据库压力几乎只集中在交卷那一刻。public class ExamStartAction extends ActionSupport { private PaperService paperService new PaperService(); public String execute() { MapString, Object session ActionContext.getContext().getSession(); User user (User) session.get(LOGIN_USER); if (user null) { return login; } ListQuestion paper paperService.generatePaper(20, 10, 10); session.put(PAPER, paper); session.put(EXAM_START_TIME, System.currentTimeMillis()); session.put(USER_ANSWERS, new String[paper.size()]); return SUCCESS; } }参数说明USER_ANSWERS用定长String[]而不是MapInteger, String因为试卷顺序已经固定数组下标直接对应题目位置判分时按索引遍历即可避免了 Map 遍历顺序不一致导致的答案错位。EXAM_START_TIME记的是服务器时间而不是客户端时间后面超时校验全靠它如果记本地时间考生改一下系统时间就能无限延长考试。答题过程中前端每个input的name写成answer[0]、answer[1]这种形式Struts2 会自动绑定到 Action 的String[] answers属性天然与数组下标对齐。考生每切换一道题前端把当前值写回数组对应位置即可不用跟服务器通信。4.2 自动判分单选多选判断题的比对逻辑与容错判分规则要在写代码之前定死多选题错选、漏选是 0 分还是半对判断题答案统一用什么符号这些决策直接影响比对函数。下面给出一个能覆盖三种题型的判分核心private boolean judge(String stdAnswer, String userAnswer, int type) { if (stdAnswer null || userAnswer null) { return false; } if (type 2) { char[] stdChars stdAnswer.toCharArray(); char[] userChars userAnswer.toCharArray(); Arrays.sort(stdChars); Arrays.sort(userChars); return Arrays.equals(stdChars, userChars); } return stdAnswer.equals(userAnswer); }逻辑说明多选题先排序再比较解决「AB」和「BA」到底算不算对的争议单选和判断直接全等比较。判断题答案统一存T和F导入时就把「对/错」「√/×」「Y/N」全部归一化判分路径只有一条不会出现五种答案格式五种判分代码的混乱局面。提交试卷时逐题比对、累加得分同时落两条数据一条成绩主记录一条答案明细记录。明细记录很重要老师看统计时能知道某道题多少人选错学生申诉时也能按questionId定位原始答案。判分循环里有一个必须处理的动作String g given[i] null ? : given[i].toUpperCase().replaceAll([^A-Z], );这行代码把用户答案里的空格、逗号、顿号全部清洗掉比如「A, B」会被规整成「AB」。如果不做这步任何前端录入格式不一致都会导致该题判 0 分而且这种错误在测试时很难发现因为你自己录入的格式永远是统一的考生可不会按你的格式来。4.3 倒计时与交卷前端定时器只是摆设服务端必须兜底前端倒计时是考试系统最容易给人安全感、实际上最不可信的部分let left 120 * 60; const timer setInterval(() { left--; const mm Math.floor(left / 60); const ss String(left % 60).padStart(2, 0); document.getElementById(countdown).textContent mm : ss; if (left 0) { clearInterval(timer); document.getElementById(examForm).submit(); } }, 1000);这段代码的作用只是展示剩余时间和到点后自动触发交卷考生打开浏览器控制台把left改大并不会让服务器放宽考试时长。真正的倒计时校验必须放在服务端交卷接口里long start (Long) session.get(EXAM_START_TIME); long duration (System.currentTimeMillis() - start) / 1000; long limit 120 * 60 60; if (duration limit) { // 60 秒只是网络延迟容忍不是额外延长考试时间 throw new BusinessException(已超时请重新进入考试); }参数说明limit里的 60 秒用来容忍网络抖动避免考生因为一次请求延迟直接被判超时。校验超时的动作必须放在判分和落库之前否则考生在考试结束后重放旧的交卷请求后台会把一份未完成的答题数组判成 0 分或正常分产生脏数据。服务端另一个必须兜底的点是幂等交卷成功后立刻把 Session 里的PAPER清掉第二次提交同一个请求时发现PAPER不存在直接走expired分支。前端交卷按钮点击后置灰只是体验问题服务端的幂等校验才是数据不被写两遍的保证这个坑在后面避坑章节还会展开。5. 成绩统计与避坑排查上线前的 5 个高频翻车点成绩模块是考完试之后老师第一眼要看的东西也是这套系统交付时最容易返工的部分。很多老源码在这里只存一个总分看起来简单真到要分析「哪道题错误率最高」的时候就傻眼了。这一章先讲表结构和导出再集中列几个我拆包时反复踩过的坑。5.1 成绩表设计一次考试一张快照别只存一个总分成绩表至少拆两张一张考试主记录一张答案明细。主记录存用户、试卷、总分、耗时、提交时间明细表存每道题的题目 ID、考生答案、是否正确。为什么要这么做如果只存总分题库后续改版、题目被删历史成绩就彻底无法回溯有了明细快照哪怕题目后来被删了老师依然能看出考生当时的作答情况。CREATE TABLE t_exam_record ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, paper_name VARCHAR(100), total_score INT, duration_seconds INT, submit_time DATETIME ); CREATE TABLE t_answer_record ( id INT PRIMARY KEY AUTO_INCREMENT, exam_record_id INT NOT NULL, question_id INT NOT NULL, user_answer VARCHAR(20) DEFAULT , is_correct TINYINT DEFAULT 0, KEY idx_exam_record (exam_record_id) );参数说明t_answer_record的exam_record_id必须建普通索引否则成绩列表页展开某一场考试的明细时会变成全表扫描。成绩明细和历史试卷内容有关联需求的话建议再给t_question加一个version或快照字段但那是后话至少这两张表已经能让「考生申诉」「错误率统计」这两个高频问询有据可查。5.2 成绩导出POI 低内存写法的切换导出成绩 Excel 是老项目里最常见的 OOM 现场问题根源在于用了HSSFWorkbook生成全量数据再写文件。学生规模一上来几百人乘几十道题的明细堆内存直接爆掉。我一般直接用SXSSFWorkbook做流式写入SXSSFWorkbook wb new SXSSFWorkbook(100); try { Sheet sheet wb.createSheet(成绩明细); for (int i 0; i data.size(); i) { Row row sheet.createRow(i); row.createCell(0).setCellValue(data.get(i).getStudentName()); row.createCell(1).setCellValue(data.get(i).getScore()); } wb.write(outputStream); } finally { wb.dispose(); }参数说明SXSSFWorkbook(100)表示内存中最多保留 100 行超出部分自动写入临时文件几十万行的数据导出不会把堆吃穿。wb.dispose()必须放在 finally 里执行它负责清理临时文件漏掉的话服务器磁盘会被导出的临时文件塞满。老代码里的HSSFWorkbook除了内存问题还有 65536 行上限超过直接抛异常这一点在导出全校成绩时几乎是必炸的雷。5.3 避坑现场5 个常见问题下面这五个坑是我在拆这类老项目时几乎每次都会遇到的按「现象 → 原因 → 解决」的顺序写清楚。坑 1页面打开报 LazyInitializationException现象考试记录列表页渲染考生信息时抛出could not initialize proxy - no Session。原因Hibernate 的关联默认懒加载Action 返回后 Session 已关闭视图层再访问关联对象就炸了。解决三选一——把关联改为EAGER数据量小可用、在 Action 内主动访问一次关联对象触发加载、或者配置 Open Session In View 过滤器。第三种虽然省事但会把事务边界拖到视图层连接占用时间变长考试场景下我一般不推荐。坑 2中文乱码控制台和数据库里全是问号现象登录用户名输入「张三」控制台打印出å¼ ä¸‰存库后也是乱码。原因Struts 编码拦截器没生效、JSP 页面编码不是 UTF-8、JDBC URL 没指定字符集三个环节断了任何一个都会乱。解决四步走——struts.xml里struts.i18n.encodingUTF-8JSP 头部contentType固定text/html; charsetUTF-8JDBC URL 带characterEncodingutf8MySQL 建库时DEFAULT CHARSETutf8mb4。四步都做到基本不会再出现乱码。坑 3连接池耗尽考试中途页面全部卡死现象在线考试运行两小时后所有请求超时日志报Connection is not available, request timed out。原因DAO 里session.close()被漏掉或者事务异常后没有回滚就直接 returnc3p0 连接只能等 timeout 回收连接越占越多直到耗尽。解决所有 Session 操作统一finally { session.close(); }事务异常走catch回滚再抛出c3p0max_size不要超过数据库max_connections的 70%。考前用脚本并发 50 个请求压一遍连接泄漏在压测下会暴露得特别快。坑 4重复交卷同一考生出现两条成绩现象网络慢考生多点几次交卷后台出现同一用户同一场次两条成绩记录。原因前端按钮没置灰服务端没做幂等校验。解决三层堵漏——前端提交按钮点击后禁用服务端落库前先查t_exam_record是否有该用户该场次记录最稳的是在(user_id, exam_id)上加唯一索引让数据库做最后一道防线。这个唯一索引是我被学生薅过之后才补上的属于典型的「没有后悔药就自己造后悔药」。坑 5组卷接口越来越慢题库到 4000 题后要等三秒现象进入考场接口耗时线性增长题库越大越卡。原因组卷用了ORDER BY RAND()MySQL 需要全表生成随机数再排序。解决按第 3 章方案改成「先查 ID → 内存洗牌 → IN 回查」配合(status, type, difficulty)联合索引实测从三秒压到两百毫秒左右。这属于数据库选型问题不是 Hibernate 的问题换 JDBC 也一样会卡根因必须从 SQL 层面解决。6. 从能跑到好用组卷策略独立成扩展点源码能跑起来只是及格线真正值钱的是这套系统的扩展结构。我拿到这类老项目做二次开发时最先做的一件事就是把组卷逻辑从PaperService里抽出来定义成策略接口。为什么因为不同考试对组卷规则的要求完全不同入学测试要简单题居多结业考试要难度均衡竞赛要全部抽难题。如果组卷逻辑写死在 Action 里需求一变就要改老代码改一次崩一次。public interface PaperStrategy { ListInteger pickQuestionIds(Session session, int count); }接口只做一件事给定一个 Session 和需要的题目数量返回题目 ID 列表。业务层不关心具体策略怎么实现只依赖这个接口。比如难度均衡策略public class DifficultyBalancedStrategy implements PaperStrategy { private int difficulty; public DifficultyBalancedStrategy(int difficulty) { this.difficulty difficulty; } Override public ListInteger pickQuestionIds(Session session, int count) { ListInteger ids session.createQuery( select q.id from Question q where q.status1 and q.difficulty:level order by q.id) .setParameter(level, difficulty) .list(); Collections.shuffle(ids); return ids.subList(0, Math.min(count, ids.size())); } }调用方通过组合多个策略生成整套试卷public ListQuestion generatePaper(int easy, int middle, int hard) { Session session HibernateUtil.getSession(); try { ListInteger ids new ArrayList(); ids.addAll(new DifficultyBalancedStrategy(1).pickQuestionIds(session, easy)); ids.addAll(new DifficultyBalancedStrategy(2).pickQuestionIds(session, middle)); ids.addAll(new DifficultyBalancedStrategy(3).pickQuestionIds(session, hard)); return selectByIds(session, ids); } finally { session.close(); } }逻辑说明后面要加「按知识点比例抽题」「按历史错题率抽题」直接新增实现类老代码一行都不用动。selectByIds负责最终的IN查询和顺序还原这部分所有策略共用。抽成接口之后这套在线考试系统的组卷模块就从一个「能跑的函数」变成了「可扩展的框架」这才是源码资源真正值得复用的地方。拿这套源码做二次开发时我的验证清单永远固定四件事第一组卷接口在 3000 条题库、20 并发下跑 100 次看平均耗时和连接数峰值第二用脚本模拟同一考生重复提交两次交卷请求确认数据库只有一条成绩第三考试中途在后台删掉一道题考生正常交卷后判分仍然以 Session 快照为准第四清空数据库后按 UTF-8 重新导入一遍中文题目确认从 Excel 到 MySQL 全程不乱码。这四项全部通过我才会把系统交给使用者。我第一次拿这类老项目改造内部培训考试平台时只顾着把页面调通没做并发和边界校验结果真到一百多人同时在线那天连接池先爆了当场翻车。从那以后我每拆一套成套的老源码都强制走一遍「三点检查」版本组合卡死、事务边界与关闭路径、服务端必有的幂等和超时校验。这个习惯帮我避开了很多隐性问题希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。