Impossible d'insérer une ligne de clé en double sur un index non unique?

14

Nous avons rencontré cette étrange erreur trois fois au cours des derniers jours, après avoir été sans erreur pendant 8 semaines, et je suis perplexe.

Voici le message d'erreur:

Executing the query "EXEC dbo.MergeTransactions" failed with the following error:
"Cannot insert duplicate key row in object 'sales.Transactions' with unique index
'NCI_Transactions_ClientID_TransactionDate'.
The duplicate key value is (1001, 2018-12-14 19:16:29.00, 304050920).".

L'indice que nous avons n'est pas unique. Si vous remarquez, la valeur de clé en double dans le message d'erreur ne correspond même pas à l'index. Chose étrange, si je relance le proc, il réussit.

C'est le lien le plus récent que j'ai pu trouver qui a mes problèmes mais je ne vois pas de solution.

https://www.sqlservercentral.com/forums/topic/error-cannot-insert-duplicate-key-row-in-a-non-unique-index

Quelques choses sur mon scénario:

  • Le proc met à jour le TransactionID (une partie de la clé primaire) - je pense que c'est ce qui cause l'erreur mais je ne sais pas pourquoi? Nous allons supprimer cette logique.
  • Le suivi des modifications est activé sur la table
  • Faire la transaction en lecture non validée

Il y a 45 champs pour chaque table, j'ai surtout répertorié ceux utilisés dans les index. Je mets à jour le TransactionID (clé en cluster) dans l'instruction de mise à jour (inutilement). Étrange que nous n'ayons eu aucun problème pendant des mois jusqu'à la semaine dernière. Et cela ne se produit que sporadiquement via SSIS.

Table

USE [DB]
GO

