首页 / 资讯中心 / 文章详情

线上考试系统万人交卷不崩:Vue + .NET 高并发架构实战

线上考试系统万人交卷不崩:Vue + .NET 高并发架构实战 ★ FEATURED ARTICLE
线上考试系统扛住万人同时交卷Vue .NET 这套架构做了些什么做在线教育平台开发的这些年我参与过的项目里最让人手心冒汗的不是电商大促而是每年两次的万人级线上考试。几万名学生同时登录、同时答题、到点集体交卷那一瞬间的并发量级几乎能把任何一个没做过专项设计的系统直接打趴下。我在这篇文章里想聊的就是一套经过实战检验的线上考试系统技术架构前端基于 Vue后端基于 .NET主要是 ASP.NET Core这套组合在高并发场景下的整体设计思路、核心细节和踩坑实录。无论你是正在设计考试系统的架构师还是准备用 Vue 和 .NET 开发此类系统的开发者这篇文章里的方案、参数和教训应该都能给你一些靠谱的参考。1. 在线考试系统的核心挑战与整体架构设计1.1 高并发场景下的核心痛点真不只是人多在做技术方案之前得先把问题拆清楚。在线考试系统的高并发跟普通网站的瞬时流量高峰有本质区别。普通宣传页的高并发是读多写少靠 CDN 加静态化就能解决大半但考试系统是典型的读多写更多、且写入有时序性的场景。痛点主要集中在三个层面。第一开场压力极其集中。考试不是均匀负载而是约定一个时间点比如上午 9 点整所有考生同时点击进入考试。这个瞬间要完成登录态校验、试卷拉取、题目渲染、计时开始等一系列操作是典型的瞬时高并发读压力。按一万考生计算假设进入动作平均耗时 800ms那么至少需要同时处理 1 万以上的并发请求这对网关、认证服务、缓存、数据库都是一次集体冲击。第二交卷瞬间的写压力巨大。所有考生在 10 点整同时点击交卷系统要批量接收答案、逐题校验、计算得分、落库存储、生成成绩快照。如果所有流程都放在一个同步接口里做完数据库连接池会立即被拖死接口超时率飙升最终结果就是大量考生交卷失败客服电话被打爆。第三防作弊与稳定性需求相互制约。现在很多考试系统要求切屏检测、人脸抓拍、异常行为记录这些能力如果都放在前端主线程里做会严重影响答题性能和浏览器稳定性尤其对于低端设备用户可能题目没做几道页面先崩了。这三个痛点决定了架构设计的基本方向把读取尽量前置化、缓存化把写入尽量异步化、削峰化把非核心逻辑尽量独立化、旁路化。1.2 为什么选 Vue 加 .NET 这个组合很多团队在这个技术选型上会有争议尤其现在 Java 系和后端框架的选择很多但我们的最终选择是有充分理由的。首先说后端ASP.NET Core 在高并发场景下的表现长期被低估了。它的原生异步模型async/await在 I/O 密集型场景下效率极高配合 Kestrel 服务器单机就能扛住相当可观的并发连接数。在我们的压测数据里一台 4C8G 的云主机部署 ASP.NET Core Web API配合 Redis 缓存在试卷拉取这类读接口上可以达到 3000 QPS而 CPU 占用率还能控制在 60% 以内。这归功于 .NET 的内存管理和线程池调度机制在高并发下线程切换开销控制的非常好。如果你之前只接触过 .NET Framework我建议重点关注 .NET 6/8 的持续改进性能提升非常明显。再说前端Vue 3 的响应式系统和组合式 API特别适合考试系统这种状态复杂、交互密集的单页应用。考试过程中的题目切换、选项选中、剩余时间展示、题目标记、答题卡状态同步都是高频状态更新Vue 3 的细粒度响应式更新机制基于 Proxy能保证这些交互流畅进行。配合 TypeScript在题目数据的类型约束上能提前避免很多低级错误。而且 Vue 生态的周边工具很成熟Pinia 做状态管理、Vue Router 做路由控制、Vite 做工程化构建整套链路组件化程度高开发效率非常高。这在前端界是少有的全家桶体验。1.3 整体架构的分层设计与核心模块划分我们先画出一张架构蓝图纯文字描述不依赖图表的读者也能顺着思路走访问层由 Nginx 反向代理承担负责 SSL 终止、静态资源托管、负载均衡和基本的速率限制。前端 Vue 应用构建后的静态文件全部交给 Nginx 托管减少不必要的后端请求。网关层引入 Ocelot.NET 生态下的 API 网关负责认证入口统一、路由转发、限流熔断。所有前端的 API 请求先经过网关再转发到具体的微服务或模块。应用层按业务域拆分为四个服务认证与用户服务、考试会话服务、题目与试卷服务、交卷与判分服务。每个服务独立部署数据库层面也做了隔离至少是逻辑隔离避免相互拖垮。数据层与中间件层包含 PostgreSQL或 SQL Server视团队习惯作为主存储Redis 作为缓存和分布式锁的载体以及 RabbitMQ 作为异步消息队列。这个结构的核心思路是读请求尽量在网关和缓存层拦截写请求通过消息队列异步消化最重的判分计算靠独立的消费者服务慢慢处理。提示如果项目初期团队规模较小不需要一上来就拆四个微服务。实际做法是代码上保持模块独立但打包时可以只部署为一个单体应用保留后续拆分边界。经验之谈先单体内聚、模块清晰比刚开始就搞微服务要稳妥的多后续按业务负载拆分也更可控。2. 前端技术架构与工程化实践细节2.1 基于 Vite 与 TypeScript 的工程搭建要点前端工程这块我们用的是 Vite 作为构建工具主要是被它的冷启动速度和热更新效率征服的。考试系统开发过程中前端页面多、组件复用频繁如果用旧的 Webpack 方案每次改完代码都要等好几秒甚至十几秒的编译开发体验割裂感太重。结合 Vite 的项目结构会按照功能模块做如下组织src/ ├── api/ # 接口请求层按业务域拆文件 │ ├── auth.ts │ ├── exam.ts │ └── user.ts ├── assets/ # 静态资源 ├── components/ # 公共组件 │ ├── CountDownTimer.vue │ ├── QuestionCard.vue │ └── AnswerSheet.vue ├── composables/ # 组合式函数 │ ├── useExamSession.ts │ └── useQuestionState.ts ├── layouts/ # 布局组件 ├── router/ # 路由配置 ├── stores/ # Pinia 状态仓库 │ ├── exam.ts │ └── user.ts ├── views/ # 页面级组件 │ ├── ExamHall.vue │ └── ExamResult.vue └── types/ # TypeScript 类型定义这里要特别强调 TypeScript 的作用。考试系统里的数据结构非常稳定且有严格语义比如题目类型单选、多选、判断、填空、答案格式选项 ID、文本内容、布尔值、答题状态未答、已答、标记、考试状态未开始、进行中、已提交、已交卷。用 TS 的类型系统把这些语义固定下来开发期就能避免大部分低级错误。例如定义一个考试会话的类型export interface ExamQuestion { questionId: string; type: single | multiple | judge | blank; content: string; options?: { optionId: string; content: string }[]; answer?: string | string[]; userAnswer?: string | string[] | boolean | null; marked: boolean; answered: boolean; score: number; } export interface ExamSession { examId: string; title: string; startTime: number; endTime: number; duration: number; // 单位秒 questionList: ExamQuestion[]; currentQuestionIndex: number; remainingSeconds: number; submitStatus: pending | submitting | submitted; }把类型定义好了组件签名、状态管理的 schema、接口的返回类型都可以基于这套类型推导出来联调时对接口结构一目了然。2.2 Pinia 状态管理与请求层的封装方案考试会话的状态管理我个人建议用 Pinia。它比起 Vuex 更简洁没有那么多模板代码同时天然支持 TypeScript 类型推断适合这种对数据类型要求高的场景。在考试会话状态管理中核心是要处理好题目列表的变更问题。考试过程中考生会做三类操作切换题目、选择答案、标记题目。每一类操作都会触发状态变更。如果频繁地直接修改整个题目列表的引用会引起不必要的组件重渲染。合理做法是通过索引更新的方式只触发对应题目的响应式变更。另外一个关键点是时间状态。剩余时间属于高频更新的数据但不应该放进 Pinia 里用普通 reactive 去管理因为每一秒的 setInterval 更新都会驱动所有访问该状态的计算属性重算。稳妥做法是把剩余时间单独提取出来用一个 reactive 对象包裹并配合watch做关键节点触发如剩余 5 分钟、剩余 1 分钟的提示。请求层封装这块Axios 统一了请求头、超时时间、错误处理。但考试系统里有两个特殊场景需要单独处理长时间保持接口连接用于心跳上报和在线状态检查。这类请求要走单独的 axios 实例避免与普通请求超时设置冲突。交卷接口的长任务处理。在极端情况下即使做了异步化前端也需要等待后端返回接收成功的确认消息这个确认要使用单独的、更长的超时时间。// 请求层核心封装片段示例 const request axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 15000, // 普通请求 15s 超时 headers: { Content-Type: application/json } }); // 心跳与交卷专用实例超时 60s const longRequest axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 60000, });2.3 实时交互与前端防作弊机制的实现考试系统对实时性有要求但不是传统意义上的毫秒级实时聊天更多是状态同步和时间校准。我们选用的是 SignalR.NET 生态下的实时通讯库由后端服务主动推送考试开始、考试结束、强制交卷、异常提醒这四类关键事件到前端。SignalR 在前端的接入其实不复杂import * as signalR from microsoft/signalr; const connection new signalR.HubConnectionBuilder() .withUrl(/hubs/examHub) .withAutomaticReconnect([0, 2000, 10000, 30000]) // 指数退避重连 .build(); connection.on(ForceSubmit, () { // 后端强制交卷立即触发交卷流程 submitExam(timeout); }); connection.on(TimeSync, (serverTime: number) { // 校准本地时钟与服务器时钟的偏差 timeOffsetRef.value serverTime - Date.now(); });这里特别要说的是时间同步问题。所有考生本地设备时钟不可能完全一致有些学生的手机/电脑时间快了或慢了一分钟如果前端直接用new Date()做倒计时会导致有些人提前看到时间到、有些人晚了引发大量投诉。我们的做法是在连接 SignalR 时获取一次服务器标准时间计算本地时钟与服务器的偏移量所有倒计时计算都基于服务器时间 本地偏移并且每 60 秒校准一次。实测下来一万人的倒计时偏差能控制在 500ms 以内。前端防作弊这块常用的方案有两类浏览器事件监听visibilitychange 事件、窗口 blur/focus 事件与屏幕捕获截图。我们不建议把这两类都直接塞进主进程里这会严重影响渲染和交互性能。更合理的做法是切屏事件的监听代码精简、高频但轻量只在发生切换时上报不持续传输数据。截图能力基于getDisplayMedia或后台静默截图视浏览器能力再通过 worker 线程或者异步队列去处理上传后立即中断释放资源。注意防作弊是双刃剑过度监控会直接伤害用户体验。建议把切屏次数上限设计为可配置项并在开考前的说明页面明确告知考生行为规范将触发阈值设置在一个合情合理的范围比如最多切屏 3 次超过就自动交卷并标记作弊嫌疑。这样既约束了考生行为又不会因为用户不小心多切了一次屏就引发大规模投诉。2.4 前端加载性能优化与弱网降级方案万人同时在线考试大部分考生的网络环境差异极大5G、Wi-Fi、还是农村的 4G 网体验绝不能一刀切。前端性能优化上我们做了这几件事。第一路由懒加载 组件异步加载。把试卷页面、成绩页面、个人信息页面分别拆成独立的 chunk首屏只加载考试大厅所需的代码。尤其试卷渲染相关的组件题卡、答题卡、计时器单独做组件级异步加载避免首屏 bundle 过大。按经验Vue 应用改造前首屏 JS 约 900KB改造后能压到 350KB 左右首屏时间从 4 秒降到 1.8 秒。第二题目数据结构化存储 局部渲染。一份包含 80 道题的试卷全部塞进一个 JSON 可能超过 1MB如果前端一次性全量渲染 DOM 节点内存占用巨大低端手机直接白屏。我们的做法是把题目数据按类型分组先渲染当前题和相关题卡其他题目数据保持不变配合 Vue 的v-if/v-for高效切换渲染性能压力比较小。第三弱网降级方案。考试系统绝对不能因为用户网络抖动就把考试中断。我们做了一套断网续答策略前端在本地内存里始终保留一份用户答案的快照同时监听网络状态。如果检测到断网页面进入弱网模式——题目列表仍可查看、答案仍可填写、切换题目正常只是所有提交动作都进入本地队列。等网络恢复队列一次性上报。这比很多系统断网就锁定页面的粗暴做法在用户留存率上差异是巨大的。3. 后端高并发处理与性能保障机制3.1 数据库设计与缓存策略的实战取舍考试系统的数据量不算特别大但读写模式很有特色。单场考试一万考生、80 道题答案记录大概是 80 万条左右这个量级在数据库层面并不夸张。真正麻烦的是集体读写同一组数据的时机比如交卷瞬间大量考生同时更新自己的答案记录和成绩记录如果都落在同一张表上锁竞争和索引维护的开销会放大到难以接受。我们的方案是两台 MySQL 实例一主一从做读写分离。考试过程中前端只读试卷、读考试状态这些请求全部走从库交卷写答案、写成绩走主库。对试卷内容的读取绝大多数直接打到 Redis 缓存上根本不会打到数据库。试卷内容缓存的粒度也有讲究。我们采用试卷模板 题目内容分离的方式整个试卷的元数据考试 ID、时长、总分、大题信息是一份缓存键每道题目单独一份缓存键。这样考生进入考试时只需先拉取试卷元数据和小题的题目 ID 列表再通过多级查询拉到具体的题目内容。这种拆分的好处是如果某场考试需要临时删掉某道争议题目只需要单独更新那道题的缓存而不需要让全体考生重新拉取整份试卷。Redis 缓存穿透的问题我们也吃过亏。考试开始前大量考生几乎同时请求个人信息 考试资格接口如果缓存里没有请求会一股脑打到数据库造成了一次真实事故。后面专门在接口层做了布隆过滤器把所有有考试资格的考生 ID 在开考前 10 分钟预加载进来请求进来先过布隆过滤器判定无资格的请求直接拒绝不落数据库。这里给出一个 Redis 缓存键设计示例exam:meta:{examId} # 试卷元数据 exam:question:{questionId} # 单题内容 exam:user:answer:{examId}:{userId} # 用户答案缓存粗粒度 exam:user:status:{examId}:{userId} # 用户考试状态进行中/已交卷 exam:qualify:{examId} # 布隆过滤器 bitmap3.2 接口层限流熔断与防止雪崩的措施保障高并发系统稳定性的关键一步是做限流和熔断。我们使用的是 AspNetCoreRateLimit 库 自定义中间件配合实现两类限流全局并发限制控制单个节点最大的并发请求量超过的请求直接返回 429前端收到后展示系统繁忙请重试提示。针对特殊接口的 IP 限流例如登录接口、验证码接口防止恶意脚本刷接口。具体限流配置参考如下appsettings.json{ IpRateLimiting: { EnableEndpointRateLimiting: true, StackBlockedRequests: false, RealIpHeader: X-Real-IP, ClientIdHeader: X-ClientId, HttpStatusCode: 429, GeneralRules: [ { Endpoint: *:/api/auth/login, Period: 1m, Limit: 10 }, { Endpoint: *:/api/exam/start, Period: 1m, Limit: 60 } ] } }除此之外我们还在网关层实现了简单熔断机制如果某个下游服务的错误率在 10 秒内超过 20%则熔断该服务的调用直接返回降级响应。考试系统的降级策略分两层设计如果交卷服务异常繁忙前端仍可正常答题提交请求会进入本地队列每 5 秒重试一次后端只保存接收成功的凭证真正的判分由后续异步处理。如果试卷拉取服务暂时不可用前端用本地缓存的题目快照继续考试同时提示试卷数据暂时缓存部分图片加载可能变慢。3.3 交卷与判分流程的异步化改造消息队列核心交卷是整个系统的命门。同步交卷在大并发下一定失败这是我在项目上试错试出来的结论。我们第一版就是同步判分一万考生同时交卷数据库连接池直接飙到上限大量请求超时最后靠重启临时恢复了。后来彻底改成异步判分。异步判分的核心流程是前端发起交卷请求带上考生 ID、试卷 ID、答案快照。后端先对请求做基础校验是否本人、考试时间是否有效、是否已经交过校验通过后把消息体答案 JSON 序列化投递到 RabbitMQ 的exam.submit.queue。接口立即返回交卷成功成绩核算中的响应浏览器收到这个响应后进入一个加载页面等待 3-5 秒然后通过轮询或 SignalR 推送获取最终成绩。后台独立的判分消费者服务从队列中一条条取消息真正的判分逻辑对比标准答案、计算得分、记录每道题得分、统计总分在服务里完成最后写入成绩表。这样交卷接口本身耗时极短整体吞吐量完全取决于 RabbitMQ 的消费能力。为了控制消息积压程度我们为每个 RabbitMQ 队列设置了长度告警如果队列长度超过 5000 条就会触发消费者扩容通过 K8s 自动扩缩容。下面是一个简化的判分消费者核心代码C#public class ExamSubmitConsumer : BackgroundService { private readonly IConnection _connection; private readonly IModel _channel; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _channel.QueueDeclare(queue: exam.submit.queue, durable: true, exclusive: false, autoDelete: false, arguments: null); var consumer new AsyncEventingBasicConsumer(_channel); consumer.Received async (model, ea) { var body ea.Body.ToArray(); var message JsonSerializer.DeserializeSubmitMessage(body); // 判分逻辑 var score await _scoringService.CalculateAndSaveAsync(message); // 手动 ACK确保消息不丢失 _channel.BasicAck(ea.DeliveryTag, false); }; _channel.BasicConsume(queue: exam.submit.queue, autoAck: false, consumer: consumer); await Task.Delay(Timeout.Infinite, stoppingToken); } }需要提醒的是消息队列方案需要配套做好消息幂等处理。因为消费者可能因为网络抖动或重试机制对同一条消息判分两次所以我们的成绩表里有(examId, userId)的唯一索引重复写入时直接返回已有结果避免成绩被覆盖。3.4 系统稳定性的监控、日志与告警体系高并发系统如果没有完整的可观测性就像蒙着眼睛开车。我们自研了一套简化的监控看板核心监控四类指标接口维度请求量、成功率、平均响应时间、P95/P99 响应时间。P95 意义比平均值大得多如果 P95 超过 1 秒说明有部分用户正在经历明显的卡顿。JVM/.NET 运行时CPU 使用率、内存占用、GC 暂停时间、线程池队列深度。如果线程池队列深度持续超过 200说明服务进入过载状态。Redis/数据库命中率、连接数、慢查询数量。Redis 缓存命中率低于 95% 时就会重点排查看是否有大量业务数据没有预热或缓存键设计不合理。消息队列队列长度、消费者消费速率、未确认消息数量。日志这块我们直接采用结构化日志Serilog把 JSON 日志推到统一的 ELK 栈里。对于考试系统还有一条独家的经验给每一个请求加上一个全局唯一的traceId从前端发起请求时生成随响应传递这样即使是一个考生从登录到交卷的全链路涉及了五六个服务、十几条日志也能通过traceId把所有上下文串起来排障效率成倍提升。4. 实战中的典型故障与排查排障技巧4.1 高频故障梳理时钟不同步、缓存穿透与重复提交故障一客户端时钟不同步引发的自动交卷事故。有一次线上考试有接近 300 名考生反馈考试还剩 3 分钟才能答完系统却提示时间到强制交卷了。排查后发现问题出在本地倒计时逻辑上当时的前端倒计时直接用new Date()而学校机房的电脑时钟整体快了大约 3 分钟。后来改造为 SignalR 定期同步服务器时间并在前端强制使用服务器时间计算剩余时间问题才彻底消失。故障二Redis 缓存穿透造成数据库瞬时过载。这件事发生在开考前 10 分钟大量并发请求涌向考生资格查询接口缓存未命中的请求堆积数据库连接池被占满。后来除了布隆过滤器还在 Redis 里加了空值缓存即使查询结果为空也向 Redis 里写入一个 30 秒有效期的占位值避免同样的空查询反复穿透。故障三交卷接口重复提交导致成绩重复写入。部分学生在交卷时点了两次按钮或者网络重试机制导致同一份答案提交了两次。如果不做幂等成绩表会出现两条记录虽然最终取的是最新值但会造成统计数据混乱。解决方案就是之前提到的唯一索引 数据库约束配合前端按钮点击后立即置灰 禁用。4.2 高效排查思路日志链路追踪与全链路压测方法遇到线上投诉不要直接扎进去看代码。我的排查顺序是先看网关层流量是否有异常流量来源比如同一 IP 频繁请求。再看服务层 P95 和错误率判断是整个服务过载还是某个接口异常。看消息队列积压量判断下游消费链路是否健康。看数据库慢查询和锁等待判断数据层是否存在瓶颈。最后翻日志根据traceId串链路查具体的异常堆栈。全链路压测更是必修课。我们使用 JMeter 和 Locust 混合搭了一套压测环境专门模拟三类压力模型开场并发冲击模拟 1 万人 10 秒内同时进入考试。考试中峰值模拟 5000 人同时在 5 分钟内高频切换题目和上交答案。交卷集中冲击模拟 1 万人 5 分钟内分三波集中交卷。压测时重点观察的是性能拐点——即并发量超过某个阈值后响应时间开始急剧上升的那个点。这个拐点数据直接决定我们要不要做扩容、要不要调整限流策略。一个很实用的压测经验是不要只压单接口要多接口混合压测。因为真实考试过程各个接口的调用占比是动态变化的接口之间存在资源竞争混合压测才能暴露真实的性能瓶颈。4.3 避坑清单前端、后端、运维三端全记录经过多个考试项目的磨炼这里整理一份高并发在线考试系统的避坑清单希望后来的开发者少走弯路。层面常见坑避坑建议前端倒计时使用本地时间统一用服务器时间 定期校准前端题目全量渲染致卡顿数据快照 按需渲染题卡、答题卡局部更新前端切屏监控过度配置阈值 前置公告说明避免误伤大量用户前端提交无幂等保护按钮禁用 请求防抖 本地队列后端同步判分交卷接口拆分为消息接收 异步消费后端缓存穿透布隆过滤器 空值缓存 热点数据预热后端数据库无唯一约束成绩表建(examId, userId)唯一索引后端消息队列无幂等消费逻辑保证幂等落库前检查唯一约束运维没有全链路监控日志带 traceId 响应时间分位数指标运维压测只看平均值关注 P95/P99 和性能拐点运维部署无弹性容器化部署 基于队列长度自动扩缩容提示考试系统这类产品有一个行业特点——稳定性优先级远高于功能丰富度。在功能评审时如果一个需求量会降低系统的并发承载能力那这个需求就要非常谨慎地评估取舍。考试期间保证每个考生能正常交卷永远是第一位的。5. 实操心得从方案到落地的几点体会架构设计做得再漂亮最终还是要落到真实的考试现场。我个人在实际操作中最大的体会是高并发的优化方案80% 靠设计20% 靠细节。设计方案决定系统的天花板而细节——比如时间同步、缓存预热、幂等控制、弱网处理——决定了考生实际能体验到的下限。从我参与的多个模拟项目来看这套 Vue .NET 的技术栈配合 Redis、RabbitMQ、容器化部署完全能扛住万人级在线考试的压力。如果你正在规划类似系统我的建议是先从交卷流程异步化做起这是性价比最高、收益最明显的改造然后再逐步补齐缓存策略、限流熔断、可观测性。不要一开始就堆微服务和高大上的组件先把核心链路跑顺比什么都重要。最后再分享一个小技巧在考试系统的开发环境里一定要有一个专门模拟弱网 断网 恢复的测试脚本。这个脚本看起来不起眼但很多上生产才暴露的问题其实在开发阶段用这种环境一跑就现原形了。线上考试容不得半点闪失多一分测试的苛刻考生那边就少一分突发的心跳加速。
阅读完成 · 觉得有帮助?
咨询建站