web-dev-qa-db-fra.com

Blocage SQL sur la même clé en cluster exclusivement verrouillée (avec NHibernate) lors de la suppression / insertion

Je travaille sur ce problème de blocage depuis quelques jours maintenant et quoi que je fasse, il persiste d'une manière ou d'une autre.

Tout d'abord, la prémisse générale: nous avons des visites avec VisitItems dans une relation un à plusieurs.

VisitItems informations pertinentes:

CREATE TABLE [BAR].[VisitItems] (
    [Id]                INT             IDENTITY (1, 1) NOT NULL,
    [VisitType]         INT             NOT NULL,
    [FeeRateType]       INT             NOT NULL,
    [Amount]            DECIMAL (18, 2) NOT NULL,
    [GST]               DECIMAL (18, 2) NOT NULL,
    [Quantity]          INT             NOT NULL,
    [Total]             DECIMAL (18, 2) NOT NULL,
    [ServiceFeeType]    INT   NOT NULL,
    [ServiceText]       NVARCHAR (200)  NULL,
    [InvoicingProviderId] INT   NULL,
    [FeeItemId]        INT             NOT NULL,
    [VisitId]          INT             NULL,
    [IsDefault] BIT NOT NULL DEFAULT 0, 
    [SourceVisitItemId] INT NULL, 
    [OverrideCode] INT NOT NULL DEFAULT 0, 
    [InvoiceToCentre] BIT NOT NULL DEFAULT 0, 
    [IsSurchargeItem] BIT NOT NULL DEFAULT 0, 
    CONSTRAINT [PK_BAR.VisitItems] PRIMARY KEY CLUSTERED ([Id] ASC),
    CONSTRAINT [FK_BAR.VisitItems_BAR.FeeItems_FeeItem_Id] FOREIGN KEY ([FeeItemId]) REFERENCES [BAR].[FeeItems] ([Id]),
    CONSTRAINT [FK_BAR.VisitItems_BAR.Visits_Visit_Id] FOREIGN KEY ([VisitId]) REFERENCES [BAR].[Visits] ([Id]), 
    CONSTRAINT [FK_BAR.VisitItems_BAR.VisitTypes] FOREIGN KEY ([VisitType]) REFERENCES [BAR].[VisitTypes]([Id]), 
    CONSTRAINT [FK_BAR.VisitItems_BAR.FeeRateTypes] FOREIGN KEY ([FeeRateType]) REFERENCES [BAR].[FeeRateTypes]([Id]),
    CONSTRAINT [FK_BAR.VisitItems_CMN.Users_Id] FOREIGN KEY (InvoicingProviderId) REFERENCES [CMN].[Users] ([Id]),
    CONSTRAINT [FK_BAR.VisitItems_BAR.VisitItems_SourceVisitItem_Id] FOREIGN KEY ([SourceVisitItemId]) REFERENCES [BAR].[VisitItems]([Id]),
    CONSTRAINT [CK_SourceVisitItemId_Not_Equal_Id] CHECK ([SourceVisitItemId] <> [Id]),
    CONSTRAINT [FK_BAR.VisitItems_BAR.OverrideCodes] FOREIGN KEY ([OverrideCode]) REFERENCES [BAR].[OverrideCodes]([Id]),
    CONSTRAINT [FK_BAR.VisitItems_BAR.ServiceFeeTypes] FOREIGN KEY ([ServiceFeeType]) REFERENCES [BAR].[ServiceFeeTypes]([Id])
)

CREATE NONCLUSTERED INDEX [IX_FeeItem_Id]
    ON [BAR].[VisitItems]([FeeItemId] ASC)

CREATE NONCLUSTERED INDEX [IX_Visit_Id]
    ON [BAR].[VisitItems]([VisitId] ASC)

Infos visite:

