事務的基本概念,Mysql事務處理原理
本文大綱:

初識事務
為什么需要事務?
這里又要掏出那個爛大街的銀行轉賬案例了,以A、B兩個賬戶的轉賬為例,假設現在要從A賬戶向B賬戶中轉入1000員,當進行轉賬時,需要先從銀行賬戶A中取出錢,然后再存入銀行賬戶B中,SQL樣本如下:
//?第一步:A賬戶余額減少減少1000??
update?balance?set?money?=?money?-500?where?name=?‘A’;
//?第二步:B賬戶余額增加1000??
update?balance?set?money?=?money?+500?where?name=?‘B’;
如果在完成了第1步的時候突然宕機了,A的錢減少了而B的錢沒有增加,那A豈不是白白丟了1000元,這時候就需要用到我們的事務了,開啟事務后SQL樣本如下:
//?第一步:開始事務
start?transaction;
//?第二步:A賬戶余額減少減少1000??
update?balance?set?money?=?money?-500?where?name=?‘A’;
//?第三步:B賬戶余額增加1000??
update?balance?set?money?=?money?+500?where?name=?‘B’;
//?第四步:提交事務
commit;
什么是事務
事務(Transaction)是訪問和更新數據庫的程序執(zhí)行單元;事務中可能包含一個或多個sql語句,這些語句要么都執(zhí)行成功,要么全部執(zhí)行失敗。
事務的四大特性(ACID)
原子性(Atomicity,或稱不可分割性)
「一個事務必須被視為一個不可分割的最小工作單元,整個事務中所有的操作要么全部提交成功,要么全部失敗回滾,對于一個事務來說,不可能只執(zhí)行其中的一部分操作,這就是事務的原子性」
一致性(Consistency)
「數據庫總是從一個一致性的狀態(tài)轉換到另外一個一致性的狀態(tài),在事務開始之前和之后,數據庫的完整性約束沒有被破壞。在前面的例子中,事務結束前后A、B賬戶總額始終保持不變」
隔離性(Isolation)
「隔離性是指,事務內部的操作與其他事務是隔離的,并發(fā)執(zhí)行的各個事務之間不能互相干擾。嚴格的隔離性,對應了事務隔離級別中的Serializable (可串行化),但實際應用中出于性能方面的考慮很少會使用可串行化?!?/strong>
持久性(Durability)
「持久性是指事務一旦提交,它對數據庫的改變就應該是永久性的。接下來的其他操作或故障不應該對其有任何影響。」
事務的隔離級別
在前文中我們介紹了隔離性,但實際上隔離性比想象的要復雜的多。在SQL標準中定義了四種隔離級別,每一種隔離級別都規(guī)定了一個事務所做的修改,哪些在事務內和事務間是可見的,哪些是不可見的,較低級別的隔離通常可以執(zhí)行跟高的并發(fā),系統(tǒng)的開銷也更低
未提交讀(READ UNCOMMITTED)
在這個隔離級別下,事務的修改即使沒有提交,對其他事務也是可見的。事務可以讀取未提交的數據,這也被稱之為臟讀。這個級別會帶來很多問題,從性能上來說,READ UNCOMMITTED不會比其他的級別好太多,但是卻會帶來很多問題,除非真的有非常必要的理由,在實際應用中一般很少使用。
提交讀(REDA COMMITED)
大多數數據系統(tǒng)的默認隔離級別都是REDA COMMITED(MySql不是),REDA COMMITED滿足前面提到的隔離性的簡單定義:一個事務開始時,只能看到已經提交的事務所做的修改。換句話說,一個事物從開始直到提交前,所做的修改對其他事務不可見。這個級別有時候也叫做不可重復讀,因為執(zhí)行兩次相同的查詢可能會得到不同的結果。
可重復讀(REPEATABLE READ)
REPEATABLE READ解決了臟讀以及不可重復度的問題。該級別保證了同一個事務多次讀取同樣記錄的結果是一致的。但是理論上,可重復度還是無法解決另外一個幻讀的問題。所謂幻讀,指的是當某個事務在讀取某個范圍內的記錄時,另外一個事務又在該范圍內插入了新的記錄,當之前的事務再次讀取該范圍的記錄時,就會產生幻行。
不可重復讀跟幻讀的區(qū)別在于,「前者是數據發(fā)生了變化,后者是數據的行數發(fā)生了變化」。
可串行化(SERIALIZABLE)
SERIALIZABLE是最高的隔離級別,它通過強制事務串行執(zhí)行,避免前面說的幻讀。簡單來說SERIALIZABLE會在讀取的每一行數據上都加鎖,所以可能會導致大量的超時和鎖爭用的問題。實際應用中也很少使用這個隔離級別,只有在非常需要確保數據一致性而且可以接受沒有并發(fā)的情況下,才考慮此級別。
保存點
我們可以在事務執(zhí)行的過程中定義保存點,在回滾時直接指定回滾到指定的保存點而不是事務開始之初,有點像我們玩游戲的時候可以存檔而不是每次都要重新再來
定義保存點的語法如下:
SAVEPOINT?保存點名稱;
當我們想回滾到某個保存點時,可以使用下邊這個語句(下邊語句中的單詞WORK和SAVEPOINT是可有可無的):
ROLLBACK?[WORK]?TO?[SAVEPOINT]?保存點名稱;
MySQL中的事務跟原理
MySQL中的事務
「MySQL中不是所有的存儲引擎都支持事務」,例如 MyISAM就不支持事務,實際上支持事務的只有InnoDB跟NDB Cluster,「本文關于事務的分析都是基于InnoDB」「MySQL默認采用的是自動提交的方式」,也就是說如果不是顯示的開始一個事務,則系統(tǒng)會自動向數據庫提交結果。在當前連接中,還可以通過設置AUTOCONNIT變量來啟用或者禁用自動提交模式。
開啟自動提交功能
SET?AUTOCOMMIT?=?1;
關閉自動提交功能。
SET?AUTOCOMMIT?=?0;
「MySQL的默認隔離級別是可重復讀(REPEATABLE READ)」。
事務的實現原理
MySQL中事務的實現原理,實際上就是要弄明天它的ACID特性是如何實現的,在這里有必要先說明的是,「ACID中的一致性是事務的最終目標,前面提到的原子性、持久性和隔離性,都是為了保證數據庫狀態(tài)的一致性」。所以我們要分析的就是MySQL的原子性、持久性和隔離性的實現原理,在分析事務的實現原理之前我們需要補充一些InnoDB的相關知識InnoDB是一個將表中的數據存儲到磁盤上的存儲引擎,所以即使關機后重啟我們的數據還是存在的。而真正「處理數據的過程是發(fā)生在內存中的」,「所以需要把磁盤中的數據加載到內存中,如果是處理寫入或修改請求的話,還需要把內存中的內容刷新到磁盤上」。而我們知道讀寫磁盤的速度非常慢,和內存讀寫差了幾個數量級,所以當我們想從表中獲取某些記錄時,InnoDB存儲引擎需要一條一條的把記錄從磁盤上讀出來么?不,那樣會慢死,InnoDB采取的方式是:「將數據劃分為若干個頁,以頁作為磁盤和內存之間交互的基本單位,InnoDB中頁的大小一般為 16 KB。也就是在一般情況下,一次最少從磁盤中讀取16KB的內容到內存中,一次最少把內存中的16KB內容刷新到磁盤中?!?/strong>我們還需要對MySQL中的日志有一定了解。MySQL的日志有很多種,如二進制日志(bin log)、錯誤日志、查詢日志、慢查詢日志等,此外InnoDB存儲引擎還提供了兩種事務日志:「redo log(重做日志)和undo log(回滾日志)。其中redo log用于保證事務持久性;undo log則是事務原子性和隔離性實現的基礎?!?/strong> InnoDB作為MySQL的存儲引擎,數據是存放在磁盤中的,但如果每次讀寫數據都需要磁盤IO,效率會很低。為此,InnoDB提供了「緩存(Buffer Pool)」,Buffer Pool中包含了磁盤中部分數據頁的映射,作為訪問數據庫的緩沖:「當從數據庫讀取數據時,會首先從Buffer Pool中讀取,如果Buffer Pool中沒有,則從磁盤讀取后放入Buffer Pool;當向數據庫寫入數據時,會首先寫入Buffer Pool,Buffer Pool中修改的數據會定期刷新到磁盤中(這一過程稱為刷臟)。」 InnoDB存儲引擎文件主要可以分為兩類,表空間文件及重做日志文件(redo log file),表空間文件又可以細分為兩類,共享表空間跟獨立表空間。「undo log位于共享表空間中的undo段中」,每個表空間都被劃分成了若干個頁面,「凡是頁面的讀寫都在buffer pool中進行,這意味著undo log也需要先寫入到buffer pool,所以undo log的生成也需要持久化,也就是說undo log的生成需要記錄對應的redo log」。(注意:不是所有的undo log的生成都會產生對應的redo log,對于操作臨時表生成的undo log并不會生成對應的undo log,因為修改臨時表而產生的undo日志只需要在系統(tǒng)運行過程中有效,如果系統(tǒng)奔潰了,那么在重啟時也不需要恢復這些undo日志所在的頁面,所以在寫針對臨時表的Undo頁面時,并不需要記錄相應的redo日志。)
持久性實現原理
Buffer Pool來優(yōu)化讀寫的性能,但是雖然Buffer Pool優(yōu)化了性能,但同時也帶來了新的問題:「如果MySQL宕機,而此時Buffer Pool中修改的數據還沒有刷新到磁盤,就會導致數據的丟失,事務的持久性無法保證」。redo log就誕生了,「redo log是物理日志,記錄的是數據庫中數據庫中物理頁的情況」,redo log包括兩部分:一是內存中的日志緩沖(redo log buffer),該部分日志是易失性的;二是磁盤上的重做日志文件(redo log file),該部分日志是持久的。在概念上,innodb通過「force log at commit」機制實現事務的持久性,即在事務提交的時候,必須先將該事務的所有事務日志寫入到磁盤上的redo log file和undo log file中進行持久化。redo log為何能保證持久性://?第一步:開始事務
start?transaction;
//?第二步:A賬戶余額減少減少1000??
update?balance?set?money?=?money?-500?where?name=?‘A’;
//?第三步:B賬戶余額增加1000??
update?balance?set?money?=?money?+500?where?name=?‘B’;
//?第四步:提交事務
commit;

