重新學習MySQL數據庫6:淺談MySQL的中事務與鎖

本文轉自互聯網

本系列文章將整理到我在GitHub上的《Java面試指南》倉庫,更多精彩內容請到我的倉庫裏查看

https://github.com/h2pl/Java-Tutorial

喜歡的話麻煩點下Star哈

文章首發於我的個人博客:

www.how2playlife.com

本文是微信公衆號【Java技術江湖】的《重新學習MySQL數據庫》其中一篇,本文部分內容來源於網絡,爲了把本文主題講得清晰透徹,也整合了很多我認爲不錯的技術博客內容,引用其中了一些比較好的博客文章,如有侵權,請聯繫作者。

該系列博文會告訴你如何從入門到進階,從sql基本的使用方法,從MySQL執行引擎再到索引、事務等知識,一步步地學習MySQL相關技術的實現原理,更好地瞭解如何基於這些知識來優化sql,減少SQL執行時間,通過執行計劃對SQL性能進行分析,再到MySQL的主從複製、主備部署等內容,以便讓你更完整地瞭解整個MySQL方面的技術體系,形成自己的知識框架。

如果對本系列文章有什麼建議,或者是有什麼疑問的話,也可以關注公衆號【Java技術江湖】聯繫作者,歡迎你參與本系列博文的創作和修訂。

<!-- more -->

『淺入深出』MySQL 中事務的實現

在關係型數據庫中,事務的重要性不言而喻,只要對數據庫稍有了解的人都知道事務具有 ACID 四個基本屬性,而我們不知道的可能就是數據庫是如何實現這四個屬性的;在這篇文章中,我們將對事務的實現進行分析,嘗試理解數據庫是如何實現事務的,當然我們也會在文章中簡單對 MySQL 中對 ACID 的實現進行簡單的介紹。

事務其實就是併發控制的基本單位;相信我們都知道,事務是一個序列操作,其中的操作要麼都執行,要麼都不執行,它是一個不可分割的工作單位;數據庫事務的 ACID 四大特性是事務的基礎,瞭解了 ACID 是如何實現的,我們也就清除了事務的實現,接下來我們將依次介紹數據庫是如何實現這四個特性的。

原子性

在學習事務時,經常有人會告訴你,事務就是一系列的操作,要麼全部都執行,要都不執行,這其實就是對事務原子性的刻畫;雖然事務具有原子性,但是原子性並不是只與事務有關係,它的身影在很多地方都會出現。

由於操作並不具有原子性,並且可以再分爲多個操作,當這些操作出現錯誤或拋出異常時,整個操作就可能不會繼續執行下去,而已經進行的操作造成的副作用就可能造成數據更新的丟失或者錯誤。

事務其實和一個操作沒有什麼太大的區別,它是一系列的數據庫操作(可以理解爲 SQL)的集合,如果事務不具備原子性,那麼就沒辦法保證同一個事務中的所有操作都被執行或者未被執行了,整個數據庫系統就既不可用也不可信。

回滾日誌

想要保證事務的原子性,就需要在異常發生時,對已經執行的操作進行回滾,而在 MySQL 中,恢復機制是通過回滾日誌(undo log)實現的,所有事務進行的修改都會先記錄到這個回滾日誌中,然後在對數據庫中的對應行進行寫入。

這個過程其實非常好理解,爲了能夠在發生錯誤時撤銷之前的全部操作,肯定是需要將之前的操作都記錄下來的,這樣在發生錯誤時纔可以回滾。

回滾日誌除了能夠在發生錯誤或者用戶執行 ROLLBACK 時提供回滾相關的信息,它還能夠在整個系統發生崩潰、數據庫進程直接被殺死後,當用戶再次啓動數據庫進程時,還能夠立刻通過查詢回滾日誌將之前未完成的事務進行回滾,這也就需要回滾日誌必須先於數據持久化到磁盤上,是我們需要先寫日誌後寫數據庫的主要原因。

