Esta página fue traducida automáticamente. Lee el original en inglés. English

Transacciones en Firebird: ACID, niveles de aislamiento, interbloqueos y resolución de conflictos de actualización

Alexey Kovyazin, con la ayuda de Vlad Khorsun y Dmitry Kuzmenko, 08-ABR-2019

Contenido:

¿Es necesario saber cómo funcionan las transacciones?

Probablemente sí, porque la noción de transacciones es simple y muchos desarrolladores subestiman la importancia de usar transacciones en Firebird correctamente. Sin embargo, solo después de obtener una comprensión profunda de cómo funcionan las transacciones, puedes comprender muchas cosas misteriosas relacionadas con el rendimiento, como las ralentizaciones repentinas de la base de datos (relacionadas con el barrido de versiones excesivas de registros que aparecen debido a una mala gestión de transacciones).

En general, la noción de transacción se aplica a cualquier sistema dinámico que pasa de un estado a otro. Por ejemplo, un ejemplo clásico de transacción es transferir dinero de una cuenta a otra. Normalmente, se ve algo así:

Code

Begin   --- transferir dinero de la cuenta 1 a la cuenta 2
 --disminuir cuenta 1
 --aumentar cuenta 2
End - confirmar la transacción

El ejemplo se reduce al hecho de que el dinero debe desaparecer de la cuenta 1 y aparecer en la cuenta 2 simultáneamente; de lo contrario, habrá un exceso de dinero o una falta inexplicable de dinero durante algún tiempo en el sistema.

Desde el punto de vista de las bases de datos, una transacción se define generalmente como un grupo de operaciones realizadas en una base de datos que se considera independiente de otras transacciones. Desde mi punto de vista, esta definición no es ni mejor ni peor que otras, pero, como cualquier definición, tiene poco sentido sin conocer el funcionamiento interno real y la lógica de un DBMS.

Se cree que una transacción en una base de datos debe cumplir los llamados requisitos ACID

Code
A - Atomicidad
C - Consistencia
I - Aislamiento
D - Durabilidad

Muchos desarrolladores de aplicaciones de bases de datos están tan inspirados por este acrónimo que a menudo usan argumentos como “no tienes D en ACID” cuando se trata de comparar diferentes DBMS (lo que generalmente va seguido de “no me importa lo que pienses”).

En realidad, todo es bastante simple: ACID es un conjunto de requisitos sobre la implementación de una transacción en un DBMS particular, algunos de ellos son muy estrictos (por ejemplo, D - ¡por supuesto, la durabilidad es importante!) mientras que otros son menos estrictos - cuando analicemos los niveles de aislamiento de transacciones, veremos que el aislamiento puede variar.

Por eso no vale la pena intentar entender de inmediato qué significa literalmente este acrónimo. En cambio, examinaremos la lógica de cómo funcionan los DBMS (y las transacciones, en particular) y veremos ACID desde el punto de vista de “cómo está hecho” en lugar de “qué significa”.

Dado que los aspectos de las transacciones son complejos, necesitamos una representación gráfica, una especie de diagramas, para mostrar el trabajo y la interacción de las transacciones. Usando estos diagramas, podremos construir una narración lógica y profundizar en los detalles de cómo funcionan las transacciones.

En primer lugar, introduciremos una línea de tiempo porque las transacciones se desarrollan en el tiempo. La línea de tiempo estará marcada de la manera que necesitamos: no necesitamos ni segundos ni minutos, sino los pasos clave de la interacción entre transacciones:

Luego agregaremos una transacción a esta línea de tiempo: dibujémosla en forma de rectángulo cuyos lados corresponderán al inicio y al final de la transacción. Dado que todas las transacciones en Firebird están numeradas, también especificaremos el número de transacción.

Así, el diagrama muestra la transacción número 11 que comenzó en el momento t3 y terminó en el momento t10. Hay dos formas de que una transacción termine: COMMIT, es decir, aplicar todos los cambios realizados dentro de la transacción, y ROLLBACK, es decir, cancelar todos los cambios realizados dentro de la transacción. Mostraremos la forma en que termina una transacción de la siguiente manera:

Para poder continuar, tendremos que especificar varios parámetros de las transacciones en estos diagramas y los especificaremos en la esquina inferior izquierda del rectángulo que representa la transacción correspondiente: este ejemplo muestra que la transacción #11 tiene el nivel de aislamiento snapshot.

