news 2026/9/18 16:19:28

ASP.NET 学生选课系统高并发扣减、防超发与 ViewState 加固

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ASP.NET 学生选课系统高并发扣减、防超发与 ViewState 加固

简介:面向计算机相关专业学生与.NET Web开发入门者的一份毕业设计文档,主题为基于ASP.NET的学生选课系统设计与实现。文档以学士学位论文结构展开,先介绍选课系统的背景、目的与实现功能,再说明SQL Server 2000数据库和Visual Studio 2005开发环境,随后深入系统三层架构、数据库表结构、首页登录、添加院系、学生选课、课程管理、成绩查询等模块的设计与实现,并涉及系统测试、部署与维护策略。压缩包内共1个docx文件,约955KB,内容完整,并覆盖摘要、目录、结论、参考文献等论文组成部分,便于按章节检索和参考。已有98人学习下载,适合用于课程设计、毕业设计选题与论文框架梳理,也可帮助读者理解ASP.NET选课系统的业务逻辑、数据库规划与关键代码组织方式,快速形成从需求分析到功能落地的整体认知。

1. 从一次选课高峰说起:ASP.NET 学生选课系统的真正难点

每年选课季,教务群里都会准时出现同一幕:课程页面显示剩余名额 20,最后却发出去 37 个。基于 ASP.NET 做学生选课系统,工作量的大头从来不是画学生、课程、选课记录三张表,而是让「判断余量」和「写入选课记录」这两步在并发下变成不可分割的一个动作。

这个系统真正要解决的是三件事:选课不超发、同一学生不重复选、退课后余量能准确回滚。它们都不是界面问题,是事务和锁的问题。

适合正在做课程设计的学生,也适合接手老 Web Forms 教务项目、准备迁到 ASP.NET MVC 的开发者。后面的路线以 SQL Server 做存储,代码在 ASP.NET MVC 5 和 ASP.NET Core 上都能落地,关键差异单独标注。

2. 用 ASP.NET MVC 搭出学生选课系统的最小可运行骨架

2.1 三张表定下来:Capacity 与 Enrolled 才是选课系统的命脉

先把表结构定死,后面所有并发方案都建立在这几个字段上。

关键字段说明
StudentsId, StudentNo, Name, MajorIdStudentNo 建唯一索引,登录名直接用它
CoursesId, Code, Name, TeacherId, Capacity, Enrolled, RowVersionEnrolled 是扣减依据,必须与 Capacity 同表同事务更新
EnrollmentsId, StudentId, CourseId, Status, EnrolledAt(StudentId, CourseId) 建唯一索引;Status:1 已选 / 2 已退 / 3 候补

「已选人数」为什么不每次用COUNT(*)现算?因为选课高峰时全校几千条选课记录都要参与聚合,扫描成本随数据量线性上涨;更要命的是聚合结果和后续写入之间没有任何原子绑定,读到 19 之后写到 20,另一个线程同样读到 19 也写 20。

放在 Courses 表上做成一个计数字段,再配合WHERE Enrolled < Capacity的单条 UPDATE,数据库才有机会在一个语句内部把「看」和「改」锁在一起。Enrollments 表上的唯一索引不是可选项,前端按钮双击、网络重发、学生刷新页面,都会造成同一人重复提交,应用层的if判断只是体验优化,兜底必须在数据库。

2.2 EF Code First 映射与 DbContext 的必要配置

// Models/Enrollment.cs public class Enrollment { public int Id { get; set; } public int StudentId { get; set; } public int CourseId { get; set; } public byte Status { get; set; } // 1=已选 2=已退 3=候补 public DateTime EnrolledAt { get; set; } }
// Data/CourseDbContext.cs public class CourseDbContext : DbContext { public CourseDbContext() : base("name=CourseDb") { } public DbSet<Student> Students { get; set; } public DbSet<Course> Courses { get; set; } public DbSet<Enrollment> Enrollments { get; set; } protected override void OnModelCreating(DbModelBuilder mb) { // 防重复选课:数据库层兜底,应用层判断只负责给友好提示 mb.Entity<Enrollment>() .HasIndex(e => new { e.StudentId, e.CourseId }) .IsUnique(); // 计数列用 rowversion 做乐观并发令牌,每次 UPDATE 自动递增 mb.Entity<Course>().Property(c => c.RowVersion).IsRowVersion(); mb.Entity<Course>().Property(c => c.Enrolled).IsRequired(); mb.Entity<Course>().Property(c => c.Name).HasMaxLength(100); } }

