数据完整性校验并不是一个孤立的数据库功能,而是关系型数据库在写入路径上设置的多道关卡。SQL本身并不存在一个名为“校验”的命令,它依靠约束、触发器、事务和存储过程协同工作,在数据落盘前或提交前拦截不符合规则的内容。以订单场景为例,订单金额不能为负数,用户ID必须存在,订单状态只能在有限集合内变化,这些要求如果只放在应用层判断,一旦多个服务共用同一个库,或者存在手工修数、数据迁移等旁路,就很容易出现脏数据。因此在数据库层设置校验规则,是维持数据质量稳定的关键手段。

从实现机制上看,SQL数据完整性校验可以分为两类。第一类是声明式约束,包括主键、唯一键、非空、检查约束、默认值和外键;第二类是过程式校验,包括触发器、存储过程和事务中的条件判断。声明式约束由数据库引擎自动执行,不需要额外调用,性能开销通常较低,适合描述字段级和表间引用关系。过程式校验更灵活,可以表达跨行、跨表甚至跨多次写入的复杂规则,但需要谨慎设计,否则容易造成锁等待、递归触发或性能下降。
一、基础约束如何拦截非法数据
实体完整性是数据完整性的第一层含义,它要求每一行数据都能被唯一识别。主键约束同时具备唯一性和非空性,一张表只能有一个主键,但可以由多个列组成复合主键。唯一约束则允许表内存在多个,它可以保证某一列或某几列的值不重复。比如用户表的邮箱字段适合加唯一约束,避免两个用户使用同一个邮箱注册。
域完整性约束的是单个字段的取值范围和格式。NOT NULL限制列不能为空,DEFAULT为列提供缺省值,CHECK约束则通过布尔表达式限制允许写入的值。不同数据库对CHECK的支持程度有差异,MySQL从8.0.16版本开始真正强制执行CHECK约束,之前版本虽然能解析但不会校验,这是很多老系统迁移时容易忽略的坑。
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
age INT CHECK (age >= 18 AND age <= 120),
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);上面的建表语句集中展示了主键、唯一、非空、默认值和CHECK约束。执行INSERT或UPDATE时,数据库会逐条检查这些条件,任何一项不满足都会立即返回错误并拒绝写入。CHECK约束的表达式应当简单直接,避免在表达式中使用子查询,因为多数数据库不允许CHECK引用其他表,而且复杂表达式会降低写入性能。对于取值范围会变化的枚举,更推荐单独建字典表并通过外键引用,而不是把枚举值硬编码到CHECK约束里。
引用完整性由外键约束保证,它的作用是维护表与表之间的关系。订单表中的user_id应当指向用户表中真实存在的id,如果没有外键约束,删除用户后订单就会变成孤儿数据。外键可以指定删除和更新时的级联行为,如RESTRICT、CASCADE、SET NULL。其中RESTRICT会阻止父表删除仍然被引用的记录,这是防止误删数据的常见选择。
ALTER TABLE orders
ADD CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE RESTRICT
ON UPDATE CASCADE;外键约束不是没有代价的。写入子表时,数据库需要到父表检查对应键是否存在,这会在父表上产生共享锁或一致性读开销。对于超高并发写入的子表,有些人会选择去掉外键,把引用检查放到应用层。这种方案虽然能减轻数据库压力,但必须配合严格的发布流程和定期数据对账,否则失去数据库最后一道防线后,脏数据可能从非常隐蔽的路径进入系统。
二、触发器补充复杂业务规则
声明式约束只能表达单行或简单表间条件,遇到跨行计算、多表状态联动等规则时,触发器是更合适的工具。触发器可以在INSERT、UPDATE、DELETE之前或之后执行,通过NEW和OLD引用将要写入或已经存在的行数据。在MySQL中,BEFORE触发器里对NEW字段赋值还可以修改即将落盘的数据,而AFTER触发器更适合做审计日志或异步通知。
一个常见的触发器场景是防止订单金额超过用户可用余额。这类校验涉及users表和orders表,CHECK约束无法跨表表达,但可以通过BEFORE INSERT触发器完成。
DELIMITER //
CREATE TRIGGER trg_order_amount_check
BEFORE INSERT ON orders
FOR EACH ROW
BEGIN
DECLARE user_balance DECIMAL(10,2);
SELECT balance INTO user_balance
FROM users
WHERE id = NEW.user_id;
IF user_balance IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '用户不存在';
END IF;
IF NEW.amount <= 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '订单金额必须大于0';
END IF;
IF NEW.amount > user_balance THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余额不足';
END IF;
END//
DELIMITER ;这段触发器在订单写入前查询用户余额,并依次检查用户是否存在、金额是否大于0、余额是否充足。SIGNAL语句用于主动抛出错误并终止当前语句,避免非法数据进入orders表。需要注意的是,触发器中的SELECT会锁住对应的用户行,如果并发提交同一用户的订单,可能会引起锁等待。因此触发器中不应放置耗时查询或访问热点表的大范围扫描。
触发器虽然灵活,但也有明显的缺点。它隐藏在表定义背后,排查问题时很容易被忽略;如果多个触发器之间存在级联关系,一次写入可能触发一连串动作,导致行为难以预测。调试触发器通常需要借助日志表或临时表记录中间状态,成本比调试普通SQL更高。建议只在约束确实无法表达的规则上使用触发器,并保持单个触发器逻辑短小、职责单一。
三、事务与存储过程保证一致性兜底
数据完整性不仅包括字段取值合法,还包括多步操作之间的原子性。例如用户下单后要同时扣减库存和增加订单记录,如果只成功了一半,数据库就会处于不一致状态。事务通过START TRANSACTION、COMMIT和ROLLBACK把多条SQL绑定为一个逻辑单元,任何一步失败都能整体回滚,从而避免部分写入。
START TRANSACTION; INSERT INTO orders (user_id, amount, status) VALUES (1001, 299.00, 'pending'); UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 2001 AND quantity > 0; COMMIT;
上面的示例假定扣减库存的UPDATE会检查quantity大于0,否则影响行数为0,但SQL语句本身不会报错。要实现更强的校验,需要在事务中获取受影响行数,如果为0则主动ROLLBACK。不同客户端语言获取受影响行数的方式不同,存储过程则可以在数据库内部完成判断,减少网络往返和逻辑分散。
存储过程适合封装一组包含条件和异常处理的写入逻辑。它可以声明局部变量、使用游标和条件判断,并通过SIGNAL返回业务错误。相比在应用代码里拼SQL,存储过程使校验规则更靠近数据本身,即使多个应用方调用同一接口,也能执行相同的校验策略。
CREATE PROCEDURE create_order(
IN p_user_id BIGINT,
IN p_amount DECIMAL(10,2)
)
BEGIN
DECLARE user_count INT;
SELECT COUNT(*) INTO user_count
FROM users
WHERE id = p_user_id;
IF user_count = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '用户不存在';
END IF;
IF p_amount <= 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '金额必须大于0';
END IF;
INSERT INTO orders (user_id, amount, status)
VALUES (p_user_id, p_amount, 'pending');
END;存储过程也不是银弹。过度使用存储过程会把业务逻辑压进数据库,造成代码版本管理困难,测试和调试也不如应用层代码方便。实际架构中,多数团队选择把核心校验放在应用层,数据库层只保留约束和极少量的触发器或存储过程,作为最终兜底。
四、校验策略选择与常见误区
设计数据完整性方案时,优先级应当是:数据库原生约束优先于触发器,触发器优先于存储过程,存储过程优先于完全依赖应用层。原生约束由数据库引擎保证,无法被绕过,执行效率也最高。触发器能覆盖更多规则,但会带来隐式行为。存储过程可以作为对外接口统一校验,但不适合承载大量复杂分支。应用层校验最灵活,但任何绕过应用的写入路径都会成为漏洞。
实际工作中常见的一个误区是把所有规则都写进数据库,认为约束越多越安全。这样做的后果是写入路径变得非常脆弱,任何字段变更或规则调整都需要修改表结构甚至重建表。另一个误区是完全不在数据库层加约束,理由是避免性能损耗。随着硬件和数据库优化器的发展,外键和CHECK约束的开销在大多数业务场景下可以接受,而脏数据带来的对账和修复成本往往远高于这一点性能损失。
批量导入数据时,有人会临时关闭外键检查和唯一性检查来提升速度,但如果导入完成后忘记重新开启或没有做补偿校验,就会绕过数据库层的保护。MySQL中关闭外键检查使用SET FOREIGN_KEY_CHECKS = 0,导入后应恢复为1,并执行一次反向查询确认没有孤儿数据。类似地,某些数据库支持禁用触发器,也需要在维护窗口内谨慎使用。
最后要强调,数据完整性校验不能只停留在写入那一刻。已经存在的历史数据可能早于约束创建,或者在约束暂时关闭时进入系统。因此建立约束后,应当定期运行校验查询,检查是否存在NULL、重复值、超出CHECK范围或孤儿引用。对于大规模表,可以按分区或时间分片执行,避免长时间锁表。只有把声明式约束、过程式规则、事务边界和周期性数据审计结合起来,数据库才能持续保持高质量的数据状态。

