Esta página foi traduzida por máquina. Leia o original em inglês. English

Transações no Firebird: ACID, níveis de isolamento, deadlocks e resolução de conflitos de atualização

Alexey Kovyazin, com a ajuda de Vlad Khorsun e Dmitry Kuzmenko, 08-ABR-2019

Conteúdo:

É necessário saber como as transações funcionam?

Provavelmente sim, porque a noção de transações é simples, muitos desenvolvedores subestimam a importância de usar transações corretamente no Firebird. No entanto, somente após obter um entendimento profundo de como as transações funcionam, você pode compreender muitas coisas misteriosas relacionadas ao desempenho, como lentidões repentinas no banco de dados (relacionadas à varredura de versões excessivas de registros que aparecem devido ao mau gerenciamento de transações).

De modo geral, a noção de transação é aplicada a qualquer sistema dinâmico que passa de um estado para outro. Por exemplo, um exemplo clássico de transação é transferir dinheiro de uma conta para outra. Normalmente, parece algo assim:

Code

Início   --- transferindo dinheiro da conta 1 para a conta 2
 --diminuir conta 1
 --aumentar conta 2
Fim - confirmando a transação

O exemplo se resume ao fato de que o dinheiro deve desaparecer da conta 1 e aparecer na conta 2 simultaneamente, caso contrário, haverá excesso de dinheiro ou falta inexplicável de dinheiro por algum tempo no sistema.

Do ponto de vista dos bancos de dados, uma transação é geralmente definida como um grupo de operações executadas em um banco de dados que é visto como independente de outras transações. Do meu ponto de vista, essa definição não é melhor nem pior que outras definições, mas, como qualquer definição, faz pouco sentido sem conhecer o funcionamento interno real e a lógica de um SGBD.

Acredita-se que uma transação em um banco de dados deve atender aos chamados requisitos ACID

Code
A - Atomicidade
C - Consistência
I - Isolamento
D - Durabilidade

Muitos desenvolvedores de aplicativos de banco de dados ficam tão inspirados por essa sigla que frequentemente usam argumentos como “você não tem D no ACID” quando se trata de comparar diferentes SGBDs (o que geralmente é seguido imediatamente por “não me importo com o que você pensa”).

Na verdade, tudo é bastante simples - ACID é um conjunto de requisitos referentes à implementação de uma transação em um SGBD específico, alguns deles são muito rigorosos (por exemplo, D - claro, durabilidade é importante!) enquanto outros são menos rigorosos - quando analisamos os níveis de isolamento de transações, veremos que o isolamento pode variar.

Por isso, não vale a pena tentar entender imediatamente o que essa sigla significa literalmente. Em vez disso, examinaremos a lógica de como os SGBDs (e as transações, em particular) funcionam e veremos o ACID do ponto de vista de “como é feito” em vez de “o que significa”.

Como os aspectos das transações são complexos, precisamos de uma representação gráfica - uma espécie de diagramas, para mostrar o trabalho e a interação das transações. Usando esses diagramas, poderemos construir uma narrativa lógica e analisar os detalhes de como as transações funcionam.

Primeiro, introduziremos uma linha do tempo porque as transações se desenvolvem ao longo do tempo. A linha do tempo será marcada da maneira que precisamos - não precisamos de segundos nem minutos, mas dos passos-chave da interação entre transações:

Em seguida, adicionaremos uma transação a essa linha do tempo - vamos desenhá-la na forma de um retângulo cujos lados corresponderão ao início e ao fim da transação. Como todas as transações no Firebird são numeradas, também especificaremos o número da transação.

Assim, o diagrama mostra a transação número 11 que começou no momento t3 e terminou no momento t10. Há duas maneiras de uma transação terminar - COMMIT, ou seja, aplicar todas as alterações feitas dentro da transação, e ROLLBACK, ou seja, cancelar todas as alterações feitas dentro da transação. Mostraremos a maneira como uma transação termina da seguinte forma:

Para podermos continuar, teremos que especificar vários parâmetros das transações nesses diagramas e os especificaremos no canto inferior esquerdo do retângulo que representa a transação correspondente - este exemplo mostra que a transação #11 tem o nível de isolamento snapshot.

