使用触发器进行同步

Yip*_*ing 11 trigger sql-server transaction acid data-synchronization

我有一个类似于之前讨论的要求:

我有两张桌子,[Account].[Balance]并且[Transaction].[Amount]

CREATE TABLE Account (
      AccountID    INT
    , Balance      MONEY
);

CREATE TABLE Transaction (
      TransactionID INT
     , AccountID    INT
    , Amount      MONEY
);
Run Code Online (Sandbox Code Playgroud)

当对[Transaction]表进行插入、更新或删除操作时,[Account].[Balance]应根据[Amount].

目前我有一个触发器来完成这项工作:

ALTER TRIGGER [dbo].[TransactionChanged] 
ON  [dbo].[Transaction]
AFTER INSERT, UPDATE, DELETE
AS 
BEGIN
IF  EXISTS (select 1 from [Deleted]) OR EXISTS (select 1 from [Inserted])
    UPDATE [dbo].[Account]
    SET
    [Account].[Balance] = [Account].[Balance] + 
        (
            Select ISNULL(Sum([Inserted].[Amount]),0)
            From [Inserted] 
            Where [Account].[AccountID] = [Inserted].[AccountID]
        )
        -
        (
            Select ISNULL(Sum([Deleted].[Amount]),0)
            From [Deleted] 
            Where [Account].[AccountID] = [Deleted].[AccountID]
        )
END
Run Code Online (Sandbox Code Playgroud)

虽然这似乎有效,但我有疑问:

  1. 触发器是否遵循关系数据库的 ACID 原则?是否有可能提交插入但触发器失败?
  2. 我的IFUPDATE声明看起来很奇怪。有没有更好的方法来更新正确的[Account]行?

Pau*_*ite 13

1.触发器是否遵循关系数据库的ACID原则?是否有可能提交插入但触发器失败?

这个问题在您链接到的相关问题中得到了部分回答。触发器代码在与导致它触发的 DML 语句相同的事务上下文中执行,保留了您提到的 ACID 原则的原子部分。触发语句和触发代码都作为一个单元成功或失败。

ACID特性也保证了整个交易(包括触发代码)将离开数据库在不违反任何明确的限制(的状态一致)和可恢复的承诺的影响将生存在数据库崩溃(耐用)。

除非周围的(也许是隐式或自动提交)事务是在运行SERIALIZABLE隔离级别,该隔离属性不会自动保证。其他并发数据库活动可能会干扰触发器代码的正确操作。例如,在您阅读它之后和更新它之前,另一个会话可能会更改帐户余额 - 一个经典的竞争条件。

2. 我的 IF 和 UPDATE 语句看起来很奇怪。有没有更好的方法来更新正确的 [Account] 行?

您链接的另一个问题没有提供任何基于触发器的解决方案,这是有充分理由。旨在保持非规范化结构同步的触发代码可能非常难以正确正确测试。即使是具有多年经验的非常高级的 SQL Server 人员也很难解决这个问题。

在保持所有场景中的正确性的同时保持良好的性能并避免死锁等问题会增加额外的难度。您的触发代码远非健壮,即使仅修改单个交易,它也会更新每个帐户的余额。基于触发器的解决方案存在各种风险和挑战,这使得该任务非常不适合该技术领域相对较新的人。

为了说明一些问题,我在下面展示了一些示例代码。这不是经过严格测试的解决方案(触发器很难!),我不建议您将其用作学习练习以外的任何东西。对于一个真实的系统,非触发解决方案有重要的好处,所以你应该仔细查看另一个问题的答案,完全避免触发的想法。

示例表

CREATE TABLE dbo.Accounts
(
    AccountID integer NOT NULL,
    Balance money NOT NULL,

    CONSTRAINT PK_Accounts_ID
    PRIMARY KEY CLUSTERED (AccountID)
);

CREATE TABLE dbo.Transactions
(
    TransactionID integer IDENTITY NOT NULL,
    AccountID integer NOT NULL,
    Amount money NOT NULL,

    CONSTRAINT PK_Transactions_ID
    PRIMARY KEY CLUSTERED (TransactionID),

    CONSTRAINT FK_Accounts
    FOREIGN KEY (AccountID)
    REFERENCES dbo.Accounts (AccountID)
);
Run Code Online (Sandbox Code Playgroud)

预防 TRUNCATE TABLE

触发器不会被 触发TRUNCATE TABLE。以下空表的存在纯粹是为了防止Transactions表被截断(被外键引用可防止表被截断):

CREATE TABLE dbo.PreventTransactionsTruncation
(
    Dummy integer NULL,

    CONSTRAINT FK_Transactions
    FOREIGN KEY (Dummy)
    REFERENCES dbo.Transactions (TransactionID),

    CONSTRAINT CHK_NoRows
    CHECK (Dummy IS NULL AND Dummy IS NOT NULL)
);
Run Code Online (Sandbox Code Playgroud)

触发器定义

以下触发代码确保只维护必要的帐户条目,并SERIALIZABLE在那里使用语义。作为一个理想的副作用,这也避免了在使用行版本控制隔离级别时可能导致的错误结果。如果没有行受源语句影响,代码还避免执行触发器代码。临时表和RECOMPILE提示用于避免基数估计不准确导致的触发器执行计划问题:

CREATE TRIGGER dbo.TransactionChange ON dbo.Transactions 
AFTER INSERT, UPDATE, DELETE 
AS
BEGIN
IF @@ROWCOUNT = 0 OR
    TRIGGER_NESTLEVEL
    (
        OBJECT_ID(N'dbo.TransactionChange', N'TR'),
        'AFTER', 
        'DML'
    ) > 1 
    RETURN;

    SET NOCOUNT, XACT_ABORT ON;

    CREATE TABLE #Delta
    (
        AccountID integer PRIMARY KEY,
        Amount money NOT NULL
    );

    INSERT #Delta
        (AccountID, Amount)
    SELECT 
        InsDel.AccountID,
        Amount = SUM(InsDel.Amount)
    FROM 
    (
        SELECT AccountID, Amount
        FROM Inserted
        UNION ALL
        SELECT AccountID, $0 - Amount
        FROM Deleted
    ) AS InsDel
    GROUP BY
        InsDel.AccountID;

    UPDATE A
    SET Balance += D.Amount
    FROM #Delta AS D
    JOIN dbo.Accounts AS A WITH (SERIALIZABLE)
        ON A.AccountID = D.AccountID
    OPTION (RECOMPILE);
END;
Run Code Online (Sandbox Code Playgroud)

测试

以下代码使用数字表创建 100,000 个余额为零的帐户:

INSERT dbo.Accounts
    (AccountID, Balance)
SELECT
    N.n, $0
FROM dbo.Numbers AS N
WHERE
    N.n BETWEEN 1 AND 100000;
Run Code Online (Sandbox Code Playgroud)

下面的测试代码插入了 10,000 个随机交易:

INSERT dbo.Transactions
    (AccountID, Amount)
SELECT 
    CONVERT(integer, RAND(CHECKSUM(NEWID())) * 100000 + 1),
    CONVERT(money, RAND(CHECKSUM(NEWID())) * 500 - 250)
FROM dbo.Numbers AS N
WHERE 
    N.n BETWEEN 1 AND 10000;
Run Code Online (Sandbox Code Playgroud)

使用SQLQueryStress工具,我在 32 个线程上运行了 100 次测试,性能良好,没有死锁,结果正确。我仍然不建议将此作为学习练习以外的任何内容。