Administrar los conflictos de simultaneidad

Sugerencia

Puede ver un ejemplo de este artículo en GitHub.

En la mayoría de los escenarios, varias instancias de aplicaciones usan las bases de datos simultáneamente, cada una de las cuales realiza modificaciones en los datos independientemente entre sí. Cuando se modifican los mismos datos al mismo tiempo, se pueden producir incoherencias y daños en los datos, por ejemplo, cuando dos clientes modifican columnas diferentes en la misma fila que están relacionadas de alguna manera. En esta página, se describen los mecanismos para garantizar que los datos sean coherentes frente a estos cambios simultáneos.

Simultaneidad optimista

EF Core implementa la simultaneidad optimista, que supone que los conflictos de simultaneidad son relativamente poco frecuentes. A diferencia de los enfoques pesimistas, que bloquean los datos por adelantado y después los modifican, la simultaneidad optimista no efectúa bloqueos, sino que se ocupa de que no se guarde la modificación de datos si los datos han cambiado desde que se consultaron. Este error de simultaneidad se notifica a la aplicación, que lo trata en consecuencia, posiblemente mediante el reintento de toda la operación en los nuevos datos.

En EF Core, la simultaneidad optimista se implementa configurando una propiedad como un token de simultaneidad. El token de simultaneidad se carga y se realiza un seguimiento del mismo cuando se consulta una entidad, al igual que cualquier otra propiedad. A continuación, cuando se realiza una operación de actualización o de eliminación durante la ejecución de SaveChanges(), el valor del token de simultaneidad en la base de datos se compara con el valor original leído por EF Core.

Para comprender cómo funciona esto, supongamos que estamos en SQL Server y definimos un tipo de entidad Person típico con una propiedad Version especial:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

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

En SQL Server, esto configura un token de simultaneidad que cambia automáticamente en la base de datos cada vez que se cambia la fila (a continuación, encontrará más detalles). Con esta configuración en vigor, vamos a examinar lo que sucede con una operación de actualización sencilla:

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
context.SaveChanges();
  1. En el primer paso, se carga una instancia de Person desde la base de datos; esto incluye el token de simultaneidad, del que ahora se realiza el seguimiento como de costumbre en EF junto con el resto de las propiedades.
  2. A continuación, la instancia de Person se modifica de alguna manera: se cambia la propiedad FirstName.
  3. A continuación, indicamos a EF Core que conserve la modificación. Dado que se ha configurado un token de simultaneidad, EF Core envía el siguiente código SQL a la base de datos:
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

Tenga en cuenta que, además de PersonId en la cláusula WHERE, EF Core también ha agregado una condición para Version; esto solo modifica la fila si la columna Version no ha cambiado desde el momento en el que la consultamos.

En el caso normal ("optimista"), no se produce una actualización simultánea y la sentencia UPDATE se completa correctamente, modificando la fila; la base de datos informa a EF Core de que se ha visto afectada una fila por la sentencia UPDATE, según lo previsto. Sin embargo, si se ha producido una actualización simultánea, la sentencia UPDATE no encuentra ninguna fila coincidente e informa de que se han visto afectadas cero filas. Como resultado, la ejecución de SaveChanges() de EF Core produce una excepción DbUpdateConcurrencyException, que la aplicación debe detectar y controlar correctamente. Las técnicas para hacerlo se detallan a continuación, en Resolución de conflictos de simultaneidad.

Aunque los ejemplos anteriores describen las actualizaciones de entidades existentes. EF también genera una excepción DbUpdateConcurrencyException al intentar eliminar una fila que se ha modificado simultáneamente. Sin embargo, esta excepción nunca se produce al agregar entidades; aunque la base de datos puede generar realmente una infracción de restricción única si se insertan filas con la misma clave, esto produce una excepción específica del proveedor y no una excepción DbUpdateConcurrencyException.

Tokens de simultaneidad generados por la base de datos de forma nativa

En el código anterior, hemos usado el atributo [Timestamp] para asignar una propiedad a la columna rowversion de SQL Server. Dado que rowversion cambia automáticamente cuando se actualiza la fila, es muy útil como token de simultaneidad de esfuerzo mínimo que protege toda la fila. La configuración de la columna rowversion de SQL Server como token de simultaneidad se realiza de la siguiente manera:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

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

