Problema de bloqueio com DELETE / INSERT simultâneo no PostgreSQL

35

Isso é bem simples, mas estou desconcertado com o que o PG faz (v9.0). Começamos com uma tabela simples:

CREATE TABLE test (id INT PRIMARY KEY);

e algumas linhas:

INSERT INTO TEST VALUES (1);
INSERT INTO TEST VALUES (2);

Usando minha ferramenta de consulta JDBC favorita (ExecuteQuery), conecto duas janelas de sessão ao banco de dados em que esta tabela mora. Ambos são transacionais (ou seja, auto-commit = false). Vamos chamá-los de S1 e S2.

O mesmo trecho de código para cada um:

1:DELETE FROM test WHERE id=1;
2:INSERT INTO test VALUES (1);
3:COMMIT;

Agora, execute isso em câmera lenta, executando um de cada vez nas janelas.

S1-1 runs (1 row deleted)
S2-1 runs (but is blocked since S1 has a write lock)
S1-2 runs (1 row inserted)
S1-3 runs, releasing the write lock
S2-1 runs, now that it can get the lock. But reports 0 rows deleted. HUH???
S2-2 runs, reports a unique key constraint violation

Agora, isso funciona bem no SQLServer. Quando o S2 faz a exclusão, ele reporta 1 linha excluída. E então a inserção do S2 funciona bem.

Eu suspeito que o PostgreSQL esteja bloqueando o índice na tabela em que essa linha existe, enquanto o SQLServer bloqueia o valor real da chave.

Estou certo? Isso pode ser feito para funcionar?

DaveyBob
fonte

Respostas:

39

Mat e Erwin estão certos, e estou adicionando outra resposta para expandir ainda mais o que eles disseram de uma maneira que não cabe em um comentário. Como suas respostas parecem não satisfazer a todos, houve uma sugestão de que os desenvolvedores do PostgreSQL devessem ser consultados, e eu sou um deles, irei elaborar.

O ponto importante aqui é que, sob o padrão SQL, dentro de uma transação em execução no READ COMMITTEDnível de isolamento da transação, a restrição é que o trabalho de transações não confirmadas não deve estar visível. Quando o trabalho das transações confirmadas se torna visível, depende da implementação. O que você está apontando é a diferença de como dois produtos optaram por implementá-lo. Nenhuma implementação está violando os requisitos do padrão.

Aqui está o que acontece no PostgreSQL, em detalhes:

S1-1 é executado (1 linha excluída)

A linha antiga é deixada no lugar, porque o S1 ainda pode reverter, mas o S1 agora mantém um bloqueio na linha, para que qualquer outra sessão que tente modificar a linha espere para ver se o S1 confirma ou retrocede. Qualquer leitura da tabela ainda pode ver a linha antiga, a menos que tente travá-la com SELECT FOR UPDATEou SELECT FOR SHARE.

S2-1 é executado (mas está bloqueado porque S1 tem um bloqueio de gravação)

S2 agora precisa esperar para ver o resultado de S1. Se o S1 reverter em vez de confirmar, o S2 excluirá a linha. Observe que se o S1 inserisse uma nova versão antes de reverter, a nova versão nunca estaria lá da perspectiva de qualquer outra transação, nem a versão antiga teria sido excluída da perspectiva de qualquer outra transação.

Execuções S1-2 (1 linha inserida)

Esta linha é independente da antiga. Se houvesse uma atualização da linha com id = 1, as versões antiga e nova seriam relacionadas e o S2 poderia excluir a versão atualizada da linha quando ela fosse desbloqueada. O fato de uma nova linha ter os mesmos valores que uma linha que existia no passado não a torna igual a uma versão atualizada dessa linha.

S1-3 é executado, liberando o bloqueio de gravação

Portanto, as alterações do S1 são persistentes. Uma linha se foi. Uma linha foi adicionada.

S2-1 é executado, agora que pode ser bloqueado. Mas relata 0 linhas excluídas. HÃ???

