Transacciones SQL: errores comunes que dejan datos a medias
Transacciones que no hacen rollback, que duran minutos o que llaman a una API en medio: los errores más frecuentes con transacciones en SQL Server y .NET.
Un pedido quedó creado sin sus líneas. Un pago se registró, pero el saldo del cliente no cambió. Nadie entiende cómo, porque "todo está dentro de una transacción".
Las transacciones prometen todo o nada. Pero esa promesa depende de detalles que es fácil pasar por alto, y cuando fallan, el resultado no es un error visible: son datos inconsistentes que alguien descubre semanas después.
Error 1: creer que un error en SQL Server deshace la transacción
BEGIN TRANSACTION;
INSERT INTO Sales.Orders (OrderId, CustomerId, Total) VALUES (1001, 42, 150000);
INSERT INTO Sales.OrderLines (OrderId, ProductId, Quantity) VALUES (1001, 999999, 1); -- falla: FK
COMMIT; -- ❌ confirma el pedido sin líneas
Por defecto, muchos errores en SQL Server (como una violación de clave foránea) solo cancelan la instrucción que falló, no la transacción completa. La ejecución continúa y el COMMIT confirma lo que sí se insertó.
La solución tiene dos partes:
SET XACT_ABORT ON; -- cualquier error en tiempo de ejecución deshace toda la transacción
BEGIN TRY
BEGIN TRANSACTION;
INSERT INTO Sales.Orders (OrderId, CustomerId, Total) VALUES (1001, 42, 150000);
INSERT INTO Sales.OrderLines (OrderId, ProductId, Quantity) VALUES (1001, 999999, 1);
COMMIT;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK;
THROW; -- relanza el error original al cliente
END CATCH;
XACT_ABORT ON hace que el comportamiento sea predecible. El TRY/CATCH asegura el ROLLBACK y THROW evita que el error se trague en silencio. En procedimientos almacenados que modifican datos, conviene que esta sea la plantilla por defecto.
Error 2: en .NET, olvidar que el using no hace commit
await using var transaction = await db.Database.BeginTransactionAsync(ct);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
await inventory.ReserveAsync(order.Lines, ct);
await transaction.CommitAsync(ct); // si no se llega aquí, el Dispose hace rollback
Este código está bien: si algo lanza una excepción antes del CommitAsync, el Dispose de la transacción hace rollback. El error aparece cuando alguien agrega un return temprano:
if (order.Total == 0)
return; // ❌ sale sin commit: todo lo anterior se deshace sin aviso
En EF Core, además, un solo SaveChangesAsync ya es transaccional. Si todos los cambios caben en una llamada, no necesitas una transacción explícita.
Error 3: llamadas externas dentro de la transacción
await using var tx = await db.Database.BeginTransactionAsync(ct);
db.Payments.Add(payment);
await db.SaveChangesAsync(ct);
await paymentGateway.ChargeAsync(payment, ct); // ❌ 3 segundos con los bloqueos tomados
await tx.CommitAsync(ct);
Dos problemas:
- Bloqueos largos. Mientras la API externa responde, las filas modificadas siguen bloqueadas. Otras peticiones que las necesitan esperan. Con tráfico, esto se convierte en una cola de bloqueos.
- Falsa atomicidad. Si el cobro se hace y luego el
Commitfalla, el cliente fue cobrado y la base de datos no lo sabe. La transacción de SQL Server no puede deshacer una llamada HTTP.
La transacción debe envolver solo operaciones de base de datos. Para coordinar con sistemas externos, el patrón habitual es registrar la intención en la base de datos (por ejemplo, un pago en estado "pendiente") y procesar la llamada externa fuera de la transacción, con reintentos idempotentes. Es la base del patrón outbox.
Error 4: transacciones que leen mucho antes de escribir
BEGIN TRANSACTION;
-- Reporte de 20 segundos para calcular algo...
SELECT ... FROM Sales.Orders WHERE ...;
UPDATE Billing.Balances SET ...;
COMMIT;
Con el nivel de aislamiento por defecto, las lecturas dentro de la transacción toman bloqueos compartidos mientras duran, y la transacción completa mantiene los bloqueos de escritura hasta el final. Cuanto más dura la transacción, más tiempo bloquea a otros y más probable es un deadlock.
Calcula fuera de la transacción y abre la transacción solo para escribir. Si necesitas que el dato leído no cambie entre la lectura y la escritura, hay formas más precisas que una transacción larga (control de concurrencia optimista, por ejemplo).
Error 5: ignorar la concurrencia
Una transacción garantiza atomicidad, no que nadie más haya cambiado el dato mientras tanto:
var product = await db.Products.FirstAsync(p => p.Id == id, ct);
product.Stock -= quantity; // otro usuario pudo descontar al mismo tiempo
await db.SaveChangesAsync(ct);
Dos peticiones simultáneas leen Stock = 5, cada una descuenta 3 y las dos guardan 2. Se vendieron 6 unidades de 5.
Con un token de concurrencia, EF Core detecta el conflicto:
modelBuilder.Entity<Product>().Property(p => p.RowVersion).IsRowVersion();
Si la fila cambió desde que se leyó, SaveChangesAsync lanza DbUpdateConcurrencyException y puedes reintentar con el valor actualizado. Para casos simples, una actualización atómica es todavía mejor:
UPDATE Inventory.Products
SET Stock = Stock - @quantity
WHERE ProductId = @id AND Stock >= @quantity;
-- Si @@ROWCOUNT = 0, no había stock suficiente
Error 6: TransactionScope sin entenderlo
TransactionScope es cómodo, pero si dentro se abren dos conexiones distintas, la transacción puede escalar a una transacción distribuida. Además, con código async necesita TransactionScopeAsyncFlowOption.Enabled o la transacción no fluye entre los await. Si trabajas con un solo DbContext o una sola conexión, la transacción explícita de esa conexión es más predecible.
El trade-off
Las transacciones cuestan bloqueos y concurrencia. Cuanto más grandes y largas, más seguras se sienten y peor escalan. El objetivo es la transacción más corta posible que mantenga los datos consistentes.
Cuándo no necesitas una transacción explícita
- Un solo
SaveChangesAsyncde EF Core: ya es atómico. - Una sola instrucción SQL: también es atómica por sí misma.
- Lecturas sin escrituras: una transacción solo agrega bloqueos.
Conclusión
Una transacción no hace que un proceso sea consistente por estar ahí. Hace falta XACT_ABORT ON, un rollback garantizado, solo operaciones de base de datos dentro y la menor duración posible. Con eso, "todo o nada" vuelve a significar lo que promete.
¿Tu base de datos se volvió lenta?
Revisamos consultas, índices y modelo de datos para que tu sistema responda rápido aunque los datos sigan creciendo.
Ver desarrollo a medida