1. 程式人生 > >從一次“併發修改欄位業務”引出多版本併發控制與InnoDB鎖

從一次“併發修改欄位業務”引出多版本併發控制與InnoDB鎖

併發欄位修改業務

最近在主要在做“工作流引擎”課題的預研工作,在涉及到“會籤任務”(工作流業務概念,這與我們今天討論文問題沒有太多關聯)的時候,遇到了一個併發修改同一個欄位的應用場景。

大致是由於要等一個活動節點的所有例項任務都完成之後才能繼續向下流轉,則引擎必須在每次任務提交的時候進行判斷。我選擇了在資料庫表中記錄下每個活動節點對應的任務例項數目,活動例項完成提交時做相應的數目修改(active_ti_num - 1)來進行對應活動節點是否完成的判斷。資料庫表結構如下:

活動表字段名 id(活動主鍵) ai_name(活動名稱) active_ti_num(當前活動未完成例項個數)
示例資料 1213398753365504001 活動1 1
任務表字段名 id(任務主鍵) ai_id(對應活動id,外來鍵)
示例資料 1213400206226272258 1213398753365504001

如上所示,當同一個活動具有多個任務例項的時候,而任務例項又併發完成,就可能由於併發update導致資料錯誤,所以我將任務例項提交處理封成了一個事務,再使用update自減的方式修改active_ti_num欄位值。

<update id="decrementActiveNum" parameterType="int">
        UPDATE wf_activtity_instance
        SET active_ti_num = active_ti_num + 1
        WHERE id = #{id}
</update>

這樣在第一個事務修改了active_ti_num後,會鎖住活動表中被修改的這一行,其他的事務便只能等待,等持有鎖的事務鎖釋放之後,其他事務可以競爭鎖再進行active_ti_num欄位修改,從而保證了不出現資料錯誤。這種處理方法也是一種比較常見的處理方法。

囉囉嗦嗦說了這麼多,業務問題雖然解決了,但不知道大家有沒有過疑惑,雖然為了保證資料不發生錯誤,修改的資料被鎖住了,但是MySQL究竟加的是行鎖還是表鎖?如果我們遇到的是併發insert操作而非update,那是否會出現新的問題?想解決這些疑惑,就需要引出我們今天的話題——“MVCC原理與在InnoDB中的實現”

MVCC概念介紹

在併發操作的控制上,MySQL的大多事務型儲存引擎實現的都不是簡單的行級鎖。基於提升併發效能的考慮,他們一般都同時實現了MVCC(多版本併發控制)。可以認為MVCC是行級鎖的一個變種,在很多場景下避免了加鎖操作,因此開銷更低。工作在 RC (讀已提交)、RR(可重複度)兩種隔離級別下。至於這個MVCC究竟是怎麼做到既保證效果,又提高併發的,我們先來看看《高效能MySQL》中的介紹。

MVCC的實現,是通過儲存資料在某個時間點的快照來實現的。MVCC是通過每行記錄後面儲存兩個隱藏的列來實現的。這兩個列,一個儲存了行的建立時間,一個儲存了行的過期時間(或刪除時間)。當然實際儲存的不是時間而是系統版本號。每開始一個新的事務,系統版本號都會自動遞增。事務開始時刻的系統版本號會作為事務的版本號。

對於SELECT操作,就查詢版本早於當前事務版本的資料行,行的刪除版本要麼未定義,要麼大於當前事務版本。
對於INSERT操作,InnoDB為新插入的每一行儲存當前系統版本號作為行版本號。
對於DELETE操作,Innodb為刪除的每一行儲存當前系統版本號作為行刪除標識。
對於UPDATE操作,Innodb為插入一行新紀錄,儲存當前系統版本號作為行版本號,同時儲存當前系統版本號到原來的行作為行刪除標識。

以上是MVCC實現的一個大致概括,各儲存引擎具體實現上還是略有不同。由於InnoBD是MySQL預設的儲存引擎,也是我專案使用的儲存引擎,因此我們就來看看在InnoBD中MVCC的實現原理與作用是怎樣的(其他儲存引擎筆者也不會是吧...)。

InnoDB中MVCC的實現思路

