☰
C#+ASP.NET+SQL Server网上选课系统课程设计:事务并发防超卖实战
2026/10/9 1:05:27 网站建设 项目流程

简介:这份资源是面向计算机相关专业学生与课程设计者的网上选课系统完整实现方案,基于C#、asp.net与SQL Server技术栈开发,适合用作毕业设计、课程作业或Web开发练手项目。压缩包共69个文件,约1.86MB,其中32个cs文件承载核心业务逻辑,20个aspx页面构成前台交互界面,另有js脚本、ascx用户控件、css样式与config配置等辅助文件,并附带db、mdf、ldf数据库文件及dll组件,可直接部署运行。资源同时提供runwen.doc说明文档与两份答辩PPT模板,方便整理设计思路与准备答辩。目前已有534人学习下载,读者可从中获取完整的选课业务流程实现、数据库表结构设计、前后端交互代码以及可参考的文档框架,适合需要快速搭建选课系统原型或对照学习asp.net三层架构的开发者。

1. 网上选课系统为什么至今仍是 C# 课程设计里最稳的选题

每年到了课程设计季,总有一批人卡在选题上:做算法太虚,做小工具太浅,做管理系统又怕烂大街。但如果你去翻历年答辩现场,会发现一个反直觉的现象——网上选课系统被做得最多,却也是被老师追问得最狠、最能拉开分差的一类。原因很简单:它同时踩中了并发、事务、权限、数据一致性这四个数据库应用的硬骨头,而 C# + ASP.NET + SQL Server 这套组合,恰好是高校教学里资料最全、上手最快、答辩时老师最认的技术栈。

这篇笔记要讲的,就是怎么用 C#、ASP.NET 和 SQL Server 从零搭出一套能跑、能演示、能扛住答辩追问的网上选课系统。它适合正在做课程设计的学生,也适合想拿一个完整案例练手 ASP.NET 数据访问和事务控制的初学者。我不会只给你一个能跑的壳子,而是把选课冲突检测、余量扣减、并发抢课这些真正会翻车的地方拆开讲清楚,让你知道每一步为什么这么写,参数为什么这么设。

2. 选课系统的数据模型与 SQL Server 表结构设计

动手写代码之前,先把数据库这张地基打牢。选课系统看着简单,无非是学生、课程、选课记录三张表,但真正决定系统能不能扛住并发、能不能防止数据错乱的,是表结构里的约束和字段设计。很多人一上来就打开 SQL Server Management Studio 建表,字段随手起名,结果写到一半发现选课记录没法判断重复、课程余量对不上,只能回头改表,血泪经验就是这么来的。

2.1 三张核心表与字段类型选择

先明确实体关系:一个学生可以选多门课,一门课可以被多个学生选,所以学生和课程之间是多对多,必须有一张中间表来承载选课关系。这三张表分别是学生表 Students、课程表 Courses、选课记录表 Enrollments。

学生表存基本信息,主键用学号还是自增 ID 是个常见纠结点。我的建议是自增 ID 做物理主键,学号加唯一约束做业务主键。原因是学号可能因为转专业、补录发生变化,而自增 ID 永远稳定,外键关联时性能也更好。课程表里最关键的是余量字段,它不能靠实时 count 选课记录算出来,那样每次查询都要聚合,高并发下直接拖垮数据库,正确做法是单独存一个 Remaining 字段,选课成功就减一。

选课记录表是整套系统的核心,它必须有一个联合唯一约束,把 StudentId 和 CourseId 绑在一起,从数据库层面杜绝同一个学生重复选同一门课。这个约束比在代码里写 if 判断可靠得多,因为并发场景下代码判断会有时间窗口,两个请求可能同时通过检查。

表名关键字段类型说明
StudentsIdint identity物理主键
StudentsStudentNovarchar(20)学号,唯一约束
CoursesIdint identity课程主键
CoursesCapacityint课程容量
CoursesRemainingint剩余名额,选课扣减
EnrollmentsStudentIdint外键关联学生
EnrollmentsCourseIdint外键关联课程
EnrollmentsEnrollTimedatetime选课时间

