如果你现在打开搜索引擎,搜“在线考试系统”,十篇里大概有八篇是前后端分离方案的教程,Vue加Spring Boot基本成了默认套餐。我之前也一直默认这个路线,直到一个真实项目把整个认知掰了过来。
去年某培训机构急着上线内部员工认证考试,第一版我们用前后端分离的方式做了一个界面还不错的答卷前端。结果在真实考试场景里反复出问题:考生作答途中刷新一下页面,已选答案就丢了;倒计时和服务端时间对不上,考生卡着最后几秒交卷时心态直接崩;网络稍微抖一下,前端和后端各执一词,状态怎么都拧不齐。最终团队推倒重来,改回“前后端不分离”的方案,用Spring Boot加模板引擎做服务端渲染,反而把这些问题一个个解掉了。
这篇文章记录的就是这次技术探索的全过程。如果你是做纯内容展示型网站,不分离未必是最优解;但如果你要做在线考试这类强状态、高并发、重安全的业务系统,我的经验是:不分离这套老牌姿势,常常比把前后端拆得干净利落更靠谱。
1. 为什么把阵线拉回“前后端不分离”
1.1 第一版方案栽在哪儿了
第一版技术栈是Vue 3 + Spring Boot,后端提供REST接口,前端独立部署。开发阶段一切正常,页面漂亮,交互顺畅,接口文档也整理得挺规范。但一旦进入真实的模拟考试,问题就暴露了。
最典型的是刷新丢状态。考试页面是个单页应用,考生在答题中途刷新,前端内存里保存的答案列表直接清空。虽然可以设计成每次答题都调用一下保存接口,但考试场景里网络不一定稳定,如果保存动作是异步的,刷新瞬间那条请求可能还没发出去,答案就没了。更麻烦的是,如果后端把“考试会话”存在某个临时的JWT或者内存Map里,刷新后token失效或Session过期,整场考试的进度都会归零。
倒计时的偏差也让人头疼。第一版是前端用setInterval每秒减一秒,理论上没问题,但浏览器标签页一旦切走,后台会降低定时器执行频率,倒计时走得越来越慢。等考生切回页面,剩余时间比实际多了几分钟,服务端时间一到就强制交卷,考生自然不认,后台每天被投诉电话打爆。
还有并发问题。考试开始那一刻,几百人同时提交答案,REST接口还好,但前端每个请求都要带身份信息,后端每次校验token、查用户、查考试状态,数据库连接瞬间被打满。而页面端因为拿不到最终结果,只能反复轮询,把服务打个半死。
1.2 考试场景的真实诉求
做在线考试系统,和做普通业务系统有一个本质区别:考试是强状态的、时间敏感的业务,且状态必须始终挂在服务端。考生答题过程中的每一步,服务端都得清楚;倒计时、已答未答、试卷版本、提交结果,这些数据不能被前端随便改写,也不能因为前端崩溃就丢掉。
前后端分离把页面渲染和业务逻辑拆成两套,好处是部署灵活、团队协作边界清晰,但它默认了“两端网络可信、状态能对账”的前提。放到考试场景里,这个前提太脆弱了。
不分离方案的最大优点,是页面和服务端天然在一个进程里。考生访问URL,服务端直接渲染出完整的试卷页面,所有状态都存在服务端会话或Redis里,刷新、断线、换浏览器,只要考试会话还在,页面就能完整恢复。倒计时由服务端统一裁决,前端只是展示器。整个系统的正确性不再依赖客户端的自觉性。
当然,不分离也不是没有代价。模板引擎渲染的页面交互能力弱一些,复杂的前端动效和局部刷新要写不少原生JS或少量框架配合。但考试页面本身交互并不复杂,无非是选选项、切题、看倒计时、交卷。放下“全栈分离”的执念之后,这个代价其实是可控的。
2. 技术选型:不分离不等于落后,选对工具才是关键
2.1 最终落地的技术栈
本次项目最终选型如下:
| 模块 | 选型 | 说明 |
|---|---|---|
| 后端框架 | Spring Boot | 稳定、生态成熟,适合单体业务快速落地 |
| 模板引擎 | Thymeleaf | 服务端渲染HTML,天然支持XSS转义 |
| 状态存储 | Redis | 存考试会话、答题进度、倒计时基准 |
| 数据库 | MySQL | 存题库、考生信息、考试成绩等持久化数据 |
| 前端 | 原生JavaScript +少量Ajax | 只负责交互反馈,不承载状态 |
| 实时通知 | SSE (Server-Sent Events) | 推送剩余时间阈值提醒、考试开关状态 |
没有引入复杂的微服务,也没有上消息队列,整个系统就是一个“胖Service + 模板渲染”的单体应用。选择单体并不是因为技术保守,而是考试系统的并发峰值集中在开考和交卷两个点,属于典型的波峰型负载,单体加合理的缓存和连接池,完全能扛住几千人同时在线。
2.2 为什么用模板引擎而不是直接返回JSON
可能有人会问,既然有Spring Boot,为什么不用前后端分离,非要让服务端渲染HTML?其实答案很简单:在线考试系统的页面结构,在服务端是完全可以确定的。
试卷页面的基本结构是题目列表、选项、倒计时、答题卡。这些内容在渲染那一刻就已经是完整数据了,不需要等客户端再发请求去拼装。服务端渲染可以直接把题目、选项、考生已答状态一次性输出到HTML里,浏览器拿到就是完整的页面,没有白屏等待,也不会因为接口并发导致页面渲染不出来。
对比一下:如果返回JSON,前端至少要经历“请求题目 -> 请求已答状态 -> 动态渲染答题卡 -> 绑定事件”四个步骤,其中任何一步失败,页面都是残缺的。模板引擎则把这些步骤压成一个HTTP请求,刷新即可完整恢复,这在考试这种不允许出错的场景里价值极高。
还有一点很实在:Thymeleaf默认会对输出内容做HTML转义,题目里即使包含特殊字符,也不会被浏览器当成脚本执行。在安全要求高的考试系统里,这等于免费获得了一层XSS防护。
3. 核心模块拆解:答题、计时、交卷是怎么串起来的
3.1 试卷生成与题目渲染
试卷不是每场考试临时随机抽题的,那样无法保证考试难度一致。我们的做法是:考试创建时,后台先按题型比例从题库抽题,形成一份固定的“试卷快照”,存到数据库。快照里记录题目ID、题目内容、选项内容、所属题型、分值,并生成一个全局唯一的试卷编号。
考生进入考试时,服务端读取试卷快照,再根据考生ID生成一份“考生试卷实例”,也就是把试卷快照复制一份到这个考生名下,并记录考试开始时间。这样做的原因是:如果直接用同一份快照,考生答案和题目之间会互相污染,也没法做乱序。
题目渲染时,Controller方法从Redis取出该考生未交卷的会话数据,把已答字段放进模型,交给Thymeleaf渲染。核心代码大致是这个思路:
@GetMapping("/exam/{examSessionId}/paper") public String paper(@PathVariable String examSessionId, Model model) { ExamSession session = examSessionService.getActiveSession(examSessionId); if (session == null || session.isFinished()) { return "redirect:/exam/expired"; } PaperSnapshot snapshot = examSessionService.getSnapshot(session.getPaperId()); Map<String, String> answers = answerStore.getBatch(session.getSessionId()); model.addAttribute("session", session); model.addAttribute("questions", snapshot.getQuestions()); model.addAttribute("answers", answers); return "exam/paper"; }关键点是:每次页面渲染都从Redis读取答案,而不是从数据库读。Redis的读取速度在毫秒级,刷新页面不会造成数据库压力;答案也永远不会因为页面刷新而丢失。
3.2 倒计时到底在谁手里:服务端时间才是权威
倒计时是考试系统里最容易出问题的地方。我们的原则很简单:前端倒计时数字只是个展示,真正决定什么时候交卷的是服务端时间。
具体实现是:考生进入考试时,服务端计算考试结束时间戳,比如examEndTime = startTime + durationMinutes * 60 * 1000,把这个时间戳连同剩余毫秒数一起传给前端。前端拿到剩余毫秒数后,用performance.now()或Date.now()做差值计算,每秒更新一次页面显示。
关键代码在服务端:
public RemainingTimeVO getRemainingTime(String examSessionId) { ExamSession session = sessionStore.get(examSessionId); if (session == null) { throw new ExamException("会话不存在"); } long now = System.currentTimeMillis(); long remain = session.getEndTime() - now; return new RemainingTimeVO(remain, session.getEndTime()); }前端只做一件事:拿到endTime后,每次计时都用endTime - Date.now(),而不是用setInterval累减。这样可以避免浏览器节能模式导致的计时偏差。交卷动作也不依赖前端倒计时是否为0,而是由服务端在收到交卷请求时校验当前时间是否超出结束时间。
这里有个细节容易被忽略:网络请求本身有延迟。如果考生在倒计时还剩3秒时点击交卷,请求到服务端可能已经超时了。所以交卷接口的校验逻辑是这样设计的:如果提交时间在结束时间后但不超过2分钟,仍然接受试卷,但标记为“超时提交”,由监考老师人工复核。如果超过2分钟,直接拒绝,按缺考处理。这个2分钟窗口很好地解决了网络延迟和考生手速带来的争议。
3.3 自动交卷、手动交卷、异常交卷的事件闭环
交卷不是只有一个入口。正常情况是考生自己点交卷;如果考生没点,倒计时归零后必须自动交卷;考生中途关掉浏览器,服务端也不能永远等下去。
我们的设计是三层兜底:
第一层是前端倒计时归零后,立即触发一次交卷请求。这一层是最快的,正常情况下能覆盖99%的考生。
第二层是服务端定时任务,每30秒扫描一次当前正在进行的考试,如果发现某场考试已过结束时间但考生还没提交,直接执行自动交卷,把当前已保存的答案作为最终答案。这个兜底主要防前端JS失效、浏览器崩溃、网络中断等情况。
第三层是考生重新登录后,如果发现还有未交卷的会话,服务端会提示“检测到一场进行中的考试”,允许考生继续作答或主动交卷。这样即使用户中途关机,重新开机后也不会丢试卷。
自动交卷有个需要注意的边界:如果考生在自动交卷前1秒刚提交了答案,两个动作可能会竞争数据。我们的解决方案是给交卷操作加上幂等保护,具体在下一节讲。
4. 考试中躲不开的并发与状态恢复问题
4.1 断网重连后如何恢复一场“进行中”的考试
网络抖动在考试现场太常见了,尤其是一些培训机构的Wi-Fi,一个教室几十人同时用,丢包率感人。有人在考试中途断网了5分钟,如果系统因此判定他考试失败,那这个系统根本没法用。
我们的恢复机制是这样的:考试页面的URL里带一个examSessionId,比如/exam/EX20250101A001/paper。无论前端浏览器怎么刷新、断网重连,只要考生重新访问这个地址,服务端就能通过会话ID找到Redis里的考试状态,把已答题目全部渲染回页面。
考生断网期间,倒计时不会暂停,这符合真实考试的规则——你写纸质试卷时也不能因为笔没油了就获得额外时间。但恢复后,页面会把最新剩余时间重新同步回来,考生看到的倒计时是“扣除断网时间后的准确值”,不会因为前端计时停止而产生幻觉。
实现上有个小坑:刷新页面后,Thymeleaf渲染的题目顺序要跟原来一致。题目乱序是在生成考生试卷实例时一次性确定的,乱序结果存在Redis里,渲染时读取这个固定顺序,而不是重新生成一次。否则考生刷新一遍,题目顺序变一次,考试体验就太糟糕了。
4.2 提交试卷的幂等处理
“保单”这个功能听起来简单,但真正做的时候,各种重复请求会把你搞疯。考生手快点两下交卷按钮、浏览器自动重试、服务端定时任务又恰好触发,三次请求同时进来,处理不好就会造成答案覆盖或者状态错乱。
幂等处理的思路是给试卷状态加一个状态机。考试会话有三个状态:进行中、已提交、已判分。交卷接口进来时,先做一次条件更新:
UPDATE exam_session SET status = 'SUBMITTED', submit_time = NOW() WHERE session_id = ? AND status = 'IN_PROGRESS'如果这条SQL影响的行数为1,说明本次是第一次成功交卷,执行后续的答案归档、计分流程;如果影响行数为0,说明试卷已经交过了,直接返回“提交成功”,不再重复处理。
这个方法的好处是不需要额外的分布式锁,数据库的行级锁天然保证了并发安全。唯一的代价是每次交卷多一次UPDATE操作,但对考试系统的访问量来说完全可以接受。
另外还要注意,交卷时必须传入一个由服务端生成的submitToken,这个令牌在试卷开始渲染时下发,只有持有合法令牌的请求才能触发交卷。否则某些恶意考生可以通过直接调用交卷接口的方式,在任意时间点强制提交一份空白答卷。
4.3 多设备同时作答的拦截
线上考试还有一个人性难题:考生在电脑上答题答到一半,旁边手机不小心也打开了考试链接,结果手机自动登录了同一账号。如果不加拦截,两个设备可以同时操作同一份试卷,最终提交时到底以哪台设备为准,扯皮事件不断。
我们的做法是在考试会话里记录一个“活动设备标识”。第一次进入考试时,前端通过调用一个接口把当前浏览器指纹(浏览器类型、屏幕分辨率、时区等基础信息)传给服务端,服务端记录下来。第二次不同设备进入时,服务端比较指纹,如果不同,直接拒绝渲染页面,提示“本场考试已在另一台设备上开始,请返回原设备继续作答”。
这个方案并不是绝对防作弊,因为指纹可以被伪造。但它的价值在于能挡住90%以上的普通误操作,真有人要恶意作弊,系统已经另有方案兜底,后面会讲到。对普通考生来说,这个提示也能减少很多不必要的纠纷。
5. 安全与防作弊:服务端渲染带来的“低配版优势”
5.1 题目乱序与选项乱序
考试系统的基础防作弊手段是乱序。我们的实现是:在生成考生试卷实例时,用随机算法打乱题目顺序,同时为每道题的选项生成一组随机排列。
乱序算法实现上有个重点:不能简单用Collections.shuffle每次都无序性随机,因为要保证同一考生刷新页面时乱序结果不变。我们是在生成试卷实例时一次性产生题目和选项的乱序映射,并以JSON格式存进Redis,后续所有渲染都基于这个映射。
public ShuffleMeta generateShuffleMeta(Long paperId, Long studentId) { // 基于学生ID和试卷ID生成一个固定种子 Long seed = Objects.hash(paperId, studentId); Random random = new Random(seed); List<Long> questionIds = loadQuestionIds(paperId); Collections.shuffle(questionIds, random); return new ShuffleMeta(questionIds, randomOptionMaps); }同一个学生在同一场考试中,种子相同,乱序结果就稳定。不同学生之间,种子不同,题目和选项顺序各不相同。这比每次渲染都随机打乱要安全得多,也稳定得多。
5.2 限制复制、切屏检测的实现细节
这部分是前端实现的,虽然前后端不分离,但浏览器端的交互限制还是需要JavaScript参与。我们禁用了答题区域的右键菜单和复制粘贴,监听copy、paste、contextmenu事件并阻止默认行为。同时监听window.blur事件,检测到页面失焦就记录一次切屏行为。
老版本浏览器对blur事件的支持参差不齐,我们的降级方案是监听document.hidden的Visibility API,只要标签页进入后台就算一次切屏。切屏次数达到2次,系统会弹窗警告;达到3次,自动交卷并标记为“作弊嫌疑”,由管理员人工复核。
这里要提醒一句:切屏检测只能作为辅助手段,不能作为判作弊的唯一依据。有些考生只是不经意间点了下浏览器工具栏,也会触发失焦。所以策略上要留出容忍度,同时把原始行为记录到日志里,交给监考人员判断。
5.3 接口签名与防重放的安全设计
在线考试系统的接口,不能直接裸奔在外网上。虽然服务端渲染减少了API暴露面,但答题保存和交卷接口仍然开放给浏览器调用,必须做一定防滥用处理。
我们的做法是给每个考生会话生成一个accessToken,存到Redis里,并设置过期时间为考试结束时间。所有Ajax请求在请求头里带X-Access-Token,服务端有一个拦截器统一校验。同时,请求体中包含一个递增的reqSeq序列号,服务端记录每个会话最后一次处理到的序列号,只处理比它大的请求。这样即使有人抓包后重放请求,也无法让旧请求重复生效。
防重放在考试场景里很重要。比如考生抓到了自己保存答案的请求,重放一次可能无关痛痒,但如果重放的是交卷请求,就会导致重复交卷。有了reqSeq之后,重放的请求只会被当成迟到请求直接丢弃。
6. 实测阶段踩过的坑,以及我后来怎么处理的
6.1 倒计时越走越偏的问题
第一版倒计时问题前面说过,切后台会导致定时器不准。换成基于服务端时间戳计算后,又出现一个新问题:网络延迟会导致初次同步的剩余时间偏大或偏小,考生看到的倒计时和服务端实际裁定的时间存在几秒误差。
后来我的处理方式很朴素:在考试页面加载完成后,额外调用一次getRemainingTime接口,用返回的最新值校正显示。不要只在页面初始化时同步一次,那样第一次渲染时的时间可能已经过期几秒了。
还有一个小技巧:前端每秒展示剩余时间时,对毫秒做向上取整。比如剩余时间5分30秒零200毫秒,展示的是5分30秒,而不是5分29秒。这样视觉上的倒计时和实际截止时间自然衔接,考生卡点交卷的争议会少很多。
6.2 高峰并发下的数据库连接不够用
第一次模拟考试时,800人同时在线,到了交卷时刻,系统突然变慢,部分交卷请求超时。翻日志发现,HikariCP默认连接池大小是10,交卷时大量请求同时更新考表,连接池被全部占满。
调整很简单,把连接池上限调到30,并给交卷业务单独配置了一个读写分离的数据源。但真正治本的是减少交卷瞬间的数据库操作。我们把“交卷”拆成两个阶段:第一阶段只做状态更新和答案归档,把结果写入Redis;第二阶段由定时任务异步从Redis批量刷入MySQL,严格控制在500条一批。这样既保证了交卷请求快速返回,又不会把数据库打爆。
压测数据供参考:40个线程持续循环交卷2000次,调整前平均耗时1.8秒,调整后平均耗时0.4秒,数据库CPU占用从90%降到40%左右。
6.3 静态资源缓存导致的答题页面异常
有一次偷偷改了前端JS里的切屏检测逻辑,结果很多考生抱怨没有生效。排查了半天,发现是浏览器缓存了旧的JS文件。因为页面URL没变,浏览器直接走了本地缓存。
这个问题在前后端不分离的架构里尤其容易犯,因为模板渲染的HTML本身很少变,但引用的JS和CSS经常要小改。后来的做法是在Thymeleaf模板里给静态资源URL加上版本号参数,版本号来自Spring Boot配置文件:
<script src="/js/exam-paper.js?v=${appVersion}"></script>每次发版更新appVersion,所有浏览器都会被迫拉取新资源,旧缓存自动失效。
6.4 给考生“再确认一次”的心跳时间
考试结束前的最后60秒,是最紧张也最容易产生纠纷的时间段。我们增加了一个功能:剩余时间低于60秒时,页面弹出一个半透明的提示条,显示“考试即将结束,请保存答案”,同时倒计时变成红色。剩余10秒时,自动给服务端发一次预提交信号,把所有当前答案强制保存一次。
这个强制保存很有用。很多考生最后一分钟一直在犹豫改答案,没有立即点击保存按钮,结果时间一到,最后改的几道题还没来得及保存。有了这次预提交,至少能保证考生闭目前页面上的答案已经传到了服务端,从根本上兜底了“倒计时归零但答案未保存”的问题。
6.5 考后的一件事:试卷快照与成绩归档
最后再分享一个容易忽略但非常重要的点:考试结束后的归档。
交卷后,不能只存一个成绩数字。考生的完整答卷——每道题选了哪个选项、每道题的原始题目和选项顺序、考试中的切屏记录、交卷时的IP和设备信息——都应该存档。我们的做法是交卷成功后立即生成一份JSON格式的答卷快照,按考试时间分目录存储。
这份快照的价值在考后一周的申诉期体现得淋漓尽致。考生说“我明明选了B,为什么判我错”,管理员只需调出快照,就能清楚看到考生的原始选择,是谁的问题一目了然。同时也为后续的试卷分析、题目难度统计提供了最准确的数据基础。
这套方案上线后,历经了三场千人规模的模拟考试和两场正式认证考试,整体运行稳定。最让我欣慰的不是某张折线图上的性能数字,而是考试结束后几乎没有接到来自考生的“为什么我的倒计时多了/少了”“我明明提交了却显示没交”这类投诉,这在考试系统里,就是最实在的验收标准。