COM+ 的交易隔離等級與鎖定

摘要:這份文件從一個 COM+ 交易的問題開始,描述問題發生的原因,解法,及其所涉及的資料庫相關知識。

作者:蔡煥麟
日期:Aug-22-2001, Dec-31-2001


問題描述

在以 Delphi 5 開發一個 3-tier 的專案時曾碰到一個問題,當兩個用戶端(幾乎)同時執行結算作業的時候,只有一個用戶可以成功完成結算,另一個用戶端則則會出現錯誤訊息:

不支援此種介面。

這個錯誤訊息實在讓人覺得不知所云,不過兩個用戶端同時進行結算就會有一個出錯,應該跟資料的鎖定有關,只是兩個用戶端只是各自更新不同的記錄,怎麼會出問題呢?

解決方法

原來問題的關鍵在於 COM+ 元件預設的交易隔離等級是 Serializable,這個待會兒再解釋,先來看解決方法:不要用分散式交易,改用本地端交易(Local Transaction)。翻成白話就是「不要使用 COM+ 的交易管理,而改用 ADO 來控制交易」,參考下面的範例:

使用分散式交易(COM+):

procedure TMem1000.UpdateSomething;
begin
  SetComplete;
  try
    Update some tables....
  except
    SetAbort;
    raise;
  end;
end;

改成 Local Transaction(ADO):

procedure TMem1000.UpdateSomething;
begin
  SetComplete;
  ADOConnection1.BeginTrans;
  try
    Update some tables....
    ADOConnection1.CommitTrans;
  except
    ADOConnection1.RollbackTrans;
    SetAbort;
    raise;
  end;
end;

當元件改成 Local Transaction 的寫法時,請記得一定要將元件的〔異動支援〕屬性設定成 "不支援"註1),否則在呼叫 ADOConnection 的 BeginTrans 方法時會出現錯誤訊息:

 "Cannot start more transactions on this session"
(在此階段作業中無法啟動更多的異動)

這時候 SetComplete/SetAbort 呼叫還是應該保留,它們雖然不會影響交易的結果,但是對於元件是否繼續執行(元件的狀態是否保留到下一次的呼叫)仍需要靠它們來控制。

隔離等級與鎖定

前面有提到 COM+ 的交易隔離等級,那什麼是"隔離等級"?Serializable 等級又是如何地影響交易的處理呢?這恐怕得從 ACID 開始談起,大部分介紹資料庫程式設計或理論的書籍都會提到 ACID, 它就是 Atomicity(單元性), Consistency(並行性), Isolation(隔離性), Durability(持續性)的縮寫,每筆交易都必須具備這四項特性才能確保交易的正確性。我們比較感興趣的是 Isolation,其他特性請參考相關書籍的介紹,這裡就不多說了。

Isolation 就是隔離或隱藏尚未交付(commit)的交易以避免各交易之間相互影響,通常是透過資料庫的鎖定機制來達成,那我們又得先來了解一下什麼是鎖定。大部分的資料庫都會提供兩種鎖定:讀取鎖定(read lock)與寫入鎖定(write lock),當資料被讀取時會產生 read lock,此時其他交易仍然可以讀取這些資料,但是不可以修改(其他交易無法對一個已經被 read lock 的記錄進行 write lock);當資料被修改時會產生 write lock,此時其他交易必須等到取得 write lock 的交易 commit 或 rollback 之後才能對該資料進行讀取及寫入動作。剛才這一長串好像繞口令的文字敘述其實可以整理成下面這兩句話

Read lock 不會排斥 read lock,但會排斥 write lock(共享鎖定)。
Write lcok 會排斥 read lock 與 write lock(獨占鎖定)。

Write lock 的時間很明確,鎖定在資料被修改時建立,交易結束時釋放,但是 read lock 的花樣就比較多了,也因此有了以下四種隔離等級好讓你調整 read lock 持續的時間以及可以讀取哪些資料:

也稱為"dirty read",某個交易可以讀取到其他交易正在修改的資料,一般 flat-file 的資料庫,像是 DBASE, Paradox 等都只支援這個等級。這個等級在多使用者同時上線進行交易時會發生衝突的機率最低,所以 concurrency(並行性)最佳,但是 consistency(資料的一致性)最差。

