原本打算寫有關 SSIS Package 中的事務控制過程的,但是發現很多基本的概念還是需要有 SQL Server 事務和事務的隔離級別做基礎鋪墊。所以花了點時間,把 SQL Server 資料庫中的事務概念,ACID 原則,事務中常見的問題,問題造成的原因和事務隔離級別等這些方面的知識好好的整理了一下。

其實有關 SQL Server 中的事務,說實話因為內容太多, 話題太廣,稍微力度控制不好就超過了我目前知識能力範圍,就不是三言兩語能夠講清楚的。所以希望大家能夠指出其中總結的不足之處,對我來說多了提高的機會,更可以幫助大家加深對事務的理解。

本文涉及到的知識點:

  • SQL Server 資料庫中事務的概念
  • ACID 原則 (加了一部分內容專門解釋原子性,提到了顯示事務以及XACT_ABORT機制來確保事務的原子性)
  • 列出事務中常見的問題以及原因:髒讀,未提交讀,不可重複讀,幻讀
  • SQL Server中 事務的隔離級別以及它們如何做到避免髒讀,未提交讀,不可重複讀和幻讀(用程式碼描述了這些問題,並且使用時間序來解釋產生的原因)

SQL Server 資料庫中事務的概念

資料庫中的事務是資料庫併發控制的基本單位,一條或者一組語句要麼全部成功,對資料庫中的某些資料成功修改; 要麼全部不成功,資料庫中的資料還原到這些語句執行

之前的樣子。比如網上訂火車票,要麼你定票成功,餘票顯示就減一張; 要麼你定票失敗獲取取消訂票,餘票的數量還是那麼多。不允許出現你訂票成功了,餘票沒有減少或者你取消訂票了,餘票顯示卻少了一張的這種情況。這種不被允許出現的情況就要求購票和餘票減少這兩個不同的操作必須放在一起,成為一個完整的邏輯鏈,這樣就構成了一個事務。

資料庫中事務的 ACID 原則

原子性 (Atomicity):事務的原子性是指一個事務中包含的一條語句或者多條語句構成了一個完整的邏輯單元,這個邏輯單元具有不可再分的原子性。這個邏輯單元要麼一起提交執行全部成功,要麼一起提交執行全部失敗。

一致性 (Consistency):可以理解為資料的完整性,事務的提交要確保在資料庫上的操作沒有破壞資料的完整性,比如說不要違背一些約束的資料插入或者修改行為。一旦破壞了資料的完整性,SQL Server 會回滾這個事務來確保資料庫中的資料是一致的。

隔離性(Isolation):與資料庫中的事務隔離級別以及鎖相關,多個使用者可以對同一資料併發訪問而又不破壞資料的正確性和完整性。但是,並行事務的修改必須與其它並行事務的修改相互獨立,隔離。 但是在不同的隔離級別下,事務的讀取操作可能得到的結果是不同的。

永續性(Durability):資料持久化,事務一旦對資料的操作完成並提交後,資料修改就已經完成,即使服務重啟這些資料也不會改變。相反,如果在事務的執行過程中,系統服務崩潰或者重啟,那麼事務所有的操作就會被回滾,即回到事務操作之前的狀態。

我理解在極端斷電或者系統崩潰的情況下,一個發生在事務未提交之前,資料庫應該記錄了這個事務的"ID"和部分已經在資料庫上更新的資料。供電恢復資料庫重新啟動之後,這時完成全部撤銷和回滾操作。如果在事務提交之後的斷電,有可能更改的結果沒有正常寫入磁碟持久化,但是有可能丟失的資料會通過事務日誌自動恢復並重新生成以寫入磁碟完成持久化。

原子性的進一步理解

關於原子性,有必要在這裡多補充一下,因為我們描述的概念是指在事務中的原子性。一條 SQL 語句和多條 SQL 語句在處理原子性上是有一些區別的,下面演示了這些區別。

先執行這些程式碼,建立一個非常簡單的測試表,這張表只簡單模擬了一個賬戶的 ID 和賬戶餘額。

USE BIWORK_SSIS
GO

IF OBJECT_ID('dbo.Account') IS NOT NULL
DROP TABLE dbo.Account
GO

CREATE TABLE dbo.Account
(
  ID INT PRIMARY KEY,
  AccountBalance MONEY CHECK(AccountBalance >= 0)
)
.

單條 SQL 語句的原子性

插入一條測試語句,然後再查詢一下結果。

這裡提到了自動提交事務,這時 T-SQL 預設的事務方式,它是一種能夠自動執行並能夠自動回滾事務的處理方式。SQL Server 除了自動提交事務之外,還有顯示事務和隱式事務,暫時不在這篇文章中討論它們的區別了。