HasIndex需要 EF 6.1 及以上;ASP.NET Core 的写法是OnModelCreating(ModelBuilder mb)里调mb.Entity<Enrollment>().HasIndex(e => new { e.StudentId, e.CourseId }).IsUnique(),语义一致。IsRowVersion()会映射成 SQL Server 的rowversion列,读出来是 byte[],不要手动赋值,也不要在页面上显示。

RowVersion 是给乐观并发用的:UPDATE ... WHERE Id=@id AND RowVersion=@old,影响行数为 0 就说明有人抢先改了。它能防超发,但高并发抢同一门课时会产生大量并发冲突异常,需要重试策略配合。

2.3 从 URL 到 Action:一次选课请求的完整链路

// Controllers/CourseController.cs [Authorize(Roles = "Student")] public class CourseController : Controller { private readonly IEnrollService _svc; public CourseController(IEnrollService svc) { _svc = svc; } // GET /Course/List?page=1 只列还有余量的课 public ActionResult List(int page = 1) { var model = _svc.GetOpenCourses(page, 20); return View(model); } // POST /Course/Enroll [HttpPost, ValidateAntiForgeryToken] public JsonResult Enroll(int courseId) { var studentId = User.Identity.GetUserId<int>(); // 从登录态取,不从表单取 var r = _svc.Enroll(studentId, courseId); return Json(new { code = r.Code, msg = r.Message }); } }

两个细节不能省。第一,ValidateAntiForgeryToken必须加:选课是写操作,缺少防伪令牌的话,学生访问一个带<img src="/Course/Enroll?courseId=xxx">的页面就能被替选。第二,studentId只能从登录态读,任何从表单或查询串取学号的写法,都等于把「替别人选课」做成一个公开接口。

2.4 把项目在本地跑起来的最小命令

ASP.NET Core 版本在 VS Code 里打开文件夹即可,三条命令足够:

dotnet restore dotnet ef database update -p ./CourseSelection.Data -s ./CourseSelection.Web dotnet run --project ./CourseSelection.Web --urls "http://localhost:5000"

-p指迁移所在项目,-s指启动项目,这两个项目不一致时必须写全,否则 EF 找不到 Web.config / appsettings.json 里的连接字符串。--urls指定监听地址,避开 5000 端口被占的情况。

.NET Framework 的 MVC 5 项目常见做法是走 IIS Express:

msbuild CourseSelection.sln /p:Configuration=Debug "C:\Program Files\IIS Express\iisexpress.exe" /path:"%CD%\CourseSelection.Web" /port:8080

连接字符串写在 Web.config 的<connectionStrings name="CourseDb">节点里,先手工执行一次建库脚本再启动,比让 EF 自动迁移可控得多。

3. 学生选课系统的并发扣减:把课程余量锁在一条 UPDATE 里

3.1 先查后改为什么在选课场景一定超发

// 典型错误写法 var c = db.Courses.Find(courseId); if (c.Enrolled < c.Capacity) { c.Enrolled++; db.Enrollments.Add(new Enrollment { StudentId = studentId, CourseId = courseId }); db.SaveChanges(); }

在默认的 Read Committed 隔离级别下,SELECT读完后共享锁立刻释放,两个线程都能读到 19,都能通过19 < 20的判断,最后都提交成 20。第二个事务提交时不会报任何错误,数据就这么漂了。

几种常见方案的边界差别很大:

方案锁行为高并发抢课表现适用场景
先查后改(无锁)SELECT 后立即释放 S 锁必然超发单人操作的内部工具
EF 乐观并发(RowVersion)UPDATE 带WHERE RowVersion=@old不超发,但大量 DbUpdateConcurrencyException并发低、愿意写重试
单条 UPDATE 原子扣减对命中行加 X 锁,持有到事务提交稳定,等待时间约等于事务时长抢课高峰首选
应用层lock/ 分布式缓存锁单机有效,多实例失效多节点部署时形同虚设只做限流,不做一致性

3.2 用一条 UPDATE 完成判断余量与扣减