只能讀取已經 commit 的資料。當某個交易欲讀取的資料正被其他交易修改時,就必須等到其他交易釋放其對資料的寫入鎖定以後才能進行讀取動作。參考下面的範例:

procedure TForm1.Button1Click(Sender: TObject);
begin
  ADOConnection1.BeginTrans;
  try
    with ADODataSet1 do
    begin
      CommandText := 'select * from Employees';
      Open;
      Edit;
      FieldByName('Region').AsString := 'TW';
      Post;
      Delay(5000);   // 在這裡故意延遲個 5 秒左右
    end;
    ADOConnection1.CommitTrans;
  except
    ADOConnection1.RollbackTrans;
    raise;
  end;
end;

要測試這個範例程式,必須開啟兩份 instance,並且幾乎同時間按下 Button1 以進行兩筆交易。當其中一筆交易執行到 Post 之後就開始取得 write lock,此時如果其他交易嘗試以 select * from Employees 來查詢資料就會因為無法取得 read lock 而必須等待,直到取得鎖定的交易 commit 或 rollback。

關聯式資料庫多以此為預設的隔離等級。

這個等級會讓 read lock 一直持續直到交易結束,以確保你在交易中讀取的資料會一直維持不變(和資料庫裡面的資料一樣),不會被其他交易修改。

這是最嚴格的隔離等級,它有一個特色,就是交易進行時不允許"幽靈資料"的產生,例如,當一筆交易執行 select count(*) 的 SQL 命令以取得某資料表的記錄筆數時,其他的交易便無法新增任何記錄到這個資料表。資料庫系統通常以 table lock 或 index range lock 避免幽靈資料的產生,也因此這個等級的 consistency 最佳,但是 concurrency 卻最差,當同時要處理的交易很多時就很容易塞車甚至出車禍。

前面有說過,COM+ 元件預設的隔離等級是 Serializable,當交易時間比較長時,資料鎖定的時間也長,所以當交易頻繁時,就容易發生多個交易搶著要鎖定同一份資料的情形。當發生鎖定衝突時,搶輸的交易就必須在後面排隊等待,因而降低了程式的執行效能,使用者便可能會抱怨程式執行的速度太慢。SQL Server 與 ADO 的預設隔離等級都是 Read Committed,所以當程式改用 Local Transaction 時發生鎖定衝突的機率就比 Serializable 等級小多了。

但是現在遇到的問題並不是速度慢,而是同時進行的兩個交易只有一個會成功,這種情形其實是 deadlock 造成的。

死結(Deadlock)

典型的 deadlock 通常由兩個交易互相等待對方釋放鎖定而造成,例如,交易 A 已經對資料表中編號 A001 的記錄取得 write lock,而且正嘗試要取得 A002 的 write lock;如果此時交易 B 已經取得 A002 的 write lock,而且正在等待交易 A 釋放對 A001 的鎖定以取得 A001 的 write lock,這時候兩個交易就會一直這麼互相地等,等,等,直到 SQL Server 偵測到死結的發生,強迫讓其中一個交易終止並回傳錯誤訊息給用戶端,讓另一個交易得以順利完成。

要減少死結發生的機率,在撰寫程式時必須盡量讓所有處理交易的程式碼都以相同的順序存取資料,以前面的例子來說,就是讓交易 A 和 B 都依照先 A001 再 A002 的順序來存取資料。

剛才介紹是典型的循環鎖定造成的死結(cyclic deadlock),還有一種 deadlock 是由 lock conversion(鎖定轉換)所引起的,例如有一筆交易需要先讀取一些資料,然後將這些資料經過運算之後再寫回去,於是這筆交易會先取得這些資料的 read lock,然後嘗試將鎖定由 read lock 轉換成 write lock,想像如果有兩個用戶端同時執行這個交易,而且正好兩筆交易同時對這些資料取得了 read lock,那麼這兩筆交易就都無法再取得 write lock 了(若不了解的話,請再複習一下前面講的)。

