SQL Server 开发系列(第七期):触发器与约束——数据完整性的守护者

SQL Server 开发系列(第七期):触发器与约束------数据完整性的守护者

上一期我们学习了存储过程与函数。这一期,我们进入数据完整性保护的最后一公里------触发器和约束。你是否遇到过:业务逻辑分散在多个应用导致数据不一致?想记录数据变更历史却无处下手?需要实现复杂的跨表验证却不知道用什么工具?约束和触发器是数据库层面的守护者,把数据完整性规则固化在数据库内,比依赖应用层更可靠。

一、约束 vs 触发器:核心区别

维度 约束 触发器
执行时机 语句执行期间 语句执行前后
性能 快(元数据级别) 慢(额外操作)
跨表验证 ❌ 不支持 ✅ 支持
复杂逻辑 ❌ 仅简单检查 ✅ 任意 T-SQL
审计日志 ❌ 不支持 ✅ 支持
业务规则 基本完整性 复杂业务逻辑
推荐度 ✅ 优先使用 ⚠️ 最后手段

黄金法则:能用约束解决的,绝不用触发器。

sql 复制代码
-- 约束示例(声明式)
CREATE TABLE Orders (
    OrderID INT PRIMARY KEY,
    CustomerID INT NOT NULL,
    Amount DECIMAL CHECK (Amount >= 0),  -- 检查约束
    OrderDate DATETIME2 DEFAULT GETDATE(),
    Status VARCHAR(20) DEFAULT 'Pending',
    CONSTRAINT FK_Orders_Customers FOREIGN KEY (CustomerID) REFERENCES Customers(CustomerID)
);

-- 触发器示例(过程式,当约束无法满足时使用)
CREATE TRIGGER trg_Orders_Audit
ON Orders
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
    INSERT INTO AuditLog (TableName, Action, OldData, NewData, ChangeTime)
    SELECT 'Orders', 'UPDATE', ..., GETDATE()
    FROM inserted, deleted
END

二、五种约束详解

2.1 主键约束(PRIMARY KEY)

sql 复制代码
-- 单列主键
CREATE TABLE Products (
    ProductID INT PRIMARY KEY,
    ProductName NVARCHAR(100)
);

-- 复合主键
CREATE TABLE OrderDetails (
    OrderID INT,
    ProductID INT,
    Quantity INT,
    PRIMARY KEY (OrderID, ProductID)
);

-- 事后添加
ALTER TABLE Products ADD CONSTRAINT PK_Products PRIMARY KEY (ProductID);

性能要点

  • 主键默认创建聚集索引(除非指定 NONCLUSTERED)
  • 主键值不应更新(否则所有非聚集索引都要更新)

2.2 外键约束(FOREIGN KEY)

sql 复制代码
-- 基础外键
CREATE TABLE Orders (
    OrderID INT PRIMARY KEY,
    CustomerID INT FOREIGN KEY REFERENCES Customers(CustomerID)
);

-- 级联操作
CREATE TABLE Orders (
    OrderID INT PRIMARY KEY,
    CustomerID INT,
    FOREIGN KEY (CustomerID) REFERENCES Customers(CustomerID)
        ON DELETE CASCADE     -- 删除客户时级联删除订单
        ON UPDATE CASCADE     -- 更新客户ID时级联更新订单
);

-- 自引用外键(员工-经理关系)
CREATE TABLE Employees (
    EmployeeID INT PRIMARY KEY,
    ManagerID INT FOREIGN KEY REFERENCES Employees(EmployeeID)
);

级联操作选项

选项 行为 适用场景
NO ACTION 拒绝操作(默认) 防止孤儿数据
CASCADE 级联更新/删除 主子表强依赖
SET NULL 外键列设为 NULL 关系可选
SET DEFAULT 设为默认值 关系降级

2.3 唯一约束(UNIQUE)

sql 复制代码
-- 单列唯一
CREATE TABLE Users (
    UserID INT PRIMARY KEY,
    Email VARCHAR(100) UNIQUE,
    Phone VARCHAR(20)
);

-- 复合唯一
CREATE TABLE UserRoles (
    UserID INT,
    RoleID INT,
    CONSTRAINT UQ_UserRoles UNIQUE (UserID, RoleID)
);

