MySQL 外键删除规则怎么选:CASCADE、SET NULL 与 RESTRICT
删除客户时报 Cannot delete or update a parent row,不应该先去关闭外键检查。这个错误可能正是在保护必须保留的历史记录。另一个容易走向相反极端的改法,是把所有外键都设成 CASCADE:客户删掉以后,地址、工单、账单一起消失,SQL 倒是成功了,业务记录却没了。
删除关系要逐种确定:地址是否跟随客户消失,工单是否保留但解除关联,有账单的客户是否应该阻止删除。下面用四张完整的 InnoDB 表比较这三种规则,并在同一个事务里观察回滚与提交。示例在 MySQL 8.4.11 执行,SQL 适用于 MySQL 8.0 及以上,不涉及关闭实例检查或修改全局参数。
先把三种删除政策写进表结构
这里只放演示数据。客户编号是稳定标识,不通过改主键完成账号迁移;三个外键的更新动作都设为 RESTRICT,删除动作各自选择。
CREATE DATABASE foreign_delete_practice CHARACTER SET utf8mb4;
USE foreign_delete_practice;
CREATE TABLE customer (
id BIGINT NOT NULL,
customer_name VARCHAR(40) NOT NULL,
archived TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (id)
) ENGINE=InnoDB;
CREATE TABLE shipping_address (
id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
address_text VARCHAR(200) NOT NULL,
PRIMARY KEY (id),
KEY idx_address_customer (customer_id),
CONSTRAINT fk_address_customer FOREIGN KEY (customer_id)
REFERENCES customer(id)
ON DELETE CASCADE ON UPDATE RESTRICT
) ENGINE=InnoDB;
CREATE TABLE support_ticket (
id BIGINT NOT NULL,
customer_id BIGINT NULL,
customer_name_snapshot VARCHAR(40) NOT NULL,
message VARCHAR(200) NOT NULL,
PRIMARY KEY (id),
KEY idx_ticket_customer (customer_id),
CONSTRAINT fk_ticket_customer FOREIGN KEY (customer_id)
REFERENCES customer(id)
ON DELETE SET NULL ON UPDATE RESTRICT
) ENGINE=InnoDB;
CREATE TABLE billing_record (
id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
amount DECIMAL(12,2) NOT NULL,
PRIMARY KEY (id),
KEY idx_billing_customer (customer_id),
CONSTRAINT fk_billing_customer FOREIGN KEY (customer_id)
REFERENCES customer(id)
ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE=InnoDB;
INSERT INTO customer VALUES (1, '演示甲', 0), (2, '演示乙', 0);
INSERT INTO shipping_address VALUES
(11, 1, '甲的演示地址'), (21, 2, '乙的地址一'), (22, 2, '乙的地址二');
INSERT INTO support_ticket VALUES
(101, 1, '演示甲', '甲的演示工单'),
(201, 2, '演示乙', '乙的演示工单');
INSERT INTO billing_record VALUES (1001, 1, 50.00);
CASCADE 表示父行删除时连带删除匹配的子行。SET NULL 表示保留子行,把对应外键列设为 NULL,所以 support_ticket.customer_id 必须允许空值。RESTRICT 表示只要还有引用就拒绝删除。在 InnoDB 中,NO ACTION 与这里的 RESTRICT 一样是立即检查,不是“等 COMMIT 时再决定”。

外键要在子表上声明,索引和字段类型也需匹配。这里父子编号均是 BIGINT,父表引用主键,子表显式列出普通索引。不要把某个 ORM 配置中的 cascade 属性与这段数据库 DDL 当成同一设置:只要真正发出的 SQL 和表约束不同,结果就可能不同。
SET NULL 也没有替业务保存历史身份。工单里单独存了 customer_name_snapshot,是为了展示“关联解除后仍有历史文本”的模型;实际需要保留哪些快照字段、是否允许清理,应由业务定义,不能指望外键自动生成一份历史对象。
第一次删除失败,不能把它当作数据库异常跳过去
客户 1 有账单,直接执行:
DELETE FROM customer WHERE id = 1;
会得到错误 1451。然后检查关联行:
SELECT id, archived FROM customer ORDER BY id;
SELECT id, customer_id FROM shipping_address ORDER BY id;
SELECT id, customer_id FROM support_ticket ORDER BY id;
SELECT id, customer_id, amount FROM billing_record ORDER BY id;
两个客户、三个地址、两个工单、一条账单都仍然存在。不能猜测“CASCADE 的地址先删掉了,所以现在只差处理账单”;这次失败语句在 InnoDB 中不会留下部分完成的级联结果。
如果客户 1 只是停止使用,示例选择归档,保留引用:
UPDATE customer SET archived = 1 WHERE id = 1;
SELECT id, customer_id FROM shipping_address WHERE customer_id = 1;
SELECT id, customer_id FROM support_ticket WHERE customer_id = 1;
地址 11 和工单 101 仍关联客户 1。设置 archived 并不是 DELETE,外键删除动作不会因此触发。查询是否隐藏已归档客户、是否禁止新增业务,要另外在应用读写规则中实现,不能把一个状态位当成数据库级“全面停用”。
更不要用 foreign_key_checks=0 绕过这条错误后直接删父行。那改变的是完整性检查行为,不是替你补齐删除政策;重新开启也不等于已经追溯修复了所有历史孤儿行。真需要改变规则,应先检查现有关系和数据保留要求,再安排明确的结构迁移。
客户 2 可以删,先看回滚能否恢复关联
客户 2 没有账单。先在同一个连接执行一轮事务,暂时不提交:
START TRANSACTION;
DELETE FROM customer WHERE id = 2;
SELECT ROW_COUNT() AS deleted_customer_rows;
SELECT id FROM shipping_address WHERE customer_id = 2 ORDER BY id;
SELECT id, customer_id, customer_name_snapshot
FROM support_ticket WHERE id = 201;
ROLLBACK;
ROW_COUNT 返回 1,表示直接删除了一条客户行,不是“全部受影响表总共只改了一行”。地址查询已经为空,工单 201 还在,但 customer_id 为 NULL,快照文本仍是演示乙。ROLLBACK 撤销这一轮对父子表的修改。
在回滚之后实际核对:
SELECT id FROM customer WHERE id = 2;
SELECT id FROM shipping_address WHERE customer_id = 2 ORDER BY id;
SELECT id, customer_id FROM support_ticket WHERE id = 201;
客户 2、地址 21/22、工单的 customer_id=2 都恢复了。这个观察成立的前提是这些表使用 InnoDB,并且修改处于同一事务中。已经提交的删除不能靠稍后在新事务里执行 ROLLBACK 撤销。
确认需要删除后,再执行提交版本:
START TRANSACTION;
DELETE FROM customer WHERE id = 2;
COMMIT;
SELECT id, archived FROM customer ORDER BY id;
SELECT id, customer_id FROM shipping_address ORDER BY id;
SELECT id, customer_id, customer_name_snapshot FROM support_ticket ORDER BY id;
SELECT id, customer_id, amount FROM billing_record ORDER BY id;
最终只剩已归档的客户 1 和地址 11。工单 101 关联客户 1,工单 201 的关联为空,两条工单都保留;账单 1001 仍关联客户 1,金额还是 50.00。这比只看父表删除返回成功更能说明实际业务结果。
工单 201 的 NULL 是允许的解除关联状态,不是“指向了一个不存在的客户”。检查历史孤儿关系时,需要排除这种合法空值:
SELECT t.id, t.customer_id
FROM support_ticket AS t
LEFT JOIN customer AS c ON c.id = t.customer_id
WHERE t.customer_id IS NOT NULL AND c.id IS NULL;
本例返回空结果。只写 WHERE c.id IS NULL,会把允许为空的工单 201 也找出来,误报为损坏数据。保留可空关系意味着读接口也要处理客户资料不存在的情况,例如使用快照文本展示,而不是解引用到客户对象时再抛空指针。这个变化需要查询、DTO 与页面一同理解。
最后从当前数据库读取生效的规则,而不是只检查建表脚本或实体注解:
SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME, DELETE_RULE, UPDATE_RULE
FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
ORDER BY CONSTRAINT_NAME;
三条 DELETE_RULE 分别为 CASCADE、RESTRICT、SET NULL,UPDATE_RULE 都是 RESTRICT。若正式表与预期不一致,先确认连到的库和实际建表定义,再讨论修改哪条约束。
级联删除仍然需要锁定和修改子行,并不因为应用只发了一条 DELETE 就没有成本。如果一个客户下面有几十万条地址或其他子记录,这一条父表操作也可能成为大事务。需要控制批次时,应重新设计显式的清理过程,而不是以为在父表上加 LIMIT 1 就把所有子表工作量也限制成了一行。