简介:面向计算机专业课程设计与初学者的SQL Server学生选课系统数据库设计资料,定位清晰,可直接用于课程设计、作业或项目演示,也适合SQL Server初学者逐步进阶。资料以SQL Server为后台,提供学生选课相关的建表、索引、视图、存储过程等SQL源码,并配套详细设计文档,帮助理解从需求分析到数据库落地的完整流程。压缩包共6个文件,体积仅139KB,主要包含.sql脚本、.docx说明文档、.zbak数据库备份、.png截图与README说明,覆盖代码、文档、演示素材三类用途。目前已有50人学习浏览,这一课程设计曾获导师认可、答辩评审95分,且已在mac与Windows 10/11下运行通过。下载后可获得可直接恢复的数据库备份和SQL脚本,便于对照文档二次修改或扩展选课、成绩、课程管理等功能,用于课设答辩或后续开发都比较省心。
1. 学生选课系统的数据库设计:为什么这是课程设计里的“高性价比”选题
很多人以为学生选课系统的数据库设计“就是三张表加几个外键”,可真打开 SQL Server 开始做课程设计才发现:光是一个“同一学期同一门课不能重复选”,就要同时靠主键、约束、存储过程和测试数据来兜底。这个项目真正拉开差距的地方不在 INSERT 和 SELECT,而在你把“限选容量、退课留痕、成绩范围”这些业务规则变成表结构、事务和文档的过程。
这篇文章按我平时带课程设计的一套完整路径来讲:先做需求与 ER 设计,再写建表与约束脚本,然后封装选课/退课的存储过程和视图,接着造一份不会超容量的测试数据,最后把文档和脚本组织成交付物。中间穿插 5 个最容易翻车的实践坑,每个都按“现象→原因→解决”展开,碰到同款报错可以直接照做。
适合正在做 SQL Server 课程设计的学生,也适合刚转数据库开发、想用一个小而全的项目练手的人。标题里的“源码”,落到数据库课程设计上就是建表 SQL、存储过程、视图和触发器的完整脚本;“详细文档”则是从 ER 图到字段说明、部署步骤、测试记录的一整套说明,后面章节都会覆盖到。
2. 需求与表设计:从选课业务规则里拆出实体、主键与约束
动手写 CREATE TABLE 之前,先把业务规则列清楚。学生选课系统最核心的规则有五条:
- 一个学生在一个学期可以选多门课程;
- 一门课程在一个学期可以被多个学生选择;
- 同一个学生同一学期不能重复选同一门课;
- 每门课有容量上限,达到上限后不能再选;
- 成绩只能在选课后回填,且范围是 0 到 100,退课要留痕。
这五条规则直接决定表怎么拆、主键怎么定、约束怎么加。跳过需求直接建表,后面补约束的代价远大于一开始写清楚。
2.1 四张核心表如何从需求里“长出来”
从以上规则里能识别出两个基础实体:学生和课程。它们之间是多对多关系——一个学生选多门课,一门课被多个学生选,所以需要一张中间表来承载“谁在什么学期选了哪门课”,这张表就是选课表。
学生表用来存学生基础信息和状态:学号、姓名、性别、出生日期、专业、班级、入学年份、是否注销。课程表用来存课程信息和容量控制:课程号、课程名、课程类型、学分、学时、容量、教师、上课时间说明。选课表则把学生和课程关联起来,同时携带学期、选课时间、成绩三个关键属性。
这里有一个设计判断:成绩字段必须挂在选课表上,而不是放在学生表或课程表。原因是成绩是“某个学生某学期某门课”的结果,属于关系实体的属性;放在学生表里,一个学生多门课就存不下;放在课程表里,一门课多个学生也存不下。逻辑上只有选课表能让成绩落到对应的选课关系上。
退课日志表是第四张表。删除选课记录后,把被删的选课信息写入日志表,保证“退课留痕”。这张表初看可有可无,但在答辩环节很能体现对数据完整性的理解。
2.2 主键与外键选型:业务主键优先,还是代理主键兜底
学生表主键用学号,课程表主键用课程号,选课表主键用联合主键 (StudentNo, CourseNo, Semester)。这里我建议优先用业务主键,而不是自增 INT 代理主键。
原因有两个。第一,学号和课程号在现实教务系统里已有成熟编码规则,用自增 ID 会截断业务语义,答辩时老师一句“你们学校学号有规则,你的表怎么是 1、2、3?”就很难解释。第二,如果非要用代理主键,选课表上还得额外加唯一约束来保证“同一学期同一门课不重复选”,建表成本没有减少,反而多一个无业务含义的列。
选课表的联合主键有一个直接好处:数据库层面把重复选课挡住了。应用程序就算忘记做重复判断,INSERT 也会因为主键冲突报错。外键方面,我建议全部使用默认的 NO ACTION,不用 ON DELETE CASCADE。真实教务系统里的学生删除一般是“注销”而不是物理删除,误级联会连带删掉选课历史。所以学生表留一个 Status 字段做逻辑删除更稳妥。
2.3 约束脚本:让 SQL Server 从第一行就开始防错
建表脚本是整套源码的地基。下面是我常用的一组脚本,先建父表,再建子表,约束在创建表时就带上:
CREATE TABLE dbo.Student ( StudentNo CHAR(10) NOT NULL, StudentName NVARCHAR(20) NOT NULL, Gender CHAR(1) NOT NULL CONSTRAINT CK_Student_Gender CHECK (Gender IN (N'男', N'女')), BirthDate DATE NULL, Major NVARCHAR(30) NOT NULL, ClassName NVARCHAR(20) NOT NULL, EnrollmentYear SMALLINT NOT NULL, Status CHAR(1) NOT NULL DEFAULT '1' CONSTRAINT CK_Student_Status CHECK (Status IN ('0', '1')), CONSTRAINT PK_Student PRIMARY KEY (StudentNo) ); GO CREATE TABLE dbo.Course ( CourseNo CHAR(8) NOT NULL, CourseName NVARCHAR(30) NOT NULL, CourseType CHAR(2) NOT NULL CONSTRAINT CK_Course_Type CHECK (CourseType IN (N'必修', N'选修')), Credit NUMERIC(2,1) NOT NULL CONSTRAINT CK_Course_Credit CHECK (Credit > 0), Period SMALLINT NOT NULL CONSTRAINT CK_Course_Period CHECK (Period >= 16), Capacity SMALLINT NOT NULL CONSTRAINT CK_Course_Capacity CHECK (Capacity > 0), Teacher NVARCHAR(20) NOT NULL, ScheduleNote NVARCHAR(40) NULL, CONSTRAINT PK_Course PRIMARY KEY (CourseNo) ); GO学生表里,学号用 CHAR(10) 而不是 VARCHAR,因为学号固定长度,用定长类型可以避免行变长带来的额外开销。姓名、专业、班级这些中文文本全部用 NVARCHAR,避免代码页问题导致中文乱码。性别和状态用 CHAR(1) 加 CHECK 约束,比用 TINYINT 更直观,也比应用层判断更可靠。
CREATE TABLE dbo.Enrollment ( StudentNo CHAR(10) NOT NULL CONSTRAINT FK_Enrollment_Student REFERENCES dbo.Student(StudentNo), CourseNo CHAR(8) NOT NULL CONSTRAINT FK_Enrollment_Course REFERENCES dbo.Course(CourseNo), Semester CHAR(7) NOT NULL CONSTRAINT CK_Enrollment_Semester CHECK (Semester LIKE '[0-9][0-9][0-9][0-9]-[12]'), EnrollTime DATETIME2(0) NOT NULL CONSTRAINT DF_Enrollment_EnrollTime DEFAULT SYSDATETIME(), Score NUMERIC(5,2) NULL CONSTRAINT CK_Enrollment_Score CHECK (Score IS NULL OR (Score >= 0 AND Score <= 100)), CONSTRAINT PK_Enrollment PRIMARY KEY (StudentNo, CourseNo, Semester) ); GO CREATE TABLE dbo.DropLog ( LogID INT IDENTITY(1,1) NOT NULL, StudentNo CHAR(10) NOT NULL, CourseNo CHAR(8) NOT NULL, Semester CHAR(7) NOT NULL, DroppedAt DATETIME2(0) NOT NULL CONSTRAINT DF_DropLog_DroppedAt DEFAULT SYSDATETIME(), CONSTRAINT PK_DropLog PRIMARY KEY (LogID) ); GO选课表里,Semester 用 CHAR(7) 存“2025-1”这样的格式,CHECK 约束用 LIKE 限定必须是四位年份加短横线加 1 或 2,从格式上挡住无效学期。成绩用 NUMERIC(5,2) 而不是 FLOAT:浮点数在成绩比较和平均值计算时有精度误差,NUMERIC 是定点数,100 分制保留两位小数足够。Score 字段允许 NULL,因为学生刚选课时还没有成绩,成绩是教师后续回填的,只能靠 CHECK 约束限制范围,不能用 NOT NULL。
3. 选课与退课的核心流程:用存储过程封装事务,而不是拼 SQL
选课逻辑涉及多个校验和一次插入,如果放在应用程序里拼 SQL,事务边界会被网络往返切成好几段,并发控制也无从谈起。把这些逻辑封装进存储过程,有三个实际好处:事务从 BEGIN 到 COMMIT 在一个数据库会话里完成;加锁策略可以在服务端统一控制;答辩时老师问“怎么防止超选”,你可以直接指着一处代码讲清楚。
我一般会为每个存储过程约定一套错误码:50001 到 50010 分别对应用户可读的错误信息,程序端只需要捕获 SQLException 并把消息展示给用户即可。这样排错时可以靠错误号快速定位,不用翻日志猜。
3.1 选课存储过程:五个业务校验与一条锁策略
选课存储过程是整套源码里最核心的一个对象。它要在一次事务里完成学生状态校验、课程存在性校验、重复选课校验、容量校验和插入选课记录:
CREATE PROCEDURE dbo.usp_SelectCourse @p_StudentNo CHAR(10), @p_CourseNo CHAR(8), @p_Semester CHAR(7) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; BEGIN TRANSACTION; -- 1. 学生状态校验:只有 Status = '1' 的在校学生可以选课 IF NOT EXISTS (SELECT 1 FROM dbo.Student WHERE StudentNo = @p_StudentNo AND Status = '1') BEGIN THROW 50001, N'学生不存在或已注销', 1; END -- 2. 课程存在性校验 IF NOT EXISTS (SELECT 1 FROM dbo.Course WHERE CourseNo = @p_CourseNo) BEGIN THROW 50002, N'课程编号不存在', 1; END -- 3. 重复选课校验:联合主键也挡住了重复插入,这里提前给出友好提示 IF EXISTS ( SELECT 1 FROM dbo.Enrollment WHERE StudentNo = @p_StudentNo AND CourseNo = @p_CourseNo AND Semester = @p_Semester ) BEGIN THROW 50003, N'该学期已选过这门课', 1; END -- 4. 锁定课程行,防止并发下超选 DECLARE @capacity SMALLINT; SELECT @capacity = Capacity FROM dbo.Course WITH (UPDLOCK, ROWLOCK) WHERE CourseNo = @p_CourseNo; -- 5. 容量判断:以锁定的课程行数据为准 IF (SELECT COUNT(*) FROM dbo.Enrollment WHERE CourseNo = @p_CourseNo AND Semester = @p_Semester) >= @capacity BEGIN THROW 50005, N'课程容量已满', 1; END INSERT INTO dbo.Enrollment (StudentNo, CourseNo, Semester, EnrollTime) VALUES (@p_StudentNo, @p_CourseNo, @p_Semester, SYSDATETIME()); COMMIT TRANSACTION; END GO参数方面,三个参数对应学生表、课程表和选课表的业务主键,调用方传入前要保证格式正确,存储过程内部的 CHECK 约束和 LIKE 校验负责兜底。参数类型与表字段完全一致,避免隐式转换导致索引失效。
| 参数 | 类型 | 说明 |
|---|---|---|
| @p_StudentNo | CHAR(10) | 学号,对应 Student.StudentNo |
| @p_CourseNo | CHAR(8) | 课程号,对应 Course.CourseNo |
| @p_Semester | CHAR(7) | 学期,格式如 2025-1 |
锁策略是这段代码的关键。第 4 步对课程行加 UPDLOCK 和 ROWLOCK,而不是直接锁选课表。原因很实际:如果一门课还没有人选,Enrollment 表里没有对应行可锁,这时并发请求会同时读到剩余名额,导致超选;而课程表中的课程行一定存在,锁住课程行后,同一门课的并发选课就会在这里排队。
SET XACT_ABORT ON 是一个容易忽略但对事务安全至关重要的设置。它保证任何运行时错误都会自动回滚整个事务,否则 THROW 之后事务可能还挂着,连接复用时会一串串地报错。时间冲突检测如果靠 ScheduleNote 字段做字符串比较,基本是玄学,真实场景需要独立的排课时间表才能可靠实现,课程设计阶段不强行写进这个过程里。
调用存储过程的方式很简单:
EXEC dbo.usp_SelectCourse @p_StudentNo = 'S000000001', @p_CourseNo = 'PE101', @p_Semester = '2025-1';如果返回“课程容量已满”,说明该课程剩余名额已经被其他事务占走;如果返回“该学期已选过这门课”,说明同一学生已经有一条选课记录。程序端只需要按错误号映射提示文案。
为什么不把超选校验写成触发器?触发器在 INSERT 之后触发,只能事后回滚,无法在插入前控制加锁顺序,并发下的表现不如存储过程可控。所以容量控制放在存储过程里,触发器只用于日志类需求,后面会看到。
3.2 退课存储过程与退课日志触发器
退课的逻辑相对简单:删除选课记录,同时保证删除动作留痕。退课存储过程用 @@ROWCOUNT 判断是否有记录被删除:
CREATE PROCEDURE dbo.usp_DropCourse @p_StudentNo CHAR(10), @p_CourseNo CHAR(8), @p_Semester CHAR(7) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; BEGIN TRANSACTION; DELETE FROM dbo.Enrollment WHERE StudentNo = @p_StudentNo AND CourseNo = @p_CourseNo AND Semester = @p_Semester; IF @@ROWCOUNT = 0 BEGIN ROLLBACK; THROW 50010, N'没有找到这条选课记录', 1; END COMMIT TRANSACTION; END GO这里先 ROLLBACK 再 THROW,是避免事务悬挂。如果直接 THROW,事务会处于“有未提交更改”的状态,连接放回连接池后,下一个请求可能继承一个不可见的事务。
退课留痕用触发器实现:
CREATE TRIGGER dbo.trg_Enrollment_AfterDelete ON dbo.Enrollment AFTER DELETE AS BEGIN SET NOCOUNT ON; INSERT INTO dbo.DropLog (StudentNo, CourseNo, Semester) SELECT StudentNo, CourseNo, Semester FROM deleted; END GO为什么不在退课存储过程里直接写 INSERT DropLog?因为退课可能有多个入口:应用程序调用存储过程、DBA 手工清理、后台维护脚本。触发器能覆盖所有删除路径,保证“退课留痕”这条规则不因入口不同而遗漏。触发体里只做简单 INSERT,不写业务校验,这点在后面避坑章节会展开。
3.3 视图与报表:把常用统计封装成对象
答辩和实际使用中,最常问的问题是“每门课选了多少人、平均分是多少”。这个统计如果每次现场写 GROUP BY 语句,既不美观也容易出错。一个视图就能把固定逻辑封装好:
CREATE VIEW dbo.v_CourseSelectionReport AS SELECT c.CourseNo, c.CourseName, c.Teacher, c.Capacity, COUNT(e.StudentNo) AS SelectedCount, AVG(e.Score) AS AvgScore FROM dbo.Course c LEFT JOIN dbo.Enrollment e ON c.CourseNo = e.CourseNo GROUP BY c.CourseNo, c.CourseName, c.Teacher, c.Capacity; GOLEFT JOIN 保证没有被选过的课程也会出现在结果里,SelectedCount 为 0,AvgScore 为 NULL。这样报表数据是完整的,不会漏课。使用时直接查询视图:
SELECT CourseNo, CourseName, SelectedCount, Capacity FROM dbo.v_CourseSelectionReport ORDER BY SelectedCount DESC;如果只想看某个学期的数据,可以在外面加条件过滤,视图本身保持通用,不写死学期。
4. 测试数据与详细文档:让项目“跑得起来、讲得清楚”
一个数据库设计项目,只有表结构和存储过程还不够。课程设计交付时,老师首先要能跑通,其次要看懂。测试数据解决“跑得起来”,详细文档解决“讲得清楚”。我一般会先造数据再写文档,因为造数据的过程会暴露约束问题,反过来再修正文档,顺序不能反。
4.1 测试数据生成:随机但有规则,不能超容量
造测试数据的第一个坑是学生表。很多同学用 WHILE 循环一条条 INSERT,效率低且代码冗长。用 ROW_NUMBER 加系统视图一次生成 200 条学生数据:
INSERT INTO dbo.Student (StudentNo, StudentName, Gender, BirthDate, Major, ClassName, EnrollmentYear, Status) SELECT 'S' + RIGHT('000000000' + CAST(n AS VARCHAR(10)), 9) AS StudentNo, N'学生' + CAST(n AS VARCHAR(10)) AS StudentName, CASE WHEN n % 2 = 0 THEN N'男' ELSE N'女' END AS Gender, DATEADD(DAY, n * 13, '20050101') AS BirthDate, N'计算机科学与技术' AS Major, N'计科240' + CAST((n - 1) % 3 + 1 AS VARCHAR(1)) AS ClassName, 2024 AS EnrollmentYear, '1' AS Status FROM ( SELECT TOP 200 ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n FROM sys.all_objects a CROSS JOIN sys.all_objects b ) s; GO学号由 S 加 9 位数字拼成,固定 10 位。姓名用“学生1”到“学生200”,性别按奇偶数交替,班级分散到三个班。这样数据量合理,且每个字段都有变化,不是毫无意义的重复值。使用 sys.all_objects 交叉连接来生成序列,不需要额外建数字表,脚本在任何环境都能直接跑。
课程数据建议手写,因为课程数量少,并且要有意识地控制容量。比如 PE101 容量 30,选课人数只放 28,留 2 个空位;MA102 容量 120,放 100 人。这样现场演示时,既能演示选课成功,也能演示容量已满。
选课数据是最容易造坏的部分。如果直接用 CROSS JOIN 随机组合,会产生重复主键;如果随机选课程,可能让某门课超过容量。我用表变量指定每门课的选课人数,再用 CROSS APPLY 从学生表随机挑选,从源头上保证不超容量:
DECLARE @CourseSelections TABLE ( CourseNo CHAR(8), SelectionCount INT ); INSERT INTO @CourseSelections (CourseNo, SelectionCount) VALUES ('CS101', 38), ('CS205', 42), ('CS310', 35), ('MA102', 100), ('PE101', 28), ('GE201', 60); INSERT INTO dbo.Enrollment (StudentNo, CourseNo, Semester, EnrollTime) SELECT s.StudentNo, cs.CourseNo, '2025-1', DATEADD(MINUTE, ROW_NUMBER() OVER (PARTITION BY cs.CourseNo ORDER BY NEWID()), SYSDATETIME()) FROM @CourseSelections cs CROSS APPLY ( SELECT TOP (cs.SelectionCount) StudentNo FROM dbo.Student WHERE Status = '1' ORDER BY NEWID() ) s; GOCROSS APPLY 对每门课从学生表随机取指定数量的学生,因为源来自 Student 表主键,同一门课不会出现重复学生。一个学生可以被多门课选中,对应真实场景里一个学生选多门课。总选课记录数等于所有 SelectionCount 之和,每门课的记录数都小于等于容量,完全可控。
提示:如果只关注数据量而不关注容量约束,建议用手动指定选课人数的方式。数据量随机但规则不随机的测试数据,在展示时很难解释清楚。
4.2 文档目录结构与字段说明表怎么写
“详细文档”不是把代码截图贴进 Word,而是让另一个同学照着文档能在一台新电脑上还原整个项目。我推荐按编号组织文档和脚本:
01-需求说明.md 02-ER图与关系模式.md 03-表结构与约束说明.md 04-存储过程与视图说明.md 05-测试方案与测试记录.md 06-部署与使用指南.md scripts/ 01-create-database.sql 02-create-tables.sql 03-create-objects.sql 04-insert-test-data.sql 05-clean.sql每份文档的内容要有明确边界。需求说明把业务规则列成条目,每条对应到表字段或存储过程;ER 图与关系模式给出实体关系图和每个表的关系模式;表结构与约束说明是核心,每个表一张字段表,包含字段名、类型、必填、默认值、约束和业务含义。
字段说明表是文档里最实用的部分,但很多同学写成了字段名加类型两列,业务含义完全没写。参考格式如下:
| 字段名 | 类型 | 必填 | 默认值 | 约束 | 说明 |
|---|---|---|---|---|---|
| StudentNo | CHAR(10) | 是 | 无 | 主键 | 学号,学校编码规则 |
| Status | CHAR(1) | 是 | '1' | CHECK IN ('0','1') | '1' 在校,'0' 注销 |
| Semester | CHAR(7) | 是 | 无 | LIKE '[0-9][0-9][0-9][0-9]-[12]' | 学期,如 2025-1 |
| Score | NUMERIC(5,2) | 否 | 无 | 0 到 100 | 选课成功后回填 |
存储过程与视图说明里,每个对象要写清参数表、返回值、错误码、调用示例。测试方案与测试记录列出被测场景:正常选课、重复选课、超容量选课、退课无记录、成绩越界,每项都写预期结果和实际结果。部署与使用指南从建库开始到验证结束,让拿到文档的人可以不看其他文件独立完成部署。
4.3 脚本文件怎么组织:排序、命名、可重复执行
脚本命名带数字前缀是为了保证执行顺序:先建库,再建表,再建对象,再插数据。很多同学把所有内容写进一个 .sql 文件,开发过程中改一次就重跑一次,对象已存在的报错会频繁打断节奏。
解决方案是每个脚本里的对象都加存在性判断。以存储过程为例:
IF OBJECT_ID(N'dbo.usp_SelectCourse', N'P') IS NOT NULL DROP PROCEDURE dbo.usp_SelectCourse; GO CREATE PROCEDURE dbo.usp_SelectCourse ...这样同一个脚本可以反复执行,不影响开发效率。03 号脚本集中放视图、存储过程和触发器,02 号脚本只放表结构。恢复环境时先跑 02 再跑 03,顺序清晰,出问题时也能快速定位是哪个脚本导致的。
文档和脚本之间要互相引用。部署指南里写明“执行 scripts 目录下的 01 到 04 号脚本”,表结构说明里写明“参见 02-create-tables.sql”。老师拿到压缩包后,不需要来回猜哪个文件对应哪段说明。
5. 学生选课系统数据库设计的 5 个实践坑与排查方法
这些坑我几乎每次给同学调课程设计都会遇到,有些甚至来自看上去很规范的教程示例。下面按“现象→原因→解决”展开,碰到同款报错可以直接照做。
5.1 建表顺序与删除顺序导致的外键报错
现象:一次性跑完整个建表脚本,报“外键引用无效”;或者想删掉一张旧表时提示“表被 FOREIGN KEY 约束引用,无法删除”。
原因:SQL Server 要求被外键引用的父表必须先存在;删除时则相反,必须先删子表再删父表。一个脚本里写好所有建表语句时,执行顺序完全由脚本内顺序决定,顺序错了就报错。
解决:建表时先父表后子表,清理时反过来。清理脚本 05-clean.sql 应该写成从最内层开始往外删:
IF OBJECT_ID(N'dbo.DropLog', N'U') IS NOT NULL DROP TABLE dbo.DropLog; IF OBJECT_ID(N'dbo.Enrollment', N'U') IS NOT NULL DROP TABLE dbo.Enrollment; IF OBJECT_ID(N'dbo.Course', N'U') IS NOT NULL DROP TABLE dbo.Course; IF OBJECT_ID(N'dbo.Student', N'U') IS NOT NULL DROP TABLE dbo.Student; GO关键是顺序。先删 DropLog,再删 Enrollment,然后才是 Course 和 Student。用 IF OBJECT_ID 判断存在性,脚本重跑不会因为表不存在而中断。
5.2 中文变问号:varchar 与 nvarchar 的选型问题
现象:插入中文后查询显示成“?”;或者应用程序传中文参数保存后乱码。
原因:varchar 按数据库代码页存储字符,中文在非中文字符代码页下会被转成问号;而 nvarchar 按 Unicode 存储,对中文没有依赖。另一个隐藏原因是 SQL 脚本文件本身保存成了非 UTF-8 编码,SSMS 执行时会按当前代码页读入,中文直接损坏。
解决:涉及中文的字段统一使用 NVARCHAR 或 NCHAR,不要用 VARCHAR;建库时显式指定排序规则:
CREATE DATABASE CourseSelectionDB COLLATE Chinese_PRC_CI_AS; GO显式指定排序规则后,换一台机器部署时行为不会随实例默认设置变化。SQL 脚本文件在编辑器里另存为“UTF-8 with BOM”格式,能避免脚本里的中文注释和字符串在 SSMS 里被误读。
5.3 CHECK 约束建了却不生效
现象:给 Score 字段建了 CHECK(0-100),插入 120 没有报错。
原因:约束被跳过或从未启用。常见三种情况:ALTER TABLE 时用了 WITH NOCHECK,现有数据没有验证就标记为信任;数据里已经存在违反约束的行,导致约束根本加不上;用 SSMS 表设计器添加约束,但从没同步回脚本文件,换环境部署时约束就丢了。
解决:用明确的 WITH CHECK 重建约束,并确认没有 NOCHECK:
ALTER TABLE dbo.Enrollment WITH CHECK ADD CONSTRAINT CK_Enrollment_Score CHECK (Score IS NULL OR (Score >= 0 AND Score <= 100)); GO验证约束是否真正启用:
SELECT name, is_disabled, is_not_trusted FROM sys.check_constraints WHERE parent_object_id = OBJECT_ID(N'dbo.Enrollment');is_disabled 为 1 表示约束被禁用,is_not_trusted 为 1 表示现有行没有被验证。两个指标都应该是 0。
5.4 并发选课时的死锁与阻塞
现象:用程序模拟 20 个学生同时选同一门只剩 1 个名额的课程,最终选课人数变成了 3 甚至 4;有时直接报死锁错误 1205。
原因:程序端先查 COUNT 再 INSERT,两条语句之间没有事务保护,多个请求同时读到剩余名额为 1,同时通过判断,最后都插入成功。这就是典型的并发超选。
解决:把容量判断放进存储过程,并且先锁课程行:
SELECT @capacity = Capacity FROM dbo.Course WITH (UPDLOCK, ROWLOCK) WHERE CourseNo = @p_CourseNo;对课程行加 UPDLOCK 后,同一门课的并发事务在这里排队。第二个事务要等第一个事务提交或回滚后才会读到容量数据,读到的 COUNT 自然包含第一个事务刚插入的记录,超选就不会发生。同时建议事务体内只做必要的查询和写入,不要执行 WAITFOR、不要查询无关大表,把锁持有时间压到最短。
5.5 触发器“绑架”主事务:触发体越大越危险
现象:删除一条选课记录,DELETE 本身没问题,但触发器里某个 UPDATE 失败,导致整个退课事务回滚,退课失败。
原因:AFTER 触发器和触发它的主语句处在同一个事务中,触发器内部报错会把主操作一起带回滚。触发体里做 UPDATE、调用存储过程、写复杂逻辑,还会显著增加死锁概率。
解决:触发器只做简单留痕,不写业务校验,不 UPDATE 其他表,不调用 THROW。退课日志触发器的正确姿势就是 3.2 节里那一段:一条 INSERT SELECT,字段直接从 deleted 虚拟表取。如果确实需要复杂逻辑,把它放到退课存储过程里,让触发器保持“哑”状态。
6. 进阶:让这个数据库设计“自带说服力”的三个交付技巧
课程设计的评分往往不只看出没跑通,还要看“换一台电脑能不能跑起来”。我总结三个让项目更稳的交付技巧:一键重建、自查 SQL、备份部署。
一键重建就是把 4.3 节的脚本顺序交给 sqlcmd 执行。写好一个 bat 文件,双击后从空库到完整数据一步到位:
sqlcmd -S .\SQLEXPRESS -E -i 01-create-database.sql sqlcmd -S .\SQLEXPRESS -E -i 02-create-tables.sql sqlcmd -S .\SQLEXPRESS -E -i 03-create-objects.sql sqlcmd -S .\SQLEXPRESS -E -i 04-insert-test-data.sql-S 指定实例名,本机默认实例写英文句点,命名实例写 .\SQLEXPRESS;-E 表示 Windows 身份登录;-i 指定脚本路径。如果对方电脑用的 SQL 身份登录,把 -E 换成 -U 用户名 -P 密码。把这段命令存成 rebuild.bat,放在项目根目录,老师拿到后不用打开 SSMS 就能部署。
自查 SQL 是部署后第一件要做的事。一条语句检查对象是否齐全:
SELECT name, type_desc FROM sys.objects WHERE type IN ('U', 'P', 'V', 'TR') ORDER BY type, name;再一条检查数据量是否符合预期:
SELECT (SELECT COUNT(*) FROM dbo.Student) AS StudentRows, (SELECT COUNT(*) FROM dbo.Course) AS CourseRows, (SELECT COUNT(*) FROM dbo.Enrollment) AS EnrollmentRows;StudentRows 为 0 说明插入脚本没执行;EnrollmentRows 和预期不符说明测试数据脚本被改过。这两条语句写入部署与使用指南的“验收步骤”,现场演示前花十秒跑一遍,能避免绝大多数尴尬。
答辩现场最稳的部署方式不是现场跑脚本,而是提前在本机部署好后生成备份文件,现场一键还原:
BACKUP DATABASE CourseSelectionDB TO DISK = N'D:\CourseSelectionDB.bak';还原时注意数据库名和文件路径:
RESTORE DATABASE CourseSelectionDB FROM DISK = N'D:\CourseSelectionDB.bak' WITH REPLACE, RECOVERY;还原完成后跑一遍自查 SQL,数据对得上再开始演示。我自己带同学做这类项目时,最后一天通常是“先备份再继续改”。改坏了就还原,这是最快的后悔药。备份文件、SQL 脚本、文档三件套一起交付,远比只给一个 .sql 文件更让人放心。希望帮到你。
本文还有配套的精品资源,点击获取