喜忧参半的SQL Server触发器

SQL Server触发器在很是有争议的主题。它们能以较低的成本提供便利,但常常被开发人员、DBA误用,致使性能瓶颈或维护性挑战。sql

本文简要回顾了触发器,并深刻讨论了如何有效地使用触发器,以及什么时候触发器会使开发人员陷入难以逃脱的困境。数据库

虽然本文中的全部演示都是在SQL Server中进行的,但这里提供的建议是大多数数据库通用的。触发器带来的挑战在MySQL、PostgreSQL、MongoDB和许多其余应用中也能够看到。安全

什么是触发器服务器

能够在数据库或表上定义SQL Server触发器,它容许代码在发生特定操做时自动执行。本文主要关注表上的DML触发器,由于它们每每被过分使用。相反,数据库的DDL触发器一般更集中,对性能的危害更小。网络

触发器是对表中数据更改时进行计算的一组代码。触发器能够定义为在插入、更新、删除或这些操做的任何组合上执行。MERGE操做能够触发语句中每一个操做的触发器。数据结构

触发器能够定义为INSTEAD OF或AFTER。AFTER触发器发生在数据写入表以后,是一组独立的操做,和写入表的操做在同一事务执行,但在写入发生以后执行。若是触发器失败,原始操做也会失败。INSTEAD OF触发器替换调用的写操做。插入、更新或删除操做永远不会发生,而是执行触发器的内容。架构

触发器容许在发生写操做时执行TSQL,而无论这些写操做的来源是什么。它们一般用于在但愿确保执行写操做时运行关键操做,如日志记录、验证或其余DML。这很方便,写操做能够来自API、应用程序代码、发布脚本,或者内部流程,触发器不管如何都会触发。app

触发器是什么样的less

用WideWorldImporters示例数据库中的Sales.Orders 表举例,假设须要记录该表上的全部更新或删除操做,以及有关更改发生的一些细节。这个操做能够经过修改代码来完成,可是这样作须要对表的代码写入中的每一个位置进行更改。经过触发器解决这一问题,能够采起如下步骤:ide

1. 建立一个日志表来接受写入的数据。下面的TSQL建立了一个简单日志表,以及一些添加的数据点:

CREATE TABLE Sales.Orders_log
( Orders_log_ID int NOT NULL IDENTITY(1,1) 
 CONSTRAINT PK_Sales_Orders_log PRIMARY KEY CLUSTERED,
 OrderID int NOT NULL,
 CustomerID_Old int NOT NULL,
 CustomerID_New int NOT NULL,
 SalespersonPersonID_Old int NOT NULL,
 SalespersonPersonID_New int NOT NULL,
 PickedByPersonID_Old int NULL,
 PickedByPersonID_New int NULL,
 ContactPersonID_Old int NOT NULL,
 ContactPersonID_New int NOT NULL,
 BackorderOrderID_Old int NULL,
 BackorderOrderID_New int NULL,
 OrderDate_Old date NOT NULL,
 OrderDate_New date NOT NULL,
 ExpectedDeliveryDate_Old date NOT NULL,
 ExpectedDeliveryDate_New date NOT NULL,
 CustomerPurchaseOrderNumber_Old nvarchar(20) NULL,
 CustomerPurchaseOrderNumber_New nvarchar(20) NULL,
 IsUndersupplyBackordered_Old bit NOT NULL,
 IsUndersupplyBackordered_New bit NOT NULL,
 Comments_Old nvarchar(max) NULL,
 Comments_New nvarchar(max) NULL,
 DeliveryInstructions_Old nvarchar(max) NULL,
 DeliveryInstructions_New nvarchar(max) NULL,
 InternalComments_Old nvarchar(max) NULL,
 InternalComments_New nvarchar(max) NULL,
 PickingCompletedWhen_Old datetime2(7) NULL,
 PickingCompletedWhen_New datetime2(7) NULL,
 LastEditedBy_Old int NOT NULL,
 LastEditedBy_New int NOT NULL,
 LastEditedWhen_Old datetime2(7) NOT NULL,
 LastEditedWhen_New datetime2(7) NOT NULL,
 ActionType VARCHAR(6) NOT NULL,
 ActionTime DATETIME2(3) NOT NULL,
UserName VARCHAR(128) NULL);

该表记录全部列的旧值和新值。这是很是全面的,咱们能够简单地记录旧版本的行,并可以经过将新版本和旧版本合并在一块儿来了解更改的过程。最后3列是新增的,提供了有关执行的操做类型(插入、更新或删除)、时间和操做人。

2. 建立一个触发器来记录表的更改:

CREATE TRIGGER TR_Sales_Orders_Audit
 ON Sales.Orders
 AFTER INSERT, UPDATE, DELETE
AS
BEGIN
 SET NOCOUNT ON;
 INSERT INTO Sales.Orders_log
 (OrderID, CustomerID_Old, CustomerID_New, 
 SalespersonPersonID_Old, SalespersonPersonID_New, 
 PickedByPersonID_Old, PickedByPersonID_New,
 ContactPersonID_Old, ContactPersonID_New, 
 BackorderOrderID_Old, BackorderOrderID_New, 
 OrderDate_Old, OrderDate_New, ExpectedDeliveryDate_Old,
 ExpectedDeliveryDate_New, 
 CustomerPurchaseOrderNumber_Old, 
 CustomerPurchaseOrderNumber_New, 
 IsUndersupplyBackordered_Old, 
 IsUndersupplyBackordered_New,
 Comments_Old, Comments_New, 
 DeliveryInstructions_Old, DeliveryInstructions_New, 
 InternalComments_Old, InternalComments_New, 
 PickingCompletedWhen_Old,
 PickingCompletedWhen_New, LastEditedBy_Old, 
 LastEditedBy_New, LastEditedWhen_Old, 
 LastEditedWhen_New, ActionType, ActionTime, UserName)
 SELECT
 ISNULL(Inserted.OrderID, Deleted.OrderID) AS OrderID,
 Deleted.CustomerID AS CustomerID_Old,
 Inserted.CustomerID AS CustomerID_New,
 Deleted.SalespersonPersonID AS SalespersonPersonID_Old,
 Inserted.SalespersonPersonID AS SalespersonPersonID_New,
 Deleted.PickedByPersonID AS PickedByPersonID_Old,
 Inserted.PickedByPersonID AS PickedByPersonID_New,
 Deleted.ContactPersonID AS ContactPersonID_Old,
 Inserted.ContactPersonID AS ContactPersonID_New,
 Deleted.BackorderOrderID AS BackorderOrderID_Old,
 Inserted.BackorderOrderID AS BackorderOrderID_New,
 Deleted.OrderDate AS OrderDate_Old,
 Inserted.OrderDate AS OrderDate_New,
 Deleted.ExpectedDeliveryDate 
 AS ExpectedDeliveryDate_Old,
 Inserted.ExpectedDeliveryDate 
 AS ExpectedDeliveryDate_New,
 Deleted.CustomerPurchaseOrderNumber 
 AS CustomerPurchaseOrderNumber_Old,
 Inserted.CustomerPurchaseOrderNumber 
 AS CustomerPurchaseOrderNumber_New,
 Deleted.IsUndersupplyBackordered 
 AS IsUndersupplyBackordered_Old,
 Inserted.IsUndersupplyBackordered 
 AS IsUndersupplyBackordered_New,
 Deleted.Comments AS Comments_Old,
 Inserted.Comments AS Comments_New,
 Deleted.DeliveryInstructions 
 AS DeliveryInstructions_Old,
 Inserted.DeliveryInstructions 
 AS DeliveryInstructions_New,
 Deleted.InternalComments AS InternalComments_Old,
 Inserted.InternalComments AS InternalComments_New,
 Deleted.PickingCompletedWhen 
 AS PickingCompletedWhen_Old,
 Inserted.PickingCompletedWhen 
 AS PickingCompletedWhen_New,
 Deleted.LastEditedBy AS LastEditedBy_Old,
 Inserted.LastEditedBy AS LastEditedBy_New,
 Deleted.LastEditedWhen AS LastEditedWhen_Old,
 Inserted.LastEditedWhen AS LastEditedWhen_New,
 CASE
 WHEN Inserted.OrderID IS NULL THEN 'DELETE'
 WHEN Deleted.OrderID IS NULL THEN 'INSERT'
 ELSE 'UPDATE'
 END AS ActionType,
 SYSUTCDATETIME() ActionTime,
 SUSER_SNAME() AS UserName
 FROM Inserted
 FULL JOIN Deleted
 ON Inserted.OrderID = Deleted.OrderID;
END

该触发器的惟一功能是将数据插入到日志表中,每行数据对应一个给定的写操做。它很简单,随着时间的推移易于记录和维护,表也会发生变化。若是须要跟踪其余详细信息,能够添加其余列,如数据库名称、服务器名称、受影响列的行数或调用的应用程序。

3.最后一步是测试和验证日志表是否正确。

如下是添加触发器后对表进行更新的测试:

UPDATE Orders
 SET InternalComments = 'Item is no longer backordered',
 BackorderOrderID = NULL,
 IsUndersupplyBackordered = 0,
 LastEditedBy = 1,
 LastEditedWhen = SYSUTCDATETIME()
FROM sales.Orders
WHERE Orders.OrderID = 10;

结果以下:

上面省略了一些列,可是咱们能够快速确认已经触发了更改,包括日志表末尾新增的列。

INSERT和DELETE

前面的示例中,进行插入和删除操做后,读取日志表中使用的数据。这种特殊的表能够做为任何相关写操做的一部分。INSERT将包含被插入操做触发,DELETE将被删除操做触发,UPDATE包含被插入和删除操做触发。

对于INSERT和UPDATE,将包含表中每一个列新值的快照。对于DELETE和UPDATE操做,将包含写操做以前表中每一个列旧值的快照。

触发器何时最有用

DML触发器的最佳使用是简短、简单且易于维护的写操做,这些操做在很大程度上独立于应用程序业务逻辑。

  • 触发器的一些重要用途包括:
  • 记录对历史表的更改
  • 审计用户及其对敏感表的操做。
  • 向表中添加应用程序可能没法使用的额外值(因为安全限制或其余限制),例如:
    •  登陆/用户名
    •  操做发生时间
    • 服务器/数据库名称
  • 简单的验证。