CREATE TABLE [BAR].[Visits] (
    [Id]                     INT            IDENTITY (1, 1) NOT NULL,
    [VisitType]              INT            NOT NULL,
    [DateOfService]          DATETIMEOFFSET  NOT NULL,
    [InvoiceAnnotation]      NVARCHAR(255)  NULL ,
    [PatientId]              INT            NOT NULL,
    [UserId]                 INT            NULL,
    [WorkAreaId]             INT            NOT NULL, 
    [DefaultItemOverride] BIT NOT NULL DEFAULT 0, 
    [DidNotWaitAdjustmentId] INT NULL, 
    [AppointmentId] INT NULL, 
    CONSTRAINT [PK_BAR.Visits] PRIMARY KEY CLUSTERED ([Id] ASC),
    CONSTRAINT [FK_BAR.Visits_CMN.Patients] FOREIGN KEY ([PatientId]) REFERENCES [CMN].[Patients] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_BAR.Visits_CMN.Users] FOREIGN KEY ([UserId]) REFERENCES [CMN].[Users] ([Id]),
    CONSTRAINT [FK_BAR.Visits_CMN.WorkAreas_WorkAreaId] FOREIGN KEY ([WorkAreaId]) REFERENCES [CMN].[WorkAreas] ([Id]), 
    CONSTRAINT [FK_BAR.Visits_BAR.VisitTypes] FOREIGN KEY ([VisitType]) REFERENCES [BAR].[VisitTypes]([Id]),
    CONSTRAINT [FK_BAR.Visits_BAR.Adjustments] FOREIGN KEY ([DidNotWaitAdjustmentId]) REFERENCES [BAR].[Adjustments]([Id]), 
);

CREATE NONCLUSTERED INDEX [IX_Visits_PatientId]
    ON [BAR].[Visits]([PatientId] ASC);

CREATE NONCLUSTERED INDEX [IX_Visits_UserId]
    ON [BAR].[Visits]([UserId] ASC);

CREATE NONCLUSTERED INDEX [IX_Visits_WorkAreaId]
    ON [BAR].[Visits]([WorkAreaId]);

Plusieurs utilisateurs souhaitent mettre à jour la table VisitItems simultanément de la manière suivante:

Une demande Web distincte créera une visite avec VisitItems (généralement 1). Ensuite (la demande de problème):

  1. La demande Web arrive, ouvre la session NHibernate, démarre la transaction NHibernate (en utilisant la lecture répétable avec READ_COMMITTED_SNAPSHOT activé).
  2. Lisez tous les éléments de visite pour une visite donnée par VisitId .
  3. Le code évalue si les éléments sont toujours pertinents ou si nous en avons besoin de nouveaux en utilisant des règles complexes (donc un peu long, par exemple 40 ms).
  4. Le code trouve qu'un élément doit être ajouté, l'ajoute en utilisant NHibernate Visit.VisitItems.Add (..)
  5. Le code identifie qu'un élément doit être supprimé (pas celui que nous venons d'ajouter), le supprime à l'aide de NHibernate Visit.VisitItems.Remove (élément).
  6. Le code valide la transaction

Avec un outil, je simule 12 requêtes simultanées, ce qui est très susceptible de se produire dans un futur environnement de production.

[MODIFIER] Sur demande, j'ai supprimé un grand nombre des détails de l'enquête que j'avais ajoutés ici pour être bref.

