Problema de bloqueo con DELETE / INSERT concurrente en PostgreSQL

35

Esto es bastante simple, pero estoy desconcertado por lo que hace PG (v9.0). Comenzamos con una tabla simple:

CREATE TABLE test (id INT PRIMARY KEY);

y unas pocas filas:

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

Utilizando mi herramienta de consulta JDBC favorita (ExecuteQuery), conecto dos ventanas de sesión a la base de datos donde vive esta tabla. Ambos son transaccionales (es decir, autocompromiso = falso). Llamémoslos S1 y S2.

El mismo bit de código para cada uno:

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

Ahora, ejecute esto en cámara lenta, ejecutando uno a la vez en las ventanas.

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

Ahora, esto funciona bien en SQLServer. Cuando S2 elimina, informa 1 fila eliminada. Y luego el inserto de S2 funciona bien.

Sospecho que PostgreSQL está bloqueando el índice en la tabla donde existe esa fila, mientras que SQLServer bloquea el valor de clave real.

Estoy en lo cierto? ¿Se puede hacer que esto funcione?

DaveyBob
fuente

Respuestas:

39

Mat y Erwin tienen razón, y solo estoy agregando otra respuesta para ampliar aún más lo que dijeron de una manera que no cabe en un comentario. Dado que sus respuestas no parecen satisfacer a todos, y hubo una sugerencia de que se debería consultar a los desarrolladores de PostgreSQL, y yo soy uno, elaboraré.

El punto importante aquí es que, según el estándar SQL, dentro de una transacción que se ejecuta en el READ COMMITTEDnivel de aislamiento de la transacción, la restricción es que el trabajo de las transacciones no confirmadas no debe ser visible. Cuando el trabajo de las transacciones confirmadas se hace visible, depende de la implementación. Lo que está señalando es una diferencia en cómo dos productos han elegido implementar eso. Ninguna implementación viola los requisitos de la norma.

Esto es lo que sucede dentro de PostgreSQL, en detalle:

S1-1 se ejecuta (1 fila eliminada)

La fila anterior se deja en su lugar, porque S1 aún puede retroceder, pero S1 ahora mantiene un bloqueo en la fila para que cualquier otra sesión que intente modificar la fila espere para ver si S1 se compromete o retrocede. Cualquier lectura de la tabla aún puede ver la fila anterior, a menos que intenten bloquearla con SELECT FOR UPDATEo SELECT FOR SHARE.

S2-1 se ejecuta (pero está bloqueado ya que S1 tiene un bloqueo de escritura)

S2 ahora tiene que esperar para ver el resultado de S1. Si S1 retrocediera en lugar de confirmar, S2 eliminaría la fila. Tenga en cuenta que si S1 inserta una nueva versión antes de deshacer, la nueva versión nunca habría estado allí desde la perspectiva de ninguna otra transacción, ni la versión anterior se habría eliminado desde la perspectiva de cualquier otra transacción.

S1-2 carreras (1 fila insertada)

Esta fila es independiente de la anterior. Si hubiera habido una actualización de la fila con id = 1, las versiones antigua y nueva estarían relacionadas, y S2 podría eliminar la versión actualizada de la fila cuando se desbloqueara. Que una nueva fila tenga los mismos valores que alguna fila que existía en el pasado no es lo mismo que una versión actualizada de esa fila.

S1-3 se ejecuta, liberando el bloqueo de escritura

Entonces los cambios de S1 son persistentes. Una fila se ha ido. Se ha agregado una fila.

S2-1 se ejecuta, ahora que puede obtener el bloqueo. Pero informa 0 filas eliminadas. HUH ???

Lo que sucede internamente es que hay un puntero de una versión de una fila a la siguiente versión de esa misma fila si se actualiza. Si se elimina la fila, no hay una versión siguiente. Cuando una READ COMMITTEDtransacción se despierta de un bloque en un conflicto de escritura, sigue esa cadena de actualización hasta el final; si la fila no se ha eliminado y si aún cumple con los criterios de selección de la consulta, se procesará. Esta fila se ha eliminado, por lo que la consulta de S2 continúa.