Quando dizemos que “a transação X insere dados” ou “a transação Y lê tais e tais dados” - isso é formalmente incorreto, porque deveríamos dizer “alterações foram feitas dentro da transação X”. Somente instruções SQL podem ler ou inserir dados, então, se for importante para a narrativa, mostraremos essas instruções dentro do retângulo da transação:

Neste exemplo, temos a operação INSERT na tabela T1, campo i1, valor 100 - essa operação é executada dentro da transação #11 e é confirmada.

Além disso, às vezes teremos que mostrar o resultado de uma operação, por exemplo, no seguinte exemplo:

Este exemplo mostra o seguinte:

  1. A transação #11 com o parâmetro de nível de isolamento definido como snapshot (os níveis de isolamento serão discutidos mais adiante, aqui é mostrado apenas para traçar o quadro completo) é iniciada no momento t3
  2. A operação INSERT INTO T1(i1) values (100) que insere o valor 100 no campo i1 da tabela t1 começa no momento t5 E termina no momento t7
  3. A operação SELECT i1 from T1 que retorna o valor i1 igual a 100 começa no momento t8
  4. A transação #11 termina com a instrução COMMIT, ou seja, as alterações feitas pela transação #11 são confirmadas no banco de dados

Assim, com a ajuda dos diagramas de transações, podemos descrever em detalhes o que acontece no banco de dados e conhecer como as transações funcionam.

Agora que temos os diagramas de transações, vamos ver o que a sigla ACID realmente significa.

Atomicidade

Atomicidade significa que ou todas as operações que compõem uma transação são executadas ou nenhuma delas é executada: “tudo ou nada”. Parece simples, mas depois os detalhes vêm à tona.

Primeiro, o SGBD (não apenas o Firebird, mas quase todos) tem 2 tipos de atomicidade: atomicidade no nível de uma instrução e atomicidade no nível de um grupo de instruções dentro de uma transação.

Atomicidade no nível de instrução significa que a instrução UPDATET1 SETX=1 WHEREY=2 é sempre executada com sucesso ou não.

Atomicidade no nível de um grupo de instruções funciona de maneira diferente (usaremos pseudocódigo aqui para marcar quando a transação é iniciada e confirmada):

Code
Iniciar transação 11
INSERT ..100
INSERT ..200
INSERT ..300
Confirmar 11

É mais ou menos assim que ficará no diagrama:

Isso significa que todas as três instruções INSERT são executadas com sucesso e as alterações feitas por elas são confirmadas no momento em que a transação #11 é confirmada.

A pergunta que frequentemente faço em workshops quando se trata de transações - a instrução COMMIT será executada com sucesso para a transação 11 se INSERT INTO..300 gerar uma exceção:

Uma parte considerável da audiência sempre responde que a instrução COMMIT não será executada com sucesso! (Curiosamente, isso faria com que a transação fosse revertida em alguns outros SGBDs!)

No entanto, isso não é verdade - basta executar o isql e conduzir um experimento com qualquer banco de dados (o isql tem uma implementação simples e direta de operações, sem “adivinhar” para o usuário).

O ponto é que a atomicidade no nível de grupos de instruções garantida pela confirmação das transações é uma questão de lógica de negócios. O desenvolvedor de um aplicativo tem que decidir se uma transação deve ser confirmada em caso de exceção na terceira instrução INSERT ou não. Se a lógica de negócios torna possível confirmar o resultado, a instrução COMMIT pode ser facilmente executada.

Assim, o requisito de atomicidade no ACID é o requisito de que o SGBD possa confirmar ou reverter os resultados de um grupo de instruções executadas dentro de uma transação. A decisão de confirmar ou reverter depende da lógica de negócios que você precisa implementar.

E vamos ressaltar mais uma vez - embora a atomicidade de uma transação para um grupo de instruções signifique a possibilidade de confirmar ou reverter todo o grupo independentemente dos resultados (e a escolha depende da lógica de negócios), a atomicidade de uma única instrução é garantida pela implementação do SGBD, ou seja, é impossível executar uma instrução (por exemplo, UPDATE) “incompletamente” (não atomicamente).