建表时把 Enrollments 的 (StudentId, CourseId) 建成唯一索引,SQL 语句是CREATE UNIQUE INDEX UX_Enroll ON Enrollments(StudentId, CourseId)。这一步别省,它是你后面防重复选课的最后一道防线。

2.2 用 T-SQL 建表并初始化测试数据

打开 SQL Server Management Studio,连上本地实例,新建一个数据库叫 CourseSelection,然后执行下面的脚本。注意数据库名和表名不要用中文,虽然 SQL Server 支持,但到了 C# 连接字符串和 ORM 映射时容易出编码问题,这是很多人踩过的坑。

-- 创建数据库 CREATE DATABASE CourseSelection; GO USE CourseSelection; GO -- 学生表 CREATE TABLE Students ( Id INT IDENTITY(1,1) PRIMARY KEY, StudentNo VARCHAR(20) NOT NULL UNIQUE, Name NVARCHAR(50) NOT NULL, Password VARCHAR(64) NOT NULL, -- 存哈希后的密码 Major NVARCHAR(50) ); -- 课程表 CREATE TABLE Courses ( Id INT IDENTITY(1,1) PRIMARY KEY, CourseCode VARCHAR(20) NOT NULL UNIQUE, CourseName NVARCHAR(100) NOT NULL, Teacher NVARCHAR(50), Capacity INT NOT NULL, Remaining INT NOT NULL, -- 初始等于 Capacity Credit DECIMAL(3,1) ); -- 选课记录表 CREATE TABLE Enrollments ( Id INT IDENTITY(1,1) PRIMARY KEY, StudentId INT NOT NULL, CourseId INT NOT NULL, EnrollTime DATETIME NOT NULL DEFAULT GETDATE(), FOREIGN KEY (StudentId) REFERENCES Students(Id), FOREIGN KEY (CourseId) REFERENCES Courses(Id) ); -- 联合唯一索引,防止重复选课 CREATE UNIQUE INDEX UX_Enroll ON Enrollments(StudentId, CourseId); -- 初始化几门课 INSERT INTO Courses (CourseCode, CourseName, Teacher, Capacity, Remaining, Credit) VALUES ('CS101', N'数据结构', N'张老师', 50, 50, 4.0), ('CS102', N'操作系统', N'李老师', 40, 40, 3.5), ('CS103', N'计算机网络', N'王老师', 60, 60, 3.0);

这段脚本里,Remaining 字段初始值和 Capacity 保持一致,后面选课逻辑只动 Remaining,不动 Capacity,这样课程容量作为原始配置永远可查。EnrollTime 用 GETDATE() 做默认值,插入时不用手动传时间,减少出错。唯一索引 UX_Enroll 是防重复的关键,如果代码逻辑漏了判断,数据库会直接抛异常,宁可报错也不能让脏数据进去。

2.3 连接字符串与数据访问层的选型

C# 连 SQL Server 常见有三种方式:原生 SqlClient、Entity Framework、Dapper。课程设计场景我一般推荐 SqlClient 手写 ADO.NET,原因是老师答辩时最爱问 SQL 语句和事务,你用 EF 把 SQL 藏起来了,反而不好讲。而且手写能让你真正理解连接、命令、事务的执行流程。

连接字符串放在 Web.config 的 connectionStrings 节点里,不要硬编码在代码里。格式是Server=.;Database=CourseSelection;Integrated Security=true;,如果你用 SQL Server 账号密码登录,就换成User Id=sa;Password=你的密码;。Integrated Security 走 Windows 身份验证,本地开发最省事,不用管密码策略。

数据访问层建议单独建一个 SqlHelper 类,把打开连接、执行命令、返回 DataTable 这些重复代码封装起来。但事务相关的操作不要塞进 SqlHelper 的通用方法里,因为事务需要多个命令共享同一个 SqlConnection 和 SqlTransaction,封装太深反而不好控制。这一点后面讲选课事务时会具体展开。

