Transactions and ACID
Make several changes succeed or fail together: autocommit, BEGIN TRANSACTION, COMMIT and ROLLBACK, why an error does not end a transaction, SET XACT_ABORT ON, the TRY…CATCH pattern, nested transactions, savepoints and what ACID means.
Every statement is a transaction
ALTER TABLE dbo.Products ADD CONSTRAINT CK_Products_Stock CHECK (Stock >= 0);GOUPDATE dbo.Products SET Stock = Stock - 15 WHERE CategoryId = 1; SELECT Name, Stock FROM dbo.Products WHERE CategoryId = 1;Msg 547, Level 16, State 0, Line 1 The UPDATE statement conflicted with the CHECK constraint "CK_Products_Stock". The conflict occurred in database "BuildMart", table "dbo.Products", column 'Stock'. The statement has been terminated. Name Stock ---------------- ----- Claw hammer 40 Cordless drill 12 Tape measure 8 m 65
BEGIN TRANSACTION and COMMIT
BEGIN TRANSACTION starts it; COMMIT makes every change since then permanent:
BEGIN TRANSACTION; INSERT INTO dbo.Orders (CustomerId, OrderDate, Status)VALUES (3, '2024-06-20', 'Pending'); DECLARE @OrderId int = SCOPE_IDENTITY(); INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice)VALUES (@OrderId, 2, 1, 89.00), (@OrderId, 8, 3, 11.90); UPDATE dbo.ProductsSET Stock = Stock - CASE ProductId WHEN 2 THEN 1 ELSE 3 ENDWHERE ProductId IN (2, 8); COMMIT; SELECT i.OrderId, p.Name, i.Quantity, p.StockFROM dbo.OrderItems AS iJOIN dbo.Products AS p ON p.ProductId = i.ProductIdWHERE i.OrderId = 11;OrderId Name Quantity Stock
------- ----------------- -------- -----
11 Cordless drill 1 11
11 Wood screws (200) 3 147ROLLBACK undoes the lot
ROLLBACK undoes every change since BEGIN TRANSACTION. @@TRANCOUNT tells you whether a transaction is open: 0 means none.
BEGIN TRANSACTION; DELETE FROM dbo.OrderItems; SELECT @@TRANCOUNT AS OpenTransactions, COUNT(*) AS OrderLinesFROM dbo.OrderItems; ROLLBACK; SELECT @@TRANCOUNT AS OpenTransactions, COUNT(*) AS OrderLinesFROM dbo.OrderItems;OpenTransactions OrderLines
---------------- ----------
1 0
OpenTransactions OrderLines
---------------- ----------
0 18The trap: an error does not end the transaction
BEGIN TRANSACTION; UPDATE dbo.Products SET Stock = Stock - 1 WHERE ProductId = 2; INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice)VALUES (99, 2, 1, 89.00); -- there is no order 99 COMMIT; SELECT Name, Stock, @@TRANCOUNT AS OpenTransactionsFROM dbo.Products WHERE ProductId = 2;Msg 547, Level 16, State 0, Line 5 The INSERT statement conflicted with the FOREIGN KEY constraint "FK__OrderItem__Order__412EB0B6". The conflict occurred in database "BuildMart", table "dbo.Orders", column 'OrderId'. The statement has been terminated. Name Stock OpenTransactions -------------- ----- ---------------- Cordless drill 11 0
SET XACT_ABORT ON
SET XACT_ABORT ON, any run-time error rolls back the whole transaction and stops the batch. Put it at the top of every script and stored procedure that uses transactions:
SET XACT_ABORT ON; BEGIN TRANSACTION; UPDATE dbo.Products SET Stock = Stock - 1 WHERE ProductId = 2; INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice)VALUES (99, 2, 1, 89.00); COMMIT;GOSELECT Name, Stock, @@TRANCOUNT AS OpenTransactionsFROM dbo.Products WHERE ProductId = 2;Msg 547, Level 16, State 0, Line 7 The INSERT statement conflicted with the FOREIGN KEY constraint "FK__OrderItem__Order__412EB0B6". The conflict occurred in database "BuildMart", table "dbo.Orders", column 'OrderId'. Name Stock OpenTransactions -------------- ----- ---------------- Cordless drill 12 0
GO), so it still runs.
The full pattern: XACT_ABORT with TRY…CATCH
SET XACT_ABORT ON; BEGIN TRY BEGIN TRANSACTION; UPDATE dbo.Products SET Stock = Stock - 1 WHERE ProductId = 2; INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice) VALUES (99, 2, 1, 89.00); COMMIT;END TRYBEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK; PRINT CONCAT('Rolled back: ', ERROR_MESSAGE()); THROW; -- pass the error on to the callerEND CATCH;Rolled back: The INSERT statement conflicted with the FOREIGN KEY constraint "FK__OrderItem__Order__412EB0B6". The conflict occurred in database "BuildMart", table "dbo.Orders", column 'OrderId'. Msg 547, Level 16, State 0, Line 8 The INSERT statement conflicted with the FOREIGN KEY constraint "FK__OrderItem__Order__412EB0B6". The conflict occurred in database "BuildMart", table "dbo.Orders", column 'OrderId'.
XACT_STATE() tells you more than @@TRANCOUNT:
| XACT_STATE() | Meaning | What you can do |
|---|---|---|
SET XACT_ABORT ON; BEGIN TRY BEGIN TRANSACTION; UPDATE dbo.Products SET Stock = Stock - 1 WHERE ProductId = 2; INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice) VALUES (99, 2, 1, 89.00);END TRYBEGIN CATCH SELECT XACT_STATE() AS XactState, @@TRANCOUNT AS OpenTransactions, ERROR_NUMBER() AS ErrorNumber; IF XACT_STATE() <> 0 ROLLBACK;END CATCH; SELECT XACT_STATE() AS XactState, @@TRANCOUNT AS OpenTransactions;XactState OpenTransactions ErrorNumber
--------- ---------------- -----------
-1 1 547
XactState OpenTransactions
--------- ----------------
0 0Nested transactions are not really nested
BEGIN TRANSACTION; -- @@TRANCOUNT 1 UPDATE dbo.Products SET Price = 20.00 WHERE ProductId = 1; BEGIN TRANSACTION; -- @@TRANCOUNT 2 UPDATE dbo.Products SET Price = 10.00 WHERE ProductId = 3; COMMIT; -- back to 1: nothing is committed yet SELECT @@TRANCOUNT AS OpenTransactions;ROLLBACK; -- undoes both updates SELECT ProductId, Name, Price FROM dbo.Products WHERE ProductId IN (1, 3);OpenTransactions
----------------
1
ProductId Name Price
--------- ---------------- -----
1 Claw hammer 18.50
3 Tape measure 8 m 9.75Savepoints: undo part of a transaction
SAVE TRANSACTION name and later run ROLLBACK TRANSACTION name. The changes before the savepoint stay, and the transaction stays open:
BEGIN TRANSACTION; INSERT INTO dbo.Orders (CustomerId, OrderDate, Status)VALUES (5, '2024-06-21', 'Pending'); -- order 11 SAVE TRANSACTION BeforeLines; INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice)VALUES (11, 12, 3, 6.80); ROLLBACK TRANSACTION BeforeLines; -- undo the line, keep the order SELECT @@TRANCOUNT AS OpenTransactions;COMMIT; SELECT o.OrderId, o.Status, (SELECT COUNT(*) FROM dbo.OrderItems AS i WHERE i.OrderId = o.OrderId) AS LinesFROM dbo.Orders AS oWHERE o.OrderId = 11;OpenTransactions
----------------
1
OrderId Status Lines
------- ------- -----
11 Pending 0ACID
| Letter | Promise | How SQL Server keeps it |
|---|---|---|
Show a hint
UPDATE p SET Stock = p.Stock + i.Quantity FROM dbo.Products AS p JOIN dbo.OrderItems AS i ON ….
Show the solution
SET XACT_ABORT ON; BEGIN TRY BEGIN TRANSACTION; UPDATE dbo.Orders SET Status = 'Cancelled' WHERE OrderId = 9; UPDATE p SET Stock = p.Stock + i.Quantity FROM dbo.Products AS p JOIN dbo.OrderItems AS i ON i.ProductId = p.ProductId WHERE i.OrderId = 9; COMMIT;END TRYBEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK; THROW;END CATCH; SELECT o.OrderId, o.Status, p.Name, p.StockFROM dbo.Orders AS oJOIN dbo.OrderItems AS i ON i.OrderId = o.OrderIdJOIN dbo.Products AS p ON p.ProductId = i.ProductIdWHERE o.OrderId = 9;OrderId Status Name Stock
------- --------- ---------------- -----
9 Cancelled Wall plugs (100) 4Remember
Each statement is its own transaction (autocommit): it changes all its rows or none. BEGIN TRANSACTION … COMMIT makes several statements one unit; ROLLBACK undoes them. Most errors end only the statement, not the transaction: the batch goes on and COMMIT commits the rest. SET XACT_ABORT ON makes any error roll back the whole transaction and stop the batch. The standard shape: SET XACT_ABORT ON; BEGIN TRY … COMMIT END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK; THROW; END CATCH. XACT_STATE(): 1 open, 0 none, -1 doomed (roll back only). Nested BEGIN TRANSACTION only counts; ROLLBACK undoes everything. SAVE TRANSACTION allows a partial undo. ACID: atomic, consistent, isolated, durable. Durability comes from the transaction log being on disk before COMMIT returns.