回滾日誌並不能將數據庫物理地恢復到執行語句或者事務之前的樣子;它是邏輯日誌,當回滾日誌被使用時,它只會按照日誌邏輯地將數據庫中的修改撤銷掉看,可以理解爲,我們在事務中使用的每一條 INSERT 都對應了一條 DELETE,每一條 UPDATE 也都對應一條相反的 UPDATE 語句。

在這裏,我們並不會介紹回滾日誌的格式以及它是如何被管理的,本文重點關注在它到底是一個什麼樣的東西,究竟解決了、如何解決了什麼樣的問題,如果想要了解具體實現細節的讀者,相信網絡上關於回滾日誌的文章一定不少。

事務的狀態

因爲事務具有原子性,所以從遠處看的話,事務就是密不可分的一個整體,事務的狀態也只有三種:Active、Commited 和 Failed,事務要不就在執行中,要不然就是成功或者失敗的狀態:

但是如果放大來看,我們會發現事務不再是原子的,其中包括了很多中間狀態,比如部分提交,事務的狀態圖也變得越來越複雜。

事務的狀態圖以及狀態的描述取自 Database System Concepts 一書中第 14 章的內容。

  • Active:事務的初始狀態,表示事務正在執行;

  • Partially Commited:在最後一條語句執行之後;

  • Failed:發現事務無法正常執行之後;

  • Aborted:事務被回滾並且數據庫恢復到了事務進行之前的狀態之後;

  • Commited:成功執行整個事務;

雖然在發生錯誤時,整個數據庫的狀態可以恢復,但是如果我們在事務中執行了諸如:向標準輸出打印日誌、向外界發出郵件、沒有通過數據庫修改了磁盤上的內容甚至在事務執行期間發生了轉賬匯款,那麼這些操作作爲可見的外部輸出都是沒有辦法回滾的;這些問題都是由應用開發者解決和負責的,在絕大多數情況下,我們都需要在整個事務提交後,再觸發類似的無法回滾的操作

以訂票爲例,哪怕我們在整個事務結束之後,才向第三方發起請求,由於向第三方請求並獲取結果是一個需要較長事件的操作,如果在事務剛剛提交時,數據庫或者服務器發生了崩潰,那麼我們就非常有可能丟失發起請求這一過程,這就造成了非常嚴重的問題;而這一點就不是數據庫所能保證的,開發者需要在適當的時候查看請求是否被髮起、結果是成功還是失敗。

並行事務的原子性

到目前爲止,所有的事務都只是串行執行的,一直都沒有考慮過並行執行的問題;然而在實際工作中,並行執行的事務纔是常態,然而並行任務下,卻可能出現非常複雜的問題:

當 Transaction1 在執行的過程中對 id = 1 的用戶進行了讀寫,但是沒有將修改的內容進行提交或者回滾,在這時 Transaction2 對同樣的數據進行了讀操作並提交了事務;也就是說 Transaction2 是依賴於 Transaction1 的,當 Transaction1 由於一些錯誤需要回滾時,因爲要保證事務的原子性,需要對 Transaction2 進行回滾,但是由於我們已經提交了 Transaction2,所以我們已經沒有辦法進行回滾操作,在這種問題下我們就發生了問題,Database System Concepts 一書中將這種現象稱爲不可恢復安排(Nonrecoverable Schedule),那什麼情況下是可以恢復的呢?

A recoverable schedule is one where, for each pair of transactions Ti and Tj such that Tj reads a data item previously written by Ti , the commit operation of Ti appears before the commit operation of Tj .

簡單理解一下,如果 Transaction2 依賴於事務 Transaction1,那麼事務 Transaction1 必須在 Transaction2 提交之前完成提交的操作:

然而這樣還不算完,當事務的數量逐漸增多時,整個恢復流程也會變得越來越複雜,如果我們想要從事務發生的錯誤中恢復,也不是一件那麼容易的事情。

