Sincronização usando gatilhos

11

Eu tenho um requisito semelhante às discussões anteriores em:

Eu tenho duas tabelas [Account].[Balance]e [Transaction].[Amount]:

CREATE TABLE Account (
      AccountID    INT
    , Balance      MONEY
);

CREATE TABLE Transaction (
      TransactionID INT
     , AccountID    INT
    , Amount      MONEY
);

Quando houver uma inserção, atualização ou exclusão na [Transaction]tabela, ela [Account].[Balance]deverá ser atualizada com base no [Amount].

Atualmente, tenho um gatilho para fazer este trabalho:

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

Embora isso pareça estar funcionando, tenho perguntas:

  1. O gatilho segue o princípio ACID do banco de dados relacional? Existe alguma chance de uma inserção ser confirmada, mas o gatilho falha?
  2. Minhas declarações IFe UPDATEparecem estranhas. Existe alguma maneira melhor de atualizar a [Account]linha correta ?
Yiping
fonte

Respostas:

13

1. O gatilho segue o princípio ACID do banco de dados relacional? Existe alguma chance de uma inserção ser confirmada, mas o gatilho falha?

Esta pergunta é parcialmente respondida em uma pergunta relacionada à qual você vinculou. O código de acionamento é executado no mesmo contexto transacional que a instrução DML que causou o disparo, preservando a parte Atômica dos princípios ACID mencionados. A instrução de acionamento e o código de acionador são bem-sucedidos ou falham como uma unidade.

As propriedades ACID também garantem que toda a transação (incluindo o código do acionador) deixará o banco de dados em um estado que não viole restrições explícitas ( Consistente ) e quaisquer efeitos confirmados recuperáveis ​​sobreviverão a uma falha no banco de dados ( Durável ).

A menos que a transação circundante (talvez implícita ou confirmada automaticamente) esteja sendo executada no SERIALIZABLEnível de isolamento , a propriedade Isolated não será garantida automaticamente. Outra atividade simultânea do banco de dados pode interferir na operação correta do seu código de gatilho. Por exemplo, o saldo da conta pode ser alterado por outra sessão após a leitura e antes da atualização - uma condição de corrida clássica.

2. Minhas declarações IF e UPDATE parecem estranhas. Existe alguma maneira melhor de atualizar a linha [Conta] correta?

Há boas razões pelas quais a outra pergunta à qual você se vinculou não oferece nenhuma solução baseada em gatilho. O código de acionamento projetado para manter uma estrutura desnormalizada sincronizada pode ser extremamente complicado para acertar e testar corretamente. Até pessoas muito avançadas do SQL Server com muitos anos de experiência lutam com isso.

Manter um bom desempenho ao mesmo tempo em que preserva a correção em todos os cenários e evita problemas como conflitos aumenta dimensões adicionais de dificuldade. Seu código de gatilho não está nem perto de ser robusto e atualiza o saldo de todas as contas, mesmo que apenas uma única transação seja modificada. Existem todos os tipos de riscos e desafios com uma solução baseada em gatilho, o que torna a tarefa profundamente inadequada para alguém relativamente novo nessa área de tecnologia.

Para ilustrar alguns dos problemas, mostro alguns exemplos de código abaixo. Esta não é uma solução rigorosamente testada (os gatilhos são difíceis!) E não estou sugerindo que você a use como outra coisa senão um exercício de aprendizado. Para um sistema real, as soluções não acionadoras têm benefícios importantes, portanto, você deve revisar cuidadosamente as respostas para a outra pergunta e evitar completamente a ideia do acionador.

Tabelas de amostra

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)
);

Prevenção TRUNCATE TABLE

Os gatilhos não são acionados por TRUNCATE TABLE. A tabela vazia a seguir existe apenas para impedir que a Transactionstabela seja truncada (ser referenciada por uma chave estrangeira impede o truncamento da tabela):

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)
);

Definição de gatilho

O código de acionamento a seguir garante que apenas as entradas de conta necessárias sejam mantidas e use a SERIALIZABLEsemântica. Como efeito colateral desejável, isso também evita resultados incorretos que podem resultar se um nível de isolamento de versão de linha estiver em uso. O código também evita a execução do código acionador se nenhuma linha foi afetada pela instrução de origem. A tabela e a RECOMPILEdica temporárias são usadas para evitar problemas no plano de execução do acionador causados ​​por estimativas imprecisas da cardinalidade:

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;

Teste

O código a seguir usa uma tabela de números para criar 100.000 contas com saldo zero:

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

O código de teste abaixo insere 10.000 transações aleatórias:

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;

Usando a ferramenta SQLQueryStress , eu executei esse teste 100 vezes em 32 threads com bom desempenho, sem conflitos e resultados corretos. Eu ainda não recomendo isso como algo além de um exercício de aprendizado.

Paul White 9
fonte