Cuando decimos que “la transacción X inserta datos” o “la transacción Y lee tales y tales datos”, es formalmente incorrecto, porque deberíamos decir “se realizaron cambios dentro de la transacción X”. Solo las sentencias SQL pueden leer o insertar datos, por lo que, si es importante para la narración, mostraremos estas sentencias dentro del rectángulo de la transacción:

En este ejemplo, tenemos la operación INSERT para la tabla T1, campo i1, valor 100: esta operación se realiza dentro de la transacción #11 y se confirma.

Además, a veces tendremos que mostrar el resultado de una operación, por ejemplo, en el siguiente ejemplo:

Este ejemplo muestra lo siguiente:

  1. La transacción #11 con el parámetro de nivel de aislamiento establecido en snapshot (los niveles de aislamiento se discutirán más adelante, aquí se muestra solo para dibujar el panorama completo) se inicia en el momento t3
  2. La operación INSERT INTO T1(i1) values (100) que inserta el valor 100 en el campo i1 de la tabla t1 comienza en el momento t5 Y termina en el momento t7
  3. La operación SELECT i1 from T1 que devuelve el valor i1 igual a 100 comienza en el momento t8
  4. La transacción #11 termina con la sentencia COMMIT, es decir, los cambios realizados por la transacción #11 se confirman en la base de datos

Así, con la ayuda de los diagramas de transacciones, podemos describir en detalle lo que sucede en la base de datos y conocer cómo funcionan las transacciones.

Ahora que tenemos los diagramas de transacciones, veamos qué significa realmente el acrónimo ACID.

Atomicidad

La atomicidad significa que o bien todas las operaciones que componen una transacción se realizan o bien ninguna de ellas se realiza: “todo o nada”. Parece pan comido, pero luego salen a la luz los detalles.

Primero, el DBMS (no solo Firebird sino casi todos) tiene 2 tipos de atomicidad: atomicidad a nivel de sentencia y atomicidad a nivel de grupo de sentencias dentro de una transacción.

La atomicidad a nivel de sentencia significa que la sentencia UPDATET1 SETX=1 WHEREY=2 siempre se ejecuta con éxito o no se ejecuta.

La atomicidad a nivel de grupo de sentencias funciona de manera diferente (usaremos pseudocódigo aquí para marcar cuándo se inicia y se confirma la transacción):

Code
Start transaction 11
INSERT ..100
INSERT ..200
INSERT ..300
Commit 11

Esto es lo que aproximadamente se verá en el diagrama:

Significa que las tres sentencias INSERT se ejecutan con éxito y los cambios realizados por ellas se confirman en el momento en que se confirma la transacción #11.

La pregunta que a menudo hago en los talleres cuando se trata de transacciones: ¿la sentencia COMMIT se ejecutará con éxito para la transacción 11 si INSERT INTO..300 genera una excepción?

Una parte considerable de la audiencia siempre responde que la sentencia COMMIT no se ejecutará con éxito. (¡Curiosamente, en algunos otros DBMS hará que la transacción se revierta!)

Sin embargo, eso no es cierto: simplemente ejecuta isql y realiza un experimento con cualquier base de datos (isql tiene una implementación simple y directa de operaciones, sin “adivinar” por el usuario).

La cuestión es que la atomicidad a nivel de grupos de sentencias garantizada por la confirmación de transacciones es una cuestión de lógica de negocio. El desarrollador de una aplicación tiene que decidir si una transacción debe confirmarse en caso de una excepción en la tercera sentencia INSERT o no. Si la lógica de negocio hace posible confirmar el resultado, la sentencia COMMIT puede ejecutarse fácilmente.

Por lo tanto, el requisito de atomicidad en ACID es el requisito de que el DBMS pueda confirmar o revertir los resultados de un grupo de sentencias ejecutadas dentro de una transacción. La decisión de confirmarla o revertirla depende de la lógica de negocio que necesites implementar.

Y subrayémoslo una vez más: aunque la atomicidad de una transacción para un grupo de sentencias significa la posibilidad de confirmar o revertir todo el grupo independientemente de los resultados (y la elección depende de la lógica de negocio), la atomicidad de una sola sentencia está garantizada por la implementación del DBMS, es decir, es imposible ejecutar una sentencia (por ejemplo, UPDATE) “incompletamente” (no atómicamente).