S2 puede o no llegar a la nueva fila durante su exploración de la tabla. Si lo hace, verá que la nueva fila se creó después de DELETEque se inició la instrucción de S2 , por lo que no es parte del conjunto de filas visibles para ella.

Si PostgreSQL reiniciara la declaración DELETE completa de S2 desde el principio con una nueva instantánea, se comportaría igual que SQL Server. La comunidad PostgreSQL no ha elegido hacer eso por razones de rendimiento. En este caso simple, nunca notaría la diferencia en el rendimiento, pero si tuviera diez millones de filas en un DELETEmomento en que se bloqueó, ciertamente lo haría. Aquí hay una compensación donde PostgreSQL ha elegido el rendimiento, ya que la versión más rápida aún cumple con los requisitos del estándar.

S2-2 se ejecuta, informa una violación de restricción de clave única

Por supuesto, la fila ya existe. Esta es la parte menos sorprendente de la imagen.

Si bien aquí hay un comportamiento sorprendente, todo está en conformidad con el estándar SQL y dentro de los límites de lo que es "específico de implementación" de acuerdo con el estándar. Ciertamente puede ser sorprendente si está asumiendo que el comportamiento de alguna otra implementación estará presente en todas las implementaciones, pero PostgreSQL se esfuerza mucho por evitar fallas de serialización en el READ COMMITTEDnivel de aislamiento, y permite algunos comportamientos que difieren de otros productos para lograrlo.

Ahora, personalmente, no soy un gran admirador del READ COMMITTEDnivel de aislamiento de transacciones en la implementación de ningún producto. Todos permiten que las condiciones de carrera creen comportamientos sorprendentes desde un punto de vista transaccional. Una vez que alguien se acostumbra a los comportamientos extraños permitidos por un producto, tiende a considerar que eso es "normal" y que las compensaciones elegidas por otro producto son extrañas. Pero cada producto tiene que hacer algún tipo de compensación por cualquier modo que no se implemente realmente SERIALIZABLE. Donde los desarrolladores de PostgreSQL han elegido trazar la línea READ COMMITTEDes minimizar el bloqueo (las lecturas no bloquean las escrituras y las escrituras no bloquean las lecturas) y minimizar la posibilidad de fallas de serialización.

El estándar requiere que las SERIALIZABLEtransacciones sean las predeterminadas, pero la mayoría de los productos no lo hacen porque causa un impacto en el rendimiento en los niveles de aislamiento de transacciones más laxos. Algunos productos ni siquiera proporcionan transacciones verdaderamente serializables cuando SERIALIZABLEse elige, especialmente Oracle y versiones de PostgreSQL anteriores a 9.1. Pero el uso de SERIALIZABLEtransacciones reales es la única forma de evitar efectos sorprendentes de las condiciones de carrera, y las SERIALIZABLEtransacciones siempre deben bloquearse para evitar las condiciones de carrera o revertir algunas transacciones para evitar una condición de carrera en desarrollo. La implementación más común de las SERIALIZABLEtransacciones es el bloqueo estricto de dos fases (S2PL), que tiene fallas tanto de bloqueo como de serialización (en forma de puntos muertos).

Revelación completa: trabajé con Dan Ports de MIT para agregar transacciones verdaderamente serializables a PostgreSQL versión 9.1 utilizando una nueva técnica llamada Aislamiento de instantáneas serializables.

kgrittn
fuente
Me pregunto si una forma realmente barata (¿cursi?) De hacer que esto funcione es emitir dos DELETES seguidos de INSERT. En mis pruebas limitadas (2 hilos), funcionó bien, pero necesito probar más para ver si eso sería válido para muchos hilos.
DaveyBob
Mientras esté usando READ COMMITTEDtransacciones, tiene una condición de carrera: ¿qué sucedería si otra transacción insertara una nueva fila después del primer DELETEinicio y antes de que DELETEcomenzara el segundo ? Con transacciones menos estrictas que SERIALIZABLElas dos formas principales de cerrar las condiciones de carrera es a través de la promoción de un conflicto (pero eso no ayuda cuando se elimina la fila) y la materialización de un conflicto. Puede materializar el conflicto al tener una tabla "id" que se actualizó para cada fila eliminada, o al bloquear explícitamente la tabla. O use reintentos por error.
kgrittn
Vuelve a intentarlo. Muchas gracias por la valiosa información!
DaveyBob
21