Consistência

Consistência significa que os dados dentro do banco de dados não apresentam contradições. Claro, aqui podemos ver um campo inteiro para especulação porque “o que significa ’não apresentar contradições’ afinal”?

Dois níveis de consistência são geralmente destacados:

  1. Nível de banco de dados, onde consistência significa correspondência dos dados às restrições do banco de dados, como chaves Primary, Unique e Foreign, Checks. Esse nível de consistência é garantido pelo fato de que as restrições do banco de dados não permitirão inserir dados que não correspondam às restrições: por exemplo, CHECK(x>0) não permitirá que um número negativo seja inserido no campo correspondente.
  2. Nível de lógica de negócios, onde a consistência é garantida pelo desenvolvedor do aplicativo com a ajuda de ferramentas oferecidas pelo SGBD, como transações.

Como as transações ajudam a garantir a consistência no nível da lógica de negócios? Bem simples - se pegarmos o exemplo de transferência de dinheiro, o desenvolvedor deve garantir que todas as alterações sejam revertidas em caso de exceção e usar uma transação o ajuda com isso.

Code
Iniciar transação
Diminuir a quantia de dinheiro na conta 1…. Sucesso
Aumentar na conta 2… Falha
Rollback ---- em caso de exceção!

Em outras palavras, o desenvolvedor deve escrever o código de tal forma que os dados sejam revertidos em caso de exceção e assim a consistência dos dados seja preservada do ponto de vista da lógica de negócios.

Dessa forma, o requisito de consistência na sigla ACID significa que é necessário que o SGBD tenha a possibilidade de sustentar a consistência dos dados com a ajuda do mecanismo de transações.

Isolamento

O requisito de isolamento de transações surge da necessidade de garantir o resultado de um conjunto de operações independentemente da ordem em que são executadas.

Simplificando, cada transação deve ser executada com um mesmo resultado, independentemente de transações estarem ativas simultaneamente.

O mecanismo de transações deve garantir consistência no nível da lógica de negócios, mas também deve proteger as transações contra dados temporários não confirmados que possam aparecer durante o processo de execução de transações concorrentes.

Na prática, fica assim:

Vemos a transação #11 iniciada no momento t2, dentro da qual uma inserção é feita na tabela no momento t3-t5. A transação #11 não é confirmada imediatamente após a inserção, mas continua ativa até o momento t8.

Concurrentemente, a transação #12 é iniciada e executa a instrução SELECT para os registros da tabela nos quais a transação #11 inseriu. A primeira instrução SELECT é executada no momento t6, que é quando a operação de inserção já terminou, mas essa instrução retorna um resultado vazio porque a transação #12 não pode ver dados não confirmados de outras transações.

A transação #11 é confirmada no momento t8 e a instrução SELECT dentro da transação #12 é executada no momento t9. Ela retorna o resultado igual a 100 porque os dados criados dentro da transação #11 agora estão confirmados (e porque o nível de isolamento da transação #12 é read committed, mas falaremos sobre isso mais adiante).

Este exemplo é bastante suficiente para ilustrar o requisito de isolamento - diferentemente da atomicidade e da consistência, o isolamento é implementado como regras estritas chamadas níveis de isolamento, e cada transação deve ter um parâmetro que define o nível de isolamento com o qual ela trabalha.

Durabilidade

O conceito de durabilidade permite que o desenvolvedor confie plenamente no fato de que os dados criados dentro de uma transação confirmada aparecerão imediatamente no banco de dados e não desaparecerão dele (sem declarações explícitas que os excluam ou alterem, é claro), não importa o que aconteça depois.

Como você pode ver, o requisito de durabilidade é apenas senso comum - dificilmente alguém concordaria em usar um sistema cujos dados possam desaparecer de repente.

ACID: resumo

ACID significa os requisitos de como as transações devem funcionar:

  • Atomicidade
    • Declarações são sempre atômicas
    • Grupos de declarações podem ser tornados atômicos com a ajuda de transações
  • Consistência
    • Dois níveis de consistência: restrições do banco de dados e lógica de negócios
  • Isolamento
    • Garantido pelo mecanismo de transações com a ajuda dos níveis de isolamento definidos para elas
  • Durabilidade
    • Todos os dados confirmados tornam-se permanentes