Consistencia

La consistencia significa que los datos dentro de la base de datos no presentan contradicciones. Por supuesto, aquí podemos ver todo un dominio para la especulación porque “¿qué significa ’no presenta contradicciones’ en absoluto”?

Generalmente se distinguen dos niveles de consistencia:

  1. Nivel de base de datos donde la consistencia significa la correspondencia de los datos con las restricciones de la base de datos, como claves Primary, Unique y Foreign, Checks. Este nivel de consistencia está garantizado por el hecho de que las restricciones de la base de datos no permitirán insertar datos que no correspondan a las restricciones: por ejemplo, CHECK(x>0) no permitirá que se inserte un número negativo en el campo correspondiente.
  2. Nivel de lógica de negocio donde la consistencia está garantizada por el desarrollador de la aplicación con la ayuda de herramientas ofrecidas por el DBMS, como las transacciones.

¿Cómo ayudan las transacciones a garantizar la consistencia a nivel de lógica de negocio? Bastante simple: si tomamos el ejemplo de transferencia de dinero, el desarrollador debe asegurarse de que todos los cambios se reviertan en caso de una excepción y usar una transacción le ayuda con eso.

Code
Starttransaction
Disminuir la cantidad de dinero en la cuenta 1…. Éxito
Aumentarla en la cuenta 2… Falla
Rollback ---- ¡en caso de una excepción!

En otras palabras, el desarrollador debe escribir código de tal manera que los datos se reviertan en caso de una excepción y así se preserve la consistencia de los datos desde el punto de vista de la lógica de negocio.

De esta manera, el requisito de consistencia en el acrónimo ACID significa que es necesario que el DBMS tenga la posibilidad de mantener la consistencia de los datos con la ayuda del mecanismo de transacciones.

Aislamiento

El requisito de aislamiento de transacciones surge de la necesidad de garantizar el resultado de un conjunto de operaciones sin importar el orden en que se realicen.

En pocas palabras, cada transacción debe ejecutarse con uno y el mismo resultado independientemente de las transacciones que estén activas concurrentemente.

Se supone que el mecanismo de transacciones garantiza la consistencia a nivel de lógica de negocio, pero también se supone que protege las transacciones contra datos temporales no confirmados que pueden aparecer durante el proceso de ejecución de transacciones concurrentes.

Se ve así en la práctica:

Vemos la transacción #11 iniciada en el momento t2, dentro de la cual se realiza una inserción en la tabla en el momento t3-t5. La transacción #11 no se confirma inmediatamente después de la inserción, sino que continúa activa hasta el momento t8.

Concurrentemente, se inicia la transacción #12 y ejecuta la sentencia SELECT para los registros de la tabla en los que se insertó la transacción #11. La primera sentencia SELECT se ejecuta en el momento t6, que es cuando la operación de inserción ya ha terminado, pero esta sentencia devuelve un resultado vacío porque la transacción #12 no puede ver datos no confirmados de otras transacciones.

La transacción #11 se confirma en el momento t8 y la sentencia SELECT dentro de la transacción #12 se ejecuta en el momento t9. Devuelve el resultado igual a 100 porque los datos creados dentro de la transacción #11 ahora están confirmados (y porque el nivel de aislamiento de la transacción #12 es read committed, pero hablaremos de eso más adelante).

Este ejemplo es suficiente para ilustrar el requisito de aislamiento: a diferencia de la atomicidad y la consistencia, el aislamiento se implementa como reglas estrictas llamadas niveles de aislamiento, y cada transacción debe tener un parámetro que establezca el nivel de aislamiento con el que trabaja.

Durabilidad

El concepto de durabilidad permite al desarrollador confiar plenamente en que los datos creados dentro de una transacción confirmada aparecerán inmediatamente en la base de datos y no desaparecerán de ella (sin declaraciones explícitas que los eliminen o modifiquen, por supuesto) sin importar lo que suceda después.

Como puede ver, el requisito de durabilidad es solo sentido común: difícilmente alguien aceptaría usar un sistema cuyos datos puedan desaparecer de repente.

ACID: resumen