关键是让触发器代码保持足够的紧凑,从而便于维护。当触发器增加到成千上万行时,它们就成了开发人员不敢去打扰的黑盒。结果,更多的代码被添加进来,可是旧的代码不多被检查。即便有了文档,这也很难维护。

为了让触发器有效地发挥做用,应该将它们编写为基于设置的。若是存储过程必须在触发器中使用,则确保它们在须要时使用表值参数,以即可以基于集的方式移动数据。下面是一个触发器的示例,该触发器遍历id,以便使用结果顺序id执行示例存储过程:

CREATE TRIGGER TR_Sales_Orders_Process
 ON Sales.Orders
 AFTER INSERT
AS
BEGIN
 SET NOCOUNT ON;
 DECLARE @count INT;
 SELECT @count = COUNT(*) FROM inserted;
 DECLARE @min_id INT;
 SELECT @min_id = MIN(OrderID) FROM inserted;
 DECLARE @current_id INT = @min_id;
 WHILE @current_id < @current_id + @count
 BEGIN
 EXEC dbo.process_order_fulfillment 
 @OrderID = @current_id;
 SELECT @current_id = @current_id + 1;
 END
END

虽然相对简单,但当一次插入多行时对 Sales.Orders的INSERT操做的性能将受到影响,由于SQL Server在执行process_order_fulfillment存储过程时将被迫逐个执行。一个简单的修复方法是重写存储过程,并将一组Order id传递到存储过程当中,而不是一次一个地这样作:

CREATE TYPE dbo.udt_OrderID_List AS TABLE(
 OrderID INT NOT NULL,
 PRIMARY KEY CLUSTERED 
( OrderID ASC));
GO
CREATE TRIGGER TR_Sales_Orders_Process
 ON Sales.Orders
 AFTER INSERT
AS
BEGIN
 SET NOCOUNT ON;
 DECLARE @OrderID_List dbo.udt_OrderID_List;
 EXEC dbo.process_order_fulfillment @OrderIDs = @OrderID_List;
END

更改的结果是将完整的id集合从触发器传递到存储过程并进行处理。只要存储过程以基于集合的方式管理这些数据,就能够避免重复执行,也就是说,避免在触发器内使用存储过程有很大的价值,由于它们添加了额外的封装层,进一步隐藏了在数据写入表时执行的TSQL。它们应该被认为是最后的手段,只有当能够在应用程序的许多地方屡次重写TSQL时才使用。

何时触发器是危险的

架构师和开发人员面临的最大挑战之一是确保触发器只在须要时使用,而不容许它们成为一刀切的解决方案。向触发器添加TSQL一般被认为比向应用程序添加代码更快、更容易,但随着时间的推移,这样作的成本会随着每添加一行代码而增长。

触发器在如下状况下会变得危险:

  • 保持尽量少的触发以减小复杂性。
  • 触发代码变得复杂。若是更新表中的一行致使要执行数千行添加的触发器代码,那么开发人员就很难彻底理解数据写入表时会发生什么。更糟糕的是,当出现问题时,故障排除很是具备挑战性。
  • 触发器跨服务器。这将网络操做引入到触发器中,可能致使在出现链接问题时写入速度变慢或失败。若是目标数据库是要维护的对象,那么即便是跨数据库触发器也会有问题。
  • 触发器调用触发器。触发器中最使人痛苦的是,当插入一行时,写操做会致使75个表中有100个触发器要执行。在编写触发器代码时,确保触发器能够执行全部必要的逻辑,而不会触发更多触发器。额外的触发一般是没必要要的。
  • 递归触发器被设置为ON。这是一个默认设置为off的数据库级别设置。打开时,它容许触发器的内容调用相同的触发器。递归触发器会极大地损害性能,调试时也会很是混乱。一般,当一个触发器中的DML做为操做的一部分触发其余触发器时,使用递归触发器。
  • 函数、存储过程或视图都在触发器中。在触发器中封装更多的业务逻辑会使它们变得更复杂,并给人一种触发器代码短小简单的错误印象,而实际上并不是如此。尽量避免在触发器中使用存储过程和函数。
  • 迭代发生。循环和游标本质上是逐行操做的,可能会致使对1000行的操做一次触发1000次,这极大地损害了查询性能。

这是一个很长的列表,但一般能够总结为短而简单的触发器会表现得更好,并避免上面的大多数陷阱。若是使用触发器来维护复杂的业务逻辑,那么随着时间的推移,愈来愈多的业务逻辑将被添加进来,而且不可避免地将违反上述最佳实践。

重要的是要注意,为了维护原子的、事务,受触发器影响的任何对象都将保持事务处于打开状态,直到该触发器完成。这意味着长触发器不只会使事务持续时间更长,并且还会持有锁并致使持续时间更长。所以,在测试触发器时,在为现有触发器建立或添加额外逻辑时,应该了解它们对锁、阻塞和等待的影响。

如何改善触发器

有不少方法可使触发器更易于维护、更容易理解和性能更高。如下是一些关于如何有效管理触发器和避免落入陷阱的建议。

触发器自己应该有良好的文档记录:

  • 这个触发器为何存在?
  • 它能作什么?
  • 它是如何工做的?
  • 对于触发器的工做方式是否有任何例外或警告?

此外,若是触发器中的TSQL难以理解,那么能够添加内联注释,以帮助第一次查看它的开发人员。

下面是触发器文档的样例:

/* 12/29/2020 EHP
 This trigger logs all changes to the table to the Orders_log
 table that occur for non-internal customers.
 CustomerID = -1 signifies an internal/test customer and 
 these are not audited.
*/
CREATE TRIGGER TR_Sales_Orders_Audit
 ON Sales.Orders
 FOR INSERT, UPDATE, DELETE
AS
BEGIN
 SET NOCOUNT ON;
 INSERT INTO Sales.Orders_log
 (OrderID, CustomerID_Old, CustomerID_New, 
 SalespersonPersonID_Old, SalespersonPersonID_New,
 PickedByPersonID_Old, PickedByPersonID_New,
 ContactPersonID_Old, ContactPersonID_New, 
 BackorderOrderID_Old, BackorderOrderID_New, 
 OrderDate_Old, OrderDate_New, 
 ExpectedDeliveryDate_Old,
 ExpectedDeliveryDate_New, 
 CustomerPurchaseOrderNumber_Old, 
 CustomerPurchaseOrderNumber_New, 
 IsUndersupplyBackordered_Old, 
 IsUndersupplyBackordered_New,
 Comments_Old, Comments_New, 
 DeliveryInstructions_Old, DeliveryInstructions_New, 
 nternalComments_Old, InternalComments_New, 
 PickingCompletedWhen_Old, PickingCompletedWhen_New, 
 LastEditedBy_Old, LastEditedBy_New, 
 LastEditedWhen_Old, LastEditedWhen_New, 
 ActionType, ActionTime, UserName)
 SELECT
 ISNULL(Inserted.OrderID, Deleted.OrderID) AS OrderID, 
 -- The OrderID can never change. 
 --This ensures we get the ID correctly, 
 --regardless of operation type.
 Deleted.CustomerID AS CustomerID_Old,
 Inserted.CustomerID AS CustomerID_New,
 Deleted.SalespersonPersonID AS SalespersonPersonID_Old,
 Inserted.SalespersonPersonID AS SalespersonPersonID_New,
 Deleted.PickedByPersonID AS PickedByPersonID_Old,
 Inserted.PickedByPersonID AS PickedByPersonID_New,
 Deleted.ContactPersonID AS ContactPersonID_Old,
 Inserted.ContactPersonID AS ContactPersonID_New,
 Deleted.BackorderOrderID AS BackorderOrderID_Old,
 Inserted.BackorderOrderID AS BackorderOrderID_New,
 Deleted.OrderDate AS OrderDate_Old,
 Inserted.OrderDate AS OrderDate_New,
 Deleted.ExpectedDeliveryDate AS ExpectedDeliveryDate_Old,
 Inserted.ExpectedDeliveryDate AS ExpectedDeliveryDate_New,
 Deleted.CustomerPurchaseOrderNumber 
 AS CustomerPurchaseOrderNumber_Old,
 Inserted.CustomerPurchaseOrderNumber 
 AS CustomerPurchaseOrderNumber_New,
 Deleted.IsUndersupplyBackordered 
 AS IsUndersupplyBackordered_Old,
 Inserted.IsUndersupplyBackordered 
 AS IsUndersupplyBackordered_New,
 Deleted.Comments AS Comments_Old,
 Inserted.Comments AS Comments_New,
 Deleted.DeliveryInstructions 
 AS DeliveryInstructions_Old,
 Inserted.DeliveryInstructions 
 AS DeliveryInstructions_New,
 Deleted.InternalComments AS InternalComments_Old,
 Inserted.InternalComments AS InternalComments_New,
 Deleted.PickingCompletedWhen AS PickingCompletedWhen_Old,
 Inserted.PickingCompletedWhen 
 AS PickingCompletedWhen_New,
 Deleted.LastEditedBy AS LastEditedBy_Old,
 Inserted.LastEditedBy AS LastEditedBy_New,
 Deleted.LastEditedWhen AS LastEditedWhen_Old,
 Inserted.LastEditedWhen AS LastEditedWhen_New,
 CASE -- Determine the operation type based on whether 
 --Inserted exists, Deleted exists, or both exist.
 WHEN Inserted.OrderID IS NULL THEN 'DELETE'
 WHEN Deleted.OrderID IS NULL THEN 'INSERT'
 ELSE 'UPDATE'
 END AS ActionType,
 SYSUTCDATETIME() ActionTime,
 SUSER_SNAME() AS UserName
 FROM Inserted
 FULL JOIN Deleted
 ON Inserted.OrderID = Deleted.OrderID
 WHERE Inserted.CustomerID <> -1 
 -- -1 indicates an internal/non-production 
 --customer that should not be audited.
 OR Deleted.CustomerID <> -1; 
 -- -1 indicates an internal/non-production 
 --customer that should not be audited.
END

请注意,该文档并不全面,但包含了一个简短的头,并解释了触发器内的一些TSQL关键部分:

  • 排除CustomerID = -1的状况。这一点对于不知道的人来讲是不明显的,因此这是一个很好的注释。
  • ActionType的CASE语句用于什么。
  • 为何在插入和删除之间的OrderID列上使用ISNULL。