Après de nombreuses recherches, l'étape suivante consistait à trouver un moyen de verrouiller l'indication sur un index différent de celui utilisé dans la clause where (c'est-à-dire la clé primaire, car elle est utilisée pour la suppression), j'ai donc modifié ma déclaration de verrouillage en :

var items = (List<VisitItem>)_session.CreateSQLQuery(@"SELECT * FROM BAR.VisitItems WITH (XLOCK, INDEX([PK_BAR.VisitItems]))
        WHERE VisitId = :visitId")
        .AddEntity(typeof(VisitItem))
        .SetParameter("visitId", qi.Visit.Id)
        .List<VisitItem>();

Cela a réduit légèrement les blocages en fréquence, mais ils se produisaient toujours. Et c'est là que je commence à me perdre:

Three exclusive locks?

<deadlock-list>
  <deadlock victim="process3f71e64e8">
    <process-list>
      <process id="process3f71e64e8" taskpriority="0" logused="0" waitresource="KEY: 5:72057594071744512 (a5e1814e40ba)" waittime="3812" ownerId="8004520" transactionname="user_transaction" lasttranstarted="2015-12-14T10:24:58.010" XDES="0x3f7cb43b0" lockMode="X" schedulerid="1" kpid="15788" status="suspended" spid="63" sbid="0" ecid="0" priority="0" trancount="1" lastbatchstarted="2015-12-14T10:24:58.013" lastbatchcompleted="2015-12-14T10:24:58.013" lastattention="1900-01-01T00:00:00.013" clientapp=".Net SqlClient Data Provider" hostname="ABC" hostpid="10016" loginname="bsapp" isolationlevel="repeatable read (3)" xactid="8004520" currentdb="5" lockTimeout="4294967295" clientoption1="671088672" clientoption2="128056">
        <executionStack>
          <frame procname="adhoc" line="1" stmtstart="18" stmtend="254" sqlhandle="0x0200000024a9e43033ef90bb631938f939038627209baafb0000000000000000000000000000000000000000">
            unknown
          </frame>
          <frame procname="unknown" line="1" sqlhandle="0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000">
            unknown
          </frame>
        </executionStack>
        <inputbuf>
          (@p0 int)SELECT * FROM BAR.VisitItems WITH (XLOCK, INDEX([PK_BAR.VisitItems]))
          WHERE VisitId = @p0
        </inputbuf>
      </process>
      <process id="process4105af468" taskpriority="0" logused="1824" waitresource="KEY: 5:72057594071744512 (8194443284a0)" waittime="3792" ownerId="8004519" transactionname="user_transaction" lasttranstarted="2015-12-14T10:24:58.010" XDES="0x3f02ea3b0" lockMode="S" schedulerid="8" kpid="15116" status="suspended" spid="65" sbid="0" ecid="0" priority="0" trancount="2" lastbatchstarted="2015-12-14T10:24:58.033" lastbatchcompleted="2015-12-14T10:24:58.033" lastattention="1900-01-01T00:00:00.033" clientapp=".Net SqlClient Data Provider" hostname="ABC" hostpid="10016" loginname="bsapp" isolationlevel="repeatable read (3)" xactid="8004519" currentdb="5" lockTimeout="4294967295" clientoption1="671088672" clientoption2="128056">
        <executionStack>
          <frame procname="adhoc" line="1" stmtstart="18" stmtend="98" sqlhandle="0x0200000075abb0074bade5aa57b8357410941428df4d54130000000000000000000000000000000000000000">
            unknown
          </frame>
          <frame procname="unknown" line="1" sqlhandle="0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000">
            unknown
          </frame>
        </executionStack>
        <inputbuf>
          (@p0 int)DELETE FROM BAR.VisitItems WHERE Id = @p0
        </inputbuf>
      </process>
    </process-list>
    <resource-list>
      <keylock hobtid="72057594071744512" dbid="5" objectname="BAR.VisitItems" indexname="PK_BAR.VisitItems" id="lock449e27500" mode="X" associatedObjectId="72057594071744512">
        <owner-list>
          <owner id="process4105af468" mode="X"/>
        </owner-list>
        <waiter-list>
          <waiter id="process3f71e64e8" mode="X" requestType="wait"/>
        </waiter-list>
      </keylock>
      <keylock hobtid="72057594071744512" dbid="5" objectname="BAR.VisitItems" indexname="PK_BAR.VisitItems" id="lock46a525080" mode="X" associatedObjectId="72057594071744512">
        <owner-list>
          <owner id="process3f71e64e8" mode="X"/>
        </owner-list>
        <waiter-list>
          <waiter id="process4105af468" mode="S" requestType="wait"/>
        </waiter-list>
      </keylock>
    </resource-list>
  </deadlock>
</deadlock-list>

Une trace du nombre de requêtes résultant ressemble à ceci.
[MODIFIER] Whoa. Quelle semaine. J'ai maintenant mis à jour la trace avec la trace non expurgée de la déclaration pertinente qui, je pense, a conduit à l'impasse.

exec sp_executesql N'SELECT * FROM BAR.VisitItems WITH (XLOCK, INDEX([PK_BAR.VisitItems]))
                WHERE VisitId = @p0',N'@p0 int',@p0=3826
go
exec sp_executesql N'SELECT visititems0_.VisitId as VisitId1_, visititems0_.Id as Id1_, visititems0_.Id as Id37_0_, visititems0_.VisitType as VisitType37_0_, visititems0_.FeeItemId as FeeItemId37_0_, visititems0_.FeeRateType as FeeRateT4_37_0_, visititems0_.Amount as Amount37_0_, visititems0_.GST as GST37_0_, visititems0_.Quantity as Quantity37_0_, visititems0_.Total as Total37_0_, visititems0_.ServiceFeeType as ServiceF9_37_0_, visititems0_.ServiceText as Service10_37_0_, visititems0_.InvoiceToCentre as Invoice11_37_0_, visititems0_.IsDefault as IsDefault37_0_, visititems0_.OverrideCode as Overrid13_37_0_, visititems0_.IsSurchargeItem as IsSurch14_37_0_, visititems0_.VisitId as VisitId37_0_, visititems0_.InvoicingProviderId as Invoici16_37_0_, visititems0_.SourceVisitItemId as SourceV17_37_0_ FROM BAR.VisitItems visititems0_ WHERE visititems0_.VisitId=@p0',N'@p0 int',@p0=3826
go
exec sp_executesql N'INSERT INTO BAR.VisitItems (VisitType, FeeItemId, FeeRateType, Amount, GST, Quantity, Total, ServiceFeeType, ServiceText, InvoiceToCentre, IsDefault, OverrideCode, IsSurchargeItem, VisitId, InvoicingProviderId, SourceVisitItemId) VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11, @p12, @p13, @p14, @p15); select SCOPE_IDENTITY()',N'@p0 int,@p1 int,@p2 int,@p3 decimal(28,5),@p4 decimal(28,5),@p5 int,@p6 decimal(28,5),@p7 int,@p8 nvarchar(4000),@p9 bit,@p10 bit,@p11 int,@p12 bit,@p13 int,@p14 int,@p15 int',@p0=1,@p1=452,@p2=1,@p3=0,@p4=0,@p5=1,@p6=0,@p7=1,@p8=NULL,@p9=0,@p10=1,@p11=0,@p12=0,@p13=3826,@p14=3535,@p15=NULL
go
exec sp_executesql N'UPDATE BAR.Visits SET VisitType = @p0, DateOfService = @p1, InvoiceAnnotation = @p2, DefaultItemOverride = @p3, AppointmentId = @p4, ReferralRequired = @p5, ReferralCarePlan = @p6, UserId = @p7, PatientId = @p8, WorkAreaId = @p9, DidNotWaitAdjustmentId = @p10, ReferralId = @p11 WHERE Id = @p12',N'@p0 int,@p1 datetimeoffset(7),@p2 nvarchar(4000),@p3 bit,@p4 int,@p5 bit,@p6 nvarchar(4000),@p7 int,@p8 int,@p9 int,@p10 int,@p11 int,@p12 int',@p0=1,@p1='2016-01-22 12:37:06.8915296 +08:00',@p2=NULL,@p3=0,@p4=NULL,@p5=0,@p6=NULL,@p7=3535,@p8=4246,@p9=2741,@p10=NULL,@p11=NULL,@p12=3826
go
exec sp_executesql N'DELETE FROM BAR.VisitItems WHERE Id = @p0',N'@p0 int',@p0=7919
go

Maintenant, mon verrou semble avoir un effet car il apparaît dans le graphique de blocage. Mais quoi? Trois verrous exclusifs et un verrou partagé? Comment cela fonctionne-t-il sur le même objet/clé? Je pensais que tant que vous avez un verrou exclusif, vous ne pouvez pas obtenir un verrou partagé de quelqu'un d'autre? Et l'inverse. Si vous avez un verrou partagé, personne ne peut obtenir un verrou exclusif, ils doivent attendre.

Je pense que je manque ici de compréhension plus approfondie sur le fonctionnement des verrous lorsqu'ils sont pris sur plusieurs clés sur la même table.

Voici quelques-unes des choses que j'ai essayées et leur impact:

  • Ajout d'une autre indication d'index sur IX_Visit_Id à l'instruction de verrouillage. Pas de changement
  • Ajout d'une deuxième colonne à IX_Visit_Id (l'ID de la colonne VisitItem); tiré par les cheveux, mais essayé quand même. Pas de changement
  • Changement du niveau d'isolement en lecture validée (par défaut dans notre projet), des blocages se produisent toujours
  • Changement du niveau d'isolement en sérialisable. Des blocages se produisent toujours, mais pire (différents graphiques). De toute façon, je ne veux pas vraiment faire ça.
  • Prendre un verrou de table les fait disparaître (évidemment), mais qui voudrait faire ça?
  • Prendre un verrou d'application pessimiste (en utilisant sp_getapplock) fonctionne, mais c'est à peu près la même chose que le verrou de table, je ne veux pas faire ça.
  • L'ajout de l'indice READPAST à l'indice XLOCK n'a fait aucune différence
  • J'ai désactivé PageLock sur l'index et PK, aucune différence
  • J'ai ajouté l'indice ROWLOCK à l'indice XLOCK, cela n'a fait aucune différence

Une note secondaire sur NHibernate: La façon dont il est utilisé et je comprends que cela fonctionne est qu'il met en cache les instructions sql jusqu'à ce qu'il trouve vraiment nécessaire de les exécuter, sauf si vous appelez flush, ce que nous essayons de ne pas faire. Ainsi, la plupart des instructions (par exemple la liste agrégée paresseusement chargée de VisitItems => Visit.VisitItems) sont exécutées uniquement lorsque cela est nécessaire. La plupart des instructions de mise à jour et de suppression réelles de ma transaction sont exécutées à la fin lorsque la transaction est validée (comme le montre la trace SQL ci-dessus). Je n'ai vraiment aucun contrôle sur l'ordre d'exécution; NHibernate décide quand faire quoi. Ma déclaration de verrouillage initiale n'est vraiment qu'une solution de rechange.

De plus, avec l'instruction lock, je ne fais que lire les éléments dans une liste inutilisée (je n'essaye pas de remplacer la liste VisitItems sur l'objet Visit car ce n'est pas comme cela que NHibernate est censé fonctionner pour autant que je sache). Donc, même si j'ai lu la liste en premier avec l'instruction personnalisée, NHibernate chargera toujours la liste dans sa collection d'objets proxy Visit.VisitItems en utilisant un appel sql distinct que je peux voir dans la trace quand il est temps de la charger paresseusement quelque part.