El tipo rowversion anterior es una característica específica de SQL Server; los detalles sobre la configuración de un token de simultaneidad de actualización automática difieren entre bases de datos y algunas bases de datos no lo admiten en absoluto (por ejemplo, SQLite). Consulte la documentación del proveedor para obtener los detalles precisos.

Tokens de simultaneidad administrados por la aplicación

En lugar de que la base de datos administre automáticamente el token de simultaneidad, puede administrarlo en el código de la aplicación. Esto permite usar la simultaneidad optimista en bases de datos como SQLite, en las que no existe ningún tipo de actualización automática nativo. Pero, incluso en SQL Server, un token de simultaneidad administrado por la aplicación puede proporcionar un control específico sobre qué cambios de columna hacen que se vuelva a generar el token. Por ejemplo, puede tener una propiedad que contenga algún valor almacenado en caché o no importante, y no desea que un cambio en esa propiedad desencadene un conflicto de simultaneidad.

A continuación, se configura una propiedad GUID para que sea un token de simultaneidad:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }

    [ConcurrencyCheck]
    public Guid Version { get; set; }
}

Dado que esta propiedad no la genera la base de datos, debe asignarla en la aplicación siempre que guarde los cambios:

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
context.SaveChanges();

Si desea que siempre se asigne un nuevo valor GUID, puede hacerlo mediante un interceptor de SaveChanges. Sin embargo, una ventaja de administrar manualmente el token de simultaneidad es que puede controlar con precisión cuándo se vuelve a generar, para evitar conflictos de simultaneidad innecesarios.

Resolución de los conflictos de simultaneidad

Independientemente de cómo se configure el token de simultaneidad, para implementar la simultaneidad optimista, la aplicación debe controlar correctamente el caso en el que se produzca un conflicto de simultaneidad, y se genera una excepción DbUpdateConcurrencyException; esto se llama resolución de un conflicto de simultaneidad.

Una opción es simplemente informar al usuario de que se produjo un error en la actualización debido a cambios en conflicto; a continuación, el usuario puede cargar los nuevos datos e intentarlo de nuevo. O bien, si la aplicación realiza una actualización automatizada, simplemente puede repetir y volver a intentarlo inmediatamente después de volver a consultar los datos.

Una manera más sofisticada de resolver los conflictos de simultaneidad es combinar los cambios pendientes con los nuevos valores de la base de datos. Los detalles precisos de los valores que se combinan dependen de la aplicación, y el proceso se puede dirigir mediante una interfaz de usuario en la que se muestren ambos conjuntos de valores.

Existen tres conjuntos de valores disponibles para ayudar a resolver un conflicto de simultaneidad:

  • Los valores actuales son los valores que la aplicación intentó escribir en la base de datos.
  • Los valores originales son los valores que se recuperaron originalmente de la base de datos, antes de realizar cualquier edición.
  • Los valores de base de datos son los valores actualmente almacenados en la base de datos.

El enfoque general para controlar un conflicto de simultaneidad es:

  1. Detecte DbUpdateConcurrencyException durante SaveChanges.
  2. Use DbUpdateConcurrencyException.Entries para preparar un nuevo conjunto de cambios para las entidades afectadas.
  3. Actualice los valores originales del token de simultaneidad para reflejar los valores actuales en la base de datos.
  4. Reintente el proceso hasta que no haya ningún conflicto.

En el ejemplo siguiente, Person.FirstName y Person.LastName están configurados como tokens de simultaneidad. Hay un comentario // TODO: en la ubicación donde se incluye la lógica específica de la aplicación para elegir el valor que se guardará.

using var context = new PersonContext();
// Fetch a person from database and change phone number
var person = context.People.Single(p => p.PersonId == 1);
person.PhoneNumber = "555-555-5555";

// Change the person's name in the database to simulate a concurrency conflict
context.Database.ExecuteSqlRaw(
    "UPDATE dbo.People SET FirstName = 'Jane' WHERE PersonId = 1");