Como você vê, tudo é bastante lógico. Na prática, a maior dificuldade é apresentada pelos níveis de isolamento, então vamos ver em detalhes como eles funcionam.

O nível de isolamento de uma transação define quais dados confirmados essa transação pode ver.

Existem níveis de isolamento que são convencionalmente chamados de padrão. Eles são descritos no padrão ANSI SQL (várias revisões). Pelo que sei, não existe um único SGBD onde eles sejam implementados exatamente da maneira descrita no padrão, mas ninguém se preocupa com isso, pois os mecanismos reais de transações em SGBDs específicos têm todas as opções necessárias para implementar a lógica de negócios.

Você pode encontrar a definição clássica dos níveis de isolamento em “A Critique of ANSI SQL Isolation Levels

Para aqueles que leram este artigo, aqui está a tabela comparando os níveis de isolamento clássicos com os similares no Firebird. É claro que a correspondência não é direta, pois os níveis de isolamento no Firebird, assim como em outros SGBDs, não estão 100% em conformidade com as definições do ANSI SQL, mas são muito semelhantes a elas.

Níveis de isolamento ANSI Nível de isolamento no Firebird
Read Uncommitted n/a
Read Committed Read Committed
Repeatable Read Snapshot
Serializable Snapshot table stability

Como qualquer outro SGBD, o Firebird tem suas peculiaridades na implementação do isolamento. Agora vamos nos concentrar em como os níveis de isolamento funcionam no Firebird, em vez de quão bem eles estão em conformidade com o padrão.

Nível de isolamento Snapshot

O nível de isolamento Snapshot foi o primeiro no código original do InterBase e continua sendo o padrão para a API principal do Firebird e utilitários (por exemplo, isql.exe). Esta pode ser a razão pela qual é o mais fácil de entender.

O Snapshot isola a transação de quaisquer alterações feitas a partir do momento em que ela é iniciada.

Vamos dar uma olhada no gráfico de transações abaixo: ele mostra a transação #10 iniciada com o nível de isolamento snapshot. Dentro desta transação, várias declarações SELECT são feitas para a tabela T1 que não possui registros neste exemplo.

A transação concorrente #15 iniciada após o início da transação #10 insere dados na tabela T1 e essa transação termina com a declaração COMMIT no momento t9, ou seja, os dados são confirmados no banco de dados neste momento e estão disponíveis para declarações de outras transações.

No entanto, a declaração na transação #10 executada no momento t10 (ou seja, após a transação #15 ser confirmada) não vê os dados inseridos, pois o nível de isolamento snapshot permite que ela veja apenas dados confirmados inseridos ou alterados ANTES DO INÍCIO da transação #10.

Assim, o nível de isolamento Snapshot permite que você trabalhe com o banco de dados como se ele estivesse congelado no momento em que a transação é iniciada. Geralmente é necessário para construir relatórios complicados baseados em dados que mudam rapidamente: o snapshot é usado para evitar a situação em que a primeira parte do relatório é baseada em alguns dados e a última parte é baseada em dados diferentes.

No entanto, esse ótimo recurso tem seu preço - quando examinarmos mais tarde como o isolamento é implementado no Firebird, você verá que iniciar transações muito longas com o nível de isolamento snapshot resulta em versões excessivas de registros e menor desempenho.

Nível de isolamento Read Committed

Uma transação com o nível de isolamento read committed pode ver os dados confirmados de outras transações que são confirmadas enquanto ela está ativa (diferentemente do caso do nível snapshot, quando você pode ver apenas dados confirmados antes do momento em que a transação é iniciada).

Vamos mostrar como o nível de isolamento read committed funciona usando o seguinte gráfico:

Ele mostra um exemplo praticamente idêntico ao anterior: duas transações concorrentes, uma das quais lê regularmente dados da tabela T1 enquanto a segunda insere e confirma dados.

Diferentemente do caso com o nível de isolamento snapshot, a transação #10 neste exemplo vê os dados inseridos e confirmados pela transação #15.

