SQL Server

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.

Por Leyton Manuel Espitia Diaz 5 min de lectura

Fragmento de SQL con SET XACT_ABORT ON y un bloque TRY CATCH con ROLLBACK

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:

  1. 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.
  2. Falsa atomicidad. Si el cobro se hace y luego el Commit falla, 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 SaveChangesAsync de 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.

Leyton Manuel Espitia Diaz

Escrito por

Leyton Manuel Espitia Diaz

CEO y fundador de Nakanaba

Desarrollador de software y fundador de Nakanaba. Lidera la arquitectura de Vitrin: backend en .NET, aplicaciones en Angular y .NET MAUI, SQL Server y servicios en Cloudflare.

¿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