-- 为 Phone 列创建唯一约束,但允许多个 NULL
CREATE UNIQUE NONCLUSTERED INDEX UQ_Users_Phone 
ON Users(Phone) WHERE Phone IS NOT NULL;  -- SQL Server 2008+ 筛选唯一索引

唯一约束 vs 唯一索引

  • 唯一约束 = 唯一索引 + 元数据标识
  • 性能相同,但唯一约束更符合声明式设计

2.4 检查约束(CHECK)

sql 复制代码
-- 范围检查
CREATE TABLE Products (
    ProductID INT PRIMARY KEY,
    Price DECIMAL CHECK (Price >= 0),
    Quantity INT CHECK (Quantity BETWEEN 0 AND 10000),
    Status VARCHAR(20) CHECK (Status IN ('Active', 'Inactive', 'Discontinued'))
);

-- 跨列检查
CREATE TABLE Orders (
    OrderID INT PRIMARY KEY,
    Discount DECIMAL,
    Amount DECIMAL,
    CONSTRAINT CHK_Discount CHECK (Discount <= Amount * 0.5)
);

-- 复杂表达式
CREATE TABLE Persons (
    BirthDate DATE,
    CHECK (DATEDIFF(YEAR, BirthDate, GETDATE()) BETWEEN 0 AND 120)
);

2.5 默认约束(DEFAULT)

sql 复制代码
CREATE TABLE Orders (
    OrderID INT PRIMARY KEY,
    OrderDate DATETIME2 DEFAULT GETDATE(),
    Status VARCHAR(20) DEFAULT 'Pending',
    Amount DECIMAL DEFAULT 0,
    CreateUser VARCHAR(50) DEFAULT SUSER_NAME()
);

三、触发器深度解析

3.1 AFTER 触发器(最常用)

sql 复制代码
CREATE TRIGGER trg_Orders_AfterUpdate
ON Orders
AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    
    -- 检查是否真的有数据变化
    IF @@ROWCOUNT = 0 RETURN;
    
    -- 使用 inserted 和 deleted 虚拟表
    INSERT INTO AuditLog (TableName, Action, OrderID, OldAmount, NewAmount, ChangeTime)
    SELECT 
        'Orders',
        'UPDATE',
        COALESCE(i.OrderID, d.OrderID),
        d.Amount,
        i.Amount,
        GETDATE()
    FROM inserted i
    FULL JOIN deleted d ON i.OrderID = d.OrderID
    WHERE ISNULL(i.Amount, 0) <> ISNULL(d.Amount, 0);  -- 只记录实际变化的行
END

触发器的虚拟表

操作 inserted deleted
INSERT 新行
UPDATE 新值 旧值
DELETE 删除的行

3.2 INSTEAD OF 触发器

sql 复制代码
-- 在视图上实现 INSERT(视图涉及多表)
CREATE TRIGGER trg_OrderView_Insert
ON OrderView
INSTEAD OF INSERT
AS
BEGIN
    -- 插入主表
    INSERT INTO Orders (OrderDate, CustomerID)
    SELECT OrderDate, CustomerID FROM inserted;
    
    -- 插入明细表
    INSERT INTO OrderDetails (OrderID, ProductID, Quantity)
    SELECT SCOPE_IDENTITY(), ProductID, Quantity FROM inserted;
END

-- 阻止特定时间的删除
CREATE TRIGGER trg_Orders_InsteadOfDelete
ON Orders
INSTEAD OF DELETE
AS
BEGIN
    IF DATEPART(HOUR, GETDATE()) BETWEEN 22 AND 23
    BEGIN
        RAISERROR('22-23点禁止删除订单', 16, 1);
        RETURN;
    END
    
    -- 允许删除
    DELETE FROM Orders WHERE OrderID IN (SELECT OrderID FROM deleted);
END

3.3 DDL 触发器(数据库级、服务器级)

sql 复制代码
-- 禁止删除表
CREATE TRIGGER trg_PreventDropTable
ON DATABASE
FOR DROP_TABLE
AS
BEGIN
    PRINT '禁止删除表!';
    ROLLBACK;
END