3. 用 ASP.NET 实现选课核心逻辑与事务控制

数据库建好之后,真正决定系统成败的是选课这个动作怎么实现。表面上看选课就是往 Enrollments 插一条记录、把 Courses 的 Remaining 减一,但这两步必须在一个事务里完成,而且要先检查余量、再扣减、再插入,顺序错了或者没加锁,就会出现超卖——明明只剩一个名额,两个学生同时点选课,结果两个人都选上了。这是选课系统最经典的翻车场景,也是答辩时老师最爱追问的点。

3.1 选课事务的完整 C# 实现

下面这段代码是选课的核心方法,放在一个 CourseService 类里。它做了四件事:开启事务、检查余量、扣减余量、插入选课记录,任何一步失败就回滚。

public string EnrollCourse(int studentId, int courseId) { string connStr = ConfigurationManager.ConnectionStrings["CourseDB"].ConnectionString; using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); // 开启事务,隔离级别用可重复读,防止余量被其他事务修改 using (SqlTransaction tran = conn.BeginTransaction(IsolationLevel.RepeatableRead)) { try { // 第一步:锁定并查询课程余量,UPDLOCK 防止其他事务同时读取 string checkSql = "SELECT Remaining FROM Courses WITH (UPDLOCK, ROWLOCK) WHERE Id = @CourseId"; SqlCommand checkCmd = new SqlCommand(checkSql, conn, tran); checkCmd.Parameters.AddWithValue("@CourseId", courseId); object result = checkCmd.ExecuteScalar(); if (result == null) { tran.Rollback(); return "课程不存在"; } int remaining = Convert.ToInt32(result); if (remaining <= 0) { tran.Rollback(); return "名额已满"; } // 第二步:扣减余量 string updateSql = "UPDATE Courses SET Remaining = Remaining - 1 WHERE Id = @CourseId AND Remaining > 0"; SqlCommand updateCmd = new SqlCommand(updateSql, conn, tran); updateCmd.Parameters.AddWithValue("@CourseId", courseId); int affected = updateCmd.ExecuteNonQuery(); if (affected == 0) { tran.Rollback(); return "扣减失败,可能名额已满"; } // 第三步:插入选课记录,唯一索引会拦截重复选课 string insertSql = "INSERT INTO Enrollments (StudentId, CourseId) VALUES (@StudentId, @CourseId)"; SqlCommand insertCmd = new SqlCommand(insertSql, conn, tran); insertCmd.Parameters.AddWithValue("@StudentId", studentId); insertCmd.Parameters.AddWithValue("@CourseId", courseId); insertCmd.ExecuteNonQuery(); tran.Commit(); return "选课成功"; } catch (SqlException ex) { tran.Rollback(); // 2627 是唯一约束冲突的错误号,说明重复选课 if (ex.Number == 2627) return "你已经选过这门课了"; return "选课失败:" + ex.Message; } } } }

这段代码有几个关键点必须说清楚。第一,隔离级别用 RepeatableRead 而不是默认的 ReadCommitted,因为我们要保证在事务期间读到的余量不会被其他事务改掉。第二,查询余量时加了WITH (UPDLOCK, ROWLOCK),UPDLOCK 会在读取时就加更新锁,阻止其他事务同时拿到同一行的更新权限,这是防超卖的核心。第三,UPDATE 语句里带了AND Remaining > 0,这是双保险,即使前面的检查因为某种原因失效,更新时也会拦住。第四,捕获 SqlException 并判断错误号 2627,这是 SQL Server 唯一约束冲突的标准错误号,用它来识别重复选课比在代码里先查一遍再插入更可靠。

参数方面,studentId 和 courseId 都通过 SqlParameter 传入,绝对不要用字符串拼接,否则就是 SQL 注入的活教材。AddWithValue 虽然方便,但在某些类型上会有隐式转换问题,严谨点可以用 Add 指定 SqlDbType,课程设计里 AddWithValue 够用,但你要知道它的边界。

3.2 退选、余量回补与并发下的数据一致性

