併發環境下,先運算元據庫還是先操作快取?

資料庫工作筆記發表於2023-12-29

背景

當你手中抓住一件東西不放時,你只能擁有一件東西,如果你肯放手,你就有機會選擇更多。與其在別人的生活裡跑龍套,不如精彩做自己。人無所舍,必無所成。跌倒了,失去了,不要緊,爬起來繼續風雨兼程,且歌且行。

併發環境下,先運算元據庫還是先操作快取?

一、概念

事務到底是什麼東西呢?想必大家學習的時候也是對事務的概念很模糊的。接下來通過一個經典例子講解事務。

銀行在兩個賬戶之間轉賬,從A賬戶轉入B賬戶1000元,系統先減少A賬戶的1000元,然後再為B賬號增加1000元。如果全部執行成功,資料庫處於一致性;如果僅執行完A賬戶金額的修改,而沒有增加B賬戶的金額,則資料庫就處於不一致狀態,這時就需要取消前面的操作。這過程中會有一系列的操作,比如餘額查詢,餘額做加減法,更新餘額等,這些操作必須保證是一個整體執行,要麼全部成功,要麼全部失敗,不能讓A賬戶錢扣了,但是中途某些操作失敗了,導致B賬戶更新餘額失敗。這樣使用者就不樂意了,銀行這不是坑我嗎?因此簡單來說,事務就是要保證一組資料庫操作,要麼全部成功,要麼全部失敗。在MySQL中,事務支援是在引擎層實現的。你現在知道,MySQL是一個支援多引擎的系統,但並不是所有的引擎都支援事務。比如MySQL原生的MyISAM引擎就不支援事務,這也是MyISAM被InnoDB取代的重要原因之一。

接下來會以InnoDB為例,抽絲剝繭MySQL在事務支援方面的特定實現。

二、隔離性與隔離級別

提到事務,你肯定會想到ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔離性、永續性),接下來我們就要講解其中的I,也就是“隔離性”。

當資料庫上存在多個事務同時執行的時候,就可能出現髒讀(dirty read)、不可重複讀(non-repeatable read)、幻讀(phantom read)的問題,為了解決這些問題,就有了“隔離級別”的概念。

我們知道,隔離界別越高,效率就越低,因此我們很多情況下需要在二者之間找到一個平衡點。SQL標準的事務隔離級別包括:讀未提交(read uncommitted)、讀提交(read committed)、可重複讀(repeatable read)和序列化(serializable )。下面我逐一為你解釋:

讀未提交:事務中的修改,即使沒有提交,對其他事務也都是可見的,事務可以讀取未提交的資料,也被稱為髒讀(Dirty Read)。這個級別會導致很多問題,從效能上來說也不會比其他隔離級別好很多,但卻缺乏其他級別的很多好處,一般實際應用中很少用,甚至有些資料庫內部根本就沒有實現。

讀已提交:事務從開始直到提交之前,所做的任何修改對其他事務都是不可見的,這個級別有時候也叫做不可重複讀(Nonrepeatable Read),因為同一事務中兩次執行同樣的查詢,可能會得到不一樣的結果

可重複度:同個事務中多次查詢結果是一致的,解決了不可重複讀的問題。此隔離級別下還是無法解決另外一個幻讀(Phantom Read)的問題,幻讀是指當某個事務在讀取某個範圍內的記錄時,另外一個事務又在該範圍內插入了新的記錄,之前的事務再次讀取該範圍的記錄時,會產生幻行

序列化:顧名思義是對於同一行記錄,“寫”會加“寫鎖”,“讀”會加“讀鎖”。當出現讀寫鎖衝突的時候,後訪問的事務必須等前一個事務執行完成,才能繼續執行。

對於上面的概念中,可能 讀已提交 和可重複讀比較難理解,下面會用一個例子說明這種集中隔離級別。假設資料表T中只有一列,其中一行的值為1,下面是按照時間順序執行兩個事務的行為。

mysql> create table T(c int) engine=InnoDB;
insert into T(c) values(1);
併發環境下,先運算元據庫還是先操作快取?

接下來講解不同的隔離級別下,事務A會有哪些不同的返回結果,也就是圖裡面V1、V2、V3的返回值分別是什麼。

1.若隔離級別是“讀未提交”, 則V1的值就是2。這時候事務B雖然還沒有提交,但是結果已經被A看到了。因此,V2、V3也都是2。

2.若隔離級別是“讀提交”,則V1是1,V2的值是2。事務B的更新在提交後才能被A看到。所以, V3的值也是2。

