Posso confiar na leitura dos valores de identidade do SQL Server em ordem?

24

TL; DR: A pergunta abaixo se resume a: Ao inserir uma linha, existe uma janela de oportunidade entre a geração de um novo Identityvalor e o bloqueio da chave de linha correspondente no índice em cluster, onde um observador externo pode ver um novo Identity valor inserido por uma transação simultânea? (No SQL Server.)

Versão detalhada

Eu tenho uma tabela do SQL Server com uma Identitycoluna chamada CheckpointSequence, que é a chave do índice clusterizado da tabela (que também possui vários índices adicionais não clusterizados). As linhas são inseridas na tabela por vários processos e encadeamentos simultâneos (no nível de isolamento READ COMMITTEDe sem IDENTITY_INSERT). Ao mesmo tempo, há processos que lêem periodicamente as linhas do índice em cluster, ordenadas por essa CheckpointSequencecoluna (também no nível de isolamento READ COMMITTED, com a READ COMMITTED SNAPSHOTopção desativada).

Atualmente, confio no fato de que os processos de leitura nunca podem "pular" um ponto de verificação. Minha pergunta é: Posso confiar nessa propriedade? E se não, o que eu poderia fazer para torná-lo realidade?

Exemplo: quando linhas com os valores de identidade 1, 2, 3, 4 e 5 são inseridas, o leitor não deve ver a linha com o valor 5 antes de ver a com o valor 4. Os testes mostram que a consulta, que contém uma ORDER BY CheckpointSequencecláusula ( e uma WHERE CheckpointSequence > -1cláusula), bloqueia de forma confiável sempre que a linha 4 for lida, mas ainda não confirmada, mesmo que a linha 5 já tenha sido confirmada.

Acredito que, pelo menos em teoria, pode haver uma condição de corrida aqui que possa causar essa suposição. Infelizmente, a documentação Identitynão diz muito sobre como Identityfunciona no contexto de várias transações simultâneas, apenas diz "Cada novo valor é gerado com base na atual semente e incremento". e "Cada novo valor para uma transação específica é diferente de outras transações simultâneas na tabela". ( MSDN )

Meu raciocínio é que ele deve funcionar de alguma forma assim:

  1. Uma transação é iniciada (explícita ou implicitamente).
  2. Um valor de identidade (X) é gerado.
  3. O bloqueio de linha correspondente é obtido no índice clusterizado com base no valor da identidade (a menos que a escalação do bloqueio seja iniciada, nesse caso, a tabela inteira está bloqueada).
  4. A linha é inserida.
  5. A transação é confirmada (possivelmente muito tempo depois), portanto, o bloqueio é removido novamente.

Eu acho que entre os passos 2 e 3, há uma janela muito pequena onde

  • uma sessão simultânea pode gerar o próximo valor de identidade (X + 1) e executar todas as etapas restantes,
  • permitindo assim que um leitor que esteja exatamente nesse ponto do tempo leia o valor X + 1, perdendo o valor de X.

Obviamente, a probabilidade disso parece extremamente baixa; mas ainda assim - isso poderia acontecer. Ou poderia?

(Se você estiver interessado no contexto: Esta é a implementação do SQL Persistence Engine da NEventStore. O NEventStore implementa um armazenamento de eventos somente para acréscimos, onde cada evento recebe um novo número de sequência de ponto de verificação crescente. para realizar cálculos de todos os tipos. Depois que um evento com ponto de verificação X for processado, os clientes consideram apenas eventos "mais recentes", ou seja, eventos com ponto de verificação X + 1 e acima. Portanto, é vital que os eventos nunca possam ser ignorados, como eles nunca seriam considerados novamente. No momento, estou tentando determinar se a Identityimplementação do ponto de verificação com base em conformidade com esse requisito. Estas são as instruções SQL exatas usadas : Esquema , consulta do Writer ,Consulta do leitor .)

Se eu estiver certo e a situação descrita acima puder surgir, posso ver apenas duas opções para lidar com elas, ambas insatisfatórias:

  • Ao visualizar um valor de sequência de ponto de verificação X + 1 antes de ver X, descarte X + 1 e tente novamente mais tarde. No entanto, como Identityé claro que pode produzir lacunas (por exemplo, quando a transação é revertida), X pode nunca chegar.
  • Portanto, mesma abordagem, mas aceite a diferença após n milissegundos. No entanto, que valor de n devo assumir?