退选逻辑比选课简单,但同样要放在事务里。先删除选课记录,再把课程余量加一,两步必须原子。这里有个容易忽略的点:删除记录时要判断是否真的删掉了,如果 affected 为 0,说明这个学生根本没选这门课,就不应该回补余量,否则余量会越退越多。

public string DropCourse(int studentId, int courseId) { string connStr = ConfigurationManager.ConnectionStrings["CourseDB"].ConnectionString; using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); using (SqlTransaction tran = conn.BeginTransaction()) { try { // 先删选课记录 string delSql = "DELETE FROM Enrollments WHERE StudentId = @StudentId AND CourseId = @CourseId"; SqlCommand delCmd = new SqlCommand(delSql, conn, tran); delCmd.Parameters.AddWithValue("@StudentId", studentId); delCmd.Parameters.AddWithValue("@CourseId", courseId); int deleted = delCmd.ExecuteNonQuery(); if (deleted == 0) { tran.Rollback(); return "你没有选这门课"; } // 再回补余量,但不能超过课程容量 string updateSql = @"UPDATE Courses SET Remaining = Remaining + 1 WHERE Id = @CourseId AND Remaining < Capacity"; SqlCommand updateCmd = new SqlCommand(updateSql, conn, tran); updateCmd.Parameters.AddWithValue("@CourseId", courseId); updateCmd.ExecuteNonQuery(); tran.Commit(); return "退选成功"; } catch (SqlException ex) { tran.Rollback(); return "退选失败:" + ex.Message; } } } }

回补余量时加了AND Remaining < Capacity,防止因为异常数据导致余量超过容量。这个条件看着多余,但如果你在测试时手动改过数据库,或者退选逻辑被重复调用,它就能兜住。数据一致性这东西,平时看不出问题,一旦出问题就是答辩现场被问住的时刻。

3.3 用存储过程把选课逻辑下沉到数据库

上面的事务写在 C# 里能跑,但如果你想让系统更稳、答辩时更有亮点,可以把选课逻辑写成存储过程。好处是减少网络往返、逻辑集中在数据库端、权限控制更清晰。下面这个存储过程实现了和 C# 版本一样的功能。

CREATE PROCEDURE sp_EnrollCourse @StudentId INT, @CourseId INT, @Result NVARCHAR(50) OUTPUT AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; DECLARE @Remaining INT; SELECT @Remaining = Remaining FROM Courses WITH (UPDLOCK, ROWLOCK) WHERE Id = @CourseId; IF @Remaining IS NULL BEGIN SET @Result = N'课程不存在'; ROLLBACK TRANSACTION; RETURN; END IF @Remaining <= 0 BEGIN SET @Result = N'名额已满'; ROLLBACK TRANSACTION; RETURN; END UPDATE Courses SET Remaining = Remaining - 1 WHERE Id = @CourseId; INSERT INTO Enrollments (StudentId, CourseId) VALUES (@StudentId, @CourseId); COMMIT TRANSACTION; SET @Result = N'选课成功'; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; IF ERROR_NUMBER() = 2627 SET @Result = N'你已经选过这门课了'; ELSE SET @Result = N'选课失败:' + ERROR_MESSAGE(); END CATCH END

C# 端调用时用 SqlCommand 的 CommandType.StoredProcedure,把 @Result 设为 Output 方向的参数。存储过程的优势在于事务边界完全在数据库内,不受 C# 端连接池回收的影响。但要注意,存储过程里的 UPDLOCK 和 ROWLOCK 提示同样不能省,否则并发下照样超卖。很多人以为写了存储过程就万事大吉,其实锁提示才是关键。

4. 选课系统避坑与常见问题排查

前面把主流程讲完了,但真正让你在答辩现场不翻车的,是知道哪里会出问题。下面这五条是我自己和身边人踩过的坑,每条都按现象、原因、解决来写,你对着排查能省下大量调试时间。

4.1 现象:两个学生同时选最后一门课,都显示成功

原因:选课逻辑没有加锁,两个事务同时读到 Remaining 为 1,都判断通过,然后各自扣减,数据库里余量变成 -1,两条选课记录都插进去了。这是最典型的超卖。

解决:查询余量时必须加WITH (UPDLOCK, ROWLOCK),并且把隔离级别提到 RepeatableRead。UPDLOCK 让第一个事务拿到更新锁后,第二个事务的查询会阻塞等待,等第一个事务提交后再读到最新的余量。如果你用的是存储过程,同样要加锁提示。测试方法很简单,开两个查询窗口,手动模拟并发,看余量会不会变成负数。

4.2 现象:学生退选后余量变成 51,但课程容量只有 50

原因:退选逻辑没有判断删除是否真的影响了行,或者回补余量时没有加Remaining < Capacity条件。如果学生重复调用退选接口,或者数据库里有脏数据,余量就会超过容量。

解决:退选时先执行 DELETE,检查 ExecuteNonQuery 的返回值,为 0 就直接返回,不回补余量。回补的 UPDATE 语句加上AND Remaining < Capacity,从数据库层面兜底。另外可以在 Courses 表上加一个 CHECK 约束CHECK (Remaining >= 0 AND Remaining <= Capacity),让数据库直接拒绝非法数据。

4.3 现象:连接字符串写错,程序报「未找到连接字符串 CourseDB」

原因:Web.config 里 connectionStrings 节点的 name 属性和代码里 ConfigurationManager.ConnectionStrings["CourseDB"] 的索引不一致,或者节点名拼写错误。还有一种情况是配置文件放错了项目,比如放到了类库项目里,Web 项目读不到。

解决:确认 Web.config 的 connectionStrings 节点下有 name="CourseDB" 的配置,代码里的索引和它完全一致。如果用了多层项目,连接字符串要放在启动项目(Web 项目)的 Web.config 里。调试时可以在代码里打印 ConfigurationManager.ConnectionStrings.Count,看看到底加载了几个连接字符串。

4.4 现象:选课成功后页面刷新,选课记录重复显示

原因:选课按钮没有做防重复提交,用户快速双击或者网络慢时重复点击,触发了两次选课请求。虽然数据库唯一索引会拦住第二条记录,但第一次请求可能已经扣了两次余量,或者页面绑定逻辑有问题。

解决:前端按钮点击后立即禁用,用 JavaScript 设置this.disabled = true。后端在插入前先查一次是否已选,虽然不能完全防并发,但能拦住大部分重复提交。最可靠的还是数据库唯一索引,捕获 2627 错误后返回友好提示。另外选课成功后用 Response.Redirect 重定向到列表页,避免刷新时重复提交表单。

4.5 现象:SQL Server 连接超时,报「超时时间已到」

原因:连接没有及时关闭,连接池被耗尽。常见于 using 块没写对,或者 SqlDataReader 没有关闭就执行了另一个命令。还有一种情况是事务没有提交或回滚,连接一直被占用。

解决:所有 SqlConnection、SqlCommand、SqlDataReader 都用 using 包裹,确保释放。事务的 try-catch 里,catch 块必须调用 Rollback,不能只写 return。如果用了 DataReader,要么用 using,要么显式 Close。可以在连接字符串里加Max Pool Size=100调整连接池上限,但根本解决还是要靠正确的释放习惯。

5. 从能跑到能讲:选课系统的进阶技巧与验证方法

把系统跑起来只是第一步,真正让你在答辩时从容的,是你能说清楚怎么验证它、怎么优化它。这一章讲几个我常用的验证方法和一个容易被忽略的进阶技巧,都是能直接上手操作的。

5.1 用 SQL Server Profiler 抓选课过程的真实 SQL

很多人写完代码不知道底层到底执行了什么 SQL,答辩时被问「你这个事务具体锁了哪一行」就答不上来。SQL Server Profiler 能帮你把真实执行的语句抓出来。打开 SSMS,菜单里找到「工具」→「SQL Server Profiler」,新建跟踪,连上你的数据库实例,在事件选择里勾选 RPC:Completed 和 SQL:BatchCompleted,然后运行你的选课程序,Profiler 窗口里就会实时显示每一条 SQL。

你会看到类似这样的输出:SELECT Remaining FROM Courses WITH (UPDLOCK, ROWLOCK) WHERE Id = 1,后面跟着UPDATE Courses SET Remaining = Remaining - 1 WHERE Id = 1,最后是 INSERT。把这三条语句的顺序和锁提示讲清楚,老师就知道你是真懂而不是抄的。如果发现 Profiler 里出现了没有加锁的查询,那就是你代码里漏了地方,赶紧补上。

5.2 用并发测试验证防超卖是否真的生效

光看代码觉得没问题,不代表并发下没问题。我一般会写一个简单的并发测试,用 C# 开多个线程同时调用选课方法,看最终余量和选课记录数是否对得上。

// 并发测试:10 个线程抢 5 个名额 int courseId = 1; int successCount = 0; int failCount = 0; object lockObj = new object(); List<Task> tasks = new List<Task>(); for (int i = 0; i < 10; i++) { int studentId = i + 1; tasks.Add(Task.Run(() => { CourseService service = new CourseService(); string result = service.EnrollCourse(studentId, courseId); lock (lockObj) { if (result == "选课成功") successCount++; else failCount++; } })); } Task.WaitAll(tasks.ToArray()); Console.WriteLine($"成功:{successCount},失败:{failCount}"); // 预期:成功 5,失败 5,数据库余量为 0

跑完这个测试,去数据库查SELECT Remaining FROM Courses WHERE Id = 1,应该是 0;查SELECT COUNT(*) FROM Enrollments WHERE CourseId = 1,应该是 5。如果余量是负数或者记录数超过 5,说明你的锁没生效。这个测试我每次改完选课逻辑都会跑一遍,比肉眼检查代码可靠得多。

5.3 用事务日志确认回滚是否干净

SQL Server 的事务日志是个黑匣子,但你可以用DBCC LOG或者更简单的fn_dblog函数查看当前数据库的日志记录。课程设计里不需要深入日志格式,但你可以做一个简单验证:故意在选课事务的 INSERT 之后抛一个异常,看余量有没有被回滚。

// 在 INSERT 之后手动抛异常,验证回滚 insertCmd.ExecuteNonQuery(); throw new Exception("测试回滚"); // 预期:catch 块执行 Rollback,余量恢复,选课记录不存在

跑完之后查数据库,如果余量没变、Enrollments 里也没有新记录,说明事务回滚是干净的。这个验证能让你在答辩时理直气壮地说「我的事务保证了原子性」,而不是背概念。

5.4 一个容易被忽略的技巧:给选课接口加限流

课程设计一般不要求限流,但如果你想让系统更完整,可以在选课接口上加一个简单的限流。用 C# 的 SemaphoreSlim 控制同时进入选课逻辑的请求数,比如限制为 20 个并发。这样即使有人恶意刷接口,也不会把数据库连接池打满。

private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(20, 20); public string EnrollCourseWithLimit(int studentId, int courseId) { if (!_semaphore.Wait(1000)) // 最多等 1 秒 return "系统繁忙,请稍后再试"; try { return EnrollCourse(studentId, courseId); } finally { _semaphore.Release(); } }

SemaphoreSlim 的构造函数第一个参数是初始许可数,第二个是最大许可数,这里都设 20,表示最多 20 个并发。Wait 方法传 1000 毫秒超时,等不到就直接返回繁忙提示,避免请求堆积。这个技巧在真实项目里很常见,课程设计里加上去,答辩时能多一个亮点。

最后说个我自己的习惯:每次改完选课或退选逻辑,我都会先跑一遍并发测试,再去 Profiler 里看一眼 SQL,确认锁提示还在、事务边界没变。这个习惯帮我拦住了至少三次自以为没问题的改动。选课系统不难,难的是在并发下还能保持数据一致,而这一点,恰恰是答辩老师最想看到的。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询