在上圖所示的一次事件中,Transaction2 依賴於 Transaction1,而 Transaction3 又依賴於 Transaction1,當 Transaction1 由於執行出現問題發生回滾時,爲了保證事務的原子性,就會將 Transaction2 和 Transaction3 中的工作全部回滾,這種情況也叫做級聯回滾(Cascading Rollback),級聯回滾的發生會導致大量的工作需要撤回,是我們難以接受的,不過如果想要達到絕對的原子性,這件事情又是不得不去處理的,我們會在文章的後面具體介紹如何處理並行事務的原子性。

持久性

既然是數據庫,那麼一定對數據的持久存儲有着非常強烈的需求,如果數據被寫入到數據庫中,那麼數據一定能夠被安全存儲在磁盤上;而事務的持久性就體現在,一旦事務被提交,那麼數據一定會被寫入到數據庫中並持久存儲起來。

當事務已經被提交之後,就無法再次回滾了,唯一能夠撤回已經提交的事務的方式就是創建一個相反的事務對原操作進行『補償』,這也是事務持久性的體現之一。

重做日誌

與原子性一樣,事務的持久性也是通過日誌來實現的,MySQL 使用重做日誌(redo log)實現事務的持久性,重做日誌由兩部分組成,一是內存中的重做日誌緩衝區,因爲重做日誌緩衝區在內存中,所以它是易失的,另一個就是在磁盤上的重做日誌文件,它是持久的

當我們在一個事務中嘗試對數據進行修改時,它會先將數據從磁盤讀入內存,並更新內存中緩存的數據,然後生成一條重做日誌並寫入重做日誌緩存,當事務真正提交時,MySQL 會將重做日誌緩存中的內容刷新到重做日誌文件,再將內存中的數據更新到磁盤上,圖中的第 4、5 步就是在事務提交時執行的。

在 InnoDB 中,重做日誌都是以 512 字節的塊的形式進行存儲的,同時因爲塊的大小與磁盤扇區大小相同,所以重做日誌的寫入可以保證原子性,不會由於機器斷電導致重做日誌僅寫入一半並留下髒數據。

除了所有對數據庫的修改會產生重做日誌,因爲回滾日誌也是需要持久存儲的,它們也會創建對應的重做日誌,在發生錯誤後,數據庫重啓時會從重做日誌中找出未被更新到數據庫磁盤中的日誌重新執行以滿足事務的持久性。

回滾日誌和重做日誌

到現在爲止我們瞭解了 MySQL 中的兩種日誌,回滾日誌(undo log)和重做日誌(redo log);在數據庫系統中,事務的原子性和持久性是由事務日誌(transaction log)保證的,在實現時也就是上面提到的兩種日誌,前者用於對事務的影響進行撤銷,後者在錯誤處理時對已經提交的事務進行重做,它們能保證兩點:

  1. 發生錯誤或者需要回滾的事務能夠成功回滾(原子性);

  2. 在事務提交後,數據沒來得及寫會磁盤就宕機時,在下次重新啓動後能夠成功恢復數據(持久性);

在數據庫中,這兩種日誌經常都是一起工作的,我們可以將它們整體看做一條事務日誌,其中包含了事務的 ID、修改的行元素以及修改前後的值。

一條事務日誌同時包含了修改前後的值,能夠非常簡單的進行回滾和重做兩種操作,在這裏我們也不會對重做和回滾日誌展開進行介紹,可能會在之後的文章談一談數據庫系統的恢復機制時提到兩種日誌的使用。

隔離性

其實作者在之前的文章 『淺入淺出』MySQL 和 InnoDB 就已經介紹過數據庫事務的隔離性,不過爲了保證文章的獨立性和完整性,我們還會對事務的隔離性進行介紹,介紹的內容可能稍微有所不同。