當設置為1的時候,事務每次提交都會將log buffer中的日志寫入os buffer并調用fsync()函數刷到log file on disk中。這種方式即使系統(tǒng)崩潰也不會丟失任何數據,但是因為每次提交都寫入磁盤,IO的性能較差。 當設置為0的時候,事務提交時不會將log buffer中日志寫入到os buffer(內核緩沖區(qū)),而是每秒寫入os buffer并調用fsync()寫入到log file on disk中。也就是說設置為0時是(大約)每秒刷新寫入到磁盤中的,當系統(tǒng)崩潰,會丟失1秒鐘的數據。 當設置為2的時候,每次提交都僅寫入到os buffer,然后是每秒調用fsync()將os buffer中的日志寫入到log file on disk。
原子性實現原理
undo log(回滾日志)來實現。insert undo log update undo log

undo type記錄的是undo log的類型,對于insert undo log,該值始終為11(TRX_UNDO_INSERT_REC),undo no在一個事務中是從0開始遞增的,也就是說只要事務沒提交,每生成一條undo日志,那么該條日志的undo no就增1。table id記錄undo log所對應的表對象。如果記錄中的主鍵只包含一個列,那么在類型為TRX_UNDO_INSERT_REC的undo日志中只需要把該列占用的存儲空間大小和真實值記錄下來,如果記錄中的主鍵包含多個列(復合主鍵),那么每個列占用的存儲空間大小和對應的真實值都需要記錄下來(圖中的len就代表列占用的存儲空間大小,value就代表列的真實值),「在回滾時只需要根據主鍵找到對應的列然后刪除即可」。end of record記錄了下一條undo log在頁面中開始的地址,start of record記錄了本條undo log在頁面中開始的地址。//?第一步:開始事務
start?transaction;
//?第二步:A賬戶余額減少減少1000??
update?balance?set?money?=?money?-500?where?name=?‘A’;
//?第三步:B賬戶余額增加1000??
update?balance?set?money?=?money?+500?where?name=?‘B’;
//?第四步:提交事務
commit;