現在幾乎可以確定 deadlock 就是導致錯誤發生的原因了,所欠缺的只是證據,於是我寫了個測試程式,模擬兩個用戶端同時進行交易並且發生 deadlock,結果果然一個用戶端可以完成交易,另一個則出現錯誤 "不支援此種介面"(註2),再利用 SQL Server 附的 SQL Profiler 工具去觀察,得到下面的結果:

從上面圖中可以看得出來,資料庫確實發生了 deadlock,問題的整個前因後果,到這裡算是水落石出了。

明日的 COM+

也許有人會問:「有沒有方法可以把 COM+ 元件的隔離等級改為 Read Committed?」,不幸地,在 Windows2000 以及更早的 NT/9x 平台來說,這是做不到的,因為 COM+ 1.0 處理元件的交易時所使用的隔離等級是寫死的。不過,這情況到了 WindowsXP 的 COM+ 1.5 就改觀了,COM+ 1.5 已經可以讓你設定元件交易的隔離等級,不僅如此,還針對元件不斷長大造成的記憶體不足的問題提供元件生命週期的自動循環控制,工程師就不用提心吊膽的每隔一兩個月去看一下元件是否吃光記憶體了,這對於 COM+ 應用程式的開發人員來說倒是一大福音。

如果想了解 COM+ 1.5 更多詳細的新功能介紹,可以參考這篇文章:

http://msdn.microsoft.com/msdnmag/issues/01/08/ComXP/ComXP.asp

用 Update Lock 來避免 deadlock

如果你現在正在 Windows2000 平台上開發 COM+ 元件,並且使用 COM+ 來控制交易的話,你可以使用 SQL Server 提供的另一種稱為 Update Lock 的鎖定型態來避免 Lock Conversion 所造成的死結,參考以下範例:

procedure TTestLock.LockConversion;
begin
  try
    with ADODataSet1 do
    begin
      CommandText := 'select * from Employees with (ROWLOCK,UPDLOCK)';
      Open;        // 開啟後就取得了 read lock

      Delay(4000); // read lock 保持 4 秒左右.
       
      Edit;
      FieldByName('Region').AsString := 'TW';
      Post;        // read lock 轉換成 update lock
      Close;
    end;
    SetComplete;
  except
    SetAbort;
    raise;
  end;
end;

或者,你也可以用下面這個方法:

procedure TTestLock.LockConversion;
begin
  try
    ADOConnection1.Execute('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
    with ADODataSet1 do
    begin
      CommandText := 'select * from Employees';

    ....以下不變.....
end;

經過測試,當兩個用戶端同時呼叫 LockConversion 進行交易,使用上面兩種方法都不會發生 deadlock,兩筆交易都可以成功。

結論

現在我們知道了,在 Windows 9x/NT/2000 環境下,COM+ 的交易使用了最嚴格的資料隔離等級,因此我們在處理比較複雜、時間較長的交易時就得多加注意,以免因為資料的鎖定而造成線上使用者長時間的等待,甚至發生死結。可是,既然使用 COM+ 來控制交易容易引發諸多問題,為什麼還要繼續使用它,而不改成比較有彈性的 Local Transaction 呢?

關於應該使用分散式交易還是 Local Transaction 的問題,必須視應用程式的特性與需求加以衡量,要考慮的因素包括:

簡單的說,如果你的資料庫分散在不同的機器上,或者必須用到多個元件來完成一個交易的情況,就應該使用 COM+ 來控制分散式交易,相反的,如果只是單一資料庫,而且每一次的交易都只透過單一元件來執行,就可以考慮使用 Local Transaction。

最後,把前面討論的做個重點整理:

參考資料


註1. 如果元件不會參與其他元件的交易的話,其〔異動支援〕設定為 "支援' 也可以正常運作,但是最好不要這樣,以免不小心而發生錯誤。
註2. 如果你的 Windows2000 沒有更新至 Service Pack 2 的話,錯誤訊息可能會是 "無法指出的錯誤"。而依照 SQL Server 2000 的線上說明,當 deadlock 發生時,使用者應該會收到下列訊息:

"交易 (處理識別碼 xxx) 已被其他處理鎖死於 (xxx) 資源上,且被選擇為犧牲者。傳回交易。"

這樣看來應該是 COM+ 把 SQL Server 的錯誤代碼解釋得不好,才會有 "不支援此種介面" 這種粗糙的錯誤訊息。