事務的隔離性是數據庫處理數據的幾大基礎之一,如果沒有數據庫的事務之間沒有隔離性,就會發生在 並行事務的原子性 一節中提到的級聯回滾等問題,造成性能上的巨大損失。如果所有的事務的執行順序都是線性的,那麼對於事務的管理容易得多,但是允許事務的並行執行卻能能夠提升吞吐量和資源利用率,並且可以減少每個事務的等待時間。

當多個事務同時併發執行時,事務的隔離性可能就會被違反,雖然單個事務的執行可能沒有任何錯誤,但是從總體來看就會造成數據庫的一致性出現問題,而串行雖然能夠允許開發者忽略並行造成的影響,能夠很好地維護數據庫的一致性,但是卻會影響事務執行的性能。

事務的隔離級別

所以說數據庫的隔離性和一致性其實是一個需要開發者去權衡的問題,爲數據庫提供什麼樣的隔離性層級也就決定了數據庫的性能以及可以達到什麼樣的一致性;在 SQL 標準中定義了四種數據庫的事務的隔離級別:READ UNCOMMITED、READ COMMITED、REPEATABLE READ 和 SERIALIZABLE;每個事務的隔離級別其實都比上一級多解決了一個問題:

  • RAED UNCOMMITED:使用查詢語句不會加鎖,可能會讀到未提交的行(Dirty Read);

  • READ COMMITED:只對記錄加記錄鎖,而不會在記錄之間加間隙鎖,所以允許新的記錄插入到被鎖定記錄的附近,所以再多次使用查詢語句時,可能得到不同的結果(Non-Repeatable Read);

  • REPEATABLE READ:多次讀取同一範圍的數據會返回第一次查詢的快照,不會返回不同的數據行,但是可能發生幻讀(Phantom Read);

  • SERIALIZABLE:InnoDB 隱式地將全部的查詢語句加上共享鎖,解決了幻讀的問題;

以上的所有的事務隔離級別都不允許髒寫入(Dirty Write),也就是當前事務更新了另一個事務已經更新但是還未提交的數據,大部分的數據庫中都使用了 READ COMMITED 作爲默認的事務隔離級別,但是 MySQL 使用了 REPEATABLE READ 作爲默認配置;從 RAED UNCOMMITED 到 SERIALIZABLE,隨着事務隔離級別變得越來越嚴格,數據庫對於併發執行事務的性能也逐漸下降。

對於數據庫的使用者,從理論上說,並不需要知道事務的隔離級別是如何實現的,我們只需要知道這個隔離級別解決了什麼樣的問題,但是不同數據庫對於不同隔離級別的是實現細節在很多時候都會讓我們遇到意料之外的坑。

如果讀者不瞭解髒讀、不可重複讀和幻讀究竟是什麼,可以閱讀之前的文章 『淺入淺出』MySQL 和 InnoDB,在這裏我們僅放一張圖來展示各個隔離層級對這幾個問題的解決情況。

隔離級別的實現

數據庫對於隔離級別的實現就是使用併發控制機制對在同一時間執行的事務進行控制,限制不同的事務對於同一資源的訪問和更新,而最重要也最常見的併發控制機制,在這裏我們將簡單介紹三種最重要的併發控制器機制的工作原理。

鎖是一種最爲常見的併發控制機制,在一個事務中,我們並不會將整個數據庫都加鎖,而是隻會鎖住那些需要訪問的數據項, MySQL 和常見數據庫中的鎖都分爲兩種,共享鎖(Shared)和互斥鎖(Exclusive),前者也叫讀鎖,後者叫寫鎖。

讀鎖保證了讀操作可以併發執行,相互不會影響,而寫鎖保證了在更新數據庫數據時不會有其他的事務訪問或者更改同一條記錄造成不可預知的問題。

時間戳

除了鎖,另一種實現事務的隔離性的方式就是通過時間戳,使用這種方式實現事務的數據庫,例如 PostgreSQL 會爲每一條記錄保留兩個字段;讀時間戳中報錯了所有訪問該記錄的事務中的最大時間戳,而記錄行的寫時間戳中保存了將記錄改到當前值的事務的時間戳。

