Lo mínimo a entender sobre transacciones

Cualquiera que se dedice a esto de desarrollar software conoce el concepto de transacción. O al menos eso quiero pensar. Otra cosa es ya hasta qué punto seamos conscientes de lo que realmente implica utilizar una transacción, cuándo y por qué debe usarse, los distintos tipos que existen, etc.

Como siempre, no pretendo entrar en detalles demasiado complejos sobre el maravilloso mundo de las transacciones, entre otras cosas porque tampoco soy un experto en la materia, pero sí me gustaría dar algunas ideas generales sobre el tema.

La transacción como unidad

Una de las características más conocidas de las transacciones es su atomicidad. Entenderlas como un todo o nada. Es el clásico ejemplo de la transferencia entre cuentas bancarias: si decremento el saldo de una cuenta, tiene que incrementarse en otra; la operación no puede quedar a medias porque desaparecería dinero (o aparecería, según el orden en que hagas las operaciones). Para muchos ésta es la esencia de una transacción.

Esto se corresponde con la A de ACID y era algo que ofrecen prácticamente todas las bases de datos relacionales. Con las llegada de las bases de datos NoSQL y sus tradeoffs para ganar escalabilidad, rendimiento y esas cosas tan necesarias para algunos, esto ya no está tan garantizado.

Si bien la atomicidad de la transacción es una característica básica y fundamental de la misma, no podemos limitarnos a pensar en las transacciones desde ese punto de vista. Necesitamos tener en cuenta también la corrección de lo que estamos haciendo y, eso nos lleva a pensar en la interrelación entre transacciones que se ejecutan de forma concurrente.

Los problemas de la concurrencia

Es muy raro (cada vez más) que una aplicación no ejecute de forma concurrente operaciones sobre una base de datos. No hace falta que la aplicación soporte usuarios concurrentes, incluso siendo una aplicación “monousuario”, es frecuente que además de las operaciones iniciadas por el usuario se ejecuten procesos en segundo plano que también toquen la base de datos (y no usar una base de datos tampoco resuelve el problema).

Al llegar a estos escenarios hay que empezar a pensar qué cosas nos pueden ocurrir cuando dos transacciones se ejecutan a la vez y qué pasa con los datos que están siendo modificados mientras se ejecuta una transacción. Esto puede dar lugar a tres problemas fundamentales:

Lecturas sucias

Las lecturas sucias se producen cuando una transacción puede leer datos que están siendo escritos por otra transacción antes de que ésta realice el commit. Un ejemplo de esto sería cuando la transacción 1 modifica un registro en la tabla Customer, la transacción 2 lo lee con las modificaciones, y luego la transacción 1 hacer un rollback, descartando las modificaciones. En ese caso, la transacción 2 habrá estado operando en base a unos datos que realmente no son válidos.

Lecturas no repetibles

Podríamos solucionar el problema de las lecturas sucias haciendo que las modificaciones realizadas por una transacción no sean visibles al resto hasta que se realice el commit.

Aun así, podríamos encontrarnos con otro problema: el de las lecturas no repetibles, que se producen cuando una transacción modifica datos que han sido leídos por otra. Por ejemplo, si la transacción 1 lee el registro Customer {Id = 1, Name = 'Paco'}, la transacción 2 modifica ese registro y realiza el commit, y la transacción 1 lo vuelve a leer, los datos que obtendrá son diferentes de los que obtuvo al principio.

Lecturas fantasma

Nuevamente esto tiene solución si hacemos que una transacción no pueda modificar datos que hayan sido leídos por otra, pero todavía nos quedaría otro problema por resolver: las lecturas fantasma.

Una lectura fantasma se produce cuando una transacción lanza una misma consulta dos veces y obtiene distinto conjunto de resultados. Si en las lecturas no repetibles podían cambiar los valores obtenidos para una fila, en una lectura fantasma podrían aparecer o desaparecer filas. El ejemplo sería si la transacción 1 consulta los clientes de Madrid y obtiene 10 filas, a continuación la transacción 2 inserta un nuevo cliente también de Madrid y hace su commit, y la transacción 1 vuelve a consultar clientes de Madrid obteniendo 11 filas.

Para solucionar esto no nos basta con proteger los datos ya leídos por cada transacción (como hacíamos para evitar las lecturas no repetibles), sino que necesitamos proteger los datos que potencialmente podrían afectar a nuevas consultas de una transacción (lo que muchas veces implica controlar tablas completas y no sólo filas individuales).

Aislamiento de transacciones

Las bases de datos ofrecen lo que se conoce como niveles de aislamiento para poder definir el tipo de garantías que esperamos durante la ejecución de una transacción. Se configuran a nivel de transacción (cada transacción puede tener un nivel de aislamiento distinto), y con ellos podemos controlar cuáles de los problemas estamos dispuestos a evitar, y cuáles podemos asumir. Cada base de datos puede implementar estos niveles de aislamiento de diferente forma, por lo que las características que vamos a ver pueden variar (especialmente en cuanto al coste a pagar por usar cada nivel), pero en general suelen ser bastante parecidas.

Read uncommitted

No hay ninguna garantía. Las transacciones no están aisladas unas de otras, por lo que se pueden producir todos los problemas que hemos visto anteriormente.

Read committed

Se garantiza que sólo se leen datos escritos por transacciones que hayan realizado su commit. De esta forma evitamos lecturas sucias. Claro, que esto no es gratis. Para conseguirlo, si intentamos leer un registro que ha sido modificado por otra transacción, nos quedaremos bloqueados hasta que esa transacción termine, ya sea con un commit o con un rollback.

Todavía se pueden producir lecturas no repetibles y lecturas fantasmas, porque no se evita que otra transacción modifique datos que hemos leído.

Repeatable read

Además de las garantías de Read committed, asegura que cada vez que leamos un registro nos devolverá siempre la misma información. Es decir, evitamos lecturas sucias y lecturas no repetibles. El precio a pagar por este aumento de garantías, además del que teníamos en Read committed, es que cuando una transacción lee un registro, el registro queda bloqueado para el resto de transacciones que intenten modificarlo, por lo que tendrán que esperar a que acabe la primera transacción antes de poder hacerlo.

Aun así, seguimos sin protegernos de lecturas fantasmas, ya que el bloqueo se realiza a nivel de registro, y podría ocurrir que otra transacción añada registros a una tabla que hemos leído.

Serializable

El máximo nivel de aislamiento (al menos de los definidos en SQL-92). Nos garantiza que no se produce ninguno de los problemas descritos anteriormente, es decir, no tendremos lecturas sucias, nuestras lecturas serán repetibles, y no habrá lecturas fantasmas. Para lograrlo esta vez el precio que pagamos es mayor, y además de los bloqueos que padecemos e inducimos con los niveles anteriores, necesitamos bloquear los rangos de registros que estamos leyendo. En el peor de los casos, eso implicaría bloquear enteras las tablas leídas por la transacción para evitar que otras puedan insertar nuevos registros.

Existen otros niveles de aislamiento pero son menos habituales. Si alguien tiene curiosidad (es un tema entretenido), puede leer un poco sobre control de concurrencia multiversión y aislamiento basado en instantáneas como alternativa eficiente para mantener la serializabilidad transaccional.

Transacciones en el mundo real

Todo esto puede parecer que está muy bien como culturilla general, pero que si no eres DBA te da más o menos lo mismo. Total, si estás desarrollando un API Web que usa un (micro)ORM para acceder a la base de datos, el mundo de las transacciones queda muy lejos y lo único que ves es por ahí una llamada a dbContext.SaveChangesAsync o similar. Lamentablemente, es posible que tu framework o tu modelo pueda ignorar la persistencia, pero tú no.

A la hora de definir agregados de dominio (o lo que sea que uses para encapsular operaciones contra tus datos) necesitas pensar en las garantías que le vas a pedir a la base de datos y en los problemas de concurrencia que puedes encontrar.

Por poner un ejemplo muy básico, suponte que tienes una clase Order que representa un pedido y contiene OrderLines con las líneas. Al persistir en base de datos (vía ORM o manual), probablemente tengas dos tablas, Orders y OrderLines, y probablemente en la tabla Orders haya información que se calcule a partir de sus líneas, por ejemplo el total del pedido.

Con una visión alegre y despreocupada del mundo transaccional podrías pensar que con usar una transacción que garantice la atomicidad, es decir, que si se guardan las líneas se guarda la cabecera, ya tienes todo resuelto, pero no es así. El nivel de aislamiento por defecto de transacciones suele ser Read committed, y no es suficiente en este escenario si dos usuarios modifican a la vez un pedido.

Imagina que dos transacciones, T1 y T2, intentan agregar a la vez una línea a un mismo pedido:

  1. T1: Carga el pedido, añade la línea L1 en memoria y se dispone a guardar.
  2. T2: Carga el pedido (sin ver la línea añadida por T1 porque no hay sido committed), añade otra línea L2 y se dispone a guardar.
  3. T1: Inserta la línea L1 en la tabla OrderLines.
  4. T2: Inserta la línea L2 en la tabla OrderLines.
  5. T1: Actualiza el pedido en la tabla Order, incluyendo en el total del pedido el importe de la línea L1, pero no el de la línea L2, ya que no sabe nada de ella.
  6. T2: Actualiza el pedido en la tabla Order, incluyendo en el total sólo su línea.

¿Ves el problema? En la base de datos el pedido contendrá las dos líneas (L1 y L2), pero su total no incluirá el importe de ambas, sólo el de L2 porque cuando T2 actualizó la base de datos no sabía nada de L1 y no la incluyó en el cálculo del total.

Éste es un caso muy simple, pero nos obliga a pensar en las garantías con que ejecutamos cada transacción. Podríamos aumentar el nivel de aislamiento, que en este caso sería necesario que fuera Serializable, o buscar alternativas como hacer un bloqueo a nivel de registro o utilizar técnicas de concurrencia optimista, pero lo que está claro es que no debemos ignorar el problema.

Todas estas opciones tienen sus inconvenientes (lo digo por si estabas pensando que la solución era usar siempre el máximo nivel de aislamiento) y hay que buscar un equilibrio entre alcanzar la solución correcta, tener el código lo más simple posible, y mantener un rendimiento razonable evitando bloqueos e interbloqueos. Lo siento, pero la concurrencia siempre es complicada.

Quizá se pueda pensar que esto es un problema de las bases de datos relacionales porque obligan a guardar información en varias tablas y por eso hay que gestionar inconsistencias, pero el mundo de las bases de datos NoSQL también tiene su cuota de problemas de los que hay que ser consciente.


