Oracle 數據庫隔離級別,特性,問題和解決方法


若是沒有任何數據庫隔離策略,在多用戶(多事務)併發時,會產生下列問題:
  - 丟失更新(lost update):兩個事務同時更新同一條數據時,會發生更新丟失。
    例如:用戶A讀取學號爲107的學生(學號=107,姓名=「小明」,年齡=28)
    => 用戶B讀取學號爲107的學生(學號=107,姓名=「小明」,年齡=28)
    => 用戶A把姓名更改成「王小明」(學號=107,姓名=「王小明」,年齡=28)
    => 用戶B把年齡更改成33(學號=107,姓名=「小明」,年齡=33)
    => 用戶A提交(學號=107,姓名=「王小明」,年齡=28)
    => 用戶B提交(學號=107,姓名=「小明」,年齡=33)
    用戶A對學生姓名的更新丟失了。
  - 髒讀(dirty read):當一個事務讀取另外一個事務還沒有提交的修改時,產生髒讀。
    例如:用戶A讀取學號爲107的學生(學號=107,姓名=「小明」,年齡=28)
    => 用戶A把姓名更改成「王小明」(學號=107,姓名=「王小明」,年齡=28)
    => 用戶B讀取學號爲107的學生(學號=107,姓名=「王小明」,年齡=28)
    => 用戶A撤銷更改,事務回滾(學號=107,姓名=「小明」,年齡=28)
    這樣用戶B至關於讀取了一個從未存在過的數據「王小明」。若是涉及到金額的話問題更爲嚴重,由於用戶B讀取了一個金額以後,極可能把這個金額與其它金額累加,再把結果保存到彙總數據之中,這樣在月底對不上帳的時候,因爲用戶A回滾了事務,數據庫內不會有任何操做記錄,這樣用戶B是什麼時候、從哪裏讀取了錯誤數據根本無從查起。
  - 不可重現的讀取(nonrepeatable read):同一查詢在同一事務中屢次進行,在此期間,因爲其餘事務提交了對數據的修改或刪除,每次返回不一樣的結果。
    例如:假設學生表裏只有「小明」和「小麗」2條記錄(小明.年齡=20,小麗.年齡=30)。若是用戶A把「小明.年齡」更改成40、把「小麗.年齡」更改成50並提交事務。在用戶A修改數據並提交前,學生的平均年齡爲(20+30)/2=25;在用戶A修改數據並提交後,學生的平均年齡爲(40+50)/2=45。
如今考慮在用戶A更新 2 名學生的年齡時,用戶B執行了一個計算平均年齡的事務: html

如前所述,在用戶A修改數據並提交前,學生的平均年齡爲(20+30)/2=25;在用戶A修改數據並提交後,學生的平均年齡爲(40+50)/2=45。用戶B計算得出的平均年齡是 25 或 45 都是能夠接受的,可是在上例中用戶B計算得出的平均年齡 35 是從未出如今系統中的錯誤數值。
  - 幻讀(phantom read):同一查詢在同一事務中屢次進行,因爲其餘提交事務所作的插入操做,雖然查詢條件相同,每次返回的結果集卻不一樣。 程序員

事務B使用相同的條件進行了2次查詢/篩選,一次是爲了向費用結算表插入彙總數據,一次爲了肯定對費用明細表的更新範圍。在這兩次篩選之間,事務A提交了一條新的費用明細數據,致使兩次篩選的結果不一致。

隔離級別

  爲避免上述併發問題,ANSI/ISO SQL92標準定義了一些隔離級別: 數據庫

  - 讀取未提交數據(read uncommitted) 編程

  - 讀取已提交數據(read committed) 併發

  - 可重現的讀取(repeatable read) 性能

  - 序列化(serializable) 測試

經過指定不一樣的隔離級別,可避免上述一種或多種併發問題,見下圖。 大數據

細心的讀者可能已經注意到上圖不包括「丟失更新(lost update)」,這是由於「丟失更新」問題須要使用樂觀鎖或悲觀鎖來解決,超出本文範圍,先不詳述。