在InnoDB中,會在每行資料後新增兩個額外的隱藏的值來實現MVCC ,一條記錄除了包括各個欄位值,還包括了當前事務id(trx_id)和一個指標(roll_pointer)。

  1. trx_id:生成這條記錄(update/delete)的事務id
  2. roll_pointer:之前undo_log中原來的那條記錄,從而構成版本鏈

注:一個事務的事務id在第一次insert/delete/update時生成

我們接下來通過具體操作的實現思路來進行講解:

Update操作

插入一條新的記錄,把原來的記錄放到undo日誌中去,再把新紀錄的roll_pointer指標指向原來的那條記錄(從而加入版本鏈)

Select操作

當執行查詢sql時會生成一致性檢視read-view,它由執行查詢時所有未提交事務id陣列(數組裡最小的id為min_id)和已建立的最大事務id( max_id)組成,查詢的資料結果需要跟read-view做比對從而得到快照結果(即從版本鏈頭部記錄開始,順著鏈開始比對,找到可見的第一個版本記錄)。

版本鏈比對規則

  1. 如果落在綠色部分( trx_id< min_id),表示這個版本是已提交的事務生成的,這個資料是可見的;

  2. 如果落在紅色部分( trx_id> max_id),表示這個版本是由將來啟動的事務生成的,是肯定不可見的。

  3. 如果落在黃色部分( min_id<=trx_id<= max_id),那就包括兩種情況

    a.若row的trx_id在陣列中,表示這個版本是由還沒提交的事務生成的,不可見,當前自己的事務是可見的。

    b.若row的trx_id不在陣列中,表示這個版本是已經提交了的事務生成的,可見

delete操作

對於刪除的情況可以認為是 update的特殊情況,會將版本鏈上最新的資料複製一份,然後將trx_id修改成刪除操作的trx_id,同時在該條記錄的頭資訊( record header)裡的( deleted flag)標記位寫上true,來表示當前記錄已經被刪除,在查詢時按照上面的規則查到對應的記錄如果 delete flag標己位為true,意味看記錄已被刪除,則不返回資料。

知道了MVCC的實現機制,那現在我們可以思考下MVCC是如何實現可重複讀的和讀已提交的呢?

MVCC是如何實現可重複讀的和讀已提交的?

可重複讀隔離級別下,SELECT一致性檢視(readview)沿用第一次生成的(這是mvcc實現可重複讀的關鍵,即使其他事務commit,但由於readview還是第一次select時生成的那個,所以當前事務還是看不到),而讀已提交隔離級別下,每次SELECT操作生成最新的一致性檢視(readview)

注:readview是在當前會話(事務)第一條sql語句執行時生成的,在可重複讀的隔離級別下,後面的語句都沿用這個readview(也就是說生成的readview是查哪個表用都有效的)

由此可見,可重複讀也解決了幻讀問題,因為新插入的記錄的trx_id肯定會出現在select事務readview的未提交事務id陣列/大於最大事務id,所以對於該事務肯定不可見,從而解決了幻讀問題。

到這可能有讀者會疑惑,之前說的都是對於讀資料的併發控制,可是你的業務是更新啊!這還不是一回事啊!

別急,接下來我們就要說到啦!

快照讀與當前讀的區別?以及在MVCC中的應用

咦?怎麼讀還有兩個?

“讀”與“讀”的區別

我們且看,在RR(可重複讀)級別中,通過MVCC機制,雖然讓資料變得可重複讀,但我們讀到的資料可能是歷史資料,是不及時的資料,不是資料庫當前的資料!這在一些對於資料的時效特別敏感的業務中,就很可能出問題。(比如說併發情況下自增或者先讀再增(更新值對原資料值有依賴性))

對於這種讀取歷史資料的方式,我們叫它快照讀 (snapshot read),而讀取資料庫當前版本資料的方式,叫當前讀 (current read)。

快照讀其實就是普通的select操作,如

select * from table ….;

當前讀則是特殊的讀操作,插入/更新/刪除操作,屬於當前讀,處理的都是當前的資料,需要加鎖

select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update ;
delete;