使用IF UPDATE

在触发器中,UPDATE提供了判断是否将数据写入给定列的能力。这能够容许触发器检查列在执行操做以前是否发生了更改。下面是该语法的示例:

CREATE TRIGGER TR_Sales_Orders_Log_BackorderID_Change
 ON Sales.Orders
 AFTER UPDATE
AS
BEGIN
 SET NOCOUNT ON;
 IF UPDATE(BackorderOrderID)
 BEGIN
 UPDATE OrderBackorderLog
 SET BackorderOrderID = Inserted.BackorderOrderID,
 PreviousBackorderOrderID = Deleted.BackorderOrderID
 FROM dbo.OrderBackorderLog
 INNER JOIN Inserted
 ON Inserted.OrderID = OrderBackorderLog.OrderID
 END
END

经过首先检查BackorderID是否被更新,触发器能够在不须要时绕事后续操做。这是一种提升性能的好方法,它容许触发器根据所需列的更新值彻底跳过代码。

COLUMNS_UPDATED指示表中的哪些列做为写操做的一部分进行了更新,能够在触发器中使用它来快速肯定指定的列是否受到插入或更新操做的影响。虽然有文档记录,但它使用起来很复杂,很难进行文档记录。我一般不建议使用它,由于它几乎确定会使不熟悉它的开发人员感到困惑。

请注意,对于UPDATE或COLUMNS_UPDATED,列是否更改并不重要。对列进行写操做,即便值没有改变,对于UPDATE操做仍然返回1,对于COLUMNS_UPDATED操做仍然返回1。它们只跟踪指定的列是不是写操做的目标,而不跟踪值自己是否改变。

每一个操做一个触发器

让触发代码尽量的简单。数据库表的触发器数量增加会大大增长表的复杂性,理解其操做变得更加困难。。

例如,考虑如下表触发器定义方式:

CREATE TRIGGER TR_Sales_Orders_I
 ON Sales.Orders
 AFTER INSERT
CREATE TRIGGER TR_Sales_Orders_IU
 ON Sales.Orders
 AFTER INSERT, UPDATE
CREATE TRIGGER TR_Sales_Orders_UD
 ON Sales.Orders
 AFTER UPDATE, DELETE
CREATE TRIGGER TR_Sales_Orders_UID
 ON Sales.Orders
 AFTER UPDATE, INSERT, DELETE
CREATE TRIGGER TR_Sales_Orders_ID
 ON Sales.Orders
 AFTER INSERT, DELETE

当插入一行时会发生什么?触发器的触发顺序是什么?这些问题的答案须要研究。维护更少的触发器是一个简单的解决方案,而且消除了对给定表中如何发生写操做的猜想。做为参考,可使用系统存储过程sp_settriggerorder修改触发器顺序,不过这只适用于AFTER触发器。

再简单一点

触发器的最佳实践是操做简单,执行迅速,而且不会由于它们的执行而触发更多的触发器。触发器的复杂程度并无明确的规则,但有一条简单的指导原则是,理想的触发器应该足够简单,若是必须将触发器中包含的逻辑移到其余地方,那么迁移的代价不会高得使人望而却步。也就是说,若是触发器中的业务逻辑很是复杂,以致于移动它的成本过高而没法考虑,那么这些触发器极可能变得过于复杂。

使用咱们前面的示例,考虑一下更改审计的触发器。这能够很容易地从触发器转移到存储过程或代码中,而这样作的工做量并不大。触发器中记录日志的方便性使它值得一作,但与此同时,咱们应该知道开发人员将TSQL从触发器迁移到另外一个位置须要多少小时。

时间的计算能够看做是触发器的可维护性成本的一部分。也就是说,若是有必要,为摆脱触发机制而必须付出的代价。这听起来可能很抽象,但平台之间的数据库迁移是很常见的。在SQL Server中执行良好的一组触发器在Oracle或PostgreSQL中可能并不有效。

优化表变量

有时,一个触发器中须要临时表,以容许对数据进行屡次更新。临时表存储在tempdb中,而且受到tempdb数据库大小、速度和性能约束的影响。

对于常常访问的临时表,优化表变量是在内存中(而不是在tempdb中)维护临时数据的好方法。

下面的TSQL为内存优化数据配置了一个数据库(若是须要):

ALTER DATABASE WideWorldImporters 
SET MEMORY_OPTIMIZED_ELEVATE_TO_SNAPSHOT = ON;
ALTER DATABASE WideWorldImporters ADD FILEGROUP WWI_InMemory_Data 
 CONTAINS MEMORY_OPTIMIZED_DATA;
ALTER DATABASE WideWorldImporters ADD FILE 
 (NAME='WideWorldImporters_IMOLTP_File_1', 
 FILENAME='C:\SQLData\WideWorldImporters_IMOLTP_File_1.mem') 
 TO FILEGROUP WWI_InMemory_Data;

一旦配置完成,就能够建立一个内存优化的表类型:

CREATE TYPE dbo.SalesOrderMetadata
AS TABLE
( OrderID INT NOT NULL PRIMARY KEY NONCLUSTERED,
 CustomerID INT NOT NULL,
 SalespersonPersonID INT NOT NULL,
 ContactPersonID INT NOT NULL,
 INDEX IX_SalesOrderMetadata_CustomerID NONCLUSTERED HASH 
 (CustomerID) WITH (BUCKET_COUNT = 1000))
WITH (MEMORY_OPTIMIZED = ON);

这个TSQL建立了演示的触发器所须要的表:

CREATE TABLE dbo.OrderAdjustmentLog
( OrderAdjustmentLog_ID int NOT NULL IDENTITY(1,1) 
 CONSTRAINT PK_OrderAdjustmentLog PRIMARY KEY CLUSTERED,
 OrderID INT NOT NULL,
 CustomerID INT NOT NULL,
 SalespersonPersonID INT NOT NULL,
 ContactPersonID INT NOT NULL,
CreateTimeUTC DATETIME2(3) NOT NULL);

下面是一个使用内存优化表的触发器演示:

CREATE TRIGGER TR_Sales_Orders_Mem_Test
 ON Sales.Orders
 AFTER UPDATE
AS
BEGIN
 SET NOCOUNT ON;
 DECLARE @OrderData dbo.SalesOrderMetadata;
 INSERT INTO @OrderData
 (OrderID, CustomerID, SalespersonPersonID, 
 ContactPersonID)
 SELECT
 OrderID,
 CustomerID,
 SalespersonPersonID,
 ContactPersonID
 FROM Inserted;
 
 DELETE OrderData
 FROM @OrderData OrderData
 INNER JOIN sales.Customers
 ON Customers.CustomerID = OrderData.CustomerID
 WHERE Customers.IsOnCreditHold = 0;
 UPDATE OrderData
 SET ContactPersonID = 1
 FROM @OrderData OrderData
 WHERE OrderData.ContactPersonID IS NULL;
 
 INSERT INTO dbo.OrderAdjustmentLog
 (OrderID, CustomerID, SalespersonPersonID, 
 ContactPersonID, CreateTimeUTC)
 SELECT
 OrderData.OrderID,
 OrderData.CustomerID,
 OrderData.SalespersonPersonID,
 OrderData.ContactPersonID,
 SYSUTCDATETIME()
 FROM @OrderData OrderData;
END

触发器内须要的操做越多,节省的时间就越多,由于内存优化的表变量不须要IO来读/写。

一旦读取了来自所插入表的初始数据,触发器的其他部分就能够不处理tempdb,从而减小使用标准表变量或临时表的开销。

下面的代码设置了一些测试数据,并运行一个更新来演示上述代码的结果:

UPDATE Customers
 SET IsOnCreditHold = 1
FROM Sales.Customers
WHERE Customers.CustomerID = 832;
UPDATE Orders
 SET SalespersonPersonID = 2
FROM sales.Orders
WHERE CustomerID = 832;

一旦执行,OrderAdjustmentLog表的内容能够被验证:

结果是意料之中的。经过减小对标准存储的依赖并将中间表移动到内存中,内存优化表提供了一种大大提升触发速度的方法。这仅限于对临时对象有大量调用的场景,但在存储过程或其余过程性TSQL中也颇有用。

替代触发器

像全部的工具同样,触发器也可能被滥用,并成为混乱、性能瓶颈和可维护性噩梦的根源。有许多比触发器更可取的替代方案,在实现(或添加到现有的)触发器以前应该考虑它们。

Temporal tables

Temporal tables是在SQL Server 2016中引入的,它提供了一种向表添加版本控制的简单方法,无需构建本身的数据结构和ETL。这种记录对应用程序是不可见的,并提供了符合ANSI标准的完整版本支持,使之成为一种简单的方法来解决保存旧版本数据的问题。

Check约束

对于简单的数据验证,Check约束能够提供所需的内容,而不须要函数、存储过程或触发器。在列上定义Check约束,并在建立数据时自动验证数据。

下面是一个Check约束的示例:

ALTER TABLE Sales.Invoices WITH CHECK ADD CONSTRAINT
CK_Sales_Invoices_ReturnedDeliveryData_Must_Be_Valid_JSON
CHECK ([ReturnedDeliveryData] IS NULL OR 
ISJSON([ReturnedDeliveryData])<>(0))

这段代码检查一个列是不是有效的JSON。若是是,则执行正常进行。若是不是,那么SQL Server将抛出一个错误,写操做将失败。Check约束能够检查列和值的任何组合,所以能够管理简单或复杂的验证任务。

建立Check约束的成本不高,并且易于维护。它们也更容易记录和理解,由于Check约束的范围仅限于验证传入数据和确保数据完整性,而触发器实际上能够作任何能够想象的事情!

惟一约束

若是一个列须要惟一的值,而且不是表上的主键,那么惟一约束是完成该任务的一种简单而有效的方法。惟一约束是索引和惟一性的组合。为了有效地验证惟一性,索引是必需的。

下面是一个惟一约束的例子:

ALTER TABLE Warehouse.Colors ADD CONSTRAINT 
UQ_Warehouse_Colors_ColorName UNIQUE NONCLUSTERED (ColorName ASC);

每当一行被插入到 Warehouse.Colors表中,将检查ColorName的惟一性。若是写操做碰巧致使了重复的颜色,那么语句将失败,数据将不会被更改。为此目的构建了惟一约束,这是在列上强制惟一性的最简单方法。

