基于.NET Core MVC的在线考试系统设计与高并发实践

发布时间:2026/10/7 18:05:22
基于.NET Core MVC的在线考试系统设计与高并发实践 1. 在线考试系统到底在解决什么问题先说个真实场景。我去年帮一所职业院校做过一次校内技能竞赛的在线考核几百号人同时登录系统答题考到一半服务器CPU飙到90%多数据库连接池被打满页面转圈圈学生急得在群里刷屏。那次之后我算是彻底想明白了一件事在线考试系统最难的不是能不能考而是很多人同时考的时候还稳不稳。这个基于 .NET Core MVC 架构的在线考试系统项目本质上就是在解决这类问题。它不是一个花哨的Demo而是一个能覆盖出题 - 组卷 - 发布考试 - 考生答题 - 自动判分 - 成绩统计完整闭环的管理系统。市面上这类源码项目很多但大部分要么是单体老架构连个依赖注入都写得稀碎要么就是过度设计上来就微服务、消息队列结果一个班级考试都跑不顺。这个项目的定位很清晰用 .NET Core MVC 这套成熟的技术栈把在线考试里最核心的几件事做扎实。适合谁看两类人。一类是刚接触 .NET Core 的开发者想找一个结构清晰、能跑通全流程的实战项目来练手另一类是学校、培训机构、企业内部需要做考核系统但不想从零开始造轮子的朋友拿这套源码做二次开发比自己瞎写省太多事。我花了两周时间把这个项目完整过了一遍从表结构到控制器再到前端页面每一步都实际跑过、改过、踩过坑。下面把这些经验按模块拆开讲尽量说人话把关键的设计思路和容易被坑的地方都点出来。2. 为什么偏偏是 .NET Core MVC 这套组合先别急着看代码得先搞清楚选型逻辑。很多人一听说在线考试系统第一反应是 Java 的 Spring Boot或者干脆用 PHP 一把梭。但 .NET Core MVC 在这类业务系统里有它非常舒服的地方。2.1 .NET Core 比起老 .NET Framework 强在哪老一代 ASP.NET WebForms 时代页面和后端逻辑耦合得很死一个页面对应一个 .aspx.cs想做前后端分离或者单元测试都费劲。而 .NET Core 从架构上就是重做的依赖注入、中间件管道、跨平台部署这些现代 Web 开发的基本盘都在。对于考试系统这种典型业务系统来说.NET Core 的启动速度、内存占用、整体性能表现都明显比 Framework 版本好尤其在高并发场景下差距更明显。我用 4核8G 的云服务器做过一个简单压测同样的业务逻辑.NET Core 版本每秒能处理的事务数大概是 Framework 版本的两倍多内存占用还低一截。对于百人级、千人级的同时在线考试场景.NET Core 完全扛得住。2.2 MVC 模式在这个项目里的具体分工MVC 把考试系统拆成三层各管各的这个分层在日常维护时特别舒服层次职责在考试系统中的具体体现Model模型数据结构和业务规则试题实体、试卷实体、考生答题记录、成绩单等View视图页面展示考生答题页面、后台题库管理页、成绩统计报表页Controller控制器接收请求、调度逻辑接收提交答案的请求、调用判分服务、返回结果举个考试中最高频的操作——考生提交一道单选题的答案。请求先打到ExamController的SubmitAnswer方法控制器把前端传来的选项ID封装成答题记录模型调用判分逻辑这道题的正确答案是什么、选对了没有然后返回 JSON 给前端。整个链路清晰出问题的时候顺着 Controller - Service - Repository 一路查下去就行。2.3 为什么不选前后端完全分离现在很多新项目一上来就是 Vue/React WebAPI 完全分离但这个考试系统没有这么干。原因很实际考试系统的页面交互虽然多但大多是表单提交、列表展示、简单的局部刷新用 MVC 自带的 Razor 视图引擎加一点 jQuery / Ajax 就能很好地完成。完全分离意味着要维护两套工程、处理跨域、还得设计一套接口鉴权工程量翻倍对这类内部业务系统来说是过度设计。当然这套源码也留了余地。API 控制器是单独分层的以后想拆出独立的前端项目后端接口可以直接复用不用推倒重来。3. 核心功能模块拆解从题库到成绩单的全链路这个考试系统的功能模块不算多但每个模块都踩在真实需求上。我逐个过一遍顺便说说每个模块在设计上的关键决策。3.1 用户与权限三种角色各管各的系统里有三种核心角色管理员、教师、考生。这里没有搞 RBAC基于角色的访问控制那一套复杂的东西而是用了最简单实用的方式——Role字段区分用户类型配合Session或Claim记录登录状态控制器层用自定义AuthorizeFilter做访问控制。为什么要这么设计因为考试系统的角色边界非常清晰不会有某个用户既是A角色又是B角色的复杂需求。过度设计成细粒度权限反而让代码变得难懂。具体实现上登录接口验证用户名密码后会把用户ID、角色、姓名写进ClaimsIdentity然后生成加密 Cookie。后续每个请求通过[Authorize(Roles Teacher)]这种特性标注就能限制访问。考生进不了后台管理页教师改不了系统设置管理员拥有全部权限。3.2 题库管理题型多样才算合格的题库题库模块支持单选题、多选题、判断题、填空题、简答题这五种常见题型。这基本覆盖了绝大多数考试场景。题库管理的核心是试题实体设计。Question表我看了下字段设计得挺讲究QuestionType题型枚举决定前端渲染什么控件、判分逻辑走哪条分支Difficulty难度等级1-5用于后面的智能组卷OptionJson选项内容用 JSON 字符串存储。这个设计很聪明因为不同题型的选项结构不一样用关系表存的话要拆好几张表JSON 一份搞定Answer标准答案选择题存选项序号判断题存对错简答题存要点关键词Analysis答案解析考完试卷分析时展示给学生看题库的增删改查没什么稀奇的但有两个细节值得表扬。第一是支持批量导入Excel 模板下载后填好题目就能一次性导入几百道题不然人工一道题一道题录教师能疯掉。第二是题目支持按知识点、难度、题型组合查询为组卷提供了筛选基础。3.3 试卷管理两种组卷方式满足不同场景组卷是这个系统最核心的业务之一。它提供了两种模式手工组卷教师从题库里逐题挑选加入试卷设置每题分数。适合小测验、单元测试这种对题目有精确把控的场景。实现上就是一个试卷和试题的关联表记录了每道题在试卷里的顺序和分值。自动组卷教师设定试卷结构比如单选题10道每题3分多选题5道每题4分判断题10道每题2分简答题2道每题10分再从题库里按难度比例随机抽题。这里用了一个按难度分布抽题的算法核心逻辑是先统计题库里各难度题目的数量然后按试卷要求的难度比例用随机数从对应难度的题目里抽取保证试卷难度分布符合预期。自动组卷的实现代码大致长这样public ListQuestion AutoGeneratePaper(int easyCount, int mediumCount, int hardCount, int questionType) { var easyQuestions _questionRepository.GetQuestionsByDifficultyAndType(1, questionType) .OrderBy(q Guid.NewGuid()).Take(easyCount).ToList(); var mediumQuestions _questionRepository.GetQuestionsByDifficultyAndType(3, questionType) .OrderBy(q Guid.NewGuid()).Take(mediumCount).ToList(); var hardQuestions _questionRepository.GetQuestionsByDifficultyAndType(5, questionType) .OrderBy(q Guid.NewGuid()).Take(hardCount).ToList(); return easyQuestions.Concat(mediumQuestions).Concat(hardQuestions).ToList(); }Guid.NewGuid()做随机排序是个小技巧比Random类更均匀也够简单。3.4 考试发布与在线答题最考验细节的模块创建一场考试需要设置考试名称、关联试卷、考试开始时间、结束时间、考试时长、允许参加考试的班级或考生列表。在线答题这块有几个设计值得单独说第一是计时机制。不是用前端倒计时而是后端在考生开始考试时记录StartTime每次提交答案时校验当前时间是否超过了StartTime Duration。为什么因为前端倒计时可以被篡改或者绕过而后端时间校验是硬性的。当然前端也有倒计时提醒只是它的角色是提示不是约束。第二是答题状态保存。考生做完一道题就通过 Ajax 保存一道不是等交卷时一次性提交。这样即使中途断网、浏览器崩溃已经保存的答案都还在。保存接口接收题目ID和答案内容写入ExamAnswerRecord表。第三是交卷处理。交卷时要做三件事校验时间没有超时、校验未提交的题目如果有考生遗漏直接交卷系统要自动把空白答案补上、触发自动判分。3.5 自动判分与成绩统计简单题目机器判复杂题目人来看自动判分是考试系统省时省力的关键。单选题、多选题、判断题直接比对答案字符串一样给分不一样零分。多选题这里有个增强逻辑——如果试卷设置了少选给一半分那答案包含在正确答案里但没选全就给一半分。这个在很多严肃考试里都有需求源码里做到了。填空题简单匹配答案关键词。题库里可以配置多个可接受答案用竖线分隔比如ASP.NET Core|.NET Core匹配上任何一个都给分。简答题机器判分只能做关键词匹配所以源码里的处理是——超出设定字数阈值且含有关键词的先自动判为待人工复核由教师在后台手动给分。这种机器初判 人工复核的模式在大规模考试里能省掉90%的批改工作量。成绩模块就比较常规了考完自动生成成绩单教师可以按班级导出 Excel按题型统计得分率。得分率这个数据很有用能一眼看出哪道题全班都答错说明这道题要么有歧义要么老师没讲透这个知识点。4. 数据库设计核心表结构和关联关系代码看多了你会发现这类业务系统的价值一半都在表结构设计里。我实际对照着建库跑了一遍把核心表拆给你看。4.1 用户和角色相关表用户表User主要字段Id主键、UserName、PasswordHash、RealName、Role1管理员、2教师、3考生、ClassId班级外键用于考生关联班级。班级表Class很简单Id、ClassName、GradeName。别小看这张表它是后面按班级发布考试、按班级统计成绩的基础。4.2 题库相关表题目表Question的核心字段我上面已经列过。这里有一个很多新手容易纠结的问题为什么选项要用 JSON 而不是单独建一张选项表我个人的看法是这个项目选择 JSON 是对的。原因有三一是选项只在出题时设置、答题时展示不会单独去查询哪些题用了这个选项二是不同题型的选项结构差异大选择题是ABCD文本判断题只有对错填空题根本没有选项一张通用表要么冗余要么稀疏三是减少表数量让代码层映射更简单。缺点是统计类查询比如哪些题目的选项包含某关键词会稍微麻烦但考试系统根本没这种需求。题目和课程/知识点关联表QuestionKnowledgePointQuestionId、KnowledgePointId支持一题对应多个知识点。4.3 试卷相关表试卷表PaperId、Title、TotalScore、Duration考试时长、CreateUserId、Status草稿/已发布。试卷题目关联表PaperQuestionId、PaperId、QuestionId、Score该题分值、SortOrder试卷内排序。这张表是组卷功能写入的核心手工组卷和自动组卷最终都是往这张表里插数据。4.4 考试与答题记录表考试表ExamId、Title、PaperId、StartTime、EndTime、ExamDuration、ExamStatus未开始/进行中/已结束、CreateUserId。考试班级关联表ExamClassExamId、ClassId一场考试可以指定多个班级参加。考生考试记录表ExamRecordId、ExamId、StudentId、StartTime、SubmitTime、Score、Status考试中/已交卷/超时自动交卷。答题明细表ExamAnswerRecordId、ExamRecordId、QuestionId、StudentAnswer、IsCorrect、GainedScore。这几个表的关系链条是Exam关联Paper和Class考生点击开始考试时生成ExamRecord每答一题写入ExamAnswerRecord交卷时按PaperQuestion里的分值汇总到ExamRecord.Score。这套表设计是典型的够用但不过度没有搞分库分表没有搞读写分离纯粹的单库多表就能支撑起一个中小规模的考试系统。等真到了几千人同时交卷打满单库的时候再谈架构升级也不迟。5. 从代码看两个关键业务流程的实现表结构清楚了代码逻辑就好讲了。这里挑两个最核心的业务流程——自动判分和自动交卷把代码级别的实现思路捋一遍。5.1 自动判分的完整调用链交卷接口ExamController.SubmitExam被调用时后端处理逻辑顺序是先校验ExamRecord状态是否已经是已交卷避免并发重复提交拉取该考生的所有ExamAnswerRecord加上试卷的PaperQuestion列表逐题调用ScoringService.ScoreQuestion(question, answer)汇总总分写入ExamRecord.Score状态改为已交卷记录SubmitTime更新该考试的ExamRecord状态回写统计信息判分核心逻辑public decimal ScoreQuestion(Question question, string studentAnswer) { if (string.IsNullOrWhiteSpace(studentAnswer)) return 0; switch (question.QuestionType) { case QuestionType.SingleChoice: case QuestionType.TrueFalse: return question.Answer.Trim() studentAnswer.Trim() ? question.Score : 0; case QuestionType.MultipleChoice: var correctSet question.Answer.Split(,).Select(a a.Trim()).ToHashSet(); var answerSet studentAnswer.Split(,).Select(a a.Trim()).ToHashSet(); if (correctSet.SetEquals(answerSet)) return question.Score; // 少选给一半分的配置在这里生效 if (question.AllowHalfScore answerSet.IsSubsetOf(correctSet) answerSet.Any()) return question.Score / 2; return 0; case QuestionType.FillBlank: var acceptedAnswers question.Answer.Split(|); return acceptedAnswers.Any(a a.Trim().Equals(studentAnswer.Trim(), StringComparison.OrdinalIgnoreCase)) ? question.Score : 0; case QuestionType.ShortAnswer: // 超过阈值就标记待人工复核 if (studentAnswer.Length question.MinimumLength) return -1; // -1 表示待教师手动判分 return 0; default: return 0; } }有个小细节值得说判断题的Answer字段存的是 True 或 False 字符串单选存的是 A、B 这样的选项序号多选存的是 A,B,C 这种逗号分隔的字符串。判分时把学生答案和标准答案都转成HashSet再比较能天然处理顺序不一致的问题。这个技巧建议直接复用。5.2 考试超时自动交卷的保障机制在线考试肯定会遇到一种情况考生答到一半考试时间到了但他没点交卷按钮页面一直挂着怎么办这个系统用了一个双重保险方案一是答题时实时校验。每次考生通过 Ajax 保存答案时后端都会顺手校验当前时间是否超过考试结束时间。如果超过了返回一个特殊状态码前端收到后弹窗告知考试时间已到系统将自动交卷然后跳转到成绩页。二是定时扫描兜底。有一个后台任务每隔30秒扫描一次ExamRecord表把那些考试时间已到但状态还是考试中的记录捞出来自动执行交卷流程。这个后台任务在 ASP.NET Core 里用IHostedService实现在Startup.cs里注册成单例服务项目启动后就在后台默默工作。public class AutoSubmitBackgroundService : BackgroundService { private readonly IServiceProvider _services; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); using (var scope _services.CreateScope()) { var examRecordRepo scope.ServiceProvider.GetRequiredServiceIExamRecordRepository(); await examRecordRepo.AutoSubmitExpiredExams(DateTime.Now); } } } }用CreateScope()从根容器里取 Repository是因为BackgroundService是单例而 DbContext 是 scoped 生命周期直接在单例里注入 DbContext 会出问题。这个坑新手经常踩我在这里提醒一下。6. 部署运行全记录从 .NET SDK 到 IIS 站点这部分全是实际操作我按自己踩坑的顺序一步步写。照着执行你本地和服务器上都能跑起来。6.1 本地开发环境准备需要准备的东西就三样.NET SDK我用的是 8.0 LTS 版本项目如果是 6.0 或 7.0 的也能兼容运行实在不行改个目标框架就升级上去了Visual Studio 2022 或者 VS Code C# 插件。我用 VS Code 也行但调试体验不如 Visual StudioSQL Server Express / LocalDB 或者完整的 SQL Server。开发期用 LocalDB 足够部署了再换正式库安装完 SDK 后在项目根目录执行dotnet restore dotnet ef database update dotnet run看到Now listening on: http://localhost:5000就说明跑起来了。用浏览器打开就能看到登录页默认管理员账号通常是admin / 123456这类初始密码正式使用前务必要改。6.2 数据库初始化一条龙项目里的 EF Core 迁移文件是现成的前提是你装了dotnet-ef工具然后按顺序执行dotnet tool install --global dotnet-ef dotnet ef migrations add InitialCreate dotnet ef database update如果你连数据库都不想装项目里一般会带一个.bak备份文件或者一个初始化 SQL 脚本在Database目录下直接用 SQL Server Management Studio 还原或者执行脚本也行。6.3 发布到 Windows 服务器 IIS 的完整步骤服务器环境Windows Server 2019 或 2022安装好 .NET Core 托管捆绑包.NET Core Hosting Bundle这是 IIS 上跑 .NET Core 的必备组件不装的话站点会一直 502.3。用命令发布项目dotnet publish -c Release -o D:\publish然后打开 IIS右键新建站点物理路径指向D:\publish应用程序池改成无托管代码模式。这个细节很重要.NET Core 应用不走 IIS 的托管管道它只作为反向代理把请求转发给 Kestrel 进程所以托管模式要选No Managed Code。万一忘了装托管捆绑包IIS 里这个站点启动后会直接报错错误信息类似HTTP Error 500.35 - ANCM Out-Of-Process Startup Failure看到这个基本就能确定是捆绑包缺失。6.4 连接字符串和配置文件appsettings.json里的ConnectionStrings是核心。发布到服务器后记得改成生产环境的数据库连接{ ConnectionStrings: { DefaultConnection: Server.;DatabaseExamSystemDb;User Idsa;Password你的密码;TrustServerCertificateTrue; }, ExamSettings: { AutoSubmitIntervalSeconds: 30, MaxConcurrentExams: 300 } }数据库账号不要用sa裸奔我自己的做法是单独建一个exam_user账号只授予这个数据库的读写权限。密码别硬编码在 json 里至少用环境变量覆盖一下。ASP.NET Core 自带配置优先级是环境变量 JSON 配置所以你在服务器上设置ConnectionStrings__DefaultConnection环境变量就能覆盖 json 里的值不用改文件。7. 高并发场景下最容易崩的几个点既然考试系统的核心是很多人同时用那并发问题必须单独拿出来说。我在压测和实际使用中遇到过的坑按危害程度排个序。7.1 连接池耗尽——最典型的在线考试事故几百个考生同时交卷每个交卷请求要写答题记录、查试卷明细、算分数、更新成绩一个请求可能打开多个数据库连接。如果代码里没有及时Dispose连接或者没有用异步方法连接池很快就满了后续请求全部排队超时表现就是页面转圈、接口报错。这个项目的主体代码用的是 EF Core 仓储模式DbContext走 scoped 生命周期每次请求一个实例用完后自动释放基本不会出泄漏问题。但如果你拿到源码后自己加了原生 ADO.NET 的代码一定要记住using (var connection new SqlConnection(connectionString)) { // 你的操作 }能用using的别省能用await的不要用.Result。同步阻塞在 .NET 里面对并发是最伤的。7.2 交卷接口的幂等问题我看过不少同类系统交卷接口根本没有幂等处理。学生第一次点击交卷网络卡了一下没响应他以为没提交成功又点了一次。结果后端收到两个交卷请求一条ExamRecord被处理了两遍成绩算了两遍最后显示的成绩还可能是第二次覆盖第一次的异常值。这个项目的处理方式算稳妥交卷接口第一行就检查ExamRecord.Status如果已经是已交卷就直接返回成功结果不再走判分逻辑。这种状态机拦截在订单、考试这类业务里是标配拿到源码后建议不要删。7.3 缓存题库减少数据库压力考试开始后所有考生都在读同一套试卷题目如果每次都去数据库查题目几十个考生同时读同一批题数据库压力不小。这个项目里有一个简单的内存缓存服务试卷发布时把题目列表缓存到内存里考试期间答题相关的查询直接走缓存考试结束后清理。public class PaperCacheService { private readonly IMemoryCache _cache; public ListPaperQuestion GetPaperQuestionsWithCache(int paperId) { var key $paper_questions_{paperId}; return _cache.GetOrCreate(key, entry { entry.AbsoluteExpirationRelativeToNow TimeSpan.FromHours(2); return _paperRepository.GetPaperQuestions(paperId).ToList(); }); } }注意缓存时间要大于最长考试时间这里是2小时否则考试还没结束缓存先过期了又得重新查数据库。7.4 多选题少选给分的配置陷阱自动组卷时有一个参数是多选题是否启用少选给一半分这个参数只在Question表里用AllowHalfScore字段控制。但问题是如果一场考试里混有多个题型判断题的单选逻辑和多选题的少选逻辑在判分代码里走的是不同分支。我遇到过配置了少选给分但判断题也被减半的情况排查后发现是switch里漏了break。源码本身没有这个问题但如果自己改判分服务请务必给每个分支写单元测试尤其是多选的集合比较逻辑最容易改出 bug。8. 二次开发和扩展的几个方向如果你准备在这套源码上继续做东西我根据实际需求提几个方向按性价比排序。8.1 增加附件上传和视频监控严肃一点的在线考试都需要考生上传附件比如简答题的图片、编程题的项目文件或者开启摄像头监控。加附件上传不难用IFormFile接收文件存到服务器指定目录再把文件路径存到ExamAnswerRecord里就可以。建议加一个文件大小限制比如单个文件不超过10MB和文件类型白名单防止有人上传恶意文件。8.2 优化判分策略引入 AI 辅助简答题的判分一直是痛点。这套系统的关键词匹配法对付请简述什么是依赖注入这种题目时相当有限。如果接一个大语言模型 API把学生答案和参考答案发给模型让它给分能做到虽然是程序判的但批改逻辑和学生工整度都像真人的效果。注意实现时要加一个争议机制——模型给出低分且置信度不确定时自动转入人工复核队列。8.3 考试封卷和成绩导出目前成绩导出是 Excel。如果学校或企业要求用特定格式如教务系统要求 JSON 上报可以加一个导出适配器。这类业务逻辑在源码里是单独的服务层接口做好之后加新的导出格式只需要新增一个实现类。8.4 消息通知考试发布后自动通知学生站内信、邮件或企业微信 webhook这个功能门槛低、见效快尤其适合培训机构。源码的User表里已经有邮箱字段只要在发布考试的方法里加一个异步通知调用就行。9. 源码里值得直接抄的三个小技巧最后说三个我实际用到别的项目里的技巧都是从这套源码里学来的不写就亏了。9.1 用Guid.NewGuid()做随机排序C# 里Random做排序种子在短时间多次创建时可能重复导致随机结果一样。OrderBy(q Guid.NewGuid())虽然性能略差但随机性足够均匀代码还简洁。批量抽题、随机生成验证码这类场景都能用。9.2 内存缓存加绝对过期时间缓存题目、配置、字典这类低频变更数据时给AbsoluteExpirationRelativeToNow设置合理的时间比滑动过期时间更适合业务系统。滑动过期适合用户会话绝对过期适合静态数据理由很简单题目改了之后缓存要尽快失效不能让旧的题目缓存留到第二天。9.3 通过服务定位器正确创建子作用域第八章里BackgroundService要访问 scoped 服务时必须CreateScope()这个模式在处理后台任务里操作数据库时是标准答案。我自己之前写过不少后台服务直接在构造函数里注入 DbContext运行一段时间后就报无法从根容器解析服务就是因为没按生命周期来。这是一个非常完整、能直接拿来跑的在线考试系统。我过完一遍代码和实际部署后最大的感受是它的设计没有炫技每个选择都踩在业务需求上。无论是当教学项目研究还是拿来做二次开发底座都很值得。如果你已经在跑这个项目或者在改的过程中遇到什么坑欢迎评论区聊聊。