使用時間戳實現事務的隔離性時,往往都會使用樂觀鎖,先對數據進行修改,在寫回時再去判斷當前值,也就是時間戳是否改變過,如果沒有改變過,就寫入,否則,生成一個新的時間戳並再次更新數據,樂觀鎖其實並不是真正的鎖機制,它只是一種思想,在這裏並不會對它進行展開介紹。

多版本和快照隔離

通過維護多個版本的數據,數據庫可以允許事務在數據被其他事務更新時對舊版本的數據進行讀取,很多數據庫都對這一機制進行了實現;因爲所有的讀操作不再需要等待寫鎖的釋放,所以能夠顯著地提升讀的性能,MySQL 和 PostgreSQL 都對這一機制進行自己的實現,也就是 MVCC,雖然各自實現的方式有所不同,MySQL 就通過文章中提到的回滾日誌實現了 MVCC,保證事務並行執行時能夠不等待互斥鎖的釋放直接獲取數據。

隔離性與原子性

在這裏就需要簡單提一下在在原子性一節中遇到的級聯回滾等問題了,如果一個事務對數據進行了寫入,這時就會獲取一個互斥鎖,其他的事務就想要獲得改行數據的讀鎖就必須等待寫鎖的釋放,自然就不會發生級聯回滾等問題了。

不過在大多數的數據庫,比如 MySQL 中都使用了 MVCC 等特性,也就是正常的讀方法是不需要獲取鎖的,在想要對讀取的數據進行更新時需要使用 SELECT ... FOR UPDATE 嘗試獲取對應行的互斥鎖,以保證不同事務可以正常工作。

一致性

作者認爲數據庫的一致性是一個非常讓人迷惑的概念,原因是數據庫領域其實包含兩個一致性,一個是 ACID 中的一致性、另一個是 CAP 定義中的一致性。

這兩個數據庫的一致性說的完全不是一個事情,很多很多人都對這兩者的概念有非常深的誤解,當我們在討論數據庫的一致性時,一定要清楚上下文的語義是什麼,儘量明確的問出我們要討論的到底是 ACID 中的一致性還是 CAP 中的一致性。

ACID

數據庫對於 ACID 中的一致性的定義是這樣的:如果一個事務原子地在一個一致地數據庫中獨立運行,那麼在它執行之後,數據庫的狀態一定是一致的。對於這個概念,它的第一層意思就是對於數據完整性的約束,包括主鍵約束、引用約束以及一些約束檢查等等,在事務的執行的前後以及過程中不會違背對數據完整性的約束,所有對數據庫寫入的操作都應該是合法的,並不能產生不合法的數據狀態。

A transaction must preserve database consistency - if a transaction is run atomically in isolation starting from a consistent database, the database must again be consistent at the end of the transaction.

我們可以將事務理解成一個函數,它接受一個外界的 SQL 輸入和一個一致的數據庫,它一定會返回一個一致的數據庫。

而第二層意思其實是指邏輯上的對於開發者的要求,我們要在代碼中寫出正確的事務邏輯,比如銀行轉賬,事務中的邏輯不可能只扣錢或者只加錢,這是應用層面上對於數據庫一致性的要求。

Ensuring consistency for an individual transaction is the responsibility of the application programmer who codes the transaction. - Database System Concepts

數據庫 ACID 中的一致性對事務的要求不止包含對數據完整性以及合法性的檢查,還包含應用層面邏輯的正確。

CAP 定理中的數據一致性,其實是說分佈式系統中的各個節點中對於同一數據的拷貝有着相同的值;而 ACID 中的一致性是指數據庫的規則,如果 schema 中規定了一個值必須是唯一的,那麼一致的系統必須確保在所有的操作中,該值都是唯一的,由此來看 CAP 和 ACID 對於一致性的定義有着根本性的區別。

