He estado trabajando en este problema de bloqueo durante bastantes días y no importa lo que haga, persiste de una forma u otra.
Primero, la premisa general: tenemos visitas con VisitItems en una relación de uno a muchos.
VisitItems información relevante:
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)
Información de la visita:
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]);
Varios usuarios desean actualizar la tabla VisitItems simultáneamente de la siguiente manera:
Una solicitud web separada creará una visita con VisitItems (generalmente 1). Entonces (la solicitud del problema):
- Entra la solicitud web, abre la sesión de NHibernate, comienza la transacción de NHibernate (usando lectura repetible con READ_COMMITTED_SNAPSHOT activado).
- Lea todos los artículos de visita para una visita dada por VisitId .
- El código evalúa si los elementos siguen siendo relevantes o si necesitamos otros nuevos que usen reglas complejas (por lo tanto, un plazo ligeramente largo, por ejemplo, 40 ms).
- El código encuentra que 1 elemento debe agregarse, lo agrega usando NHibernate Visit.VisitItems.Add (..)
- El código identifica que un elemento debe eliminarse (no el que acabamos de agregar), lo elimina con NHibernate Visit.VisitItems.Remove (elemento).
- El código confirma la transacción
Con una herramienta, simulo 12 solicitudes simultáneas que es muy probable que suceda en un entorno de producción futuro.
[EDITAR] A solicitud, eliminé muchos de los detalles de la investigación que había agregado aquí para mantenerlo breve.
Después de mucha investigación, el siguiente paso fue pensar en una forma de cómo puedo bloquear la pista en un índice diferente al que se usa en la cláusula where (es decir, la clave principal, ya que se usa para eliminar), por lo que modifiqué mi declaración de bloqueo para :
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>();
Esto redujo los puntos muertos en frecuencia ligeramente, pero todavía estaban sucediendo. Y aquí es donde estoy empezando a perderme:
<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>
Este es un rastro del número resultante de consultas.
[EDITAR] Whoa. Que semana. Ahora he actualizado la traza con la traza no redactada de la declaración relevante que creo que conduce al punto muerto.
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
Ahora mi bloqueo parece tener un efecto ya que se muestra en el gráfico de punto muerto. ¿Pero que? ¿Tres cerraduras exclusivas y una cerradura compartida? ¿Cómo funciona eso en el mismo objeto / clave? Pensé que mientras tengas un bloqueo exclusivo, ¿no puedes obtener un bloqueo compartido de otra persona? Y a la inversa. Si tiene un bloqueo compartido, nadie puede obtener un bloqueo exclusivo, tienen que esperar.
Creo que me falta una comprensión más profunda aquí sobre cómo funcionan las cerraduras cuando se toman en varias claves en la misma tabla.
Estas son algunas de las cosas que he probado y su impacto:
- Se agregó otra sugerencia de índice en IX_Visit_Id a la declaración de bloqueo. Ningún cambio
- Se agregó una segunda columna a IX_Visit_Id (el Id de la columna VisitItem); descabellado, pero lo intenté de todos modos. Ningún cambio
- Se cambió el nivel de aislamiento a lectura confirmada (valor predeterminado en nuestro proyecto), todavía se producen puntos muertos
- Se cambió el nivel de aislamiento a serializable. Los puntos muertos siguen sucediendo, pero peor (gráficos diferentes). Realmente no quiero hacer eso, de todos modos.
- Tomar un candado de mesa los hace desaparecer (obviamente), pero ¿quién querría hacer eso?
- Tomar un bloqueo de aplicación pesimista (usando sp_getapplock) funciona, pero eso es casi lo mismo que el bloqueo de la tabla, no quiero hacer eso.
- Agregar la sugerencia READPAST a la sugerencia XLOCK no hizo ninguna diferencia
- He desactivado PageLock en el índice y PK, no hay diferencia
- He agregado la pista ROWLOCK a la pista XLOCK, no hice ninguna diferencia
Alguna nota al margen sobre NHibernate: la forma en que se usa y entiendo que funciona es que almacena en caché las instrucciones sql hasta que realmente considera necesario ejecutarlas, a menos que llame a flush, que estamos tratando de no hacer. Por lo tanto, la mayoría de las declaraciones (p. Ej., La lista de agregados de VisitItems => Visit.VisitItems) con carga lenta se ejecutan solo cuando es necesario. La mayoría de las declaraciones de actualización y eliminación reales de mi transacción se ejecutan al final cuando se confirma la transacción (como se evidencia en el seguimiento de sql anterior). Realmente no tengo control sobre la orden de ejecución; NHibernate decide cuándo hacer qué. Mi declaración de bloqueo inicial es realmente solo una solución alternativa.
Además, con la declaración de bloqueo, solo estoy leyendo los elementos en una lista no utilizada (no estoy tratando de anular la lista VisitItems en el objeto Visit, ya que no es así como se supone que NHibernate funciona). Entonces, aunque leí la lista primero con la declaración personalizada, NHibernate aún cargará la lista nuevamente en su colección de objetos proxy Visit.VisitItems usando una llamada sql separada que puedo ver en el seguimiento cuando es hora de cargarla perezosamente en algún lugar.
Pero eso no debería importar, ¿verdad? Ya tengo la cerradura de dicha llave? ¿Cargarlo de nuevo no cambiará eso?
Como nota final, tal vez para aclarar: cada proceso agrega primero su propia visita con VisitItems, luego entra y la modifica (lo que activará la eliminación e inserción y el punto muerto). En mis pruebas, nunca hay ningún proceso que cambie exactamente la misma visita o visititetems.
¿Alguien tiene una idea sobre cómo abordar esto más? ¿Algo que pueda intentar solucionar de una manera inteligente (sin bloqueos de mesa, etc.)? Además, me gustaría saber por qué este bloqueo tripple-x es incluso posible en el mismo objeto. No entiendo.
Avíseme si necesita más información para resolver el rompecabezas.
[EDITAR] Actualicé la pregunta con el DDL para las dos tablas involucradas.
También se me pidió una aclaración sobre la expectativa: Sí, algunos puntos muertos aquí y allá están bien, solo volveremos a intentarlo o haremos que el usuario vuelva a enviar (en general). Pero en la frecuencia actual con 12 usuarios simultáneos, esperaría que solo haya uno cada pocas horas como máximo. Actualmente aparecen varias veces por minuto.
Además de eso, obtuve más información sobre trancount = 2, lo que podría indicar un problema con las transacciones anidadas, que realmente no estamos usando. También investigaré eso y documentaré los resultados aquí.
SELECT OBJECT_NAME(objectid, dbid) AS objectname, * FROM sys.dm_exec_sql_text(0x0200000024a9e43033ef90bb631938f939038627209baafb0000000000000000000000000000000000000000)
el sqlhandle en cada marco executeStack para determinar aún más lo que realmente se está ejecutando.