上面的兩個自動提交事務中,每一個自動提交事務只包含一條 SQL 語句,不能再分,要麼成功,要麼失敗。

再比如,在一條 SQL 語句中插入多條資料時,其中一條資料是符合約束的。但因為另外一條資料違反了檢查約束,這樣也會導致整個 Insert 語句失敗,因此沒有一條資料能夠插入到資料表中。

多條 SQL 語句形成的一個整體的原子性

假設下面的這兩條 Insert 語句構成一個具備原子性特徵的邏輯單元,是一個整體需要形成一個事務,那麼應該如何處理。

INSERT INTO dbo.Account VALUES(1004,-1)
INSERT INTO dbo.Account VALUES(1005,500)

很顯然如果直接這麼執行的話,1004 插入失敗,1005 可以插入成功,這樣就是兩個不同的事務了。SQL Server 提供了兩種方式來確保這種包含多組 SQL 語句的邏輯塊具備原子性特徵。

方式一 - 使用顯示事務組合多條 SQL 語句構成一個整體以實現事務的原子性

第一種就是非常常見的顯示事務,通過顯示的使用 BEGIN TRANSACTION, COMMIT TRANSACTION 以及 ROLLBACK TRANSACTION 命令將一組 SQL 語句形成一個完整的事務來提交,提交要麼成功,要麼失敗。

-- 開始一個事務
BEGIN TRANSACTION

-- TRY CATCH 語句
BEGIN TRY

 -- 這一條會違反檢查約束,插入失敗
    INSERT INTO dbo.Account VALUES(1004,-1)
 -- 這一條會插入成功,但此時事務還未真正提交
    INSERT INTO dbo.Account VALUES(1005,500)

END TRY
BEGIN CATCH
 -- 發生錯誤,事務回滾
    IF @@TRANCOUNT > 0
        ROLLBACK TRANSACTION;
END CATCH;

-- 沒有進入 CATCH 塊,提交事務
IF @@TRANCOUNT > 0
    COMMIT TRANSACTION;
GO

當然最終的結果就是事務回滾,一條資料都沒有插入到資料表中,所以失敗時就全部失敗,確保了事務的原子性。

方式二 - 通過設定  XACT_ABORT 為 ON 來確保事務的原子性

先來看預設的設定,當  XACT_ABORT 為 OFF 狀態的時候。

-- SET XACT_ABORT OFF - 預設的 SQL Server 設定
SET XACT_ABORT OFF
BEGIN TRANSACTION
 -- 這一條會違反檢查約束,插入失敗
    INSERT INTO dbo.Account VALUES(1004,-1)
 -- 這一條會插入成功
 INSERT INTO dbo.Account VALUES(1005,500)
COMMIT TRANSACTION

當  XACT_ABORT 為 OFF 狀態即 SQL Server 預設設定下,上面的事務中,SQL Server 在通常情況下只會回滾執行失敗的語句,也就是說只會回滾 1004 這條資料,而 1005 會插入成功。很顯然,這違背了事務的原子性,因為我們也沒有顯示的寫出要 ROLLBACK TRANSACTION 來。

OK!那我們將 XACT_ABORT 設定為 ON,這時就告訴了它後面的事務,如果遇到錯誤就立即終止事務並回滾。這樣不通過顯示的 ROLLBACK TRANSACTION 也可以確保事務的原子性。

在上面的這個例子中,只有事務 2 會成功提交,而事務1和3會回滾,插入操作執行失敗。

注意一點,上面的每個事務後面加了一個 GO 關鍵字,如果不加 GO 這個關鍵字,一起執行這些 SQL 語句會導致事務2和3因為事務1的執行失敗而不能執行到, GO 關鍵字形成了一個批處理,表示前面的一組 SQL 語句一起處理。

GO 關鍵字非常有意思,GO 後面可以加上次數,表示前面的一條或者一組 SQL 執行幾次。

通過上面的示例,應該可以理解原子性與事務的關係了,以及如何實現事務的原子性。

事務中常見的問題

瞭解完事務的 ACID 的原則後,再來看看在 SQL Server 中多使用者併發的情況下,使用事務可能會遇到的一些情況:

髒讀 (Dirty Reads) :一個事務正在訪問並修改資料庫中的資料但是沒有提交,但是另外一個事務可能讀取到這些已作出修改但未提交的資料。這樣可能導致的結果就是所有的操作都有可能回滾,比如第一個事務對資料做出的修改可能違背了資料表的某些約束,破壞了完整性,但是恰巧第二個事務卻讀取到了這些不正確的資料造成它自身操作也發生失敗回滾。