Oracle 的隔離級別

  SQL92定義的隔離級別在理論上很完善,可是 Oracle 顯然認爲在實際實現的時候並不該該徹底照搬SQL92的模型。 
  - Oracle不支持 SQL92 標準中的「讀取未提交數據(read uncommitted)」隔離級別,想要髒讀都沒可能。
  - Oracle 支持 SQL92 標準中的「讀取已提交數據(read committed)」隔離級別,(這也是Oracle默認的隔離級別)。
  - Oracle不支持 SQL92 標準中的「可重現的讀取(repeatable read)」隔離級別,要想避免「不可重現的讀取(nonrepeatable read)」能夠直接使用「序列化(serializable)」隔離級別。
  - Oracle 支持 SQL92 標準中的「序列化(serializable)」隔離級別,可是並不真正阻塞事務的執行(這一點在後文還有詳述)。
  - Oracle 還另外增長了一個非SQL92標準的「只讀(read-only)」隔離級別。

Oracle的序列化(serializable)隔離級別

  序列化,顧名思義,是讓併發的事務感受上是一個挨一個地串行執行的。之因此說是「感受上」,是由於當2個事務併發時,Oracle並不會阻塞其中一個事務去等待另外一個事務執行完畢再執行,而是仍然讓2個事務同時並行,那麼如何能「感受」是串行的呢?請看下圖的實驗。 ui

用戶B的事務由於指定了serializable隔離級別,因此雖然在查詢費用明細表以前,用戶A提交了對費用明細表的更改,可是由於用戶A提交的更改是在用戶B的事務開始以後才提交的,因此這個更改對用戶B的事務不可見。也就是說,用戶B的事務開始以後,其它事務提交的更改都不會再影響事務內的查詢結果,這樣感受上用戶A的事務好像是在用戶B的事務結束以後才執行的似的。這原本是很是好的一個特性,極大地提升了並行性,可是也會形成問題。

  問題1:Oracle的這種「假串行」會讓嚴格依賴於時間的程序產生混亂。

  
請看下圖這個例子,對費用結算的例子稍稍作了一點改動。 this

程序員的本意是統計2012-3-4這天從零點至運行程序之時的費用總額。若是他覺得 Oracle 的 serializable 會像 C# 的 lock 同樣阻塞其它事務的話,就會對結果很是吃驚:在2012-3-4 0:00 ~ 2012-3-4 10:02 實際有3條費用明細,總額爲20+30+100=150,而不是用戶B的事務統計得出的50。

  問題2:ORA-08177 Can't serialize access for this transaction (沒法序列化訪問)錯誤。

  若是你使用了 serialize 隔離級別,沒準你的客戶會常常抱怨這個隨機出現的錯誤。兄弟,你並不孤獨!
  致使這個錯誤的緣由有2個:
  (1) 兩個事務同時更新了同一條數據。你能夠這樣重現這個錯誤:事務B開始(使用serialize 隔離級別) => 事務A開始,更新 表1.RowA 但不提交 => 事務B更新表1.RowA,由於行鎖定而被阻塞 => 事務A提交 => 事務B報 ORA-08177 錯誤。
  (2) 事務所更新的表的 initrans 參數過小。Oracle 官方文檔的說法是,若是使用了 serialize 隔離級別,表的 initrans 參數最小要設置成3(默認是1)。

alter table 費用明細表 initrans 3;

  原文:「Oracle Database stores control information in each data block to manage access by concurrent transactions. Therefore, if you set the transaction isolation level to SERIALIZABLE, then you must use the ALTER TABLE command to set INITRANS to at least 3. This parameter causes Oracle Database to allocate sufficient storage in each block to record the history of recent transactions that accessed the block. Higher values should be used for tables that will undergo many transactions updating the same blocks.」
  注意,人家說的是「最小是3」。我用本身筆記本里的 32 位 Oracle10g 測試的結果是設置成 3 也會頻繁地報 ORA-08177 錯誤。後來改爲5 和 10,都不行。改爲50,終於不報錯了。可是都說了這個錯誤是隨機的,有時候3也沒問題的——反過來講,設置成50也未必保險。坑爹啊!真坑爹!!這就像菜譜裏面寫的「放入適量的油……」,他喵的到底多少算是「適量」啊?!!!
  有興趣的讀者可使用下圖的語句實際測試一下。

  個人建議是,仍是儘可能不要用 serialize 隔離級別吧,用戶是不會理解什麼叫「沒法序列化訪問」的,他只會以爲你的「XX功能會隨機地很差用」卻是真的。稍後咱們再簡單討論一下不用 serialize 隔離級別如何避免幻讀。如今先來看一下 Oracle 官方文檔建議的適合使用 serialize 隔離級別的3種狀況。

  (1) With large databases and short transactions that update only a fewrows(大數據庫、只更新幾條數據的短事務)

  (2) Where the chance that two concurrent transactions will modify thesame rows is relatively low(2個併發事務更新同一條數據的概率不大)

  (3) Where relatively long-running transactions are primarily read only(相對運行時間較長的事務主要用來讀取數據)