隔離性實現原理
一個事務中的寫操作,對另外一個事務中寫操作的影響 一個事務中的寫操作,對另外一個事務中讀操作的影響
MySQL中的鎖機制(InnoDB)
讀鎖跟寫鎖
讀鎖 又稱為共享鎖`,簡稱S鎖,顧名思義,共享鎖就是多個事務對于同一數據可以共享一把鎖,「都能訪問到數據,但是只能讀不能修改?!?/strong>寫鎖 又稱為排他鎖`,簡稱X鎖,顧名思義,排他鎖就是不能與其他所并存,如一個事務獲取了一個數據行的排他鎖,其他事務就不能再獲取該行的其他鎖,包括共享鎖和排他鎖,但是獲取排他鎖的事務是可以對數據就行讀取和修改。
行鎖跟表鎖
表鎖在操作數據時會鎖定整張表,并發(fā)性能較差; 行鎖則只鎖定需要操作的數據,并發(fā)性能好。 但是由于加鎖本身需要消耗資源(獲得鎖、檢查鎖、釋放鎖等都需要消耗資源),因此在鎖定數據較多情況下使用表鎖可以節(jié)省大量資源。MySQL中不同的存儲引擎支持的鎖是不一樣的,例如MyIsam只支持表鎖,而InnoDB同時支持表鎖和行鎖,且出于性能考慮,絕大多數情況下使用的都是行鎖。
意向鎖
意向鎖分為兩種,意向讀鎖(IS)跟意向寫鎖(IX) 意向鎖是表級別的鎖 為什么需要意向鎖呢?思考一個問題:如果我們想對某個表加一個表鎖,那么在加鎖之前我們需要去檢查表中的每一行記錄是否已經被單獨加了行鎖,這樣的話豈不是意味著我們需要去遍歷表中所有的記錄依次進行檢查,遍歷是不可能的,這輩子都不可能遍歷的,基于效率的考慮,我們可以在每次給行記錄加鎖時先給當前表加一個意向鎖,如果我們要對行加讀鎖(S)的話,那么就先給表加一個意向讀鎖(IS),如果要對行加寫鎖(X)的話,那么先給表加一個意向寫鎖(IX),這樣當我們需要給整個表加鎖的時候就可以通過先判斷表上是否已經存在了意向鎖來決定是否可以上鎖了,避免遍歷,提高了效率。 意向鎖跟普通的讀鎖寫鎖間的兼容性如下:
| IS | IX | S | X | |
CREATE?TABLE?`user`?(
??`id`?int(11)?NOT?NULL?AUTO_INCREMENT,
??`name`?varchar(10)?NOT?NULL,
??PRIMARY?KEY?(`id`),
)?ENGINE=InnoDB?AUTO_INCREMENT=7?DEFAULT?CHARSET=utf8;
INSERT?INTO?`test`.`user`(`id`,?`name`)?VALUES?(1,?'a張大膽');
INSERT?INTO?`test`.`user`(`id`,?`name`)?VALUES?(3,?'b王翠花');
INSERT?INTO?`test`.`user`(`id`,?`name`)?VALUES?(6,?'c范統(tǒng)');
INSERT?INTO?`test`.`user`(`id`,?`name`)?VALUES?(8,?'d朱逸群');
INSERT?INTO?`test`.`user`(`id`,?`name`)?VALUES?(15,?'e董格求');
Record Lock(記錄鎖)
鎖定單條記錄 也分為S鎖跟X鎖

