ASP.NET Core 选课系统高并发名额扣减与冲突判定

发布时间:2026/9/18 19:12:54

ASP.NET Core 选课系统高并发名额扣减与冲突判定 简介这份基于ASP.NET的学生选课系统设计与实现文档面向计算机相关专业的本科生、课程设计或毕业设计参考者也适合刚接触Web开发、希望理解三层架构与教学管理系统落地的初学者。正文围绕系统设计、数据库设计、功能模块实现、测试部署与维护展开涉及SQL Server 2000数据库和Visual Studio 2005开发环境并覆盖首页登录、院系添加、学生选课、课程管理和成绩查询等模块给出了用户界面、业务逻辑层与数据访问层的实现思路。压缩包共1个docx文件约955KB属于文档型资料便于通读、摘录和按章节改写。目录包含摘要、前言、数据库与开发工具简介、系统设计、各功能模块实现、系统使用、结论、参考文献、致谢与附录结构完整可作为论文写作模板和项目复现的参照。目前已有98人学习下载。1. 从一份课程表到选课系统ASP.NET 到底要解决什么选课开放的那一分钟教务系统面对的不是查询压力而是同一门热门课在几秒内被几百人同时下单。一份「基于 ASP.NET 的学生选课系统设计与实现」真正难的地方从来不是把增删改查页面拼出来而是三件事同时成立课程时间不能撞、教学班名额不能超、退课之后名额要能准确回到池子里。新项目直接上 ASP.NET Core MVC 加 EF Core是现在最省心的组合如果学校机房里还跑着 .NET Framework 上的 ASP.NET MVC 5 老系统业务逻辑可以照抄差别主要在依赖注入、EF Core 迁移和异步管道这几处默认行为上。这套东西适合两类人做课程设计需要一套能跑、能讲清原理的完整方案的学生以及要把老教务模块迁到新栈、又不想在并发上翻车的工程师。下面按数据模型、选课事务、调试配置、进阶验证这条线走一遍。2. 学生选课系统的数据模型与时间冲突判定2.1 课程、教学班、选课记录三张表之外必须补的字段把「课程」和「上课时间」塞进同一张表是这类系统最常见的起点也是后面一切冲突判定的根源。同一门《数据结构》可能由三个老师在不同时段各开一个班课程名和学分是共享的容量、教师、时间、地点是每个班独立的。所以至少要拆成课程定义、开课班次、选课记录三层再加上学生表。表关键字段最容易漏的字段漏了之后会发生什么CoursesCode、Name、CreditSemester跨学期的同名课程数据串在一起统计学分时翻倍CourseOfferingsTeacherName、CapacityEnrolled、RowVersion名额靠实时 COUNT 算并发下必然超卖EnrollmentsStudentId、OfferingIdStatus、CreatedAt退课记录被当成有效选课冲突判定永远误报Enrolled这个冗余字段很多人不敢加觉得用COUNT(*)更「正确」。但在选课高峰每次请求都对选课记录做一次聚合统计等于把行级争用放大成表级扫描。把它当成一个必须和选课记录同事务更新的计数器才是能扛住并发的写法。2.2 用 EF Core Code First 建出选课系统的最小数据层先把实体落下来。注意Weekday加StartSlot、SlotCount这组字段它们是后面区间重叠判定的基础// Models/CourseOffering.cs public class CourseOffering { public int Id { get; set; } public string CourseCode { get; set; } ; public string CourseName { get; set; } ; public double Credit { get; set; } public string TeacherName { get; set; } ; public string Semester { get; set; } ; // 形如 2025-2026-1 public int Capacity { get; set; } // 教学班容量 public int Enrolled { get; set; } // 已选人数同事务维护 public byte Weekday { get; set; } // 1周一 … 7周日 public byte StartSlot { get; set; } // 起始节次从 1 开始 public byte SlotCount { get; set; } // 连排节数 public byte[] RowVersion { get; set; } Array.Emptybyte(); public ListEnrollment Enrollments { get; set; } new(); }Weekday用byte而不是DateTime是因为冲突判定只关心「星期几的第几节」不关心具体日期。把时间存成字符串再在内存里解析是另一个高频坑数据库没法为它建索引判定只能全表拉回来跑选课量一上来就卡死。然后是DbContext里三处必须显式配置的地方protected override void OnModelCreating(ModelBuilder b) { b.EntityCourseOffering() .Property(x x.RowVersion).IsRowVersion(); // SQL Server 的 rowversion b.EntityEnrollment(e { // 同一学生对同一教学班只能有一条有效记录 e.HasIndex(x new { x.StudentId, x.OfferingId }) .IsUnique() .HasFilter([Status] 2); // 退课记录不参与唯一性 e.Property(x x.Status).HasConversionint(); // 冲突判定走这个索引避免全表扫 e.HasIndex(x new { x.StudentId, x.Status }); }); }参数说明HasFilter([Status] 2)是 SQL Server 的过滤索引语法方括号是标识符转义换成 SQLite 要写成HasFilter(\Status\ 2)双引号包裹。IsRowVersion()只对 SQL Server 生效映射到 SQLite 时不会被识别需要改成IsConcurrencyToken()并在保存前手动给新值否则并发令牌形同虚设。2.3 时间冲突判定的区间重叠 SQL 与索引冲突判定本质上是一维区间重叠新课的[start, startcount)和已选课的区间有交集就算冲突。条件用两句话就能写完注意都是严格不等号-- 查询该学生在同一学期内与目标课程时间重叠的所有已选课程 SELECT o.Id, o.CourseName, o.Weekday, o.StartSlot, o.SlotCount FROM Enrollments e JOIN CourseOfferings o ON o.Id e.OfferingId WHERE e.StudentId studentId AND e.Status 1 -- 只算有效选课 AND o.Semester semester AND o.Weekday weekday -- 同一天 AND o.StartSlot targetEnd -- 已选课的开始 新课的结束 AND o.StartSlot o.SlotCount targetStart; -- 已选课的结束 新课的开始逻辑说明targetStart、targetEnd由目标教学班的StartSlot和StartSlot SlotCount算出来在应用层算好再传参避免在 SQL 里做表达式运算导致索引失效。参数说明与边界节次用半开区间表示第 3 节连排 2 节就是[3,5)占第 3、4 节。如果教务的节次表里包含课间休息别去建模休息时间那属于展示层问题。当SlotCount为 0 时上面的条件恒不成立等于课程不参与冲突判定实践课、线上课可以用这种方式绕过——但要记得在录入界面做非空校验别让人误填。索引方面(StudentId, Status)用来快速筛出这个学生的有效选课(Semester, Weekday)用来裁剪学年学期和星期维度。两个索引配合单次冲突判定基本能控制在毫秒级。3. ASP.NET MVC 控制器里实现选课、退课与名额扣减3.1 选课动作的完整控制器代码选课接口是整个系统唯一需要认真写事务的地方。它要按顺序做完四件事锁住教学班这一行、判断名额、判断冲突、扣名额写记录[Authorize(Roles Student)] [HttpPost] [ValidateAntiForgeryToken] public async TaskIActionResult Enroll(int offeringId, CancellationToken ct) { var studentId int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); await using var tx await _db.Database.BeginTransactionAsync(ct); // 1) 对该教学班行加更新锁并发请求在这里排队 var offering await _db.CourseOfferings .FromSqlInterpolated($SELECT * FROM CourseOfferings WITH (UPDLOCK, ROWLOCK) WHERE Id {offeringId}) .SingleOrDefaultAsync(ct); if (offering is null) return NotFound(); if (offering.Enrolled offering.Capacity) return BadRequest(名额已满); // 2) 时间冲突判定 var targetEnd (int)offering.StartSlot offering.SlotCount; var conflict await _db.Enrollments .Where(e e.StudentId studentId e.Status EnrollmentStatus.Enrolled) .Join(_db.CourseOfferings.Where(o o.Semester offering.Semester), e e.OfferingId, o o.Id, (e, o) o) .Where(o o.Weekday offering.Weekday o.StartSlot targetEnd o.StartSlot o.SlotCount offering.StartSlot) .Select(o o.CourseName) .FirstOrDefaultAsync(ct); if (conflict ! null) return BadRequest($与《{conflict}》时间冲突); // 3) 扣名额 写记录同一个事务提交 offering.Enrolled 1; _db.Enrollments.Add(new Enrollment { StudentId studentId, OfferingId offeringId, Status EnrollmentStatus.Enrolled, CreatedAt DateTime.UtcNow }); try { await _db.SaveChangesAsync(ct); // 唯一索引冲突在这里抛 await tx.CommitAsync(ct); } catch (DbUpdateException ex) when (IsUniqueViolation(ex)) { await tx.RollbackAsync(ct); return Conflict(请勿重复选课); } return Ok(new { offeringId, enrolled offering.Enrolled }); }参数说明FromSqlInterpolated会把{offeringId}编译成真正的 SQL 参数而不是字符串拼接注入风险不用额外担心。WITH (UPDLOCK, ROWLOCK)是 SQL Server 的表提示作用是让并发的第二个请求在这一行上等待而不是各自读到旧的Enrolled值再各自加一。如果项目用 SQLite 做课程设计表提示语法不成立两种替代方案一是把事务改成BeginTransaction(IsolationLevel.Serializable)SQLite 会直接锁库二是干脆不锁把名额扣减写成条件更新让数据库自己保证原子性UPDATE CourseOfferings SET Enrolled Enrolled 1 WHERE Id id AND Enrolled Capacity; -- 受影响行数为 0 表示没抢到直接返回名额已满第二种写法在 SQL Server 上同样有效而且比显式锁更轻很多高并发选课场景最后都收敛到这一条语句。3.2 用唯一索引兜底重复选课与状态机的配合应用层的判重逻辑再严谨也可能被两次快速连点、浏览器回退重提交、或者前后端分离下的重试绕过。唯一索引就是最后一道闸门冲突会以DbUpdateException的形式冒出来private static bool IsUniqueViolation(DbUpdateException ex) ex.InnerException is SqlException sql (sql.Number 2601 || sql.Number 2627); // 2601 唯一索引重复键2627 唯一约束冲突 // SQLite 换成 SqliteException判断 SqliteErrorCode 19注意这里必须按数据库厂商的错误码判断不能笼统地 catch 所有DbUpdateException然后返回「重复选课」。外键约束失败、字段超长、并发令牌冲突都会抛同一个异常类型一刀切会让真正的错误被吞掉排查时只能看到错误提示。选课状态机的取值要让唯一索引、名额统计、冲突判定三处口径一致否则过滤索引会失效状态值含义占用名额参与冲突判定是否进唯一索引0待确认预选阶段是是是1已选是是是2已退课否否否3超时释放否否否把状态 0 算作占用名额是很多系统的争议点。如果预选阶段不占名额学生可以无限预选正式放开时冲突集中爆发如果占用就要配合超时释放否则名额会被一直挂着不放。3.3 超时未确认的名额回收回收任务放在BackgroundService里每 5 分钟扫一次先改状态再减计数两个动作用同一个事务包住protected override async Task ExecuteAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { var expired await _db.Enrollments .Where(e e.Status EnrollmentStatus.Pending e.CreatedAt DateTime.UtcNow.AddMinutes(-15)) .ToListAsync(ct); // 按教学班分组一次 UPDATE 减到位避免逐条更新放大锁竞争 foreach (var group in expired.GroupBy(e e.OfferingId)) { await _db.CourseOfferings .Where(o o.Id group.Key) .ExecuteUpdateAsync(s s.SetProperty(o o.Enrolled, o o.Enrolled - group.Count()), ct); foreach (var e in group) e.Status EnrollmentStatus.Expired; } await _db.SaveChangesAsync(ct); await Task.Delay(TimeSpan.FromMinutes(5), ct); } }参数说明ExecuteUpdateAsync是 EF Core 7 之后引入的直接更新不会把实体加载进变更跟踪器适合这种批量计数场景。减名额的数量一定要用实际扫描出来的分组条数而不是固定减 1否则一批过期记录会漏减。真正上生产时还要考虑把这段扫描的过滤条件建成(Status, CreatedAt)联合索引否则每次都是全表扫。4. VS Code 调通 ASP.NET Core 选课项目的配置与排错4.1 launch.json、tasks.json、appsettings.json 要改哪几行在 VS Code 里跑 ASP.NET Core 项目报错十有八九出在program路径和预构建任务上。三个文件各管一段// .vscode/launch.json { version: 0.2.0, configurations: [ { name: CourseSelection, type: coreclr, request: launch, preLaunchTask: build, program: ${workspaceFolder}/bin/Debug/net8.0/CourseSelection.dll, cwd: ${workspaceFolder}, env: { ASPNETCORE_ENVIRONMENT: Development }, serverReadyAction: { action: openExternally, pattern: \\bNow listening on:\\s(https?://\\S) } } ] }参数说明program指向编译产物而不是csproj写成.csproj会报「找不到可执行文件」。serverReadyAction.pattern匹配的是 ASP.NET Core 6 之后输出的Now listening on:日志如果用的是 .NET Framework 的 ASP.NET MVC 5 加 IIS Express这行日志根本不存在需要装 IIS Express 扩展并把type换成iisexpress否则永远卡在启动等待里。// .vscode/tasks.json { version: 2.0.0, tasks: [{ label: build, command: dotnet, type: process, args: [build, ${workspaceFolder}/CourseSelection.csproj], problemMatcher: $msCompile }] }$msCompile这个 problemMatcher 负责把编译错误解析成可点击的问题列表删掉它之后错误只会在终端里滚过去反而更难查。// appsettings.Development.json { ConnectionStrings: { Default: Serverlocalhost;DatabaseCourseSelection;Trusted_ConnectionTrue;TrustServerCertificateTrue }, Enrollment: { ConfirmTimeoutMinutes: 15, MaxConcurrentRequests: 300 } }参数说明TrustServerCertificateTrue只在本地开发用来自签证书报错发布前要删掉。MultipleActiveResultSetstrue是另一个常见配置项选课系统里建议不要开——它会让同连接上的并行查询合法化把本应暴露出来的 N1 查询藏起来等到高峰期才发现一次选课打了四十条 SQL。4.2 建库、迁移与日常启动命令dotnet new mvc -n CourseSelection cd CourseSelection dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Design dotnet tool install --global dotnet-ef dotnet ef migrations add InitSchema dotnet ef database update dotnet watch run # 改代码自动重启调冲突判定时特别省事dotnet watch在选课逻辑调试阶段比反复重启调试器快得多但改到DbContext或迁移文件时它不会自动重建模型需要手动停掉再跑一次。启动阶段常见的报错和判断方向报错真实原因处理The term dotnet-ef is not recognized全局工具目录不在 PATH把~/.dotnet/tools加进 PATHUnable to create a DbContext连接字符串没被加载确认ASPNETCORE_ENVIRONMENT是 Development端口被占用启动即退出上次 watch 进程没杀干净lsof -i:5000找进程或换--urlsSQLite Error 19: constraint failed唯一索引生效了属于预期捕获后返回 409 而不是 5004.3 登录态与 ViewState 相关的安全配置面试 ASP.NET MVC 前后端岗位时过滤器执行顺序几乎必问而选课系统正好用得上[ValidateAntiForgeryToken]属于授权阶段执行的过滤器比 Action 里的业务代码更早拦下伪造请求所以选课、退课这类写操作全都挂上它比在方法体里手写校验 token 更可靠。ASP.NET Core 项目要保证多实例部署时登录态不串关键在 Data Protection 密钥builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(/var/keys)) .SetApplicationName(CourseSelection); // 多台机器必须一致否则 A 机发的 Cookie B 机解不开 builder.Services.ConfigureApplicationCookie(o { o.Cookie.HttpOnly true; o.Cookie.SameSite SameSiteMode.Lax; o.Cookie.SecurePolicy CookieSecurePolicy.Always; o.SlidingExpiration false; // 选课高峰别让会话无限续期 });如果是维护在 .NET Framework 上的老 ASP.NET 教务系统网页里的隐藏字段承担着状态回传的职责它的签名和加密校验一旦被关掉客户端提交的内容就没有完整性保护可以被构造成任意内容再送回服务器。常见的错误做法是遇到「ViewState 校验失败」的报错直接去web.config把校验关掉问题看上去消失了攻击面却同时打开。正确方向是固定机器密钥并保持校验开启system.web pages enableViewStateMactrue viewStateEncryptionModeAlways / httpCookies httpOnlyCookiestrue requireSSLtrue / machineKey validationHMACSHA256 decryptionAES / /system.web参数说明enableViewStateMac控制签名校验任何情况下都不该设为 falseviewStateEncryptionModeAlways让回传内容整体加密machineKey三台服务器保持一致免得用户换个节点就掉登录。另外在页面初始化阶段把ViewStateUserKey绑到当前会话标识上可以让隐藏字段和会话一一对应跨会话重放同一份回传内容就不再成立。迁移到 ASP.NET Core 之后这套机制由防伪令牌接管概念可以平移实现方式完全不同。5. 选课高峰的进阶技巧缓存、限流与压测验证课程列表和开课详情是典型的读多写少数据一个学期里基本不变用IMemoryCache或分布式缓存挡掉大部分查询是见效最快的优化。但要守住一条线只缓存课程名、学分、教师、上课时间这些描述性字段Enrolled绝对不能进缓存。public async TaskListOfferingDto GetOfferingsAsync(string semester, CancellationToken ct) { var key $offerings:{semester}; if (!_cache.TryGetValue(key, out ListOfferingDto? list)) { list await _db.CourseOfferings .Where(o o.Semester semester) .Select(o new OfferingDto(o.Id, o.CourseName, o.TeacherName, o.Capacity, o.Enrolled, o.Weekday, o.StartSlot)) .AsNoTracking() .ToListAsync(ct); // 名额字段变化频繁缓存 20 秒已经是上限 _cache.Set(key, list, TimeSpan.FromSeconds(20)); } return list!; }参数说明AsNoTracking()去掉变更跟踪查询开销能降一截20 秒的过期时间是权衡结果再长就会让学生看到过期名额点进去才发现满了投诉量反而上升。缓存键带上学期避免跨学期串数据。限流放在选课接口上用 ASP.NET Core 内置的限流中间件按用户维度排队比直接封 IP 合理因为机房出口 IP 往往只有一个网段。最后一步是验证。并发问题不压测是看不出来的用工具对选课接口打并发然后回查数据库bombardier -c 200 -n 2000 -m POST \ -H Content-Type: application/x-www-form-urlencoded \ -H Cookie: .AspNetCore.Cookies登录后复制 \ -b offeringId1__RequestVerificationToken令牌 \ http://localhost:5000/Enroll-- 压测后核对计数器与真实记录数必须相等且不超过容量 SELECT o.Id, o.Capacity, o.Enrolled, COUNT(e.Id) AS RealCount FROM CourseOfferings o LEFT JOIN Enrollments e ON e.OfferingId o.Id AND e.Status 1 WHERE o.Id 1 GROUP BY o.Id, o.Capacity, o.Enrolled;判断标准很直接Enrolled等于RealCount同时小于等于Capacity。如果Enrolled小于RealCount说明计数器有丢失更新如果大于Capacity说明行锁或者条件更新没有被真正走到。这两种结果都不会在单机手工点击时暴露只有并发下才会现形所以每次改动选课事务逻辑之后这一轮核对都要重跑一次。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/9/18 19:12:54

