Control de concurrencia en Entity Framework

  • net-core
  • ef-core

El control de concurrencia en una base de datos es esencial para garantizar la integridad de los datos y evitar problemas de actualización simultánea. Para implementarlo hay varias opciones disponibles. Una de las formas más comunes, y la que vamos a implementar en este post, es utilizar el mecanismo optimista. Este mecanismo asume que las colisiones de actualización son infrecuentes y, por lo tanto, permite que varios usuarios modifiquen los mismos datos al mismo tiempo. Sin embargo, si dos usuarios intentan actualizar el mismo registro a la vez, uno de ellos recibirá un error y tendrá que volver a intentar la actualización.

La implementación de este mecanismo en Entity Framework es bastante sencilla. El primer paso sería añadir una propiedad RowVersion a nuestra entidad e indicar que hará las funciones de time span. De está manera, siempre que se guarde un registro en la base de datos, se actualizará el valor de ese time span. Una práctica que me gusta hacer en todos mis modelos, es añadir la propiedad RowVersion e Id en una clase para poder usarla como base en todas las entidades sobre las que quiero controlar la concurrencia.

public class BaseEntity
{
    public int Id { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; }
}

Asímismo, cuando un registro sea modificado, Entity Framework comparará los valores del RowVersion, permitiendo la modificación solamente si el nuevo registro y el existente en la base de datos tienen el mismo valor. De lo contrario, se lanzará una excepción de tipo DbUpdateConcurrencyException. De esta forma, evitamos que un usuario pueda modificar un registro si, desde que lo leyo hasta que lo ha modificado, alguién ha realizado cambios.

A continuación se muestra un pequeño flujo de ejemplo para asentar los conceptos:

  • El usuario A lee el registro 1 de la tabla T, cuyo time span es X

  • El usuario B lee el registro 1 de la tabla T, cuyo time span es X

  • El usuario B modifica el registro 1 de la tabla T y lo guarda. El nuevo time span es Y

  • El usuario A intenta modificar el registro 1 de la tabla T, pero obtiene una excepción DbUpdateConcurrencyException porque el time span X es diferente al existente en la base de datos (Y)

Con todo lo anterior, se estaría controlando la concurrencia correctamente a la hora de modificar un registro, pero ¿cómo podemos hacer un control similar en operaciones de borrado? Con la solución anteriormente expuesta, el comportamiento sería el siguiente:

  • El usuario A lee el registro 1 de la tabla T, cuyo time span es X

  • El usuario B lee el registro 1 de la tabla T, cuyo time span es X

  • El usuario B modifica el registro 1 de la tabla T y lo guarda. El nuevo time span es Y

  • El usuario A elimina el registro 1 de la tabla T.

En ese supuesto, el usuario A puede eliminar el registro a pesar de que el time span no coincide, ya que el registro fue modificado por el usuario B. Esto se debe a que Entity Framework no hace validación alguna en operaciones de borrado. No obstante, no sería complicado modificar este comportamiento o, mejor dicho, añadir una nueva operación en la que se controle la concurrencia a la hora de eliminar un registro. Bastaría con añadir un método de extensión, que nos permita eliminar un elemento de un DbSet indicando su identificador y su RowVersion. El registro solo se borrará si el RowVersion especificado coincide con el existente en la base de datos.

public static async Task<EntityEntry<T>> RemoveConcurrently<T>(this DbSet<T> dbSet, int id, byte[] rowVersion) where T : BaseEntity
{
    var toRemove = await dbSet.FirstOrDefaultAsync(x => x.Id == id);
    if (toRemove?.RowVersion.SequenceEqual(rowVersion) != true)
        throw new DbUpdateConcurrencyException();

    return dbSet.Remove(toRemove);
}

De esta manera, si en el flujo anterior se utilizase este nuevo método para eliminar un registro, quedaría como se muestra a continuación:

  • El usuario A lee el registro 1 de la tabla T, cuyo time span es X

  • El usuario B lee el registro 1 de la tabla T, cuyo time span es X

  • El usuario B modifica el registro 1 de la tabla T y lo guarda. El nuevo time span es Y

  • El usuario A intenta eliminar el registro 1 de la tabla T, pero obtiene una excepción DbUpdateConcurrencyException porque el time span X es diferente al existente en la base de datos (Y)

Llegado a este punto, sí que tendríamos un control de concurrencia optimista tanto en operaciones de actualización como de borrado.

Tal y como se ha indicado al inicio del post, existen distintos mecanismos para controlar la concurrencia. Aunque aquí solo se ha implementado el optimista, a continuación se detallan algunos más:

  • Pesimista: Asume que las colisiones de actualización son frecuentes y, por lo tanto, restringe el acceso a los datos a un solo usuario a la vez. Cuando un usuario quiere acceder a un registro, se le asigna un bloqueo exclusivo para evitar que otros usuarios lo modifiquen hasta que él haya terminado. Esto puede ocasionar problemas de rendimiento y bloqueos innecesarios si el acceso concurrente es bajo.

  • Basado en versiones: Se basa en el almacenamiento de varias versiones de un registro. Cada vez que un usuario actualiza un registro, se crea una nueva versión de éste. Si otro usuario intenta actualizar la misma versión, recibirá un error y deberá volver a intentar la actualización con la nueva versión.

  • Basado en valores: Compara los valores originales de un registro con los valores actuales antes de realizar una actualización. Si algún valor ha cambiado desde la última lectura, se rechaza la actualización y se informa al usuario del conflicto.

  • Basado en reglas: Aplica reglas predefinidas para resolver conflictos de actualización automáticamente. Por ejemplo, se pueden establecer reglas para que siempre gane la actualización más reciente o la que contenga datos más precisos.

En resumen, existen varias técnicas de control de concurrencia, cada una con sus propias ventajas y desventajas. La elección dependerá de las necesidades específicas de cada caso.

Foto de Joshua Sortino en Unsplash