内置的解决方案将更高效、更容易维护和更容易记录。任何看到惟一约束的开发人员都将当即理解它的做用,而不须要深刻挖掘TSQL来弄清事情是如何工做的,这种简单性使其成为理想的解决方案。

外键约束

与Check约束和惟一约束同样,外键约束是在写入数据以前验证数据完整性的另外一种方式。外键将一一表中的列连接到另外一张表。当数据插入到目标表时,它的值将根据引用的表进行检查。若是该值存在,则写操做正常进行。若是不是,则抛出错误,语句失败。

这是一个简单的外键例子:

ALTER TABLE Sales.Orders WITH CHECK ADD CONSTRAINT
FK_Sales_Orders_CustomerID_Sales_Customers FOREIGN KEY (CustomerID)
REFERENCES Sales.Customers (CustomerID);

当数据写入Sales.Orders时,CustomerID列将根据Sales.Customers中的CustomerID列进行检查。

与惟一约束相似,外键只有一个目的:验证写入一个表的数据是否存在于另外一个表中。它易于文档化,易于理解,实现效率高。

触发器不是执行这些验证检查的正确位置,与使用外键相比,它是效率较低的解决方案。

存储过程

在触发器中实现的逻辑一般能够很容易地移动到存储过程当中。这消除了大量触发代码可能致使的复杂性,同时容许开发人员更好的维护。存储过程能够自由地构造操做,以确保尽量多的原子性。

实现触发器的基本原则之一是确保一组操做与写操做一致。全部成功或失败都是做为原子事务的一部分。应用程序并不老是须要这种级别的原子性。若是有必要,能够在存储过程当中使用适当的隔离级别或表锁定来保证事务的完整性。

虽然SQL Server(和大多数RDBMS)提供了ACID保证事务将是原子的、一致的、隔离的和持久的,但咱们本身代码中的事务可能须要也可能不须要遵循相同的规则。现实世界的应用程序对数据完整性的需求各不相同。

存储过程容许自定义代码,以实现应用程序所需的数据完整性,确保性能和计算资源不会浪费在不须要的数据完整性上。

例如,一个容许用户发布照片的社交媒体应用程序不太可能须要它的事务彻底原子化和一致。若是个人照片出如今你以前或以后一秒,没人会在乎。一样,若是你在我编辑照片的时候评论个人照片,时间对使用这些数据的人来讲可能并不重要。另外一方面,一个管理货币交易的银行应用程序须要确保交易是谨慎执行的,这样就不会出现资金丢失或数字报告错误的状况。若是我有一个银行帐户,里面有20美圆,我取出20美圆的同时,其余人也取出了20美圆,咱们不可能都成功。咱们中的一个先获得20美圆,另外一个遇到关于0美圆余额的适当错误消息。

函数

函数提供了一种简单的方法,能够将重要的逻辑封装到一个单独的位置。在50个表插入中重用的单个函数比50个触发器(每一个表一个触发器)执行相同逻辑要容易得多。

考虑如下函数:

CREATE FUNCTION Website.CalculateCustomerPrice
 (@CustomerID INT, @StockItemID INT, @PricingDate DATE)
RETURNS DECIMAL(18,2)
WITH EXECUTE AS OWNER
AS
BEGIN
 DECLARE @CalculatedPrice decimal(18,2);
 DECLARE @UnitPrice decimal(18,2);
 DECLARE @LowestUnitPrice decimal(18,2);
 DECLARE @HighestDiscountAmount decimal(18,2);
 DECLARE @HighestDiscountPercentage decimal(18,3);
 DECLARE @BuyingGroupID int;
 DECLARE @CustomerCategoryID int;
 DECLARE @DiscountedUnitPrice decimal(18,2);
 SELECT @BuyingGroupID = BuyingGroupID,
 @CustomerCategoryID = CustomerCategoryID
 FROM Sales.Customers
 WHERE CustomerID = @CustomerID;
 SELECT @UnitPrice = si.UnitPrice
 FROM Warehouse.StockItems AS si
 WHERE si.StockItemID = @StockItemID;
 SET @CalculatedPrice = @UnitPrice;
 SET @LowestUnitPrice = (
 SELECT MIN(sd.UnitPrice)
 FROM Sales.SpecialDeals AS sd
 WHERE ((sd.StockItemID = @StockItemID) 
 OR (sd.StockItemID IS NULL))
 AND ((sd.CustomerID = @CustomerID) 
 OR (sd.CustomerID IS NULL))
 AND ((sd.BuyingGroupID = @BuyingGroupID) 
 OR (sd.BuyingGroupID IS NULL))
 AND ((sd.CustomerCategoryID = @CustomerCategoryID) 
 OR (sd.CustomerCategoryID IS NULL))
 AND ((sd.StockGroupID IS NULL) OR EXISTS (SELECT 1 
 FROM Warehouse.StockItemStockGroups AS sisg
 WHERE sisg.StockItemID = @StockItemID
 AND sisg.StockGroupID = sd.StockGroupID))
 AND sd.UnitPrice IS NOT NULL
 AND @PricingDate BETWEEN sd.StartDate AND sd.EndDate);
 IF @LowestUnitPrice IS NOT NULL AND @LowestUnitPrice < @UnitPrice
 BEGIN
 SET @CalculatedPrice = @LowestUnitPrice;
 END;
 SET @HighestDiscountAmount = (
 SELECT MAX(sd.DiscountAmount)
 FROM Sales.SpecialDeals AS sd
 WHERE ((sd.StockItemID = @StockItemID) 
 OR (sd.StockItemID IS NULL))
 AND ((sd.CustomerID = @CustomerID) 
 OR (sd.CustomerID IS NULL))
 AND ((sd.BuyingGroupID = @BuyingGroupID) 
 OR (sd.BuyingGroupID IS NULL))
 AND ((sd.CustomerCategoryID = @CustomerCategoryID) 
 OR (sd.CustomerCategoryID IS NULL))
 AND ((sd.StockGroupID IS NULL) OR EXISTS 
 (SELECT 1 FROM Warehouse.StockItemStockGroups AS sisg 
 WHERE sisg.StockItemID = @StockItemID
 AND sisg.StockGroupID = sd.StockGroupID))
 AND sd.DiscountAmount IS NOT NULL
 AND @PricingDate BETWEEN sd.StartDate AND sd.EndDate);
 IF @HighestDiscountAmount IS NOT NULL AND (
 @UnitPrice - @HighestDiscountAmount) < @CalculatedPrice
 BEGIN
 SET @CalculatedPrice = @UnitPrice - @HighestDiscountAmount;
 END;
 SET @HighestDiscountPercentage = (
 SELECT MAX(sd.DiscountPercentage)
 FROM Sales.SpecialDeals AS sd
 WHERE ((sd.StockItemID = @StockItemID)
 OR (sd.StockItemID IS NULL))
 AND ((sd.CustomerID = @CustomerID) 
 OR (sd.CustomerID IS NULL))
 AND ((sd.BuyingGroupID = @BuyingGroupID) 
 OR (sd.BuyingGroupID IS NULL))
 AND ((sd.CustomerCategoryID = @CustomerCategoryID) 
 OR (sd.CustomerCategoryID IS NULL))
 AND ((sd.StockGroupID IS NULL) OR EXISTS 
 (SELECT 1 FROM Warehouse.StockItemStockGroups AS sisg
 WHERE sisg.StockItemID = @StockItemID
 AND sisg.StockGroupID = sd.StockGroupID))
 AND sd.DiscountPercentage IS NOT NULL
 AND @PricingDate BETWEEN sd.StartDate AND sd.EndDate);
 IF @HighestDiscountPercentage IS NOT NULL
 BEGIN
 SET @DiscountedUnitPrice = ROUND(@UnitPrice * 
 @HighestDiscountPercentage / 100.0, 2);
 IF @DiscountedUnitPrice < @CalculatedPrice 
 SET @CalculatedPrice = @DiscountedUnitPrice;
 END;
 RETURN @CalculatedPrice;
END;

就复杂性而言,这绝对是一头猛兽。虽然它接受标量参数来肯定计算价格,但它执行的操做很是大,甚至包括对Warehouse.StockItemStockGroups, Warehouse.StockItems和Sales.Customers的额外读取。若是这是一个常常针对单行数据使用的关键计算,那么将其封装在一个函数中是得到所需计算的一种简单方法,而不会增长触发器的复杂性。当心使用函数,并确保使用大型数据集进行测试。简单的标量函数一般能够很好地伸缩性较大的数据,但更复杂的函数可能性能较差。

编码

当从应用程序修改表中的数据时,还能够在写入数据以前执行额外的数据操做或验证。这一般代价低廉,性能很好,并有助于减小失控触发器对数据库的负面影响。

将代码放入触发器的常见理由是,这样作能够避免修改代码、推送构建,不然会致使更改应用程序。这与在数据库中进行更改相关的任何风险直接相反。这一般是应用程序开发人员和数据库开发人员之间关于谁将负责新代码的讨论。

这是一个粗略的指导方针,但有助于在代码添加到应用程序或触发器以后测量可维护性和风险。

计算列

其余列发生更改时,计算列能够包括经过各类各样的算术运算和函数进行计算,获得结果。它们能够包含在索引中,也能够包含在惟一的约束中,甚至主键中。

当任何底层值发生变化时,SQL Server会自动维护计算的列。注意,每一个计算出来的列最终都是由表中其余列的值决定的。

这是使用触发器来维护指定列值的一种很好的替代方法。计算列是高效的、自动的,而且不须要维护。它们只是简单地工做,甚至容许将复杂的计算直接集成到一个表中,而在应用程序或SQL Server中不须要额外的代码。

使用SQL Server触发器

触发器在SQL Server中是一个有用的特性,但像全部工具同样,它也可能被误用或滥用。在决定是否使用触发器时,必定要考虑触发器的目的。

若是一个触发器被用来将简短的事务数据写入日志表,那么它极可能是一个很好的触发器。若是触发器被用来强制执行复杂的业务规则,那么极可能须要从新考虑处理这类操做的最佳方式。

有不少工具能够做为触发器的可行替代品,好比检查约束、计算列等,解决问题的方法并不短缺。数据库体系结构的成功在于为工做选择正确的工具。

