在线考试系统微服务实战:Nacos、OpenFeign与Redis分布式锁架构设计
1. 项目整体设计与架构拆解接到这个在线学习考试组卷管理系统的需求时我心里其实先愣了一下一个看起来“不就是学生答题、老师出卷”的系统为什么要上微服务真正分析完业务之后这个答案变得很清晰。几千名考生同时点击“开始考试”的那一阵系统要在几秒内完成组卷、下发题目、提交答卷、计算成绩这些操作全部集中在同一时间窗口单体应用很容易把数据库连接池打满接口响应能卡到几秒以上。再往后学生刷题、班级排名、知识点掌握度统计又是纯读多写少的场景和考试提交这种高并发写场景混在一个应用里互相拖累是必然的。所以拆成微服务不是赶时髦而是需求逼出来的。这个项目最终的技术栈是 SpringBoot Vue Spring Cloud前端分学生端和后台管理端后台带可视化统计页面。整体拆了六个基础服务再加一个网关覆盖用户认证、课程管理、题库管理、试卷生成、考试执行、成绩统计这几条业务线。如果你是刚开始接触微服务想找一个能把注册中心、网关、远程调用、分布式事务、分布式锁、消息通知这些点全部串起来的实战项目这套系统的选型和拆解思路可以直接照着落地。1.1 微服务怎么拆才合理别按技术拆按业务边界拆很多人第一次做微服务容易把“层”当“服务”拆比如拆出一个 controller 服务、一个 service 服务、一个 dao 服务这就是典型的错误示范。服务拆分的粒度和依据应当是业务功能能否独立迭代、独立伸缩、独立容错。比如用户认证和用户信息管理可以放一起也可以拆开但更关键的是要看数据表会不会被多个模块同时强依赖。我这个项目里的划分方式是用户服务管学生、教师、管理员的基础信息和登录注册题库服务管题目的 CRUD、导入导出、审核上下架试卷服务管试卷模板、组卷规则和自动组卷算法考试服务负责一场考试从发布到交卷的全生命周期成绩统计服务沉淀答题记录、计算难度和区分度、跑报表消息通知服务处理交卷提醒、考试即将开始提醒这类异步通知。每一个服务对应一组高内聚的功能互相之间只通过 OpenFeign 调用接口不直接共享数据库表。为什么题库和考试必须拆成两个服务这是我最想解释的一个点。题库表在真实使用中会积累几十万甚至上百万道题组卷算法做一次深度筛选可能要反复扫描题目表这是典型的 CPU 密集和读密集操作而考试提交是典型的写密集操作有大量 insert 和 update。两者放一起一次低效的全表查询就能把连接池占住导致正在交卷的考生请求排队。拆开后考试服务完全走 Redis 缓存和异步落库题库服务的慢查询再严重也不会影响考生交卷这就是微服务隔离价值最直观的体现。1.2 组件选型Nacos、OpenFeign、Gateway 为什么是主力Spring Cloud 全家桶里可以用的组件很多但真实项目里不需要全部堆上去堆组件只会增加排障成本。我这套系统的核心选型如下服务注册与发现Nacos。没有用 Eureka因为 Eureka 2.x 的闭源和社区停滞让它在规划上存在不确定性Nacos 同时承担注册中心和配置中心两个职责少部署一套组件对中小团队很友好。远程调用OpenFeign。Feign 把 HTTP 调用封装成接口配合 Nacos 做服务发现调用方不需要关心对端实例的 IP 和端口负载均衡交给 Spring Cloud LoadBalancer 处理。网关Spring Cloud Gateway。注意不要选 Zuul 1.x那个是阻塞式 IO并发一高线程池就容易被打满。Gateway 基于 WebFlux 构建性能表现更好同时我们在网关层统一做了 JWT 鉴权、白名单放行和路由转发。配置管理Nacos Config Bootstrap。数据库连接、Redis 地址、业务开关全部放到配置中心改配置不用重启服务对微服务这种多实例部署的场景非常重要。版本组合这里一定要说清楚很多人在这上面栽过跟头。我这里用的是 Spring Boot 2.6.x Spring Cloud Alibaba 2021.0.x Spring Cloud 2021.0.x这套组合经历过大量生产验证最稳。Spring Boot 3.x 虽然新但如果你不是顺便要做 JDK 17 升级不建议在这个项目里冒险因为部分开源组件的兼容性还没完全闭合。1.3 画架构图之前先把数据和流量走向想明白做微服务项目我有个习惯动手编码之前先画一张部署架构图和一张核心请求时序图。架构图不要求画得多精美但要把请求链路画清楚。比如学生登录后前端请求先到 Nginx再转发到 Gateway 网关网关校验 JWT 后根据路由规则转发到用户服务或考试服务考试服务再通过 OpenFeign 调用题库服务查询题目信息调用成绩统计服务保存答题明细。整个链路里面哪些环节是同步阻塞的哪些环节可以异步解耦画完图之后一目了然。流量走向决定缓存和消息队列放在哪。考试开始那一刻的高峰流量是“读题目、交卷”所以开考时要把一份试卷的题目内容提前预热到 Redis而不是每次答题都查数据库。交卷后成绩计算和排名统计全部丢到消息队列里异步处理学生端只需要先看到“提交成功”成绩随后刷新。这个异步设计让考试服务的峰值压力降低一个量级。2. 核心服务设计与关键技术实现2.1 题库与组卷算法到底怎么在百万题里挑出合适的题题库服务是整个组卷系统的数据基础表结构设计直接影响后续所有功能的复杂度。我的核心表包括题目表存题干、题型、难度系数、所属课程和知识点 ID题目选项表存选项内容和是否正确答案知识点表维护课程的知识点树。难度系数用 1 到 5 的浮点数表示1 最简单5 最难这是组卷算法进行卷面难度控制的基础。自动组卷的流程我是这样设计的教师先设置试卷模板包括总题数、每种题型的题数、期望难度范围、知识点覆盖范围。然后由组卷算法生成实际试卷。算法不是粗暴地随机抽题而是分几步走。先把规则按题型分组然后针对每个规则组去题目表里做候选集采样候选集条件包含知识点匹配、难度区间、题目状态为已审核。候选集拿到后做洗牌再按规则数量截取。如果某类题在第一次筛选时数量不足就做约束放宽先把难度区间从精确匹配放宽到邻近档位再不够就放宽知识点范围用“先窄后宽”的试探方式尽可能凑齐一套卷子。2.2 组卷并发控制分布式锁保护试卷模板组卷过程有个容易被忽略的并发问题同一个试卷模板如果被两个教师同时触发“自动组卷”或者同一个模板在考试发布时被重复组卷就会产生两套不同的试卷后续存哪个都会出问题。我在组卷入口加了一把 Redis 分布式锁锁的 key 设计为paper:gen:{templateId}加锁成功才能执行组卷逻辑执行完成后释放。这里有个经验值得分享就是锁的粒度要精确到模板 ID而不是搞一个全局锁。如果所有组卷都用同一把锁整个系统的组卷操作就全部串行化了试卷多的时候性能完全不能看。精确到模板 ID 之后不同模板的组卷操作互不影响只有同一模板被重复触发时才会排队等待这就是分布式锁粒度选择的要点。使用 Redis 分布式锁还有几个容易踩的坑。第一加锁和设置过期时间必须是一个原子操作要用SET key value EX seconds NX分开执行会出现锁没设过期时间导致死锁的情况。第二释放锁的时候要校验自己的标识用 Lua 脚本先比对再删除防止把别人加的锁误删了。第三业务执行时间可能超过锁的超时时间这会导致业务还没执行完锁就自动释放了我后来引入了 Redisson 的看门狗机制自动续期。如果不想引入额外依赖可以把超时时间设置成业务预估最大耗时的三到五倍给足冗余。2.3 在线考试的分布式事务与幂等设计考试交卷这个动作涉及考试服务本地写入答卷记录、调用成绩统计服务保存作答明细、调用消息服务发送成绩通知。在微服务架构下这三个步骤分属三个服务不可能用本地数据库事务一次性搞定。如果强制引入 Seata 做分布式事务在这个量级的系统里反而会把性能和运维复杂度拖垮我的方案是本地消息表 消息队列 幂等消费。具体流程是这样考试服务先在自己库里的exam_submit_record表插入一条“待处理”的提交记录同时把“保存作答明细”“计算成绩”“发送通知”这几个动作以消息形式写入本地消息表然后立即返回前端“交卷成功”。后台有个定时任务扫描本地消息表把未发送的消息投递到 RabbitMQ各下游服务消费消息后执行自己的逻辑执行成功后回调确认。下游消费必须做幂等比如成绩统计服务消费“保存作答明细”消息时先查一下该考生该试卷的记录是否已存在存在就直接跳过否则才执行插入。这样即使消息重复投递结果也不会出错。事务虽然是最终一致的但用户感知上不会有问题。因为交卷后不是立刻需要看到成绩成绩在几秒内异步刷新完全符合预期。学生在交卷后看到的“提交成功”是本地事务已经完成的状态后面的计算和通知即便短暂延迟也影响不到核心体验。生成考试 ID 和试卷 ID 时我用的也是雪花算法生成的分布式 ID而不是数据库自增主键。原因是两个服务各自往库里插入数据如果都用自增 ID在跨库查询和日志追踪时很难通过 ID 判断全局顺序。雪花 ID 本身包含时间戳和机器码信息在排查问题时能直接看出大概的生成时间比自增主键好用很多。2.4 考试防作弊与缓存预热把题目答案藏起来在线考试和纸质考试最大的区别是防作弊只能靠技术和流程设计。我的做法是开考时把整套试卷的题目内容预热到 Redis考生每答一道题前端请求只会从 Redis 里获取当前这题的题干和选项题目答案字段在开考期间完全不会通过接口下发给前端。交卷时由后端在内存里完成判分考生端拿到的只有最终成绩连“哪道题对了哪道题错了”的明细都不会在首次交卷时返回。这就避免了学生用两个账号反复开考刷题把题目和答案搜集出来的问题。题目的图片和音视频材料也存在一个隐患。如果直接让前端访问图片 URL学生可以绕过考试接口直接下载资源再配合其他手段反推答案。我的处理是把题目附件也放到预签名 URL 里并且设置有效期开考期间生成的 URL 过一段时间就失效。同时文件服务用的是 MinIO部署在内网公网只能通过网关转发访问相当于把资源和题目加了一层访问控制。3. 前台学生端与可视化方案落地3.1 Vue3 项目结构动态路由和页面权限前台学生端和管理后台我分开建了两个 Vue 项目技术栈统一用 Vue3 Vite Pinia没有用 Vue2。Vue3 组合式 API 对复杂表单和页面状态的管理比 Options API 舒服很多特别是答题页这种需要维护大量中途状态的页面用ref和reactive组织数据要清晰不少。Vite 的开发体验也比 Webpack 领先一个档次冷启动基本是秒级。权限控制用动态路由实现。用户登录后拿到的不是完整路由表而是一个权限标识列表。前端根据权限列表动态注册路由比如学生能看到考试列表和成绩单教师能看到组卷工具和题库管理管理员看到的是组织架构和全局统计。这里需要特别留意刷新页面后的状态恢复问题否则用户一刷新浏览器路由动态注册的逻辑还没跑完页面就跳到 404 了。我的方案是在router.beforeEach里做全局前置守卫每次跳转前检查 Pinia 里有没有用户信息和路由表如果没有就先拉取用户信息、重建动态路由然后再放行。3.2 答题页的防刷新机制与答案暂存答题页是整个前台中最容易出问题的页面。学生可能考到一半误触刷新也可能因为网络原因页面白屏如果答案完全依赖内存刷新一次就得重答这种体验就是灾难。我的处理是让学生在答题过程中每切换一道题就把当前答案同步到 localStorage本地缓存里始终保存最近一次答案。同时监听beforeunload事件在页面关闭或刷新前弹出确认提示提醒学生当前考试尚未完成。真正交卷时前端把整份答卷一次性提交到后端后端校验通过后清空本地缓存。这个方案还要配合倒计时逻辑前端考试时长倒计时归零时自动触发一次强制交卷后端的定时任务也会扫描超过截止时间仍未交卷的考试记录自动执行交卷。前后端双保险不会出现考试时间到了学生还在答题的情况。3.3 可视化报表数据统计服务怎么支持图表后台的可视化页面包括成绩分布图、班级排名趋势图、知识点掌握度雷达图、题目区分度散点图。这些图表的原始数据不能直接让前端去查询考试库那样不仅请求链路长还会把明细数据暴露给前端。我在成绩统计服务里做了一层聚合接口按课程、班级、时间范围返回已经聚合好的 JSON 数据前端只需要把接口数据映射到 ECharts 的 series 里即可。一个值得注意的设计是聚合结果不要实时去数据库计算。我每天凌晨用定时任务把前一天的答题记录做一次全量聚合结果存到统计结果表里。白天教师打开报表页面时接口只查聚合后的结果表几毫秒就能返回。如果教师想看实时排名再单独走一个轻量级的实时统计接口但这个接口只统计最近一小时内交卷人数不多的小范围数据不会让成绩统计服务成为性能瓶颈。可视化的指标选择上也要讲究。比如题目区分度不能只看正确率高低要结合成绩排名分析高分组和低分组在该题上的得分差异。高分组都会而低分组都不会的题区分度就高是套好题所有人都答对的题虽然正确率 100%但对筛选考生没有意义。这些教育测量学的指标不复杂但比单纯堆图表观感更能体现系统的专业性。3.4 文件与流媒体MinIO 和 m3u8 播放题目里的图片和音视频文件统一通过 MinIO 对象存储管理SpringBoot 服务封装一个文件上传和下载的工具类生成预签名 URL 提供给前端。这部分有个典型坑预签名 URL 默认会用 MinIO 的 endpoint如果 endpoint 配置的是内网地址前端拿到这个 URL 也访问不了。解决方法是配置一个公网可访问的网关地址并且MINIO_ENDPOINT和生成预签名 URL 时使用的地址必须保持一致。英语听力题里会遇到 m3u8 视频播放的需求前端直接用原生video标签是播放不了的因为原生 H5 播放器不支持 HLS 流协议。我是用hls.js这个库来做解流播放在 Vue 组件里封装了一个HlsPlayer初始化时判断当前浏览器是否原生支持 HLS比如 Safari 可以直接播其他浏览器则加载 hls.js 来实现。实际测试下来m3u8 流的首帧加载速度比 mp4 慢一点所以我在封装组件时加了加载中遮罩避免学生看着黑屏以为题目附件坏了。4. 典型问题与排查思路实录4.1 网关转发和跨域的多层纠缠项目里最折磨人的问题是跨域和路由转发。后端各个服务之间通过 OpenFeign 调用时不存在跨域问题但浏览器直接请求后端接口时就涉及跨域了。我的处理是在 Gateway 网关层统一配置 CORS前端所有请求只发给网关不直接访问子服务这样跨域配置只需要在一个地方维护。网关路由的路径 StripPrefix 也很容易配错。前端请求路径是/api/exam/list网关需要把这个请求转发到考试服务的/list接口就必须配置StripPrefix1把第一级路径api去掉。如果配置不对考试服务收到的路径是/api/exam/list而实际接口路径是/list就会出现 404。这类问题日志上很难排查我后来在网关加了一个全局日志过滤器把请求进入网关的原始路径、转发路径、目标服务和响应码都打印出来排查效率提升特别明显。4.2 高并发下 Redis 缓存和分布式锁的连锁故障系统上线第一周就遇到过一次缓存雪崩事故。原因是所有试卷模板的缓存过期时间设置成同一个固定值缓存同一时间失效大量请求同时穿透到数据库差点把题库服务的连接池打爆。后来改成固定值加随机偏移量的方式每个模板的过期时间在基础值上加一个随机秒数让失效时间分散开。另外在查询接口前加了一层 Guava 本地缓存即使 Redis 缓存挂了本地缓存还能扛住一小段时间。还有一次组卷接口突然变慢的问题排查后发现是 Redis 锁的释放逻辑写错了。当时用delete命令直接删锁没有校验是不是自己的锁结果 A 线程的锁提前过期后B 线程拿到锁开始组卷A 线程执行完把 B 的锁给删了C 线程又拿到锁进来三个线程同时组同一份卷。重复组卷本身不算严重问题但数据库锁争用导致接口变慢。改成 Lua 脚本校验后再删除这个问题就消失了。这类排查经验让我深刻体会到分布式锁的代码只有几行但每个细节都能决定线上稳定性。4.3 数据库层面的拆分和读写分离微服务拆分之后数据库也跟着拆了。用户库、题库库、考试库分别由对应服务独立持有跨库之间不做外键只通过服务接口交互。拆分后遇到一个典型问题报表统计需要关联用户表的班级信息但统计服务和用户服务是两个不同的库SQL 没法直接 join。我的方案是统计服务在每天晚上同步一份班级维度的快照表报表页面只查这份快照不再实时跨服务查询。题库库是读多写少的最典型场景我给题库库做主从架构写操作走主库组卷候选集查询走从库。SpringBoot 这边通过动态数据源切换实现写操作使用主数据源查询时切到只读数据源。这个方案实施后主库负载明显下降试卷组卷接口的响应时间从平均 600ms 降到 150ms 左右。4.4 版本兼容和构建环境的大坑如果你是自己下载源码运行最容易卡住的一关就是环境版本。我整理过一个稳定的版本组合就直接照抄吧JDK 1.8、Spring Boot 2.6.13、Spring Cloud 2021.0.5、Spring Cloud Alibaba 2021.0.5.0、Node.js 16。Spring Boot 版本太高的话比如用到 2.7 以上部分 Spring Cloud 组件和第三方的 starter 会出现意想不到的兼容问题Spring Boot 3.x 则必须要 JDK 17你会面临一边升级框架一边改代码的双重压力。前端项目也要注意 Node 版本。Vue3 Vite 在 Node.js 16 以下会直接报错Node.js 17 以上有些依赖的构建行为也会变化。建议先用 nvm 锁定 Node 版本再执行npm install。另外 npm 的镜像源在国内环境下要配置淘宝源不然装一个hls.js都能等好几分钟。现象排查方向解决方式服务间调用一直报连接超时检查 Nacos 注册中心中服务是否都正常注册确认各服务 bootstrap.yml 的 Nacos 地址配置正确前端接口请求 404检查网关路由 StripPrefix 是否配置正确调整 StripPrefix 层级配合网关日志确认最终转发路径Redis 锁时不时失效检查是否用了非原子命令加锁、删除锁是否校验标识改用 SETNX 原子命令加锁释放锁用 Lua 脚本缓存穿透导致数据库压力大查看是否存在空值缓存问题对查询为空的数据也做短时间缓存配合布隆过滤器拦截我自己的习惯是先搭 Nacos、GateWay、用户服务、考试服务这四个核心模块把一条完整的“登录到交卷”链路打通再逐步加题库、统计、消息通知。微服务最怕的不是代码量而是刚开始就把服务边界和数据归属搞混。数据归属这件事如果一开始没想清楚后面改起来就是牵一发动全身。如果这个系统后续要扩展方向也很明确把消息通知服务接入微信公众号测试号做考试提醒把主观题批改服务单独抽出来做自然语言评分或者把统计分析模块做成独立的大屏展示页面。这些扩展都不会影响已经稳定的核心考试链路这就是当初花时间设计服务边界的价值。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →