En sistemas distribuidos que usan bases de datos relacionales como SQL Server, es común que múltiples hilos o procesos intenten leer y modificar simultáneamente la misma entidad. Este fenómeno se llama concurrencia y, lejos de ser un bug, es una consecuencia natural de los entornos altamente paralelos y asincrónicos.

En este artículo aprenderás:

  • ¿Qué es la concurrencia en SQL Server con EF Core?
  • ¿Por qué los errores de concurrencia no son fallos del sistema¿
  • ¿Cómo usar concurrencia optimista y pesimista con EF Core sobre SQL Server?
  • ¿Qué estrategias de reprocesamiento aplicar?
  • Bonus: ¿Qué pasa si usas Dapr o bases NewSQL?
  • Bonus: ¿Y si usas PostgreSQL?

¿Qué es la concurrencia en SQL Server con EF Core?

Cuando dos procesos cargan y actualizan una misma fila casi al mismo tiempo, el segundo puede fallar si la fila fue modificada en medio. EF Core detecta esto gracias a una propiedad [Timestamp] que representa el token de versión.

public class Pedido {
    public Guid Id { get; set; }
    public string Estado { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; } // SQL Server usa rowversion (8 bytes binarios)
}

Al llamar a SaveChanges(), EF genera una consulta como:

UPDATE Pedidos SET Estado = 'Confirmado'
WHERE Id = @id AND RowVersion = @version

Si la RowVersion ya no coincide (otro proceso la modificó), no se actualiza ninguna fila y EF lanza un DbUpdateConcurrencyException.

¿Por qué esto NO es un bug?

Porque significa que otro proceso modificó el mismo dato antes que tú, y tu versión ya no está actualizada. Esto es normal en sistemas escalables con varios consumidores o instancias. EF Core no puede asumir qué versión es la correcta: te notifica para que tú tomes una decisión.

Estrategias para reprocesar

Reintento completo

for (int intento = 0; intento < 3; intento++) {
    try {
        await db.SaveChangesAsync();
        return;
    } catch (DbUpdateConcurrencyException ex) {
        foreach (var entry in ex.Entries) {
            var databaseValues = await entry.GetDatabaseValuesAsync();
            entry.OriginalValues.SetValues(databaseValues);
        }
    }
}

Reintento parcial (Merge de cambios)

catch (DbUpdateConcurrencyException ex) {
    var entry = ex.Entries.Single();
    var dbValues = await entry.GetDatabaseValuesAsync();
    var proposedValues = entry.CurrentValues;

    foreach (var prop in proposedValues.Properties) {
        if (/* detecta conflicto */)
            proposedValues[prop] = dbValues[prop];
    }

    entry.OriginalValues.SetValues(dbValues);
    await db.SaveChangesAsync();
}

Fallback completo

Si todos los reintentos fallan, puedes abandonar la operación y aplicar una estrategia alternativa. Esto puede implicar:

  • Registrar el fallo en logs o sistemas de monitorización

  • Mover el identificador del mensaje o entidad a una dead-letter queue

  • Notificar a un operador humano

if (!guardado)
{
    await RegistrarEnColaDeErrores(pedidoId);
    logger.LogWarning($"No se pudo procesar el pedido {pedidoId}. Fallback completo activado.");
}

Fallback parcial

Cuando no puedes o no quieres repetir toda la lógica del dominio, pero sí el intento de guardar, puedes aplicar la lógica una sola vez y reintentar solo el guardado:

var pedido = await db.Pedidos.FindAsync(pedidoId);
pedido.Confirmar();

for (int i = 0; i < 3; i++)
{
    try
    {
        await db.SaveChangesAsync();
        return;
    }
    catch (DbUpdateConcurrencyException)
    {
        await db.Entry(pedido).ReloadAsync();
        pedido.Confirmar();
    }
}

await NotificarReintentoFallido(pedidoId);

Este enfoque reduce el riesgo de ejecutar lógica de dominio varias veces, especialmente si involucra efectos colaterales como envíos de emails, llamadas a APIs o generación de eventos.

Si ningún intento funciona, registra el error y deriva el caso a una cola de errores, log o alerta.

Concurrencia optimista vs pesimista en SQL Server