原文连接:https://www.red-gate.com/simple-talk/sql/database-administration/sql-server-triggers-good-scary/

SQL Server触发器在很是有争议的主题。它们能以较低的成本提供便利,但常常被开发人员、DBA误用,致使性能瓶颈或维护性挑战。

本文简要回顾了触发器,并深刻讨论了如何有效地使用触发器,以及什么时候触发器会使开发人员陷入难以逃脱的困境。

虽然本文中的全部演示都是在SQL Server中进行的,但这里提供的建议是大多数数据库通用的。触发器带来的挑战在MySQL、PostgreSQL、MongoDB和许多其余应用中也能够看到。

什么是触发器

能够在数据库或表上定义SQL Server触发器,它容许代码在发生特定操做时自动执行。本文主要关注表上的DML触发器,由于它们每每被过分使用。相反,数据库的DDL触发器一般更集中,对性能的危害更小。

触发器是对表中数据更改时进行计算的一组代码。触发器能够定义为在插入、更新、删除或这些操做的任何组合上执行。MERGE操做能够触发语句中每一个操做的触发器。

触发器能够定义为INSTEAD OF或AFTER。AFTER触发器发生在数据写入表以后,是一组独立的操做,和写入表的操做在同一事务执行,但在写入发生以后执行。若是触发器失败,原始操做也会失败。INSTEAD OF触发器替换调用的写操做。插入、更新或删除操做永远不会发生,而是执行触发器的内容。

触发器容许在发生写操做时执行TSQL,而无论这些写操做的来源是什么。它们一般用于在但愿确保执行写操做时运行关键操做,如日志记录、验证或其余DML。这很方便,写操做能够来自API、应用程序代码、发布脚本,或者内部流程,触发器不管如何都会触发。

触发器是什么样的

用WideWorldImporters示例数据库中的Sales.Orders 表举例,假设须要记录该表上的全部更新或删除操做,以及有关更改发生的一些细节。这个操做能够经过修改代码来完成,可是这样作须要对表的代码写入中的每一个位置进行更改。经过触发器解决这一问题,能够采起如下步骤:

1. 建立一个日志表来接受写入的数据。下面的TSQL建立了一个简单日志表,以及一些添加的数据点:

CREATE TABLE Sales.Orders_log
( Orders_log_ID int NOT NULL IDENTITY(1,1) 
 CONSTRAINT PK_Sales_Orders_log PRIMARY KEY CLUSTERED,
 OrderID int NOT NULL,
 CustomerID_Old int NOT NULL,
 CustomerID_New int NOT NULL,
 SalespersonPersonID_Old int NOT NULL,
 SalespersonPersonID_New int NOT NULL,
 PickedByPersonID_Old int NULL,
 PickedByPersonID_New int NULL,
 ContactPersonID_Old int NOT NULL,
 ContactPersonID_New int NOT NULL,
 BackorderOrderID_Old int NULL,
 BackorderOrderID_New int NULL,
 OrderDate_Old date NOT NULL,
 OrderDate_New date NOT NULL,
 ExpectedDeliveryDate_Old date NOT NULL,
 ExpectedDeliveryDate_New date NOT NULL,
 CustomerPurchaseOrderNumber_Old nvarchar(20) NULL,
 CustomerPurchaseOrderNumber_New nvarchar(20) NULL,
 IsUndersupplyBackordered_Old bit NOT NULL,
 IsUndersupplyBackordered_New bit NOT NULL,
 Comments_Old nvarchar(max) NULL,
 Comments_New nvarchar(max) NULL,
 DeliveryInstructions_Old nvarchar(max) NULL,
 DeliveryInstructions_New nvarchar(max) NULL,
 InternalComments_Old nvarchar(max) NULL,
 InternalComments_New nvarchar(max) NULL,
 PickingCompletedWhen_Old datetime2(7) NULL,
 PickingCompletedWhen_New datetime2(7) NULL,
 LastEditedBy_Old int NOT NULL,
 LastEditedBy_New int NOT NULL,
 LastEditedWhen_Old datetime2(7) NOT NULL,
 LastEditedWhen_New datetime2(7) NOT NULL,
 ActionType VARCHAR(6) NOT NULL,
 ActionTime DATETIME2(3) NOT NULL,
UserName VARCHAR(128) NULL);

该表记录全部列的旧值和新值。这是很是全面的,咱们能够简单地记录旧版本的行,并可以经过将新版本和旧版本合并在一块儿来了解更改的过程。最后3列是新增的,提供了有关执行的操做类型(插入、更新或删除)、时间和操做人。

2. 建立一个触发器来记录表的更改:

CREATE TRIGGER TR_Sales_Orders_Audit
 ON Sales.Orders
 AFTER INSERT, UPDATE, DELETE
AS
BEGIN
 SET NOCOUNT ON;
 INSERT INTO Sales.Orders_log
 (OrderID, CustomerID_Old, CustomerID_New, 
 SalespersonPersonID_Old, SalespersonPersonID_New, 
 PickedByPersonID_Old, PickedByPersonID_New,
 ContactPersonID_Old, ContactPersonID_New, 
 BackorderOrderID_Old, BackorderOrderID_New, 
 OrderDate_Old, OrderDate_New, ExpectedDeliveryDate_Old,
 ExpectedDeliveryDate_New, 
 CustomerPurchaseOrderNumber_Old, 
 CustomerPurchaseOrderNumber_New, 
 IsUndersupplyBackordered_Old, 
 IsUndersupplyBackordered_New,
 Comments_Old, Comments_New, 
 DeliveryInstructions_Old, DeliveryInstructions_New, 
 InternalComments_Old, InternalComments_New, 
 PickingCompletedWhen_Old,
 PickingCompletedWhen_New, LastEditedBy_Old, 
 LastEditedBy_New, LastEditedWhen_Old, 
 LastEditedWhen_New, ActionType, ActionTime, UserName)
 SELECT
 ISNULL(Inserted.OrderID, Deleted.OrderID) AS OrderID,
 Deleted.CustomerID AS CustomerID_Old,
 Inserted.CustomerID AS CustomerID_New,
 Deleted.SalespersonPersonID AS SalespersonPersonID_Old,
 Inserted.SalespersonPersonID AS SalespersonPersonID_New,
 Deleted.PickedByPersonID AS PickedByPersonID_Old,
 Inserted.PickedByPersonID AS PickedByPersonID_New,
 Deleted.ContactPersonID AS ContactPersonID_Old,
 Inserted.ContactPersonID AS ContactPersonID_New,
 Deleted.BackorderOrderID AS BackorderOrderID_Old,
 Inserted.BackorderOrderID AS BackorderOrderID_New,
 Deleted.OrderDate AS OrderDate_Old,
 Inserted.OrderDate AS OrderDate_New,
 Deleted.ExpectedDeliveryDate 
 AS ExpectedDeliveryDate_Old,
 Inserted.ExpectedDeliveryDate 
 AS ExpectedDeliveryDate_New,
 Deleted.CustomerPurchaseOrderNumber 
 AS CustomerPurchaseOrderNumber_Old,
 Inserted.CustomerPurchaseOrderNumber 
 AS CustomerPurchaseOrderNumber_New,
 Deleted.IsUndersupplyBackordered 
 AS IsUndersupplyBackordered_Old,
 Inserted.IsUndersupplyBackordered 
 AS IsUndersupplyBackordered_New,
 Deleted.Comments AS Comments_Old,
 Inserted.Comments AS Comments_New,
 Deleted.DeliveryInstructions 
 AS DeliveryInstructions_Old,
 Inserted.DeliveryInstructions 
 AS DeliveryInstructions_New,
 Deleted.InternalComments AS InternalComments_Old,
 Inserted.InternalComments AS InternalComments_New,
 Deleted.PickingCompletedWhen 
 AS PickingCompletedWhen_Old,
 Inserted.PickingCompletedWhen 
 AS PickingCompletedWhen_New,
 Deleted.LastEditedBy AS LastEditedBy_Old,
 Inserted.LastEditedBy AS LastEditedBy_New,
 Deleted.LastEditedWhen AS LastEditedWhen_Old,
 Inserted.LastEditedWhen AS LastEditedWhen_New,
 CASE
 WHEN Inserted.OrderID IS NULL THEN 'DELETE'
 WHEN Deleted.OrderID IS NULL THEN 'INSERT'
 ELSE 'UPDATE'
 END AS ActionType,
 SYSUTCDATETIME() ActionTime,
 SUSER_SNAME() AS UserName
 FROM Inserted
 FULL JOIN Deleted
 ON Inserted.OrderID = Deleted.OrderID;
END

该触发器的惟一功能是将数据插入到日志表中,每行数据对应一个给定的写操做。它很简单,随着时间的推移易于记录和维护,表也会发生变化。若是须要跟踪其余详细信息,能够添加其余列,如数据库名称、服务器名称、受影响列的行数或调用的应用程序。

3.最后一步是测试和验证日志表是否正确。

如下是添加触发器后对表进行更新的测试:

UPDATE Orders
 SET InternalComments = 'Item is no longer backordered',
 BackorderOrderID = NULL,
 IsUndersupplyBackordered = 0,
 LastEditedBy = 1,
 LastEditedWhen = SYSUTCDATETIME()
FROM sales.Orders
WHERE Orders.OrderID = 10;

结果以下:

上面省略了一些列,可是咱们能够快速确认已经触发了更改,包括日志表末尾新增的列。

INSERT和DELETE

前面的示例中,进行插入和删除操做后,读取日志表中使用的数据。这种特殊的表能够做为任何相关写操做的一部分。INSERT将包含被插入操做触发,DELETE将被删除操做触发,UPDATE包含被插入和删除操做触发。

对于INSERT和UPDATE,将包含表中每一个列新值的快照。对于DELETE和UPDATE操做,将包含写操做以前表中每一个列旧值的快照。

触发器何时最有用

DML触发器的最佳使用是简短、简单且易于维护的写操做,这些操做在很大程度上独立于应用程序业务逻辑。

  • 触发器的一些重要用途包括:
  • 记录对历史表的更改
  • 审计用户及其对敏感表的操做。
  • 向表中添加应用程序可能没法使用的额外值(因为安全限制或其余限制),例如:
    •  登陆/用户名
    •  操做发生时间
    • 服务器/数据库名称
  • 简单的验证。