不可重複讀取(Non-Repeatable Reads):  A 事務兩次讀取同一資料,B事務也讀取這同一資料,但是 A 事務在第二次讀取前B事務已經更新了這一資料。所以對於A事務來說,它第一次和第二次讀取到的這一資料可能就不一致了。

幻讀(Phantom Reads):與不可重複讀有點類似,都是兩次讀取,不同的是 A 事務第一次操作的比如說是全表的資料,此時 B 事務並不是只修改某一具體資料而是插入了一條新資料,而後 A 事務第二次讀取這全表的時候就發現比上一次多了一條資料,發生幻覺了。

更新丟失(Lost Update):兩個事務同時更新,但由於某一個事務更新失敗發生回滾操作,這樣有可能的結果就是第二個事務已更新的資料因為第一個事務發生回滾而導致資料最終沒有發生更新,因此兩個事務的更新都失敗了。

SQL Server 中事務的隔離級別以及與髒讀,不可重複讀,幻讀等關係(程式碼論證和時間序)

瞭解了在併發訪問資料庫的情況下可能會出現這些問題,就可以繼續瞭解資料庫隔離級別這樣的一個概念,通俗一點講就是:你希望通過何種方式讓併發的事務隔離開來,隔離到什麼程度?比如可以容忍髒讀,或者不希望併發的事務出現髒讀的情況,那麼這些可以通過隔離級別的設定使得併發事務之間的隔離程度變得寬鬆或者很嚴峻。

隔離級別越高,讀取髒資料或者造成資料不統一不完整的機會就越少,但是在高併發的系統中,效能降低就越嚴重。隔離級別越低,併發系統中效能上提升很大,但是資料本身可能不完整。

在 SQL Server 2012 中可以通過這樣的語法來設定事務的隔離級別 (從低到高排列):

SET TRANSACTION ISOLATION LEVEL
    { READ UNCOMMITTED
    | READ COMMITTED
    | REPEATABLE READ
    | SNAPSHOT
    | SERIALIZABLE
    }
[ ; ]

下面通過程式碼示例來演示各個事務隔離級別的表現,執行下面 SQL 語句,插入一條測試語句。

TRUNCATE TABLE BIWORK_SSIS.dbo.Account
GO

INSERT INTO BIWORK_SSIS.dbo.Account VALUES(1001,1000)

SELECT * FROM BIWORK_SSIS.dbo.Account
GO

Read Uncommitted (未提交讀)

隔離級別最低,容易產生的問題就是髒讀,因為可以讀取其它事務修改了的但是沒有提交的資料。它的作用跟在事務中 SELECT 語句物件表上設定 (NOLOCK) 相同。

開啟兩個查詢視窗,第一個視窗表示事務 A, 第二個視窗表示事務B。 事務A 保持預設的隔離級別,事務B 設定它們的隔離級別為 READ UNCOMMITTED, 可以通過 DBCC USEROPITIONS 檢視更改後的結果。

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED 
DBCC USEROPTIONS

測試步驟:

先執行事務 A 的 SQL 程式碼

BEGIN TRANSACTION

UPDATE BIWORK_SSIS.dbo.Account
SET AccountBalance = 500 
WHERE ID  = 1001

WAITFOR DELAY '00:00:10'

ROLLBACK TRANSACTION

SELECT * FROM BIWORK_SSIS.dbo.Account WHERE ID = 1001

馬上接著再執行 事務 B 的 SQL 程式碼 

-- 第1次查詢 發生在 A 事務未提交或者回滾之前
SELECT * FROM BIWORK_SSIS.dbo.Account WHERE ID = 1001

WAITFOR DELAY '00:00:10'

-- 第2次查詢 發生在 A 事務回滾之後
SELECT * FROM BIWORK_SSIS.dbo.Account WHERE ID = 1001

可以看出,事務 B 對 ID = 1001 的這條資料進行了兩次讀取,但是很顯然第一次讀取的資料是髒資料。下面模擬了一下它們發生的時序,雖然不算嚴謹,但是可以幫助理解髒讀產生的原因。

還可以把事務B 的隔離級別改回來成為預設的  READ COMMITTED,然後執行完事務 A 之後馬上執行帶有 NOLOCK 的查詢,效果和上面描述的也是一致的。 一旦加上 NOLOCK,可以認為它的作用就等同於隔離級別為 READ UNCOMMITTED。

SELECT * FROM BIWORK_SSIS.dbo.Account WITH(NOLOCK) WHERE ID = 1001

Read Committed (已提交讀)