Gap Lock(間隙鎖)
鎖定一個范圍,但是不包含記錄本身 間隙鎖的主要作用在于防止幻讀的發(fā)生,雖然也有S鎖跟X鎖的區(qū)分,但是它們的作用都是相同的,而且如果你對一條記錄加了 間隙鎖(不論是共享間隙鎖還是獨占間隙鎖),并不會限制其他事務對這條記錄加記錄鎖或者繼續(xù)加間隙鎖,再強調一遍,間隙鎖的作用僅僅是為了防止幻讀的發(fā)生。

Next-Key Lock(Gap Lock+Record Lock)
Next-Key Lock,那么此時鎖定的區(qū)域如下所示
Next-Key Lock除了鎖定間隙之外還要鎖定當前記錄MVCC(多版本并發(fā)控制)
版本鏈
MVCC之前我們需要對MySQL中的行記錄格式有一定了解,其實除了我們在數據庫中定義的列之外,每一行中還包含了幾個隱藏列,分別是row_id:行記錄的唯一標志 transaction_id:事務ID roll_pointer:回滾指針
Unique鍵作為主鍵,如果表中連Unique鍵都沒有定義的話,則InnoDB會為表默認添加一個名為row_id的隱藏列作為主鍵。也就是說只有在表中既沒有定義主鍵,也沒有申明唯一索引的情況MySQL才會添加這個隱藏列。InnoDB存儲引擎就會給它分配一個獨一無二的事務id,分配方式如下:對于只讀事務來說,只有在它第一次對某個用戶創(chuàng)建的「臨時表執(zhí)行增、刪、改操作」時才會為這個事務分配一個 事務id,否則的話是不分配事務id的。對于讀寫事務來說,只有在它「第一次對某個表(包括用戶創(chuàng)建的臨時表)執(zhí)行增、刪、改操作」時才會為這個事務分配一個 事務id,否則的話也是不分配事務id的。有的時候雖然我們開啟了一個讀寫事務,但是在這個事務中全是查詢語句,并沒有執(zhí)行增、刪、改的語句,那也就意味著這個事務并不會被分配一個 事務id。
roll_pointer我們就可以找到對應的undo log,然后根據undo log進行回滾。update undo log中也包含roll_pointer跟transaction_id。update undo log中的roll_pointer指針其實就是保存的被更新的記錄中的roll_pointer指針#?開啟事務
START?TRANSACTION;
#?插入一條數據
INSERT?INTO?`test`.`user`(`id`,?`name`)?VALUES?(16,?'e杜子騰');
#?更新插入的數據
UPDATE?`test`.`user`?SET?name?=?"史珍香"?WHERE?id?=?16;
#?刪除數據
DELETE?from??`test`.`user`?WHERE?id?=?16;