/****** Object:  Table [sales].[Transactions]    Script Date: 5/29/2019 1:37:49 PM ******/
SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[sales].[Transactions]') AND type in (N'U'))
BEGIN
CREATE TABLE [sales].[Transactions]
(
    [TransactionID] [bigint] NOT NULL,
    [ClientID] [int] NOT NULL,
    [TransactionDate] [datetime2](2) NOT NULL,
    /* snip*/
    [BusinessUserID] [varchar](150) NOT NULL,
    [BusinessTransactionID] [varchar](150) NOT NULL,
    [InsertDate] [datetime2](2) NOT NULL,
    [UpdateDate] [datetime2](2) NOT NULL,
 CONSTRAINT [PK_Transactions_TransactionID] PRIMARY KEY CLUSTERED 
(
    [TransactionID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, DATA_COMPRESSION=PAGE) ON [DB_Data]
) ON [DB_Data]
END
GO
USE [DB]

IF NOT EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[sales].[Transactions]') AND name = N'NCI_Transactions_ClientID_TransactionDate')
begin
CREATE NONCLUSTERED INDEX [NCI_Transactions_ClientID_TransactionDate] ON [sales].[Transactions]
(
    [ClientID] ASC,
    [TransactionDate] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, DATA_COMPRESSION = PAGE) ON [DB_Data]
END

IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[sales].[DF_Transactions_Units]') AND type = 'D')
BEGIN
ALTER TABLE [sales].[Transactions] ADD  CONSTRAINT [DF_Transactions_Units]  DEFAULT ((0)) FOR [Units]
END
GO

IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[sales].[DF_Transactions_ISOCurrencyCode]') AND type = 'D')
BEGIN
ALTER TABLE [sales].[Transactions] ADD  CONSTRAINT [DF_Transactions_ISOCurrencyCode]  DEFAULT ('USD') FOR [ISOCurrencyCode]
END
GO

IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[sales].[DF_Transactions_InsertDate]') AND type = 'D')
BEGIN
ALTER TABLE [sales].[Transactions] ADD  CONSTRAINT [DF_Transactions_InsertDate]  DEFAULT (sysdatetime()) FOR [InsertDate]
END
GO

IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[sales].[DF_Transactions_UpdateDate]') AND type = 'D')
BEGIN
ALTER TABLE [sales].[Transactions] ADD  CONSTRAINT [DF_Transactions_UpdateDate]  DEFAULT (sysdatetime()) FOR [UpdateDate]
END
GO

table temporaire

same columns as the mgdata. including the relevant fields. Also has a non-unique clustered index
(
    [BusinessTransactionID] [varchar](150) NULL,
    [BusinessUserID] [varchar](150) NULL,
    [PostalCode] [varchar](25) NULL,
    [TransactionDate] [datetime2](2) NULL,

    [Units] [int] NOT NULL,
    [StartDate] [datetime2](2) NULL,
    [EndDate] [datetime2](2) NULL,
    [TransactionID] [bigint] NULL,
    [ClientID] [int] NULL,

) 

CREATE CLUSTERED INDEX ##workingTransactionsMG_idx ON #workingTransactions (TransactionID)

It is populated in batches (500k rows at a time), something like this
IF OBJECT_ID(N'tempdb.dbo.#workingTransactions') IS NOT NULL DROP TABLE #workingTransactions;
select fields 
into #workingTransactions
from import.Transactions
where importrowid between two number ranges -- pseudocode

Clé primaire

 CONSTRAINT [PK_Transactions_TransactionID] PRIMARY KEY CLUSTERED 
(
    [TransactionID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, DATA_COMPRESSION=PAGE) ON [Data]
) ON [Data]

Index non clusterisé

CREATE NONCLUSTERED INDEX [NCI_Transactions_ClientID_TransactionDate] ON [sales].[Transactions]
(
    [ClientID] ASC,
    [TransactionDate] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, DATA_COMPRESSION = PAGE)

exemple de déclaration de mise à jour

-- updates every field
update t 
set 
    t.transactionid = s.transactionid,
    t.[CityCode]=s.[CityCode],
      t.TransactionDate=s.[TransactionDate],
     t.[ClientID]=s.[ClientID],
                t.[PackageMonths] = s.[PackageMonths],
                t.UpdateDate = @UpdateDate
              FROM #workingTransactions s
              JOIN [DB].[sales].[Transactions] t 
              ON s.[TransactionID] = t.[TransactionID]
             WHERE CAST(HASHBYTES('SHA2_256 ',CONCAT( S.[BusinessTransactionID],'|',S.[BusinessUserID],'|', etc)
                <> CAST(HASHBYTES('SHA2_256 ',CONCAT( T.[BusinessTransactionID],'|',T.[BusinessUserID],'|', etc)

Ma question est, que se passe-t-il sous le capot? Et quelle est la solution? Pour référence, le lien ci-dessus mentionne ceci:

À ce stade, j'ai quelques théories:

  • Bug lié à la pression de la mémoire ou au grand plan de mise à jour parallèle, mais je m'attendrais à un type d'erreur différent et jusqu'à présent, je ne peux pas corréler les faibles ressources temporelles de ces erreurs isolées et sporadiques.
  • Un bogue dans l'instruction ou les données UPDATE provoque une violation en double réelle sur la clé primaire, mais un bogue obscur de SQL Server entraîne un message d'erreur qui cite le mauvais nom d'index.
  • Lectures sales résultant d'une isolation non validée en lecture provoquant une double mise à jour parallèle à double insertion. Mais les développeurs ETL prétendent que la lecture par défaut validée est utilisée, et il est difficile de déterminer exactement quel niveau d'isolement le processus est réellement utilisé lors de l'exécution.

Je soupçonne que si je modifie le plan d'exécution comme solution de contournement, peut-être un indice MAXDOP (1) ou l'utilisation d'un indicateur de trace de session pour désactiver le fonctionnement du spouleur, l'erreur disparaîtra simplement, mais on ne sait pas comment cela affecterait les performances

Version

Microsoft SQL Server 2017 (RTM-CU13) (KB4466404) - 14.0.3048.4 (X64) 30 novembre 2018 12:57:58 Copyright (C) 2017 Microsoft Corporation Enterprise Edition (64 bits) sur Windows Server 2016 Standard 10.0 (Build 14393 :)

Gabe
la source

Réponses:

10

Ma question est, que se passe-t-il sous le capot? Et quelle est la solution?

C'est un bug. Le problème est que cela ne se produit qu'occasionnellement et sera difficile à reproduire. Pourtant, votre meilleure chance est d'engager le support Microsoft. Le traitement des mises à jour est extrêmement complexe, ce qui nécessitera une enquête très détaillée.

Pour un exemple du genre de complexités impliquées, jetez un oeil à mes messages MERGE Bug avec des index filtrés et des résultats incorrects avec des vues indexées . Aucun de ces éléments n'est directement lié à votre problème, mais ils donnent une saveur.

Rédiger une mise à jour déterministe

C'est bien sûr assez générique. Peut-être plus utilement, je peux dire que vous devriez chercher à réécrire votre UPDATEdéclaration actuelle . Comme le dit la documentation :

Soyez prudent lorsque vous spécifiez la clause FROM pour fournir les critères de l'opération de mise à jour. Les résultats d'une instruction UPDATE ne sont pas définis si l'instruction inclut une clause FROM qui n'est pas spécifiée de telle sorte qu'une seule valeur est disponible pour chaque occurrence de colonne qui est mise à jour, c'est-à-dire si l'instruction UPDATE n'est pas déterministe.

Votre UPDATEn'est pas déterministe et les résultats ne sont donc pas définis . Vous devez le modifier pour qu'au plus une ligne source soit identifiée pour chaque ligne cible. Sans cette modification, le résultat de la mise à jour peut ne refléter aucune ligne source individuelle.

Exemple

Permettez-moi de vous montrer un exemple, en utilisant des tableaux calqués sur ceux donnés dans la question:

CREATE TABLE dbo.Transactions
(
    TransactionID bigint NOT NULL,
    ClientID integer NOT NULL,
    TransactionDate datetime2(2) NOT NULL,

    CONSTRAINT PK_dbo_Transactions
        PRIMARY KEY CLUSTERED (TransactionID),

    INDEX dbo_Transactions_ClientID_TranDate
        (ClientID, TransactionDate)
);

CREATE TABLE #Working
(
    TransactionID bigint NULL,
    ClientID integer NULL,
    TransactionDate datetime2(2) NULL,

    INDEX cx CLUSTERED (TransactionID)
);

Pour simplifier les choses, placez une ligne dans la table cible et quatre lignes dans la source:

INSERT dbo.Transactions 
    (TransactionID, ClientID, TransactionDate)
VALUES 
    (1, 1, '2019-01-01');

INSERT #Working 
    (TransactionID, ClientID, TransactionDate)
VALUES 
    (1, 2, NULL),
    (1, NULL, '2019-03-03'),
    (1, 3, NULL),
    (1, NULL, '2019-02-02');

Les quatre lignes source correspondent à la cible TransactionID, alors laquelle sera utilisée si nous exécutons une mise à jour (comme celle de la question) qui se joint à elle TransactionIDseule?

UPDATE T
SET T.TransactionID = W.TransactionID,
    T.ClientID = W.ClientID,
    T.TransactionDate = W.TransactionDate
FROM #Working AS W
JOIN dbo.Transactions AS T
    ON T.TransactionID = W.TransactionID;

(La mise à jour de la TransactionIDcolonne n'est pas importante pour la démo, vous pouvez la commenter si vous le souhaitez.)

La première surprise est que le se UPDATEtermine sans erreur, bien que la table cible n'autorise pas les null dans aucune colonne (toutes les lignes candidates contiennent un null).

Le point important est que le résultat n'est pas défini et, dans ce cas, produit un résultat qui ne correspond à aucune des lignes source:

SELECT
    T.TransactionID,
    T.ClientID,
    T.TransactionDate
FROM dbo.Transactions AS T;
╔═══════════════╦══════════╦════════════════════════╗
║ TransactionID ║ ClientID ║    TransactionDate     ║
╠═══════════════╬══════════╬════════════════════════╣
║             1 ║        2 ║ 2019-03-03 00:00:00.00 ║
╚═══════════════╩══════════╩════════════════════════╝

démo db <> fiddle

Plus de détails: N'IMPORTE QUEL agrégat est cassé

La mise à jour doit être écrite de manière à réussir si elle est écrite en tant qu'instruction équivalente MERGE, ce qui vérifie les tentatives de mise à jour de la même ligne cible plusieurs fois. Je ne recommande généralement pas d'utiliser MERGEdirectement, car il a été soumis à de nombreux bogues d'implémentation et a normalement de moins bonnes performances.

En prime, vous pouvez constater que la réécriture de votre mise à jour actuelle pour être déterministe entraînera également la disparition de votre problème de bogue occasionnel. Le bug du produit existera toujours pour les personnes qui écrivent des mises à jour non déterministes bien sûr.

Paul White 9
la source