☰
在线考试系统从单体到微服务的架构演进与高并发实践
2026/10/17 8:41:12 网站建设 项目流程

简介:这是一套基于Vue与Spring Cloud微服务架构实现的在线考试系统完整源码,面向Java全栈开发者、微服务学习者及高校课程设计实践者,解决传统单体考试系统扩展性差、权限粗放、题库管理低效等痛点。资源共200个文件,含115个Java后端核心逻辑文件(微服务模块、JWT鉴权、AOP日志)、18个Vue前端组件与18个JS业务脚本(覆盖试卷抽题、Excel批量导入、角色权限控制等关键交互),以及YML配置、SQL建表、Nginx反向代理配置等基础设施文件,压缩包仅535KB,轻量但结构完整。已有971人学习下载,可直接部署于CentOS 7环境,包含从Eureka注册中心、Zuul网关到Nuxt在线考试前台的全链路实现,附带MySQL建表语句、Excel试题模板及细粒度权限控制目录结构,便于理解微服务拆分逻辑与前后端分离工程组织方式。 在线考试系统可能是并发模型最不"均衡"的一类业务系统了:平时流量低到一天也没几百个请求,但一到正式考试的时间点,几百上千名学生会在同一秒涌入,登录、拉卷、答题暂存、交卷四个动作在几分钟内集中爆发。我负责的这个项目就是从最早的 Spring Boot 单体应用,一步步演变成现在这套 Vue + Spring Cloud 微服务架构,中间经历过多次被打崩的教训,也沉淀了不少同类系统可以直接复用的设计和排障经验。

这套系统现在承担着三个校区、每个学期十余场正式考试的压力,前端是 Vue 3 + Element Plus + Pinia,后端是 Spring Boot 2.7 + Spring Cloud Alibaba 微服务集群,核心服务拆分成了网关、认证、用户、题库、试卷、考试、阅卷七个部分。如果你正在规划在线考试、在线教育这类平台,或者想找一个真实的微服务落地案例做参考,这篇分享应该能帮你少走不少弯路。

1. 为什么要拆:单体考试系统的三个崩溃瞬间

1.1 单体架构下的考试系统长什么样

最早这套系统就是一个典型的 Spring Boot 单体应用,用户管理、题库维护、组卷、考试会话、自动阅卷、成绩统计全部放在同一个工程里。前端的页面也直接由后端的 Thymeleaf 模板渲染,部署时打一个 jar 包,丢到一台 4C8G 的云主机上跑,数据库就是一个 MySQL 实例。

单体架构的开发效率确实高。第一版从立项到上线只花了不到三个月,功能也基本够用,几十个人同时考试没什么问题,教师上传试题、学生答题、系统自动判分,这些流程都能正常跑通。但只要用户量级往上涨,问题马上就暴露出来了。

1.2 三次把线上系统打崩的真实事故

第一次事故发生在一次三百人同时在线模拟考。下午两点考试开始,两点零五分左右达到请求峰值,Tomcat 默认配置的 200 个线程被全部占满,数据库连接池直接爆掉,大量请求排队超时,最直接的表现就是考生点了"下一题"要转十几秒才能出题。最后有将近五十个考生没有正常完成交卷,成绩数据出现了不同程度的缺失。

第二次事故是教师批量导入题目。一位老师一次性导入了一份五千道题的 Excel,导入逻辑本身没什么大问题,但解析和去重过程非常耗 CPU,把整个应用进程的 CPU 打到了 100%。偏偏那天下午有一场年级联考,在线考试服务依赖同一个进程,考生答题接口的响应时间从几十毫秒直接飙升到十几秒,相当于一次功能性故障把核心业务全拖下水了。

第三次事故更尴尬,是迭代过程中改挂了老接口。为了适配新需求,我在成绩统计模块里调整了一个公共方法的签名,结果没有留意到历史成绩查询接口也引用了这个方法,上线后发现整个成绩模块全部报错。单体应用就是这样:模块之间没有物理隔离,改一处代码的风险是全局的。

1.3 什么时候才值得上微服务

很多人会问,是不是所有在线考试系统都需要微服务?我的判断标准很简单:业务的访问模型是否分层明显、团队规模是否能支撑多服务维护、未来是否有独立的伸缩诉求。

