在线考试系统这个方向,算是Java全栈项目里非常经典的一个了。我前前后后做过几个不同版本的,从最初的单体JSP项目到后来前后端分离的SpringBoot+Vue方案,踩了不少坑,也积累了一些经验。最近刚好把一套基于SpringBoot+Vue的在线考试答题系统完整重构了一遍,从数据库设计到前后端联调都重新梳理过,这里把整个思路和关键实现细节做一个完整的复盘,希望能给正在做类似项目或者准备搞毕设的同学一些参考。
这套系统到底能干什么?用户角色分成三类:管理员、教师、学生。管理员负责系统基础配置和用户管理,教师负责创建考试、录入题库、批改主观题、查看成绩统计,学生登录后参加考试、查看分数和错题。核心业务流程就是:题库管理 → 创建考试 → 随机组卷 → 学生答题 → 自动判分 → 成绩统计,另外还要盯住考试过程中的防作弊和异常处理,比如断网续考、超时交卷这些场景。
1. 项目整体设计与技术选型拆解
1.1 为什么选SpringBoot+Vue这套组合
很多人纠结技术栈,其实选型背后逻辑很简单:看生态成熟度、看招聘市场需求、看自己能hold住的程度。SpringBoot目前在Java后端领域基本是事实标准,内嵌Tomcat、自动配置、Starter机制让开发效率提升好几个档次。Vue作为前端渐进式框架,上手曲线平缓,配合Element UI做后台管理界面非常顺手。
我选择SpringBoot 2.7.x + Vue 2.6.x这套组合,有几个实际考量。SpringBoot 2.7虽然不算最新,但稳定性和第三方库兼容性都经过了市场验证,网上能找到的资料最多,遇到问题几乎都能搜到解决方案。Vue 2.6搭配vue-element-admin这类现成的后台模板,开发效率极高。如果你用Vue 3 + Element Plus,语法差异和生态变化会增加不少试错成本,除非你很熟Vue 3,否则建议保守一点。
1.2 前后端分离架构与项目结构设计
整个系统严格采用前后端分离架构,前端和后端是完全独立的两个工程,通过RESTful接口通信。前端的静态资源单独部署,后端只提供API。这样做的好处是前后端可以并行开发、独立部署、独立扩容。
后端项目结构采用经典的分层架构:
exam-system/ ├── pom.xml └── src/main/java/com/example/exam/ ├── ExamApplication.java // 启动类 ├── config/ // 配置类(跨域、拦截器、异常处理) ├── controller/ // 控制层 │ ├── admin/ // 管理员相关接口 │ ├── teacher/ // 教师相关接口 │ └── student/ // 学生相关接口 ├── service/ // 业务逻辑层 │ └── impl/ ├── mapper/ // MyBatis数据访问层 ├── entity/ // 实体类 ├── dto/ // 数据传输对象 ├── vo/ // 视图对象(返回给前端的数据封装) ├── common/ // 通用工具、常量、统一返回结果 └── util/ // 工具类这里重点提一下分层的重要性。很多初学者喜欢把业务逻辑写在Controller里,图省事,但项目一旦复杂起来就彻底乱套了。Controller只负责参数接收和结果返回,Service层处理核心业务逻辑,Mapper层只做数据库操作。每一层各司其职,后期维护调试才能有的放矢。我在重构时就吃过这种亏,早期版本Controller里有大量业务代码,改个判分逻辑要翻好几个文件,这次彻底梳理清楚了。
前端部分,我没有用完整的vue-element-admin那个重量级模板,而是基于Vue CLI手动搭建了一个轻量化的后台管理结构,更清爽也更容易理解:
exam-frontend/ ├── public/ ├── src/ │ ├── api/ // 接口请求封装 │ ├── assets/ // 静态资源 │ ├── components/ // 公共组件 │ ├── router/ // 路由配置 │ ├── store/ // Vuex状态管理 │ ├── utils/ // 工具函数(request拦截器等) │ ├── views/ // 页面视图 │ │ ├── admin/ │ │ ├── teacher/ │ │ └── student/ │ ├── App.vue │ └── main.js ├── package.json └── vue.config.js1.2 环境版本搭配的坑
版本搭配这事情看着简单,实际踩坑无数。我最终确定的一套稳定版本组合是这样的:JDK 1.8(别用17,除非你很清楚兼容性问题)、SpringBoot 2.7.6、MyBatis 3.5.x + mybatis-plus 3.5.x(增强插件可以少写很多SQL)、MySQL 5.7(8.0也行,注意驱动和时区配置差异)、Redis(可选,做验证码缓存和分布式会话)、Vue 2.6.14 + Vue Router 3.x + Vuex 3.x + Element UI 2.15.x。
这里要多说一句,千万别自信到把所有依赖都升级到最新版。我试过一次把SpringBoot升到3.0,结果javax命名空间换成了jakarta,一堆第三方依赖全部报错,折腾了两天才回退。做项目讲究的是稳,而不是新。
2. 核心业务模块与数据库设计思路
2.1 五大核心业务模块拆解
一套完整的在线考试系统,从业务功能上拆解,核心模块就这么几个:用户权限模块、题库管理模块、考试管理模块、在线答题模块、成绩统计模块。
用户权限这块,我们用的是Spring Security + JWT的方案,按角色区分接口访问权限。管理员管用户列表、系统配置;教师管题库、试卷、批改;学生管考试、答题、成绩查询。JWT无状态认证适合前后端分离架构,不需要在服务端存Session,前端拿到Token后每次请求带在请求头里就行。
题库管理模块支持单选题、多选题、判断题、填空题和主观题五种题型。每种题型有不同的字段属性和判分策略。前端用富文本编辑器实现题目和选项的录入,图片支持上传到本地服务器或OSS。
考试管理模块是系统里最复杂的部分,涉及创建考试、选择题目(手动选题或随机抽题)、设置考试时间、设置总分和及格线、分配参考学生等一系列操作。考试配置的核心是一个复杂的JSON对象,包含了考试基本信息、题目配置规则、防作弊设置等。
在线答题模块要处理的是学生端的考试体验:倒计时、试题导航、标记题目、切换题目、自动保存答题记录。这个模块对前端交互的要求比较高,要保证流畅度和数据安全性。
成绩统计模块对客观题自动判分,主观题由教师手动批改打分,最终汇总数据生成成绩单和统计图表。这里还涉及Excel导出功能,方便教师线下存档分析。
2.2 数据库表设计详解
数据库设计是一套系统最核心的地基,表结构设计得好不好,直接决定了后续开发的效率。我设计的主要表如下:
用户表(sys_user):
- id、username、password(BCrypt加密存储)、real_name、role(admin/teacher/student)、phone、email、avatar、status、create_time
题库表(question_bank):
- id、question_type(1单选/2多选/3判断/4填空/5主观)、question_content(题目内容,富文本HTML)、options(JSON格式存储选项)、answer(标准答案)、analysis(答案解析)、difficulty(1-5难度系数)、subject_id(所属科目)、created_by、create_time
科目表(subject):
- id、name、description、create_time
考试表(exam):
- id、title、subject_id、exam_type、start_time、end_time、duration(考试时长,分钟)、total_score、pass_score、status(1未开始/2进行中/3已结束)、random_question(是否随机抽题)、question_config(JSON格式存储抽题规则)、create_by、create_time
考试记录表(exam_record):
- id、exam_id、student_id、start_time、submit_time、objective_score、subjective_score、total_score、status(1考试中/2已交卷/3超时交卷)
答题详情表(exam_record_detail):
- id、record_id、question_id、student_answer、is_correct、score
考试学生关联表(exam_student):
- id、exam_id、student_id、status
这个设计里有几个关键点值得说一下。答题详情表是判分的基础,学生每答一道题就实时写入一条记录,客观题当场判定对错,主观题先记录答案待教师批改,批改后再回填分数。题库表中的options字段用JSON格式存储,比单独建一张选项表更省事,查询效率也更高,因为绝大多数场景都是整题读取,几乎没有单独操作选项的需求。question_config字段也是JSON,将抽题规则、分值分布、难度比例全部封装在里面,灵活度很高。
2.3 关键索引与数据一致性考虑
这套表里性能瓶颈主要出现在考试开始时的批量查询和答题过程中的频繁写入。我在exam_record_detail表建了联合索引(record_id, question_id),在exam_student表建了(exam_id, student_id)唯一索引,防止同一个学生重复参加同一场考试。另外在sys_user表的username字段加了唯一索引,这个是最基本的保障了。
数据一致性方面,考试提交时我用事务包裹整段逻辑:更新考试状态、计算总分、回填成绩,要么全部成功要么全部回滚。还有一点容易忽略:并发提交的问题。如果学生连续点两次交卷按钮,两个请求同时到达,可能造成重复提交。我的方案是在前端按钮加loading禁点,后端在exam_record表加一个submitted字段,提交前先用乐观锁检查状态字段,拿到更新失败就直接返回。
3. 核心后端功能实现细节
3.1 统一返回结果与全局异常处理
前后端对接时,最忌讳的就是返回格式五花八门。我在common包下定义了一个统一返回类Result,结构固定为code、msg、data三个字段,code为200表示成功,其他为各种错误码。所有Controller的返回类型都统一为Result,这样前端axios拦截器就可以统一处理响应。
@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMsg("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String msg) { Result<T> result = new Result<>(); result.setCode(code); result.setMsg(msg); return result; } }全局异常处理用@RestControllerAdvice来做。我自定义了三个业务异常:BusinessException(通用业务异常)、UnauthorizedException(未认证)、ForbiddenException(无权访问)。不要小看这个东西,没有全局异常处理的时候,每写一个接口都要try-catch,代码里全是重复的样板代码,有了它之后所有异常统一抛出、统一拦截、统一转换格式,清爽很多。
3.2 JWT登录认证与权限拦截
登录认证这块我对比过几种方案:Session + Cookie、Spring Security + JWT、Sa-Token。最终选了Spring Security + JWT,原因很简单:Spring Security生态成熟、和SpringBoot配合天衣无缝,JWT无状态认证适合前后端分离。
JWT生成的核心逻辑是用户登录成功后,将用户id、角色、用户名等信息放入Claims,用HS256算法签名生成Token,设置过期时间(一般2小时)。每次请求经过JWT过滤器时,从请求头的Authorization字段取出Token,校验签名和过期时间,解析出用户信息放入SecurityContext。
public String generateToken(SysUser user) { Map<String, Object> claims = new HashMap<>(); claims.put("userId", user.getId()); claims.put("username", user.getUsername()); claims.put("role", user.getRole()); Date now = new Date(); Date expireTime = new Date(now.getTime() + 7200 * 1000); // 2小时过期 return Jwts.builder() .setClaims(claims) .setIssuedAt(now) .setExpiration(expireTime) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }注意一点:密钥在配置文件中要设置为环境变量或者配置中心管理,不要硬编码在代码里。我这里只是一个示例,实际项目中我通常把SECRET_KEY放到application.yml的配置项里。
权限拦截这块我用的注解方式,在Controller方法上标注@PreAuthorize("hasRole('TEACHER')")之类的权限表达式。Spring Security的@EnableGlobalMethodSecurity(prePostEnabled = true)开启方法级权限校验,配合自定义的JWT过滤器,能实现比较精细的权限控制。
3.3 随机组卷与自动判分的关键实现
随机组卷是考试系统里面最有算法味道的部分。需求是这样的:教师创建考试时,指定单选题10道(每题3分)、多选题5道(每题5分)、判断题5道(每题2分),难度比例设为简单:中等:困难 = 3:5:2。系统从题库中按规则抽取符合条件的题目,保证每道题不重复且总分正确。
我的实现逻辑分三步:
- 从题库中查出该科目下所有符合条件的题目列表(按难度、题型过滤)
- 按难度比例确定各难度级别需要抽取的题数
- 对每个难度级别的题目列表进行随机乱序,取前N道
public List<Question> generateExamPaper(Exam exam) { ExamQuestionConfig config = exam.getQuestionConfig(); List<Question> selectedQuestions = new ArrayList<>(); // 遍历每个题型的配置 for (QuestionTypeConfig typeConfig : config.getTypeConfigs()) { // 按难度获取题目池 for (Integer difficulty = 1; difficulty <= 5; difficulty++) { Integer countForDifficulty = typeConfig.getDifficultyCount().get(difficulty); if (countForDifficulty == null || countForDifficulty == 0) continue; // 查询该难度级别下所有题目 List<Question> pool = questionMapper.selectQuestions( exam.getSubjectId(), typeConfig.getType(), difficulty); // 洗牌算法随机抽取 Collections.shuffle(pool); selectedQuestions.addAll(pool.subList(0, Math.min(countForDifficulty, pool.size()))); } } // 校验题目总数和总分是否符合配置 if (selectedQuestions.size() < config.getTotalQuestionCount()) { throw new BusinessException("题库中可用题目不足,请增加题目或调整配置"); } return selectedQuestions; }这里有个坑要提醒大家:如果题库中符合条件的题目数量不够,直接subList会报越界异常。所以抽取前要判断题目池大小,不够时优先保证简单题,或者直接给前端返回错误提示。
自动判分逻辑按题型区分处理。单选题和判断题:比对答案是否一致即可。多选题这里容易出问题:全对才得分还是少选得一半分?我的实现是支持两种策略,在考试配置中指定。大多数场景用的是全对才得分,即学生选项与标准答案完全一致才给满分,否则0分。
注意:多选题答案存储统一用逗号分隔的字符串,比较前要sort一下再比对,不然"ABD"和"DBA"明明是一样的选项却判成不一致。
填空题的判分比较特殊,因为没法和客观题一样精确匹配。我做了一个模糊匹配的方案:把学生答案和标准答案都去掉空格、全角半角转换、忽略标点符号,然后做包含关系判断,完全匹配得满分,包含得分一半,否则零分。这样能兼容大多数填空中细微的格式差异。
主观题不参与自动判分,由教师在后台的待批改列表中逐题查看学生答案和参考答案,手动给分。
3.4 缓存与并发控制的细节考量
考试开始时,学生会集中访问试卷内容,如果每次请求都从数据库读取完整的题目列表,对数据库的压力会比较大。我用Redis做了一个缓存策略:考试创建时把生成的试卷缓存到Redis,key为exam:paper:{examId},过期时间设为考试时长+10分钟。学生请求试卷时先查缓存,缓存没有再从数据库查。
考试交卷时的并发问题前面提到过,我用的是乐观锁:
@Update("UPDATE exam_record SET status = 2, submit_time = NOW(), total_score = #{totalScore} " + "WHERE id = #{id} AND status = 1") int submitExam(@Param("id") Long id, @Param("totalScore") BigDecimal totalScore);这里的关键点在SQL条件里的AND status = 1,只有记录状态还是考试中的时候才能提交成功。如果重复提交,找不到符合条件的数据,返回0条更新,直接提示"试卷已提交,请勿重复操作"。
4. 前端Vue实现与交互细节
4.1 前端工程结构与请求封装
前端这块我用Vue CLI手工搭建,路由采用动态路由方案——根据登录用户的角色动态生成可访问的路由表。store里存了用户信息和token,刷新页面时从localStorage读取,再根据角色拉取对应菜单。
axios请求封装是我比较看重的一块,统一处理BaseURL、Token注入、响应拦截、错误提示:
// utils/request.js import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 15000 }) // 请求拦截器 - 自动携带Token service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) // 响应拦截器 - 统一处理业务状态码 service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { if (res.code === 401) { // Token过期,清理登录状态跳转到登录页 localStorage.removeItem('token') localStorage.removeItem('userInfo') router.push('/login') Message.error('登录状态已过期,请重新登录') } else { Message.error(res.msg || '请求出错') } return Promise.reject(new Error(res.msg || '请求出错')) } return res }, error => { Message.error(error.message || '网络异常') return Promise.reject(error) } ) export default service这里设置401状态码统一跳登录页的逻辑很必要。JWT过期是必然会发生的事,与其让用户看着白屏报错,不如被统一拦截然后拉回登录页,体验好很多。
4.2 在线答题页面的状态管理
在线答题页面是整个前端交互最复杂的部分,我单独设计了状态管理方案。每道题的答题状态、选中答案、标记状态、题目切换都通过Vuex或者组件内状态统一管理,核心逻辑包括:倒计时处理、答题数据自动保存、切换题目时的状态保持。
答题数据的存储结构设计成了一个对象Map,key是题目id,value是学生的答案,类型可能是单选字符串、多选数组、填空字符串或复合对象。每次切换题目时同步写入Vuex并调localStorage做本地缓存,这样即使页面意外刷新,也能恢复未提交的答案。
// Vuex store 中答题状态核心结构 state: { examInfo: null, // 考试信息 questionList: [], // 题目列表 answers: {}, // 学生答案 { questionId: answer } markedList: [], // 标记的题目id数组 currentIndex: 0, // 当前题目索引 remainingTime: 0 // 剩余秒数 }倒计时这块要特别说一下。很多人直接用setInterval在前端做倒计时,然后让前端倒计时结束就自动交卷,这是个巨大的隐患——服务端和客户端时间不同步、网络延迟、页面刷新都会导致计时不准确。我的方案是:前端倒计时只作为展示,真正的计时以服务端为准。前端每次请求同步一次剩余时间,如果发现剩余时间为0,前端展示时间到并锁定答题界面,提交时服务端还会校验考试是否已超时,双重保障。
4.3 Element UI实现的管理端界面
管理端我全部基于Element UI组件库实现。菜单布局用el-container进行上中下的布局,顶部显示用户信息和退出按钮,侧边栏动态生成菜单,中间是路由视图出口。
题库管理页面是典型的el-table列表 + 分页 + 搜索条件。编辑题目用一个对话框,内部根据题型动态渲染不同的表单结构,单选题和多选题有选项增删交互,主观题是纯富文本编辑。
考试配置页面重点在于问卷配置的动态表单。教师选择题型、填写每题分数、选择题目数量、设置难度比例,这些数据会动态生成一个JSON配置,提交时传给后端存储。
成绩统计页面集成ECharts展示数据分布:柱状图展示各分数段人数分布、折线图展示最近几次考试班级平均分走势、饼图展示优秀率/及格率/不及格率的占比。
5. 部署联调与踩坑记录
5.1 部署方案与配置要点
开发环境的联调我配置了代理,解决开发时的跨域问题。生产环境部署时,我建议用Nginx直接托管前端静态资源,然后将/api路径反向代理到后端的8080端口。这样前后端域名统一,不产生跨域问题:
server { listen 80; server_name exam.example.com; # 前端静态资源 root /usr/share/nginx/html/exam-frontend; index index.html; # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }数据库连接配置里,有几个细节容易在这里出问题:MySQL 8.0的驱动类名是com.mysql.cj.jdbc.Driver,5.7及以前是com.mysql.jdbc.Driver。连接串要带上useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai,不然后端时间处理会各种不对。
Redis配置要注意密码验证,生产环境至少设置一个强密码并限制访问来源,开发环境用localhost绑定就够了。
5.2 JWT过期与Redis会话的取舍
在做登录认证的时候其实还有一个备选方案——Redis存储Token,即服务端生成Token后存入Redis,设置过期时间,请求进来时校验Redis中是否存在该Token。相比纯JWT的好处是可以主动踢人下线、可以比较方便地控制单个用户同时登录的设备数量。坏处是服务端需要维护Redis状态,变成半有状态服务。
我的最终方案是采用了JWT为主、Redis为辅的混合设计:JWT负责无状态认证,Redis存储用户的刷新令牌和操作日志,同时利用Redis实现单点登录限制——用户登录时,以userId为key,存储最新Token,旧Token直接失效。这样兼顾了无状态认证的简洁和可管理性。
5.3 常见问题与排查技巧实录
我把重构过程中遇到的典型问题整理成一个速查表,这些都是实操中真的很常见的问题:
| 问题现象 | 可能原因 | 排查/解决方法 |
|---|---|---|
| 前端请求接口404 | Nginx只代理/api前缀,前端请求路径写错 | 检查请求URL是否以/api开头,后端接口路径是否匹配 |
| 跨域报错CORS | 前端地址和后端地址不同源 | 开发环境用代理解决,生产环境用Nginx同域部署或后端配置@CrossOrigin |
| 中文乱码 | 数据库、连接串、页面编码不一致 | 确认MySQL表字符集为utf8mb4,连接串加characterEncoding=utf8,前端页面meta标签charset=utf-8 |
| 登录后Token丢失 | 前端存储Token后刷新页面Store清空 | 登录后将token和用户信息持久化到localStorage,Vuex初始值从localStorage读取 |
| 后端返回时间差8小时 | MySql连接串serverTimezone未配置 | 确认serverTimezone=Asia/Shanghai,或后端统一使用LocalDateTime接收前端时间戳 |
| 随机组卷题目总数不足 | 题库对应科目和难度级别的题目不够 | 在前端创建考试时实时校验题目数量,按科目统计各题型难度数量成展示给教师 |
| 学生交卷后分数为0 | 判分逻辑中答案格式不一致 | 统一答案存储格式,多选用逗号分隔的字符串,判分前对答案做trim和排序 |
| 图片上传后无法访问 | 静态资源路径未映射 | 配置资源映射目录,Nginx添加对应location |
这里特别说一下图片上传的坑。题库中的图片我存的是相对路径,比如/upload/20240115/xxx.jpg,后端要把upload目录映射成静态资源,开发环境用配置类映射,生产环境要确保upload目录可写入并且被Nginx配置了访问权限。这个细节不处理好,就会出现教师端能看到图片、学生端打不开图片的诡异问题。
还有一个最容易忽略但非常关键的问题:数据库连接池配置。默认的HikariCP最大连接数是10,在高并发考试场景下可能不够用。我调整了连接池配置,maximum-pool-size设到50,minimum-idle设到10,同时给MySQL设置max_connections=200,避免高峰期连接被占满。
5.4 性能优化实践
在线考试系统的性能和用户体验高度相关,考试页面加载慢、题目切换卡顿都会直接影响学生的考试情绪。我做性能优化的几个方向供参考:
数据库层面:列表查询用分页,只查询需要的字段,模糊搜索用LIMIT控制返回量。百万级数据量时,题库表可以根据科目或题型做分表,但一般学校或培训机构场景数据量远达不到,普通索引优化就够了。
接口层面:试卷详情返回时,一次性返回所有题目而不是每道题一个请求。这样能大幅减少接口请求次数,对弱网环境特别友好。前端先渲染题目列表骨架,学生点击导航可以快速跳转。
缓存层面:热点数据(已结束考试的题目和答案查询)用Redis缓存,减少MySQL压力。登录用户的基本信息也做了本地缓存,避免每次请求都查数据库。
前端层面:路由用懒加载,按需加载页面组件,首屏只加载登录页和基础框架的代码,其他页面等访问时再加载。打包时开启gzip压缩,配合Nginx的gzip on配置,静态资源体积能减少60%以上。
5.5 这个项目的扩展方向
基础版本跑通之后,可以往这几个方向扩展:试题批量导入(Excel解析模板上传)、考试数据可视化大屏(对接ECharts展示实时考试状态)、智能组卷算法(基于知识点覆盖度、历次成绩难度适应性的综合推荐)、人脸识别防作弊(接入第三方API)、错题本与知识点分析(根据答题记录自动归类错题)。
这些扩展方向并不是简单的功能堆砌,而是围绕教育场景的痛点来做的。比如智能组卷,本质上是把教师从人工选题的重复劳动里解放出来,让组卷结果更有科学性和针对性。再比如错题本,解决了学生考前复习没有方向抓不住重点的问题,这比单纯的题库功能价值大得多。
6. 写在最后的实操体会
这套系统从零开始搭建、重构、部署、运行下来,我个人最大的感受是:在线考试系统看起来功能明确、结构清晰,但真正深入做下来,细节密度远超预期。从数据表设计到判分策略的兜底逻辑,从前端答题的状态管理到服务端并发提交的保护机制,每一环都有很多需要琢磨的东西。
其中让我最头疼的是多选题的判分歧义和超时交卷的状态一致性。判断题和多选题看似简单,学生的答案存储格式如果有细微差别,判分就出问题。超时交卷如果同时发生大量提交,MySQL的锁竞争会变得严重。我最终的方案是用乐观锁 + Redis的分布式锁来双保险,保证极端情况下数据也不出错,这套方案在压测中表现还算稳定。
最后再分享一个血的教训:项目开始前一定先花时间把数据库设计文档写清楚。我看到太多同学边开发边改表结构,最后改到前后端都失控。我这次重构是先把所有表关系画清楚,字段定义全部确定,再动手写代码的,整体开发效率反而比边想边改高了很多。另外,代码里所有提示信息不要散落硬编码,统一抽到一个常量类或Message枚举里,后期改文案时不用满项目搜字符串。
如果你也在做一个类似的考试系统,希望这篇复盘能帮你少走一些弯路。做得过程中如果有拿不准的设计决策,优先从"数据是否正确、是否冗余、是否容易理解"这三个角度去权衡,基本不会出大错。