Creo que esto es por diseño, de acuerdo con la descripción del nivel de aislamiento de lectura confirmada para PostgreSQL 9.2:

Los comandos ACTUALIZAR, ELIMINAR, SELECCIONAR PARA ACTUALIZAR y SELECCIONAR PARA COMPARTIR se comportan de la misma manera que SELECCIONAR en términos de búsqueda de filas de destino: solo encontrarán las filas de destino que se confirmaron a la hora de inicio del comando 1 . Sin embargo, tal fila de destino podría haber sido actualizada (o eliminada o bloqueada) por otra transacción concurrente para cuando se encuentre. En este caso, el actualizador esperará a que se confirme o revierta la primera transacción de actualización (si aún está en progreso). Si el primer actualizador retrocede, sus efectos se niegan y el segundo actualizador puede continuar con la actualización de la fila encontrada originalmente. Si el primer actualizador se compromete, el segundo actualizador ignorará la fila si el primer actualizador lo eliminó 2, de lo contrario, intentará aplicar su operación a la versión actualizada de la fila.

La fila se inserta en la S1que no existía aún cuando S2que está DELETEcomenzaron. Por lo tanto, no se verá por la eliminación en el punto S2( 1 ) anterior. El que S1borró es ignorado por S2's de DELETEacuerdo con ( 2 ).

Entonces S2, la eliminación no hace nada. Sin embargo, cuando aparece el inserto, ese ve S1el inserto:

Porque comienza el modo de lectura confirmada cada comando con una nueva instantánea que incluye todas las transacciones confirmadas hasta ese instante, los comandos posteriores en la misma transacción verán los efectos de la transacción concurrente confirmada en cualquier caso . El punto en cuestión anterior es si un solo comando ve o no una vista absolutamente consistente de la base de datos.

Entonces, el intento de inserción por S2falla con la violación de restricción.

Continuando leyendo ese documento, usando lectura repetible o incluso serializable no resolvería su problema por completo: la segunda sesión fallaría con un error de serialización en la eliminación.

Sin embargo, esto le permitirá volver a intentar la transacción.

Estera
fuente
Gracias Mat. Si bien eso parece ser lo que está sucediendo, parece haber una falla en esa lógica. Me parece que, en un nivel iso READ_COMMITTED, estas dos declaraciones deben tener éxito dentro de un tx: DELETE FROM test WHERE ID = 1 INSERT INTO test VALUES (1) Quiero decir, si elimino la fila y luego inserto la fila, entonces ese inserto debería tener éxito. SQLServer hace esto bien. Tal como están las cosas, me está costando mucho lidiar con esta situación en un producto que tiene que funcionar con ambas bases de datos.
DaveyBob
11

Estoy completamente de acuerdo con la excelente respuesta de @ Mat . Solo escribo otra respuesta, porque no cabe en un comentario.

En respuesta a su comentario: El DELETEin S2 ya está conectado a una versión de fila particular. Dado que esto es asesinado por S1 mientras tanto, S2 se considera exitoso. Aunque no es obvio a simple vista, la serie de eventos es prácticamente así:

   S1 DELETE exitoso  
S2 DELETE (exitoso por proxy - DELETE de S1)  
   S1 vuelve a insertar el valor eliminado prácticamente mientras tanto  
S2 INSERT falla con una violación de restricción de clave única

Todo es por diseño. Realmente necesita usar las SERIALIZABLEtransacciones para sus requisitos y asegurarse de volver a intentar el error de serialización.

Erwin Brandstetter
fuente
1

Use una clave primaria DEFERRABLE e intente nuevamente.

Frank Heikens
fuente
gracias por el consejo, pero usar DEFERRABLE no hizo ninguna diferencia. El documento se lee como debería, pero no lo hace.
DaveyBob
-2

También nos enfrentamos a este problema. Nuestra solución es agregar select ... for updateantes delete from ... where. El nivel de aislamiento debe ser Lectura comprometida.

Mian Huang
fuente