3.若隔離級別是“可重複讀”,則V1、V2是1,V3是2。之所以V2還是1,遵循的就是這個要求:事務在執行期間看到的資料前後必須是一致的。

4.若隔離級別是“序列化”,則在事務B執行“將1改成2”的時候,會被鎖住。直到事務A提交後,事務B才可以繼續執行。所以從A的角度看, V1、V2值是1,V3的值是2。

在實現上,資料庫裡面會建立一個檢視,訪問的時候以檢視的邏輯結果為準。在“可重複讀”隔離級別下,這個檢視是在事務啟動時建立的,整個事務存在期間都用這個檢視。在“讀提交”隔離級別下,這個檢視是在每個SQL語句開始執行的時候建立的。這裡需要注意的是,“讀未提交”隔離級別下直接返回記錄上的最新值,沒有檢視概念;而“序列化”隔離級別下直接用加鎖的方式來避免並行訪問。

注意一下,每種資料庫的行為會有所不一樣,Oracle資料庫的預設隔離界別是“讀提交”,因此,當我們需要進行不同資料庫種類之間遷移的時候,為了保證資料庫隔離級別的一致,切記將MYSQL的隔離級別涉及為 “讀提交”。配置的方式是,將啟動引數transaction-isolation的值設定成READ-COMMITTED。你可以用show variables來檢視當前的值。

併發環境下,先運算元據庫還是先操作快取?

併發環境下,先運算元據庫還是先操作快取?

每種隔離級別都有它自己的使用場景,你要根據自己的業務情況來定。我想你可能會問那什麼時候需要“可重複讀”的場景呢?我們來看一個資料校對邏輯的案例。

假設你在管理一個個人銀行賬戶表。一個表存了每個月月底的餘額,一個表存了賬單明細。這時候你要做資料校對,也就是判斷上個月的餘額和當前餘額的差額,是否與本月的賬單明細一致。你一定希望在校對過程中,即使有使用者發生了一筆新的交易,也不影響你的校對結果。

這時候使用“可重複讀”隔離級別就很方便。事務啟動時的檢視可以認為是靜態的,不受其他事務更新的影響。

三、事務隔離的實現

接下來以可重複度來展開事務隔離具體是怎麼實現的。

在MySQL中,實際上每條記錄在更新的時候都會同時記錄一條回滾操作。記錄上的最新值,通過回滾操作,都可以得到前一個狀態的值。

假設一個值從1被按順序改成了2、3、4,在回滾日誌裡面就會有類似下面的記錄。

併發環境下,先運算元據庫還是先操作快取?

可以看到當前值是4,從圖中可以看到在查詢的時候,不同時刻啟動的事務會有不同的read-view。如圖中看到的,在檢視A、B、C裡面,這一個記錄的值分別是1、2、4,同一條記錄在系統中可以存在多個版本,就是資料庫的多版本併發控制(MVCC)。對於read-view A,要得到1,就必須將當前值依次執行圖中所有的回滾操作得到。同時你會發現,即使現在有另外一個事務正在將4改成5,這個事務跟read-view A、B、C對應的事務是不會衝突的。

你一定會問,回滾日誌總不能一直保留吧,什麼時候刪除呢?

這是肯定不能一直保留的,在不需要的時候才刪除。系統會判斷,當沒有事務再需要用到這些回滾日誌時,回滾日誌會被刪除。

那麼什麼時候才不需要了呢?就是當系統裡沒有比這個回滾日誌更早的read-view的時候。

基於上面的說明,我們來討論一下為什麼建議你儘量不要使用長事務。

長事務意味著系統裡面會存在很老的事務檢視。由於這些事務隨時可能訪問資料庫裡面的任何資料,所以這個事務提交之前,資料庫裡面它可能用到的回滾記錄都必須保留,這就會導致大量佔用儲存空間。

在MySQL 5.5及以前的版本,回滾日誌是跟資料字典一起放在ibdata檔案裡的,即使長事務最終提交,回滾段被清理,檔案也不會變小。我見過資料只有20GB,而回滾段有200GB的庫。最終只好為了清理回滾段,重建整個庫。

除了對回滾段的影響,長事務還佔用鎖資源,也可能拖垮整個庫,這個我們會在後面講鎖的時候展開。

四、事務啟動方式

MySQL的事務啟動方式有以下幾種:

  1. 顯式啟動事務語句, begin 或 start transaction。配套的提交語句是commit,回滾語句是rollback。
  2. set autocommit=0,這個命令會將這個執行緒的自動提交關掉。意味著如果你只執行一個select語句,這個事務就啟動了,而且並不會自動提交。這個事務持續存在直到你主動執行commit 或 rollback 語句,或者斷開連線。