使用默認的 read committed 隔離級別,如何避免幻讀產生的問題

  使用默認的 read committed 隔離級別,如何編寫程序才能避免幻讀產生的問題呢?首先,不管是「不可重現的讀取(nonrepeatable read)」仍是「幻讀(phantom read)」,都是由於程序反覆讀取數據產生的。因此首先須要作的是,在一個事務裏確保只讀取數據一次。最好用C#而不是存儲過程實現業務邏輯,這樣很容易作到只讀取一次,而後把結果存放到IList或IDictionary裏。比較難辦的是須要更新數據的狀況。回顧一下前面所舉的幻讀的例子。

事務B使用相同的條件進行了2次查詢/篩選,一次是爲了向費用結算表插入彙總數據,一次爲了肯定對費用明細表的更新範圍。在這兩次篩選之間,事務A提交了一條新的費用明細數據,致使兩次篩選的結果不一致。要避免這個問題,仍是要貫徹「只讀取一次」的原則,或者更廣義地說,是「只肯定一次篩選範圍」。大體有2種方法。
  <法一> 能夠先把符合條件的費用明細讀取出來保存到一個列表裏,而後不管統計仍是更新,都侷限於這個列表裏的數據。下面的C#代碼與上圖的功能相同,可是沒有幻讀的問題。

複製代碼
// 用戶B的事務開始  IList<費用明細> chargeList = 費用明細Repository.獲取未結算列表();
費用結算 balance = new 費用結算 
{
    總金額 = chargeList.Sum(t => t.金額),
    結算編號 = "J122" };
費用結算Repository.Save(balance); // 這時候用戶A提交了一條新的費用明細,不過不要緊  foreach(費用明細 charge in chargeList)
{
    charge.是否已結算 = 1;
    charge.結算編號 = "J122";
    費用明細Repository.Update(charge);
}
複製代碼

這個方法的缺點是要對 chargeList 裏的每一個實體 Update 一次,若是數據量較大可能會有性能問題。這時候能夠用<法二>。
本文爲了表述的方便使用了中文和英文混雜的代碼,實際編程的時候不要這樣作。
  <法二> 使用事務B獨有的方法標識出操做數據的範圍。

雖然上圖是用SQL語句來演示的,使用C#(實體+ORM)一樣能夠用這種方法。

嚴格依賴時間的程序

嚴格來講這並非幻讀形成的問題——事務A還沒提交呢。這種設計十分危險,不管使用 read committed 仍是 serializable 隔離級別都不足以免併發形成的不一致,應該儘可能避免這樣的設計。依賴時間很危險,由於系統時間是隨時可能被系統管理員更改的,更別提有些國家和地區會實行夏時制,想一想看,事務B提交了以後,系統時間被回撥了1小時!
  然而世事每每不盡如人意,你可能不幸遇到了這樣一個遺留系統,或者用戶有不少其它的業務或與你交互的系統嚴格依賴於時間而逼得你不得不這麼作的時候,該怎麼辦呢?
  <法一> 在業務邏輯層面,能夠把用戶B和用戶A的兩個方法使用C#提供的線程同步技術串行化——理論上行的通,可是操做費用明細實體的方法那麼多,很容易有所遺漏。
  <法二> 在Repository層面,爲費用明細實體設置一個令牌,而且能夠設置是否進入令牌模式。在令牌模式下,費用明細Repository裏面的全部持久化操做都必須拿到令牌才能操做,拿不到令牌直接拋異常。平時的業務操做都在非令牌模式下工做。在用戶B想要進行結算操做時,事務開始以後,立刻設置成令牌模式,而後獲取令牌,這樣就能確保此時只有用戶B才能操做費用明細表了。此法雖然併發性不好,可是既簡單又保險。並且不少時候像結算這樣的操做一個月(或一天)只進行一次,併發性差一些也能夠忍受。值得注意的是下面這種狀況。

雖然發生的機率不高,可是讓令牌法完全失效了。綜合考慮系統時間被管理員改變的可能性,僅僅在結算事務裏獨佔令牌也是不夠的,還必須在費用明細Repository.Save()方法裏驗證費用明細.建立時間必須大於最近一次的結算時間。

相關文章
相關標籤/搜索