-- 审计 DDL 操作
CREATE TRIGGER trg_AuditDDL
ON DATABASE
FOR CREATE_TABLE, ALTER_TABLE, DROP_TABLE
AS
BEGIN
    INSERT INTO DDLAudit (EventType, ObjectName, SQLText, LoginName, EventTime)
    VALUES (
        EVENTDATA().value('(/EVENT_INSTANCE/EventType)[1]', 'VARCHAR(100)'),
        EVENTDATA().value('(/EVENT_INSTANCE/ObjectName)[1]', 'VARCHAR(255)'),
        EVENTDATA().value('(/EVENT_INSTANCE/TSQLCommand)[1]', 'VARCHAR(MAX)'),
        SUSER_NAME(),
        GETDATE()
    );
END

3.4 触发器的重要设置

sql 复制代码
-- 查看触发器选项
SELECT 
    name,
    is_instead_of_trigger,
    is_disabled,
    is_not_for_replication
FROM sys.triggers
WHERE parent_id = OBJECT_ID('Orders');

-- 禁用/启用触发器
DISABLE TRIGGER trg_Orders_Audit ON Orders;
ENABLE TRIGGER trg_Orders_Audit ON Orders;

-- 设置嵌套触发器(服务器级别)
EXEC sp_configure 'nested triggers', 0;  -- 0=禁止嵌套,1=允许(默认)
RECONFIGURE;

-- 设置递归触发器(数据库级别)
ALTER DATABASE YourDB SET RECURSIVE_TRIGGERS ON;

四、实战场景

4.1 审计日志(最常用)

sql 复制代码
-- 完整的审计触发器模板
CREATE TABLE AuditLog (
    AuditID BIGINT IDENTITY PRIMARY KEY,
    TableName VARCHAR(100),
    Action VARCHAR(10),
    RecordID VARCHAR(100),
    OldData NVARCHAR(MAX),
    NewData NVARCHAR(MAX),
    ChangeUser VARCHAR(100),
    ChangeTime DATETIME2,
    HostName VARCHAR(100),
    AppName VARCHAR(100)
);

CREATE TRIGGER trg_Products_Audit
ON Products
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
    SET NOCOUNT ON;
    
    DECLARE @Action VARCHAR(10);
    DECLARE @OldData NVARCHAR(MAX);
    DECLARE @NewData NVARCHAR(MAX);
    DECLARE @RecordID VARCHAR(100);
    
    IF EXISTS (SELECT * FROM inserted) AND EXISTS (SELECT * FROM deleted)
        SET @Action = 'UPDATE';
    ELSE IF EXISTS (SELECT * FROM inserted)
        SET @Action = 'INSERT';
    ELSE
        SET @Action = 'DELETE';
    
    -- 获取受影响的第一条记录(生产环境需循环或使用游标)
    SELECT TOP 1 
        @RecordID = CAST(COALESCE(i.ProductID, d.ProductID) AS VARCHAR),
        @NewData = (SELECT * FROM inserted FOR JSON AUTO),
        @OldData = (SELECT * FROM deleted FOR JSON AUTO)
    FROM (SELECT 1 AS dummy) dummy
    LEFT JOIN inserted i ON 1=0
    LEFT JOIN deleted d ON 1=0;
    
    INSERT INTO AuditLog (TableName, Action, RecordID, OldData, NewData, ChangeUser, ChangeTime, HostName, AppName)
    VALUES ('Products', @Action, @RecordID, @OldData, @NewData, 
            SUSER_NAME(), GETDATE(), HOST_NAME(), APP_NAME());
END

4.2 复杂业务规则

sql 复制代码
-- 防止超额订购
CREATE TRIGGER trg_OrderDetails_CheckStock
ON OrderDetails
AFTER INSERT, UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    
    IF EXISTS (
        SELECT 1
        FROM inserted i
        JOIN Products p ON i.ProductID = p.ProductID
        WHERE i.Quantity > p.Stock
    )
    BEGIN
        RAISERROR('订购数量超过库存', 16, 1);
        ROLLBACK;
        RETURN;
    END
    
    -- 扣减库存(也可在 AFTER 中做)
    UPDATE p
    SET Stock = Stock - i.Quantity
    FROM Products p
    JOIN inserted i ON p.ProductID = i.ProductID;
END

4.3 数据同步(替代方案)

sql 复制代码
-- 触发器同步到历史表
CREATE TRIGGER trg_Orders_SyncToHistory
ON Orders
AFTER UPDATE
AS
BEGIN
    -- 将修改前的数据存入历史表
    INSERT INTO OrdersHistory (OrderID, CustomerID, Amount, Status, ModifiedDate)
    SELECT OrderID, CustomerID, Amount, Status, GETDATE()
    FROM deleted;
