并发执行变成串行_大神浅谈数据库并发控制 锁和 MVCC
在學習幾年編程之后,你會發現所有的問題都沒有簡單、快捷的解決方案,很多問題都需要權衡和妥協,而本文介紹的就是數據庫在并發性能和可串行化之間做的權衡和妥協 - 并發控制機制。

如果數據庫中的所有事務都是串行執行的,那么它非常容易成為整個應用的性能瓶頸,雖然說沒法水平擴展的節點在最后都會成為瓶頸,但是串行執行事務的數據庫會加速這一過程;而并發(Concurrency)使一切事情的發生都有了可能,它能夠解決一定的性能問題,但是它會帶來更多詭異的錯誤。
引入了并發事務之后,如果不對事務的執行進行控制就會出現各種各樣的問題,你可能沒有享受到并發帶來的性能提升就已經被各種奇怪的問題折磨的欲仙欲死了。
概述
如何控制并發是數據庫領域中非常重要的問題之一,不過到今天為止事務并發的控制已經有了很多成熟的解決方案,而這些方案的原理就是這篇文章想要介紹的內容,文章中會介紹最為常見的三種并發控制機制:

分別是悲觀并發控制、樂觀并發控制和多版本并發控制,其中悲觀并發控制其實是最常見的并發控制機制,也就是鎖;而樂觀并發控制其實也有另一個名字:樂觀鎖,樂觀鎖其實并不是一種真實存在的鎖,我們會在文章后面的部分中具體介紹;最后就是多版本并發控制(MVCC)了,與前兩者對立的命名不同,MVCC 可以與前兩者中的任意一種機制結合使用,以提高數據庫的讀性能。
既然這篇文章介紹了不同的并發控制機制,那么一定會涉及到不同事務的并發,我們會通過示意圖的方式分析各種機制是如何工作的。
悲觀并發控制
控制不同的事務對同一份數據的獲取是保證數據庫的一致性的最根本方法,如果我們能夠讓事務在同一時間對同一資源有著獨占的能力,那么就可以保證操作同一資源的不同事務不會相互影響。

最簡單的、應用最廣的方法就是使用鎖來解決,當事務需要對資源進行操作時需要先獲得資源對應的鎖,保證其他事務不會訪問該資源后,在對資源進行各種操作;在悲觀并發控制中,數據庫程序對于數據被修改持悲觀的態度,在數據處理的過程中都會被鎖定,以此來解決競爭的問題。
讀寫鎖
為了最大化數據庫事務的并發能力,數據庫中的鎖被設計為兩種模式,分別是共享鎖和互斥鎖。當一個事務獲得共享鎖之后,它只可以進行讀操作,所以共享鎖也叫讀鎖;而當一個事務獲得一行數據的互斥鎖時,就可以對該行數據進行讀和寫操作,所以互斥鎖也叫寫鎖。

共享鎖和互斥鎖除了限制事務能夠執行的讀寫操作之外,它們之間還有『共享』和『互斥』的關系,也就是多個事務可以同時獲得某一行數據的共享鎖,但是互斥鎖與共享鎖和其他的互斥鎖并不兼容,我們可以很自然地理解這么設計的原因:多個事務同時寫入同一數據難免會發生各種詭異的問題。

如果當前事務沒有辦法獲取該行數據對應的鎖時就會陷入等待的狀態,直到其他事務將當前數據對應的鎖釋放才可以獲得鎖并執行相應的操作。
兩階段鎖協議
兩階段鎖協議(2PL)是一種能夠保證事務可串行化的協議,它將事務的獲取鎖和釋放鎖劃分成了增長(Growing)和縮減(Shrinking)兩個不同的階段。

在增長階段,一個事務可以獲得鎖但是不能釋放鎖;而在縮減階段事務只可以釋放鎖,并不能獲得新的鎖,如果只看 2PL 的定義,那么到這里就已經介紹完了,但是它還有兩個變種:
Strict 2PL:事務持有的互斥鎖必須在提交后再釋放;
Rigorous 2PL:事務持有的所有鎖必須在提交后釋放;

雖然鎖的使用能夠為我們解決不同事務之間由于并發執行造成的問題,但是兩階段鎖的使用卻引入了另一個嚴重的問題,死鎖;不同的事務等待對方已經鎖定的資源就會造成死鎖,我們在這里舉一個簡單的例子:

兩個事務在剛開始時分別獲取了 draven 和 beacon 資源上面的鎖,然后再請求對方已經獲得的鎖時就會發生死鎖,雙方都沒有辦法等到鎖的釋放,如果沒有死鎖的處理機制就會無限等待下去,兩個事務都沒有辦法完成。
死鎖的處理
死鎖在多線程編程中是經常遇到的事情,一旦涉及多個線程對資源進行爭奪就需要考慮當前的幾個線程或者事務是否會造成死鎖;解決死鎖大體來看有兩種辦法,一種是從源頭杜絕死鎖的產生和出現,另一種是允許系統進入死鎖的狀態,但是在系統出現死鎖時能夠及時發現并且進行恢復。

預防死鎖
有兩種方式可以幫助我們預防死鎖的出現,一種是保證事務之間的等待不會出現環,也就是事務之間的等待圖應該是一張有向無環圖,沒有循環等待的情況或者保證一個事務中想要獲得的所有資源都在事務開始時以原子的方式被鎖定,所有的資源要么被鎖定要么都不被鎖定。
但是這種方式有兩個問題,在事務一開始時很難判斷哪些資源是需要鎖定的,同時因為一些很晚才會用到的數據被提前鎖定,數據的利用率與事務的并發率也非常的低。一種解決的辦法就是按照一定的順序為所有的數據行加鎖,同時與 2PL 協議結合,在加鎖階段保證所有的數據行都是從小到大依次進行加鎖的,不過這種方式依然需要事務提前知道將要加鎖的數據集。
另一種預防死鎖的方法就是使用搶占加事務回滾的方式預防死鎖,當事務開始執行時會先獲得一個時間戳,數據庫程序會根據事務的時間戳決定事務應該等待還是回滾,在這時也有兩種機制供我們選擇,一種是 wait-die 機制:

當執行事務的時間戳小于另一事務時,即事務 A 先于 B 開始,那么它就會等待另一個事務釋放對應資源的鎖,否則就會保持當前的時間戳并回滾。
另一種機制叫做 wound-wait,這是一種搶占的解決方案,它和 wait-die 機制的結果完全相反,當前事務如果先于另一事務執行并請求了另一事務的資源,那么另一事務會立刻回滾,將資源讓給先執行的事務,否則就會等待其他事務釋放資源:

兩種方法都會造成不必要的事務回滾,由此會帶來一定的性能損失,更簡單的解決死鎖的方式就是使用超時時間,但是超時時間的設定是需要仔細考慮的,否則會造成耗時較長的事務無法正常執行,或者無法及時發現需要解決的死鎖,所以它的使用還是有一定的局限性。
死鎖檢測和恢復
如果數據庫程序無法通過協議從原理上保證死鎖不會發生,那么就需要在死鎖發生時及時檢測到并從死鎖狀態恢復到正常狀態保證數據庫程序可以正常工作。在使用檢測和恢復的方式解決死鎖時,數據庫程序需要維護數據和事務之間的引用信息,同時也需要提供一個用于判斷當前數據庫是否進入死鎖狀態的算法,最后需要在死鎖發生時提供合適的策略及時恢復。
在上一節中我們其實提到死鎖的檢測可以通過一個有向的等待圖來進行判斷,如果一個事務依賴于另一個事務正在處理的數據,那么當前事務就會等待另一個事務的結束,這也就是整個等待圖中的一條邊:

如上圖所示,如果在這個有向圖中出現了環,就說明當前數據庫進入了死鎖的狀態?TransB -> TransE -> TransF -> TransD -> TransB,在這時就需要死鎖恢復機制接入了。
如何從死鎖中恢復其實非常簡單,最常見的解決辦法就是選擇整個環中一個事務進行回滾,以打破整個等待圖中的環,在整個恢復的過程中有三個事情需要考慮:

每次出現死鎖時其實都會有多個事務被波及,而選擇其中哪一個任務進行回滾是必須要做的事情,在選擇犧牲品(Victim)時的黃金原則就是最小化代價,所以我們需要綜合考慮事務已經計算的時間、使用的數據行以及涉及的事務等因素;當我們選擇了犧牲品之后就可以開始回滾了,回滾其實有兩種選擇一種是全部回滾,另一種是部分回滾,部分回滾會回滾到事務之前的一個檢查點上,如果沒有檢查點那自然沒有辦法進行部分回滾。
在死鎖恢復的過程中,其實還可能出現某些任務在多次死鎖時都被選擇成為犧牲品,一直都不會成功執行,造成饑餓(Starvation),我們需要保證事務會在有窮的時間內執行,所以要在選擇犧牲品時將時間戳加入考慮的范圍。
鎖的粒度
到目前為止我們都沒有對不同粒度的鎖進行討論,一直以來我們都討論的都是數據行鎖,但是在有些時候我們希望將多個節點看做一個數據單元,使用鎖直接將這個數據單元、表甚至數據庫鎖定起來。這個目標的實現需要我們在數據庫中定義不同粒度的鎖:

當我們擁有了不同粒度的鎖之后,如果某個事務想要鎖定整個數據庫或者整張表時只需要簡單的鎖住對應的節點就會在當前節點加上顯示(explicit)鎖,在所有的子節點上加隱式(implicit)鎖;雖然這種不同粒度的鎖能夠解決父節點被加鎖時,子節點不能被加鎖的問題,但是我們沒有辦法在子節點被加鎖時,立刻確定父節點不能被加鎖。
在這時我們就需要引入意向鎖來解決這個問題了,當需要給子節點加鎖時,先給所有的父節點加對應的意向鎖,意向鎖之間是完全不會互斥的,只是用來幫助父節點快速判斷是否可以對該節點進行加鎖:

這里是一張引入了兩種意向鎖,意向共享鎖和意向互斥鎖之后所有的鎖之間的兼容關系;到這里,我們通過不同粒度的鎖和意向鎖加快了數據庫的吞吐量。
樂觀并發控制
除了悲觀并發控制機制 - 鎖之外,我們其實還有其他的并發控制機制,樂觀并發控制(Optimistic Concurrency Control)。樂觀并發控制也叫樂觀鎖,但是它并不是真正的鎖,很多人都會誤以為樂觀鎖是一種真正的鎖,然而它只是一種并發控制的思想。

在這一節中,我們將會先介紹基于時間戳的并發控制機制,然后在這個協議的基礎上進行擴展,實現樂觀的并發控制機制。
基于時間戳的協議
鎖協議按照不同事務對同一數據項請求的時間依次執行,因為后面執行的事務想要獲取的數據已將被前面的事務加鎖,只能等待鎖的釋放,所以基于鎖的協議執行事務的順序與獲得鎖的順序有關。在這里想要介紹的基于時間戳的協議能夠在事務執行之前先決定事務的執行順序。
每一個事務都會具有一個全局唯一的時間戳,它即可以使用系統的時鐘時間,也可以使用計數器,只要能夠保證所有的時間戳都是唯一并且是隨時間遞增的就可以。

基于時間戳的協議能夠保證事務并行執行的順序與事務按照時間戳串行執行的效果完全相同;每一個數據項都有兩個時間戳,讀時間戳和寫時間戳,分別代表了當前成功執行對應操作的事務的時間戳。
該協議能夠保證所有沖突的讀寫操作都能按照時間戳的大小串行執行,在執行對應的操作時不需要關注其他的事務只需要關心數據項對應時間戳的值就可以了:

無論是讀操作還是寫操作都會從左到右依次比較讀寫時間戳的值,如果小于當前值就會直接被拒絕然后回滾,數據庫系統會給回滾的事務添加一個新的時間戳并重新執行這個事務。
基于驗證的協議
樂觀并發控制其實本質上就是基于驗證的協議,因為在多數的應用中只讀的事務占了絕大多數,事務之間因為寫操作造成沖突的可能非常小,也就是說大多數的事務在不需要并發控制機制也能運行的非常好,也可以保證數據庫的一致性;而并發控制機制其實向整個數據庫系統添加了很多的開銷,我們其實可以通過別的策略降低這部分開銷。
而驗證協議就是我們找到的解決辦法,它根據事務的只讀或者更新將所有事務的執行分為兩到三個階段:

在讀階段,數據庫會執行事務中的全部讀操作和寫操作,并將所有寫后的值存入臨時變量中,并不會真正更新數據庫中的內容;在這時候會進入下一個階段,數據庫程序會檢查當前的改動是否合法,也就是是否有其他事務在 RAED PHASE 期間更新了數據,如果通過測試那么直接就進入 WRITE PHASE 將所有存在臨時變量中的改動全部寫入數據庫,沒有通過測試的事務會直接被終止。
為了保證樂觀并發控制能夠正常運行,我們需要知道一個事務不同階段的發生時間,包括事務開始時間、驗證階段的開始時間以及寫階段的結束時間;通過這三個時間戳,我們可以保證任意沖突的事務不會同時寫入數據庫,一旦由一個事務完成了驗證階段就會立即寫入,其他讀取了相同數據的事務就會回滾重新執行。
作為樂觀的并發控制機制,它會假定所有的事務在最終都會通過驗證階段并且執行成功,而鎖機制和基于時間戳排序的協議是悲觀的,因為它們會在發生沖突時強制事務進行等待或者回滾,哪怕有不需要鎖也能夠保證事務之間不會沖突的可能。
多版本并發控制
到目前為止我們介紹的并發控制機制其實都是通過延遲或者終止相應的事務來解決事務之間的競爭條件(Race condition)來保證事務的可串行化;雖然前面的兩種并發控制機制確實能夠從根本上解決并發事務的可串行化的問題,但是在實際環境中數據庫的事務大都是只讀的,讀請求是寫請求的很多倍,如果寫請求和讀請求之前沒有并發控制機制,那么最壞的情況也是讀請求讀到了已經寫入的數據,這對很多應用完全是可以接受的。