En EF Core con SQL Server, puedes optar por dos modelos principales de concurrencia: optimista y pesimista. Lo importante es entender que no se trata de elegir uno u otro para todo el sistema, sino de aplicar el enfoque adecuado según el contexto y el tipo de entidad. Hay escenarios donde la concurrencia optimista es suficiente (por ejemplo, entidades que se modifican esporádicamente), y otros donde se requiere concurrencia pesimista (como en casos financieros o de inventario crítico donde la consistencia es clave).

No es blanco o negro: puedes (y debes) combinar ambos enfoques dentro del mismo modelo de datos. Algunas tablas pueden funcionar perfectamente con versiones (RowVersion), mientras que otras necesitan bloqueos explícitos.

A continuación se muestra una comparativa rápida:

Tipo Estrategia Detalles
Optimista [Timestamp] EF Core compara RowVersion. No hay bloqueos en la BD.
Pesimista WITH (UPDLOCK) Bloquea explícitamente la fila en lectura para evitar cambios externos.

Ejemplo de concurrencia pesimista:

await using var tx = await db.Database.BeginTransactionAsync();
var pedido = await db.Pedidos
    .FromSqlInterpolated($"SELECT * FROM Pedidos WITH (UPDLOCK, ROWLOCK) WHERE Id = {pedidoId}")
    .FirstOrDefaultAsync();

pedido.Confirmar();
await db.SaveChangesAsync();
await tx.CommitAsync();

Esto evita que otro proceso lea o actualice esa fila hasta que completes la transacción.

Bonus: ¿Qué pasa si usas Dapr o bases NewSQL?

Dapr proporciona mecanismos de concurrencia mediante ETags. Si usas Dapr con SQL Server como state store o con EF Core, aún puedes sufrir DbUpdateConcurrencyException, ya que Dapr no elimina la concurrencia, solo proporciona patrones para manejarla.

En bases NewSQL como CockroachDB o Spanner, la lógica cambia:

  • CockroachDB: no bloquea, pero si hay conflicto en commit, aborta una transacción y obliga a reintentar.
  • Spanner: garantiza serializabilidad y aborta una de las transacciones si detecta conflicto.

Esto quiere decir que, aunque no tengas que lidiar con locks o versiones manuales, sigues necesitando lógica de reintentos.

Bonus: ¿Y si usas PostgreSQL?

PostgreSQL no tiene un tipo rowversion como SQL Server, pero puedes lograr concurrencia optimista con EF Core usando xmin (símbolo del MVCC interno) o bien una columna manual con bytea o timestamp.

En escenarios de concurrencia pesimista, PostgreSQL permite usar FOR UPDATE en combinación con NOWAIT o SKIP LOCKED, lo que da mucho control sobre bloqueos explícitos.

var entidad = await db.Entidades
    .FromSqlRaw("SELECT * FROM Entidades WHERE Id = {0} FOR UPDATE NOWAIT", id)
    .SingleAsync();

Esto se comporta de forma muy parecida al WITH (UPDLOCK) de SQL Server.

En resumen, PostgreSQL es perfectamente compatible con estas estrategias de concurrencia, aunque requiere una configuración ligeramente diferente. EF Core las soporta sin problema.

Conclusión

La concurrencia no es un bug: es parte del diseño de sistemas distribuidos.

Y recuerda: si todo falla, un buen fallback es mejor que una inconsistencia silenciosa. Porque si dejas que los errores de concurrencia pasen desapercibidos, te arriesgas a tener un sistema incoherente, donde los datos pierden fiabilidad y la trazabilidad se vuelve imposible.

En sistemas que procesan cientos de miles de transacciones al día, incluso un 1% de fallos por concurrencia puede significar miles de operaciones con consecuencias no detectadas. Esto puede derivar en tener que hacer:

  • Sanitización de datos manual o semiautomática

  • Procesos nocturnos de reconstrucción o reconciliación de entidades

  • Reprocesamiento de colas en dead-letter con lógica especial

Y lo peor: puede que lo descubras demasiado tarde, cuando ya has enviado emails incorrectos, cobrado de más a clientes o generado informes erróneos.

Por eso, no se trata solo de reintentar. Se trata de ser conscientes de que la concurrencia existe, aceptarla, diseñar para ella y observar sus síntomas.

Un sistema resiliente no es el que nunca falla, sino el que falla con elegancia y visibilidad.

No se trata de evitar fallos, sino de saber qué hacer cuando ocurren.

Información adicional