Mais cela ne devrait pas avoir d'importance, non? J'ai déjà le verrou sur ladite clé? Le recharger ne changera pas cela?

Pour finir, peut-être pour clarifier: chaque processus ajoute d'abord sa propre visite avec VisitItems, puis entre et la modifie (ce qui déclenchera la suppression et l'insertion et le blocage). Dans mes tests, il n'y a jamais de processus modifiant exactement la même visite ou VisitItems.

Quelqu'un a-t-il une idée sur la façon d'aborder cela plus loin? Tout ce que je peux essayer de contourner cela de manière intelligente (pas de verrous de table, etc.)? Aussi, je voudrais savoir pourquoi ce verrou tripple-x est même possible sur le même objet. Je ne comprends pas.

Veuillez me faire savoir si des informations supplémentaires sont nécessaires pour résoudre le puzzle.

[EDIT] J'ai mis à jour la question avec le DDL pour les deux tables impliquées.

On m'a également demandé des éclaircissements sur l'attente: oui, quelques blocages ici et là sont ok, nous allons simplement réessayer ou demander à l'utilisateur de soumettre à nouveau (en général). Mais à la fréquence actuelle avec 12 utilisateurs simultanés, je m'attends à ce qu'il n'y en ait qu'un au maximum toutes les quelques heures. Actuellement, ils apparaissent plusieurs fois par minute.