考试系统的特殊性在于,题库维护和考试会话是完全不同的两种并发模型。题库模块是典型的低并发管理型应用,平时老师维护题目,请求量小;而考试会话是短时高并发的写密集型应用。把这两个模块拆开,就可以各自独立扩容,考务高峰期只把考试服务从 1 个实例扩到 4 个,而题库服务保持不变,成本收益比非常清晰。

当然,如果业务量只有几十个人同时在线,团队也只有两三个人,那么上微服务大概率是给自己找麻烦。微服务带来的配置中心、注册中心、网关、分布式事务这些问题,每一项都需要额外成本。我们是在单体的瓶颈已经清晰可见、且系统维护迭代频繁触碰业务红线之后,才下决心改造的。

2. 服务划分:题库、考试会话、阅卷、网关的职责与边界

2.1 七个服务的拆分清单和依赖关系

改造的第一步不是写代码,而是梳理业务域。我们按"变化频率、并发模型、独立伸缩性"三个维度,把系统拆成了下面这七个服务:

服务名核心职责典型接口数据库
gateway统一入口、路由转发、鉴权过滤、限流熔断无业务接口无
auth-service登录、刷新令牌、短信验证码/api/auth/**auth_db
user-service学生、教师、管理员信息管理/api/user/**user_db
question-service题库维护、试题批量导入导出/api/question/**question_db
paper-service组卷策略、试卷模板、考试发布/api/paper/**paper_db
exam-service考试会话、答题暂存、交卷受理/api/exam/**exam_db
grading-service自动判分、人工判分、成绩发布/api/grading/**grading_db

服务间调用用 OpenFeign,注册和配置统一走 Nacos,网关是 Spring Cloud Gateway,限流用的是 Sentinel。前端的 Vue 应用只认网关这一个入口,不直接访问任何下游服务。

依赖关系大概是这样的:认证服务要查用户服务确认账号状态;考试服务要调试卷服务拿试卷内容、调题库服务核对题目版本;阅卷服务要调考试服务读答题快照、调试卷服务拿标准答案。这里要注意,服务间不能形成环形依赖,一旦出现 A 调 B、B 调 C、C 又调 A,后续的版本发布和故障定位就会非常痛苦。

2.2 数据库绝对不能跨服务 join

各个服务的数据存储是独立拆分的,每个服务独占一个数据库或 schema,禁止出现跨库 join。刚开始团队成员很不适应,因为在单体时代一条 SQL 就能关联出"学生姓名 + 试卷名称 + 成绩",拆库后必须通过两次 HTTP 调用或者一次消息事件来组装数据。

这里我的建议是:如果只是展示型数据聚合,就通过接口调用拿数据;如果涉及最终一致性场景,比如交卷后的成绩统计,就通过消息队列异步处理。查询性能实在扛不住时,再考虑引入数据同步方案,把多个库的只读数据汇聚到一个查询库。千万别图便宜直接跨库 join,一旦这么干了,服务间的物理边界就被破坏了,后面所有治理手段都会失效。

2.3 一个跨服务业务操作的处理思路

以"教师发布一场考试"为例,这个操作会同时涉及试卷服务、考试服务和用户服务:教师选一份试卷,设置考试时间和参加班级,发布后系统要通知相关考生。在单体时代就是一个事务搞定的事,拆成微服务后就要分层处理。

我们实际的落地方式是:paper-service 只负责生成考试记录并落库,然后向消息队列发送一条"考试已发布"的事件;exam-service 消费事件后创建对应的考试会话模板,user-service 消费事件后给相关学生生成待办通知。如果某个下游服务消费失败,消息队列会重试,配合统一的消息去重表保证幂等。这样做的代价是逻辑比单体复杂,但换来的是各个服务可以独立发布、独立扩容。

3. Vue 前端侧:登录鉴权、路由守卫、答题数据如何与网关协作

3.1 前端只认网关一个入口

前后端分离之后,Vue 工程和 Spring Cloud 服务群变成了两个独立部署的应用。前端工程构建后放到 Nginx 里托管,所有业务请求统一走/api前缀打到网关,由网关再路由到具体的微服务。

这里有个开发环境的细节:本地开发时前端跑在 Vite 服务上(默认 5173 端口),后端接口在 8080,会有跨域问题。我们是让 Vite 的 proxy 把/api代理到网关地址,生产环境则用 Nginx 的同源转发。这样前端代码里不用关心后端地址,也不用在代码里写死 API 域名。

3.2 Axios 拦截器与 Token 刷新:并发 401 的坑

认证流程用的是 accessToken + refreshToken 双令牌方案。登录成功后,前端把 accessToken 存内存或 LocalStorage,refreshToken 存 cookie;请求拦截器给每个请求的 Header 加上Authorization: Bearer xxx,网关统一校验 token 并解析出 userId 和角色。

响应拦截器里有一个非常容易出现并发问题的地方:当 accessToken 过期时,如果页面同时发起了多个请求,它们会同时收到 401,如果每个请求都去调刷新接口,就会产生重复刷新,甚至把 refreshToken 也刷失效。解决办法是设置一个isRefreshing标记,第一个 401 触发刷新流程,后续的 401 请求先挂到等待队列里,刷新完成后再逐个重放。核心代码大致是这种结构:

let isRefreshing = false let waitQueue = [] service.interceptors.response.use( response => response.data, async error => { const { response, config } = error if (response.status === 401 && !config._retry) { if (isRefreshing) { return new Promise(resolve => { waitQueue.push(() => resolve(service(config))) }) } config._retry = true isRefreshing = true try { const { data } = await axios.post('/api/auth/refresh', { refreshToken: getRefreshToken() }) setAccessToken(data.accessToken) waitQueue.forEach(cb => cb()) waitQueue = [] return service(config) } finally { isRefreshing = false } } return Promise.reject(error) } )

这个细节看起来很基础,但真实线上环境没有处理好就会出现"同一时刻大量 401 → 刷新接口被刷爆 → 登录态反而丢失"的连锁反应。

3.3 答题卡、倒计时、定时保存的前端实现

考生端的试卷页面是整个前端最复杂的部分。左侧是答题卡,用网格展示所有题号,区分"未答、已答、标记"三种状态;右侧是题目内容。我用 Pinia 维护一个answers对象,结构是{ questionId: { value, marked } },答题卡上的状态全部通过 computed 实时计算,只要答案对象有变化,左侧状态就自动刷新。

这里有一个性能细节:考生每做完一道题就触发一次请求是没有必要的,反而会给后端增加大量瞬时写压力。我们做的是增量暂存机制:每 30 秒定时器触发一次,用Object.keys(answers)和上次提交的快照做 diff,只把变化过的题目批量提交到暂存接口。页面卸载前再补发最后一次暂存。

倒计时这块,后端的处理是在 Redis 里存考试结束时间戳,前端拿到结束时间后跑一个setInterval每秒更新显示,但交卷的最终判定完全依赖服务端时间。考生修改本机时钟在真实考试里是很常见的操作,如果交卷判定信任前端传的时间,那整个防作弊体系就是摆设。

3.4 路由守卫与角色权限的边界

前端路由按角色分了三块:学生端、教师端、管理端。我的做法是在路由的 meta 里声明roles,比如meta: { roles: ['admin'] },在全局前置守卫里判断当前用户角色是否匹配。如果没有登录统一跳到登录页,登录后根据角色跳转到对应首页。

但这里必须强调一点:前端路由守卫只是优化用户体验,真正的权限控制一定在后端接口上。因为前端所有代码都是公开的,有心人完全可以绕过路由守卫直接调接口。网关层做粗粒度的角色校验,各业务服务做细粒度的资源权限校验,这道防线不能省。

顺带一提,在线考试系统有时需要接入考生监控视频,教师端要实时查看考试画面。我们用的是 hls.js 直接播放监控服务产出的 m3u8 流,Vue 组件里封装了一个视频播放器,只在考试进行时拉流,结束即销毁,避免内存泄漏。

4. 考试会话的并发与数据一致性:答题暂存、交卷、自动阅卷

4.1 高频写请求是怎么压垮数据库的

答题暂存是典型的"高频小写"场景。按两千人同时考试计算,每人每 30 秒提交一次,平均每秒就有约 67 个批量写请求,每个请求里包含几十道题的答案更新。如果这些请求全部直接打 MySQL,数据库压力会非常大,而且大概率产生大量行锁竞争。

我们的方案是引入 Redis 作为暂存的热路径。每次前端提交答案,exam-service 先更新 Redis 里的一个 Hash 结构,field 是 questionId,value 是答案内容加时间戳;然后由一个定时任务每两分钟把 Redis 中变更过的数据批量刷新到 MySQL。这样把"每秒几十次的写数据库"变成了"每两分钟一批的批量写",MySQL 的压力下降了一个量级。

但这套设计有几个关键点要注意:Redis 里的考试会话一定要设置 TTL,不能无限期保存;定时任务刷新时不能全量覆盖,要做增量更新;交卷时必须先把 Redis 里剩余的暂存数据全部 flush 到 MySQL,再进入判分流程,否则就会出现"交卷后还有答案没落库"的必现问题。

4.2 幂等设计:同一个答案被提交两次怎么办

考试场景下,网络抖动和重复点击非常常见,同一个答案被提交两次甚至多次是常态。如果后端不做幂等处理,数据表里就可能出现重复记录,成绩统计就会算错。

我们的做法是双保险。第一层是逻辑幂等:前端每次提交答案时带上lastVersion字段,这个版本号由后端在第一次保存答案时生成并返回,存入 Redis 的exam:answer:version:{examSessionId}:{questionId}。后端收到提交后比较版本号,如果提交的版本号小于当前版本号,直接忽略本次更新。第二层是数据库层的唯一约束:答题明细表里有(exam_session_id, question_id)的唯一索引,用INSERT ... ON DUPLICATE KEY UPDATE处理,即使逻辑层漏过了重复请求,数据库也不会产生脏数据。

4.3 交卷链路为什么要拆成异步

交卷是整个系统里最复杂的一个操作。服务端要做的事包括:校验考试时间是否结束、从 Redis 拉取全部答案、写答题快照、调用阅卷服务判分、更新成绩、生成考试报告。如果全部同步执行,接口响应时间很容易超过三秒,而且一旦某个下游服务抖动,考生端就会误以为交卷失败,疯狂点击重试,进一步放大压力。

我们最后把交卷接口设计成了"受理模式":前端调用交卷接口,exam-service 只做必要校验,然后立刻把交卷事件写入本地消息表并返回"已受理",前端收到后进入"交卷处理中"的等待页;后台由消息队列把事件发给 grading-service,阅卷服务完成判分后,再通过 WebSocket 或轮询通知前端展示成绩。

这个方案牺牲了一点点实时性,但换来了极大的稳定性。即使判分链路超时,最多也只是成绩晚出几秒,不会影响考生交卷这个核心动作。单次交卷的平均响应时间从原来的 3.5 秒降到了 300 毫秒以内,2000 人同时交卷时再也没有出现雪崩。

4.4 异常场景:断网、超时自动交卷、重复交卷

学生写到一半断网是常态。我们的兜底方案是:exam-service 里有一个定时任务,每三十秒扫描一遍 Redis 里的考试会话,把那些"考试已结束但会话状态仍是进行中"的数据捞出来,自动标记为异常交卷,并保存最后一次暂存的答案。这样即使考生中途掉线再也没有回来,系统也能基于已保存的答题记录给出成绩,不至于整场考试白考。

重复交卷的防护是用 Redis 的SETNX实现分布式锁。交卷接口进入时先尝试获取锁,key 是exam:commit:{examSessionId},只有拿到锁且当前会话状态为"进行中"的请求才能继续执行,状态改为"已交卷"后释放锁。后续再来的交卷请求直接返回"已交卷"状态码,前端自动跳到成绩页面。

5. 部署、压测与线上问题排查

5.1 基础设施和部署形态

我们最终的部署没上 K8s,用的是 Docker Compose。原因很简单:团队规模不大,容器编排的运维成本还没有完全消化,而 Compose 在这类中小规模的微服务集群里完全够用。实际的主机配置是三台 4C8G 的云主机加一台 2C4G 的轻量机:一台跑 Nacos 和 RabbitMQ,一台跑 Redis、MinIO 和 MySQL,另外两台跑 Java 微服务实例,前端 Nginx 放在轻量机上。

Nacos 我们用的是 standalone 模式并开启了 MySQL 持久化存储。很多人容易忽略,Nacos 默认是内嵌数据库,重启后配置和注册数据可能丢失。生产环境一定要切换到 MySQL 存储模式,否则某个服务一重启,几十个配置和开关全没了,排查起来非常痛苦。

5.2 压测结果与参数调整

上线前我们对交卷链路做了一次完整的 JMeter 压测,模拟 2000 并发用户同时交卷。第一次压测的结果惨不忍睹,大量请求超时,TP99 直接飙到 12 秒。经过几轮参数调整后,系统才稳定下来。下面是主要的调整项:

参数调整前调整后说明
Tomcat threads max200500各业务服务应用线程池上限
HikariCP maximum-pool-size1050连接池上限,配合 MySQL 连接数调整
MySQL max_connections3001000数据库最大连接数
Gateway Netty 连接数默认4096网关层连接数显式调大
RabbitMQ 消费者并发18阅卷服务消费消息并发数

调整之后,2000 并发的交卷压测 TP99 降到了 1.2 秒左右,单机可以扛住整个学校高峰期考试。这里有一个很重要的经验:光调一个参数往往没有效果,需要网关、应用容器、数据库连接池、消息队列全链路一起协同调整,才能找到真正的瓶颈。

5.3 三个隐蔽故障及完整排查链路

第一个故障是正式考试前一天的配置中心事故。晚上八点我改了一条网关的限流规则,保存后大约五分钟,测试同学反馈打开试卷页面白屏。排查链路是这样的:先看前端 Nginx 访问日志,发现/api/exam/paper/...返回 503;再看网关日志,发现路由刷新失败;最后回到 Nacos 管理台,检查配置内容,发现限流规则里多了一个不可见字符,导致整个配置解析失败。那次之后我定了个规矩:所有配置变更必须先在 Nacos 的测试命名空间里验证,再同步到生产命名空间。

第二个故障是交卷后成绩迟迟不显示。排查时先看 RabbitMQ 管理界面,发现判分消息队列里堆积了两万多条消息;再看 grading-service 的消费日志,发现消费者线程池被大量长时间运行的判分任务占满。原因是主观题判分需要调用一个外部的 OCR 识别服务,那个服务在高峰期响应很慢,拖死了整个消费线程池。解决方式是给判分任务增加了优先级队列,客观题优先处理,主观题单独一个队列、单独控制并发,互不干扰。

第三个故障是前端 Vue 路由的刷新 404。测试同学在考试页面刷新浏览器时,Nginx 返回了 404,因为 Vue Router 用的是 history 模式,直接访问/exam/join/xxx对应的真实文件路径是不存在的。Nginx 默认找不到文件就返回 404,必须配置 try_files 回退到 index.html:

location / { root /opt/dist; index index.html; try_files $uri $uri/ /index.html; }

5.4 灰度发布的一次实际尝试

微服务改造之后,我们做了一次灰度发布实验。当时要上线新版判分逻辑,担心对成绩计算有影响,所以只想让管理员的账号先体验新逻辑。用 Nacos 的元数据配置,给新版本的 grading-service 实例打上version=gray标签,配合网关里基于 Header 的自定义路由规则,判断请求来自管理员就把流量引导到灰度实例,普通学生仍然走旧版本。

这次灰度整体上是成功的,新逻辑跑了一天后没有发现异常,再逐步把权重调大,最终完成了全量切换。我的感受是,灰度发布的价值不只是"上线更安全",更重要的是给了开发和测试一个真实环境下的观察窗口。

如果让我重新做一遍这个项目,我最想调整的可能是前端工程从一开始就用 Vue 3 的组合式 API 来组织状态管理,而不是中间从选项式迁移,那次重构的代价属实不小。但整体的微服务拆分方案我不会再动,因为考试系统的业务边界天然清晰,题库、考试、阅卷这几个域的划分经住了线上环境的检验,系统的可维护性和扩展能力都有了本质的提升。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询