O que acontece internamente é que existe um ponteiro de uma versão de uma linha para a próxima versão dessa mesma linha, se ela for atualizada. Se a linha for excluída, não há próxima versão. Quando uma READ COMMITTEDtransação é despertada de um bloco em um conflito de gravação, segue-se essa cadeia de atualização até o fim; se a linha não tiver sido excluída e se ainda atender aos critérios de seleção da consulta, ela será processada. Esta linha foi excluída, portanto, a consulta do S2 segue em frente.

O S2 pode ou não chegar à nova linha durante a verificação da tabela. Se isso acontecer, verá que a nova linha foi criada após o DELETEinício da instrução S2 e, portanto, não faz parte do conjunto de linhas visíveis para ela.

Se o PostgreSQL reiniciasse toda a instrução DELETE do S2 desde o início com um novo instantâneo, ele se comportaria da mesma maneira que o SQL Server. A comunidade do PostgreSQL não escolheu fazer isso por razões de desempenho. Nesse caso simples, você nunca notaria a diferença no desempenho, mas se você tivesse dez milhões de linhas em um DELETEquando foi bloqueado, certamente o faria. Aqui há uma troca onde o PostgreSQL escolheu o desempenho, uma vez que a versão mais rápida ainda cumpre os requisitos do padrão.

S2-2 é executado, relata uma violação exclusiva de restrição de chave

Obviamente, a linha já existe. Esta é a parte menos surpreendente da imagem.

Embora exista algum comportamento surpreendente aqui, tudo está em conformidade com o padrão SQL e dentro dos limites do que é "específico da implementação" de acordo com o padrão. Certamente pode ser surpreendente se você estiver assumindo que o comportamento de alguma outra implementação estará presente em todas as implementações, mas o PostgreSQL se esforça muito para evitar falhas de serialização no READ COMMITTEDnível de isolamento e permite alguns comportamentos que diferem de outros produtos para conseguir isso.

Agora, pessoalmente, não sou um grande fã do READ COMMITTEDnível de isolamento de transações na implementação de nenhum produto. Todos eles permitem que as condições de corrida criem comportamentos surpreendentes do ponto de vista transacional. Quando alguém se acostuma aos comportamentos estranhos permitidos por um produto, eles tendem a considerar o "normal" e as compensações escolhidas por outro produto como estranhas. Mas todo produto precisa fazer algum tipo de troca por qualquer modo que não seja realmente implementado SERIALIZABLE. Onde os desenvolvedores do PostgreSQL optaram por chamar a atenção READ COMMITTEDé minimizar o bloqueio (leituras não bloqueiam gravações e gravações não bloqueiam leituras) e minimizar as chances de falhas de serialização.

O padrão exige que as SERIALIZABLEtransações sejam o padrão, mas a maioria dos produtos não faz isso porque causa um impacto no desempenho nos níveis de isolamento de transação mais frouxos. Alguns produtos nem fornecem transações verdadeiramente serializáveis ​​quando SERIALIZABLEsão escolhidos - principalmente o Oracle e as versões do PostgreSQL anteriores à 9.1. Porém, usar verdadeiramente SERIALIZABLEtransações é a única maneira de evitar efeitos surpreendentes das condições de corrida, e as SERIALIZABLEtransações sempre devem bloquear para evitar as condições de corrida ou reverter algumas transações para evitar uma condição de corrida em desenvolvimento. A implementação mais comum de SERIALIZABLEtransações é o bloqueio estrito de duas fases (S2PL), que apresenta falhas de bloqueio e de serialização (na forma de deadlocks).

Divulgação completa: Trabalhei com Dan Ports do MIT para adicionar transações verdadeiramente serializáveis ​​ao PostgreSQL versão 9.1 usando uma nova técnica chamada Serializable Snapshot Isolation.

kgrittn
fonte
Gostaria de saber se uma maneira realmente barata (brega?) De fazer esse trabalho é emitir dois DELETES seguidos pelo INSERT. No meu teste limitado (2 threads), funcionou bem, mas preciso testar mais para ver se isso seria válido para muitos threads.
DaveyBob
Enquanto você estiver usando READ COMMITTEDtransações, você tem uma condição de corrida: o que aconteceria se outra transação inserisse uma nova linha após o DELETEinício e antes do início da segunda DELETE? Com transações menos rigorosas do que SERIALIZABLEas duas principais maneiras de fechar as condições de corrida, é através da promoção de um conflito (mas isso não ajuda quando a linha está sendo excluída) e a materialização de um conflito. Você pode materializar o conflito tendo uma tabela "id" atualizada para cada linha excluída ou bloqueando explicitamente a tabela. Ou use tentativas com erro.
kgrittn
Tente novamente. Muito obrigado pela visão valiosa!
DaveyBob
21