关键是让触发器代码保持足够的紧凑,从而便于维护。当触发器增加到成千上万行时,它们就成了开发人员不敢去打扰的黑盒。结果,更多的代码被添加进来,可是旧的代码不多被检查。即便有了文档,这也很难维护。

为了让触发器有效地发挥做用,应该将它们编写为基于设置的。若是存储过程必须在触发器中使用,则确保它们在须要时使用表值参数,以即可以基于集的方式移动数据。下面是一个触发器的示例,该触发器遍历id,以便使用结果顺序id执行示例存储过程:

CREATE TRIGGER TR_Sales_Orders_Process
 ON Sales.Orders
 AFTER INSERT
AS
BEGIN
 SET NOCOUNT ON;
 DECLARE @count INT;
 SELECT @count = COUNT(*) FROM inserted;
 DECLARE @min_id INT;
 SELECT @min_id = MIN(OrderID) FROM inserted;
 DECLARE @current_id INT = @min_id;
 WHILE @current_id < @current_id + @count
 BEGIN
 EXEC dbo.process_order_fulfillment 
 @OrderID = @current_id;
 SELECT @current_id = @current_id + 1;
 END
END

虽然相对简单,但当一次插入多行时对 Sales.Orders的INSERT操做的性能将受到影响,由于SQL Server在执行process_order_fulfillment存储过程时将被迫逐个执行。一个简单的修复方法是重写存储过程,并将一组Order id传递到存储过程当中,而不是一次一个地这样作:

CREATE TYPE dbo.udt_OrderID_List AS TABLE(
 OrderID INT NOT NULL,
 PRIMARY KEY CLUSTERED 
( OrderID ASC));
GO
CREATE TRIGGER TR_Sales_Orders_Process
 ON Sales.Orders
 AFTER INSERT
AS
BEGIN
 SET NOCOUNT ON;
 DECLARE @OrderID_List dbo.udt_OrderID_List;
 EXEC dbo.process_order_fulfillment @OrderIDs = @OrderID_List;
END

更改的结果是将完整的id集合从触发器传递到存储过程并进行处理。只要存储过程以基于集合的方式管理这些数据,就能够避免重复执行,也就是说,避免在触发器内使用存储过程有很大的价值,由于它们添加了额外的封装层,进一步隐藏了在数据写入表时执行的TSQL。它们应该被认为是最后的手段,只有当能够在应用程序的许多地方屡次重写TSQL时才使用。

何时触发器是危险的

架构师和开发人员面临的最大挑战之一是确保触发器只在须要时使用,而不容许它们成为一刀切的解决方案。向触发器添加TSQL一般被认为比向应用程序添加代码更快、更容易,但随着时间的推移,这样作的成本会随着每添加一行代码而增长。

触发器在如下状况下会变得危险:

  • 保持尽量少的触发以减小复杂性。
  • 触发代码变得复杂。若是更新表中的一行致使要执行数千行添加的触发器代码,那么开发人员就很难彻底理解数据写入表时会发生什么。更糟糕的是,当出现问题时,故障排除很是具备挑战性。
  • 触发器跨服务器。这将网络操做引入到触发器中,可能致使在出现链接问题时写入速度变慢或失败。若是目标数据库是要维护的对象,那么即便是跨数据库触发器也会有问题。
  • 触发器调用触发器。触发器中最使人痛苦的是,当插入一行时,写操做会致使75个表中有100个触发器要执行。在编写触发器代码时,确保触发器能够执行全部必要的逻辑,而不会触发更多触发器。额外的触发一般是没必要要的。
  • 递归触发器被设置为ON。这是一个默认设置为off的数据库级别设置。打开时,它容许触发器的内容调用相同的触发器。递归触发器会极大地损害性能,调试时也会很是混乱。一般,当一个触发器中的DML做为操做的一部分触发其余触发器时,使用递归触发器。
  • 函数、存储过程或视图都在触发器中。在触发器中封装更多的业务逻辑会使它们变得更复杂,并给人一种触发器代码短小简单的错误印象,而实际上并不是如此。尽量避免在触发器中使用存储过程和函数。
  • 迭代发生。循环和游标本质上是逐行操做的,可能会致使对1000行的操做一次触发1000次,这极大地损害了查询性能。

这是一个很长的列表,但一般能够总结为短而简单的触发器会表现得更好,并避免上面的大多数陷阱。若是使用触发器来维护复杂的业务逻辑,那么随着时间的推移,愈来愈多的业务逻辑将被添加进来,而且不可避免地将违反上述最佳实践。

重要的是要注意,为了维护原子的、事务,受触发器影响的任何对象都将保持事务处于打开状态,直到该触发器完成。这意味着长触发器不只会使事务持续时间更长,并且还会持有锁并致使持续时间更长。所以,在测试触发器时,在为现有触发器建立或添加额外逻辑时,应该了解它们对锁、阻塞和等待的影响。

如何改善触发器

有不少方法可使触发器更易于维护、更容易理解和性能更高。如下是一些关于如何有效管理触发器和避免落入陷阱的建议。

触发器自己应该有良好的文档记录:

  • 这个触发器为何存在?
  • 它能作什么?
  • 它是如何工做的?
  • 对于触发器的工做方式是否有任何例外或警告?

此外,若是触发器中的TSQL难以理解,那么能够添加内联注释,以帮助第一次查看它的开发人员。

下面是触发器文档的样例:

/* 12/29/2020 EHP
 This trigger logs all changes to the table to the Orders_log
 table that occur for non-internal customers.
 CustomerID = -1 signifies an internal/test customer and 
 these are not audited.
*/
CREATE TRIGGER TR_Sales_Orders_Audit
 ON Sales.Orders
 FOR INSERT, UPDATE, DELETE
AS
BEGIN
 SET NOCOUNT ON;
 INSERT INTO Sales.Orders_log
 (OrderID, CustomerID_Old, CustomerID_New, 
 SalespersonPersonID_Old, SalespersonPersonID_New,
 PickedByPersonID_Old, PickedByPersonID_New,
 ContactPersonID_Old, ContactPersonID_New, 
 BackorderOrderID_Old, BackorderOrderID_New, 
 OrderDate_Old, OrderDate_New, 
 ExpectedDeliveryDate_Old,
 ExpectedDeliveryDate_New, 
 CustomerPurchaseOrderNumber_Old, 
 CustomerPurchaseOrderNumber_New, 
 IsUndersupplyBackordered_Old, 
 IsUndersupplyBackordered_New,
 Comments_Old, Comments_New, 
 DeliveryInstructions_Old, DeliveryInstructions_New, 
 nternalComments_Old, InternalComments_New, 
 PickingCompletedWhen_Old, PickingCompletedWhen_New, 
 LastEditedBy_Old, LastEditedBy_New, 
 LastEditedWhen_Old, LastEditedWhen_New, 
 ActionType, ActionTime, UserName)
 SELECT
 ISNULL(Inserted.OrderID, Deleted.OrderID) AS OrderID, 
 -- The OrderID can never change. 
 --This ensures we get the ID correctly, 
 --regardless of operation type.
 Deleted.CustomerID AS CustomerID_Old,
 Inserted.CustomerID AS CustomerID_New,
 Deleted.SalespersonPersonID AS SalespersonPersonID_Old,
 Inserted.SalespersonPersonID AS SalespersonPersonID_New,
 Deleted.PickedByPersonID AS PickedByPersonID_Old,
 Inserted.PickedByPersonID AS PickedByPersonID_New,
 Deleted.ContactPersonID AS ContactPersonID_Old,
 Inserted.ContactPersonID AS ContactPersonID_New,
 Deleted.BackorderOrderID AS BackorderOrderID_Old,
 Inserted.BackorderOrderID AS BackorderOrderID_New,
 Deleted.OrderDate AS OrderDate_Old,
 Inserted.OrderDate AS OrderDate_New,
 Deleted.ExpectedDeliveryDate AS ExpectedDeliveryDate_Old,
 Inserted.ExpectedDeliveryDate AS ExpectedDeliveryDate_New,
 Deleted.CustomerPurchaseOrderNumber 
 AS CustomerPurchaseOrderNumber_Old,
 Inserted.CustomerPurchaseOrderNumber 
 AS CustomerPurchaseOrderNumber_New,
 Deleted.IsUndersupplyBackordered 
 AS IsUndersupplyBackordered_Old,
 Inserted.IsUndersupplyBackordered 
 AS IsUndersupplyBackordered_New,
 Deleted.Comments AS Comments_Old,
 Inserted.Comments AS Comments_New,
 Deleted.DeliveryInstructions 
 AS DeliveryInstructions_Old,
 Inserted.DeliveryInstructions 
 AS DeliveryInstructions_New,
 Deleted.InternalComments AS InternalComments_Old,
 Inserted.InternalComments AS InternalComments_New,
 Deleted.PickingCompletedWhen AS PickingCompletedWhen_Old,
 Inserted.PickingCompletedWhen 
 AS PickingCompletedWhen_New,
 Deleted.LastEditedBy AS LastEditedBy_Old,
 Inserted.LastEditedBy AS LastEditedBy_New,
 Deleted.LastEditedWhen AS LastEditedWhen_Old,
 Inserted.LastEditedWhen AS LastEditedWhen_New,
 CASE -- Determine the operation type based on whether 
 --Inserted exists, Deleted exists, or both exist.
 WHEN Inserted.OrderID IS NULL THEN 'DELETE'
 WHEN Deleted.OrderID IS NULL THEN 'INSERT'
 ELSE 'UPDATE'
 END AS ActionType,
 SYSUTCDATETIME() ActionTime,
 SUSER_SNAME() AS UserName
 FROM Inserted
 FULL JOIN Deleted
 ON Inserted.OrderID = Deleted.OrderID
 WHERE Inserted.CustomerID <> -1 
 -- -1 indicates an internal/non-production 
 --customer that should not be audited.
 OR Deleted.CustomerID <> -1; 
 -- -1 indicates an internal/non-production 
 --customer that should not be audited.
END

请注意,该文档并不全面,但包含了一个简短的头,并解释了触发器内的一些TSQL关键部分:

  • 排除CustomerID = -1的状况。这一点对于不知道的人来讲是不明显的,因此这是一个很好的注释。
  • ActionType的CASE语句用于什么。
  • 为何在插入和删除之间的OrderID列上使用ISNULL。

