简介:这份数据库课程设计文档面向高校计算机相关专业学生与数据库初学者,围绕“学生选课管理系统”这一经典实践课题,提供从需求分析到系统实现的完整设计思路。内容涵盖学生、教师、管理员三类角色的权限划分,以及学生表、教师表、专业表、课程表、专业课程表、学生课程信息表、班级表等数据字典定义,并给出关系模式、主外键约束与参照关系图。逻辑结构设计部分还涉及1NF、2NF、3NF范式理论、密码加密存储、索引优化与完整性、安全性考量,系统实现则结合SQL语句与C#界面代码,演示登录、增删改查及总分、平均分、排名统计等操作。资源包共1个docx文件,约597KB,结构紧凑,适合直接参考撰写课程设计报告或搭建数据库实验。目前已有552人学习下载,可作为数据库课程设计、期末大作业与关系模型练习的实用范本。
1. 学生选课管理系统:从课程设计到能跑起来的数据库应用
学生选课管理系统几乎是每届计算机专业都绕不开的数据库课程设计题目,但真正把它做扎实的人并不多。多数人卡在两个地方:一是数据库表结构设计得经不起推敲,选课冲突、容量超限、重复选课这些约束全靠代码兜底;二是用 C# 写出来的界面和数据库之间只有最原始的增删改查,事务、并发、连接池这些真正体现工程能力的东西一个都没碰。这篇笔记就围绕「数据库课程设计—学生选课管理系统」这个标题,把一套能直接复现的方案讲清楚:数据库怎么建、C# 怎么连、选课逻辑怎么保证一致性、哪些参数必须调、哪些坑一定会踩。适合正在做课程设计的学生,也适合想用一个小项目把数据库知识点串起来的 C# 入门开发者。整套方案用 SQL Server 或 MySQL 都行,代码以 C# WinForms/WPF 加 ADO.NET 为主线,不依赖任何重型框架,跑得起来、看得见数据、改得动逻辑。
2. 表结构定生死:学生选课管理系统的库表设计与约束
2.1 四张核心表怎么拆,字段怎么定
学生选课管理系统的数据模型其实不复杂,核心就是学生、课程、教师、选课记录四个实体。但恰恰是这种「看起来简单」的题目,最容易在设计阶段埋雷。我一般会先画清楚实体关系:一个学生可以选多门课,一门课可以被多个学生选,所以学生和课程之间是多对多,必须有一张中间表来承载选课关系。教师和课程是一对多,一个教师可以开多门课。这四张表拆出来就是 Student、Course、Teacher、SC(选课记录)。
字段设计上有几个必须想清楚的点。学生表里学号做主键,不要用自增 ID 做主键再给学号加唯一索引,课程设计阶段直接用学号做业务主键更直观,查询和调试都方便。课程表里课程号做主键,同时要有容量字段 Capacity 和已选人数字段 SelectedCount,这两个字段是后面并发控制的关键。选课表 SC 用学号加课程号的联合主键,天然防止同一个学生重复选同一门课,这个约束比在代码里写 if 判断可靠得多。
-- 学生表:学号做主键,姓名、专业、年级为基本属性 CREATE TABLE Student ( Sno VARCHAR(12) PRIMARY KEY, -- 学号,业务主键 Sname NVARCHAR(20) NOT NULL, -- 姓名 Major NVARCHAR(30), -- 专业 Grade INT, -- 年级 Password VARCHAR(64) NOT NULL DEFAULT '123456' -- 登录密码,课程设计够用 ); -- 课程表:容量和已选人数分开存,方便做并发扣减 CREATE TABLE Course ( Cno VARCHAR(10) PRIMARY KEY, -- 课程号 Cname NVARCHAR(40) NOT NULL, -- 课程名 Credit DECIMAL(3,1), -- 学分 Capacity INT NOT NULL DEFAULT 50, -- 课容量上限 SelectedCount INT NOT NULL DEFAULT 0, -- 当前已选人数 Tno VARCHAR(10) -- 授课教师工号 ); -- 选课表:联合主键防重复选课,这是最省事的约束手段 CREATE TABLE SC ( Sno VARCHAR(12) NOT NULL, Cno VARCHAR(10) NOT NULL, SelectTime DATETIME DEFAULT GETDATE(), -- 选课时间,便于排查 Score DECIMAL(5,1) NULL, -- 成绩,选课时为空 PRIMARY KEY (Sno, Cno), FOREIGN KEY (Sno) REFERENCES Student(Sno), FOREIGN KEY (Cno) REFERENCES Course(Cno) );这段建表语句里,联合主键PRIMARY KEY (Sno, Cno)是最值得说的一处。很多同学喜欢在 SC 表里加一个自增 ID 做主键,然后指望代码去判断重复,结果测试时手快点了两次选课按钮,数据库里就多了一条记录。联合主键把「一个学生对一门课只能有一条选课记录」这件事交给数据库来保证,代码只需要处理插入失败的情况就行。外键约束则保证了不会出现选了不存在的课这种脏数据。
参数上要注意Capacity和SelectedCount的默认值。容量默认给 50 是课程设计的常见规模,实际调的时候根据课程性质改,选修课可以给 100,实验课给 30。SelectedCount默认 0,每次选课成功加一,退课减一,这个字段和容量字段配合使用,是判断课程是否已满的依据。
2.2 索引和约束:让查询和并发都稳
表建好之后,索引不是可有可无的装饰。学生选课管理系统里最频繁的查询有两类:一是按学号查某个学生选了哪些课,二是按课程号查某门课有哪些学生选。SC 表的联合主键已经覆盖了按学号加课程号的查询,但单独按课程号查的时候用不上这个索引的最左前缀,所以需要额外加一个课程号索引。
-- 按课程号查选课名单是高频操作,单独建索引 CREATE INDEX idx_sc_cno ON SC(Cno); -- 课程表按教师查课也是常见需求 CREATE INDEX idx_course_tno ON Course(Tno); -- 学生表按姓名模糊查询,课程设计阶段数据量小,可以不建 -- 但如果要做姓名搜索,建议用前缀索引而不是全表扫描约束方面,除了主键和外键,还应该给Capacity加一个检查约束,防止有人把容量设成负数。SelectedCount也要保证不大于Capacity,但这个约束在并发场景下用数据库检查约束会带来额外的锁开销,课程设计阶段我一般放在应用层用事务保证,数据库层只加一个非负检查。
注意:MySQL 和 SQL Server 在检查约束的语法上略有差异,MySQL 8.0 之后才真正强制执行 CHECK 约束,5.7 版本会忽略。如果用的是旧版本 MySQL,别指望 CHECK 能拦住脏数据。
索引建多了会影响插入性能,选课表在选课高峰期写入频繁,所以索引数量要克制。SC 表上除了主键和课程号索引,不要再加其他索引。课程表和学生表都是读多写少,索引可以稍微宽松一点。
3. C# 连数据库:连接池、参数化查询和事务的落地写法
3.1 连接字符串怎么配,连接池参数怎么调
C# 连数据库最常见的方式是 ADO.NET,核心对象是SqlConnection(SQL Server)或MySqlConnection(MySQL)。连接字符串里藏着很多课程设计里不会讲但实际很重要的参数。以 SQL Server 为例,一个能用的连接字符串长这样:
// 连接字符串:数据库地址、库名、账号密码、连接池参数 string connStr = "Server=localhost;Database=CourseSelection;" + "User Id=sa;Password=YourPassword;" + "Min Pool Size=5;Max Pool Size=100;" + "Connection Timeout=15;";这里Min Pool Size和Max Pool Size是连接池的核心参数。默认情况下连接池最小是 0,最大是 100。课程设计阶段并发不高,但如果你在循环里反复开连接关连接,没有连接池的话每次都要走一次 TCP 握手和身份验证,几十次循环就能感觉到明显卡顿。把Min Pool Size设成 5,意味着应用启动后连接池里至少保持 5 个空闲连接,第一次查询就不用等建连。Max Pool Size设成 100 是上限,超过这个数的新请求会排队等待,等待时间由Connection Timeout控制,默认 15 秒,超时抛异常。
一个血泪经验是:不要用using包住连接之后在using块外面继续用这个连接对象。using结束时会调用Dispose,连接被归还到池里,但对象本身已经不能用了。正确做法是把数据库操作封装成方法,连接在方法内部开、内部关,外部只拿结果。
// 封装查询方法:连接在方法内开闭,依赖连接池复用 public static DataTable Query(string sql, params SqlParameter[] parameters) { using (var conn = new SqlConnection(connStr)) using (var cmd = new SqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); var adapter = new SqlDataAdapter(cmd); var dt = new DataTable(); adapter.Fill(dt); // 连接由 using 自动关闭并归还池 return dt; } }这个方法每次调用都 new 一个连接,看起来开销很大,实际上因为连接池的存在,new SqlConnection只是从池里拿一个现成的物理连接,Dispose也只是归还,真正的 TCP 连接并没有反复建立。这就是连接池的价值,也是为什么推荐「短连接」写法而不是全局维护一个长连接。
3.2 参数化查询:别再用字符串拼接 SQL
课程设计里最常见的翻车现场就是 SQL 注入。比如登录功能写成"SELECT * FROM Student WHERE Sno='" + txtSno.Text + "'",学生在学号框里输入' OR '1'='1,直接绕过密码登录。这不是危言耸听,每年课程设计答辩都有人的系统被这么攻破。
参数化查询的写法很简单,把用户输入的值用SqlParameter传进去,数据库驱动会负责转义和类型处理。
// 参数化查询:用户输入永远不拼进 SQL 文本 string sql = "SELECT COUNT(*) FROM Student WHERE Sno=@Sno AND Password=@Pwd"; var parameters = new SqlParameter[] { new SqlParameter("@Sno", txtSno.Text.Trim()), new SqlParameter("@Pwd", txtPwd.Text.Trim()) }; int count = Convert.ToInt32(Query(sql, parameters).Rows[0][0]); if (count > 0) { /* 登录成功 */ }参数化查询除了防注入,还有一个好处是执行计划复用。数据库会对带参数的 SQL 做一次编译,后续相同结构不同参数的查询直接复用执行计划,省去了解析和优化的开销。字符串拼接的 SQL 每次文本都不一样,执行计划缓存命中率极低。这一点在选课高峰期批量操作时差别很明显。
参数类型也要注意。SqlParameter不指定类型时会根据值自动推断,但字符串推断成NVARCHAR还是VARCHAR会影响索引使用。学号字段是VARCHAR(12),参数最好显式指定SqlDbType.VarChar,长度 12,避免隐式转换导致索引失效。
3.3 选课事务:容量扣减和插入必须原子完成
选课这个动作在数据库层面包含两步:往 SC 表插一条记录,同时把 Course 表的SelectedCount加一。这两步必须一起成功或一起失败,否则会出现「选了课但人数没加」或者「人数加了但没选上课」的脏数据。这就是事务的用武之地。
// 选课事务:插入选课记录 + 更新已选人数,原子提交 public static bool SelectCourse(string sno, string cno) { using (var conn = new SqlConnection(connStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 第一步:检查容量并锁定该课程行,防止并发超选 string checkSql = "SELECT Capacity, SelectedCount FROM Course " + "WITH (UPDLOCK, ROWLOCK) WHERE Cno=@Cno"; int capacity, selected; using (var cmd = new SqlCommand(checkSql, conn, tran)) { cmd.Parameters.AddWithValue("@Cno", cno); using (var reader = cmd.ExecuteReader()) { if (!reader.Read()) return false; // 课程不存在 capacity = reader.GetInt32(0); selected = reader.GetInt32(1); } } if (selected >= capacity) return false; // 已满,直接返回 // 第二步:插入选课记录,联合主键会拦住重复选课 string insertSql = "INSERT INTO SC(Sno, Cno) VALUES(@Sno, @Cno)"; using (var cmd = new SqlCommand(insertSql, conn, tran)) { cmd.Parameters.AddWithValue("@Sno", sno); cmd.Parameters.AddWithValue("@Cno", cno); cmd.ExecuteNonQuery(); } // 第三步:已选人数加一 string updateSql = "UPDATE Course SET SelectedCount=SelectedCount+1 " + "WHERE Cno=@Cno"; using (var cmd = new SqlCommand(updateSql, conn, tran)) { cmd.Parameters.AddWithValue("@Cno", cno); cmd.ExecuteNonQuery(); } tran.Commit(); return true; } catch (SqlException ex) when (ex.Number == 2627) // 主键冲突 { tran.Rollback(); return false; // 重复选课 } catch { tran.Rollback(); throw; } } } }这段代码里有三个关键点。第一,WITH (UPDLOCK, ROWLOCK)是 SQL Server 的锁提示,在读取课程容量的时候就对该行加更新锁,防止两个事务同时读到相同的SelectedCount然后都认为还有名额。第二,插入选课记录时如果联合主键冲突,SQL Server 会抛 2627 号错误,捕获这个错误返回 false 比先查再插更可靠,因为查和插之间有时间窗口。第三,所有操作在同一个事务里,任何一步失败都回滚,不会留下半截数据。
MySQL 的写法类似,锁提示换成SELECT ... FOR UPDATE,主键冲突错误码是 1062。参数上要注意事务的隔离级别,默认的READ COMMITTED在 SQL Server 里配合UPDLOCK已经够用,如果改成READ UNCOMMITTED虽然并发高但会读到脏数据,选课场景不能接受。
4. 避坑与排查:学生选课管理系统最常见的五个翻车点
4.1 选课人数超了容量,但数据库里查不出原因
现象是课程容量 50,实际选课记录有 52 条。原因通常是没用事务或者没用锁提示,两个学生同时选最后一个名额,都读到了SelectedCount=49,都判断没满,都插入成功。解决就是上面 3.3 节的写法,读取容量时加UPDLOCK,把检查和更新放在一个事务里。如果已经出现了超选数据,用一条 SQL 修正:UPDATE Course SET SelectedCount=(SELECT COUNT(*) FROM SC WHERE SC.Cno=Course.Cno)。
4.2 连接池耗尽,程序卡死无响应
现象是程序运行一段时间后所有数据库操作都超时,日志里出现「超时时间已到,但是尚未从池中获取连接」。原因是某处代码打开了连接但没有关闭,比如在try块里conn.Open()之后抛了异常,catch里只记了日志没关连接。解决是统一用using包裹连接,或者用try-finally确保Close被调用。排查方法是在连接字符串里加Pooling=true并观察 SQL Server 的sys.dm_exec_sessions,看连接数是否持续增长不释放。
4.3 中文姓名存进去变成问号
现象是学生姓名「张三」存到数据库里变成「??」。原因是数据库字段用了VARCHAR而不是NVARCHAR,或者连接字符串没有指定字符集。SQL Server 里中文必须用NVARCHAR,C# 参数要指定SqlDbType.NVarChar。MySQL 里要确保库和表的字符集是utf8mb4,连接字符串加CharSet=utf8mb4。已经存错的数据没法自动恢复,只能删掉重插。
4.4 选课按钮点一次插两条记录
现象是学生点一次选课,SC 表里出现两条相同记录。如果联合主键建对了,第二条会插入失败,但界面上可能因为没处理异常而显示成功。原因是按钮的 Click 事件被触发了两次,可能是双击,也可能是代码里手动绑定了两次事件。解决是在插入前禁用按钮,操作完成后恢复,同时捕获主键冲突异常并提示「请勿重复选课」。更彻底的做法是在数据库层用联合主键兜底,代码层用if (!SelectCourse(...))判断返回值。
4.5 退课后容量没有释放,课程显示已满
现象是学生退了课,SC 表里记录删了,但 Course 表的SelectedCount没减,导致课程一直显示满员。原因是退课逻辑只删了 SC 记录,忘了更新计数。解决是把退课也包在事务里,删除 SC 记录的同时UPDATE Course SET SelectedCount=SelectedCount-1。另外要注意退课时的边界:如果SelectedCount已经是 0,减一会变成负数,所以更新语句要加WHERE SelectedCount > 0。
5. 进阶技巧:用存储过程和触发器把选课逻辑下沉到数据库
课程设计做到后面,如果想让系统更「数据库味」一点,可以把选课和退课的核心逻辑写成存储过程,C# 端只负责调用。这样做的好处是逻辑集中在数据库层,换前端不用重写业务规则,而且存储过程在数据库内部执行,省去了网络往返。
-- 选课存储过程:把容量检查、插入、计数更新封装在一起 CREATE PROCEDURE sp_SelectCourse @Sno VARCHAR(12), @Cno VARCHAR(10), @Result INT OUTPUT AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; DECLARE @Capacity INT, @Selected INT; SELECT @Capacity = Capacity, @Selected = SelectedCount FROM Course WITH (UPDLOCK, ROWLOCK) WHERE Cno = @Cno; IF @Capacity IS NULL BEGIN SET @Result = -1; ROLLBACK; RETURN; END -- 课程不存在 IF @Selected >= @Capacity BEGIN SET @Result = -2; ROLLBACK; RETURN; END -- 课程已满 INSERT INTO SC(Sno, Cno) VALUES(@Sno, @Cno); UPDATE Course SET SelectedCount = SelectedCount + 1 WHERE Cno = @Cno; COMMIT; SET @Result = 0; -- 成功 END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK; SET @Result = -3; -- 重复选课或其他错误 END CATCH ENDC# 端调用时用SqlCommand的CommandType.StoredProcedure,把@Result设为Output参数,根据返回值判断结果。-1是课程不存在,-2是已满,-3是重复选课,0是成功。这样前端只需要弹对应的提示,不用关心数据库细节。
触发器可以用在退课场景:在 SC 表上建一个AFTER DELETE触发器,删除选课记录时自动把对应课程的SelectedCount减一。这样即使有人直接操作数据库删记录,计数也能保持一致。但触发器是把双刃剑,调试困难,逻辑隐蔽,课程设计里用一两个体会一下就行,生产环境要谨慎。
验证存储过程是否生效,最直接的方法是在 SSMS 里执行EXEC sp_SelectCourse '2023001', 'C001', @r OUTPUT; SELECT @r,看返回值和 SC 表、Course 表的数据变化。如果返回 0 但 SC 表没记录,检查事务是否提交;如果返回 -3 但确实是第一次选课,检查 SC 表里是不是已经有这个学号加课程号的记录。
我自己做课程设计时最深的教训是:别等到答辩前一天才去测并发。平时用单线程点按钮一切正常,答辩时老师随口说「两个人同时选最后一门课会怎样」,现场写个循环开十个线程一跑,数据就乱了。后来我养成的习惯是,任何涉及计数更新的功能,写完先开两个数据库连接手动模拟并发,确认锁和事务生效了再往下做。希望帮到你。
本文还有配套的精品资源,点击获取