這是 SQL Server 的預設設定,已提交讀,可以避免髒讀,可以滿足大多數要求。事務中的語句不能讀取已由其它事務做出修改但是還未提交的資料,但是能夠讀取由其它事務做出修改並提交了的資料。也就是說,有可能會出現 Non-Repeatable Reads 不可重複讀取和 Phantom Reads 幻讀的情況,因為當前事務中可能出現兩次讀取同一資源,但是兩次讀取的過程之間,另外一事務可能對這一資源完成了讀取更新並提交的行為,這樣資料前後可能就不一致了。因此,這一個預設的隔離級別能夠解決髒讀但是解決不了 Non-Repeatable Reads 不可重複讀。

接著上一個例子,看看如果將隔離級別設定為 READ COMMITTED,能否避免髒讀? 還是先執行事務 A,再接著執行事務 B。

因為已提交讀不能讀取已由其它事物做出修改但是還未提交的資料,因此事務B 就必須等待事務 A 完成對資料的修改提交或者回滾之後才能開始讀取。執行事務A 和事務B,明顯事務B 有一個等待事務A提交或者回滾的過程,看看它們的時序圖。

由此可以看出隔離級別 READ COMMITTED 可以避免髒讀,但是也有可能出現其它的問題,請看這個例子。先執行事務A,接著直接執行事務 B。

從上面的執行結果來看,很明顯在事務 A 中,同一個事務中對 ID  = 1001 的取值出現了前後不一致的情況。假設這裡不是簡單的查詢,而是先查詢賬戶餘額有 1000元錢,然後後面的動作就是取 1000元錢,很明顯第二次取的時候發現只有 500 元了。原因就是在第一次查詢和取的間隙之間被事務 B 鑽了空子,修改了餘額。這種情況就是上面所介紹到的不可重複讀取,請看下面的時序圖。

所以 READ COMMITTED 已提交讀隔離級別能夠避免髒讀,但是仍然會遇到不可重複讀取的問題。

Repeatable Read (可重複讀)

不能讀取已由其它事務修改了但是未提交的行,其它任何事務也不能修改在當前事務完成之前由當前事務讀取的資料。但是對於其它事務插入的新行資料,當前事務第二次訪問錶行時會檢索這一新行。因此,這一個隔離級別的設定解決了 Non-Repeatable Reads 不可重複讀取的問題,但是避免不了 Phantom Reads 幻讀。

接著上面的例子做出一些修改,增加了一些查詢,記得把 ID = 1001 的餘額改回 1000。將事務 A 的隔離級別設定為 REPEATABLE READ 可重複讀級別,來看看這個隔離級別的表現。

儘管在最後的查詢結果中, ID  = 1001 的餘額為 500 元,但是在事務 A 中的兩次讀取一次發生在 事務 B 開始之前,一次發生在 事務 B 提交之後,但是它們讀取的餘額是保持一致的,看不到事務 B 對這個值的修改。

從上面的時序圖中可以看出,事務 A 第一次讀取到的 ID = 1001 的餘額值和第二次讀取到的是一樣的,可以理解為在事務 A 的查詢期間是不允許事務 B 修改這個值的。 因為事務 A 確實沒有看到這個變化,所以事務A 也確實認為事務B 聽了它的話,沒有做出 Update 的操作。但是實際上,事務 B 已經完成了這個操作,只不過由於 事務 A 中隔離級別設定為 REPEATABLE READ 可重複讀,所以兩次讀取的結果始終保持著一致。

那麼這裡的示例是事務B在修改資料,如果是新增加一行記錄呢?

事務 A 又開始暈菜了!居然兩次查詢的結果不一樣,第二次查詢多了一條資料,這就是幻讀!

SNAPSHOT (快照隔離)

可以解決幻讀 Phantom Reads 的問題,當前事務中讀取的資料在整個事務開始到事務提交結束之間,這個資料版本是一致的。其它的事務可能對這些資料做出修改,但是對於當前事務來說它是看不到這些變化。有點類似於當前事務拿到這個資料的時候是拿到這個資料的快照,因此在這個快照上做出的操作同一事務中前後幾次操作都是基於同一資料版本。因此,這一個隔離級別的設定可以解決 Phantom Reads 幻讀問題。但是要注意的是,其它事務是可以在當前事務完成之前修改由當前事務讀取的資料。

在使用 SNAPSHOT 之前要注意,預設情況下資料庫不允許設定 SNAPSHOT 隔離級別,直接設定會出現類似於這樣的錯誤:

DBCC execution completed. If DBCC printed error messages, contact your system administrator.

Msg 3952, Level 16, State 1, Line 8

Snapshot isolation transaction failed accessing database 'BIWORK_SSIS' because snapshot isolation is not allowed in this database. Use ALTER DATABASE to allow snapshot isolation.