ACID significa los requisitos de cómo deben funcionar las transacciones:

  • Atomicidad
    • Las declaraciones siempre son atómicas
    • Los grupos de declaraciones pueden hacerse atómicos con la ayuda de transacciones
  • Consistencia
    • Dos niveles de consistencia: restricciones de la base de datos y lógica de negocio
  • Aislamiento
    • Garantizado por el mecanismo de transacciones con la ayuda de los niveles de aislamiento establecidos para ellas
  • Durabilidad
    • Todos los datos confirmados se vuelven permanentes

Como puede ver, todo es bastante lógico. En la práctica, la mayor dificultad la plantean los niveles de aislamiento, así que veamos en detalle cómo funcionan.

El nivel de aislamiento de una transacción define qué datos confirmados puede ver esta transacción.

Hay niveles de aislamiento que se denominan convencionalmente estándar. Están descritos en el estándar ANSI SQL (varias revisiones). Hasta donde sé, no existe un solo DBMS donde se implementen exactamente como se describen en el estándar, pero a nadie le preocupa eso, ya que los mecanismos reales de transacciones en DBMS específicos tienen todas las opciones necesarias para implementar la lógica de negocio.

Puede encontrar la definición clásica de los niveles de aislamiento en “A Critique of ANSI SQL Isolation Levels

Para aquellos que han leído este artículo, aquí está la tabla que compara los niveles de aislamiento clásicos con los similares en Firebird. Por supuesto, la correspondencia no es directa porque los niveles de aislamiento en Firebird, así como en otros DBMS, no cumplen al 100% con las definiciones de ANSI SQL, pero son muy similares a ellas.

Niveles de aislamiento ANSI Nivel de aislamiento en Firebird
Read Uncommitted n/a
Read Committed Read Committed
Repeatable Read Snapshot
Serializable Snapshot table stability

Como cualquier otro DBMS, Firebird tiene sus peculiaridades en la implementación del aislamiento. Ahora nos centraremos en cómo funcionan los niveles de aislamiento en Firebird, en lugar de cómo cumplen con el estándar.

Nivel de aislamiento Snapshot

El nivel de aislamiento Snapshot fue el primero en el código original de InterBase y sigue siendo el predeterminado para la API principal de Firebird y sus utilidades (por ejemplo, isql.exe). Esta puede ser la razón por la que es el más fácil de entender.

Snapshot aísla la transacción de cualquier cambio realizado desde el momento de su inicio.

Echemos un vistazo al diagrama de transacciones a continuación: muestra la transacción #10 iniciada con el nivel de aislamiento snapshot. Dentro de esta transacción, se ejecutan varias declaraciones SELECT sobre la tabla T1 que no tiene registros en este ejemplo.

La transacción concurrente #15, iniciada después del inicio de la transacción #10, inserta datos en la tabla T1 y esta transacción termina con la declaración COMMIT en el momento t9, es decir, los datos se confirman en la base de datos en este momento y están disponibles para las declaraciones de otras transacciones.

Sin embargo, la declaración en la transacción #10 ejecutada en el momento t10 (es decir, después de que la transacción #15 se confirma) no ve los datos insertados porque el nivel de aislamiento snapshot le permite ver solo datos confirmados insertados o modificados ANTES DEL INICIO de la transacción #10.

Por lo tanto, el nivel de aislamiento Snapshot le permite trabajar con la base de datos como si estuviera congelada en el momento en que comienza la transacción. Generalmente es necesario para construir informes complicados basados en datos que cambian rápidamente: se usa snapshot para evitar la situación en la que la primera parte del informe se basa en algunos datos y la última parte en datos diferentes.

Sin embargo, esta gran característica tiene su precio: cuando examinemos más adelante cómo se implementa el aislamiento en Firebird, verá que iniciar transacciones muy largas con el nivel de aislamiento snapshot resulta en versiones excesivas de registros y un rendimiento más bajo.

Nivel de aislamiento Read Committed

Una transacción con el nivel de aislamiento read committed puede ver los datos confirmados de otras transacciones que se confirman mientras está activa (a diferencia del caso con el nivel snapshot, cuando solo puede ver datos confirmados antes del momento en que comienza la transacción).

Mostremos cómo funciona el nivel de aislamiento read committed usando el siguiente diagrama:

Muestra un ejemplo prácticamente idéntico al anterior: dos transacciones concurrentes, una de las cuales lee regularmente datos de la tabla T1 mientras que la segunda inserta y confirma datos.

A diferencia del caso con el nivel de aislamiento snapshot, la transacción #10 en este ejemplo ve los datos insertados y confirmados por la transacción #15.

Este ejemplo nos da una idea sobre el impacto del nivel de aislamiento read committed: las declaraciones dentro de una transacción con este nivel de aislamiento pueden ver datos confirmados antes del momento en que se ejecuta la declaración correspondiente.

El siguiente diagrama muestra un ejemplo donde dos transacciones concurrentes #11 y #18 modifican datos.

Tenga en cuenta que la transacción #11 comienza antes del inicio de la transacción #14 que lee datos, mientras que la transacción #18 comienza después, pero esto no afecta el resultado: si los datos están confirmados, pueden ser vistos por la transacción concurrente con el nivel de aislamiento read committed.

Esta posibilidad hace que el nivel de aislamiento read committed sea una elección natural para aquellas declaraciones SQL que se ejecutan regularmente para mostrar el estado más reciente de la base de datos (por ejemplo, para mostrar los pedidos más recientes).

La parte dedicada a la recolección de basura mostrará que las transacciones read committed con el modificador de solo lectura en Firebird hasta la versión 4 son la mejor opción para transacciones de lectura “infinitas” porque se inician como preconfirmadas.

Nivel de aislamiento Snapshot table stability

Es posible hacer la historia sobre el modo de aislamiento snapshot table stability, que es la contraparte del modo Serializable estándar, muy corta o bastante larga y detallada.

La versión corta de la historia es la siguiente: este nivel es completamente similar al nivel snapshot con la diferencia de que además bloquea la tabla (la tabla debe especificarse explícitamente en los parámetros de la transacción) para escritura y lectura. Esto significa que es posible iniciar una transacción que ocupará completamente la tabla especificada y cualquier otra transacción obtendrá errores de acceso.

En otras palabras, una transacción con el nivel de aislamiento snapshot table stability pondrá efectivamente todas las consultas a la tabla especificada en una cola. De hecho, solo las lecturas en transacciones regulares se harán fuera de turno (como de costumbre), mientras que todos los demás modos formarán una cola (depende de la interacción, por supuesto).

Si se implementa sin precaución, puede causar bloqueos e imposibilidad de trabajar con la base de datos, por eso los desarrolladores de aplicaciones de bases de datos Firebird pueden tener miedo de usar este nivel de aislamiento.

Sin embargo, si se implementa correctamente, el nivel de aislamiento Serializable permite formar fácilmente colas y hacer cambios secuenciales en los registros de la base de datos, lo que puede ser muy útil para implementar contadores, números de documentos secuenciales y otros objetos similares.

Para describir correctamente cómo formar una cola con la ayuda de una transacción con el nivel de aislamiento snapshot table stability, tendremos que examinar un parámetro más de transacción: wait/nowait, y luego volver al ejemplo de la cola.

Anteriormente examinamos una forma de interacción entre transacciones donde los datos se modifican dentro de una transacción y se leen dentro de otra.

Sin embargo, a menudo sucede en la práctica que diferentes transacciones intentan modificar los mismos datos y, dado que solo un resultado se guarda en la base de datos, la transacción concurrente recibirá un mensaje de conflicto; de hecho, una excepción que interrumpirá (y cancelará) la ejecución de esa declaración específica que intenta modificar los datos ya modificados.

La opción wait define cómo debe reaccionar una transacción ante el conflicto de actualización. Hay tres formas de configurar esta opción:

  1. Wait (sin parámetros) = esperar hasta que termine la transacción concurrente
  2. Wait Timeout N sec = esperar hasta que termine la transacción concurrente, pero no más de N segundos
  3. Nowait: no esperar hasta que termine la transacción concurrente

Tenga en cuenta que la opción wait se especifica en pseudocódigo aquí, mientras que los nombres pueden ser diferentes en la API y en los componentes específicos, aunque el significado sigue siendo el mismo.

Veamos en detalle qué sucede en caso de conflictos de actualización con varias variantes de la opción wait.

Wait