CREATE PROCEDURE dbo.usp_Enroll @StudentId INT, @CourseId INT, @Code INT OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; -- 运行时错误自动回滚,避免留下半个事务 BEGIN TRY BEGIN TRAN; -- 判断与扣减合成一条语句,中途不放开行锁 UPDATE dbo.Courses SET Enrolled = Enrolled + 1 WHERE Id = @CourseId AND Enrolled < Capacity; IF @@ROWCOUNT = 0 BEGIN ROLLBACK; SET @Code = -1; -- 名额已满,或课程不存在 RETURN; END INSERT INTO dbo.Enrollments (StudentId, CourseId, Status, EnrolledAt) VALUES (@StudentId, @CourseId, 1, SYSDATETIME()); COMMIT; SET @Code = 0; END TRY BEGIN CATCH IF XACT_STATE() <> 0 ROLLBACK; SET @Code = CASE WHEN ERROR_NUMBER() IN (2601, 2627) THEN -2 -- 唯一索引冲突:重复选课 ELSE -99 END; END CATCH END

这段的关键在UPDATE ... WHERE Enrolled < Capacity:判断条件和写操作在同一个语句里,SQL Server 对 Id 主键命中的那一行加排他锁并持有到事务结束,后面排队的会话读到的是锁释放后的新值,不会再出现两个事务同时认为「还有位置」的情况。前提是Courses.Id上有聚集索引,如果 WHERE 走的是全表扫描,锁会升级到表级,整张课程表都会被堵住。

@@ROWCOUNT = 0同时覆盖了两种情况:名额已满,以及课程 Id 不存在(被人手工改了表单里的隐藏字段)。这两种都归到 -1,前端只需要提示「该课程已无可选名额」。

3.3 EF 调用存储过程与错误码映射

