喜憂參半的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中是一個有用的特性,但像全部工具同樣,它也可能被誤用或濫用。在決定是否使用觸發器時,必定要考慮觸發器的目的。

若是一個觸發器被用來將簡短的事務數據寫入日誌表,那麼它極可能是一個很好的觸發器。若是觸發器被用來強制執行複雜的業務規則,那麼極可能須要從新考慮處理這類操做的最佳方式。

有不少工具能夠做爲觸發器的可行替代品,好比檢查約束、計算列等,解決問題的方法並不短缺。數據庫體系結構的成功在於爲工做選擇正確的工具。

相關文章
相關標籤/搜索