roll pointer指針指向了這條undo log,同時如果不是新增操作,那么生成的undo log中也會保存一個roll pointer,其值是從被修改的數據中復制過來了,在我們上邊的例子中update undo log的roll pointer就復制了insert進去的數據中的roll pointer指針的值。roll pointer指針,我們可以找到一個有undo log組成的鏈表,這個undo log鏈表其實就是這條記錄的版本鏈。ReadView(快照)
READ UNCOMMITTED隔離級別的事務來說,由于可以讀到未提交事務修改過的記錄,所以直接讀取記錄的最新版本就好了;SERIALIZABLE隔離級別的事務來說,MySQL規(guī)定使用加鎖的方式來訪問記錄;READ COMMITTED和REPEATABLE READ隔離級別的事務來說,都必須保證讀到已經提交了的事務修改過的記錄,也就是說假如另一個事務已經修改了記錄但是尚未提交,是不能直接讀取最新版本的記錄的,核心問題就是:「需要判斷一下版本鏈中的哪個版本是當前事務可見的」。ReadView(快照)的概念,「在Select操作前會為當前事務生成一個快照,然后根據快照中記錄的信息來判斷當前記錄是否對事務是可見的,如果不可見那么沿著版本鏈繼續(xù)往上找,直至找到一個可見的記錄?!?/strong>m_ids:表示在生成ReadView時當前系統(tǒng)中活躍的讀寫事務的事務id列表。min_trx_id:表示在生成ReadView時當前系統(tǒng)中活躍的讀寫事務中最小的事務id,也就是m_ids中的最小值。max_trx_id:表示生成ReadView時系統(tǒng)中應該分配給下一個事務的id值。?小貼士:注意max_trx_id并不是m_ids中的最大值,事務id是遞增分配的。比方說現在有id為1,2,3這三個事務,之后id為3的事務提交了。那么一個新的讀事務在生成ReadView時,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。 ? creator_trx_id:表示生成該ReadView的事務的事務id。?小貼士:我們前邊說過,只有在對表中的記錄做改動時(執(zhí)行INSERT、DELETE、UPDATE這些語句時)才會為事務分配事務id,否則在一個只讀事務中的事務id值都默認為0。 ?

