ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

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说明行锁或者条件更新没有被真正走到。这两种结果都不会在单机手工点击时暴露只有并发下才会现形所以每次改动选课事务逻辑之后这一轮核对都要重跑一次。本文还有配套的精品资源点击获取
返回列表