Entonces, imaginemos dos transacciones concurrentes activas (#11 y #14) dentro de las cuales se ejecuta la declaración UPDATE que debe modificar un mismo registro en una misma tabla T1.

La transacción #14 se ejecuta con la opción wait (si usa isql para reproducir los ejemplos, wait está configurado por defecto).

La declaración UPDATE en la transacción #11 comienza en el momento t3 y termina en el momento t5, pero la transacción aún no está confirmada, es decir, la declaración COMMIT no está presente hasta el momento t6.

El diagrama a continuación muestra esta situación:

La declaración UPDATE también se ejecuta en la transacción #14 e intenta actualizar el mismo registro en la misma tabla, pero comienza más tarde, aproximadamente en el momento t4.

Dado que hay un conflicto de actualización con la actualización de la transacción #11 y wait está especificado en la transacción #14, la declaración UPDATE esperará hasta que termine la transacción conflictiva #11.

Si la transacción #11 dura lo suficiente, la declaración UPDATE en la transacción #14 parecerá congelada desde el punto de vista del usuario que observa la ejecución de esta declaración.

Si reproduce esta situación con la ayuda de dos isql.exe, la siguiente imagen muestra el momento en que la segunda transacción (para ser exactos, la transacción donde la declaración UPDATE concurrente comienza más tarde; es la transacción #14 en nuestro ejemplo) espera hasta que termine la primera transacción (es la transacción #11 en nuestro ejemplo).

Después de que se ejecuta la declaración COMMIT en la transacción #11, la transacción #14 que la espera será notificada inmediatamente y la actualización conflictiva terminará con una excepción.

A continuación puede ver un ejemplo de dicho mensaje de error (el número de la transacción concurrente no coincide con nuestro ejemplo porque los números de transacciones comienzan desde el principio en cada base de datos y luego solo se incrementan, restableciéndose solo después de una copia de seguridad/restauración):

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 atención a la palabra “deadlock” en el mensaje de error: ahora en realidad no hay un deadlock según su definición clásica. En su lugar, hay un conflicto de actualización, pero los desarrolladores de Firebird no cambian el mensaje de error porque se ha utilizado durante más de 35 años. Trataremos el verdadero deadlock “clásico” más adelante.

Entonces, hemos examinado la situación en la que la transacción con la declaración UPDATE concurrente termina con la declaración COMMIT. Tomemos ahora una situación similar, pero cuando se revierte; puede verla en el diagrama a continuación:

La situación es completamente similar a la anterior: dos declaraciones UPDATE intentan actualizar un mismo registro, pero esta vez la transacción concurrente #20 termina siendo revertida y los cambios dentro de la transacción #15 se guardan en la base de datos sin error como resultado.

Así, la opción de espera permite organizar la lógica de negocio de las actualizaciones de tal manera que las actualizaciones conflictivas esperan infinitamente en una cola, esperando hasta el último momento que la transacción que entra en conflicto con ellas termine con la sentencia ROLLBACK.

¿Esta táctica siempre tiene sentido? Por supuesto, depende de la implementación de la lógica de negocio, pero Firebird también ofrece otras opciones para resolver conflictos de actualización con la ayuda de la opción de espera.

Espera con tiempo de espera

En primer lugar, puede ser una buena idea limitar el tiempo de espera - en lugar de esperar infinitamente en caso de conflicto, puede limitar el tiempo de espera especificando un tiempo de espera para la opción de espera.

En isql.exe, dicho parámetro se especifica con la ayuda de la siguiente sentencia:

Code
SET TRANSACTION WAIT LOCK TIMEOUT N;

Donde N es el tiempo (en segundos) que la transacción concurrente esperará a que se resuelva el conflicto.

Puede encontrar más detalles sobre las sentencias de control de transacciones en la Referencia del Lenguaje de Firebird. Tenga en cuenta que puede haber diferentes formas de especificar el tiempo de espera en controladores o componentes de acceso específicos (generalmente, con la ayuda del parámetro de la API).

Puede ver un ejemplo en isql en la siguiente imagen:

Estudiemos con la ayuda de diagramas de transacciones cómo interactúan las transacciones si se especifica el tiempo de espera para la opción de espera.

Entonces, la situación es la misma: dos transacciones concurrentes #11 y #14 dentro de las cuales se ejecuta la sentencia UPDATE que intenta actualizar un mismo registro en la tabla T1.

Sin embargo, en este caso, la sentencia dentro de la transacción #14 espera hasta que la transacción #11 termine o hasta que expire el tiempo de espera especificado (3 segundos), lo que ocurra primero.

El tiempo de espera expira antes en este ejemplo, la sentencia termina con una excepción:

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

Tenga en cuenta que “deadlock” aparece nuevamente en el mensaje de error, pero sigue sin ser un “verdadero” deadlock.

En consecuencia, la situación es similar a la de la opción de espera, pero está limitada por el tiempo de espera: si el tiempo de espera especificado expira antes de que termine la transacción concurrente.

Especificar la opción de espera con tiempo de espera puede ser una buena solución para implementar la lógica de negocio si sabe con certeza que todas las transacciones de escritura son bastante cortas (por ejemplo, no más de 1-2 segundos).

Nowait

Es muy fácil explicar qué es Nowait desde el punto de vista formal: es una espera con tiempo de espera cero. Si especifica nowait en las transacciones, las actualizaciones conflictivas generarán una excepción inmediatamente.

En este caso, nuevamente tenemos transacciones concurrentes #11 y #14 (nowait) donde se ejecutan las sentencias UPDATE concurrentes. La sentencia dentro de la transacción con la opción nowait no espera cuando ve una actualización concurrente, sino que genera la siguiente excepción inmediatamente en el momento de su actualización (solo el número de transacción es 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>

Así es como se ve en un ejemplo con dos herramientas isql:

Tenga en cuenta que a la transacción nowait no le importa cuándo ni cómo termina la transacción con la sentencia UPDATE concurrente, ya sea con la sentencia COMMIT o ROLLBACK, la excepción se genera de todos modos.

Desde el punto de vista de la lógica de negocio, la transacción nowait puede ser conveniente si sabe con certeza que la actualización concurrente debe resultar en la cancelación indudable de las acciones de la sentencia actual.

Muchos controladores de Firebird utilizan la opción nowait como valor predeterminado y, como muchos desarrolladores no saben que es posible establecer un nivel menos estricto para resolver conflictos de actualización (por ejemplo, wait lock timeout 1), sus aplicaciones (y a veces también los usuarios) sufren errores innecesarios debido a conflictos.

Dado que la palabra clave “deadlock” está presente en cada excepción relacionada con conflictos de actualización, muchos desarrolladores de aplicaciones están seguros de que esto es lo que realmente es un deadlock verdadero (algunos incluso piensan que cierto Dead tuvo un papel en este error).

Al mismo tiempo, si echamos un vistazo al archivo de configuración firebird.conf, veremos el parámetro DeadlockTimeout allí (es de 10 segundos por defecto), y si miramos el encabezado de salida de la utilidad fb_lock_print, también veremos el parámetro “Deadlock scans”.

La cuestión es que un “deadlock verdadero” es posible en Firebird y la palabra clave “deadlock” que aparece en todas las excepciones relacionadas con conflictos de actualización no tiene conexión directa con él. Afortunadamente, el deadlock verdadero ocurre muy raramente.

Veamos qué es este “deadlock verdadero”. Para hacerlo, echemos un vistazo al siguiente diagrama de interacción de transacciones:

Tenemos dos transacciones concurrentes con la opción de espera donde se ejecuta la sentencia UPDATE. A diferencia del caso de un simple conflicto de actualización, aquí podemos ver un conflicto de actualización interdependiente:

  • La transacción #11 actualiza el registro con la clave = 20, y la transacción #12 actualiza el registro con la clave = 10;
  • Después de eso, la transacción #11 actualiza el registro con la clave = 10, y la transacción #12 actualiza el registro con la clave = 20;

Como resultado, tenemos una situación en la que cada transacción tiene que esperar a que la otra termine y ambas pueden esperar infinitamente porque ambas tienen la opción de espera especificada para ellas. Por supuesto, el servidor no puede permitir que eso suceda, por lo que una de las transacciones se verá obligada a revertirse después del tiempo de espera especificado en el parámetro DeadlockTimeout establecido en el valor de 10 segundos por defecto.

Podemos reproducir esta situación con la ayuda de dos isql:

Después de que se inicia la segunda transacción, ocurre una situación de deadlock verdadero. Para descubrirlo con certeza, el servidor inicia un procedimiento llamado Deadlock scan: se inicia a intervalos iguales a DeadlockTimeout que equivale a 10 segundos por defecto.

Tenga en cuenta que el cliente (isql en este caso) recibe un mensaje de conflicto de actualización regular, pero se inicia en 10 segundos incluso si la transacción se inicia con la opción de espera.

Después de que el servidor detecta el bloqueo interdependiente de dos transacciones, también incrementará el contador interno de deadlock (puede verlo en la salida de fb_lock_print).

Uso práctico de Snapshot Table Stability

Ahora que sabemos cómo funcionan las transacciones con sentencias UPDATE conflictivas, podemos volver al nivel de aislamiento Snapshot Table Stability y encontrarle un uso práctico.

Entonces, cuando se especifica este nivel de aislamiento, la tabla se bloquea para escritura e incluso para lectura.

Tenga en cuenta que si la tabla no se especifica explícitamente en los parámetros de la transacción, todas las tablas a las que las sentencias acceden dentro de esta transacción se bloquean y esto ocurre durante el primer acceso a una tabla. Aparentemente, si este nivel de aislamiento se usa sin precaución, fácilmente conducirá a una gran cantidad de conflictos de actualización.

La cláusula Reserving TableNN le permite especificar una tabla en particular (o varias tablas) para que se bloquee al comienzo de la transacción (también es posible especificar el modo de reserva).

Esta gran característica junto con la opción de espera le permite implementar una cola secuencial muy efectiva para cambiar una tabla específica.

En la práctica, se ve así: aquellos clientes que necesitan crear una cola para una tabla en particular inician la transacción SNAPSHOT TABLE STABILITY especificando esta tabla y luego intentan realizar dentro de esta transacción una operación y terminarla inmediatamente.

Por ejemplo, queremos crear un contador que se incrementa secuencialmente en una tabla con el único registro del tipo CREATE TABLE Table1(i1 integer not null), pero no podemos usar un generador por alguna razón.

El pseudocódigo se ve aproximadamente así:

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

Si ejecutamos este código no con el nivel de aislamiento Snapshot Table Stability (Table1), sino con un nivel de aislamiento más bajo, será posible que una sentencia UPDATE concurrente interfiera entre el inicio de la transacción y antes de su sentencia UPDATE. Como resultado, obtendremos una excepción de actualización de inmediato (nowait) o la sentencia se congelará hasta el final de la concurrente (wait) o el tiempo de espera (wait interval), en otras palabras, el conflicto se resolverá de alguna manera a nivel de sentencia.

Con el nivel de aislamiento snapshot table stability, estamos protegidos contra esto porque la tabla se reserva al comienzo de la transacción: es completamente nuestra o completamente no nuestra. Si especificamos la opción de espera para resolver conflictos, las conexiones paralelas formarán automáticamente una cola sin manejar ningún error.

Por supuesto, este enfoque solo se puede aplicar a transacciones cortas (como en nuestro ejemplo).

En la práctica, el nivel de aislamiento Snapshot table stability se utiliza para formar colas y recalcular lógica compleja en modo exclusivo (en tablas relativamente pequeñas o cuando no hay otros usuarios).

Dentro del motor, Firebird utiliza el nivel de aislamiento Snapshot Table Stability para crear índices, es decir, cuando ejecuta la sentencia ALTER INDEX indexname ACTIVE;, Firebird ocupará completamente la tabla para la que se está construyendo el índice.

¿Qué sigue?

Este artículo ofrece solo una introducción a los conceptos de las transacciones de Firebird. Para comprender completamente cómo funcionan las transacciones en Firebird, es necesario considerar la arquitectura multigeneracional (versiones de registros y conceptos de recolección de basura), considerar los marcadores de transacción (Oldest Interesting, Oldest Active, Oldest Snapshot, next) y otras cosas.

El artículo se basa en los materiales del seminario/taller “All About Transactions”, que se presentó por primera vez en 2013 durante los seminarios Firebird Tour, y en la base de la formación de IBSurgeon " Firebird Transaction in details".

Contactos

[email protected] No dude en contactarnos con cualquier pregunta o sugerencia: [email protected]