在他的關於 JTS 的系列文章的第 1 和第 2 部分,Brian 講述了一些基礎知識,包括什麼是事務以及 J2EE 容器如何使事務服務對 EJB 組件透明。盡管能夠以聲明的方式而不是編程的方式指定組件的事務性語義可以大大增強配置企業應用程序時的靈活性,但在裝配應用程序時做出不當的決定會削弱應用程序的性能和穩定性。在這最後一部分,Brian 討論了 J2EE 提供的用來管理事務劃分和隔離的工具和一些高效率地使用這些工具的指導。請單擊文章頂部或底部的 討論,在 討論論壇與作者和其他讀者分享您對本文的想法。
在本系列的第 1 部分(“ An introduction to transactions”)和第 2 部分(“ The magic beind the scenes”)中,我們定義了什麼是事務,列舉了事務的基本特性(property),並探討了 Java 事務服務(Java Transaction Service)和 J2EE 容器如何合作為事務提供對 J2EE 組件的透明支持。在本文中,我們將討論事務的劃分和隔離這個主題。
為 EJB 組件定義事務劃分和隔離屬性(attribute)的職責由應用程序裝配人員來承擔。如果這些屬性設置不當,會對應用程序的性能、可伸縮性或容錯能力造成嚴重的後果。不幸的是,並沒有一種必須遵守的規則用於正確設置這些屬性,但有一些指導可以幫助我們在並發危險和性能危險之間找到一種平衡。
我們在第 1 部分中討論過,事務主要是一種異常處理機制。事務在程序中的用途與合法合同在日常業務中的用途相似:如果出了什麼問題它們可以幫助恢復。但由於大多數時間內都沒實際 發生什麼錯誤,我們就希望能夠盡量減少它們的開銷以及對其余時間的占用。我們在應用程序中如何使用事務會對應用程序的性能和可伸縮性產生很大的影響。
事務劃分
J2EE 容器提供了兩種機制用來定義事務的起點和終點:bean 管理的事務和容器管理的事務。在 bean 管理的事務中,用 UserTransaction.begin() 和 UserTransaction.commit() 在 bean 方法中顯式開始和結束一個事務。另一方面,容器管理的事務提供了更多的靈活性。通過在裝配描述符中為每個 EJB 方法定義事務性屬性,您可以指定每個方法的事務性需求並讓容器確定何時開始和結束一個事務。無論在哪種情況下,構建事務的基本指導方針都是一樣的。
進來,出去
事務劃分的第一條規則是“盡量短小”。事務提供並發控制;這通常意味著資源管理器將代表您獲得您在事務期間訪問的數據項的鎖,並且它必須一直持有這些鎖,直到事務結束。(請回憶一下本系列第 1 部分所討論的 ACID特性,其中“ACID”的“I”代表“隔離”(Isolation)。也就是說,一個事務的結果影響不到與該事務並發執行的其它事務。)當您擁有鎖時,任何需要訪問您鎖定的數據項的其它事務將不得不一直等待,直到您釋放鎖。如果您的事務很長,那些其它的所有事務都將被鎖定,您的應用程序吞吐量將大幅度下降。
規則 1:使事務盡可能短小。
通過使事務盡量短小,您可以把阻礙其它事務的時間縮到最短,從而提高應用程序的可伸縮性。保持事務盡可能短小的最好方法當然是不在事務中間做任何不必要耗費時間的事,特別是不要在事務中間等待用戶輸入。
開始一個事務,從數據庫檢索一些數據,顯示數據,然後在仍處於事務中時請用戶做出一個選擇可能比較誘人。千萬別這麼做!即使用戶注意力集中,也要花費數秒來響應 ― 而在數據庫中擁有鎖數秒的時間已經是很長的了。如果用戶決定離開計算機,或許是去吃午餐或者甚至回家一天,會發生什麼情況?應用程序將只好無奈停機。在事務期間執行 I/O 是導致災難的秘訣。
規則 2:在事務期間不要等待用戶輸入。
將相關的操作歸在一起
由於每個事務都有不小的開銷,您可能認為最好是在單個事務中執行盡可能多的操作以使每個操作的開銷達到最小。但規則 1 告訴我們長事務對可伸縮性不利。那麼如何實現最小化每個操作的開銷和可伸縮性之間的平衡呢?
我們把規則 1 設置為邏輯上的極端 ― 每個事務一個操作 ― 這樣不僅會導致額外開銷,還會危及應用程序狀態的一致性。假定事務性資源管理器維護應用程序狀態的一致性(請回憶一下第 1 部分,其中“ACID”的“C”代表“一致性”(Consistency)),但它們依賴應用程序來定義一致性的意思。實際上,我們在描述事務時使用的一致性的定義有點圓滑:應用程序說一致性是什麼意思它就是什麼意思。應用程序把幾組應用程序狀態的變化組織到幾個事務中,結果應用程序的狀態就成了 定義上的(by definition)一致。然後資源管理器確保如果它必須從故障恢復的話,就把應用程序狀態恢復到最近的一致狀態。
在第 1 部分中,我們給出了一個在銀行應用程序中將資金從一個帳戶轉移到另一個帳戶的示例。清單 1 展示了這個示例可能的 SQL 實現,它包含 5 個 SQL 操作(一個選擇,兩個更新和兩個插入操作):
清單 1. 資金轉移的樣本 SQL 代碼
SELECT accountBalance INTO aBalance
FROM Accounts WHERE accountId=aId;
IF (aBalance >= transferAmount) THEN
UPDATE Accounts
SET accountBalance = accountBalance - transferAmount
WHERE accountId = aId;
UPDATE Accounts
SET accountBalance = accountBalance + transferAmount
WHERE accountId = bId;
INSERT INTO AccountJournal (accountId, amount)
VALUES (aId, -transferAmount);
INSERT INTO AccountJournal (accountId, amount)
VALUES (bId, transferAmount);
ELSE
FAIL "Insufficient funds in account";
END IF
如果我們把這個操作作為五個單獨的事務來執行會發生什麼情況?這樣不僅會使執行速度變慢(由於事務開銷),還會失去一致性。例如,如果一個人從帳戶 A 取了錢,作為執行第一次 SELECT(檢查余額)和隨後的記入借方 UPDATE 之間的一個單獨事務的一部分,會發生什麼情況?這樣會違反我們認為這段代碼會強制遵守的業務規則 ― 帳戶余額應該是非負的。如果在第一次 UPDATE 和第二次 UPDATE 之間系統失敗會發生什麼情況?現在,當系統恢復時,錢已經離開了帳戶 A 但還沒有記入帳戶 B 的貸方,並且也無記錄說明原因。這樣,哪個帳戶的所有者都不會開心。
清單 1 中的五個 SQL 操作是單個相關操作 ― 將資金從一個帳戶轉移到另一個帳戶 ― 的一部分。因此,我們希望要麼全部執行它們,要麼一個也不執行,建議在單個事務中全部執行它們。
規則 3:將相關操作歸到單個事務中。
理想化的平衡
規則 1 說事務應盡可能短小。清單 1 中的示例表明有時候我們必須把一些操作歸到一個事務中來維護一致性。當然,它要依賴應用程序來確定“相關操作”是由什麼組成的。我們可以把規則 1 和 3 結合在一起,提供一個描述事務范圍的一般指導,我們規定它為規則 4:
規則 4:把相關操作歸到單個事務中,但把不相關的操作放到單獨的事務中。
容器管理的事務
在使用容器管理的事務時,不是顯式聲明事務的起點和終點,而是為每個 EJB 方法定義事務性需求。bean 的 assembly-descriptor 的 container-transaction 部分的 trans-attribute 元素中定義了事務模式。(清單 2 中顯示了一個 assembly-descriptor 示例。)方法的事務模式以及狀態 ― 調用方法是否早已在事務中被征用 ― 決定了當 EJB 方法被調用時容器應該進行下面幾個操作中的哪一個:
征用現有事務中的方法。
創建一個新事務,並征用該事務中的方法。
不征用任何事務中的方法。
拋出一個異常。
清單 2. 樣本 EJB 裝配描述符
<assembly-descriptor>
...
<container-transaction>
<method>
<ejb-name>MyBean</ejb-name>
<method-name>*</method-name>
</method>
<trans-attribute>Required</trans-attribute>
</container-transaction>
<container-transaction>
<method>
<ejb-name>MyBean</ejb-name>
<method-name>logError</method-name>
</method>
<trans-attribute>RequiresNew</trans-attribute>
</container-transaction>
...
</assembly-descriptor>
J2EE 規范定義了六種事務模式: Required 、 RequiresNew 、 Mandatory 、 Supports 、 NotSupported 和 Never 。表 1 概述了每種模式的行為 ― 在現有事務中被調用和不在事務內調用時的行為 ― 並描述了每種模式受哪些類型的 EJB 組件支持。(一些容器可能允許您在選擇事務模式時有更多的靈活性,但這種使用要依賴特定於容器的功能,因此不適合跨容器的情況)。
表 1. 事務模式
事務模式 Bean 類型 在事務 T 內被調用時的行為 在事務外被調用時的行為 Required 會話、實體、消息驅動 在 T 中征用 新建事務 RequiresNew 會話、實體 新建事務 新建事務 Supports 會話、消息驅動 在 T 中征用 不帶事務運行 Mandatory 會話、實體 在 T 中征用 出錯 NotSupported 會話、消息驅動 不帶事務運行 不帶事務運行 Never 會話、消息驅動 出錯 不帶事務運行
在只使用容器管理的事務的應用程序中,只有組件調用事務模式為 Required 或 RequiresNew 的 EJB 方法時才啟動事務。如果容器創建一個事務作為調用事務性方法的結果,當該方法完成時將關閉該事務。如果方法正常返回,容器將提交事務(除非應用程序已經要求回滾事務)。如果方法通過拋出一個異常退出,容器將回滾事務並傳播該異常。如果在現有事務 T 中調用了一個方法,並且事務模式指定應該不帶事務運行該方法或者在新事務中運行該方法,那麼事務 T 將被暫掛,一直到方法完成,然後先前的事務 T 被恢復。
選擇一種事務模式
那麼我們應該為自己的 bean 方法選擇哪種模式呢?對於會話 bean 和消息驅動 bean,您通常想使用 Required 來確保每個調用都被作為事務的一部分執行,但仍將允許方法作為一個更大的事務的組件。請小心使用 RequiresNew ;只有在確定自己的方法的行為應該與調用您的方法的行為分開提交時,才應該使用這種模式。 RequiresNew 一般情況下只和與系統中其它對象關系很少或沒什麼關系的對象(比如日志對象)一起使用。(把 RequiresNew 與日志對象一起使用比較有意義,因為您可能希望在不管外圍事務是否提交的情況下提交日志消息。)
RequiresNew 使用不當會導致與上面的描述相似的情況,其中,清單 1 中的代碼在五個分開的事務而不是一個事務中執行,這樣會使應用程序處於不一致狀態。
對於 CMP(容器管理的持久性,container-managed persistence)實體 bean,通常是希望使用 Required 。 Mandatory 也是一個合理的選項,特別是在最初開發時;這將會警告您實體 bean 方法在事務外被調用這種情況,這時可能會指出一個部署錯誤。您幾乎從不希望把 RequiresNew 和 CMP 實體 bean 一起使用。 NotSupported 和 Never 旨在用於非事務性資源,比如 Java 事務 API(Java Transaction API,JTA)事務中無法征用的外部非事務性系統或事務性系統的適配器。
如果 EJB 應用程序設計得當,應用上面的事務模式指導往往會自然地產生規則 4 建議的事務劃分。原因是 J2EE 體系架構鼓勵把應用程序分解為最小的方便處理的塊,並且每個塊都作為一個單獨的請求被處理( 不管是以 HTTP 請求的形式還是作為在 JMS 隊列中排隊的消息的結果)。
重溫隔離
在第 1 部分中,我們定義了 隔離(isolation)的意思是:一個事務的影響對與該事務並發執行的其它事務是不可見的;從事務的角度來看,好象事務是連續執行而非並行執行。盡管事務性資源管理器經常可以同時處理許多事務並提供隔離的假象,但有時隔離限制實際上要求把新事務延遲到現有事務完成後才開始。由於完成一個事務至少包括一個同步磁盤 I/O(寫到事務日志),這就會把每秒的事務數限制到接近每秒的寫磁盤次數,這對可伸縮性不利。
實際上,通常是充分放松隔離需求以允許更多的事務並發執行並使系統響應能夠得到改善,使可伸縮性變得更強。幾乎所有的數據庫都支持標准隔離級別:讀未提交的(Read Uncommitted)、讀已提交的(Read Committed)、可重復的讀(Repeatable Read) 和可串行化的(Serializable)。
不幸的是,為容器管理的事務管理隔離目前是在 J2EE 規范的范圍之外。但是,許多 J2EE 容器,比如 IBM WebSphere 和 BEA WebLogic,將提供特定於容器的擴展,這些擴展允許您以每方法(per-method)為基礎設置事務隔離級別,設置方法與在裝配描述符中設置事務模式的方法相同。對於 bean 管理的事務,您可以通過 JDBC 或者其它資源管理器連接設置隔離級別。
為闡明隔離級別之間的差異,我們首先把幾個並發危險分類 ― 這幾種危險是當沒有適當地隔離時一個事務可能會干涉另一個事務的情況。下列的所有這些危險都與這種情況( 第二個事務已經啟動後第一個事務變得對第二個事務 可見)的結果有關:
髒讀(Dirty Read):當一個事務的中間(未提交的)結果對另一個事務可見時就會發生這種情況。
不可重復的讀(Unrepeatable Read):當一個事務讀取一個數據項,然後重新讀取這個數據項並看到不同的值時就是發生了這種情況。
虛讀(Phantom Read):當一個事務執行返回多個行的查詢,稍後再次執行同一個查詢並看到第一次執行該查詢沒出現的額外行時就是發生了這種情況。
四個標准隔離級別與這三個隔離危險相關,如表 2 所示。最低的隔離級別“讀未提交的”並不能保護事務不被其它事務更改,但它的速度最快,因為它不需要爭奪讀鎖。最高的隔離級別“可串行化的”與上面給出的隔離的定義相當;每個事務好象都與其它事務的影響完全隔離。
表 2. 事務隔離級別
隔離級別 髒讀 不可重復的讀 虛讀 讀未提交的 是 是 是 讀已提交的 否 是 是 可重復的讀 否 否 是 可串行化的 否 否 否
對於大多數數據庫,缺省的隔離級別為“讀已提交的”,這是個很好的缺省選擇,因為它阻止事務在事務中的任何給定的點看到應用程序數據的不一致視圖。“讀已提交的”是一個很不錯的隔離級別,用於大多數典型的短事務,比如獲取報表數據或獲取要顯示給用戶的數據的時候(多半是作為 Web 請求的結果),也用於將新數據插入到數據庫的情況。
當您需要所有事務間有較高級別的一致性時,使用較高的隔離級別“可重復的讀”和“可串行化的”比較合適,比如在清單 1 示例中,您希望從檢查余額以確保有足夠的資金到您實際取錢期間賬戶余額一直保持不變;這就要求至少要用“可重復的讀”隔離級別。在數據一致性絕對重要的情況下,比如審核記帳數據庫以確保一個帳戶的所有借方金額和貸方金額的總數等於它目前的余額時,可能還需要防止創建新行。這種情況下就需要使用“可串行化的”隔離級別。
最低的隔離級別“讀未提交的”很少使用。它適用於您只需要獲得近似值,否則查詢將導致您不希望的性能開銷這種情況。當您想要估計一個變化很快的數量,如定單數或者今天所下定單的總金額(以美元為單位)時一般使用““讀未提交的”。
因為隔離和可伸縮性之間實際是一種此消彼長的關系,所以您在為事務選擇隔離級別時應該小心行事。選擇太低的級別對數據比較危險。選擇太高的級別可能對性能不利,盡管負載比較輕時可能不會這樣。一般來說,數據一致性問題比性能問題更嚴重。如果拿不准,應該以小心為主,選擇一個較高的隔離級別。這就引出了規則 5:
規則 5:使用保證數據安全的最低隔離級別,但如果拿不准,請使用“可串行化的”。
即使您打算剛開始時以小心為主並希望結果性能可以接受 ―(被稱為“拒絕和祈禱(denial and prayer)”的性能管理技術 ― 很可能是最常用的性能策略,盡管大多數開發者都不承認這一點),在開發組件時考慮隔離需求也是有利的。您應該努力編寫能夠容忍級別較低但實用的隔離級別的事務,這樣,當稍後性能成為問題時,自己就不會陷入困境。因為您需要知道方法正在做什麼以及這個方法中隱藏了什麼一致性假設來正確設置隔離級別,那麼在開發期間仔細說明並發需求和假設,以便在裝配應用程序時幫助作出正確的決定也不失為一個好主意。
結束語
本文中提供的許多指導可能看起來有點互相矛盾,因為象事務劃分和隔離這種問題本來就是此消彼長的。我們正在努力平衡安全性(如果我們不關心安全性,那就壓根不必用事務了)和我們用來提供安全限度的工具的性能開銷。正確的平衡要依賴許多因素,包括與系統故障或當機時間相關的代價或損害以及組織的風險承受能力。