使用IF UPDATE

在触发器中,UPDATE提供了判断是否将数据写入给定列的能力。这能够容许触发器检查列在执行操做以前是否发生了更改。下面是该语法的示例:

CREATE TRIGGER TR_Sales_Orders_Log_BackorderID_Change
 ON Sales.Orders
 AFTER UPDATE
AS
BEGIN
 SET NOCOUNT ON;
 IF UPDATE(BackorderOrderID)
 BEGIN
 UPDATE OrderBackorderLog
 SET BackorderOrderID = Inserted.BackorderOrderID,
 PreviousBackorderOrderID = Deleted.BackorderOrderID
 FROM dbo.OrderBackorderLog
 INNER JOIN Inserted
 ON Inserted.OrderID = OrderBackorderLog.OrderID
 END
END

经过首先检查BackorderID是否被更新,触发器能够在不须要时绕事后续操做。这是一种提升性能的好方法,它容许触发器根据所需列的更新值彻底跳过代码。

COLUMNS_UPDATED指示表中的哪些列做为写操做的一部分进行了更新,能够在触发器中使用它来快速肯定指定的列是否受到插入或更新操做的影响。虽然有文档记录,但它使用起来很复杂,很难进行文档记录。我一般不建议使用它,由于它几乎确定会使不熟悉它的开发人员感到困惑。

请注意,对于UPDATE或COLUMNS_UPDATED,列是否更改并不重要。对列进行写操做,即便值没有改变,对于UPDATE操做仍然返回1,对于COLUMNS_UPDATED操做仍然返回1。它们只跟踪指定的列是不是写操做的目标,而不跟踪值自己是否改变。

每一个操做一个触发器

让触发代码尽量的简单。数据库表的触发器数量增加会大大增长表的复杂性,理解其操做变得更加困难。。

例如,考虑如下表触发器定义方式:

CREATE TRIGGER TR_Sales_Orders_I
 ON Sales.Orders
 AFTER INSERT
CREATE TRIGGER TR_Sales_Orders_IU
 ON Sales.Orders
 AFTER INSERT, UPDATE
CREATE TRIGGER TR_Sales_Orders_UD
 ON Sales.Orders
 AFTER UPDATE, DELETE
CREATE TRIGGER TR_Sales_Orders_UID
 ON Sales.Orders
 AFTER UPDATE, INSERT, DELETE
CREATE TRIGGER TR_Sales_Orders_ID
 ON Sales.Orders
 AFTER INSERT, DELETE

当插入一行时会发生什么?触发器的触发顺序是什么?这些问题的答案须要研究。维护更少的触发器是一个简单的解决方案,而且消除了对给定表中如何发生写操做的猜想。做为参考,可使用系统存储过程sp_settriggerorder修改触发器顺序,不过这只适用于AFTER触发器。

再简单一点

触发器的最佳实践是操做简单,执行迅速,而且不会由于它们的执行而触发更多的触发器。触发器的复杂程度并无明确的规则,但有一条简单的指导原则是,理想的触发器应该足够简单,若是必须将触发器中包含的逻辑移到其余地方,那么迁移的代价不会高得使人望而却步。也就是说,若是触发器中的业务逻辑很是复杂,以致于移动它的成本过高而没法考虑,那么这些触发器极可能变得过于复杂。

使用咱们前面的示例,考虑一下更改审计的触发器。这能够很容易地从触发器转移到存储过程或代码中,而这样作的工做量并不大。触发器中记录日志的方便性使它值得一作,但与此同时,咱们应该知道开发人员将TSQL从触发器迁移到另外一个位置须要多少小时。

时间的计算能够看做是触发器的可维护性成本的一部分。也就是说,若是有必要,为摆脱触发机制而必须付出的代价。这听起来可能很抽象,但平台之间的数据库迁移是很常见的。在SQL Server中执行良好的一组触发器在Oracle或PostgreSQL中可能并不有效。

优化表变量

有时,一个触发器中须要临时表,以容许对数据进行屡次更新。临时表存储在tempdb中,而且受到tempdb数据库大小、速度和性能约束的影响。

对于常常访问的临时表,优化表变量是在内存中(而不是在tempdb中)维护临时数据的好方法。

下面的TSQL为内存优化数据配置了一个数据库(若是须要):

ALTER DATABASE WideWorldImporters 
SET MEMORY_OPTIMIZED_ELEVATE_TO_SNAPSHOT = ON;
ALTER DATABASE WideWorldImporters ADD FILEGROUP WWI_InMemory_Data 
 CONTAINS MEMORY_OPTIMIZED_DATA;
ALTER DATABASE WideWorldImporters ADD FILE 
 (NAME='WideWorldImporters_IMOLTP_File_1', 
 FILENAME='C:\SQLData\WideWorldImporters_IMOLTP_File_1.mem') 
 TO FILEGROUP WWI_InMemory_Data;

一旦配置完成,就能够建立一个内存优化的表类型:

CREATE TYPE dbo.SalesOrderMetadata
AS TABLE
( OrderID INT NOT NULL PRIMARY KEY NONCLUSTERED,
 CustomerID INT NOT NULL,
 SalespersonPersonID INT NOT NULL,
 ContactPersonID INT NOT NULL,
 INDEX IX_SalesOrderMetadata_CustomerID NONCLUSTERED HASH 
 (CustomerID) WITH (BUCKET_COUNT = 1000))
WITH (MEMORY_OPTIMIZED = ON);

这个TSQL建立了演示的触发器所须要的表:

CREATE TABLE dbo.OrderAdjustmentLog
( OrderAdjustmentLog_ID int NOT NULL IDENTITY(1,1) 
 CONSTRAINT PK_OrderAdjustmentLog PRIMARY KEY CLUSTERED,
 OrderID INT NOT NULL,
 CustomerID INT NOT NULL,
 SalespersonPersonID INT NOT NULL,
 ContactPersonID INT NOT NULL,
CreateTimeUTC DATETIME2(3) NOT NULL);

下面是一个使用内存优化表的触发器演示:

CREATE TRIGGER TR_Sales_Orders_Mem_Test
 ON Sales.Orders
 AFTER UPDATE
AS
BEGIN
 SET NOCOUNT ON;
 DECLARE @OrderData dbo.SalesOrderMetadata;
 INSERT INTO @OrderData
 (OrderID, CustomerID, SalespersonPersonID, 
 ContactPersonID)
 SELECT
 OrderID,
 CustomerID,
 SalespersonPersonID,
 ContactPersonID
 FROM Inserted;
 
 DELETE OrderData
 FROM @OrderData OrderData
 INNER JOIN sales.Customers
 ON Customers.CustomerID = OrderData.CustomerID
 WHERE Customers.IsOnCreditHold = 0;
 UPDATE OrderData
 SET ContactPersonID = 1
 FROM @OrderData OrderData
 WHERE OrderData.ContactPersonID IS NULL;
 
 INSERT INTO dbo.OrderAdjustmentLog
 (OrderID, CustomerID, SalespersonPersonID, 
 ContactPersonID, CreateTimeUTC)
 SELECT
 OrderData.OrderID,
 OrderData.CustomerID,
 OrderData.SalespersonPersonID,
 OrderData.ContactPersonID,
 SYSUTCDATETIME()
 FROM @OrderData OrderData;
END

触发器内须要的操做越多,节省的时间就越多,由于内存优化的表变量不须要IO来读/写。

一旦读取了来自所插入表的初始数据,触发器的其他部分就能够不处理tempdb,从而减小使用标准表变量或临时表的开销。

下面的代码设置了一些测试数据,并运行一个更新来演示上述代码的结果:

UPDATE Customers
 SET IsOnCreditHold = 1
FROM Sales.Customers
WHERE Customers.CustomerID = 832;
UPDATE Orders
 SET SalespersonPersonID = 2
FROM sales.Orders
WHERE CustomerID = 832;

一旦执行,OrderAdjustmentLog表的内容能够被验证:

结果是意料之中的。经过减小对标准存储的依赖并将中间表移动到内存中,内存优化表提供了一种大大提升触发速度的方法。这仅限于对临时对象有大量调用的场景,但在存储过程或其余过程性TSQL中也颇有用。

替代触发器

像全部的工具同样,触发器也可能被滥用,并成为混乱、性能瓶颈和可维护性噩梦的根源。有许多比触发器更可取的替代方案,在实现(或添加到现有的)触发器以前应该考虑它们。

Temporal tables

Temporal tables是在SQL Server 2016中引入的,它提供了一种向表添加版本控制的简单方法,无需构建本身的数据结构和ETL。这种记录对应用程序是不可见的,并提供了符合ANSI标准的完整版本支持,使之成为一种简单的方法来解决保存旧版本数据的问题。

Check约束

对于简单的数据验证,Check约束能够提供所需的内容,而不须要函数、存储过程或触发器。在列上定义Check约束,并在建立数据时自动验证数据。

下面是一个Check约束的示例:

ALTER TABLE Sales.Invoices WITH CHECK ADD CONSTRAINT
CK_Sales_Invoices_ReturnedDeliveryData_Must_Be_Valid_JSON
CHECK ([ReturnedDeliveryData] IS NULL OR 
ISJSON([ReturnedDeliveryData])<>(0))

这段代码检查一个列是不是有效的JSON。若是是,则执行正常进行。若是不是,那么SQL Server将抛出一个错误,写操做将失败。Check约束能够检查列和值的任何组合,所以能够管理简单或复杂的验证任务。

建立Check约束的成本不高,并且易于维护。它们也更容易记录和理解,由于Check约束的范围仅限于验证传入数据和确保数据完整性,而触发器实际上能够作任何能够想象的事情!

惟一约束

若是一个列须要惟一的值,而且不是表上的主键,那么惟一约束是完成该任务的一种简单而有效的方法。惟一约束是索引和惟一性的组合。为了有效地验证惟一性,索引是必需的。

下面是一个惟一约束的例子:

ALTER TABLE Warehouse.Colors ADD CONSTRAINT 
UQ_Warehouse_Colors_ColorName UNIQUE NONCLUSTERED (ColorName ASC);

每当一行被插入到 Warehouse.Colors表中,将检查ColorName的惟一性。若是写操做碰巧致使了重复的颜色,那么语句将失败,数据将不会被更改。为此目的构建了惟一约束,这是在列上强制惟一性的最简单方法。

