1. 基礎(chǔ)知識
1.1 常規(guī)讀和帶鎖讀
- 帶鎖讀(當(dāng)前讀):如
select .. lock in share mode
、select .. for update
甜橱、以及隱含當(dāng)前讀的insert
、update
略板、delete
等(讀出來才能進(jìn)行更新/刪除/唯一索引判斷等) - 常規(guī)讀(一致性讀):如常用的
select ...
【帶鎖讀】通過加鎖的方式保證事務(wù)隔離特性(有無臟讀/不可重復(fù)讀/幻讀等)洼哎;
【常規(guī)讀】則是通過 多版本并發(fā)控制機制(MVCC,Multi-Version Concurrency Control)實現(xiàn)让簿。
插入/更新/刪除等寫操作時:既會加鎖保證【帶鎖讀】的隔離特性;也會備份之前版本的數(shù)據(jù)用于MVCC秀睛,以保證【常規(guī)讀】的隔離特性(詳見本文第二節(jié))尔当。
1.2 事務(wù)隔離級別
隔離級別 | 臟讀 (Dirty Read) |
不可重復(fù)讀 (Non-Repeatable Read) |
幻讀 (Phantom Read) |
---|---|---|---|
未提交讀 (UNCOMMITTED) |
? | ? | ? |
提交讀 (READ COMMITTED) |
- | ? | ? |
可重復(fù)讀 (REPEATABLE READ) |
- | - | - |
串行化 (SERIALIZABLE) |
- | - | - |
值得注意的是:MySQL InnoDB中默認(rèn)的隔離級別【可重復(fù)讀】下,是不存在幻讀問題(帶鎖讀/常規(guī)讀下的幻讀)蹂安。
本事務(wù)讀到其他事務(wù)尚未提交的數(shù)據(jù)時椭迎,稱之為【臟讀】。
這里的【臟】指的是【未提交的數(shù)據(jù)】田盈,這個和讀到【過期的數(shù)據(jù)】是不同的畜号。
比如某個時刻,a=1 已經(jīng)被其他事務(wù)更新成 a=2 且提交了缠黍,而我這個事務(wù)還是讀到a=1弄兜,這就是讀到過期數(shù)據(jù)了,可以稱之為【過期讀】瓷式。
而如果其他事務(wù)更新 a=2 尚未提交,我這個事務(wù)就讀到了a=2语泽,這個就是【臟讀】了贸典。
臟讀通常是不可容忍的,除非有特殊要求踱卵,否則隔離級別一般不會設(shè)置為【未提交讀】廊驼。
同一個事務(wù)中,同樣的SQL惋砂,多次查詢妒挎,查詢結(jié)果不一樣時,稱之為【不可重復(fù)讀】西饵。
例如 同一個事務(wù)中酝掩,第一次查詢 [name=zhangsan] 但是第二次查就變?yōu)榱耍篬name=lisi]
相反地,如果同一個事務(wù)中眷柔,每次查詢結(jié)果都不會變時期虾,自然就是【可重復(fù)讀】了原朝。
【可重復(fù)讀】隔離級別下,讀到的數(shù)據(jù)有可能是過期的镶苞,但不會是臟讀喳坠。
類似
select * from t where id=1
和select * from t where id=1 for update
并不屬于同樣的SQL。所以哪怕是在【可重復(fù)讀】的隔離級別下茂蚓,同一個事務(wù)中壕鹉,這兩條SQL查詢結(jié)果不一樣也是正常的。
同一個事務(wù)中聋涨,同樣的SQL晾浴,多次查詢,查詢的結(jié)果集不一樣時牛郑,稱之為【幻讀】
例如 同一個事務(wù)中怠肋,第一次查詢結(jié)果為一行,但是第二次查詢就變成兩行了淹朋。
【不可重復(fù)讀】關(guān)注的是某行內(nèi)容是否發(fā)生變化笙各,而【幻讀】則關(guān)注行數(shù)量是否發(fā)生變化。
注:本文幻讀的含義主要參考MySQL官網(wǎng)文檔:14.7.4 Phantom Rows 础芍;即快照讀和當(dāng)前讀 認(rèn)為是不同的 query.
2. MVCC
2.1 多個版本的行數(shù)據(jù)
InnoDB中記錄數(shù)據(jù)的基本單位為頁(InnoDB Page杈抢,默認(rèn)16KB),頁的類型有有多種的仑性,比如存儲當(dāng)前數(shù)據(jù)的數(shù)據(jù)頁(B-Tree Node)惶楼、存儲邏輯回滾/備份數(shù)據(jù)的undo 頁(Undo Log Page)等。
當(dāng)執(zhí)行insert/update/delete
等寫操作時诊杆,除了要修改對應(yīng)數(shù)據(jù)頁之外歼捐,還會對之前的數(shù)據(jù)進(jìn)行備份(記錄至undo頁中)。如果事務(wù)需要回滾晨汹,找到對應(yīng)的undo 記錄進(jìn)行應(yīng)用回滾即可豹储。
注意:哪怕事務(wù)尚未提交,寫操作也會立即修改當(dāng)前的數(shù)據(jù)頁淘这。所以回滾要到undo log中找剥扣。
顯然,行數(shù)據(jù)是會有多個版本的(當(dāng)前數(shù)據(jù)頁 + undo頁)铝穷,為了區(qū)分各個版本的數(shù)據(jù)钠怯,每一行記錄都會額外多出一個隱藏的版本號字段(trx_id),trx_id即對應(yīng)寫操作的事務(wù)id曙聂。
每個事務(wù)都能分配到一個全局遞增的事務(wù)id(trx_id)晦炊,當(dāng)該事務(wù)進(jìn)行寫操作時,會將該值一并寫入行記錄中(見下例)。
例一:當(dāng)前事務(wù)id=10刽锤,插入:[id=1, name=zhangsan]
:
- 找到可以插入的數(shù)據(jù)頁镊尺;
- 寫入記錄:
[id=1, name=zhangsan, trx_id=10]
,
同時生成undo log:[log_type="insert", id=1]
*回滾*時:找到undo log進(jìn)行應(yīng)用并思,刪除id=1
的記錄(插入的反操作)庐氮。
例二:當(dāng)前事務(wù)id=20,更新:[set name=lisi where id = 1]
- 找到對應(yīng)記錄的所在記錄頁宋彼;
- 修改記錄為:
[id=1, name=lisi, trx_id=20]
同時生成undo log:[log_type="update", id=1, name=zhangsan, trx_id=10]
*回滾*時:找到undo log進(jìn)行應(yīng)用弄砍,反向更新數(shù)據(jù)回 [id=1, name=zhangsan, trx_id=10]
例三:當(dāng)前事務(wù)id=30,刪除:[id=1]
- 找到對應(yīng)記錄的所在記錄頁输涕;
- 修改記錄為:
[id=1, name=lisi, trx_id=30, delete_flag=1]
音婶,
同時生成undo log:[log_type="update", id=1, name=lisi, trx_id=20, delete_flag=0]
執(zhí)行刪除SQL時,并不是直接將記錄從數(shù)據(jù)頁中抹掉莱坎,而是通過一個刪除位(delete_flag)來進(jìn)行標(biāo)識衣式,將該字段置為1即標(biāo)識這行數(shù)據(jù)已經(jīng)被刪除了;同時和其他寫一樣會記錄操作事務(wù)的trx_id檐什。
*回滾*時:找到undo log進(jìn)行應(yīng)用碴卧,反向更新數(shù)據(jù)回 [id=1, name=lisi, trx_id=20, delete_flag=0]
undo log的具體記錄字段可以稍微了解下:
insert into...
:含主鍵;delete ..
:含所有字段的之前的值乃正。update ..
:含需要更新字段的之前的值住册;
如果是更新主鍵,等同于將之前行記錄的刪除瓮具,然后再插入荧飞,將產(chǎn)生兩條undo log。
undo log除了用于備份數(shù)據(jù)支持事務(wù)回滾之外名党,其數(shù)據(jù)多版本的特性與事務(wù)快照結(jié)合之后叹阔,將可以用于支持事務(wù)隔離的相關(guān)特性(比如避免臟讀/不可重復(fù)讀/幻讀等)。
2.2 事務(wù)快照
大家應(yīng)該拍過照片传睹,按下快門条获,我們就可以將當(dāng)前時刻的景物記錄到一張小小的照片,盡管時光荏苒蒋歌,歲月變遷,照片中的景物也不會發(fā)生變化委煤。
如果我們給數(shù)據(jù)庫中的事務(wù)拍一張照片的話堂油,我們會看到:在拍照的那一瞬間,有的事務(wù)已經(jīng)提交碧绞,有的正在運行中府框,有的事務(wù)尚未開始:
就如上圖中的快照讥邻,trx_id小于15的事務(wù)都已經(jīng)提交了迫靖,大于等于31的則尚未開始院峡;中間的15/25還在跑,而20/30已經(jīng)提交系宜。
如果你現(xiàn)在的事務(wù)id為25照激,當(dāng)隔離級別為【可重復(fù)讀】時:你能到哪些事務(wù)修改的數(shù)據(jù)呢?答案是顯然的盹牧,已經(jīng)提交的則看得見(圖中黑色)俩垃,還沒提交的自然就看不見,否則就是臟讀了汰寓。
可時間是會變化的口柳,假設(shè)后來15進(jìn)行了提交,那我們能否看得見該事務(wù)的修改記錄呢(比如 a=1 修改為了 a=2)有滑?這個也是應(yīng)該看不見的跃闹,因為如果事務(wù)15提交前我們看到的是a=1,而提交后變?yōu)閍=2了毛好,這就出現(xiàn)了不可重復(fù)讀了望艺,這顯然和【可重復(fù)讀】相悖了。
事實上睛榄,正如前面所說時間會變但照片不變一樣荣茫,一旦我們拍下事務(wù)快照之后,id=15的事務(wù)對于咱們來講,“它一直都是處于未提交的”(除非我們重新拍過另外一張快照)麦备。
在【可重復(fù)讀】隔離級別下腹缩,一旦觸發(fā)快照后,這個快照會一直存在咧欣,直至事務(wù)結(jié)束。哪些事務(wù)已提交轨帜,哪些沒提交魄咕,也會在這一瞬間定格。這也就保證了我們永遠(yuǎn)都在同一張照片里面“找”數(shù)據(jù)蚌父,從而保證了【可重復(fù)讀】哮兰。
接下來我們來看一下怎樣基于事務(wù)快照來“找”數(shù)據(jù)。
注:【可重復(fù)讀】隔離級別下苟弛,事務(wù)快照的觸發(fā)時機主要有:
- 開啟事務(wù)后(
begin/start transaction;
)喝滞,執(zhí)行第一條常規(guī)讀SQL(select
)時;- 開啟事務(wù)時膏秫,直接開啟快照:
start transaction with consistent snapshot
.
2.3 MVCC查詢基本流程
基于數(shù)據(jù)快照和多版本數(shù)據(jù)右遭,查詢的大概過程為:
- 觸發(fā)事務(wù)快照;
- 根據(jù)查詢條件找到的數(shù)據(jù)頁中的記錄,獲取該數(shù)據(jù)的版本號(即寫入該記錄的事務(wù)trx_id)
- 基于快照窘哈,判斷這個寫入記錄的事務(wù)(trx_id)對于快照來講是否可見:
3.1 如果可見吹榴,則返回結(jié)果;
3.2 如果不可見滚婉,繼續(xù)找下一個版本的數(shù)據(jù)图筹。
我們可以用一個簡單的數(shù)據(jù)結(jié)構(gòu)(Read View)來記錄事務(wù)快照(建議結(jié)合上節(jié)的事務(wù)快照圖看):
Read_View {
// 最小的事務(wù)id,數(shù)據(jù)版本號 < min_id 表示可見
long min_id;
// 最大的事務(wù)id满哪,數(shù)據(jù)版本號 >= max_id 表示不可見
long max_id;
// 中間還在跑的事務(wù)id婿斥,數(shù)據(jù)版本號在里面則表示不可見(排除本事務(wù),自己肯定看得到自己修改的記錄)
long[] running_ids;
// 是否可見
bool canSee(long data_version_trx_id) {
return data_version_trx_id < min_id || !running_ids.contains(data_version_trx_id);
}
}
假設(shè)時間上有那么三個寫操作哨鸭,
- 插入記錄:
[id=1, name=zhangsan]
民宿,操作的事務(wù)trx_id = 10
- 更新記錄為:
[id=1, name=lisi]
,操作的事務(wù)trx_id = 20
- 刪除該記錄:操作的事務(wù)
trx_id = 30
都執(zhí)行后像鸡,其數(shù)據(jù)多版本的一個呈現(xiàn)如下圖:
如果期間有其他事務(wù)有觸發(fā)過快照活鹰,基于【可重復(fù)讀】的隔離級別,快照之后讀到的數(shù)據(jù)都是一樣的(同一個事務(wù)中)只估。我們來分析一下志群,等上面三個操作均執(zhí)行完成之后,我們是怎么追溯回快照時刻的數(shù)據(jù)的蛔钙。
例一:假設(shè)本事務(wù)在某個時刻建立了快照:[min_trx_id=40, max_trx_id=50, running_ids=[40]]
锌云,而后在某個時刻發(fā)起查詢select * from t where id=1
:
快照時刻,事務(wù)10/20/30均已經(jīng)提交了吁脱,所以最新的修改記錄就是事務(wù)30將這條記錄給刪了桑涎,這個“刪除”的修改對于快照是可見的,所以結(jié)果返回空了兼贡。
例二:假設(shè)本事務(wù)在某個時刻建立了快照:[min_trx_id=15, max_trx_id=28, running_ids=[15]]
攻冷,而后在某個時刻發(fā)起查詢select * from t where id=1
:
快照時刻,事務(wù)10/20已經(jīng)提交遍希,而事務(wù)30尚未開始等曼,所以能看到所有已經(jīng)提交中最新的記錄,即事務(wù)20:更新記錄為[id=1, name=lisi]
例三:假設(shè)本事務(wù)在某個時刻建立了快照:[min_trx_id=10, max_trx_id=28, running_ids=[10, 20]]
凿蒜,而后在某個時刻發(fā)起查詢select * from t where id=1;
:
快照時刻禁谦,事務(wù)30尚未開始,事務(wù)10/20均在運行中废封,均屬于未提交枷畏;插入的事務(wù)(10)都尚未提交,所以都看不見虱饿,最終返回空。
關(guān)于undo log的清除:
- 對于運行中事務(wù)引用到的undo log,不可以清除氮发,因為可能要用于回滾渴肉;
- 對于插入產(chǎn)生的undo log,在對應(yīng)寫事務(wù)結(jié)束后便可以刪除了爽冕;因為對于"insert"類型的undo對于其他事務(wù)來講等同于空(插入之前的數(shù)據(jù)自然是空的)仇祭。
- 對于其他類型的undo log,將對被定期清除(Purge)颈畸,前提是要確定當(dāng)前所有的事務(wù)快照不會再有機會用到(到達(dá))該版本的數(shù)據(jù)了乌奇。
可以看到,事務(wù)快照不變時眯娱,看到的數(shù)據(jù)將始終停留在某一個版本的礁苗。
- 隔離級別為【可重復(fù)讀】時,一旦獲取快照后徙缴,會一直用這個快照试伙,從而保證不會出現(xiàn)【不可重復(fù)讀】∮谘基于快照疏叨,如果插入數(shù)據(jù)的事務(wù)尚未提交,也是不可見的穿剖,這也就避免了【幻讀】蚤蔓。
- 隔離級別為【提交讀】時,每次常規(guī)讀(
select
)都會創(chuàng)建一個新的事務(wù)快照糊余,所以每次讀到的都是最新快照時刻的數(shù)據(jù)秀又;這也就導(dǎo)致了【不可重復(fù)讀】。 - 隔離級別為【未提交讀】時啄刹,并沒有使用快照涮坐,而是無論事務(wù)有無提交,直接讀數(shù)據(jù)頁中的行記錄作為結(jié)果(undo頁的數(shù)據(jù)都不管)誓军,從而導(dǎo)致【臟讀】袱讹。
3. 總結(jié)
- 帶鎖讀通過加鎖保證事務(wù)特性,而常規(guī)讀通過MVCC實現(xiàn)昵时;
- 當(dāng)執(zhí)行寫SQL時捷雕,除了寫數(shù)據(jù)頁,還會記錄undo log壹甥;undo log可用于以前版本數(shù)據(jù)的回溯救巷;
- 同一個事務(wù)快照,常規(guī)讀 讀到的數(shù)據(jù) 一直都是一致的句柠;
- 針對不同的隔離級別浦译,常規(guī)讀時:
- 未提交讀:直接讀取當(dāng)前數(shù)據(jù)頁的數(shù)據(jù)(可能臟讀)棒假;
- 提交讀:每次讀都建立新快照,會讀到已提交的精盅、最新的數(shù)據(jù)(無臟讀帽哑、可能不可重復(fù)讀)
- 可重復(fù)讀:只會在第一次讀時(或
start transaction with consistent snapshot
)建立快照,之后的常規(guī)讀均基于該快照(無臟讀叹俏、無不可重復(fù)讀妻枕、無幻讀) - 串行化:主動開啟事務(wù)查詢時,會將常規(guī)
select
將被轉(zhuǎn)換為select .. lock in share mode
粘驰,通過加鎖的方式(參考可重復(fù)讀)保證事務(wù)特性(無臟讀屡谐、無不可重復(fù)讀、無幻讀)