總結

事務的 ACID 四大基本特性是保證數據庫能夠運行的基石,但是完全保證數據庫的 ACID,尤其是隔離性會對性能有比較大影響,在實際的使用中我們也會根據業務的需求對隔離性進行調整,除了隔離性,數據庫的原子性和持久性相信都是比較好理解的特性,前者保證數據庫的事務要麼全部執行、要麼全部不執行,後者保證了對數據庫的寫入都是持久存儲的、非易失的,而一致性不僅是數據庫對本身數據的完整性的要求,同時也對開發者提出了要求 - 寫出邏輯正確並且合理的事務。

最後,也是最重要的,當別人在將一致性的時候,一定要搞清楚他的上下文,如果對文章的內容有疑問,可以在評論中留言。

淺談數據庫併發控制 - 鎖和 MVCC

轉自https://draveness.me/database-concurrency-control

在學習幾年編程之後,你會發現所有的問題都沒有簡單、快捷的解決方案,很多問題都需要權衡和妥協,而本文介紹的就是數據庫在併發性能和可串行化之間做的權衡和妥協 - 併發控制機制。

如果數據庫中的所有事務都是串行執行的,那麼它非常容易成爲整個應用的性能瓶頸,雖然說沒法水平擴展的節點在最後都會成爲瓶頸,但是串行執行事務的數據庫會加速這一過程;而併發(Concurrency)使一切事情的發生都有了可能,它能夠解決一定的性能問題,但是它會帶來更多詭異的錯誤。

引入了併發事務之後,如果不對事務的執行進行控制就會出現各種各樣的問題,你可能沒有享受到併發帶來的性能提升就已經被各種奇怪的問題折磨的欲仙欲死了。

概述

如何控制併發是數據庫領域中非常重要的問題之一,不過到今天爲止事務併發的控制已經有了很多成熟的解決方案,而這些方案的原理就是這篇文章想要介紹的內容,文章中會介紹最爲常見的三種併發控制機制:

分別是悲觀併發控制、樂觀併發控制和多版本併發控制,其中悲觀併發控制其實是最常見的併發控制機制,也就是鎖;而樂觀併發控制其實也有另一個名字:樂觀鎖,樂觀鎖其實並不是一種真實存在的鎖,我們會在文章後面的部分中具體介紹;最後就是多版本併發控制(MVCC)了,與前兩者對立的命名不同,MVCC 可以與前兩者中的任意一種機制結合使用,以提高數據庫的讀性能。

既然這篇文章介紹了不同的併發控制機制,那麼一定會涉及到不同事務的併發,我們會通過示意圖的方式分析各種機制是如何工作的。

悲觀併發控制

控制不同的事務對同一份數據的獲取是保證數據庫的一致性的最根本方法,如果我們能夠讓事務在同一時間對同一資源有着獨佔的能力,那麼就可以保證操作同一資源的不同事務不會相互影響。

最簡單的、應用最廣的方法就是使用鎖來解決,當事務需要對資源進行操作時需要先獲得資源對應的鎖,保證其他事務不會訪問該資源後,在對資源進行各種操作;在悲觀併發控制中,數據庫程序對於數據被修改持悲觀的態度,在數據處理的過程中都會被鎖定,以此來解決競爭的問題。

讀寫鎖

爲了最大化數據庫事務的併發能力,數據庫中的鎖被設計爲兩種模式,分別是共享鎖和互斥鎖。當一個事務獲得共享鎖之後,它只可以進行讀操作,所以共享鎖也叫讀鎖;而當一個事務獲得一行數據的互斥鎖時,就可以對該行數據進行讀和寫操作,所以互斥鎖也叫寫鎖。

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

如果當前事務沒有辦法獲取該行數據對應的鎖時就會陷入等待的狀態,直到其他事務將當前數據對應的鎖釋放纔可以獲得鎖並執行相應的操作。