Alguma ideia melhor?

Fabian Schmied
fonte
Você já tentou usar Sequência em vez de identidade? Com a identidade, acho que não é possível prever com segurança qual inserção obterá um valor de identidade específico, mas isso não deve ser um problema ao usar uma sequência. Claro que isso muda como você faz as coisas agora.
Antoine Hernandez
@SoleDBAGuy Uma sequência não tornaria a condição de corrida que eu descrevi acima ainda mais provável? Eu produzo um novo valor de sequência X (substituindo a etapa 2 acima) e insiro uma linha (etapas 3 e 4). Entre 2 e 3, existe a possibilidade de alguém produzir o próximo valor de Sequência X + 1, confirmar e um leitor ler esse valor X + 1 antes mesmo de inserir minha linha com o valor de Sequência X.
Fabian Schmied

Respostas:

26

Ao inserir uma linha, existe uma janela de oportunidade entre a geração de um novo valor de Identidade e o bloqueio da chave de linha correspondente no índice em cluster, em que um observador externo pode ver um novo valor de Identidade inserido por uma transação simultânea?

Sim.

A alocação de valores de identidade é independente da transação do usuário que contém . Esse é um dos motivos pelos quais os valores de identidade são consumidos, mesmo que a transação seja revertida. A operação de incremento em si é protegida por uma trava para evitar corrupção, mas essa é a extensão das proteções.

Nas circunstâncias específicas de sua implementação, a alocação de identidade (uma chamada para CMEDSeqGen::GenerateNewValue) é feita antes que a transação do usuário para a inserção seja ativada (e, portanto, antes de qualquer bloqueio).

Ao executar duas inserções simultaneamente com um depurador conectado para permitir o congelamento de um encadeamento logo após o valor da identidade ser incrementado e alocado, consegui reproduzir um cenário em que:

  1. A sessão 1 adquire um valor de identidade (3)
  2. A sessão 2 adquire um valor de identidade (4)
  3. A sessão 2 executa sua inserção e confirma (para que a linha 4 seja totalmente visível)
  4. A sessão 1 executa sua inserção e confirma (linha 3)

Após a etapa 3, uma consulta usando o número da linha sob bloqueio de leitura confirmada retornou o seguinte:

Captura de tela

Na sua implementação, isso faria com que o Checkpoint ID 3 fosse ignorado incorretamente.

A janela de falta de oportunidade é relativamente pequena, mas existe. Para fornecer um cenário mais realista do que um depurador conectado: Um thread de consulta em execução pode gerar o agendador após a etapa 1 acima. Isso permite que um segundo encadeamento aloque um valor de identidade, insira e confirme, antes que o encadeamento original seja retomado para executar sua inserção.

Para maior clareza, não há bloqueios ou outros objetos de sincronização protegendo o valor da identidade depois que ele é alocado e antes de ser usado. Por exemplo, após a etapa 1 acima, uma transação simultânea pode ver o novo valor de identidade usando funções T-SQL como IDENT_CURRENTantes da linha existir na tabela (mesmo não confirmada).

Fundamentalmente, não há mais garantias quanto aos valores de identidade do que o documentado :

  • Cada novo valor é gerado com base na atual semente e incremento.
  • Cada novo valor para uma transação específica é diferente de outras transações simultâneas na tabela.

É isso mesmo.

Se for necessário um processamento FIFO transacional estrito , você provavelmente não terá outra opção a não ser serializar manualmente. Se o aplicativo tiver requisitos menos específicos, você terá mais opções. A questão não é 100% clara a esse respeito. No entanto, você pode encontrar algumas informações úteis no artigo de Remus Rusanu, Usando tabelas como filas .

Paul White diz que a GoFundMonica
fonte
7

Como Paul White respondeu absolutamente correto, existe a possibilidade de linhas de identidade "puladas" temporariamente. Aqui está apenas um pequeno pedaço de código para reproduzir esse caso por conta própria.