從上圖中我們可以看到,在根據當前數據庫中運行中的讀寫事務id,會去生成一個ReadView。 然后根據要讀取的數據記錄中的事務id(方便區(qū)別,記為 r_trx_id)跟ReadView中保存的幾個屬性做如下判斷
如果被訪問版本的 r_trx_id屬性值與ReadView中的creator_trx_id值相同,意味著當前事務在訪問它自己修改過的記錄,所以該版本可以被當前事務訪問。如果被訪問版本的 r_trx_id屬性值小于ReadView中的min_trx_id值,表明生成該版本的事務在當前事務生成ReadView前已經提交,所以該版本可以被當前事務訪問。如果被訪問版本的 r_trx_id屬性值大于或等于ReadView中的max_trx_id值,表明生成該版本的事務在當前事務生成ReadView后才開啟,所以該版本不可以被當前事務訪問。如果被訪問版本的 r_trx_id屬性值在ReadView的min_trx_id和max_trx_id之間,那就需要判斷一下r_trx_id屬性值是不是在m_ids列表中,如果在,說明創(chuàng)建ReadView時生成該版本的事務還是活躍的,該版本不可以被訪問;如果不在,說明創(chuàng)建ReadView時生成該版本的事務已經被提交,該版本可以被訪問。如果某個版本的數據對當前事務不可見的話,那就順著版本鏈找到下一個版本的數據,繼續(xù)按照上邊的步驟判斷可見性,依此類推,直到版本鏈中的最后一個版本。如果最后一個版本也不可見的話,那么就意味著該條記錄對該事務完全不可見,查詢結果就不包含該記錄。
提交讀每次select都會生成一個快照 可重復讀只有在第一次會生成一個快照
總結
書籍:掘金小冊《MySQL 是怎樣運行的:從根兒上理解 MySQL》:https://juejin.im/book/6844733769996304392
書籍:《MySQL技術內幕:InnoDB存儲引擎》:關注公眾號,程序員DMZ,后臺回復InnoDB即可領取
書籍:《高性能MySQL》:關注公眾號,程序員DMZ,后臺回復MySQL即可領取
文章:《深入學習MySQL事務:ACID特性的實現原理》:https://www.cnblogs.com/kismetv/p/10331633.html
文章:《詳細分析MySQL事務日志(redo log和undo log)》:https://www.cnblogs.com/f-ck-need-u/p/9010872.html
文章:《Mysql事務實現原理》:https://www.lagou.com/lgeduarticle/82740.html
文章:《面試官:你說熟悉MySQL事務,那來談談事務的實現原理吧!》:https://mp.weixin.qq.com/s/jrfZr3YzE_E0l3KjWAz1aQ
文章:《InnoDB 事務分析-Undo Log》:http://leviathan.vip/2019/02/14/InnoDB%E7%9A%84%E4%BA%8B%E5%8A%A1%E5%88%86%E6%9E%90-Undo-Log/
文章:《InnoDB 的 Redo Log 分析》:http://leviathan.vip/2018/12/15/InnoDB%E7%9A%84Redo-Log%E5%88%86%E6%9E%90/
文章:《MySQL redo & undo log-這一篇就夠了》:https://www.jianshu.com/p/336e4995b9b8
有道無術,術可成;有術無道,止于術
歡迎大家關注Java之道公眾號
好文章,我在看??