兩階段鎖協議

兩階段鎖協議(2PL)是一種能夠保證事務可串行化的協議,它將事務的獲取鎖和釋放鎖劃分成了增長(Growing)和縮減(Shrinking)兩個不同的階段。

在增長階段,一個事務可以獲得鎖但是不能釋放鎖;而在縮減階段事務只可以釋放鎖,並不能獲得新的鎖,如果只看 2PL 的定義,那麼到這裏就已經介紹完了,但是它還有兩個變種:

  1. Strict 2PL:事務持有的互斥鎖必須在提交後再釋放;

  2. 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 對數據庫的性能有着非常大的提升;雖然這種協議能夠針對一些實際情況做出一些明顯的性能提升,但是也會導致兩個問題,一個是每一次讀操作都會更新讀時間戳造成兩次的磁盤寫入,第二是事務之間的衝突是通過回滾解決的,所以如果衝突的可能性非常高或者回滾代價巨大,數據庫的讀寫性能還不如使用傳統的鎖等待方式。

1. MVCC簡介與實踐

MySQL 在InnoDB引擎下有當前讀和快照讀兩種模式。

1 當前讀即加鎖讀,讀取記錄的最新版本號,會加鎖保證其他併發事物不能修改當前記錄,直至釋放鎖。插入/更新/刪除操作默認使用當前讀,顯示的爲select語句加lock in share mode或for update的查詢也採用當前讀模式。

2 快照讀:不加鎖,讀取記錄的快照版本,而非最新版本,使用MVCC機制,最大的好處是讀取不需要加鎖,讀寫不衝突,用於讀操作多於寫操作的應用,因此在不顯示加[lock in share mode]/[for update]的select語句,即普通的一條select語句默認都是使用快照讀MVCC實現模式。所以樓主的爲了讓大家明白所做的演示操作,既有當前讀也有快照讀……

1.1 什麼是MVCC

MVCC是一種多版本併發控制機制。

1.2 MVCC是爲了解決什麼問題?

  • 大多數的MYSQL事務型存儲引擎,如,InnoDB,Falcon以及PBXT都不使用一種簡單的行鎖機制.事實上,他們都和MVCC–多版本併發控制來一起使用.

  • 大家都應該知道,鎖機制可以控制併發操作,但是其系統開銷較大,而MVCC可以在大多數情況下代替行級鎖,使用MVCC,能降低其系統開銷.

1.3 MVCC實現

MVCC是通過保存數據在某個時間點的快照來實現的. 不同存儲引擎的MVCC. 不同存儲引擎的MVCC實現是不同的,典型的有樂觀併發控制和悲觀併發控制.

2.MVCC 具體實現分析

下面,我們通過InnoDB的MVCC實現來分析MVCC使怎樣進行併發控制的.  InnoDB的MVCC,是通過在每行記錄後面保存兩個隱藏的列來實現的,這兩個列,分別保存了這個行的創建時間,一個保存的是行的刪除時間。這裏存儲的並不是實際的時間值,而是系統版本號(可以理解爲事務的ID),沒開始一個新的事務,系統版本號就會自動遞增,事務開始時刻的系統版本號會作爲事務的ID.下面看一下在REPEATABLE READ隔離級別下,MVCC具體是如何操作的.

2.1簡單的小例子

create table yang(  id int primary key auto_increment,  name varchar(20));

假設系統的版本號從1開始.

INSERT

InnoDB爲新插入的每一行保存當前系統版本號作爲版本號.  第一個事務ID爲1;

<pre>start transaction; insert into yang values(NULL,'yang') ; insert into yang values(NULL,'long'); insert into yang values(NULL,'fei'); commit;

</pre>

對應在數據中的表如下(後面兩列是隱藏列,我們通過查詢語句並看不到)

id name 創建時間(事務ID) 刪除時間(事務ID)
1 yang 1 undefined
2 long 1 undefined
3 fei 1 undefined