有些客戶端連線框架會預設連線成功後先執行一個set autocommit=0的命令。這就導致接下來的查詢都在事務中,如果是長連線,就導致了意外的長事務。

因此,我會建議你總是使用set autocommit=1, 通過顯式語句的方式來啟動事務。

但是有的開發同學會糾結“多一次互動”的問題。對於一個需要頻繁使用事務的業務,第二種方式每個事務在開始時都不需要主動執行一次 “begin”,減少了語句的互動次數。如果你也有這個顧慮,我建議你使用commit work and chain語法。

在autocommit為1的情況下,用begin顯式啟動的事務,如果執行commit則提交事務。如果執行 commit work and chain,則是提交事務並自動啟動下一個事務,這樣也省去了再次執行begin語句的開銷。同時帶來的好處是從程式開發的角度明確地知道每個語句是否處於事務中。

你可以在information_schema庫的innodb_trx這個表中查詢長事務,比如下面這個語句,用於查詢持續時間超過60s的事務。

select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60

獲取資料:

本次給大家分享一些學習資料,裡面包括:(BATJ面試資料、高可用、高併發、高效能及分散式、Jvm效能調優、Spring原始碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)和Java進階學習路線圖,以及千人高階程式設計師學習交流群...

領取方式: 加q群 468897908 免費獲取!

五、MVCC工作原理

可重複讀隔離級別下,事務在啟動的時候就“拍了個快照”。請注意,這個快照是基於整個庫的,這時候你肯定覺得不可思議,如果一個庫上百G的資料,那麼我啟動一個事務,那MYSQL豈不是要將上百G的資料拷貝出來,這個過程不是非常慢嗎?但是為什麼我們平時並沒有感覺到它:u6e80:️呢?

事實上,我們並不需要拷貝出這100G的資料。我們先來看看這個快照是怎麼實現的。InnoDB裡面每個事務有一個唯一的事務ID,叫作transaction id。它是在事務開始的時候向InnoDB的事務系統申請的,是按申請順序嚴格遞增的。

每次事務更新資料的時候,都會生成一個新的資料版本,並且把transaction id賦值給這個資料版本的事務ID,記為row trx_id。同時,舊的資料版本要保留,並且在新的資料版本中,能夠有資訊可以直接拿到它。這也說明了,資料表中的一行記錄,可能存在多個版本(row),每個版本有自己的row_trx_id.

下面用一張圖說明一個記錄被多個事務連續更新後的狀態,如下圖所示:

併發環境下,先運算元據庫還是先操作快取?

圖中用打括號表示一行資料的4個版本,當前最新版本是V4,k的值是12,它是被transaction id 為25的事務更新的,因此它的row trx_id也是25。

你可能會問,前面的文章不是說,語句更新會生成undo log(回滾日誌)嗎?那麼,undo log在哪呢?

實際上,圖2中的三個虛線箭頭,就是undo log;而V1、V2、V3並不是物理上真實存在的,而是每次需要的時候根據當前版本和undo log計算出來的。比如,需要V2的時候,就是通過V4依次執行U3、U2算出來。

明白了多版本和row trx_id的概念後,我們再來想一下,InnoDB是怎麼定義那個“100G”的快照的。按照可重複讀的定義,一個事務啟動的時候,能夠看到所有已經提交的事務結果。但是之後,這個事務執行期間,其他事務的更新對它不可見。因此,一個事務只需要在啟動的時候宣告說,“以我啟動的時刻為準,如果一個資料版本是在我啟動之前生成的,就認;如果是我啟動以後才生成的,我就不認,我必須要找到它的上一個版本”。

當然,如果“上一個版本”也不可見,那就得繼續往前找。還有,如果是這個事務自己更新的資料,它自己還是要認的。在實現上, InnoDB為每個事務構造了一個陣列,用來儲存這個事務啟動瞬間,當前正在“活躍”的所有事務ID。“活躍”指的就是,啟動了但還沒提交。

陣列裡面事務ID的最小值記為低水位,當前系統裡面已經建立過的事務ID的最大值加1記為高水位。這個檢視陣列和高水位,就組成了當前事務的一致性檢視(read-view)。而資料版本的可見性規則,就是基於資料的row trx_id和這個一致性檢視的對比結果得到的。這個檢視陣列把所有的row trx_id 分成了幾種不同的情況。如下圖所示:

併發環境下,先運算元據庫還是先操作快取?

