简介:面向计算机相关专业学生与.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 才是选课系统的命脉
先把表结构定死,后面所有并发方案都建立在这几个字段上。
| 表 | 关键字段 | 说明 |
|---|---|---|
| Students | Id, StudentNo, Name, MajorId | StudentNo 建唯一索引,登录名直接用它 |
| Courses | Id, Code, Name, TeacherId, Capacity, Enrolled, RowVersion | Enrolled 是扣减依据,必须与 Capacity 同表同事务更新 |
| Enrollments | Id, 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>validationKey和decryptionKey建议用 IIS 管理器的「生成密钥」功能产出,或由部署流水线在发布环节注入。这两串东西进 git 仓库,就和把数据库密码贴到 README 里没有区别。
enableViewStateMac在 .NET 4.5.2 之后是默认且强制开启的,任何「为了排查校验失败把它临时关掉」的改动都是高危操作。viewStateEncryptionMode="Always"让内容本身也加密,避免课程列表里的学号、成绩能直接从页面源码里读出来。
注意:多台服务器或开了 Web Garden 的环境,所有节点必须用同一份 machineKey。负载均衡把请求调度到另一台机器上就报「ViewState 验证失败」,八成是 key 不一致,不要顺手去关 MAC 开关。
4.3 收敛类型解析面:别让 ViewState 成为反序列化入口
ViewState 的读取过程本身就是一次反序列化,风险不在框架默认实现,而在项目里额外打开的口子。
- 不要自己写继承序列化辅助类(如基于
ObjectStateFormatter、LosFormatter的封装)去处理请求参数,尤其不要把客户端传上来的 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 Forms | ASP.NET MVC 5 | ASP.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 秒拿不到连接直接失败好过拖死 |
| CommandTimeout | EF DbContext | 5 | 扣减是毫秒级操作,超过 5 秒基本在等锁 |
| 死锁重试次数 | 应用层封装 | 3 | SQL 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,绝大多数抢课场景的问题都出在第一个。
本文还有配套的精品资源,点击获取