El bloqueo pesimista evita un conflicto bloqueando la fila para que nadie más pueda tocarla. El bloqueo optimista deja que el conflicto ocurra y lo detecta al guardar, comprobando una columna de versión.
Cuál conviene usar depende de la frecuencia con la que se producen realmente los conflictos. Los conflictos poco frecuentes, el caso normal en las aplicaciones web, favorecen el bloqueo optimista, porque no mantiene bloqueos y no cuesta nada hasta que algo sale mal. Los conflictos frecuentes en una fila muy disputada favorecen el bloqueo pesimista, porque reintentar una y otra vez un guardado fallido es peor que esperar una vez.
Requisitos previos para ejecutar la demostración
En este artículo, usaremos Entity Framework Core como abstracción sobre el acceso a datos y el paquete NuGet Testcontainers para crear mediante código un contenedor de base de datos de SQL Server. Esta configuración nos permite mostrar las estrategias de bloqueo pesimista y de bloqueo optimista en un entorno realista.
Después de configurar un contenedor de prueba de SQL Server, ya podemos configurar Entity Framework Core para que se conecte a esta instancia de SQL Server:
builder.Services
.AddDbContext<ApplicationDbContext>((sp, contextBuilder) =>
contextBuilder.UseSqlServer(connectionString),
contextLifetime: ServiceLifetime.Scoped);
Con el DbContext configurado, necesitamos un DTO sencillo para manejar las solicitudes a los endpoints de la API:
public record AssignWorkItemRequest(long Id, string AssignedTo, bool ForceConflict = false);
Por último, necesitamos una herramienta como Swagger, Postman, etc., para enviar solicitudes a la API.
Con todo preparado, ya podemos mostrar tanto el bloqueo pesimista como el bloqueo optimista.
¡Empecemos!
¿Qué es el bloqueo optimista?
El bloqueo optimista no bloquea nada. Supone que los conflictos son poco frecuentes, deja que todos lean y editen libremente y, al guardar, comprueba si la fila ha cambiado desde que se leyó.
La comprobación se apoya en una columna de versión: un rowversion, un contador, una marca de tiempo, un GUID. La actualización lleva la versión que se leyó y pide a la base de datos que la use como condición, además de la clave.
Si la fila conserva esa versión, se actualiza una fila y el guardado tiene éxito. Si otro escritor se ha adelantado, la versión ya no coincide, se actualizan cero filas y el guardado falla en lugar de sobrescribir.
EF Core convierte ese resultado de cero filas en una DbUpdateConcurrencyException, que es la señal para recargar, combinar los cambios o preguntar al usuario. Nunca es una señal para reintentar a ciegas, porque un reintento a ciegas es precisamente la actualización perdida que la columna de versión sirve para evitar.
Entre la lectura y el guardado no se mantiene nada, así que el patrón funciona entre solicitudes, entre procesos y con clientes desconectados.
Bloqueo optimista con RowVersion en EF Core
Para demostrarlo, vamos a definir una entidad WorkItemWithRowVersion:
public class WorkItemWithRowVersion
{
public long Id { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string Status { get; set; }
public DateTime DueDate { get; set; }
public string AssignedTo { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
}
Aquí añadimos una propiedad RowVersion. Esta propiedad lleva el atributo [Timestamp], lo que permite a EF Core manejar automáticamente las comprobaciones de concurrencia.
Como alternativa, podemos configurar RowVersion con la Fluent API de EF Core:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<WorkItemWithRowVersion>().Property(p => p.RowVersion).IsRowVersion();
}
Podemos usar cualquiera de los dos enfoques, según el estilo de configuración de la aplicación.
Para probar el bloqueo optimista, vamos a crear un endpoint nuevo que obtiene la entidad WorkItemWithRowVersion, actualiza el campo AssignedTo y guarda los cambios. Si ForceConflict vale true, una actualización SQL directa simula un conflicto de concurrencia modificando el campo AssignedTo en la base de datos:
[HttpPost("/workItem/assign-optimistic-row-version")]
public async Task<IActionResult> AssignWorkItemWithAutomaticOptimisticLockAsync(
AssignWorkItemRequest assignWorkItemRequest,
CancellationToken cancellationToken)
{
var workItem = await DbContext.WorkItemsWithRowVersion
.FirstOrDefaultAsync(x => x.Id == assignWorkItemRequest.Id, cancellationToken);
if (workItem is null)
return NotFound($"Work Item with Id {assignWorkItemRequest.Id} was not found!");
workItem.AssignedTo = assignWorkItemRequest.AssignedTo;
if (assignWorkItemRequest.ForceConflict)
{
_ = await DbContext.Database.ExecuteSqlInterpolatedAsync(
$@"UPDATE dbo.WorkItemsWithRowVersion
SET AssignedTo = 'John Stevens'
WHERE Id = {assignWorkItemRequest.Id}", cancellationToken);
}
try
{
await DbContext.SaveChangesAsync(cancellationToken);
return Ok("Work item updated successfully with optimistic locking.");
}
catch (DbUpdateConcurrencyException ex)
{
Logger.LogError(ex, "Error while saving changes");
return Conflict("Resource was already modified. Please retry");
}
}
Fíjate en que aquí no actualizamos explícitamente la propiedad RowVersion: EF Core la gestiona automáticamente gracias al atributo [Timestamp]. Esta configuración deja el control de versiones en manos de la base de datos, lo que resulta eficiente en la mayoría de los casos de uso. Todo lo demás es seguimiento de cambios normal, que explicamos a fondo en nuestra guía sobre cómo modificar datos con EF Core.
Hay un detalle de la simulación que conviene señalar. En un conflicto real, ese UPDATE que compite viene de otra solicitud, en otra conexión. Aquí lo ejecutamos en línea a través del mismo DbContext que está a punto de guardar, así que la demostración es determinista y no una carrera que tengamos que ganar.
Fíjate también en el bloque catch de DbUpdateConcurrencyException. EF Core lanza esta excepción cuando detecta un conflicto, lo que indica que otro proceso ha modificado los datos.
Para comprobarlo, enviemos una solicitud a /workItem/assign-optimistic-row-version con un payload válido para una actualización correcta:
{
"id": 2,
"assignedTo": "John Newman",
"forceConflict": false
}
Como era de esperar, el resultado muestra que hemos actualizado el registro:
Work item updated successfully with optimistic locking.

Ebook gratis
¿Tu Web API está lista para producción?
33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.
El ebook está en inglés.
Descarga la checklist gratisPDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.
A continuación, simulamos un conflicto de concurrencia con un nuevo payload:
{
"id": 2,
"assignedTo": "John Oldman",
"forceConflict": true
}
Aquí modificamos el valor de assignedTo y establecemos forceConflict en true. Esta vez, la respuesta indica un conflicto:
Resource was already modified. Please retry
Además, podemos ver un mensaje registrado en la consola:
{"The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions."}
Este mensaje confirma que el bloqueo optimista funciona como esperábamos.
Bloqueo optimista con ConcurrencyToken en EF Core
En algunos casos, quizá queramos más control sobre las versiones y gestionarlas manualmente dentro de la aplicación. El enfoque ConcurrencyToken ofrece esa flexibilidad.
If-Match hacen la comprobación de versión entre el cliente y la API. Para esa parte, consulta nuestro artículo Concurrencia optimista en ASP.NET Core Web API.Para demostrarlo, podemos definir una entidad WorkItemWithConcurrencyToken:
public class WorkItemWithConcurrencyToken
{
public long Id { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string Status { get; set; }
public DateTime DueDate { get; set; }
public string AssignedTo { get; set; }
[ConcurrencyCheck]
public long Version { get; set; }
}
Aquí añadimos una propiedad Version marcada con [ConcurrencyCheck], que también podría ser un entero, un GUID u otro valor único.
Como alternativa, podemos configurar Version como token de concurrencia con la Fluent API de EF Core:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<WorkItemWithConcurrencyToken>()
.Property(p => p.Version)
.IsConcurrencyToken();
}
Ahora, vamos a crear un endpoint nuevo que obtiene la entidad WorkItemWithConcurrencyToken, actualiza el campo AssignedTo e incrementa manualmente la propiedad Version. Si ForceConflict vale true, una actualización SQL directa simula un conflicto de concurrencia:
[HttpPost("/workItem/assign-manual-optimistic-concurrency-token")]
public async Task<IActionResult> AssignWorkItemWithManualOptimisticLockAsync(
AssignWorkItemRequest assignWorkItemRequest,
CancellationToken cancellationToken)
{
var workItem = await DbContext.WorkItemsWithConcurrencyToken
.FirstOrDefaultAsync(x => x.Id == assignWorkItemRequest.Id, cancellationToken);
if (workItem is null)
return NotFound($"Work Item with Id {assignWorkItemRequest.Id} was not found!");
workItem.AssignedTo = assignWorkItemRequest.AssignedTo;
workItem.Version++;
if (assignWorkItemRequest.ForceConflict)
{
_ = await DbContext.Database.ExecuteSqlInterpolatedAsync(
$@"UPDATE dbo.WorkItemsWithConcurrencyToken
SET AssignedTo = 'John Stevens', Version = Version + 1
WHERE Id = {assignWorkItemRequest.Id}", cancellationToken);
}
try
{
await DbContext.SaveChangesAsync(cancellationToken);
return Ok("Work item updated successfully with optimistic locking.");
}
catch (DbUpdateConcurrencyException ex)
{
Logger.LogError(ex, "Error while saving changes");
return Conflict("Resource was already modified. Please retry");
}
}
Este endpoint es parecido al anterior, con una diferencia clave: aquí actualizamos la propiedad manualmente.
Igual que antes, enviemos primero una solicitud a /workItem/assign-manual-optimistic-concurrency-token con un payload válido:
{
"id": 3,
"assignedTo": "John Doe",
"forceConflict": false
}
Como era de esperar, el resultado muestra que la solicitud ha modificado el recurso:
Work item updated successfully with optimistic locking.
Para simular un conflicto de concurrencia, cambiemos assignedTo en el payload, pongamos forceConflict en true y enviemos una nueva solicitud:
Resource was already modified. Please retry
Como antes, EF Core detecta el conflicto y lanza una DbUpdateConcurrencyException, que manejamos en el bloque catch, y el resultado confirma que el token de concurrencia funciona.
Además, con el logging de EF Core activado, veremos en los logs los detalles del conflicto, que indican que la columna Version se comprueba para controlar la concurrencia:
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (3ms) [Parameters=[@__assignWorkItemRequest_Id_0='?' (DbType = Int64)], CommandType='Text', Com
SELECT TOP(1) [w].[Id], [w].[AssignedTo], [w].[Description], [w].[DueDate], [w].[Status], [w].[Title], [w].[Versio
FROM [WorkItemsWithConcurrencyToken] AS [w]
WHERE [w].[Id] = @__assignWorkItemRequest_Id_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (10ms) [Parameters=[@p0='?' (DbType = Int64)], CommandType='Text', CommandTimeout='30']
UPDATE dbo.WorkItemsWithConcurrencyToken
SET AssignedTo = 'John Stevens', Version = Version + 1
WHERE Id = @p0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (2ms) [Parameters=[@p2='?' (DbType = Int64), @p0='?' (Size = 4000), @p1='?' (DbType = Int64), @
SET IMPLICIT_TRANSACTIONS OFF;
SET NOCOUNT ON;
UPDATE [WorkItemsWithConcurrencyToken] SET [AssignedTo] = @p0, [Version] = @p1
OUTPUT 1
WHERE [Id] = @p2 AND [Version] = @p3;
El último mensaje de log indica que durante la actualización se comprueban tanto Id como Version, lo que confirma que nuestro token de concurrencia manual funciona correctamente.
Si necesitamos un registro duradero de qué escritura ganó y cuál perdió, eso corresponde a un nivel por debajo del controlador. Los interceptores de EF Core ven todos los intentos de guardado, incluido el que no supera la comprobación de versión.
¿Qué es el bloqueo pesimista?
El bloqueo pesimista bloquea una fila antes de leerla, de modo que ninguna otra transacción puede modificarla hasta que termine la primera. Supone que se avecina un conflicto y lo evita.
El bloqueo vive en la base de datos, no en la aplicación. Cualquier otro escritor que llegue a la misma fila espera, y cuánto espera no es algo que controle nuestro código.
En esa espera está toda la contrapartida. Nunca se pierde nada y nunca hay que reintentar nada, pero el throughput sobre una fila disputada se vuelve secuencial, y una transacción que se mantiene abierta demasiado tiempo acaba en un tiempo de espera agotado o en un interbloqueo (deadlock).
Encaja en trabajos donde los conflictos son frecuentes y la operación es corta: un contador, un nivel de existencias, una reserva de asiento procesada en un solo viaje de ida y vuelta.
Encaja mal en la edición interactiva. Mantener un bloqueo de base de datos mientras un usuario tiene un formulario abierto no es un patrón para el que esté pensada ninguna base de datos.

Ebook gratis
¿Tu Web API está lista para producción?
33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.
El ebook está en inglés.
Descarga la checklist gratisPDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.
La propia guía de concurrencia de EF Core de Microsoft llega a la misma conclusión sobre una transacción que espera a una persona: “la transacción debe permanecer activa durante un tiempo potencialmente largo, algo que debería evitarse en la mayoría de los casos”.
Una distinción importante: el bloqueo del que hablamos es un bloqueo de base de datos, que mantiene la propia base de datos y que es visible para todos los procesos que se conectan a ella. La instrucción lock y compañía, en cambio, protegen una estructura de datos dentro de un solo proceso, y son una herramienta distinta para un problema distinto. Los tratamos en el artículo sobre los mecanismos de bloqueo propios de C# para la contención dentro de un proceso, junto con ReaderWriterLockSlim y cuándo supera a lock.
Bloqueo pesimista con una transacción serializable
Para mostrar el bloqueo pesimista en EF Core, usaremos una entidad WorkItem sencilla:
public class WorkItem
{
public long Id { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string Status { get; set; }
public DateTime DueDate { get; set; }
public string AssignedTo { get; set; }
}
La clase WorkItem contiene propiedades para representar una tarea, entre ellas Id, Title y Description para resumir y detallar la tarea, Status para indicar su estado actual, DueDate para controlar la fecha límite y un campo AssignedTo que identifica a la persona responsable.
Podemos conseguir el bloqueo pesimista en EF Core con una transacción explícita:
[HttpPost("/workItem/assign-pessimistic")]
public async Task<IActionResult> AssignWorkItemWithPessimisticLockAsync(
AssignWorkItemRequest assignWorkItemRequest,
CancellationToken cancellationToken)
{
using var transaction = await DbContext.Database
.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken);
var workItem = await DbContext.WorkItems.
FirstOrDefaultAsync(x => x.Id == assignWorkItemRequest.Id, cancellationToken);
if (workItem is null)
return NotFound($"Work Item with Id {assignWorkItemRequest.Id} was not found!");
workItem.AssignedTo = assignWorkItemRequest.AssignedTo;
try
{
await DbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok("Work item updated successfully with pessimistic locking.");
}
catch (Exception ex)
{
await transaction.RollbackAsync(cancellationToken);
Logger.LogError(ex, "Error while saving changes");
return Problem("An error occurred while updating the resource.");
}
}
Usamos el nivel de aislamiento de transacción serializable para garantizar que ningún otro proceso pueda modificar la entidad mientras el proceso actual accede a ella. Sin embargo, debemos tener en cuenta que el nivel de transacción serializable puede bloquear más filas de las necesarias, lo que puede perjudicar el rendimiento y aumentar el riesgo de interbloqueos.
Para probar este endpoint, enviemos una solicitud a /workItem/assign-pessimistic con un payload válido:
{
"id": 1,
"assignedTo": "John Doe"
}
Como era de esperar, recibimos un resultado que muestra que se ha usado el bloqueo pesimista:
Work item updated successfully with pessimistic locking.
Estos resultados indican que la transacción ha actualizado la entidad sin que se produjeran modificaciones concurrentes.
Por qué una transacción serializable no siempre basta
Un SELECT simple dentro de una transacción serializable toma un bloqueo compartido, no uno exclusivo. Por eso, dos transacciones pueden leer la misma fila sin problemas, y después ambas intentan convertirlo en un bloqueo de escritura en SaveChangesAsync(). En ese punto, ninguna puede avanzar hasta que la otra lo libere, y la base de datos lo resuelve eliminando una de ellas como víctima del interbloqueo. La lectura funcionó para las dos. Lo que choca es la escritura.
No es un caso límite que haya que deducir. La Transaction Locking and Row Versioning Guide de Microsoft describe el patrón sin rodeos: con REPEATABLE READ o SERIALIZABLE, “las actualizaciones concurrentes pueden provocar un interbloqueo”.
El remedio habitual es tomar el bloqueo más fuerte en el momento de leer, no en el de escribir. En SQL Server eso significa poner en la consulta una sugerencia de bloqueo de actualización, que no es compatible con un segundo bloqueo de actualización, así que la segunda transacción espera en lugar de provocar un interbloqueo. EF Core no tiene una API propia para las sugerencias de tabla (table hints), así que en este caso hay que pasar por SQL directo con FromSql en lugar de usar la API fluida.
Bloqueo optimista vs bloqueo pesimista: ¿cuál usar?
Elegir el mecanismo de bloqueo adecuado depende de varios factores: las necesidades concretas de la aplicación, la frecuencia con la que se producen conflictos, el nivel de concurrencia y el rendimiento de la aplicación. Veamos una comparación que nos ayuda a tomar una decisión fundamentada sobre la estrategia de bloqueo adecuada para nuestra aplicación:
El bloqueo optimista es la opción predeterminada en casi cualquier aplicación web, y el bloqueo pesimista es la excepción a la que recurrimos de forma deliberada.
La pregunta decisiva no es cuál es más seguro. Los dos son correctos. La pregunta es con qué frecuencia chocan realmente dos escritores en la misma fila. Si eso es raro, el bloqueo optimista no cuesta absolutamente nada hasta que ocurre.
Si las colisiones son habituales en una fila concreta, el bloqueo optimista se degrada mucho: cada escritor hace su trabajo, falla y vuelve a hacerlo, y el throughput se hunde por los reintentos, no por la espera.
La duración importa tanto como la frecuencia. Un bloqueo pesimista solo es viable cuando la transacción es corta y la dirige una máquina. Todo lo que dependa de la atención de un usuario tiene que ser optimista, porque la alternativa es un bloqueo de base de datos que dura mientras alguien deje abierta una pestaña del navegador.
También se pueden combinar. Optimista en todas partes y pesimista en las dos o tres filas que de verdad se disputan es una respuesta habitual y sensata.
| Criterio | Bloqueo pesimista | Bloqueo optimista |
|---|---|---|
| Mecanismo | Bloquea la fila mientras dura la transacción | Compara un valor de versión al guardar |
| El conflicto | Se evita | Se detecta |
| Costo cuando no hay conflictos | Se mantiene el bloqueo y los demás escritores esperan | Ninguno |
| Costo cuando hay un conflicto | Ninguno adicional; el segundo escritor ya esperó | El guardado falla y hay que reintentarlo |
| Modo de fallo | Esperas por bloqueo, tiempos de espera agotados, interbloqueos | DbUpdateConcurrencyException |
| Escala a | Una sola base de datos, una transacción | Cualquier sistema, incluidos los clientes desconectados |
| Tolera que el usuario tarde mucho en actuar | No, el bloqueo se mantendría todo ese tiempo | Sí |
| Cómo se implementa en EF Core | Transacción explícita más nivel de aislamiento o sugerencias de tabla | [Timestamp] / IsRowVersion(), [ConcurrencyCheck] / IsConcurrencyToken() |
| Quién gestiona la columna de versión | No se aplica: no hay columna de versión | La base de datos, con una columna RowVersion, o nuestro propio código, con un token de concurrencia que se incrementa manualmente |
| Sistemas típicos | Libros contables financieros, inventario, reservas de asientos y de franjas horarias | CMS y redes sociales, edición de perfiles, edición colaborativa |
| Cuándo recurrir a él | Los conflictos son frecuentes y reintentar es costoso | Los conflictos son raros, que es lo más habitual |
Las dos secuencias que hay detrás de esa tabla se ven mejor una junto a otra:
Conclusión
En este artículo, hemos visto los mecanismos de bloqueo pesimista y optimista en EF Core, con ejemplos prácticos y los casos en los que cada uno resulta más adecuado. Aplicar estas estrategias ayuda a mantener la coherencia de los datos y evita problemas de concurrencia, de modo que las aplicaciones pueden atender a muchos usuarios y un tráfico de datos elevado sin poner en riesgo la integridad de los datos. Elegir el mecanismo de bloqueo adecuado garantiza que, incluso con mucha carga, nuestros sistemas sigan siendo fiables, ágiles y resistentes a los conflictos.
Entonces, ¿tu aplicación sufre conflictos de datos, problemas de coherencia o cuellos de botella de rendimiento? ¿Usas las estrategias de bloqueo correctas? Echemos un nuevo vistazo a nuestras aplicaciones con lo que hemos aprendido hoy, para ver si hay algo que mejorar. ¡Y no olvides volver para leer nuestro próximo artículo!
Probado con .NET 10.0.10, Testcontainers 3.10.0 y SQL Server 2022.