END

五、性能陷阱与优化

5.1 触发器性能问题

问题 后果 解决方案
大量数据更新 触发器逐行处理 基于集合操作,不要使用游标
嵌套触发器 性能指数下降 禁用不必要的嵌套
递归触发器 无限循环 谨慎使用,设置最大递归深度
触发器中的事务 长事务锁表 尽量减少触发器内逻辑
日志膨胀 大量审计数据 异步写入或采样记录
sql 复制代码
-- ❌ 坏:触发器中的游标
CREATE TRIGGER trg_Bad ON Orders AFTER UPDATE
AS
BEGIN
    DECLARE cur CURSOR FOR SELECT OrderID FROM inserted;
    OPEN cur;
    FETCH NEXT...
    -- 逐行处理 10000 行 → 极慢
END

-- ✅ 好:基于集合
CREATE TRIGGER trg_Good ON Orders AFTER UPDATE
AS
BEGIN
    INSERT INTO AuditLog (OrderID, OldStatus, NewStatus)
    SELECT i.OrderID, d.Status, i.Status
    FROM inserted i
    JOIN deleted d ON i.OrderID = d.OrderID
    WHERE i.Status <> d.Status;  -- 只处理实际变化的行
END

5.2 避免递归触发器

sql 复制代码
-- 场景:更新订单状态时自动更新客户等级,客户等级变化又触发订单更新 → 死循环

-- 解决方案1:禁用递归触发器
ALTER DATABASE YourDB SET RECURSIVE_TRIGGERS OFF;

-- 解决方案2:使用 TRIGGER_NESTLEVEL 检测
CREATE TRIGGER trg_UpdateCustomerLevel
ON Orders
AFTER UPDATE
AS
BEGIN
    IF TRIGGER_NESTLEVEL() > 1 RETURN;  -- 避免递归
    -- 正常逻辑
END

六、约束 vs 触发器:如何选择?

sql 复制代码
-- ✅ 使用约束的场景
-- 1. 非空、唯一、主键
-- 2. 简单取值范围(CHECK)
-- 3. 外键关系

-- ⚠️ 使用触发器的场景
-- 1. 审计日志
-- 2. 复杂跨表验证
-- 3. 级联更新(外键级联无法满足时)
-- 4. 视图上的 DML 操作
-- 5. DDL 操作审计

-- ❌ 不应使用触发器的场景
-- 1. 可用约束替代(如简单 CHECK)
-- 2. 大批量 ETL 操作(触发器会大幅降低速度)
-- 3. 逻辑可以放在应用层或存储过程

七、监控触发器

sql 复制代码
-- 查看所有触发器
SELECT 
    OBJECT_NAME(parent_id) AS TableName,
    name AS TriggerName,
    is_instead_of_trigger,
    is_disabled,
    OBJECT_DEFINITION(object_id) AS TriggerCode
FROM sys.triggers
WHERE parent_class_desc = 'OBJECT_OR_COLUMN';

-- 查看触发器执行统计
SELECT 
    OBJECT_NAME(st.objectid) AS TriggerName,
    st.execution_count,
    st.total_worker_time / st.execution_count AS avg_cpu_us,
    st.total_logical_reads / st.execution_count AS avg_logical_reads
FROM sys.dm_exec_trigger_stats st
WHERE st.database_id = DB_ID()
ORDER BY st.total_worker_time DESC;

-- 查看触发器的依赖关系
SELECT 
    referencing.name AS TriggerName,
    referenced.name AS ReferencedTable
FROM sys.sql_expression_dependencies d
JOIN sys.objects referencing ON d.referencing_id = referencing.object_id
JOIN sys.objects referenced ON d.referenced_id = referenced.object_id
WHERE referencing.type = 'TR';

八、核心总结

知识点 核心要点
优先使用约束 性能好、声明式、易维护
触发器慎用 性能差、调试难、隐蔽性强
AFTER 触发器 最常用,用于审计和附加逻辑
INSTEAD OF 用于视图、特殊拦截逻辑
inserted/deleted 虚拟表,需基于集合操作
递归/嵌套 可能导致性能问题或死循环
审计日志 触发器的经典场景

一句话记住本期内容