Crie um banco de dados e uma tabela de teste:

create database IdentityTest
go
use IdentityTest
go
create table dbo.IdentityTest (ID int identity, c1 char(10))
create clustered index CI_dbo_IdentityTest_ID on dbo.IdentityTest(ID)

Execute inserções simultâneas e selecione nesta tabela em um programa de console C #:

using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Threading;

namespace IdentityTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var insertThreads = new List<Thread>();
            var selectThreads = new List<Thread>();

            //start threads for infinite inserts
            for (var i = 0; i < 100; i++)
            {
                insertThreads.Add(new Thread(InfiniteInsert));
                insertThreads[i].Start();
            }

            //start threads for infinite selects
            for (var i = 0; i < 10; i++)
            {
                selectThreads.Add(new Thread(InfiniteSelectAndCheck));
                selectThreads[i].Start();
            }
        }

        private static void InfiniteSelectAndCheck()
        {
            //infinite loop
            while (true)
            {
                //read top 2 IDs
                var cmd = new SqlCommand("select top(2) ID from dbo.IdentityTest order by ID desc")
                {
                    Connection = new SqlConnection("Server=localhost;Database=IdentityTest;Integrated Security=SSPI;Application Name=IdentityTest")
                };

                try
                {
                    cmd.Connection.Open();
                    var dr = cmd.ExecuteReader();

                    //read first row
                    dr.Read();
                    var row1 = int.Parse(dr["ID"].ToString());

                    //read second row
                    dr.Read();
                    var row2 = int.Parse(dr["ID"].ToString());

                    //write line if row1 and row are not consecutive
                    if (row1 - 1 != row2)
                    {
                        Console.WriteLine("row1=" + row1 + ", row2=" + row2);
                    }
                }
                finally
                {
                    cmd.Connection.Close();
                }
            }
        }

        private static void InfiniteInsert()
        {
            //infinite loop
            while (true)
            {
                var cmd = new SqlCommand("insert into dbo.IdentityTest (c1) values('a')")
                {
                    Connection = new SqlConnection("Server=localhost;Database=IdentityTest;Integrated Security=SSPI;Application Name=IdentityTest")
                };

                try
                {
                    cmd.Connection.Open();
                    cmd.ExecuteNonQuery();
                }
                finally
                {
                    cmd.Connection.Close();
                }
            }
        }
    }
}

Esse console imprime uma linha para cada caso quando um dos segmentos de leitura "perde" uma entrada.

Stefan Kainz
fonte
11
Código legal, mas você só verifica se há IDs consecutivos ( "// escreve a linha se a linha1 e a linha não forem consecutivas" ). Pode haver lacunas produzidas que seu código será impresso. Isso não significa que essas lacunas serão preenchidas mais tarde.
precisa saber é o seguinte
11
Como o código não desencadeia um cenário que IDENTITYproduziria lacunas (como reverter uma transação), as linhas impressas realmente mostram os valores "ignorados" (ou pelo menos o fizeram quando executei e verifiquei na minha máquina). Amostra de reprodução muito boa!
Fabian Schmied
5

É melhor não esperar que as identidades sejam consecutivas, pois existem muitos cenários que podem deixar lacunas. É melhor considerar a identidade como um número abstrato e não anexar nenhum significado comercial a ela.

Basicamente, as lacunas podem ocorrer se você reverter as operações INSERT (ou excluir explicitamente as linhas) e duplicatas podem ocorrer se você definir a propriedade da tabela IDENTITY_INSERT como ON.

Lacunas podem ocorrer quando:

  1. Os registros são excluídos.
  2. Ocorreu um erro ao tentar inserir um novo registro (revertido)
  3. Uma atualização / inserção com valor explícito (opção identity_insert).
  4. O valor incremental é maior que 1.
  5. Uma transação é revertida.

A propriedade de identidade em uma coluna nunca garantiu:

• Singularidade

• Valores consecutivos em uma transação. Se os valores precisarem ser consecutivos, a transação deverá usar um bloqueio exclusivo na tabela ou o nível de isolamento SERIALIZABLE.

• Valores consecutivos após a reinicialização do servidor.