17 comentarios en “Lo mínimo a entender sobre transacciones

  1. El 90% de los problemas de rendimiento (incluyendo interbloqueos) que me reportan tiene que ver con el “descuido” de lo que comentas (pura ignorancia realmente). Desafortunadamente, normalmente no pueden aplicarse soluciones locales (sí hacks que son pan para hoy) pues el problema es estructural. Ésto debería hacer ver la importancia de tu último post.
    En realidad, algo de mimo, pensar antes de actuar y REFACTORIZAR como haga falta permite encontrar soluciones sencillas y muy efectivad, pero claro………..

  2. GreenEyed dijo:

    Por tocar un poco la moral, el caso que comentas es cierto, pero en este caso lo primero es no guardar el total en la tabla order :P.
    Volviendo al tema, es como ignorar el tema de la concurrencia “por que las probabilidades de que vaya mal son bajisimas, es imposible que…”. Oir esa frase, que la he oido en persona, es como ver al Balrog: “amigo, date la vuelta y corre”

  3. “Por tocar un poco la moral, el caso que comentas es cierto, pero en este caso lo primero es no guardar el total en la tabla order :P.”

    Sí, el ejemplo es discutible y desnormalizar información tiene sus pegas. Pero guardar ese total depende mucho del rendimiento que necesites luego (por ejemplo para informes) y de si es fácil (o siquiera viable) recalcularlo a partir de la información que hay en base de datos.

  4. GreenEyed dijo:

    Pero en ese caso, los triggers son una buena ayuda para no fiarse de que el programador se acuerde o que haga modificaciones en dos tablas con un simple read_commited.

  5. El ejemplo depende mucho de muchas cosas, la solución mas simple tal como la estáis “mirando” es que el total sea un campo calculado o una vista cacheada o un backend específico para informes (pensar en OLAP xej). Pero realmente el ejemplo trabaja con muchas transacciones cuando sólo hay una (por eso a mi me gustan los procs alm). En escenarios mas complejos (de concurrencia) se pueden firmar las filas (para conocer la relación causal) o prebloqueat una entidad dispersa (el pedido), etc… ya digo que depende mucho, pero en el caso habitual, define un proc alm que desacople negocio con las restricciones en el backen (como la del total) y listo, para eso se inventaron las bbdd relacionales.

  6. En todo el tiempo que llevo trabajando con bases de datos he tenido que refrescar este tema de las transacciones en tres ocasiones, la última fue hace poco, hará un par de meses.

    Donde yo veo el problema es que la gente al desarrollar elige un nivel de aislamiento para todas las transacciones de la aplicación. Eso es un gran error, para cada operación de base de datos se debe tener en cuenta la concurrencia y elegir el nivel de aislamiento necesario para la operación a realizar.

    Si no me equivoco, los bloqueos en la base de datos se realizan igual para todas las transacciones elijamos el nivel de aislamiento que elijamos. El nivel de aislamiento va a determinar que bloqueos se tendrán en cuenta por nuestra transacción. De modo que aunque T1 tenga isolation level Serializable, si T2 tiene nivel read uncommited, T2 no tendrá en cuenta los bloqueos de T1.

    Igual al contrario, si T1 tiene nivel read uncommited y T2 tiene Serializable, T2 quedará bloqueado hasta que T1 termine aunque T1 sea read uncommited.

    Por último está el tema de “tan solo voy a leer de la base de datos, ¿inicio transacción?” Si una operación no inicia transacción no hay bloqueos, por lo tanto en otra operación con transacción T usando Serializable, T no encontrará bloqueos y podrá leer los datos leidos en la primera operación.

    No quiero decir con esto que siempre haya que iniciar transacción, pero si debemos saber en cada momento que es lo que queremos y usar las transacciones para ello.

    Por favor corregidme si me equivoco y así asiento este tema, por que algunas veces me entran dudas.

    Saludos!

  7. “los bloqueos en la base de datos se realizan igual para todas las transacciones elijamos el nivel de aislamiento que elijamos”

    no creo o hay que concretar de qué hablamos

    “nosotros” especificamos las transacciones y el motor decide que bloqueos hacen falta (afortunadamente). En una misma transacción puedes intentar leer en ciertos registros, bloquear otros, cierta tabla entera, etc.

    De hecho uno de los problemas más recurrentes son propagar transacciones a nivel de aplicación (algo que siempre he visto horripilante) donde el motor de base de datos ya no puede determinar las relaciones entre ellas y es común terminar con interbloqueos (ej. si la app abre dos transacciones, etc.).

  8. Como yo lo entiendo una cosa es el bloqueo y otra es que debido a tu nivel de aislamiento ese bloqueo te afecte.

    Si tu abres una transacción T1 serializable y actualizas una fila, existe un bloqueo sobre esa fila.
    Si abres una transacción T2 serializable he intentas leer la fila actualizada, no podrás hasta que T1 termine. Por lo tanto hay un bloqueo en esa fila que minivel de aislamiento respeta.

    Si abres una transacción T3 read uncommitable, puedes leer la fila actualizada por T1. El bloqueo no te afecta por tu nivel de aislamiento.

    ¿No es así?

  9. Mmm sí así es, pero entonces es obvio que a cualquier transacción le van a afectar los bloqueos (un bloqueo de lectura es un bloqueo de lectura y un bloqueo de escritura es un bloqueo de escritura y uno de registro es de registro… ), por eso lo interpreté al revés, no todas las transacciones producen el mismo tipo de bloqueos, porque para mí lo importante es entender que bloqueos va a producir tu transacción (la verdad, no recuerdo preocuparme por dónde se bloqueará). Pero sí, creo que hablamos más o menos de lo mismo.

  10. porque para mí lo importante es entender que bloqueos va a producir tu transacción

    Sin embargo, los bloqueos no dependen de la transacción inicial, si no del nivel de aislamiento de la segunda. Tu puedes pensar que tu transacción va a bloquear unos datos, sin embargo si finalmente hay bloqueo o no va a depender del nivel de aislamiento de la siguiente transacción que vaya a trabajar con los datos que tu bloqueas.

    De las 3 veces que me he estado mirando este tema, esta última vez este tema fue el que más me llamó la atención. Porque pensé que había que tenerlo muy en cuenta, y le daba una vuelta a todo.

    Lo mismo estoy equivocado, me gustaría leer comentarios de más gente para corroborarlo.

    Saludos,

  11. No se si yo soy “más gente” :P pero un bloqueo es un bloqueo y aunque solo tengas una transacción, esa transacción solicita dicho bloqueo, lo tendrá, esperará, etc. según como esté el tema, pero si es la primera, lo adquirirá seguro, en general como digo, tú deberías poder saber qué va a bloquear tu consulta.

    Por ejemplo en postgresql, si lanzas:

    wsb=# create table foo (x int);
    CREATE TABLE
    wsb=# insert into foo(x) values(2);
    INSERT 0 1
    wsb=# begin transaction;
    BEGIN
    wsb=# delete from foo where x = 2;
    DELETE 1
    wsb=#

    Esta transacción no cerrada ha bloqueado el registro, otra cosa es que no haya otras transacciones bloqueadas, pero que esta transacción es dueña y señora de ese registro está claro. Podemos ver los bloqueos generados así:

    wsb=# SELECT locktype, page, tuple FROM pg_locks WHERE relation::regclass = ‘foo’::regclass;
    locktype | page | tuple
    ———-+——+——-
    relation | |
    (1 row)
    wsb=#

    Ahora si otra transacción intenta modificar ese registro se verá bloqueada (bloqueada por el bloqueo ¡que ya existía!) por ejemplo:

    wsb=# begin transaction;
    BEGIN
    wsb=# update foo set x = 3 where x = 2;
    (esperando……)

    Podemos ver ahora el bloqueo original de la transacción, la petición de bloqueo de la segunda transacción y el bloqueo efectivo de la 2 por la 1:

    wsb=# SELECT locktype, page, tuple FROM pg_locks WHERE relation::regclass = ‘foo’::regclass;
    locktype | page | tuple
    ———-+——+——-
    relation | |
    relation | |
    tuple | 0 | 1
    (3 rows)
    wsb=#

    Lo que le pase a la 2ª transacción dependerá lógicamente de lo que termine haciendo la 1ª (borre o no el registro, cree otro registro coincidente, rollbackee, etc.)

    Petición de bloqueo, bloqueo efectivo, transacción bloqueada, … es posible que con tanto “bloqueo” haya confusión a que nos referimos, pero que dada una consulta debes poder identificar qué va a bloquear tu consulta me parece imprescindible.

  12. JoseJuan

    He abierto dos ventanas de SSMS sobre una base de datos SQL Server, en la primera he hecho esto:

    create table foo (x int);
    insert into foo(x) values(2);
    begin tran t1;
    delete from foo where x = 2;

    Lo he dejado así, y ahora en la otra ventana hago esto:

    set transaction isolation level READ UNCOMMITTED
    select * from foo where x = 2

    Y me ha devuelto la tabla vacía. Luego he hecho esto:
    set transaction isolation level READ COMMITTED
    select * from foo where x = 2

    Y entonces se ha quedado bloqueado a la espera de que termine la otra transacción.

    A eso es a lo que me refiero, con que el bloqueo existe, pero depende del nivel de aislamiento de la otra operación que se tenga en cuenta el bloqueo o no.

  13. Si que estoy equivocado …
    He hecho otra prueba.

    Ventana1:
    set transaction isolation level SERIALIZABLE
    begin tran t1
    select * from foo where x = 2

    Ventana2:
    set transaction isolation level READ UNCOMMITTED
    begin tran t1
    update foo set x = 3 where x = 2

    Y la segunda ventana se me ha quedado bloqueada … no es como yo pensaba.

    Tengo que darle más vueltas :D

  14. “pero depende del nivel de aislamiento de la otra operación que se tenga en cuenta el bloqueo o no”

    mmm me parece que confundes los bloqueos de los *objetos* de la base de datos (que son los importantes para mi) con los bloqueos de las *transacciones* (que son los que tienes que evitar o al menos conocer cómo se comportarán) con la *estrategia* a la hora de bloquear dichas transacciones para asegurar integridad (los niveles de aislamiento)

    pero igual estoy bloqueado xD

  15. Realmente esto de las transacciones nunca me ha traído problemas, con la excepción de que se me olvide poner readOnly en el @Transactional, normalmente siempre manejo todo con un ORM, en mi caso Hiberante y trato de apegarme a JPA y JTA, lo cual me ayuda bastante para organizar el código y llevar una vida despreocupada, pero creo que comenzare a investigar mas sobre este tema.

    Normalmente soy bastante respetuoso del ACID, lo ultimo que quisiera es tener un bloqueos incontrolables por un requerimiento que dije que si se podía, cuando realmente trae mas problemas que valor al negocio, todo forma parte de encontrar el equilibrio.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos necesarios están marcados *

*

Puedes usar las siguientes etiquetas y atributos HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>