简介:《数据库实验四》是一份面向高校数据库课程学习者的实验配套文档,围绕主键与唯一约束的创建删除、表间引用完整性验证、级联引用测试等课内任务展开,并延伸到外键级联更新与数据一致性保障的课外练习,适合正在完成数据库实验、需要对照失败提示与验证结论梳理知识点的学生。资源包共1个docx文件,约711KB,以实验记录和思考题解答为主体,篇幅紧凑,便于打印或随堂查阅。已有345人学习,说明其在同类实验任务中具有一定参考度。文档保留了主表更新触发REFERENCE约束冲突、从表更新违反FOREIGN KEY约束等典型报错及原因分析,并针对增删改操作破坏实体完整性、参照完整性和用户定义完整性的情形给出举例,同时讨论约束被拒绝时为何应保留约束、新增主外键冲突的排查方向。读者可借此理解完整性约束的边界、外键级联更新的限制,以及用数据存在性校验保证主从表关联的思路。
1. 一条 UPDATE 被两边夹住:数据库完整性约束最常见的翻车现场
把 dept 表里的部门代号00101改成00108,一句 UPDATE,回车,报错:UPDATE 语句与 REFERENCE 约束"FK__person__DeptNo__2B0A656D"冲突。想着那就先改从表 person 吧,结果又报UPDATE 语句与 FOREIGN KEY 约束"fk_no"冲突。主表改不了,从表也改不了,改一个小小的部门代号,愣是被夹在门缝里动弹不得。数据库完整性约束这块,做实验的人十个里有八个卡在这一步,做业务系统维护字典表的人也照样卡。这篇排错笔记适合正被这类报错拦住、想知道为什么报、怎么改、以后怎么防的开发者,内容从建约束时报冲突,一路讲到主表更新被拦、从表更新被拦、级联更新加了却不生效。
2. 建约束就报错:ALTER TABLE 加主键与唯一约束的四种翻车
很多人的第一反应是“先把约束加上,再谈测试”。结果约束根本加不上去,报一句「某个语句与某个列(或某几列)冲突」,连实验的第一步都走不下去。这一章把建约束阶段最常见的四种翻车摆开:联合主键加不上、唯一约束删不掉、加主键时撞到重复数据、加外键时撞到孤儿数据。每一种都会给出现象、根因和能直接抄的脚本。
2.1 联合主键:ALTER TABLE pay 之前必须先查的两件事
实验要求把 pay 表的 No、Year、Month 三列联合定义为主键。主键的两个硬性前提是「非空」和「唯一」,缺一个 ALTER TABLE 就会失败。所以在敲建主键的语句之前,我一般会先跑两条自查语句,把脏数据提前暴露出来,而不是等报错再回头猜。
-- 检查一:三列里有没有 NULL,主键列不允许 NULL SELECT SUM(CASE WHEN No IS NULL OR [Year] IS NULL OR [Month] IS NULL THEN 1 ELSE 0 END) AS null_rows, COUNT(*) AS total_rows FROM pay; -- 检查二:三列组合有没有重复,重复数据会让主键建不上 SELECT No, [Year], [Month], COUNT(*) AS dup_cnt FROM pay GROUP BY No, [Year], [Month] HAVING COUNT(*) > 1;第一条语句用SUM(CASE WHEN ...)的写法一次扫表就能统计出含 NULL 的行数,比WHERE No IS NULL分别查三遍省事,total_rows顺手给了你总行数,方便判断 NULL 占比。第二条是标准的分组统计,HAVING COUNT(*) > 1只留下重复的组合,结果集为空才说明可以安全建主键。注意[Year]和[Month]加了方括号,因为它们和 T-SQL 内置的日期函数同名,虽然多数情况下不加也能解析,但放在建表、建约束语句里加方括号能省掉一类莫名其妙的语法错误。
两条检查都通过之后,再建主键:
-- 三列联合主键:No + Year + Month 三列组合起来唯一即可,单列不要求唯一 ALTER TABLE pay ADD CONSTRAINT PK_pay PRIMARY KEY (No, [Year], [Month]);PK_pay是给约束起的名字,后面删除、排查都要靠它。括号里三列的顺序决定了主键索引的列序:把最常用于等值查询的列放前面,索引前缀才能被复用。如果三列顺序随手写成(Year, Month, No),将来按 No 单独查询时这个索引基本用不上。删除主键的语句很直白:
ALTER TABLE pay DROP CONSTRAINT PK_pay;坑在依赖上:如果其他表的外键正引用着这个主键,DROP 会直接失败,报错大意是「该约束正被某表的外键引用」。遇到这种情况,要么先删外键、改完再加回来,要么换一条不删主键的实现路径,硬删只会把依赖关系搞得更乱。
2.2 删唯一约束:DROP CONSTRAINT 说找不到对象,多半是名字猜错了
实验第二步是删掉 dept 表部门名称列上的唯一约束。这里最容易翻车的不是删,而是不知道约束叫什么名字。手工建的时候自己起了名,删的时候照着写就行;当年建的时候没起名,由系统自动生成,名字就是一串带哈希后缀的字符,靠记忆根本对不上。
-- 先查 dept 表上到底有哪些约束,拿到准确名字再动手 EXEC sp_helpconstraint 'dept'; -- 或者从系统视图里按表名过滤,看约束类型和列 SELECT o.name AS constraint_name, o.type_desc, c.name AS column_name FROM sys.objects AS o JOIN sys.index_columns AS ic ON ic.object_id = o.parent_object_id JOIN sys.columns AS c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE o.parent_object_id = OBJECT_ID('dept') AND o.type IN ('UQ', 'PK');sp_helpconstraint是 SQL Server 自带的存储过程,参数就是表名,输出里会列出该表所有约束的名称、类型和作用的列,适合快速对照。第二条查询走系统视图,sys.objects里type = 'UQ'代表唯一约束,ic用来把约束关联到具体列。两条一起用,基本不会出现名字对不上的情况。拿到名字之后:
-- 建:部门名称列唯一(允许 NULL,但整列只能存在一个 NULL) ALTER TABLE dept ADD CONSTRAINT UK_dept_DeptName UNIQUE (DeptName); -- 删:注意这里是 DROP CONSTRAINT,不是 DROP INDEX ALTER TABLE dept DROP CONSTRAINT UK_dept_DeptName;参数上要留个心眼:SQL Server 的唯一约束底层是唯一索引,索引把 NULL 视为相等,所以整列里最多只能有一行 NULL,第二行 NULL 会直接报重复。这和很多人印象里「唯一约束允许任意多个 NULL」是两回事,那是别的数据库产品的行为。另外删唯一约束和删主键一样,如果这个唯一列正被别表的外键引用,DROP 同样会被拦住。
2.3 加约束撞到重复数据:先看清楚到底哪几行在打架
思考题里问得很准:给表加主键约束时报「某语句与某列冲突」,主要原因是什么。答案就一句话,现有的数据不符合新约束的要求。比如想把姓名设成主键,但表里有两个同名的人,主键要求唯一,这条 ALTER TABLE 必然失败。报错文本只会告诉你冲突,不会告诉你哪几行冲突,所以得自己查。
-- 加主键前:把重复的行找出来,看清楚重复的规模和分布 SELECT Name, COUNT(*) AS cnt FROM student GROUP BY Name HAVING COUNT(*) > 1 ORDER BY cnt DESC; -- 加唯一约束前:NULL 也要一起看,唯一约束只容得下一个 NULL SELECT COUNT(*) - COUNT(Name) AS null_cnt FROM student;第一条查询的结果直接给了你要处理的对象:要么给重复数据加区分(比如姓名后加学号后缀),要么换一列当主键,要么用联合主键把区分度补上。第二条用COUNT(*) - COUNT(列名)的差值算 NULL 行数,这是最省事的写法,COUNT(列名)会跳过 NULL,两个一减就是 NULL 的个数。处理完重复和 NULL,再加约束就不会报冲突了。
加外键时撞到冲突是同一类问题的另一面。思考题里举的例子是选课表的课程号想加外键参照课程表,但选课表里已经存在课程表里没有的课程号。这种行叫孤儿行,查法如下:
-- 加外键前:找出从表里在主表找不到参照的那些行 SELECT s.Cno, COUNT(*) AS orphan_cnt FROM sc AS s LEFT JOIN course AS c ON c.Cno = s.Cno WHERE c.Cno IS NULL GROUP BY s.Cno;这里用LEFT JOIN ... WHERE 右表主键 IS NULL的写法,比NOT IN稳:NOT IN遇到子查询结果里有 NULL 会整体返回空集,排查时容易得出「没有孤儿行」的错误结论,白折腾半天。LEFT JOIN加判空在语义上没有这个坑,也能顺带统计每个孤儿值出现了几次,方便决定是补主表数据还是删从表数据。
2.4 约束名是自动生成的哈希串:这不是玄学,是排查线索
实验里同一个部门代号更新,前后两次报错给出的约束名居然不一样,一次是FK__person__DeptNo__2B0A656D,另一次是FK__person__DeptNo__38996AB5。这不是数据库抽风,而是因为中间删过、重建过这个外键,系统自动命名规则是「FK__从表名__列名__一串哈希」,哈希段由对象 ID 派生,重建一次就换一次。
这条线索的价值在于:报错里的约束名是可以直接粘出来用的,但粘之前最好确认它当前还在不在。常见做法是先把它丢进系统视图查一遍,确认存在、确认它挂在哪张表、哪个列上,再决定是删还是改。第 6 章会给完整的查询脚本,这里先记住一个结论:报错信息里的约束名是起点,不是答案。
3. 改主表被拦下:UPDATE 语句与 REFERENCE 约束冲突怎么拆
这是整个实验里最容易让人懵的一次报错。改的是 dept,报的却是 person 上的约束,而且还叫 REFERENCE,在表定义里根本搜不到REFERENCE这个关键字。搞清楚这条报错的措辞逻辑,后面的排查会快很多。
3.1 报错文本逐个词拆:REFERENCE 和 FOREIGN KEY 不是一回事
SQL Server 的外键冲突统一报 547 号错误,但消息措辞有两种,对应两个完全相反的操作方向:
- 对主表做 INSERT / UPDATE / DELETE,被外键拦住时,报「与 REFERENCE 约束 XXX 冲突」。因为此时是从表那一端在「参照」你正在动的这一行,数据库在保护参照关系。
- 对从表做 INSERT / UPDATE,被外键拦住时,报「与 FOREIGN KEY 约束 XXX 冲突」。因为此时是你正在动的这一行找不到可参照的父行。
所以看到「REFERENCE」,脑子里要立刻翻译成「我在改被参照的那一端」,多半是外键的父表。看到「FOREIGN KEY」,翻译成「我在改参照别人的那一端」,多半是外键的子表。这个区分不是文字游戏,它直接决定你下一步该去查哪张表的数据。
3.2 为什么主表和从表都不能直接改:这是一个环
把 dept 的00101改成00108,person 里有一批行的 DeptNo 还是00101,改完这些行就成了孤儿,数据库拒绝。反过来直接改 person 呢?00108在 dept 里根本不存在,外键又不让写进去。一个要等对方先动,一个要等对方存在,这就是典型的环。
很多人第一反应是「那把外键删了不就好了」。思考题里对这个问题给了明确态度:应该按照约束给出的提示去做合理操作,而不是删约束。理由很实际,约束是数据一致性的最后一道闸,删掉之后脏数据能顺利写进去,等到半个月后业务报表对不上账,你连是哪次操作搞脏的都不知道。所以正确解法不是拆闸,而是找一条合法的操作顺序。
3.3 三步法:先插新父行、再改子行、最后删旧父行
打破这个环最稳的办法是引入一个中间态——让新旧两个父行同时存在一小会儿。整个动作拆成三步,顺序不能颠倒:
BEGIN TRY BEGIN TRAN; -- 第一步:把新部门号作为一条合法的主表记录插进去 INSERT INTO dept (DeptNo, DeptName) VALUES ('00108', '某某部门'); -- 第二步:从表整体迁移到新部门号上 UPDATE person SET DeptNo = '00108' WHERE DeptNo = '00101'; -- 第三步:确认没有残留引用后,旧父行才能删 DELETE FROM dept WHERE DeptNo = '00101' AND NOT EXISTS (SELECT 1 FROM person WHERE DeptNo = '00101'); COMMIT; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK; THROW; END CATCH;第一步的 INSERT 是最关键的一步,它让00108成为一个真实存在的主表行,外键检查就能通过。第二步把从表迁过去,此时两个父行都在,谁都不缺参照。第三步的 DELETE 带了NOT EXISTS保护,只要从表还有残留就删不掉,等于在语句层面又做了一次自检。外面套TRY...CATCH和TRAN,任何一步失败整体回滚,不会留下改了一半的数据——中途失败又不回滚,是这类操作里最难收场的局面。
3.4 必须一步完成时:禁用约束、事务内改完、WITH CHECK 恢复
有些场景下插中间行不方便,比如部门名称列上有唯一约束,新增一条同名部门直接冲突。这时候可以用「临时禁用约束」的做法,但要清楚它的代价。
BEGIN TRY BEGIN TRAN; -- 暂时不检查这条外键,只影响后续 DML,不动已有数据 ALTER TABLE person NOCHECK CONSTRAINT FK__person__DeptNo__2B0A656D; UPDATE dept SET DeptNo = '00108' WHERE DeptNo = '00101'; UPDATE person SET DeptNo = '00108' WHERE DeptNo = '00101'; -- 重新启用并强制校验现有数据,任何不一致都会在这里抛错 ALTER TABLE person WITH CHECK CHECK CONSTRAINT FK__person__DeptNo__2B0A656D; COMMIT; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK; THROW; END CATCH;NOCHECK CONSTRAINT的作用范围要说清楚:它只是让后续的增删改跳过这条外键的检查,不验证也不修改已有数据,所以禁用期间如果操作出错,脏数据是能写进去的。结尾的WITH CHECK CHECK CONSTRAINT才是安全阀,WITH CHECK表示启用时验证现有数据,CHECK表示把约束重新打开,两个词缺一个,约束就处于「开着但不信任」的半吊子状态,第 5 章会专门讲这个状态带来的问题。
4. 改从表被拦下:UPDATE 语句与 FOREIGN KEY 约束 "fk_no" 冲突怎么解
主表那条路走通了,很多人会顺手去试从表,把 pay 表里的工号000002改成000020,又撞一次 547,这次措辞变成了 FOREIGN KEY,约束名是fk_no。这条报错和上一条的检查方向正好相反,处理思路也不一样。
4.1 fk_no 拦的是新值:000020 在主表里根本没有
从表更新时,外键检查只看更新后的新值。把000002改成000020,数据库拿着000020去 person 表里找,找不到,直接拒绝。至于旧值000002本来合不合法,对这次检查毫无影响。
这里还有个隐藏的第二道检查容易被忽略:pay 表的 No 是联合主键(No, Year, Month)的一部分,改 No 这个动作同时也在改主键。所以一次 UPDATE 可能先撞主键、再撞外键,或者反过来。看到fk_no报错时不要急着下结论说数据全错,先确认到底是哪道检查没过。
-- 确认新旧两个工号在主表里到底存不存在 SELECT No, Name, DeptNo FROM person WHERE No IN ('000002', '000020');这条查询很朴素,但能立刻把问题定性:结果里只有000002,说明是「新值不存在」,该补主表数据;结果里两个都有,那报错就不是外键引起的,要回头去看主键和唯一约束。排查时最怕的就是凭猜测直接动手改数据,一条 SELECT 能省掉半小时乱试。
4.2 一次性把所有孤儿值捞出来,别一个个试
如果怀疑从表里不止一行有问题,逐行 UPDATE 试错效率太低。常见做法是用一条语句把从表里所有主表找不到的值全列出来,一次看个够:
-- 列出 pay 表里所有在主表中没有对应工号的值,以及各出现了几次 SELECT p.No, COUNT(*) AS row_cnt FROM pay AS p WHERE NOT EXISTS ( SELECT 1 FROM person AS pe WHERE pe.No = p.No ) GROUP BY p.No ORDER BY row_cnt DESC;NOT EXISTS在这里比NOT IN安全,原因和第 2 章一样,子查询里出现 NULL 时NOT IN会让整个结果变空。GROUP BY加COUNT(*)是为了看清每个孤儿值影响的规模,比如某个工号牵扯了 200 行缴费记录,那就不能简单删掉了事,得先补主表。
4.3 补主表还是改回新值:两种正解和一个错误的绕法
确认新值在主表不存在之后,有两条正路。第一条是把父行补上,再改从表:
BEGIN TRY BEGIN TRAN; -- 先在主表里把 000020 这个人员建出来,字段按表结构补全 INSERT INTO person (No, Name, DeptNo) VALUES ('000020', '某某', '00101'); -- 父行存在了,从表改工号才能通过外键检查 UPDATE pay SET No = '000020' WHERE No = '000002'; COMMIT; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK; THROW; END CATCH;第二条是承认000020是个错误输入,把 UPDATE 语句改成WHERE No = '000002'对应的正确目标值,压根不产生越界数据。这两条路都对,判断依据是业务上000020到底该不该存在。
错误的绕法是删掉fk_no再改,改完不加回来。这么干确实不报错了,代价是 pay 表里从此可以塞进任意工号,等你要按工号关联人员信息时,会发现一批记录永远 join 不出人。约束报错本质上是数据库在替你做数据体检,把体检项目取消了,不等于病好了。真实项目里更稳的做法是保留约束,把这类「改主键值」的操作收敛到一个存储过程里,由过程统一处理父子顺序,避免每个调用方各自发挥。
4.4 约束命名规范:fk_no 一眼能认,哈希串只能靠查
这次报错里fk_no这个名字很友好:一看就知道是工号字段上的外键,不用查系统视图也能定位。对比第 3 章那个FK__person__DeptNo__2B0A656D,同样的事情,排查成本差了一截。
所以在自己的库里建约束,我习惯一律显式命名,格式固定成FK_从表_列名、UQ_表_列名、PK_表名。好处不只是好看:脚本可以重复执行、报错信息自带定位信息、做变更脚本时能准确写出 DROP 的目标。代价是名字要维护,重建约束时如果忘了沿用旧名,就会出现新旧两个名字并存的情况,所以变更脚本里我一般会先判断存在性再建。
5. 避坑记录:级联更新加了却不生效的五个瞬间
级联这块是最容易产生「我明明设了」错觉的地方。实验里把 dept 的00101改成00108之后,从表 person 确实跟着变了,看着是成功了;可换到课程表的场景,把0809023501改成0809023601,sc 表里的选课记录却没跟着动。同一套思路,两种结果,下面这五条是踩得最多的。
5.1 五条踩坑记录
第一条:明明报错里给了约束名,DROP 却说对象不存在。
现象:把报错信息里的FK__person__DeptNo__38996AB5直接粘到DROP CONSTRAINT后面,报对象不存在。
原因:这个后缀是系统自动生成的哈希,只要外键被删过重建过,后缀就换了。你在第 3 章看到的名字,和第 5 章看到的名字,很可能指向两个不同的对象生命周期。
解决:DROP 之前先查当前真实存在的约束名,用第 2 章那条sys.objects查询,或者第 6 章的sys.foreign_keys脚本,拿到结果再粘。
第二条:加上了 ON UPDATE CASCADE,还是报 REFERENCE 冲突。
现象:确认外键定义里写了级联,改主表依然失败。
原因:三种可能——级联加在了另一条外键上(person 上不止一个外键)、改的是同名旧约束、或者语句写成了ALTER TABLE ... ADD CONSTRAINT但表里已有不合规数据,用了WITH NOCHECK建出来的约束处于不可信状态。
解决:用sys.foreign_keys查update_referential_action_desc字段,值是CASCADE才算真加了;同时看is_not_trusted是不是 1,是 1 说明约束没验证过现有数据。
第三条:sc 表里的选课记录没跟着课程号一起变。
现象:course 的0809023501改成0809023601,sc 里引用旧课程号的行还是旧值。
原因:外键只写了REFERENCES course(cno),没写ON UPDATE CASCADE。SQL Server 的默认行为是NO ACTION,也就是「不动作、直接拒绝」,而如果外键当时是用WITH NOCHECK建的或者干脆没建,就会出现「改了但不报错、数据悄悄不一致」的情况,比报错更危险。
解决:删了重建,明确带上级联子句,重建前先跑第 2.3 节那条孤儿行查询,把不合规数据清掉。
-- 先删旧外键,再带级联重建 ALTER TABLE sc DROP CONSTRAINT FK_sc_cno; ALTER TABLE sc ADD CONSTRAINT FK_sc_cno FOREIGN KEY (cno) REFERENCES course (cno) ON UPDATE CASCADE ON DELETE CASCADE;ON UPDATE CASCADE管父行主键改变时子行跟着改,ON DELETE CASCADE管父行删除时子行跟着删。两个子句要分开想清楚,尤其是删除级联,它会让「删一条课程」变成「删掉所有选课记录」,做实验时很方便,上生产前必须确认这真的是业务想要的语义。
第四条:给 course.cpno 加自引用外键并带级联,直接建不上。
现象:cpno引用本表的cno,加上ON UPDATE CASCADE后建约束失败,提示可能引起循环或多重级联路径。
原因:这是 SQL Server 的硬性限制,不是配置问题。自引用外键本身可以建,但一旦带上级联动作,就可能在校验时形成无限递归,引擎干脆在定义阶段就拒绝。同理,同一张表被两条级联路径同时指向时,也会报同一类错误。
解决:这条路上级联走不通,得换方案,具体见 5.2。
第五条:临时禁用了约束,后来忘了恢复。
现象:某次为了批量改数据执行了NOCHECK CONSTRAINT,之后一切正常,直到某天发现约束根本没在生效,脏数据已经进来一批。
原因:NOCHECK只是关掉检查,不会自己恢复,而且关掉之后约束进入「不信任」状态,优化器也不再拿它做推断。
解决:所有禁用都包在事务里,结尾强制WITH CHECK CHECK CONSTRAINT恢复;定期跑一遍 5.3 的巡检脚本,把is_disabled = 1或is_not_trusted = 1的外键捞出来。
5.2 级联走不通时:按拓扑顺序改,或者用触发器兜住
思考题里问得很直接:外键不能实现级联更新时,用什么方法保证数据正确性。答案不是「想办法让级联生效」,而是承认限制、换执行路径。最实用的还是「禁用约束 + 事务内按引用方向改 + 恢复校验」这一套,改的顺序有讲究:先动被引用方(父行),再动引用方(子行),中间态由禁用的约束兜住,最后统一校验。
-- 自引用或级联受限的场景:临时禁用、事务内改完、强制恢复校验 ALTER TABLE sc NOCHECK CONSTRAINT FK_sc_cno; BEGIN TRY BEGIN TRAN; UPDATE course SET cno = '0809023601' WHERE cno = '0809023501'; UPDATE sc SET cno = '0809023601' WHERE cno = '0809023501'; COMMIT; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK; THROW; END CATCH; -- 这一步不能省,WITH CHECK 会验证现有数据并把约束恢复为可信状态 ALTER TABLE sc WITH CHECK CHECK CONSTRAINT FK_sc_cno;如果这种改号操作在业务里是常态,把它封装进触发器更省心。下面这个 AFTER 触发器挂在 course 上,只要课程号变了,就把 sc 里对应的选课记录同步过去:
CREATE TRIGGER TR_course_cno_update ON course AFTER UPDATE AS BEGIN SET NOCOUNT ON; -- 只处理 cno 真正发生变化的批次,其他列更新直接返回 IF NOT UPDATE(cno) RETURN; -- 集合方式更新,一次处理整批,不做逐行游标 UPDATE sc SET cno = i.cno FROM sc JOIN deleted AS d ON d.cno = sc.cno JOIN inserted AS i ON i.cno <> d.cno; END;deleted和inserted是触发器里的两个虚拟表,分别装着本次 UPDATE 前后的行,JOIN起来就能精确找到「课程号变了」的那些行。IF NOT UPDATE(cno)是个便宜的短路判断,避免每次更新别的列都白跑一遍。这里用集合更新而不是CURSOR,是因为触发器里的游标在大批量更新时性能会塌方,而且写法上容易写错。触发器能解决同步问题,代价是把逻辑藏在了看不见的地方,调试时不容易发现,所以业务简单的话我更倾向存储过程,触发器只在改号这种横切场景用。
5.3 关掉约束检查之后必须做的收尾
前面反复提到「不信任状态」,它是这么产生的:任何一次NOCHECK CONSTRAINT,或者用WITH NOCHECK添加约束,都会让这条约束的is_not_trusted变成 1。这个状态对外表现是「约束还在、也还在拦新数据」,但它不再保证历史数据合规,查询优化器也不会基于它做优化。
-- 巡检:列出被禁用或不被信任的外键,定期跑一次 SELECT fk.name AS fk_name, OBJECT_NAME(fk.parent_object_id) AS child_table, fk.is_disabled, fk.is_not_trusted, fk.update_referential_action_desc AS on_update, fk.delete_referential_action_desc AS on_delete FROM sys.foreign_keys AS fk WHERE fk.is_disabled = 1 OR fk.is_not_trusted = 1;结果集为空才说明库里没有遗留问题。is_disabled = 1是被彻底关掉的,新数据都不检查;is_not_trusted = 1是开着但不保证历史数据,两种都要修,修法都是ALTER TABLE 表名 WITH CHECK CHECK CONSTRAINT 约束名。跑这条语句时如果报错,那说明历史数据里确实有不一致的行,得按第 4.2 节的孤儿查询去找出来。
6. 快速定位:从一条 547 报错到约束定义的四步排查
踩了这么多坑之后,我处理这类报错形成了固定动作,四步之内基本能落到具体表和具体数据上,不用来回试。
第一步,看措辞定方向。报错里出现 REFERENCE,说明被改的是父表,动作是 INSERT/UPDATE/DELETE 中试图破坏参照关系的那一个;出现 FOREIGN KEY,说明被改的是子表,动作多半是 INSERT/UPDATE。这一步只需要几秒钟,却能直接砍掉一半的排查范围。把这个判断固化成一张对照表,比记文字快:
| 报错关键词 | 动作发生在 | 典型语句 | 第一件要查的事 |
|---|---|---|---|
| REFERENCE 约束 | 主表(被参照端) | UPDATE/DELETE dept | 从表里还有多少行引用旧值 |
| FOREIGN KEY 约束 | 从表(参照端) | INSERT/UPDATE pay | 新值在主表里存不存在 |
| PRIMARY KEY 约束 | 任意表 | INSERT/UPDATE | 是否与已有组合重复 |
| UNIQUE 约束 | 任意表 | INSERT/UPDATE | 是否重复,是否多个 NULL |
| 提示循环或多重级联路径 | 建约束阶段 | ALTER TABLE ADD CONSTRAINT | 是否自引用或存在多条级联路径 |
第二步,把约束名当索引用。报错里的名字可以直接粘进查询,一次性拿到它挂在哪张表、哪个列、级联规则是什么、有没有被禁用:
-- 把报错里的约束名粘到 WHERE 里,一条语句看完它的全部属性 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.update_referential_action_desc AS on_update, fk.delete_referential_action_desc AS on_delete, fk.is_disabled, fk.is_not_trusted FROM sys.foreign_keys AS fk JOIN sys.foreign_key_columns AS fkc ON fkc.constraint_object_id = fk.object_id WHERE fk.name = 'fk_no';sys.foreign_keys存的是外键本身,sys.foreign_key_columns存的是列映射,用constraint_object_id关联起来才能拿到「哪一列指向哪一列」。on_update和on_delete两个字段最值得看,值是NO_ACTION就说明级联没设,别再去怀疑约束没生效。如果想连主键、唯一约束一起看,EXEC sp_helpconstraint 'person'更省事。
第三步,查数据而不是猜数据。定下是哪张表的哪几列之后,直接跑孤儿行查询或重复行查询,把结果集摆在眼前。这一步的关键是「一次查全」,用GROUP BY统计规模,而不是一行一行试。多数时候结果集会立刻告诉你该补父行、该删子行,还是压根不该动数据。
第四步,决定改数据还是改约束。顺序上永远是先想办法让数据合规,实在做不到才动约束定义,而且动约束必须配套三件事:显式命名、带上正确的级联子句、建完立刻用WITH CHECK验证一次。级联能不能用,还要看表结构有没有自引用或多重路径,这两条是引擎层面的红线,绕不过去就退回触发器和存储过程。
从那以后我每次动这类带外键的表,都强制先跑一遍sp_helpconstraint和执行计划无关的那两条孤儿查询,哪怕只是改一行部门代号。真正花时间的从来不是写 UPDATE 的那一秒,而是事后发现数据悄悄不一致、回头去翻是哪次操作搞脏的那半天。希望帮到你。
本文还有配套的精品资源,点击获取