SELECT

InnoDB會根據以下兩個條件檢查每行記錄:  a.InnoDB只會查找版本早於當前事務版本的數據行(也就是,行的系統版本號小於或等於事務的系統版本號),這樣可以確保事務讀取的行,要麼是在事務開始前已經存在的,要麼是事務自身插入或者修改過的.  b.行的刪除版本要麼未定義,要麼大於當前事務版本號,這可以確保事務讀取到的行,在事務開始之前未被刪除.  只有a,b同時滿足的記錄,才能返回作爲查詢結果.

DELETE

InnoDB會爲刪除的每一行保存當前系統的版本號(事務的ID)作爲刪除標識.  看下面的具體例子分析:  第二個事務,ID爲2;

<pre>start transaction; select from yang; //(1) select from yang; //(2) commit;

</pre>

假設1

假設在執行這個事務ID爲2的過程中,剛執行到(1),這時,有另一個事務ID爲3往這個表裏插入了一條數據;  第三個事務ID爲3;

<pre>start transaction; insert into yang values(NULL,'tian'); commit;

</pre>

這時表中的數據如下:

id name 創建時間(事務ID) 刪除時間(事務ID)
1 yang 1 undefined
2 long 1 undefined
3 fei 1 undefined
4 tian 3 undefined

然後接着執行事務2中的(2),由於id=4的數據的創建時間(事務ID爲3),執行當前事務的ID爲2,而InnoDB只會查找事務ID小於等於當前事務ID的數據行,所以id=4的數據行並不會在執行事務2中的(2)被檢索出來,在事務2中的兩條select 語句檢索出來的數據都只會下表:

id name 創建時間(事務ID) 刪除時間(事務ID)
1 yang 1 undefined
2 long 1 undefined
3 fei 1 undefined

假設2

假設在執行這個事務ID爲2的過程中,剛執行到(1),假設事務執行完事務3後,接着又執行了事務4;  第四個事務:

<pre>start transaction; delete from yang where id=1; commit;

</pre>

此時數據庫中的表如下:

id name 創建時間(事務ID) 刪除時間(事務ID)
1 yang 1 4
2 long 1 undefined
3 fei 1 undefined
4 tian 3 undefined

接着執行事務ID爲2的事務(2),根據SELECT 檢索條件可以知道,它會檢索創建時間(創建事務的ID)小於當前事務ID的行和刪除時間(刪除事務的ID)大於當前事務的行,而id=4的行上面已經說過,而id=1的行由於刪除時間(刪除事務的ID)大於當前事務的ID,所以事務2的(2)select * from yang也會把id=1的數據檢索出來.所以,事務2中的兩條select 語句檢索出來的數據都如下:

id name 創建時間(事務ID) 刪除時間(事務ID)
1 yang 1 4
2 long 1 undefined
3 fei 1 undefined

UPDATE

InnoDB執行UPDATE,實際上是新插入了一行記錄,並保存其創建時間爲當前事務的ID,同時保存當前事務ID到要UPDATE的行的刪除時間.

假設3

假設在執行完事務2的(1)後又執行,其它用戶執行了事務3,4,這時,又有一個用戶對這張表執行了UPDATE操作:  第5個事務:

<pre>start transaction; update yang set name='Long' where id=2; commit;

</pre>

根據update的更新原則:會生成新的一行,並在原來要修改的列的刪除時間列上添加本事務ID,得到表如下:

id name 創建時間(事務ID) 刪除時間(事務ID)
1 yang 1 4
2 long 1 5
3 fei 1 undefined
4 tian 3 undefined
2 Long 5 undefined

繼續執行事務2的(2),根據select 語句的檢索條件,得到下表:

id name 創建時間(事務ID) 刪除時間(事務ID)
1 yang 1 4
2 long 1 5
3 fei 1 undefined

還是和事務2中(1)select 得到相同的結果.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章