1. 选课系统的需求拆解:不能上来就写代码
做毕设有个很典型的误区:一拿到题就开始建数据库、写登录页,最后交上去才发现业务规则跟实际脱节。我接手这套基于 ASP.NET 的在线选课系统时,第一步不是打开 Visual Studio,而是把教务场景的选课流程完完整整列了一遍,因为“选课”这件事真正跑起来之后,比大多数人想象的要绕得多。
1.1 业务参与角色与核心场景
一套可用的选课系统,无非围绕三类人转:
- 学生:登录、浏览本学期开设课程、按时间规则选课、退课、查看已选列表和学分。
- 教师:查看自己名下课程的上课时间与容量、查选课学生名单、设置或调整限选人数。
- 管理员:维护课程基本信息、分配授课教师、维护学期窗口、按需生成统计报表。
核心场景是可以直接对着说的:一个学生在有限选课时间内,要能避开上课时间冲突,要能在课程容量满员时得到明确提示,要能退掉多余的课程并把名额释放给其他人。这三个场景看着简单,一旦并发上来,任何一条都可能变成下午的高峰拥堵点。
1.2 需求边界与异常场景预判
我在需求分析阶段多花了两天去列异常清单,后面实现省了不少事。比如:
- 一名学生同一时间段只能选一门课。
- 每门课存在容量上限,选满后不能再选。
- 选课系统只在规定时间段开放,时间窗外应拒绝操作。
- 退课后,课程释放的名额应能被其他学生立即使用。
- 同一学生不可重复选同一门课。
- 学分上限/下限需要校验(可以根据具体学校规则定制)。
这个清单看起来平淡,但每一条后端都要配一道防线。页面隐藏按钮、禁用下拉框这些只是前端交互,真正的强约束必须落在服务端和数据库层,否则并发场景下前端控得再花哨都没用。这一点我会在后面“并发选课”那部分专门展开。
2. 技术栈为什么锁定了 ASP.NET:选型权衡与工程分层
很多毕设同学纠结到底用 Python + Flask、Java Spring Boot 还是 ASP.NET,其实选什么不重要,重要的是你选得有理由、有理有据。这套系统锁定 ASP.NET,主要看重的是它在 Windows 生态下与 SQL Server 的深度配合,以及三层结构的成熟度。
2.1 ASP.NET Web Forms 还是 MVC,选哪个更合适
对于毕业生来说,我建议优先考虑ASP.NET MVC。虽然 Web Forms 有服务器控件拖拽,看起来很省事,但它的 ViewState 在选课这种高频提交场景里会带来额外页面负担,而且你很难把业务逻辑与页面生命周期完全剥离开。MVC 的 Controller 天然承担路由与请求处理,Model 只做数据载体,视图专注呈现,三者分离。答辩时你可以顺着“为什么要分离”这个点延伸出很多话,老师显然更希望听到架构层面的思考。
一个真实的顾虑是:很多学校还在教三层架构(表示层、业务逻辑层、数据访问层),而 MVC 本身就是一种表现层模式。如果你在 MVC 的 Controller 里直接写数据库操作,实际就没达到三层的要求。所以我的做法是在 MVC 框架内再叠一层明显的分层——Controller 只做参数接收与视图返回,业务规则全部放在 Service 层,数据访问放在 Repository 层,数据库操作走 ADO.NET 或 Entity Framework。这样既满足学校对分层架构的要求,又跟 MVC 的机制不冲突,双赢。
2.2 环境搭建与工程架构分层
开发环境的准备非常简单:Visual Studio + SQL Server 即可,如果没有 Visual Studio,使用 VS Code 配合 SDK 命令创建 ASP.NET Core 项目也完全可以。不过既然是毕设,用 Visual Studio 调试、断点、发布流程都更直观,我最终用的是Visual Studio 2022 + ASP.NET MVC 5 + Entity Framework 6 + SQL Server 2019的组合。
工程内部我拆成四个项目:
| 项目/目录 | 职责 |
|---|---|
| Controllers | 接收请求、返回视图,不写业务逻辑 |
| Services | 选课冲突检测、容量扣减、退课等核心业务 |
| Repository | 所有 SQL 或 EF 数据访问操作 |
| Models / ViewModels | 数据库表映射、页面视图数据载体 |
这样拆分的原因是:如果将来要换数据库、改业务规则,只需要动对应层,不会牵一发而引爆整个页面层。答辩时老师看到这个层次也会觉得你不是临时拼凑的。
3. 数据库设计:冲突检测与并发选课的最底层支撑
这套系统的核心其实是数据库设计。选课系统业务不复杂,课程就那几列,学生就那几列,但“时间冲突判断”和“剩余容量扣减”这两件事写起来并不轻松。
3.1 核心表结构与字段约定
我的核心表一共四张:
- Student 学生表:学号(主键)、姓名、专业、年级、已修学分。
- Teacher 教师表:工号(主键)、姓名、职称。
- Course 课程表:课程编号(主键)、课程名称、学分、授课教师、上课时间(存星期几、第几节起止)、容量、已选人数、选课状态。
- CourseSelection 选课记录表:选课ID 自增主键、学号、课程编号、选课时间、状态(正常/已退选)。
这里有个非常关键的细节:“上课时间”不要存成一个字符串。很多同学图省事,直接写一列CourseTime = "周一 3-4节",这样的字段完全没法做冲突检测。我的方案是拆成三列:
WeekDay(1-7 代表周一至周日)BeginPeriod(开始节次)EndPeriod(结束节次)
判断冲突时用“某课程的时间段与学生已选课程的时间段是否存在区间重叠”来实现,用纯 SQL 的AND比较就能完成。
3.2 选课冲突检测的 SQL 与索引策略
冲突检测的核心 SQL 逻辑如下:
SELECT COUNT(*) FROM CourseSelection cs INNER JOIN Course c ON cs.CourseId = c.CourseId WHERE cs.StudentId = @StudentId AND cs.Status = '选课中' AND c.WeekDay = @WeekDay AND ( (@BeginPeriod >= c.BeginPeriod AND @BeginPeriod <= c.EndPeriod) OR (@EndPeriod >= c.BeginPeriod AND @EndPeriod <= c.EndPeriod) )这句逻辑的意思是:把新课程的节次区间放到已选课程的起止区间里比对,只要两个区间有交集就判定冲突。这个条件能在数据库端完成过滤,无需把全部选课记录拉到内存里判断,性能上站得住。
容量控制方面,我没有在页面端做任何“还剩多少”的预估,而是每次选课都直接查询Course表的SelectedCount和Capacity做比较。同时给CourseSelection表建了一个唯一索引,这就是防重复选课的最后一道保险:
CREATE UNIQUE INDEX IX_StudentCourse_Active ON CourseSelection(StudentId, CourseId) WHERE Status = '选课中';这个过滤唯一索引在 SQL Server 里叫“筛选索引”,只用它约束活动状态的行,退课后的历史记录不会影响再次选择同一门课。这个设计在答辩时一提出来,基本能算一个加分项。
4. 从登录到选课:核心链路的编码拆解
数据库结构定了,后面就进入编码阶段。我按照“登录会话 → 角色路由 → 在线选课 → 退课”这条链路来做,每一段都配上了对应的 MVC 实现。
4.1 登录会话与角色鉴权
ASP.NET MVC 内置了 Forms Authentication,用 Cookie 保存会话凭证。我在登录时根据用户名去对应角色表里判断身份,写进用户身份票据,然后再重定向到不同首页。
[HttpPost] [ValidateAntiForgeryToken] public ActionResult Login(UserLoginModel model) { // 实际项目中应从数据库取哈希值做比对,绝不直接比对明文 var user = _studentService.Validate(model.Username, model.Password); if (user == null) { ModelState.AddModelError("", "用户名或密码错误"); return View(model); } FormsAuthentication.SetAuthCookie(user.StudentId, model.RememberMe); return RedirectToAction("Index", "Course"); }这里有个容易被忽视的坑:密码必须哈希存储。我在用户表里保存的是加盐哈希后的密码串,登录验证时把用户输入的明文先做同样的哈希再比对,这样就算数据库泄露也不会直接暴露密码。登录成功后用[Authorize]特性限制需要登录才能访问的控制器动作,再用[Authorize(Roles = "admin")]之类区分管理员与普通学生。
4.2 在线选课的核心业务代码:事务与锁
选课不是简单的“往表里插一行”。它需要三步保证是一个原子操作:
- 校验课程存在且处于开放状态。
- 校验学生未选过该课、当前不冲突、课程未满。
- 插入选课记录并把该课程“已选人数”加一。
这三步任何一步失败,前面所有操作都要撤回。因此我把它包在事务里,并且用“先更新再查询”的方式去锁定课程行,避免两个人同时拿到“还有1个名额”的错误结论。
public bool EnrollCourse(string studentId, int courseId) { using (var transaction = new TransactionScope()) { var course = _courseRepository.GetForUpdate(courseId); // 实际加锁 if (course == null || course.Status != 1) return false; if (course.SelectedCount >= course.Capacity) return false; if (_selectionRepository.HasActiveSelection(studentId, courseId)) return false; if (_selectionRepository.CheckConflict(studentId, course.WeekDay, course.BeginPeriod, course.EndPeriod)) return false; _selectionRepository.Add(new CourseSelection { StudentId = studentId, CourseId = courseId, SelectTime = DateTime.Now, Status = "选课中" }); course.SelectedCount++; _courseRepository.Update(course); transaction.Complete(); return true; } }在 SQL Server 层面,GetForUpdate建议配合UPDATE语句加行锁,或者使用查询提示UPDLOCK锁定行读,防止并发下同时读到旧容量。这一段是整篇代码里最值得在答辩时讲清楚的地方。
4.3 容量扣减与退课补偿
退课相对简单,但同样要放在事务里做——先更新选课记录状态为“已退选”,再把课程表SelectedCount减一。这样退课释放的名额才能立刻被其他人使用。如果不做扣减,会出现课程人数虚高,导致后面真正想选的人选不进来。
我还在退课接口里做了时间限制:系统只有教务开放的时间窗内才允许退课,时间窗外只能查看不能操作。页面上的退课按钮,用请求时间做服务端判断,而不是单纯靠前端隐藏——前端改一下按钮就绕过去了,服务端判断才是硬约束。
5. 被我踩过的坑:并发选课、时间窗口与数据一致性的问题排查
跑通 Demo 只是第一步,真正拿到校园网环境里模拟高峰期,问题才一个一个浮出来。下面这几个坑是我在保护和压力测试阶段实际遇到的。
5.1 两个学生同抢最后 1 个名额的处理
最初版本里,我选课是先执行SELECT SelectedCount FROM Course WHERE CourseId = @Id,判断容量,再执行INSERT,最后UPDATE。第一次模拟两个学生同抢最后 1 个名额时,两个人居然都提示“选课成功”。原因很简单:判断容量的SELECT是快照读,两个并发事务在同一瞬间都看到剩余容量为 1,于是双双通过了条件判断。
修复方案就是要让判断和剩余容量的写操作形成不可分割的关键区:
UPDATE Course SET SelectedCount = SelectedCount + 1 WHERE CourseId = @CourseId AND SelectedCount < Capacity;先执行更新,根据影响行数判断是否真的获得了名额。如果影响行数为 0,说明课程已经满了;如果为 1,说明这个名额被当前事务拿走了,再插入选课记录。这样从源头避免了“读到旧数据”的问题。
与此同时,SELECT时给课程行加锁也可以解决:
SELECT * FROM Course WITH (UPDLOCK, ROWLOCK) WHERE CourseId = @CourseId;加锁之后,第二个事务会等待第一个事务提交后才读到新的容量。两个方案二选一即可,也可以配合使用作为双重保险。
5.2 时间冲突检测在大数据量下的性能翻车
第一版冲突检测是循环遍历学生的已选课程,逐条取出上课节次再在内存里比较。数据量只有几十条时毫无压力,但我模拟了连续三个学期上千条选课记录后,页面响应明显变慢。慢的原因不在于循环本身,而在于每取一门课都在 ORM 层开启了一次新的数据库查询——典型的 N+1 查询问题。
我把方案改成一次查出该生所有当前有效的选课记录及其课程信息:
var activeCourses = _context.CourseSelection .Where(cs => cs.StudentId == studentId && cs.Status == "选课中") .Select(cs => cs.Course) .ToList();然后内存里用区间求交集判断,配合 index 后加载速度从 800ms 降到了 60ms 以内。虽然内存比较在数据量超大的时候不一定最优,但对一个毕设系统来说,这个性能量级完全够用,而且代码更直观,条例更清楚。
5.3 HTTPS 与跨页面跳转的登录态丢失
本地调试一切正常,发布到 IIS 之后有同学反馈:登录成功后跳转页面就退出,或者在选课页面点击链接跳回登录页。排查下来是 Cookie 的SameSite属性问题。我开发的站点和登录校验其实都在同一个域,但浏览器对 Cookie 的策略收紧后,跨站请求默认不携带身份凭据。
解决办法是在web.config里显式做会话配置:
<system.web> <authentication mode="Forms"> <forms loginUrl="~/Account/Login" timeout="60" slidingExpiration="true" /> </authentication> <httpCookies httpOnlyCookies="true" requireSSL="false" sameSite="Lax" /> </system.web>这里要注意,生产环境若部署在 HTTPS 下,requireSSL="true"必须打开,否则 Cookie 不随请求传递。这个配置有两种情况最容易踩:一种是本地没走 HTTPS 时把requireSSL打开导致无法登录;一种是上线后没开,导致登录态随时丢失。建议上线时仔细核对站点协议再定这个参数。
5.4 数据库时间与服务器时间的不一致
系统上线后的某天,选课还没到开放时间,学生却能选课;还有一天已经截止,页面却仍显示开放。查到最后是应用服务器和数据库服务器使用了不同时区的时间,导致我业务代码里用DateTime.Now和数据库的GETDATE()相互比较时出现了偏差。
我把所有时间比较统一到了数据库时间,业务代码里不直接取服务器本地时间算窗口,而是通过数据库GETDATE()统一获取当前时间。这样即使服务器的时区设置有别,依然能够保证选课窗口的一致性。这个经验虽然小,但在毕设答辩的“细节”环节非常能拿分。
6. 从“能跑”到“有料”:展示亮点、答辩准备与可扩展方向
系统跑通后,我还做了一些“锦上添花”的工作。毕设答辩最忌展示一堆基础 CRUD,老师随便追问一个并发场景就卡壳。所以在代码之外,我给自己留了几个演讲亮点和扩展思路。
6.1 展示时一定要讲清的三个亮点
第一个亮点是选课接口的原子性设计,使用行锁和事务保证容量不超,这个能直接回应“高并发下怎么办”的问题。第二个亮点是筛选唯一索引,即同一学生在同一门课下只允许有一条选课中的记录,退课后可以重新选,这是普通唯一索引做不到的。第三个亮点是时间窗控制全部放在服务端判断,前端只做体验优化,避免了用户绕过页面直接构造请求。
这几个点串联起来的话术是:从数据库约束、到事务机制、再到服务端校验,每一层都在独立兜底。哪怕某一次请求绕过了某一层,下一层依然能挡住。这个思想本身在工程实践里也很重要,老师听到这里通常会点头。
6.2 答辩中被问得最多的五个问题
我整理了一个高频问答清单,给同组几个同学模拟时非常有用:
- 为什么选 ASP.NET MVC 而不是 Web Forms?
答:从职责分离、测试难度和页面状态控制三个角度说明即可。 - 容量并发选课是怎么控制的?
答:事务 + 行锁 + 条件更新,讲清楚操作顺序和失败回滚。 - 时间冲突是怎么判断的?
答:把上课时间拆成星期几和节次起止,用区间重叠判断。 - 如果学生同时提交多次选课请求怎么办?
答:先靠唯一索引挡住重复插入,再靠事务保证容量扣减幂等。 - 密码存在数据库里安全吗?
答:说明加盐哈希的具体流程。
6.3 后续可以往哪些方向扩展
这套系统如果想继续深化,价值也是肉眼可见的。可以引入 Redis 缓存课程列表,加载热门课程时减少数据库压力;可以增加选修课抽签机制,第一轮选课不限容量,结束后按优先级摇号;可以把投票算法扩展为排课系统,把教师节次偏好和教室资源加入约束求解;也可以引入消息队列处理大量退选/改选的异步过程,让页面秒级响应。
哪怕是只做其中一项,论文的“改进与展望”章节就能写得很实,而不是空泛地写“系统还有待完善”。我在最后迭代版本中加入了简单的成绩录入与学分统计模块,将选课入口与后续成绩打通,对学生端和教师端的价值都提升了一个层次。
如果你正在做类似的毕设题目,我建议不要满足于“页面能跳转、课程能插入”的门外效果。试着把自己变成一个真实的教务系统用户,想一想高峰期是什么体验、多人抢课会发生什么、数据要不要恢复。把这些想清楚后,你会发现系统就从“课设”变成了“设计”——这正是老师最想看到的差别。