• Reutilização de valores.

Se você não puder usar valores de identidade por causa disso, crie uma tabela separada contendo um valor atual e gerencie o acesso à atribuição de tabela e número com seu aplicativo. Isso tem o potencial de afetar o desempenho.

https://msdn.microsoft.com/en-us/library/ms186775(v=sql.105).aspx
https://msdn.microsoft.com/en-us/library/ms186775(v=sql.110) .aspx

stacylaray
fonte
Penso que as lacunas não são o meu principal problema - o meu principal problema é a crescente visibilidade dos valores. (Ie, digamos, valor de identidade 7 não deve ser observável a uma ordenação consulta por esse valor antes de valor de identidade 6 é.)
Fabian Schmied
11
Eu vi valores de identidade cometer tais como: 1, 2, 5, 3, 4.
stacylaray
Claro, isso é facilmente reproduzível, por exemplo, usando o cenário da resposta de Lennart. A questão com a qual estou lutando é se posso observar essa ordem de confirmação ao usar uma consulta com uma ORDER BY CheckpointSequencecláusula (que por acaso é a ordem do índice em cluster). Acho que tudo se resume à questão de saber se a geração de um valor de identidade está de alguma forma vinculada aos bloqueios executados pela instrução INSERT ou se essas são simplesmente duas ações não relacionadas executadas pelo SQL Server uma após a outra.
Fabian Schmied
11
Qual é a consulta? Se estiver usando a leitura confirmada, no seu exemplo, a ordem por mostraria 1, 2, 3, 5 porque eles foram confirmados e 4 não, ou seja, leitura suja. Além disso, sua explicação sobre o NEventStore declara "Portanto, é vital que os eventos nunca possam ser ignorados, pois nunca mais serão considerados".
stacylaray
A consulta é fornecida acima ( gist.github.com/fschmied/47f716c32cb64b852f90 ) - é paginada, mas resume-se a uma simples SELECT ... FROM Commits WHERE CheckpointSequence > ... ORDER BY CheckpointSequence. Acho que essa consulta não passaria da linha bloqueada 4 ou seria? (Em meus experimentos, ele bloqueia quando a consulta tenta adquirir o bloqueio do teclado para a linha 4.)
Fabian Schmied
1

Suspeito que ocasionalmente possa causar problemas, problemas que pioram quando o servidor está sob carga pesada. Considere duas transações:

  1. T1: inserir em T ... - digamos, 5 são inseridos
  2. T2: inserir em T ... - digamos, 6 ser inserido
  3. T2: confirmar
  4. O leitor vê 6, mas não 5
  5. T1: confirmar

No cenário acima, seu LAST_READ_ID será 6, portanto, 5 nunca serão lidos.

Lennart
fonte
Meus testes parecem indicar que esse cenário não é um problema porque o Reader (etapa 4) bloqueará (até T1 liberar seus bloqueios) ao tentar ler a linha com o valor 5. Estou perdendo alguma coisa?
Fabian Schmied 29/03
Você pode estar certo, não conheço muito bem o mecanismo de bloqueio no SQL Server (por isso suspeito na minha resposta).
Lennart
Depende do nível de isolamento do leitor. É o meu ver tanto, bloquear ou ver apenas 6.
Michael Green
0

Executando este script:

BEGIN TRAN;
INSERT INTO dbo.Example DEFAULT VALUES;
COMMIT;

Abaixo estão os bloqueios que vejo adquiridos e liberados como capturados por uma sessão de evento estendido:

name            timestamp                   associated_object_id    mode    object_id   resource_type   session_id  resource_description
lock_acquired   2016-03-29 06:37:28.9968693 1585440722              IX      1585440722  OBJECT          51          
lock_acquired   2016-03-29 06:37:28.9969268 7205759890195415040     IX      0           PAGE            51          1:1235
lock_acquired   2016-03-29 06:37:28.9969306 7205759890195415040     RI_NL   0           KEY             51          (ffffffffffff)
lock_acquired   2016-03-29 06:37:28.9969330 7205759890195415040     X       0           KEY             51          (29cf3326f583)
lock_released   2016-03-29 06:37:28.9969579 7205759890195415040     X       0           KEY             51          (29cf3326f583)
lock_released   2016-03-29 06:37:28.9969598 7205759890195415040     IX      0           PAGE            51          1:1235
lock_released   2016-03-29 06:37:28.9969607 1585440722              IX      1585440722  OBJECT          51      

