關於事務嵌套,以前了解一些,但總是屬於模棱兩可,平時處理這種問題時候也是依照前人的經驗,但至於為什么這么做,還真是“知其然不知其所以然”。(博客園的代碼展開為什么不能展開呢?還請各位大俠指點!)
今天一個同事問我關於事務的問題,我就用代碼給他舉例測試,在測試的過程中我遇到了一點小問題,但在繼續測試的時候,我解決了這個問題,也讓我對事務的嵌套有了更加深刻的認識。
廢話不再多說了,開始正題。
本文的目的是跟大家討論一下關於嵌套事務的相關問題,所以有關事務的基礎知識和概念,本文假設讀者已經了解。
嵌套事務一般的使用場景是一些公用的,最小單元的業務邏輯,這些業務邏輯很多情況下都是被另外一些更加復雜,更加完整的業務邏輯調用。
為了更加貼近實際,本文的例子盡量接近真實業務,在此我們拿一個電子商務網站的訂單支付來進行舉例,具體例子如下
提交訂單之后,支付訂單(扣除賬戶余額)並更新訂單的狀態。
根據業務,我們創建三個表
會員表
賬戶變動記錄表
訂單表
建表語句如下:
CREATE TABLE T_Users( Id INT IDENTITY(1,1) PRIMARY KEY NOT NULL,--自增編號 UserName NVARCHAR(50) NOT NULL,--用戶名,保持唯一 UserMoney DECIMAL(9,2) NOT NULL DEFAULT 0--用戶賬戶余額,不能小於 ) CREATE TABLE T_MoneyLog( Id INT IDENTITY(1,1) PRIMARY KEY NOT NULL,--自增編號 UserName NVARCHAR(50) NOT NULL, --用戶名 ChangeType INT NOT NULL,--賬戶變動類型(1支付訂單,發送短信,提交參會申請) MoneyBefore DECIMAL(9,2) NOT NULL DEFAULT 0, --會員賬戶變動前余額 ChangeMoney DECIMAL(9,2) NOT NULL DEFAULT 0, --變動的金額 MoneyAfter DECIMAL(9,2) NOT NULL DEFAULT 0, --會員賬戶變動后余額 Remark NVARCHAR(100), --賬戶變動備注 AddTime DATETIME NOT NULL DEFAULT GETDATE() --變動時間 ) CREATE TABLE T_Order( Id INT IDENTITY(1,1) NOT NULL, --自增編號 OrderId VARCHAR(20) NOT NULL PRIMARY KEY,--訂單號 SumMoney DECIMAL(9,2) NOT NULL DEFAULT 0,--訂單總共需要支付費用 OrderStatus INT NOT NULL DEFAULT 0,--訂單狀態(未支付,已支付) AddTime DATETIME NOT NULL DEFAULT GETDATE(),--訂單提交時間 PayTime DATETIME NULL--訂單支付時間 )
注:因為是測試,所以這里簡化了這些表。
因為扣除賬戶余額是一個公用的,最小單元的業務邏輯,所以我們專門建立一個存儲過程來執行該過程。代碼如下:
Create Proc [dbo].[Sp_UserMoneyLess] @UserName NVARCHAR(50), --用戶名 @ChangeMoney DECIMAL(9,2), --變動金額 @ChangeType INT, --賬戶變動類型 @Result INT OUTPUT, --輸出參數,執行結果 @Msg NVARCHAR(100) OUTPUT --執行結果描述 AS --為輸出參數設置默認值 SET @Result = 1 SET @Msg = '執行成功' DECLARE @before DECIMAL(9,2),@after DECIMAL(9,2) --開啟事務 BEGIN TRAN UserMoneyLess SELECT @before=tu.UserMoney FROM T_Users tu WHERE tu.UserName=@UserName SET @after=@before-@ChangeMoney IF @after<0 BEGIN SET @Result=-1 SET @Msg = '賬戶余額不足' ROLLBACK TRAN UserMoneyLess RETURN END --更新會員賬戶余額 UPDATE T_Users SET UserMoney = @after WHERE UserName = @UserName IF @@ERROR<>0 BEGIN SET @Result = -2 SET @Msg = '更新賬戶余額發生異常,異常信息:' + ERROR_MESSAGE() ROLLBACK TRAN UserMoneyLess RETURN END --產生賬戶變動記錄 INSERT INTO T_MoneyLog ( UserName, ChangeType, MoneyBefore, ChangeMoney, MoneyAfter, Remark, AddTime ) VALUES ( @UserName, @ChangeType, @before, @ChangeMoney, @after, '支付訂單扣除賬戶余額', GETDATE() ) IF @@ERROR<>0 BEGIN SET @Result = -3 SET @Msg = '產生賬戶變動記錄發生異常,異常信息:' + ERROR_MESSAGE() ROLLBACK TRAN UserMoneyLess END COMMIT TRAN UserMoneyLess
現在我們來測試一下我們的Sp_UserMoneyLess,在測試之前,我們插入一些測試數據:
INSERT INTO T_Users ( UserName, UserMoney ) VALUES ( 'Geodon', 100 ) INSERT INTO T_Order ( -- Id -- this column value is auto-generated OrderId, UserName, SumMoney, OrderStatus, AddTime ) VALUES ( '20130303160545612', 'Geodon', 120, 0, GETDATE() )
好了,測試數據已經准備完畢。現在我們可以開始我們的測試了,執行下面的測試代碼:
DECLARE @Result INT,@Msg NVARCHAR(200) EXEC Sp_UserMoneyLess 'Geodon',120,1,@Result OUTPUT,@Msg OUTPUT SELECT @Result,@Msg
運行結果:
Ok沒問題,是我們想要的結果。
再進行一次測試,測試余額足夠的情況,執行下面的測試代碼:
DECLARE @Result INT,@Msg NVARCHAR(200) EXEC Sp_UserMoneyLess 'Geodon',10,1,@Result OUTPUT,@Msg OUTPUT SELECT @Result,@Msg
運行結果:
我們查詢一下執行結果
Ok沒問題,正常執行。
接下來我們再為訂單支付創建一個存儲過程,代碼如下:
CREATE PROC Sp_PayOrder @OrderId VARCHAR(20), --訂單號 @UserName NVARCHAR(50), --用戶名 @Result INT OUTPUT, --支付結果 @Msg NVARCHAR(100) OUTPUT --支付結果描述 AS --為輸出參數設置默認值 SET @Result=1 SET @Msg='執行成功' --查詢訂單需要支付的金額,如果訂單號不存在或者該訂單支付過,返回-1,停止執行 DECLARE @orderMoney DECIMAL(9,2) SELECT @orderMoney = to1.SumMoney FROM T_Order to1 WHERE to1.OrderId=@OrderId AND to1.OrderStatus=0 AND to1.UserName=@UserName IF @orderMoney IS NULL BEGIN SET @Result=-1 SET @Msg='訂單號不存在或者該訂單支付過' RETURN END --開啟事務 BEGIN TRAN PayOrder --扣除會員賬戶余額 EXEC Sp_UserMoneyLess @UserName,@orderMoney,1,@Result OUTPUT,@Msg OUTPUT IF @Result<>1 BEGIN SET @Result=-2 ROLLBACK TRAN PayOrder RETURN END --更新訂單支付狀態 UPDATE T_Order SET OrderStatus = 1 WHERE OrderId=@OrderId AND OrderStatus=0 COMMIT TRAN PayOrder
這個存儲過程包含了一個事務,而且在事務的內部又調用了Sp_UserMoneyLess,而Sp_UserMoneyLess這個存儲過程內部又包含了一個事務,這就出現了事務嵌套的場景,這也正是本文要討論的內容。
我們現在進行測試,執行如下測試代碼:
DECLARE @Result INT,@Msg NVARCHAR(200) EXEC Sp_PayOrder '20130303160545612','Geodon',@Result OUTPUT,@Msg OUTPUT SELECT @Result,@Msg
運行結果如下:
大家可以看到,執行存儲過程出現了錯誤
我們先來看一下第一個錯誤
可以看的出,這個錯誤是在內部事務回滾帶有名稱的事務的時候,發現沒有該名稱的事務或保存點,因為會引發這個異常,為什么會這樣呢?大家看一下微軟的解釋:
ROLLBACK TRANSACTION 語句的 transaction_name 參數引用一組命名嵌套事務的內部事務是非法的,transaction_name 只能引用最外部事務的事務名稱。
哦!原來Sql Server不允許我們在內部的事務中包含事務名稱。那好,我們現在就把這個事務名稱去掉,代碼修改如下:
ALTER PROC [dbo].[Sp_UserMoneyLess] @UserName NVARCHAR(50), --用戶名 @ChangeMoney DECIMAL(9,2), --變動金額 @ChangeType INT, --賬戶變動類型 @Result INT OUTPUT, --輸出參數,執行結果 @Msg NVARCHAR(100) OUTPUT --執行結果描述 AS --為輸出參數設置默認值 SET @Result = 1 SET @Msg = '執行成功' DECLARE @before DECIMAL(9,2),@after DECIMAL(9,2) --開啟事務 BEGIN TRAN SELECT @before=tu.UserMoney FROM T_Users tu WHERE tu.UserName=@UserName SET @after=@before-@ChangeMoney IF @after<0 BEGIN SET @Result=-1 SET @Msg = '賬戶余額不足' ROLLBACK TRAN RETURN END --更新會員賬戶余額 UPDATE T_Users SET UserMoney = @after WHERE UserName = @UserName IF @@ERROR<>0 BEGIN SET @Result = -2 SET @Msg = '更新賬戶余額發生異常,異常信息:' + ERROR_MESSAGE() ROLLBACK TRAN RETURN END --產生賬戶變動記錄 INSERT INTO T_MoneyLog ( UserName, ChangeType, MoneyBefore, ChangeMoney, MoneyAfter, Remark, AddTime ) VALUES ( @UserName, @ChangeType, @before, @ChangeMoney, @after, '支付訂單扣除賬戶余額', GETDATE() ) IF @@ERROR<>0 BEGIN SET @Result = -3 SET @Msg = '產生賬戶變動記錄發生異常,異常信息:' + ERROR_MESSAGE() ROLLBACK TRAN END COMMIT TRAN
再次執行測試代碼:
DECLARE @Result INT,@Msg NVARCHAR(200) EXEC Sp_PayOrder '20130303160545612','Geodon',@Result OUTPUT,@Msg OUTPUT SELECT @Result,@Msg
從結果中我們發現除了上次的第二個錯誤之外,又產生了另外一個錯誤:
為什么又出現了這個錯誤呢?從這個錯誤我們可以分析出錯誤的原因是在外部的事務中回滾事務的時候沒有找到對應的Begin Tran,可我們的代碼中明明有 Begin Tran PayOrder啊,為什么還會出現這個錯誤呢?在此我找到了微軟的解釋:
如果在一組嵌套事務的任意級別執行使用外部事務名稱的 ROLLBACK TRANSACTION transaction_name 語句,那么所有嵌套事務都將回滾。如果在一組嵌套事務的任意級別執行沒有transaction_name 參數的 ROLLBACK WORK 或 ROLLBACK TRANSACTION 語句,那么所有嵌套事務都將回滾,包括最外部事務。
原來內部的事務中如果執行了沒有事務名稱的回滾,會將所有的嵌套事務,包括最外層的事務都回滾。那怎么辦呢?難道我們不能使用嵌套事務?你可以能會說:不可能啊,應該是可以的啊!呵呵,是的,我們當然可以使用嵌套事務。解決這個問題有兩種方法:
第一種:利用Commit Tran的原理,內部事務任何時候進行Commit tran,不管數據異常與否,如果出現異常數據,返回異常數據提示就可以。
這種方法來源於微軟的一段解釋:
SQL Server 數據庫引擎將忽略內部事務的提交。根據最外部事務結束時采取的操作,將提交或者回滾內部事務。如果提交外部事務,也將提交內部嵌套事務。如果回滾外部事務,也將回滾所有內部事務,不管是否單獨提交過內部事務。
也就是說,最終的事務是否提交,決定權在外部的事務,即使內部事務進行了提交,只要外部事務根據內部返回的值來決定提交或者回滾,就可以把外部和所有嵌套的事務都提交或者回滾。
好了,我們把代碼修改如下:
ALTER PROC [dbo].[Sp_UserMoneyLess] @UserName NVARCHAR(50), --用戶名 @ChangeMoney DECIMAL(9,2), --變動金額 @ChangeType INT, --賬戶變動類型 @Result INT OUTPUT, --輸出參數,執行結果 @Msg NVARCHAR(100) OUTPUT --執行結果描述 AS --為輸出參數設置默認值 SET @Result = 1 SET @Msg = '執行成功' DECLARE @before DECIMAL(9,2),@after DECIMAL(9,2) --開啟事務 BEGIN TRAN SELECT @before=tu.UserMoney FROM T_Users tu WHERE tu.UserName=@UserName SET @after=@before-@ChangeMoney IF @after<0 BEGIN SET @Result=-1 SET @Msg = '賬戶余額不足' COMMIT TRAN RETURN END --更新會員賬戶余額 UPDATE T_Users SET UserMoney = @after WHERE UserName = @UserName IF @@ERROR<>0 BEGIN SET @Result = -2 SET @Msg = '更新賬戶余額發生異常,異常信息:' + ERROR_MESSAGE() COMMIT TRAN RETURN END --產生賬戶變動記錄 INSERT INTO T_MoneyLog ( UserName, ChangeType, MoneyBefore, ChangeMoney, MoneyAfter, Remark, AddTime ) VALUES ( @UserName, @ChangeType, @before, @ChangeMoney, @after, '支付訂單扣除賬戶余額', GETDATE() ) IF @@ERROR<>0 BEGIN SET @Result = -3 SET @Msg = '產生賬戶變動記錄發生異常,異常信息:' + ERROR_MESSAGE() END COMMIT TRAN
然后我們再次執行訂單支付的模擬:
DECLARE @Result INT,@Msg NVARCHAR(200) EXEC Sp_PayOrder '20130303160545612','Geodon',@Result OUTPUT,@Msg OUTPUT SELECT @Result,@Msg
運行結果
Ok,出現了我們想要的結果。
第二種,利用事務保存點來解決。
該方法利用在內部嵌套中增加一個事務保存點(Save Tran savepoint_name),然后在內部嵌套中需要進行回滾的地方執行Rollback Tran savepoint_name這樣,就可以把事務回滾到savepoint_name這個保存點,好了,了解了原理,我們修改代碼如下:
ALTER PROC [dbo].[Sp_UserMoneyLess] @UserName NVARCHAR(50), --用戶名 @ChangeMoney DECIMAL(9,2), --變動金額 @ChangeType INT, --賬戶變動類型 @Result INT OUTPUT, --輸出參數,執行結果 @Msg NVARCHAR(100) OUTPUT --執行結果描述 AS --為輸出參數設置默認值 SET @Result = 1 SET @Msg = '執行成功' DECLARE @before DECIMAL(9,2),@after DECIMAL(9,2) --開啟事務 SAVE TRAN UserMoneyLess SELECT @before=tu.UserMoney FROM T_Users tu WHERE tu.UserName=@UserName SET @after=@before-@ChangeMoney IF @after<0 BEGIN SET @Result=-1 SET @Msg = '賬戶余額不足' ROLLBACK TRAN UserMoneyLess RETURN END --更新會員賬戶余額 UPDATE T_Users SET UserMoney = @after WHERE UserName = @UserName IF @@ERROR<>0 BEGIN SET @Result = -2 SET @Msg = '更新賬戶余額發生異常,異常信息:' + ERROR_MESSAGE() ROLLBACK TRAN UserMoneyLess RETURN END --產生賬戶變動記錄 INSERT INTO T_MoneyLog ( UserName, ChangeType, MoneyBefore, ChangeMoney, MoneyAfter, Remark, AddTime ) VALUES ( @UserName, @ChangeType, @before, @ChangeMoney, @after, '支付訂單扣除賬戶余額', GETDATE() ) IF @@ERROR<>0 BEGIN SET @Result = -3 SET @Msg = '產生賬戶變動記錄發生異常,異常信息:' + ERROR_MESSAGE() ROLLBACK TRAN UserMoneyLess END
大家可以看到我們在原來事務起始的地方,增加了一個事務保存點SAVE TRAN UserMoneyLess,然后在下面回滾的地方,加上了保存點名稱。
再次執行測試代碼:
DECLARE @Result INT,@Msg NVARCHAR(200) EXEC Sp_PayOrder '20130303160545612','Geodon',@Result OUTPUT,@Msg OUTPUT SELECT @Result,@Msg
結果:
Ok,是我們想要的結果。
上面兩種方法解決了事務嵌套的問題。當然用上述的兩種方法所創建的嵌套存儲過程如果想要單獨使用,必須在外層嵌套一層事務或者在業務層調用這種存儲的時候,加上事務,各個ORM中都有事務的功能,如果你還在用DbHelper,也可以自己封裝一個事務處理類,在業務邏輯層進行事務處理。
上面我們利用一個實際業務場景來講解了事務嵌套的一些問題和一些處理方法,但因為個人知識有限,如本文中有理解不到位的地方,還請各位大俠們不吝賜教。