Este exemplo nos dá uma ideia sobre o impacto do nível de isolamento read committed: declarações dentro de uma transação com este nível de isolamento podem ver dados confirmados antes do momento em que a declaração correspondente é executada.

O próximo gráfico mostra um exemplo onde duas transações concorrentes #11 e #18 alteram dados.

Observe que a transação #11 começa antes do início da transação #14 que lê dados, enquanto a transação #18 começa depois dela, mas isso não afeta o resultado: se os dados são confirmados, eles podem ser vistos pela transação concorrente com o nível de isolamento read committed.

Essa possibilidade torna o nível de isolamento read committed uma escolha natural para aquelas declarações SQL que são executadas regularmente para mostrar o estado mais recente do banco de dados (por exemplo, para mostrar os pedidos mais recentes).

A parte dedicada à coleta de lixo mostrará que transações read committed com o modificador read-only no Firebird até a versão 4 são a melhor escolha para transações de leitura “infinitas”, pois são iniciadas como pré-confirmadas.

Nível de isolamento Snapshot table stability

É possível tornar a história sobre o modo de isolamento snapshot table stability, que é um contraparte ao modo de isolamento Serializable padrão, ou muito curta ou bastante longa e detalhada.

A versão curta da história é a seguinte: este nível é completamente semelhante ao nível snapshot, com adicionalmente o bloqueio da tabela (a tabela deve ser explicitamente especificada nos parâmetros da transação) para escrita e leitura. Isso significa que é possível iniciar uma transação que ocupará totalmente a tabela especificada e qualquer outra transação receberá erros de acesso.

Em outras palavras, uma transação com o nível de isolamento snapshot table stability colocará efetivamente todas as consultas à tabela especificada em uma fila. Na verdade, apenas leituras em transações regulares serão feitas fora de sua vez (como de costume), enquanto todos os outros modos formarão uma fila (depende da interação, é claro).

Se implementado sem cuidado, pode causar bloqueios e impossibilidade de trabalhar com o banco de dados, e é por isso que os desenvolvedores de aplicativos de banco de dados Firebird podem ter medo de usar este nível de isolamento.

No entanto, se implementado corretamente, o nível de isolamento Serializable torna possível formar facilmente filas e fazer alterações sequenciais em registros do banco de dados, o que pode ser muito útil para implementar contadores, números sequenciais de documentos e outros objetos semelhantes.

Para descrever corretamente como formar uma fila com a ajuda de uma transação com o nível de isolamento snapshot table stability, teremos que examinar mais um parâmetro de transação: wait/nowait - e então voltar ao exemplo da fila.

Anteriormente, examinamos tal forma de interação entre transações em que os dados são alterados dentro de uma transação e lidos dentro de outra transação.

No entanto, muitas vezes acontece na prática quando diferentes transações tentam alterar os mesmos dados e, como apenas um resultado é salvo no banco de dados, a transação concorrente receberá uma mensagem de conflito - na verdade, uma exceção que interromperá (e cancelará) a execução daquela declaração específica que está tentando alterar os dados já alterados.

A opção wait define como uma transação deve reagir ao conflito de atualização. Existem três maneiras de configurar esta opção:

  1. Wait (sem parâmetros) = esperar até que a transação concorrente termine
  2. Wait Timeout N sec = esperar até que a transação concorrente termine, mas não mais que N segundos
  3. Nowait - não esperar até que a transação concorrente termine

Observe que a opção wait é especificada em pseudocódigo aqui, enquanto os nomes podem ser diferentes na API e nos componentes específicos, embora o significado permaneça o mesmo.

Vamos ver em detalhes o que acontece em caso de conflitos de atualização com várias variantes da opção wait.

Wait