由此我們可以想到,事務的隔離級別實際上都是定義了當前讀的級別,MySQL為了減少鎖處理(包括等待其它鎖)的時間,提升併發能力,引入了快照讀的概念,使得select不用加鎖。而update、insert這些“當前讀”,就需要另外的模組來解決了。記下來,我們詳細來說說當前讀

當前讀(“寫”)

事務的隔離級別中雖然只定義了讀資料的要求,實際上這也可以說是寫資料的要求。上文的“讀”,實際是講的快照讀;而這裡說的“寫”就是當前讀了。

讀問題在上文中已經解決了,根據MVCC的定義,併發提交資料時會出現衝突,那麼衝突時如何解決呢?我們再來看看InnoDB中RR級別對於寫資料的處理。

InnoDB使用了Next-Key鎖解決當前讀中的幻讀問題。首先我們看下什麼是Next-Key鎖。

Next-key Lock:鎖定索引項本身和索引範圍。即Record Lock和Gap Lock的結合。可解決幻讀問題。

Record Lock:對索引項加鎖,鎖定符合條件的行。其他事務不能修改和刪除加鎖項;

Gap Lock:對索引項之間的“間隙”加鎖,鎖定記錄的範圍(對第一條記錄前的間隙或最後一條將記錄後的間隙加鎖),不包含索引項本身。其他事務不能在鎖範圍內插入資料,這樣就防止了別的事務新增幻影行。

接下來我們可以看看RR級別和RC級別的對比,來體會Next-key鎖的作用。

RC級別:

RR級別:

通過對比我們可以發現,在RC級別中,事務A修改了所有teacher_id=30的資料,但是當事務Binsert進新資料後,事務A發現莫名其妙多了一行teacher_id=30的資料,而且沒有被之前的update語句所修改,這就是“當前讀”的幻讀。

RR級別中,事務A在update後加鎖,事務B無法插入新資料,這樣事務A在update前後讀的資料保持一致,避免了幻讀。這個鎖,就是Gap鎖。

InnoDB是這麼實現的:

在class_teacher這張表中,teacher_id是個索引,那麼它就會維護一套B+樹的資料關係。
而InnoDB使用的是聚集索引,teacher_id身為二級索引,就要維護一個索引欄位和主鍵id的樹狀結構,學過資料結構的同學都會知道,在樹節點內部關鍵字保持順序排列如下圖(意會)。

如上圖索引結構,Innodb將這段資料分成幾個個區間

(negative infinity, 5],
(5,30],
(30,positive infinity);

update class_teacher set class_name=‘初三四班’ where teacher_id=30;不僅用行鎖,鎖住了相應的資料行;同時也在兩邊的區間,(5,30]和(30,positive infinity),都加入了gap鎖。這樣事務B就無法在這個兩個區間insert進新資料。

因此,受限於這種實現方式,Innodb很多時候會鎖住不需要鎖的區間。如下圖所示

update的teacher_id=20是在(5,30]區間,即使沒有修改任何資料,Innodb也會在這個區間加gap鎖,導致事務B必須等待,而其它區間不會影響,事務C正常插入。

此外,如果(where條件)使用的是沒有索引的欄位,比如update class_teacher set teacher_id=7 where class_name=‘初三八班(即使沒有匹配到任何資料)’,那麼會給全表加入gap鎖。同時,它不能像上文中行鎖一樣經過MySQL Server過濾自動解除不滿足條件的鎖,因為沒有索引,則這些欄位也就沒有排序,也就沒有區間。除非該事務提交,否則其它事務無法插入任何資料。

行鎖防止別的事務修改或刪除,GAP鎖防止別的事務新增,行鎖和GAP鎖結合形成的的Next-Key鎖共同解決了RR級別在寫資料時的幻讀問題。

總結

MVCC不可重複讀的保證其實是由快照讀和當前讀兩個方面著手,快照讀通過mvcc的版本控制來解決,不需要真正加鎖。當前讀通過行鎖和GAP鎖(鎖的範圍為索引B+樹中當前索引兩邊的區間,要是沒有索引就鎖表)結合形成的的Next-Key鎖來解決不可重複度和幻讀的問題。

參考資料

《高效能MySQL》第三版

美團技術團