var saved = false;
while (!saved)
{
    try
    {
        // Attempt to save changes to the database
        context.SaveChanges();
        saved = true;
    }
    catch (DbUpdateConcurrencyException ex)
    {
        foreach (var entry in ex.Entries)
        {
            if (entry.Entity is Person)
            {
                var proposedValues = entry.CurrentValues;
                var databaseValues = entry.GetDatabaseValues();

                foreach (var property in proposedValues.Properties)
                {
                    var proposedValue = proposedValues[property];
                    var databaseValue = databaseValues[property];

                    // TODO: decide which value should be written to database
                    // proposedValues[property] = <value to be saved>;
                }

                // Refresh original values to bypass next concurrency check
                entry.OriginalValues.SetValues(databaseValues);
            }
            else
            {
                throw new NotSupportedException(
                    "Don't know how to handle concurrency conflicts for "
                    + entry.Metadata.Name);
            }
        }
    }
}

Uso de niveles de aislamiento para el control de la simultaneidad

La simultaneidad optimista mediante tokens de simultaneidad no es la única manera de garantizar que los datos permanezcan coherentes frente a cambios simultáneos.

Un mecanismo para garantizar la coherencia es el nivel de aislamiento de transacciones de lecturas repetibles. En la mayoría de las bases de datos, este nivel garantiza que una transacción vea los datos de la base de datos tal y como estaban cuando se inició la transacción, sin verse afectados por ninguna actividad simultánea posterior. Tomando nuestro ejemplo básico anterior, cuando consultamos una instancia de Person para actualizarla de alguna manera, la base de datos debe asegurarse de que ninguna otra transacción interfiera con esa fila de la base de datos hasta que se complete la transacción. En función de la implementación de la base de datos, esto sucede de una de estas dos maneras:

  1. Cuando se consulta la fila, la transacción efectúa un bloqueo compartido en ella. Cualquier transacción externa que intente actualizar la fila se bloqueará hasta que se complete la transacción. Esta es una forma de bloqueo pesimista y se implementa mediante el nivel de aislamiento de "lectura repetible" de SQL Server.
  2. En lugar de bloquear, la base de datos permite que la transacción externa actualice la fila, pero cuando su propia transacción intente actualizarla, se generará un error de "serialización", lo que indica que se ha producido un conflicto de simultaneidad. Se trata de una forma de bloqueo optimista (como la característica de token de simultaneidad de EF) y se implementa mediante el nivel de aislamiento de instantáneas de SQL Server, así como mediante el nivel de aislamiento de lecturas repetibles de PostgreSQL.

Tenga en cuenta que el nivel de aislamiento "serializable" proporciona las mismas garantías que la lectura repetible (y agrega otras adicionales), por lo que funciona de la misma manera con respecto a lo anterior.

El uso de un nivel de aislamiento superior para administrar conflictos de simultaneidad es más sencillo, no requiere tokens de simultaneidad y proporciona otras ventajas; por ejemplo, las lecturas repetibles garantizan que la transacción siempre vea los mismos datos en las consultas dentro de la transacción, evitando incoherencias. Sin embargo, este enfoque tiene sus inconvenientes.

En primer lugar, si la implementación de la base de datos usa el bloqueo para implementar el nivel de aislamiento, se deben bloquear otras transacciones que intenten modificar la misma fila para la totalidad de la transacción. Esto podría tener un efecto adverso en el rendimiento simultáneo (mantenga la transacción corta), aunque tenga en cuenta que el mecanismo de EF produce una excepción y le obliga a reintentar en su lugar, lo que también tiene un impacto. Esto se aplica al nivel de lectura repetible de SQL Server, pero no al nivel de instantánea, que no bloquea las filas consultadas.

Lo más importante es que este enfoque requiere una transacción para abarcar todas las operaciones. Si, por ejemplo, consulta Person para mostrar sus detalles a un usuario y, a continuación, esperar a que el usuario realice cambios, la transacción debe permanecer activa durante un tiempo potencialmente largo, lo que se debe evitar en la mayoría de los casos. Como resultado, este mecanismo suele ser adecuado cuando todas las operaciones contenidas se ejecutan inmediatamente y la transacción no depende de entradas externas que puedan aumentar su duración.

Recursos adicionales

Vea Detección de conflictos en EF Core para obtener un ejemplo de ASP.NET Core con detección de conflictos.