En plus de cela, j'ai obtenu plus d'informations sur le trancount = 2, ce qui pourrait indiquer un problème avec les transactions imbriquées, que nous n'utilisons pas vraiment. Je vais également enquêter sur cela et documenter les résultats ici.

29
Ben

J'ai quelques réflexions. Tout d'abord, le moyen le plus simple d'éviter les interblocages est de toujours prendre les verrous dans le même ordre. Cela signifie qu'un code différent utilisant des transactions explicites doit accéder aux objets dans le même ordre, mais également accéder aux lignes individuellement par clé dans une transaction explicite doit être trié sur cette clé. Essayez de trier Visit.VisitItems par son PK avant de faire Add ou Delete sauf s'il s'agit d'une énorme collection auquel cas je trierais sur SELECT.

Le tri n'est probablement pas votre problème ici. Je suppose que 2 threads saisissent des verrous partagés sur tous les VisitItemIDs pour un VisitID donné et que le thread A DELETE ne peut pas terminer tant que le thread B n'a pas libéré son verrou partagé qu'il a gagné jusqu'à ce que son DELETE se termine. Les verrous d'application fonctionneront ici et ne sont pas aussi mauvais que les verrous de table car ils ne bloquent que par méthode et les autres SELECT fonctionneront très bien. Vous pouvez également prendre un verrou exclusif sur la table Visit pour le VisitID donné, mais là encore, c'est potentiellement exagéré.