约束是数据完整性的第一道防线,触发器是最后的手段------能用 CHECK 不用触发器,能用外键不用触发器,只在审计、复杂跨表验证时才考虑触发器。

动手练习

sql 复制代码
-- 问题1:如何阻止周末删除订单数据?

-- 问题2:设计一个触发器,当订单金额超过 10000 时,自动将订单状态设为 'VIP',并发送通知到 Notification 表。

-- 问题3:下面的触发器有什么问题?
CREATE TRIGGER trg_UpdateStock
ON OrderDetails
AFTER INSERT
AS
BEGIN
    DECLARE @ProductID INT, @Quantity INT
    DECLARE cur CURSOR FOR SELECT ProductID, Quantity FROM inserted
    OPEN cur
    FETCH NEXT FROM cur INTO @ProductID, @Quantity
    WHILE @@FETCH_STATUS = 0
    BEGIN
        UPDATE Products SET Stock = Stock - @Quantity WHERE ProductID = @ProductID
        FETCH NEXT FROM cur INTO @ProductID, @Quantity
    END
    CLOSE cur
    DEALLOCATE cur
END

点击查看参考答案

问题1

sql 复制代码
CREATE TRIGGER trg_PreventWeekendDelete
ON Orders
INSTEAD OF DELETE
AS
BEGIN
    IF DATEPART(WEEKDAY, GETDATE()) IN (1, 7)  -- 周日=1, 周六=7(取决于 SET DATEFIRST)
    BEGIN
        RAISERROR('周末禁止删除订单', 16, 1);
        RETURN;
    END
    DELETE FROM Orders WHERE OrderID IN (SELECT OrderID FROM deleted);
END

问题2

sql 复制代码
CREATE TRIGGER trg_Orders_VIPCheck
ON Orders
AFTER INSERT, UPDATE
AS
BEGIN
    INSERT INTO Notifications (OrderID, Message, CreateTime)
    SELECT OrderID, '订单金额超10000,已自动升级为VIP订单', GETDATE()
    FROM inserted
    WHERE Amount > 10000;
    
    UPDATE Orders SET Status = 'VIP' WHERE OrderID IN (SELECT OrderID FROM inserted WHERE Amount > 10000);
END

问题3:问题:使用游标逐行处理,性能极差。优化:

sql 复制代码
CREATE TRIGGER trg_UpdateStock_Optimized
ON OrderDetails
AFTER INSERT
AS
BEGIN
    UPDATE p
    SET Stock = Stock - i.Quantity
    FROM Products p
    JOIN (SELECT ProductID, SUM(Quantity) AS Quantity FROM inserted GROUP BY ProductID) i
        ON p.ProductID = i.ProductID;
END

下一期预告

事务与并发控制(开发视角)------ 避免死锁与数据异常

  • 乐观锁 vs 悲观锁的实现
  • 应用层处理死锁的重试逻辑
  • 隔离级别的开发选择指南
  • 使用行版本控制避免读写阻塞

📌 本文代码已在 SQL Server 2019+ 验证。触发器虽强大,但会带来维护和调试的复杂性,建议优先考虑约束和存储过程。

本系列持续更新中,点击关注不错过第八期(开发系列收官之作)。

相关推荐
21439652 小时前
持久化存储如何适配不同浏览器?解决隐私模式下存储失败的指南
jvm·数据库·python
justjinji2 小时前
c++怎么读取大端序设备的固件bin文件_字节反转与位移操作【详解】
jvm·数据库·python
m0_515098422 小时前
如何处理视图中的Definer_视图创建者权限变更对视图有效性的影响
jvm·数据库·python
2401_883600252 小时前
如何创建物化视图_CREATE MATERIALIZED VIEW基本语法与数据填充
jvm·数据库·python
xxjj998a2 小时前
MySQL无法连接到本地localhost的解决办法2024.11.8
数据库·mysql·adb
电商API_180079052472 小时前
京东商品详情接口返回数据说明API调用示例
数据库·性能优化·数据挖掘·数据分析·网络爬虫
Elivs.Xiang2 小时前
Redis - Docker环境下的持久化、主从复制、哨兵、集群、淘汰策略
数据库·redis·docker
u0109147602 小时前
JavaScript中类的装饰器提案在属性与方法上的应用
jvm·数据库·python
Absurd5872 小时前
Laravel Eloquent 中精准检测时间区间重叠的完整实践指南
jvm·数据库·python