Observe o bloqueio RI_N KEY adquirido imediatamente antes do bloqueio da tecla X para a nova linha que está sendo criada. Esse bloqueio de curta duração impedirá que uma inserção simultânea adquira outro bloqueio RI_N KEY, pois os bloqueios RI_N são incompatíveis. A janela que você mencionou entre as etapas 2 e 3 não é uma preocupação porque o bloqueio de intervalo é adquirido antes do bloqueio de linha na chave recém-gerada.

Desde que você SELECT...ORDER BYinicie a verificação antes das linhas recém-inseridas desejadas, esperaria o comportamento desejado no READ COMMITTEDnível de isolamento padrão , desde que a READ_COMMITTED_SNAPSHOTopção do banco de dados esteja desativada.

Dan Guzman
fonte
11
De acordo com technet.microsoft.com/en-us/library/… , dois bloqueios com RangeI_Nsão compatíveis , ou seja, não se bloqueiam (o bloqueio geralmente existe para bloquear em um leitor serializável existente).
Fabian Schmied 30/03
@FabianSchmied, interessante. Esse tópico entra em conflito com a matriz de compatibilidade de bloqueios em technet.microsoft.com/en-us/library/ms186396(v=sql.105).aspx , que mostra que os bloqueios não são compatíveis. O exemplo de inserção no link que você mencionou indica o mesmo comportamento mostrado no rastreamento na minha resposta (bloqueio de intervalo de inserção de curta duração para testar o intervalo antes do bloqueio de tecla exclusivo).
Dan Guzman
11
Na verdade, a matriz diz "N" para "nenhum conflito" (não para "não compatível") :)
Fabian Schmied
0

Pelo meu entendimento do SQL Server, o comportamento padrão é que a segunda consulta não exiba nenhum resultado até que a primeira consulta tenha sido confirmada. Se a primeira consulta executar um ROLLBACK em vez de um COMMIT, haverá um ID ausente na sua coluna.

Configuração básica

Tabela de banco de dados

Criei uma tabela de banco de dados com a seguinte estrutura:

CREATE TABLE identity_rc_test (
    ID4VALUE INT IDENTITY (1,1), 
    TEXTVALUE NVARCHAR(20),
    CONSTRAINT PK_ID4_VALUE_CLUSTERED 
        PRIMARY KEY CLUSTERED (ID4VALUE, TEXTVALUE)
)

Nível de isolamento do banco de dados

Eu verifiquei o nível de isolamento do meu banco de dados com a seguinte instrução:

SELECT snapshot_isolation_state, 
       snapshot_isolation_state_desc, 
       is_read_committed_snapshot_on
FROM sys.databases WHERE NAME = 'mydatabase'

O qual retornou o seguinte resultado para meu banco de dados:

snapshot_isolation_state    snapshot_isolation_state_desc   is_read_committed_snapshot_on
0                           OFF                             0

(Essa é a configuração padrão para um banco de dados no SQL Server 2012)

Scripts de teste

Os scripts a seguir foram executados usando as configurações padrão do cliente SSMS do SQL Server e as configurações padrão do SQL Server.

Configurações de conexões do cliente

O cliente foi configurado para usar o Nível de isolamento de transação READ COMMITTEDconforme as Opções de consulta no SSMS.

Consulta 1

A consulta a seguir foi executada em uma janela de consulta com o SPID 57

SELECT * FROM dbo.identity_rc_test
BEGIN TRANSACTION [FIRST_QUERY]
INSERT INTO dbo.identity_rc_test (TEXTVALUE) VALUES ('Nine')
/* Commit is commented out to prevent the INSERT from being commited
--COMMIT TRANSACTION [FIRST_QUERY]
--ROLLBACK TRANSACTION [FIRST_QUERY]
*/

Consulta 2

A consulta a seguir foi executada em uma janela de consulta com o SPID 58