上圖是資料庫版本可見性規則,對於當前事務的啟動瞬間來說,一個資料版本的row trx_id,有以下幾種可能:

  1. 如果落在綠色部分,表示這個版本是已提交的事務或者是當前事務自己生成的,這個資料是可見的;
  2. 如果落在紅色部分,表示這個版本是由將來啟動的事務生成的,是肯定不可見的;
  3. 如果落在黃色部分,那就包括兩種情況
  4. a. 若 row trx_id在陣列中,表示這個版本是由還沒提交的事務生成的,不可見;
  5. b. 若 row trx_id不在陣列中,表示這個版本是已經提交了的事務生成的,可見。

比如,對於圖2中的資料來說,如果有一個事務,它的低水位是18,那麼當它訪問這一行資料時,就會從V4通過U3計算出V3,所以在它看來,這一行的值是11。

你看,有了這個宣告後,系統裡面隨後發生的更新,是不是就跟這個事務看到的內容無關了呢?因為之後的更新,生成的版本一定屬於上面的2或者3(a)的情況,而對它來說,這些新的資料版本是不存在的,所以這個事務的快照,就是“靜態”的了。

所以你現在知道了,InnoDB利用了“所有資料都有多個版本”的這個特性,實現了“秒級建立快照”的能力。

接下來我們用一個例子來鞏固一下MVCC的知識,例子如下:

下面是一個只有兩行的表的初始化語句。

mysql> CREATE TABLE `t` (
 `id` int(11) NOT NULL,
 `k` int(11) DEFAULT NULL,
 PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);
併發環境下,先運算元據庫還是先操作快取?

begin/start transaction 命令並不是一個事務的起點,在執行到它們之後的第一個操作InnoDB表的語句,事務才真正啟動。如果你想要馬上啟動一個事務,可以使用start transaction with consistent snapshot 這個命令。

還需要注意的是,我們的例子中如果沒有特別說明,都是預設autocommit=1。

在這個例子中,事務C沒有顯式地使用begin/commit,表示這個update語句本身就是一個事務,語句完成的時候會自動提交。事務B在更新了行之後查詢; 事務A在一個只讀事務中查詢,並且時間順序上是在事務B的查詢之後。

讓我們想一下圖中的三個事務,分析一下事務A的語句返回的結果是什麼?

答案:事務B查到的k的值是3,而事務A查到的k的值是1,是不是感到有點奇怪?

接下來我們用假設分析法,進行如下的假設:

  1. 事務A開始前,系統裡面只有一個活躍事務ID是99;
  2. 事務A、B、C的版本號分別是100、101、102,且當前系統裡只有這四個事務;
  3. 三個事務開始前,(1,1)這一行資料的row trx_id是90。

這樣,事務A的檢視陣列就是[99,100], 事務B的檢視陣列是[99,100,101], 事務C的檢視陣列是[99,100,101,102]。

為了便於我們分析,接下來我們通過一個圖去分析,如下圖所示:

併發環境下,先運算元據庫還是先操作快取?

這裡需要說明一下,“start transaction with consistent snapshot; ”的意思是從這個語句開始,建立一個持續整個事務的一致性快照。所以,在讀提交隔離級別下,這個用法就沒意義了,等效於普通的start transaction。

從圖中可以看到,第一個有效更新是事務C,把資料從(1,1)改成了(1,2)。這時候,這個資料的最新版本的row trx_id是102,而90這個版本已經成為了歷史版本。

第二個有效更新是事務B,把資料從(1,2)改成了(1,3)。這時候,這個資料的最新版本(即row trx_id)是101,而102又成為了歷史版本。

你可能注意到了,在事務A查詢的時候,其實事務B還沒有提交,但是它生成的(1,3)這個版本已經變成當前版本了。但這個版本對事務A必須是不可見的,否則就變成髒讀了。

好,現在事務A要來讀資料了,它的檢視陣列是[99,100]。當然了,讀資料都是從當前版本讀起的。所以,事務A查詢語句的讀資料流程是這樣的:

1.找到(1,3)的時候,判斷出row trx_id=101,比高水位大,處於紅色區域,不可見;

2.接著,找到上一個歷史版本,一看row trx_id=102,比高水位大,處於紅色區域,不可見;

3.再往前找,終於找到了(1,1),它的row trx_id=90,比低水位小,處於綠色區域,可見。

這樣執行下來,雖然期間這一行資料被修改過,但是事務A不論在什麼時候查詢,看到這行資料的結果都是一致的,所以我們稱之為一致性讀。

這個判斷規則是我通過一些資料和高效能MYSQL中從程式碼邏輯直接轉譯過來的,但是正如你所見,用於人肉分析可見性很麻煩。

