简介:在线考试系统完整源码采用Struts+Hibernate框架,面向Java Web学习者、毕业设计开发者以及需要快速搭建考试类项目的技术人员。系统覆盖教师管理考生、上传试题、设定考试参数、发布在线考试,以及考生登录答题、自动评分、成绩查看等完整流程,同时提供MySQL数据表设计,可作为理解Struts与Hibernate分层协作及ORM映射的实战参考。压缩包共119个文件,以java源码、jsp页面、class编译文件为主,辅以xml配置、js交互脚本、css样式以及jpg/gif图片资源,整体仅341KB,结构紧凑。已有1739人学习浏览,适合具备Java基础、希望掌握Struts控制器流转与Hibernate持久化配置的开发者对照研读,借助源码类与页面文件可快速梳理考试模块实现思路并迁移复用。
1. 在线考试系统不是玩具:Struts+Hibernate 这组老架构能学到的东西
能跑起来的在线考试系统,比你自己从零写还难找;能扛住一场真实考试的 Struts+Hibernate 老项目,就更少了。这份在线考试系统 zip 属于骨架完整、业务闭环的老架构源码:登录权限、题库管理、随机组卷、在线答题、自动判分、成绩统计导出,教科书里该有的模块都覆盖到了。它适合三类人——课程设计要找底子的学生、想拆老框架整合手法的初级 Java 开发、临时要搭内部培训考试平台的从业者。下面按我拆包的顺序,把配置、业务、判分、避坑一次讲透。
2. 骨架先行:把 Struts+Hibernate 的整合跑通再动业务
拿到任何一套老框架源码,我从不先打开业务页面,而是先看两样东西:版本组合和事务边界。Struts+Hibernate 这套组合对版本非常敏感,Struts 2.3 与 2.5 的行为差异、Hibernate 5.2 与 5.3 的 API 差异,足以让一份原本完好的源码在本地环境上编译不过或者启动即崩。先把骨架立住,后面题库、组卷、判分才不会踩在沙子上。
提示:下载源码后第一件事不是启动 Tomcat,而是先核对 pom.xml 或 lib 目录里的 struts2-core 与 hibernate-core 版本,版本不配对,后面所有坑都会提前暴雷。
2.1 依赖与版本:先卡死这一组,再谈其他
老项目最常见的问题是依赖版本被 Maven 或者 IDE 自动升级,导致原本的写法全部失效。我一般会把版本固定成下面这一组,这也是这套源码所在的常见区间:
| 组件 | 推荐版本 | 备注 |
|---|---|---|
| Struts2 | 2.3.32 | 主流中文教程与老代码都是这个分支 |
| Hibernate | 5.2.18.Final | 兼容 JPA 2.1,buildSessionFactory()无参写法还能用 |
| MySQL 驱动 | 5.1.49 | 配 MySQL 5.7 稳定,驱动类名还是com.mysql.jdbc.Driver |
| hibernate-c3p0 | 5.2.18.Final | 连接池与 Hibernate 版本严格对齐 |
不建议盲目升到 Struts 2.5 或 Hibernate 5.3:Struts 2.5 之后通配符方法和动态方法调用默认收紧,老教程里*映射那一套直接失效;Hibernate 5.3 之后Configuration.buildSessionFactory()必须传StandardServiceRegistry参数,网上大量老HibernateUtil代码会编译报错。版本定死,至少能省掉一半的环境问题。
<properties> <struts2.version>2.3.32</struts2.version> <hibernate.version>5.2.18.Final</hibernate.version> </properties> <dependencies> <dependency> <groupId>org.apache.struts</groupId> <artifactId>struts2-core</artifactId> <version>${struts2.version}</version> </dependency> <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-core</artifactId> <version>${hibernate.version}</version> </dependency> <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-c3p0</artifactId> <version>${hibernate.version}</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.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 存状态
登录是这套系统里第一个完整闭环,也是拆框架整合逻辑最好的入口。Struts+Hibernate 的协作方式很直接:表单提交到 Action,Action 调 DAO,DAO 用 Hibernate 查库,查完结果放 Session。先看struts.xml配置:
<constant name="struts.devMode" value="true" /> <constant name="struts.i18n.encoding" value="UTF-8" /> <package name="exam" namespace="/" extends="struts-default"> <action name="login" class="com.demo.action.LoginAction"> <result name="success" type="redirect">/index.jsp</result> <result name="input">/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; } Map<String, 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 里name="username"的输入框会自动映射到 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 name="hibernate.connection.driver_class">com.mysql.jdbc.Driver</property> <property name="hibernate.connection.url">jdbc:mysql://localhost:3306/exam?useUnicode=true&characterEncoding=utf8</property> <property name="hibernate.connection.username">root</property> <property name="hibernate.connection.password">123456</property> <property name="hibernate.dialect">org.hibernate.dialect.MySQL5Dialect</property> <property name="hibernate.show_sql">true</property> <property name="hibernate.hbm2ddl.auto">update</property> <property name="hibernate.c3p0.max_size">20</property> <property name="hibernate.c3p0.min_size">5</property> <property name="hibernate.c3p0.timeout">120</property>参数说明:JDBC URL 里的useUnicode=true&characterEncoding=utf8是中文不乱码的第二道闸,注意 XML 里&必须写成&。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字段是给管理员下架错题用的,组卷时必须带status=1过滤,否则刚录进去还没审核的题目会随机出现在试卷里。
3.2 随机组卷:先查 ID 再洗牌,还是数据库 ORDER BY RAND()?
很多初版代码喜欢这么写:
String hql = "from Question q where q.status=1 order by rand()"; Query query = session.createQuery(hql); query.setMaxResults(30); List<Question> list = query.list();这段代码在两百条数据时毫无感觉,题库到三千条以上就开始明显卡顿。ORDER BY RAND()会要求 MySQL 对全表所有行生成随机数再排序,数据量越大越线性恶化。更麻烦的是,这种写法没法同时控制「单选 20 题、多选 10 题、判断 10 题」的比例,只能抽完再在内存里过滤,抽到数量不够又要重抽。我一般会拆成三步:先查候选 ID、内存洗牌、再按 ID 回查详情。
// 1. 按题型+难度查候选 ID,不加载完整对象 String hql = "select q.id from Question q where q.status=1 and q.type=1 order by q.id"; List<Integer> ids = session.createQuery(hql).list(); // 2. 内存洗牌,取前 needed 个 Collections.shuffle(ids); List<Integer> 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); List<Question> questions = query.list();逻辑说明:第一步只查 ID,不加载整行对象,内存开销小;第三步用setParameterList把选中的 ID 一次性带回题目详情,避免了一条一条查的 N+1 问题。这里有一个细节: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); List<Question> pageData = c.list(); // 总数单独查,别用 list().size() Number total = (Number) session.createQuery( "select count(q) from Question q where q.status=1").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.0,Integer.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() { Map<String, Object> session = ActionContext.getContext().getSession(); User user = (User) session.get("LOGIN_USER"); if (user == null) { return "login"; } List<Question> 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[]而不是Map<Integer, 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.encoding=UTF-8;JSP 头部contentType固定text/html; charset=UTF-8;JDBC URL 带characterEncoding=utf8;MySQL 建库时DEFAULT CHARSET=utf8mb4。四步都做到,基本不会再出现乱码。
坑 3:连接池耗尽,考试中途页面全部卡死
现象:在线考试运行两小时后,所有请求超时,日志报Connection is not available, request timed out。原因:DAO 里session.close()被漏掉,或者事务异常后没有回滚就直接 return,c3p0 连接只能等 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 { List<Integer> pickQuestionIds(Session session, int count); }接口只做一件事:给定一个 Session 和需要的题目数量,返回题目 ID 列表。业务层不关心具体策略怎么实现,只依赖这个接口。比如难度均衡策略:
public class DifficultyBalancedStrategy implements PaperStrategy { private int difficulty; public DifficultyBalancedStrategy(int difficulty) { this.difficulty = difficulty; } @Override public List<Integer> pickQuestionIds(Session session, int count) { List<Integer> ids = session.createQuery( "select q.id from Question q where q.status=1 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 List<Question> generatePaper(int easy, int middle, int hard) { Session session = HibernateUtil.getSession(); try { List<Integer> 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 全程不乱码。这四项全部通过,我才会把系统交给使用者。
我第一次拿这类老项目改造内部培训考试平台时,只顾着把页面调通,没做并发和边界校验,结果真到一百多人同时在线那天,连接池先爆了,当场翻车。从那以后,我每拆一套成套的老源码,都强制走一遍「三点检查」:版本组合卡死、事务边界与关闭路径、服务端必有的幂等和超时校验。这个习惯帮我避开了很多隐性问题,希望帮到你。
本文还有配套的精品资源,点击获取