内置的解决方案将更高效、更容易维护和更容易记录。任何看到惟一约束的开发人员都将当即理解它的做用,而不须要深刻挖掘TSQL来弄清事情是如何工做的,这种简单性使其成为理想的解决方案。

外键约束

与Check约束和惟一约束同样,外键约束是在写入数据以前验证数据完整性的另外一种方式。外键将一一表中的列连接到另外一张表。当数据插入到目标表时,它的值将根据引用的表进行检查。若是该值存在,则写操做正常进行。若是不是,则抛出错误,语句失败。

这是一个简单的外键例子:

ALTER TABLE Sales.Orders WITH CHECK ADD CONSTRAINT
FK_Sales_Orders_CustomerID_Sales_Customers FOREIGN KEY (CustomerID)
REFERENCES Sales.Customers (CustomerID);

当数据写入Sales.Orders时,CustomerID列将根据Sales.Customers中的CustomerID列进行检查。

与惟一约束相似,外键只有一个目的:验证写入一个表的数据是否存在于另外一个表中。它易于文档化,易于理解,实现效率高。

触发器不是执行这些验证检查的正确位置,与使用外键相比,它是效率较低的解决方案。

存储过程

在触发器中实现的逻辑一般能够很容易地移动到存储过程当中。这消除了大量触发代码可能致使的复杂性,同时容许开发人员更好的维护。存储过程能够自由地构造操做,以确保尽量多的原子性。

实现触发器的基本原则之一是确保一组操做与写操做一致。全部成功或失败都是做为原子事务的一部分。应用程序并不老是须要这种级别的原子性。若是有必要,能够在存储过程当中使用适当的隔离级别或表锁定来保证事务的完整性。

虽然SQL Server(和大多数RDBMS)提供了ACID保证事务将是原子的、一致的、隔离的和持久的,但咱们本身代码中的事务可能须要也可能不须要遵循相同的规则。现实世界的应用程序对数据完整性的需求各不相同。

存储过程容许自定义代码,以实现应用程序所需的数据完整性,确保性能和计算资源不会浪费在不须要的数据完整性上。

例如,一个容许用户发布照片的社交媒体应用程序不太可能须要它的事务彻底原子化和一致。若是个人照片出如今你以前或以后一秒,没人会在乎。一样,若是你在我编辑照片的时候评论个人照片,时间对使用这些数据的人来讲可能并不重要。另外一方面,一个管理货币交易的银行应用程序须要确保交易是谨慎执行的,这样就不会出现资金丢失或数字报告错误的状况。若是我有一个银行帐户,里面有20美圆,我取出20美圆的同时,其余人也取出了20美圆,咱们不可能都成功。咱们中的一个先获得20美圆,另外一个遇到关于0美圆余额的适当错误消息。

函数

函数提供了一种简单的方法,能够将重要的逻辑封装到一个单独的位置。在50个表插入中重用的单个函数比50个触发器(每一个表一个触发器)执行相同逻辑要容易得多。

考虑如下函数:

CREATE FUNCTION Website.CalculateCustomerPrice
 (@CustomerID INT, @StockItemID INT, @PricingDate DATE)
RETURNS DECIMAL(18,2)
WITH EXECUTE AS OWNER
AS
BEGIN
 DECLARE @CalculatedPrice decimal(18,2);
 DECLARE @UnitPrice decimal(18,2);
 DECLARE @LowestUnitPrice decimal(18,2);
 DECLARE @HighestDiscountAmount decimal(18,2);
 DECLARE @HighestDiscountPercentage decimal(18,3);
 DECLARE @BuyingGroupID int;
 DECLARE @CustomerCategoryID int;
 DECLARE @DiscountedUnitPrice decimal(18,2);
 SELECT @BuyingGroupID = BuyingGroupID,
 @CustomerCategoryID = CustomerCategoryID
 FROM Sales.Customers
 WHERE CustomerID = @CustomerID;
 SELECT @UnitPrice = si.UnitPrice
 FROM Warehouse.StockItems AS si
 WHERE si.StockItemID = @StockItemID;
 SET @CalculatedPrice = @UnitPrice;
 SET @LowestUnitPrice = (
 SELECT MIN(sd.UnitPrice)
 FROM Sales.SpecialDeals AS sd
 WHERE ((sd.StockItemID = @StockItemID) 
 OR (sd.StockItemID IS NULL))
 AND ((sd.CustomerID = @CustomerID) 
 OR (sd.CustomerID IS NULL))
 AND ((sd.BuyingGroupID = @BuyingGroupID) 
 OR (sd.BuyingGroupID IS NULL))
 AND ((sd.CustomerCategoryID = @CustomerCategoryID) 
 OR (sd.CustomerCategoryID IS NULL))
 AND ((sd.StockGroupID IS NULL) OR EXISTS (SELECT 1 
 FROM Warehouse.StockItemStockGroups AS sisg
 WHERE sisg.StockItemID = @StockItemID
 AND sisg.StockGroupID = sd.StockGroupID))
 AND sd.UnitPrice IS NOT NULL
 AND @PricingDate BETWEEN sd.StartDate AND sd.EndDate);
 IF @LowestUnitPrice IS NOT NULL AND @LowestUnitPrice < @UnitPrice
 BEGIN
 SET @CalculatedPrice = @LowestUnitPrice;
 END;
 SET @HighestDiscountAmount = (
 SELECT MAX(sd.DiscountAmount)
 FROM Sales.SpecialDeals AS sd
 WHERE ((sd.StockItemID = @StockItemID) 
 OR (sd.StockItemID IS NULL))
 AND ((sd.CustomerID = @CustomerID) 
 OR (sd.CustomerID IS NULL))
 AND ((sd.BuyingGroupID = @BuyingGroupID) 
 OR (sd.BuyingGroupID IS NULL))
 AND ((sd.CustomerCategoryID = @CustomerCategoryID) 
 OR (sd.CustomerCategoryID IS NULL))
 AND ((sd.StockGroupID IS NULL) OR EXISTS 
 (SELECT 1 FROM Warehouse.StockItemStockGroups AS sisg 
 WHERE sisg.StockItemID = @StockItemID
 AND sisg.StockGroupID = sd.StockGroupID))
 AND sd.DiscountAmount IS NOT NULL
 AND @PricingDate BETWEEN sd.StartDate AND sd.EndDate);
 IF @HighestDiscountAmount IS NOT NULL AND (
 @UnitPrice - @HighestDiscountAmount) < @CalculatedPrice
 BEGIN
 SET @CalculatedPrice = @UnitPrice - @HighestDiscountAmount;
 END;
 SET @HighestDiscountPercentage = (
 SELECT MAX(sd.DiscountPercentage)
 FROM Sales.SpecialDeals AS sd
 WHERE ((sd.StockItemID = @StockItemID)
 OR (sd.StockItemID IS NULL))
 AND ((sd.CustomerID = @CustomerID) 
 OR (sd.CustomerID IS NULL))
 AND ((sd.BuyingGroupID = @BuyingGroupID) 
 OR (sd.BuyingGroupID IS NULL))
 AND ((sd.CustomerCategoryID = @CustomerCategoryID) 
 OR (sd.CustomerCategoryID IS NULL))
 AND ((sd.StockGroupID IS NULL) OR EXISTS 
 (SELECT 1 FROM Warehouse.StockItemStockGroups AS sisg
 WHERE sisg.StockItemID = @StockItemID
 AND sisg.StockGroupID = sd.StockGroupID))
 AND sd.DiscountPercentage IS NOT NULL
 AND @PricingDate BETWEEN sd.StartDate AND sd.EndDate);
 IF @HighestDiscountPercentage IS NOT NULL
 BEGIN
 SET @DiscountedUnitPrice = ROUND(@UnitPrice * 
 @HighestDiscountPercentage / 100.0, 2);
 IF @DiscountedUnitPrice < @CalculatedPrice 
 SET @CalculatedPrice = @DiscountedUnitPrice;
 END;
 RETURN @CalculatedPrice;
END;

就复杂性而言,这绝对是一头猛兽。虽然它接受标量参数来肯定计算价格,但它执行的操做很是大,甚至包括对Warehouse.StockItemStockGroups, Warehouse.StockItems和Sales.Customers的额外读取。若是这是一个常常针对单行数据使用的关键计算,那么将其封装在一个函数中是得到所需计算的一种简单方法,而不会增长触发器的复杂性。当心使用函数,并确保使用大型数据集进行测试。简单的标量函数一般能够很好地伸缩性较大的数据,但更复杂的函数可能性能较差。

编码

当从应用程序修改表中的数据时,还能够在写入数据以前执行额外的数据操做或验证。这一般代价低廉,性能很好,并有助于减小失控触发器对数据库的负面影响。

将代码放入触发器的常见理由是,这样作能够避免修改代码、推送构建,不然会致使更改应用程序。这与在数据库中进行更改相关的任何风险直接相反。这一般是应用程序开发人员和数据库开发人员之间关于谁将负责新代码的讨论。

这是一个粗略的指导方针,但有助于在代码添加到应用程序或触发器以后测量可维护性和风险。

计算列

其余列发生更改时,计算列能够包括经过各类各样的算术运算和函数进行计算,获得结果。它们能够包含在索引中,也能够包含在惟一的约束中,甚至主键中。

当任何底层值发生变化时,SQL Server会自动维护计算的列。注意,每一个计算出来的列最终都是由表中其余列的值决定的。

这是使用触发器来维护指定列值的一种很好的替代方法。计算列是高效的、自动的,而且不须要维护。它们只是简单地工做,甚至容许将复杂的计算直接集成到一个表中,而在应用程序或SQL Server中不须要额外的代码。

使用SQL Server触发器

触发器在SQL Server中是一个有用的特性,但像全部工具同样,它也可能被误用或滥用。在决定是否使用触发器时,必定要考虑触发器的目的。

若是一个触发器被用来将简短的事务数据写入日志表,那么它极可能是一个很好的触发器。若是触发器被用来强制执行复杂的业务规则,那么极可能须要从新考虑处理这类操做的最佳方式。

有不少工具能够做为触发器的可行替代品,好比检查约束、计算列等,解决问题的方法并不短缺。数据库体系结构的成功在于为工做选择正确的工具。

相关文章
相关标签/搜索