C#抽象类与多态实战:从EyeColor到Area的运行时绑定

简介:这是一份面向编程初学者的C#面向对象编程(OOP)入门教程PDF,聚焦抽象类、继承、多态等核心概念,帮助读者夯实C#语言基础并掌握实际编码范式。资源为单文件PDF文档(59KB),内容结构…

2026/9/18 19:07:53

STM32CubeProgrammer安装与嵌入式AI固件烧录实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/18 14:13:01

拯救者Y7000黑屏故障排查与维修实战指南

1. 项目概述:一台黑屏的拯救者Y7000,到底卡在哪一步? 联想拯救者Y7000系列笔记本,从2018年第一代搭载i5-8300H开始,到后来的i7-9750H、i7-10750H、i5-11400H,再到2023年款的R7-7840HS,它始终是学…

2026/9/18 0:01:09

Google Colab 实战:运行模型、数据加载与报错排查

1. 为什么我劝你先搞懂 Colab 的运行模型1.1 Colab 到底是什么,跟本地跑代码差在哪Google Colab 简单说就是一台跑在浏览器里的 Linux 虚拟机,你打开一个 Notebook,背后就连上了一台带 GPU 的远程机器。你在单元格里敲的每一行 Python&#x…

2026/9/18 0:01:09

C语言数据类型与表达式详解

1. C语言数据与数据类型概述在C语言编程中,数据是程序处理的核心对象。理解数据的分类和特性是掌握C语言的基础。C语言中的数据主要分为四大类:常量、变量、表达式和函数。这些数据类型构成了C语言程序的基本元素,每种类型都有其独特的特性和…

2026/9/18 0:01:09

SQL时间字段指定时间段查询:区间语义、索引与时区避坑

上周排查一个线上问题&#xff0c;用户反馈"昨天的订单一条都没查到"&#xff0c;但数据库里明明躺着两千多条。最后定位下来&#xff0c;不是数据丢了&#xff0c;也不是接口挂了&#xff0c;而是那个查询条件把时间段写成了> 2024-05-20 00:00:00 AND < 2024…

2026/9/18 14:13:03

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行&#xff0c;Type-C接口算是典型的“看着简单&#xff0c;做起来全坑”的东西。光引脚就24个&#xff0c;高低速信号、电源、控制线全部塞在一个小小的连接器里&#xff0c;如果PCB布局不做规划&#xff0c;打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/18 14:13:02

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/18 14:13:02

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码