简介:围绕数据库完整性约束与 T-SQL 操作展开的《数据库实验四》实验文档,面向正在学习数据库原理、需要完成课内实验与课后练习的高校学生。内容以主键和唯一约束的创建删除为起点,延伸到主表与从表间引用完整性测试、级联引用设置,并记录更新操作触发约束冲突时的报错信息与验证结论,便于对照复现实验过程。文档还整理了思考题参考答案及课外外键级联更新任务,涉及插入、删除、修改破坏实体完整性、参照完整性和用户定义完整性的典型情形,可帮助读者理解约束被拒绝时的处理思路与排错方向。资源包共 1 个 docx 文件,大小约 711KB,结构紧凑,适合作为实验报告撰写和考前复习的参考。目前已有 345 人学习,适合希望补齐约束语法与完整性验证细节的读者按任务逐项练习。
1. 约束不是「建议」:一条 UPDATE 被拒绝时,数据库到底在拦什么
数据库里最容易被误解的一句话是「这个字段有约束」。听起来像写在文档里的一句注释,实际上它是存储引擎在写日志之前的一道硬闸门。把 dept 表的部门代号00101改成00108,回车,消息栏立刻弹出UPDATE 语句与 REFERENCE 约束 "FK__person__DeptNo__2B0A656D" 冲突,整条语句一行都没落盘。第一次见这个错误的人往往会想「我就改一个字符,凭什么不让」。引擎拦的并不是这次修改本身,而是修改成功之后,所有拿部门代号去 person 表找人的查询会集体失准——那些行会指向一个不存在的部门,而这种脏数据一旦写进去,事后没有任何办法判断它原本该指向哪里。
真正值得搞懂的是:同一条复选框式的ALTER TABLE,为什么有的立刻生效、有的把整条语句打回、有的干脆把从表也一起改了。这三种结局背后是三套完全不同的检查路径,分别在语句解析阶段、行写入阶段和约束建立阶段生效。搞清楚它们各自在哪一层拦截,你才能从「报错了去删约束」变成「报错了先判断该改哪一边的数据」。
2. 主键与唯一约束:为什么联合主键的列序不能随便排
把 pay 表的三列捆成主键,看起来只是为了「让这三列组合不重复」,实际发生的事比这多得多:引擎要为这三列建一棵索引树,把每一列都提升为 NOT NULL,并且把这棵树的键登记为「可以被别的表引用」。唯一约束长得几乎一样,但在依赖关系、NULL 语义和索引类型上分道扬镳。
2.1 主键和唯一约束在存储层差在哪
最直观的差别是 NULL。主键的每一列都被强制 NOT NULL,而唯一约束列允许空值——不过 SQL Server 的唯一索引把多个 NULL 视为彼此相等,所以一张表上该列最多只能有一行是 NULL。这个细节经常被「唯一约束可以有空值」这句话掩盖,写业务代码时按「随便多少行 NULL 都行」来设计,插入第二行空值就被打回。
第二条差别是索引类型。建主键时,如果表上还没有聚簇索引,引擎默认把这棵主键索引建成聚簇索引,整张表的物理行序就跟着主键走;唯一约束默认建的是非聚簇索引,只在旁边另起一棵树。这意味着对于一张高频按主键范围取数的表,主键列序直接决定了数据在页上的排列顺序,进而决定了范围查询要读多少个页。
第三条差别体现在删除上。主键可以成为别的表外键的引用目标,唯一约束同样可以,但一旦被引用,DROP CONSTRAINT就会被直接拒绝——引擎不允许你悄悄抽掉别人脚下的地板。
-- pay 表:一条工资记录由「工号 + 年 + 月」唯一确定 -- 注意 YEAR / MONTH 是 T-SQL 内置函数名,当列名时必须加方括号 ALTER TABLE pay ADD CONSTRAINT PK_pay PRIMARY KEY (No, [Year], [Month]); -- 部门名称不允许重复,但它的语义不是这张表的身份标识 ALTER TABLE dept ADD CONSTRAINT UK_dept_DName UNIQUE (DName);这两条语句的差别不在关键字,而在后果。PK_pay一旦建成,pay 表的每一行就有了稳定身份,别的表可以写REFERENCES pay(No, [Year], [Month]);而UK_dept_DName建完之后,任何想往 dept 里塞同名部门的 INSERT 都会被拒,但没人能拿部门名称去当外键目标——语义上它随时可能被改名。
2.2 联合主键的列序决定了哪类查询能走索引
PRIMARY KEY (No, [Year], [Month])建出来的是一棵以这三列依次拼接为键的 B 树。索引的可用性遵守最左前缀:能命中WHERE No = ?、WHERE No = ? AND [Year] = ?、三列全给;但WHERE [Year] = ?这种跳过第一列的查询用不上它,只能退化成扫描。
所以联合主键的列序不是随便排的。常见的排序思路是把选择性最高、且最常单独出现在 WHERE 里的列放最左。工号显然比年份、月份更能定位到少量行,所以No打头是对的。如果反过来写成([Year], [Month], No),那么「查某员工全部工资记录」这个最常见的查询就完全用不上索引。
另一个常被忽视的代价在从表侧。联合主键意味着引用它的外键也必须带上完整三列,从表得把这三列全部冗余存一遍。三列都是定长字符加两个整数还好,如果主键是几个VARCHAR(50)拼起来的,从表就会被撑得很胖,每次插入还要多维护一棵更宽的索引。
-- 想确认主键到底建成了什么,别靠记忆,查元数据 SELECT i.name AS index_name, i.type_desc AS index_type, i.is_primary_key, i.is_unique_constraint, ic.key_ordinal, c.name AS column_name FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = OBJECT_ID('pay') ORDER BY ic.key_ordinal;这段查询把主键的真实结构摊开给你看:key_ordinal告诉你列在键里的位置,type_desc告诉你是聚簇还是非聚簇,is_unique_constraint区分这棵索引是主键带来的还是唯一约束带来的。调试「为什么这个查询没走索引」时,先跑一遍这个,比猜快得多。
2.3 加约束时会扫全表,这不是免费的
ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY不只是改一行元数据。引擎必须先确认现有数据满足唯一性,做法是对这三列排一次序或建一棵索引树,遇到重复键就整条语句回滚,报出类似「因发现重复键,CREATE UNIQUE INDEX 语句终止」的错误。这个校验过程在数据量大时可能持续很久,期间持有锁。
正因为如此,实验里思考题问「加主键约束报冲突是什么原因」,答案就藏在这一步:不是语法错了,是存量数据里存在重复组合,比如两个人同一年同一月被录了两次工资。解决办法只能是先把重复行找出来处理掉,再重建约束。
想要跳过校验也有办法,WITH NOCHECK可以让你在脏数据上把约束建上去,但它会留下一个隐患:约束被标记为不可信,优化器不会再用它来简化查询计划,而且它只对新增数据生效,存量脏数据依然躺在表里。这个坑后面单独说。
3. 外键的方向性:主表更新与从表更新为什么报不一样的错
外键约束最反直觉的地方在于它是「双向的」,但两个方向的检查内容完全不同。同一个外键,主表动一次和从表动一次,触发的是两套判断逻辑,报出来的错误码虽然都是 547,但拦下的原因完全是两回事。
3.1 谁引用我,和我引用谁
从表 person 上的DeptNo引用 dept 上的DeptNo。当你要改 dept 里的00101,引擎要回答的问题是:「person 里还有没有行指着 00101?」如果有,而且外键是默认的 NO ACTION,那这次修改被拒,因为改完之后那些行就成了孤儿。
反过来,当你要改 person 里的DeptNo,引擎要回答的是另一个问题:「新值在 dept 里存不存在?」存不存在才决定放不放行,旧值有没有人引用根本不重要。实验里把 pay 表的工号000002改成000020失败,正是因为 person 里压根没有000020这个人,从表的新值找不到参照对象。
把这两条路径记住,很多看起来莫名其妙的现象就顺了。比如「我只改了从表的一个非外键列,为什么也报外键错」这种疑问,多半是改的那列其实是外键的一部分,或者在一条级联链上被连带触发了。真正只改金额、备注这类非外键列时,外键检查根本不会被唤醒。
还有一类情况常被忽略:外键列允许 NULL 时,插入 NULL 是放行的。这是 MATCH SIMPLE 的语义——「我暂时还没有归属」。所以「从表插入一行没填部门号」不会报错,但「从表插入一行填了不存在的部门号」一定报错。区分这两种情况,是理解检查时机的关键。
-- 从表侧的完整定义:显式命名,别让引擎替你起名 ALTER TABLE pay WITH CHECK ADD CONSTRAINT fk_pay_person FOREIGN KEY (No) REFERENCES person(No);WITH CHECK表示对现存数据也做一次校验,如果 pay 里已经有 person 里不存在的工号,这条语句直接失败。这恰恰是好事——它逼你在建约束之前先把历史脏数据清干净。如果用WITH NOCHECK,语句能过,但约束从此处于「只对未来生效」的半残状态。
3.2 拆解 FK__person__DeptNo__2B0A656D 这个名字
错误信息里那串约束名不是随机的。SQL Server 在你不显式命名时,会按FK__<从表>__<列>__<对象ID后缀>的规则自动生成。中间那段十六进制是表的对象 ID 截出来的,表一旦被 drop 重建,这串后缀就变了。
这正好解释了实验记录里那个容易被当成笔误的细节:第 3 步报的是FK__person__DeptNo__2B0A656D,第 5 步报的是FK__person__DeptNo__38996AB5。同一张表、同一个列、同一个约束语义,名字却不同,只能说明中间把表重建过一遍。这不是记录错了,是自动命名机制的必然结果。
后果很实际:如果你的脚本里写死了DROP CONSTRAINT FK__person__DeptNo__2B0A656D,换一台机器、换一个数据库实例,这段脚本立刻报「对象不存在」。血泪经验是,凡是会被代码引用的约束,一律显式命名,命名规则自己定,比如fk_person_dept。自动命名只适合那种建完就再也不碰的一次性约束。
3.3 从表更新时,索引缺失会让主表操作变成灾难
外键的检查成本是不对称的。从表插入或更新时,引擎拿着新值去主表的主键或唯一索引里查一下,一次索引查找,很快。但主表更新或删除时,引擎得去每个引用它的从表里找「还有谁指着我」,这时候如果从表的外键列上没有索引,就是一次全表扫描。
-- 从表外键列没有索引时,主表每一次 UPDATE / DELETE 都是一次全表扫描 CREATE INDEX IX_pay_No ON pay(No);这条索引在很多教程里被漏掉,因为小数据量的实验表上完全感受不到。等到生产环境 pay 表攒到几百万行,某次主表改一个部门号,整张 pay 表被扫一遍还加上锁升级,业务方开始打电话,你才会回头补这条索引。判断要不要建的标准很简单:如果外键列已经是某个复合索引的最左前缀,不用重复建;否则基本都该建。
还要注意一个反向的陷阱:外键列上建索引会拖慢从表的插入,因为每次写入要多维护一棵树。所以不是无脑全建,而是先看这张从表的读多还是写多、主表会不会频繁改被引用键。主键稳定的字典类表,这个索引可有可无;主键会变的表,必须建。
4. 级联引用:ON UPDATE CASCADE 是怎么把一次 UPDATE 变成多次的
实验里最有意思的对比是第 3 步和第 5 步:同样是UPDATE dept SET DeptNo = '00108' WHERE DeptNo = '00101',加约束前被拒,加了ON UPDATE CASCADE之后主表改成功、从表 person 里对应的部门号也自动跟着改成了00108。表面上是「多了个开关」,底层是引擎把一条语句展开成了一条语句链。
4.1 级联是一次同事务内的展开,不是异步补偿
当主表的一行被更新,引擎会沿着外键关系找出所有引用它的从表行,对每一条执行一次等价的 UPDATE,把外键列的新值同步过去。这一整套动作发生在同一个事务、同一条语句里。任何一步失败——比如从表上还有一个触发器报错、或者从表的外键列上还有另一个约束挡着——整条语句连同主表的那次修改一起回滚。
这个「原子展开」的性质很重要。它意味着你不需要写补偿逻辑,也不会出现「主表改了、从表改了一半」的中间状态。但它也意味着级联的代价是累加的:主表改一行,可能触发从表改一万行,这一万行的索引维护、日志写入、锁持有全部算在你最初那条语句头上。
级联有四个方向可以配:ON DELETE CASCADE、ON UPDATE CASCADE、ON DELETE SET NULL、ON UPDATE SET NULL,还有SET DEFAULT。选择哪一种,取决于业务语义而不是技术偏好。订单明细跟着订单走,删订单就该级联删明细;员工调动部门,改部门号就该级联改员工记录;而如果从表记录必须保留、只是暂时失去归属,那SET NULL更合适——前提是那一列允许 NULL。
-- 主表更新时把从表的引用值一起改掉,删除方向仍然保持拒绝 ALTER TABLE person ADD CONSTRAINT fk_person_dept FOREIGN KEY (DeptNo) REFERENCES dept(DeptNo) ON UPDATE CASCADE ON DELETE NO ACTION;注意这里只开了更新方向。删除方向保持 NO ACTION,是因为删部门这种操作在业务上应该走审批流程,不该让一条 DELETE 悄悄带走所有员工记录。级联配置应该逐个方向单独决策,不要图省事全开。
4.2 自引用外键一加级联,很容易撞上多重路径
实验的课外部分尝试给 course 表的cpno建自引用外键(cpno引用同表的cno),并开启级联更新,结果失败。大多数人第一反应是「语法写错了」,其实语法没错,是引擎在设计上就禁止了这种组合。
原因在于,如果cpno → cno开了级联,同时sc.cno → course.cno也开了级联,那么更新一个课程号时,引擎要判断两条路径:直接改 sc 里的引用,和通过 cpno 间接改另一批 course 行再改 sc。这两条路径可能指向同一批行,执行顺序不同会得到不同结果。为了杜绝这种不确定性,引擎在创建约束阶段就直接拒绝,报出「可能会导致循环或多重级联路径」类的错误。
这不是 SQL Server 独有的保守,多数关系型数据库对级联路径都有类似限制。它不是能力缺陷,是把「结果必须唯一确定」放在了「配置灵活」之前。理解了这一点,遇到这个报错就不会再去翻语法手册了。
4.3 引擎不给级联时,用触发器加事务兜住
当外键方向被禁止级联,而业务又确实需要「改一个课程号、所有引用跟着动」,可行的路子有两条。一条是把操作拆成多步、由应用层按顺序提交:先插新课程号、再把 sc 和 cpno 指向新号、最后删旧号,整个过程包在一个事务里。这条路的缺点是逻辑散落在代码中,任何一个入口忘了走这套流程就出脏数据。
另一条路是把顺序固化进数据库,用 INSTEAD OF 触发器接管更新。
CREATE TRIGGER tr_course_cno_cascade ON course INSTEAD OF UPDATE AS BEGIN SET NOCOUNT ON; -- 只处理主键真正发生变化的行,其余列照常更新 -- 把旧号到新号的映射先固化,避免后续 join 读到已被改写的行 DECLARE @map TABLE (old_cno CHAR(10) PRIMARY KEY, new_cno CHAR(10)); INSERT INTO @map (old_cno, new_cno) SELECT d.cno, i.cno FROM deleted d JOIN inserted i ON i.cno = d.cno OR i.cno <> d.cno; -- 单行更新时成立 -- 1. 先改引用方 sc,让它指向新号 UPDATE sc SET cno = m.new_cno FROM sc JOIN @map m ON m.old_cno = sc.cno; -- 2. 再改自引用列 cpno UPDATE course SET cpno = m.new_cno FROM course c JOIN @map m ON m.old_cno = c.cpno; -- 3. 最后才改 course 本体,顺序反了就会撞外键 UPDATE course SET cno = m.new_cno FROM course c JOIN @map m ON m.old_cno = c.cno; END;这段代码的核心是执行顺序:先改引用方,再改被引用方。反过来做,第一步就会因为「还有行指着旧号」而失败。@map表变量把新旧值的对应关系先固化下来,避免在多条 UPDATE 之间出现「改到一半查不到原始值」的问题。
必须说清楚它的局限:上面这段在单行更新时正确,多行批量更新时deleted和inserted之间缺少可靠的关联合成,容易配错。真实项目里更稳的做法是要求调用方逐行提交,或者用MERGE配合唯一键映射。触发器不是银弹,它把复杂度从应用层搬到了数据库层,代价是调试变难、执行计划不透明。
5. 避坑:约束实验里五个反复翻车的现场
5.1 五条踩坑记录
现象一:明明建了外键,脏数据还是插进去了。原因是用WITH NOCHECK建的约束只校验新增数据,存量数据一概不管,而且约束被标记为不可信,优化器从此不再信任它。解决办法是先清理存量脏数据,再执行ALTER TABLE person WITH CHECK CHECK CONSTRAINT fk_person_dept,把约束重新启用并完整校验一遍。建约束时就用WITH CHECK,能省掉这一轮返工。
现象二:想改主键,DROP CONSTRAINT一直失败。报错说被外键引用。原因是你先建了从表外键,再回头动主键。正确顺序是:先查出所有引用这张表的外键,把它们禁用或删除,改完主键后再按原定义重建,整个流程包在事务里,中途失败就整体回滚。裸着手动一步步敲,中间断了就是半残状态。
现象三:脚本换台机器就报「约束不存在」。因为脚本里写死了FK__person__DeptNo__2B0A656D这种自动生成的名字,而名字里的十六进制后缀来自对象 ID,表一重建就变。解决办法是所有会被引用的约束一律显式命名,fk_<从表>_<主表>这种规则足够用,脚本里只引用自己起的名字。
现象四:大表上加约束,整个库卡住。ADD CONSTRAINT ... PRIMARY KEY要排序建索引,默认路径会长时间持锁。可行的缓解是先以ONLINE = ON建好唯一索引,再用ADD CONSTRAINT ... PRIMARY KEY ...复用这棵索引,避免二次排序;或者在业务低峰执行并接受这次锁。前者需要版本支持,后者需要排期,没有第三条免费的午餐。
现象五:主表改了,从表跟着变了,另一张表却没动。这是最隐蔽的一类,因为它不报错。原因是你只给其中一条外键配了ON UPDATE CASCADE,另一条引用路径仍然是 NO ACTION,引擎按各自的配置分别处理。排查办法是把这张表被引用的所有外键列出来,逐条核对级联配置,别只看你改的那条。
5.2 排查顺序:从错误号倒推约束对象
遇到约束报错,先别急着改 SQL。错误信息里已经给了足够线索,按固定顺序走一遍,比乱试快得多。
第一步看错误号。547 是约束冲突,2627 是主键或唯一约束冲突,1505 是建索引时发现重复键,1785 是级联路径冲突,3725/3726 是「对象被引用,不能删」。这几个号覆盖了实验里九成的情况。
第二步从错误信息里抠出约束名,去元数据表里反查它到底约束了什么。
-- 拿到约束名之后,查清楚它的方向和级联配置 SELECT fk.name AS fk_name, OBJECT_NAME(fk.parent_object_id) AS child_table, COL_NAME(fkc.parent_object_id, fkc.parent_column_id) AS child_col, OBJECT_NAME(fk.referenced_object_id) AS parent_table, COL_NAME(fkc.referenced_object_id, fkc.referenced_column_id) AS parent_col, fk.delete_referential_action_desc AS on_delete, fk.update_referential_action_desc AS on_update, fk.is_disabled, fk.is_not_trusted FROM sys.foreign_keys fk JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id WHERE fk.name = 'fk_person_dept';is_not_trusted这一列是重点,它告诉你这个约束是不是「只对新数据生效」的半残状态。on_update和on_delete两列告诉你级联到底开没开,很多时候你以为开了,其实只开了一半。
第三步才是判断该改哪边。主表侧报错,说明有人还引用着旧值,要么先改从表、要么开级联;从表侧报错,说明新值在主表里不存在,要么先补主表、要么别改这个值。判断完之后再动手,比一上来就DROP CONSTRAINT稳妥得多。
注意:把约束删掉确实能让语句立刻通过,但脏数据从此没有人拦。等到某天做报表发现一批记录指向不存在的部门,你已经无法判断它们原本该指向哪里了。
6. 设计取舍:约束该沉到数据库,还是留给应用层
做完这一轮实验,最容易得出的结论是「约束越多越安全」。这个结论对一半。约束的价值在于它把规则放在了所有写入路径的唯一入口上——不管是应用代码、后台脚本还是临时手工 SQL,想绕过它必须显式地禁用约束,这个动作本身就是一次告警。相比之下,把校验写在应用层,等于给每个入口都复制一份规则,漏掉一处就是一处漏洞。
代价也很实在。约束的报错信息是给 DBA 看的,不是给用户看的;UPDATE 语句与 REFERENCE 约束冲突这种文案直接弹到界面上,用户只会一脸茫然。所以工程上的常见做法是分层:数据库只负责最后一道兜底,保证任何情况下都不会产生孤儿数据;应用层负责把约束失败翻译成「该部门下还有 12 名员工,请先转移」这种可操作的提示。
| 维度 | 约束放在数据库 | 校验放在应用层 |
|---|---|---|
| 覆盖所有写入路径 | 是,唯一入口 | 否,每个入口各写一遍 |
| 报错可读性 | 差,是给运维看的 | 好,可以定制文案 |
| 修改规则的成本 | 需要 DDL 变更,可能锁表 | 改代码重新发布即可 |
| 并发下的可靠性 | 强,引擎保证原子性 | 依赖事务和锁的正确使用 |
| 大表上的性能影响 | 每次写入都要维护索引和校验 | 几乎为零 |
我的倾向是:涉及行间关系的规则(外键、唯一性、非空)交给数据库,涉及业务语义的规则(金额上限、状态机流转、日期区间)交给应用层,两层都做。前者是防止系统烂掉的地基,后者是给用户看的说明书。实验里思考题问「被约束拒绝时该删约束还是该调整操作」,答案之所以是后者,正是因为约束拦住的那一次操作,往往是数据本身有问题,删约束只是把问题藏起来。
验证这套东西有没有真的生效,别信「我建过了」。跑一遍正向和反向用例:主表改一个被引用的值,看是拒绝还是级联;从表插一个不存在的引用,看是否被拦;再查一次is_not_trusted和is_disabled,确认没有半残的约束混在里面。这三步做完,你才敢说约束真的在工作。
从那以后我每次改表结构,都强制走一遍「查依赖 → 显式命名 → 事务内改 → 正反用例验证」这四步,哪怕只是加一个看起来无关紧要的唯一约束。吃过一次「约束名写死、换环境脚本全崩」的亏之后,这个习惯就再也没敢省。希望帮到你。
本文还有配套的精品资源,点击获取