在這種大前提下,數據庫系統引入了另一種并發控制機制 -?多版本并發控制(Multiversion Concurrency Control),每一個寫操作都會創建一個新版本的數據,讀操作會從有限多個版本的數據中挑選一個最合適的結果直接返回;在這時,讀寫操作之間的沖突就不再需要被關注,而管理和快速挑選數據的版本就成了 MVCC 需要解決的主要問題。
MVCC 并不是一個與樂觀和悲觀并發控制對立的東西,它能夠與兩者很好的結合以增加事務的并發量,在目前最流行的 SQL 數據庫 MySQL 和 PostgreSQL 中都對 MVCC 進行了實現;但是由于它們分別實現了悲觀鎖和樂觀鎖,所以 MVCC 實現的方式也不同。
MySQL 與 MVCC
MySQL 中實現的多版本兩階段鎖協議(Multiversion 2PL)將 MVCC 和 2PL 的優點結合了起來,每一個版本的數據行都具有一個唯一的時間戳,當有讀事務請求時,數據庫程序會直接從多個版本的數據項中具有最大時間戳的返回。

更新操作就稍微有些復雜了,事務會先讀取最新版本的數據計算出數據更新后的結果,然后創建一個新版本的數據,新數據的時間戳是目前數據行的最大版本?+1:

數據版本的刪除也是根據時間戳來選擇的,MySQL 會將版本最低的數據定時從數據庫中清除以保證不會出現大量的遺留內容。
PostgreSQL 與 MVCC
與 MySQL 中使用悲觀并發控制不同,PostgreSQL 中都是使用樂觀并發控制的,這也就導致了 MVCC 在于樂觀鎖結合時的實現上有一些不同,最終實現的叫做多版本時間戳排序協議(Multiversion Timestamp Ordering),在這個協議中,所有的的事務在執行之前都會被分配一個唯一的時間戳,每一個數據項都有讀寫兩個時間戳:

當 PostgreSQL 的事務發出了一個讀請求,數據庫直接將最新版本的數據返回,不會被任何操作阻塞,而寫操作在執行時,事務的時間戳一定要大或者等于數據行的讀時間戳,否則就會被回滾。
這種 MVCC 的實現保證了讀事務永遠都不會失敗并且不需要等待鎖的釋放,對于讀請求遠遠多于寫請求的應用程序,樂觀鎖加 MVCC 對數據庫的性能有著非常大的提升;雖然這種協議能夠針對一些實際情況做出一些明顯的性能提升,但是也會導致兩個問題,一個是每一次讀操作都會更新讀時間戳造成兩次的磁盤寫入,第二是事務之間的沖突是通過回滾解決的,所以如果沖突的可能性非常高或者回滾代價巨大,數據庫的讀寫性能還不如使用傳統的鎖等待方式。
總結
數據庫的并發控制機制到今天已經有了非常成熟、完善的解決方案,我們并不需要自己去設計一套新的協議來處理不同事務之間的沖突問題,從數據庫的并發控制機制中學習到的相關知識,無論是鎖還是樂觀并發控制在其他的領域或者應用中都被廣泛使用,所以了解、熟悉不同的并發控制機制的原理是很有必要的。
Reference
淺談數據庫并發控制 - 鎖和 MVCC · 面向信仰編程
PESSIMISTIC vs. OPTIMISTIC concurrency control
PostgreSQL Concurrency with MVCC
Well-known Databases Use Different Approaches for MVCC
Serializability
Race condition
推薦閱讀
大神是如何學習 Go 之并發編程與定時器
大神是如何學習 Go 之并發編程與同步原語
喜歡本文的朋友,歡迎關注“Go語言中文網”:
Go語言中文網啟用微信學習交流群,歡迎加微信:274768166
總結
以上是生活随笔為你收集整理的并发执行变成串行_大神浅谈数据库并发控制 锁和 MVCC的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: python+selenium获取coo
- 下一篇: 国防科大JAVA工程师笔试题_国防科大人