public EnrollResult Enroll(int studentId, int courseId) { var pCode = new SqlParameter("@Code", SqlDbType.Int) { Direction = ParameterDirection.Output }; using (var db = new CourseDbContext()) { db.Database.CommandTimeout = 5; // 扣减本身是毫秒级,超过 5 秒基本在等锁 db.Database.ExecuteSqlCommand( "EXEC dbo.usp_Enroll @StudentId, @CourseId, @Code OUTPUT", new SqlParameter("@StudentId", studentId), new SqlParameter("@CourseId", courseId), pCode); return EnrollResult.From((int)pCode.Value); } }

输出参数必须在 SqlParameter 上显式声明Direction = Output,否则拿到的永远是 null。CommandTimeout设成 5 秒是有意为之:正常扣减在 10 毫秒内完成,一旦超过 5 秒说明锁等待异常,快速失败比让请求挂在 IIS 工作线程上更好。

错误码表建议在服务层就固化下来,别让前端去猜数字:

@Code含义前端处理
0选课成功刷新余量,按钮置灰
-1名额已满 / 课程不存在询问是否加入候补
-2重复选课提示已选过该课并跳转我的课表
-99未知错误提示稍后重试,同时写错误日志

3.4 退课与候补:余量回滚别写成两条 UPDATE

退课和选课是镜像操作,同样要保证「改状态」和「减余量」一起生效,并且要幂等:

UPDATE dbo.Enrollments SET Status = 2 WHERE StudentId = @StudentId AND CourseId = @CourseId AND Status = 1; IF @@ROWCOUNT = 1 -- 只有真正把状态从 1 改成 2 的那一次才减余量 UPDATE dbo.Courses SET Enrolled = Enrolled - 1 WHERE Id = @CourseId AND Enrolled > 0;

Status = 1这个条件就是幂等保护:学生连点两次退课,第二次受影响行数为 0,不会把余量减两遍。Enrolled > 0是最后一道保险,防止历史脏数据把计数减成负数。

候补队列用同一张 Enrollments 表、把 Status 记为 3 即可,有人退课时按EnrolledAt顺序取队首转成 1,这一步同样要放在事务里,否则候补转正和余量扣减会对不上。

4. ASP.NET ViewState 加密与反序列化风险的排查与加固

4.1 ViewState 在选课页面里到底做了什么

如果课程列表用的是 Web Forms 的 GridView,ViewState 会把整棵控件树和字段值序列化、编码后塞进一个隐藏域。二十行课程数据加上分页控件,很容易撑到几百 KB,每次回发都要上传下载一遍。选课页真正需要跨回发保留的只有那个课程 Id,其余全是包袱。

体积问题还在其次。ViewState 是放在客户端的、可被任意修改的一段文本,服务端之所以敢信它,只因为它带着一份 MAC 签名。签名一关,等于让用户自己决定服务端要读什么。

4.2 machineKey 与 ViewStateMac:这两个开关决定了页面能不能被改

<system.web> <machineKey validation="HMACSHA256" decryption="AES" validationKey="由 IIS 管理器或部署流程生成,不要提交到代码仓库" decryptionKey="同上" /> <pages enableViewStateMac="true" viewStateEncryptionMode="Always" /> </system.web>

validationKeydecryptionKey建议用 IIS 管理器的「生成密钥」功能产出,或由部署流水线在发布环节注入。这两串东西进 git 仓库,就和把数据库密码贴到 README 里没有区别。

enableViewStateMac在 .NET 4.5.2 之后是默认且强制开启的,任何「为了排查校验失败把它临时关掉」的改动都是高危操作。viewStateEncryptionMode="Always"让内容本身也加密,避免课程列表里的学号、成绩能直接从页面源码里读出来。

注意:多台服务器或开了 Web Garden 的环境,所有节点必须用同一份 machineKey。负载均衡把请求调度到另一台机器上就报「ViewState 验证失败」,八成是 key 不一致,不要顺手去关 MAC 开关。

4.3 收敛类型解析面:别让 ViewState 成为反序列化入口

ViewState 的读取过程本身就是一次反序列化,风险不在框架默认实现,而在项目里额外打开的口子。

  • 不要自己写继承序列化辅助类(如基于ObjectStateFormatterLosFormatter的封装)去处理请求参数,尤其不要把客户端传上来的 base64 字符串直接交给Deserialize方法。
  • 不需要 ViewState 的页面显式关掉:<%@ Page EnableViewState="false" %>,单个控件上也可以设EnableViewState="false"。列表页关掉之后体积能降一个数量级。
  • 不要把业务对象塞进 ViewState。跨回发要保存的状态走 Session 或显式的 hidden field 加服务端校验,ViewState 只留给框架自己用。
  • 老项目停留在 .NET 4.0 的,先把目标框架升到 4.8 再谈其他优化,新版本默认配置里对类型解析的限制更严。

4.4 面试常问的那几个 ASP.NET 安全边界

被问到 Web Forms 和 MVC 差异时,落到选课系统上其实很好答:

关注点Web FormsASP.NET MVC 5ASP.NET Core
状态保存ViewState 存在客户端默认无状态默认无状态,Session 需显式配置
CSRF 防护ViewStateUserKey + MAC@Html.AntiForgeryToken()表单自动附带防伪令牌
输出编码<%: %>@默认编码Razor 默认编码
版本头暴露X-AspNet-Version默认不暴露

真正要记住的一点是:ViewState 存在客户端,能被改但改不动;Session 存在服务端,进程重启就丢。课程余量这两个地方都不能放,它只能存在于数据库那一个 Enrolled 字段上,并且只能通过第 3 章那条带条件的 UPDATE 去改。

5. ASP.NET 学生选课系统的压测与容量验证

5.1 用 hey 打一门课:从 50 并发到 500 并发

# 先登录拿到 Cookie,把 .AspNet.ApplicationCookie 换成真实值 hey -n 2000 -c 200 -m POST \ -H "Content-Type: application/json" \ -H "Cookie: .AspNet.ApplicationCookie=替换成真实值" \ -d '{"courseId":101}' \ http://localhost:5000/api/enroll

-n是总请求数,-c是并发数,-m指定方法。这份脚本用的是同一个学生的 Cookie,测出来的是「同一学生重复提交」的表现,正常应该返回 1 个 0 和 1999 个 -2(重复选课)。要测真实的抢课并发,得准备 N 个学生的 Cookie 文件,用脚本按行读取轮流替换-H里的值,这样才有 N 个不同 StudentId 争同一门课。

5.2 压测后必查的一条对账 SQL

SELECT c.Id, c.Capacity, c.Enrolled, COUNT(e.Id) AS ActualEnrolled, CASE WHEN c.Enrolled > c.Capacity THEN '超发' WHEN c.Enrolled <> COUNT(e.Id) THEN '计数漂移' ELSE '正常' END AS Verdict FROM dbo.Courses c LEFT JOIN dbo.Enrollments e ON e.CourseId = c.Id AND e.Status = 1 WHERE c.Id = 101 GROUP BY c.Id, c.Capacity, c.Enrolled;

Enrolled > Capacity说明扣减语句没拦住并发,问题在第 3.2 章的 WHERE 条件或索引上;Enrolled <> COUNT说明有事务回滚不干净,或者退课路径没同步减余量,去查 3.4 的状态机。这两种判定分开看,能直接把问题范围缩到一条 SQL 上,不用大海捞针翻日志。

5.3 连接池、超时与死锁重试的参数表

参数位置建议值说明
Max Pool Size连接字符串100并发 200 时池子太小会排队,太大会把 SQL Server 线程打满
Connect Timeout连接字符串15抢课瞬间建连慢,15 秒拿不到连接直接失败好过拖死
CommandTimeoutEF DbContext5扣减是毫秒级操作,超过 5 秒基本在等锁
死锁重试次数应用层封装3SQL Server 会挑一�transaction 做牺牲品,捕获 1205 后重试
// 只对死锁错误 1205 重试,其他异常直接抛,避免掩盖真正的 bug for (int i = 0; i < 3; i++) { try { return _svc.Enroll(studentId, courseId); } catch (SqlException ex) when (ex.Number == 1205) { Thread.Sleep(50 * (i + 1)); // 50ms、100ms、150ms 指数退避 } } throw new TimeoutException("选课请求繁忙,请稍后重试");

重试只能包在事务外面,写在存储过程内部毫无意义——死锁发生时事务已经被回滚了。

5.4 看什么指标判断瓶颈在哪

压测过程中如果 P99 掉到几秒,先分清楚是等锁还是等连接:

SELECT TOP 10 wait_type, wait_time_ms, waiting_tasks_count FROM sys.dm_os_wait_stats WHERE wait_type LIKE 'LCK%' OR wait_type IN ('WRITELOG', 'PAGEIOLATCH_SH') ORDER BY wait_time_ms DESC;

LCK_M_X排第一,说明事务持锁时间太长,去检查存储过程里是不是夹了别的查询;WRITELOG高,说明事务提交过于频繁,考虑把同一批候补转正合并成一次事务。应用侧同时看 ASP.NET 的 Requests Queued 计数器和连接池的活跃连接数,前者涨说明工作线程不够,后者贴到 Max Pool Size 说明瓶颈在数据库连接而不是 CPU。一个实用顺序是先看LCK_M_X再看WRITELOG,绝大多数抢课场景的问题都出在第一个。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 16:18:24

Unity快速次表面散射SSS:基于曲率与厚度的移动端皮肤渲染

做了几年Unity渲染&#xff0c;皮肤材质一直是个绕不开的坎。角色模型明明做得挺精细&#xff0c;一打光就像上了层清漆的塑料&#xff0c;尤其是耳廓、鼻翼、指缝这些透着薄肉的部位&#xff0c;黑得死死的&#xff0c;怎么调都缺那股“活人气”。其实问题不在贴图&#xff0c…

作者头像 李华
网站建设 2026/9/18 16:15:55

用Python解析docx题库:从三支一扶试题到结构化数据

简介&#xff1a;2019年广西柳州市三支一扶考试补录试题及答案解析&#xff0c;是一份面向三支一扶备考群体的真题演练资料&#xff0c;尤其适合报考广西柳州地区岗位、需要熟悉当地笔试题型和难度的考生。资源包内共1个docx文档&#xff0c;文档约46页&#xff0c;整体大小仅6…

作者头像 李华
网站建设 2026/9/18 16:06:50

ResNet残差结构实战解析:从退化问题到工业级微调

/* 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 16:05:42

Claude Code 跨会话又“失忆”?TaoToken 供 Key 后 Memory 索引照旧跑

/* 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 16:04:51

AT89C51密码锁:矩阵键盘与AT24C02掉电存储设计

简介&#xff1a;这份面向单片机课程设计与电子制作入门的 Word 文档&#xff0c;围绕 AT89C51 单片机电子密码锁展开&#xff0c;适合电子信息、自动化等专业学生及嵌入式初学者参考。内容以 AT89C51 最小系统为核心&#xff0c;串联 44 矩阵键盘、LCD1602 显示与报警模块&…

作者头像 李华