一個資料版本,對於一個事務檢視來說,除了自己的更新總是可見以外,有三種情況:

  1. 版本未提交,不可見;
  2. 版本已提交,但是是在檢視建立後提交的,不可見;
  3. 版本已提交,而且是在檢視建立前提交的,可見。

現在,我們用這個規則來判斷圖4中的查詢結果,事務A的查詢語句的檢視陣列是在事務A啟動的時候生成的,這時候:

  • (1,3)還沒提交,屬於情況1,不可見;
  • (1,2)雖然提交了,但是是在檢視陣列建立之後提交的,屬於情況2,不可見;
  • (1,1)是在檢視陣列建立之前提交的,可見。

你看,去掉數字對比後,只用時間先後順序來判斷,分析起來是不是輕鬆多了。所以,後面我們就都用這個規則來分析。

這時候你是不是有一個這樣的疑問:事務B的update語句,如果按照一致性讀,好像結果不對哦?

事務B的檢視陣列是先建立的,之後事務C才提交,不是應該看不見(1,2)嗎,怎麼能算出(1,3)來?

確實如此,如果事務B在更新之前查詢一次資料,這個查詢返回的k的值確實是1。

但是,當它要去更新資料的時候,就不能再在歷史版本上更新了,否則事務C的更新就丟失了。

因此,事務B此時的set k=k+1是在(1,2)的基礎上進行的操作,這裡就用到了這樣一條規則:更新資料都是先讀後寫的,而這個讀,只能讀當前的值,稱為“當前讀”(current read)。

因此,在更新的時候,當前讀拿到的資料是(1,2),更新後生成了新版本的資料(1,3),這個新版本的row trx_id是101。所以,在執行事務B查詢語句的時候,一看自己的版本號是101,最新資料的版本號也是101,是自己的更新,可以直接使用,所以查詢得到的k的值是3。

這裡我們提到了一個概念,叫作當前讀。其實,除了update語句外,select語句如果加鎖,也是當前讀。因此,如果把事務A的查詢語句select * from t where id=1修改一下,加上lock in share mode 或 for update,也都可以讀到版本號是101的資料,返回的k的值是3。下面這兩個select語句,就是分別加了讀鎖(S鎖,共享鎖)和寫鎖(X鎖,排他鎖)。

mysql> select k from t where id=1 lock in share mode;
mysql> select k from t where id=1 for update;

假設事務C不是馬上提交的,而是變成了下面的事務C’,會怎麼樣呢?如下圖所示:

併發環境下,先運算元據庫還是先操作快取?

事務C’的不同是,更新後並沒有馬上提交,在它提交前,事務B的更新語句先發起了。前面說過了,雖然事務C’還沒提交,但是(1,2)這個版本也已經生成了,並且是當前的最新版本。那麼,事務B的更新語句會怎麼處理呢?

這時候,我們的“兩階段鎖協議”(後面鎖的章節會降到)就要上場了。事務C’沒提交,也就是說(1,2)這個版本上的寫鎖還沒釋放。而事務B是當前讀,必須要讀最新版本,而且必須加鎖,因此就被鎖住了,必須等到事務C’釋放這個鎖,才能繼續它的當前讀。

那麼回到之前的隔離界別中的事務的可重複讀的能力是怎麼實現的?

可重複讀的核心就是一致性讀(consistent read);而事務更新資料的時候,只能用當前讀。如果當前的記錄的行鎖被其他事務佔用的話,就需要進入鎖等待。

而讀提交的邏輯和可重複讀的邏輯類似,它們最主要的區別是:

1.在可重複讀隔離級別下,只需要在事務開始的時候建立一致性檢視,之後事務裡的其他查詢都共用這個一致性檢視;

2.在讀提交隔離級別下,每一個語句執行前都會重新算出一個新的檢視。

接下來再看一下,在讀提交隔離級別下,事務A和事務B的查詢語句查到的k,分別應該是多少呢?如下圖所示:

併發環境下,先運算元據庫還是先操作快取?

可以看到此時事務A的查詢語句的檢視陣列是在執行這個語句的時候建立的,時間線上(1,2)、(1,3)的生成時間都在建立這個檢視陣列的時刻之前。

但是,在這個時刻:(1,3)還沒提交,屬於情況1,不可見;(1,2)提交了,屬於情況3,可見。所以,這時候事務A查詢語句返回的是k=2。顯然地,事務B查詢結果k=3。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31545684/viewspace-2653470/,如需轉載,請註明出處,否則將追究法律責任。

相關文章