Acredito que isso ocorra por design, de acordo com a descrição do nível de isolamento de confirmação de leitura do PostgreSQL 9.2:

Os comandos UPDATE, DELETE, SELECT FOR UPDATE e SELECT FOR SHARE se comportam da mesma forma que SELECT em termos de pesquisa de linhas de destino: eles encontrarão apenas as linhas de destino que foram confirmadas no momento de início do comando 1 . No entanto, essa linha de destino pode já ter sido atualizada (ou excluída ou bloqueada) por outra transação simultânea no momento em que é encontrada. Nesse caso, o aspirador de atualização aguardará a primeira transação de atualização confirmar ou reverter (se ainda estiver em andamento). Se o primeiro atualizador reverter, seus efeitos serão negados e o segundo atualizador poderá prosseguir com a atualização da linha encontrada originalmente. Se o primeiro atualizador confirmar, o segundo atualizador ignorará a linha se o primeiro atualizador a excluir 2, caso contrário, tentará aplicar sua operação à versão atualizada da linha.

A linha que você inserir no S1ainda não existia quando S2é DELETEiniciada. Portanto, não será visto pela exclusão S2conforme ( 1 ) acima. O que S1excluiu é ignorado por S2's de DELETEacordo com ( 2 ).

Assim S2, a exclusão não faz nada. Quando a inserção vem junto, porém, que se faz ver S1de inserção:

Como o modo Read Committed inicia cada comando com um novo instantâneo que inclui todas as transações confirmadas até aquele instante, os comandos subsequentes na mesma transação verão os efeitos da transação simultânea confirmada em qualquer caso . O ponto em questão acima é se um único comando vê ou não uma visão absolutamente consistente do banco de dados.

Portanto, a tentativa de inserção S2falha com a violação de restrição.

Continuar lendo esse documento, usar leitura repetível ou até serializável não resolveria o problema completamente - a segunda sessão falharia com um erro de serialização na exclusão.

Isso permitiria tentar novamente a transação.

Esteira
fonte
Obrigado Mat. Enquanto isso parece ser o que está acontecendo, parece haver uma falha nessa lógica. Parece-me que, em um nível iso READ_COMMITTED, essas duas instruções devem ser bem-sucedidas dentro de um tx: DELETE FROM test WHERE ID = 1 INSERT INTO VALUES (1) Quero dizer, se eu excluir a linha e inserir a linha, essa inserção deve ser bem-sucedida. O SQLServer acerta isso. No momento, estou tendo dificuldades para lidar com essa situação em um produto que precisa trabalhar com os dois bancos de dados.
DaveyBob 26/10/12
11

Concordo plenamente com a excelente resposta de @ Mat . Só escrevo outra resposta, porque não caberia em um comentário.

Em resposta ao seu comentário: O DELETEno S2 já está conectado a uma versão de linha específica. Como isso é eliminado pelo S1 nesse meio tempo, o S2 se considera bem-sucedido. Embora não seja óbvio à primeira vista, a série de eventos é praticamente assim:

   S1 DELETE bem sucedido  
S2 DELETE (bem-sucedido por proxy - DELETE de S1)  
   S1 reinsere o valor excluído virtualmente enquanto isso  
S2 INSERT falha com violação de restrição de chave exclusiva

É tudo por design. Você realmente precisa usar SERIALIZABLEtransações para seus requisitos e tente novamente com falha de serialização.

Erwin Brandstetter
fonte
1

Use uma chave primária DEFERRABLE e tente novamente.

Frank Heikens
fonte
obrigado pela dica, mas usar DEFERRABLE não fez nenhuma diferença. O documento está como deveria, mas não está.
DaveyBob 26/10/12
-2

Também enfrentamos esse problema. Nossa solução está adicionando select ... for updateantes delete from ... where. O nível de isolamento deve ser Read Confirmado.

Mian Huang
fonte