Je vous recommande de transformer votre suppression matérielle en une suppression logicielle (UPDATE ... SET IsDeleted = 1 au lieu d'utiliser DELETE) et de nettoyer ces enregistrements plus tard, en bloc, à l'aide d'une tâche de nettoyage qui n'utilise pas de transactions explicites. Cela nécessitera évidemment la refactorisation d'un autre code pour ignorer ces lignes supprimées, mais c'est ma méthode préférée pour gérer les DELETEs inclus dans un SELECT dans une transaction explicite.

Vous pouvez également supprimer le SELECT de la transaction et passer à un modèle de concurrence optimiste. Le framework d'entité le fait gratuitement, pas sûr de NHibernate. EF déclencherait une exception de concurrence optimiste si votre DELETE renvoie 0 lignes affectées.

2
Ben Campbell

J'ai fait quelques commentaires à cet effet, mais je ne suis pas sûr que vous obteniez les résultats souhaités lorsque vous combinez le niveau d'isolement des transactions en lecture répétable avec un instantané validé en lecture.

Le TIL signalé dans votre liste d'interblocages est une lecture répétable, ce qui est encore plus restrictif que Read Committed, et étant donné le flux que vous décrivez, conduit probablement à des blocages.

Ce que vous essayez peut-être de faire, c'est que votre DB TIL reste reproductible en lecture, mais définissez la transaction pour utiliser le snapshot TIL explicitement avec une instruction set transaction isolation level. Référence: https://msdn.Microsoft.com/en-us/library/ms173763.aspx Si c'est le cas, je pense que vous devez avoir quelque chose de incorrect. Je ne connais pas nHibernate, mais il semble qu'il y ait une référence ici: http://www.anujvarma.com/fluent-nhibernate-setting-database-transaction-isolation-level/

Si l'architecture de votre application le permet, une option serait d'essayer de lire l'instantané validé au niveau de la base de données, et si vous obtenez toujours des blocages, activez l'instantané avec le versionnage des lignes. REMARQUE: si vous faites cela, vous devez repenser votre configuration tempdb si vous activez l'instantané (versioning de ligne). Je peux vous fournir toutes sortes de documents à ce sujet si vous en avez besoin - faites le moi savoir.

2
Joe Hayes

Avez-vous essayé de déplacer la mise à jour des visites avant toute modification de visitItems? Ce verrou X devrait protéger les lignes "enfants".

Faire une trace acquise des verrous complets (et la conversion en lisible par l'homme) est beaucoup de travail, mais pourrait montrer la séquence plus clairement.

1
stox