BEGIN TRANSACTION [SECOND_QUERY]
INSERT INTO dbo.identity_rc_test (TEXTVALUE) VALUES ('Ten')
COMMIT TRANSACTION [SECOND_QUERY]
SELECT * FROM dbo.identity_rc_test

A consulta não é concluída e está aguardando o lançamento do bloqueio eXclusive em uma PAGE.

Script para determinar o bloqueio

Este script exibe o bloqueio que ocorre nos objetos de banco de dados para as duas transações:

SELECT request_session_id, resource_type,
       resource_description, 
       resource_associated_entity_id,
       request_mode, request_status
FROM sys.dm_tran_locks
WHERE request_session_id IN (57, 58)

E aqui estão os resultados:

58  DATABASE                    0                   S   GRANT
57  DATABASE                    0                   S   GRANT
58  PAGE            1:79        72057594040549300   IS  GRANT
57  PAGE            1:79        72057594040549300   IX  GRANT
57  KEY         (a0aba7857f1b)  72057594040549300   X   GRANT
58  KEY         (a0aba7857f1b)  72057594040549300   S   WAIT
58  OBJECT                      245575913           IS  GRANT
57  OBJECT                      245575913           IX  GRANT

Os resultados mostram que a janela de consulta um (SPID 57) possui um bloqueio compartilhado (S) no DATABASE, um bloqueio eXlusive (IX) pretendido no OBJECT, um bloqueio eXlusive (IX) pretendido na página que deseja inserir e um eXclusivo O bloqueio (X) na KEY está inserido, mas ainda não foi confirmado.

Devido aos dados não confirmados, a segunda consulta (SPID 58) possui um bloqueio compartilhado (S) no nível DATABASE, um bloqueio de compartilhamento compartilhado (IS) no OBJECT, um bloqueio de compartilhamento compartilhado (IS) na página ) trava na KEY com um status de solicitação WAIT.

Sumário

A consulta na primeira janela de consulta é executada sem confirmação. Como a segunda consulta pode apenas READ COMMITTEDdados, aguarda até que o tempo limite ocorra ou até que a transação tenha sido confirmada na primeira consulta.

Isso é do meu entendimento do comportamento padrão do Microsoft SQL Server.

Você deve observar que o ID está realmente em sequência para leituras subsequentes pelas instruções SELECT, se a primeira instrução COMMITs.

Se a primeira instrução executar um ROLLBACK, você encontrará um ID ausente na sequência, mas ainda com o ID em ordem crescente (desde que você tenha criado o INDEX com a opção padrão ou ASC na coluna ID).

Atualizar:

(Sem rodeios) Sim, você pode confiar na coluna de identidade funcionando corretamente, até encontrar um problema. Há apenas um HOTFIX referente ao SQL Server 2000 e a coluna de identidade no site da Microsoft.

Se você não pudesse confiar na atualização correta da coluna de identidade, acho que haveria mais hotfixes ou patches no site da Microsoft.

Se você possui um contrato de suporte da Microsoft, sempre pode abrir um caso consultivo e solicitar informações adicionais.

John aka hot2use
fonte
11
Obrigado pela análise, mas a minha pergunta é se existe uma janela de tempo entre a geração do próximo Identityvalor e a aquisição do bloqueio KEY na linha (onde poderiam ocorrer leituras / gravadores simultâneos). Não acho que isso seja provado impossível por suas observações, porque não se pode parar a execução da consulta e analisar os bloqueios durante esse período de tempo muito curto.
Fabian Schmied 30/03
Não, você não pode parar as declarações, mas minha observação (lenta) é o que acontece em uma base rápida / normal. Assim que um SPID adquire um bloqueio para inserir dados, o outro não poderá adquirir o mesmo bloqueio. A declaração mais rápida terá a vantagem de já ter adquirido o bloqueio e o ID em sequência. A próxima instrução receberá o próximo ID após o bloqueio ter sido liberado.
John aka hot2use
11
Normalmente, suas observações correspondem às minhas (e também às minhas expectativas) - é bom saber. Eu me pergunto se existem situações excepcionais em que elas não se sustentam.
Fabian Schmied