Transacciones en bases de datos

Cuando trabajamos con las bases de datos, solemos pensar en que las operaciones que realizamos contra ellas se realizan una a una de forma independiente, pero esto ocasiona varios problemas: ¿cómo garantizamos que los datos son coherentes si, por ejemplo, actualizamos la tabla con dos operaciones de modificación que suceden a la vez? ¿Qué pasa cuando hay errores en alguna operación y esos errores se propagan dejando la base de datos en un estado insatisfactorio? Es por este tipo de situaciones que se diseñaron las transacciones.

Las transacciones son la unidad mínima de ejecución de las operaciones en base de datos, y consisten en una serie de una o varias operaciones, las cuales se ejecutan de forma aislada entre cada transacción. Las transacciones cumplen los propiedades ACID, las cuales nos garantizan que la base de datos estará en un estado correcto tras cada transacción.

Propiedades ACID

  • Atomicidad: todos los cambios se realizan como si fueran una sola operación. Por ejemplo, si en una transacción hay tres actualizaciones se tienen que ejecutar las tres, si falla alguna no se realizará ninguna de las tres operaciones.
  • Consistencia: la base de datos se mantiene en un estado coherente antes y después de la ejecución tras cada transacción, es decir, se siguen cumpliendo las reglas definidas en la base de datos tras cada transacción.
  • Aislamiento (isolation): cada transacción se ejecuta de forma independiente del resto de transacciones, de tal manera que las transacciones sólo ven los estados finales de las ejecuciones de las transacciones y no los estados intermedios.
  • Durabilidad: el estado de la base de datos tras la ejecución de las transacciones persisten en el tiempo y no desaparecen, incluso cuando hay un fallo en el sistema.

Estas transacciones son transparentes al usuario, es decir, el usuario no sabe que se están ejecutando varias operaciones como si fueran una, si no que ve cómo se ejecuta la actualización de la base de datos y que todo sigue funcionando correctamente.

Cómo podemos aprovechar los desarrolladores las transacciones

Vamos a poner un ejemplo práctico en el que podemos usar las transacciones. Imaginemos que queremos actualizar el email de un usuario en base de datos y al mismo tiempo que se quede guardado un registro en el que se especifique que se ha actualizado ese usuario tal día a tal hora. Si ha habido algún fallo no queremos que se cree este registro, por lo que consideraremos que esa operación tiene que ser atómica, es decir, si falla una de las dos operaciones, no se tiene que actualizar el usuario ni crear el registro.

Cuando trabajamos con bases de datos solemos hacerlo desde diferentes aproximaciones, pero las más comunes son conectándonos a la base de datos y ejecutar consultas directamente, o mediante librerías. Cuando nos conectamos a la base de datos y ejecutamos una consulta podemos indicar que vamos a hacerlo en una transacción; por ejemplo con PostgreSQL lo hacemos mediante la orden BEGIN

BEGIN;
UPDATE users SET email = 'test@test.org' WHERE id = 1;
INSERT INTO logs VALUES(4, 1, 'user_updated');
COMMIT;

Con la orden COMMIT indicamos el fin de la transacción y se ejecutarán ambas operaciones en una sola transacción. De esta manera nosotros mismos creamos la transacción de forma explícita y tenemos el control total de qué operaciones se realizan en la transacción.

Otra aproximación es realizar transacciones a través del código para así tener esa lógica de negocio en nuestro código y no ser dependientes de la base de datos. Cuando, por ejemplo, desarrollamos un endpoint que actualiza el dinero de la cuenta de un usuario, es bastante habitual que como desarrolladores pensemos que simplemente con una llamada al servicio que actualiza esa entidad (por ejemplo usando el Repository Pattern) es suficiente para lo que necesitamos.

async save(user: User): Promise<void> {
    await this.repository.save(user);
  }

Ahora bien, queremos que de la misma manera que hemos hecho la transacción en PostgreSQL se pueda realizar la transacción de crear un registro cuando se actualiza el usuario. Muchos ORMs actuales como Doctrine o TypeORM permiten ejecutar varias operaciones en una transacción. En el caso de TypeORM por defecto cuando se hace una actualización se crea una transacción que ejecuta esa operación en una transacción única, pero podemos usar el Query Runner que proporciona TypeORM para indicar que vamos a iniciar una transacción.

const queryRunner = this.datasource.createQueryRunner();
await queryRunner.startTransaction();

Con la función startTransaction() le estamos indicando al Query Runner que todo lo que le sigue después se contará como una transacción. Por ejemplo, imaginemos que queremos actualizar un usuario y, además, añadir un registro de que se ha actualizado el usuario en otra tabla aparte. Para ello podemos hacer lo siguiente:

const queryRunner = this.datasource.createQueryRunner();
await querRunner.startTransaction();
try {
  await queryRunner.manager.save(User, user);
  const log = new Log(user.id, 'user created');
  await queryRunner.manager.save(Log, log);
  await queryRunner.commitTransaction();  
}
...

¿Qué estamos haciendo en este trozo de código? Lo primero es que indicamos que vamos a iniciar una transacción con startTransaction, de esta manera todo lo que hagamos será considerado como una única transacción hasta que indiquemos que vamos a terminar la transacción. Con Query Runner podemos usar el Manager de TypeORM indicando que vamos a guardar el usuario en base de datos. Una vez actualizado el usuario creamos un Log indicando la operación que acabamos de realizar, e insertamos en la base de datos el Log. Finalmente con el método commitTransaction() indicamos que todas esas operaciones se commitearan en una transacción.

En el código hemos metido estas operaciones en un try-catch. ¿Por qué? Para poder gestionar los errores y qué queremos hacer cuando ha habido un error. ORMs como Doctrine hacen un rollback automático cuando detectan un error, pero en TypeORM tenemos que indicarlo explícitamente.

...
} catch (error) {
  await queryRunner.rollbackTransaction();
} finally {
  await queryRunner.release();
}

El método release() es necesario en TypeORM para indicar que el query runner que hemos creado tiene que cerrar la conexión que crea.

propiedades acid
bases de datos

Conclusiones sobre transacciones en bases de datos

Las transacciones nos permiten tener mayor control cuando queremos hacer varias operaciones en la base de datos y queremos que al realizarlas, la bd se quede en un estado que cumpla las reglas de negocio. Además, nos ayudan a gestionar que, en caso de que ocurran errores en las operaciones sobre la base de datos, ésta se mantenga en un estado que no comprometa el correcto funcionamiento de la aplicación.

Saber cómo funcionan y utilizarlas de forma que se adapten a nuestras necesidades nos permiten aprovechar toda la potencia que nos proporcionan, así nos aseguraremos que la base de datos siempre estará en un estado correcto tras varias operaciones dependientes unas de otras.

¿Quieres aprender más sobre desarrollo y programación? Si no leíste nuestro último artículo, lo puedes encontrar aquí.

Backend

Picture of Samuel Sánchez

Samuel Sánchez

Meditación zen como camino a la calma tras desplegar en producción
Picture of Samuel Sánchez

Samuel Sánchez

Meditación zen como camino a la calma tras desplegar en producción

We are HIRING!

What Can We Do