Então, vamos imaginar duas transações ativas concorrentes (#11 e #14) dentro das quais a declaração UPDATE é executada e deve alterar um mesmo registro em uma mesma tabela T1.

A transação #14 é executada com a opção wait (se você usar isql para reproduzir os exemplos, wait é definido por padrão).

A declaração UPDATE na transação #11 começa no momento t3 e termina no momento t5, mas a transação ainda não foi confirmada - ou seja, a declaração COMMIT não está lá até o momento t6.

O gráfico abaixo mostra esta situação:

A declaração UPDATE também é executada na transação #14 e tenta atualizar o mesmo registro na mesma tabela, mas começa mais tarde - aproximadamente no momento t4.

Como há um conflito de atualização com a atualização da transação #11 e wait é especificado na transação #14, a declaração UPDATE esperará até que a transação conflitante #11 termine.

Se a transação #11 durar tempo suficiente, a declaração UPDATE na transação #14 parecerá congelada do ponto de vista do usuário que observa a execução desta declaração.

Se você reproduzir esta situação com a ajuda de dois isql.exe, a próxima imagem mostra o momento em que a segunda transação (para ser exato, a transação onde a declaração UPDATE concorrente começa mais tarde - é a transação #14 em nosso exemplo) espera até que a primeira transação termine (é a transação #11 em nosso exemplo).

Após a declaração COMMIT ser executada na transação #11, a transação #14 que espera por ela será imediatamente notificada e a atualização conflitante terminará com uma exceção.

Abaixo você pode ver um exemplo de tal mensagem de erro (o número da transação concorrente não coincide com nosso exemplo porque os números das transações começam do início em cada banco de dados e então apenas incrementam, sendo redefinidos somente após backup/restauração):

Code
SQL> update T1 set i1 = 2 where i1=1;
Statement failed, SQLSTATE = 40001
deadlock
-update conflicts with concurrent update
-concurrent transaction number is 19
SQL>

Preste atenção à palavra “deadlock” na mensagem de erro - agora na verdade não há deadlock de acordo com sua definição clássica. Em vez disso, há um conflito de atualização, mas os desenvolvedores do Firebird não alteram a mensagem de erro porque ela é usada há mais de 35 anos. Trataremos do verdadeiro deadlock “clássico” mais tarde.

Então, examinamos a situação em que a transação com a declaração UPDATE concorrente termina com a declaração COMMIT. Vamos agora considerar uma situação semelhante, mas quando ela é revertida - você pode vê-la no gráfico abaixo:

A situação é completamente semelhante à anterior - duas declarações UPDATE tentam atualizar um mesmo registro, mas a transação concorrente #20 acaba sendo revertida desta vez e as alterações dentro da transação #15 são salvas no banco de dados sem erro como resultado.

Assim, a opção de espera possibilita organizar a lógica de negócio das atualizações de tal forma que atualizações conflitantes esperem infinitamente em uma fila, torcendo até o último momento para que a transação que está em conflito com elas termine com a instrução ROLLBACK.

Essa tática sempre faz sentido? Claro, depende da implementação da lógica de negócio, mas o Firebird também oferece outras opções para resolver conflitos de atualização com a ajuda da opção de espera.

Espera com tempo limite

Em primeiro lugar, pode ser uma boa ideia limitar o tempo de espera - em vez de esperar infinitamente em caso de conflito, você pode limitar o tempo de espera especificando um tempo limite para a opção de espera.

No isql.exe, esse parâmetro é especificado com a ajuda da seguinte instrução:

Code
SET TRANSACTION WAIT LOCK TIMEOUT N;

Onde N é o tempo (em segundos) que a transação concorrente aguardará pela resolução do conflito.

Você pode encontrar mais detalhes sobre instruções de controle de transação na Referência de Linguagem do Firebird. Observe que pode haver maneiras diferentes de especificar o tempo limite em drivers ou componentes de acesso específicos (geralmente, com a ajuda do parâmetro da API).

Você pode ver um exemplo no isql na imagem abaixo:

Vamos estudar com a ajuda de gráficos de transação como as transações interagem se você especificar o tempo limite para a opção de espera.

Então, a situação é a mesma - duas transações concorrentes #11 e #14 dentro das quais a instrução UPDATE que tenta atualizar um mesmo registro na tabela T1 é executada.

No entanto, neste caso, a instrução dentro da transação #14 espera até que a transação #11 termine ou até que o tempo limite especificado (3 segundos) expire - o que ocorrer primeiro.

O tempo limite expira antes neste exemplo, a instrução termina com uma exceção:

Code
SQL> update T1 set i1=6 where i1=1;
Statement failed, SQLSTATE = 40001
lock time-out on wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 40

Observe que “deadlock” está novamente na mensagem de erro, mas ainda não é um “deadlock” verdadeiro.

Correspondentemente, a situação é semelhante à da opção de espera, mas é limitada pelo tempo limite - se o tempo limite especificado expirar antes do término da transação concorrente.

Especificar a opção de espera com tempo limite pode ser uma boa solução para implementar lógica de negócio se você souber com certeza que todas as transações de escrita são bastante curtas (por exemplo, não mais que 1-2 segundos).

Nowait

É muito fácil explicar o que é Nowait do ponto de vista formal - é uma espera com tempo limite zero. Se você especificar nowait nas transações, atualizações conflitantes gerarão uma exceção imediatamente.

Neste caso, novamente temos transações concorrentes #11 e #14 (nowait) onde as instruções UPDATE concorrentes são executadas. A instrução dentro da transação com a opção nowait não espera quando vê uma atualização concorrente, mas gera a seguinte exceção imediatamente no momento de sua atualização (apenas o número da transação é diferente):

Code
SQL> update T1 set i1=5 where i1=1;
Statement failed, SQLSTATE = 40001
lock conflict on no wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 50
SQL>

Aqui está como fica em um exemplo com duas ferramentas isql:

Observe que a transação nowait não se importa quando e como a transação com a instrução UPDATE concorrente termina - seja com a instrução COMMIT ou ROLLBACK, a exceção ainda é gerada.

Do ponto de vista da lógica de negócio, a transação nowait pode ser conveniente se você souber com certeza que a atualização concorrente deve resultar inquestionavelmente no cancelamento das ações da instrução atual.

Muitos drivers do Firebird usam a opção nowait como valor padrão e, como muitos desenvolvedores não sabem que é possível definir um nível menos estrito para resolver conflitos de atualização (por exemplo, wait lock timeout 1), seus aplicativos (e às vezes usuários também) sofrem com erros desnecessários devido a conflitos.

Como a palavra-chave “deadlock” está presente em cada exceção relacionada a conflitos de atualização, muitos desenvolvedores de aplicativos têm certeza de que isso é o que um deadlock verdadeiro realmente é (alguns até pensam que o próprio Dead teve um papel nesse erro).

Ao mesmo tempo, se dermos uma olhada no arquivo de configuração firebird.conf, veremos o parâmetro DeadlockTimeout lá (é 10 segundos por padrão), e se olharmos o cabeçalho de saída do utilitário fb_lock_print, também veremos o parâmetro “Deadlock scans”.

A questão é que um “deadlock verdadeiro” é possível no Firebird e a palavra-chave “deadlock” que aparece em todas as exceções relacionadas a conflitos de atualização não tem conexão direta com ele. Felizmente, o deadlock verdadeiro ocorre raramente.

Vamos ver o que é esse “deadlock verdadeiro”. Para isso, dê uma olhada no seguinte gráfico de interação de transações:

Temos duas transações concorrentes com a opção de espera onde a instrução UPDATE é executada. Diferentemente do caso de um simples conflito de atualização, aqui podemos ver um conflito de atualização interdependente:

  • A transação #11 atualiza o registro com a chave = 20, e a transação #12 atualiza o registro com a chave = 10;
  • Depois disso, a transação #11 atualiza o registro com a chave = 10, e a transação #12 atualiza o registro com a chave = 20;

Como resultado, temos uma situação em que cada transação tem que esperar a outra terminar e ambas podem esperar infinitamente porque ambas têm a opção de espera especificada para elas. Claro, o servidor não pode permitir que isso aconteça, então uma das transações será forçada a ser revertida após o tempo limite especificado no parâmetro DeadlockTimeout definido com o valor de 10 segundos por padrão.

Podemos reproduzir essa situação com a ajuda de dois isql:

Após a segunda transação ser iniciada, ocorre uma situação de deadlock verdadeiro. Para descobrir com certeza, o servidor inicia um procedimento chamado Deadlock scan - ele é iniciado em intervalos iguais a DeadlockTimeout que é igual a 10 segundos por padrão.

Observe que o cliente (isql neste caso) recebe uma mensagem regular de conflito de atualização, mas ela é iniciada em 10 segundos mesmo se a transação for iniciada com a opção de espera.

Após o servidor detectar o bloqueio interdependente de duas transações, ele também incrementará o contador interno de deadlock (você pode vê-lo na saída do fb_lock_print).

Uso prático do Snapshot Table Stability

Agora que sabemos como as transações funcionam com instruções UPDATE conflitantes, podemos voltar ao nível de isolamento Snapshot Table Stability e encontrar um uso prático para ele.

Então, quando esse nível de isolamento é especificado, a tabela é bloqueada para escrita e até mesmo para leitura.

Observe que se a tabela nos parâmetros da transação não for explicitamente especificada, todas as tabelas que as instruções acessarem dentro desta transação são bloqueadas e isso acontece durante o primeiro acesso a uma tabela. Aparentemente, se esse nível de isolamento for usado sem cautela, facilmente levará a um grande número de conflitos de atualização.

A cláusula Reserving TableNN permite especificar uma tabela particular (ou várias tabelas) para ser bloqueada no início da transação (também é possível especificar o modo de reserva).

Esse ótimo recurso junto com a opção de espera permite implementar uma fila sequencial muito eficaz para alterar uma tabela específica.

Na prática, fica assim - aqueles clientes que precisam criar uma fila para uma tabela específica iniciam a transação SNAPSHOT TABLE STABILITY especificando essa tabela e então tentam executar dentro desta transação uma operação e terminá-la imediatamente.

Por exemplo, queremos criar um contador sequencialmente incrementado em uma tabela com o único registro do tipo CREATE TABLE Table1(i1 integer not null), mas não podemos usar um generator por algum motivo.

O pseudocódigo se parece aproximadamente com isto:

Code
set transaction snapshot table stability reserving TABLE1 for protected write
UPDATE Table1 Set i1 = i1+1;
SELECT i1 from Table1;
COMMIT;

Se executarmos esse código não com o nível de isolamento Snapshot Table Stability (Table1), mas com um nível de isolamento mais baixo, será possível que uma instrução UPDATE concorrente interfira entre o início da transação e antes de sua instrução UPDATE. Como resultado, obteremos uma exceção de atualização imediatamente (nowait) ou a instrução congelará até o fim da concorrente (wait) ou o tempo limite (wait interval) - em outras palavras, o conflito será resolvido de alguma forma no nível da instrução.

Com o nível de isolamento snapshot table stability, estamos protegidos contra isso porque a tabela é reservada no início da transação - ela é inteiramente nossa ou inteiramente não nossa. Se especificarmos a opção de espera para resolver conflitos, as conexões paralelas formarão automaticamente uma fila sem tratar nenhum erro.

Claro, essa abordagem só pode ser aplicada a transações curtas (como no nosso exemplo).

Na prática, o nível de isolamento Snapshot table stability é usado para formar filas e recalcular lógica complexa no modo exclusivo (em tabelas relativamente pequenas ou quando não há outros usuários).

Dentro do mecanismo, o Firebird usa o nível de isolamento Snapshot Table Stability para criar índices - ou seja, quando você executa a instrução ALTER INDEX indexname ACTIVE;, o Firebird ocupará completamente a tabela para a qual o índice está sendo construído.

O que vem a seguir?

Este artigo dá apenas uma introdução aos conceitos de transações do Firebird. Para entender completamente como as transações funcionam no Firebird, é necessário considerar a arquitetura multigeracional (versões de registro e conceitos de coleta de lixo), considerar marcadores de transação (Oldest Interesting, Oldest Active, Oldest Snapshot, next) e outras coisas.

O artigo é baseado nos materiais do seminário/workshop “All About Transactions”, que foi introduzido pela primeira vez em 2013 durante os seminários Firebird Tour, e com base no treinamento da IBSurgeon " Firebird Transaction in details".

Contatos

[email protected] Sinta-se à vontade para entrar em contato conosco com qualquer dúvida ou sugestão: [email protected]