之前寫了幾篇mysql存儲原理的文章。
2 表對象緩存
1 連接層
這一篇終於到事務了,事務大家都知道ACID概念,那麼mysql是如何完成事務的呢?
先來看結論——
原子性:
一次事務中的所有操作,要麼全部完成要麼全部不執行。這裏是通過undo log來實現的。
undo log又是什麼呢,可以理解爲要執行的sql的反向sql,也就是回滾sql。譬如你insert了一條,undo log裏就會保存一個delete xxx id = xxx。
持久性:
一旦一個事務被提交,就算服務器崩潰,仍然不能丟數據,在下次啓動時需要能自動恢復。這裏是通過redo log實現的。
那麼如何才能持久呢,很簡單,寫入硬盤就行了。通過前面幾篇的學習,我們知道mysql大部分時間是在Innodb_buffer_pool裏做內存讀寫的,特定情況下才會落盤。這樣如果突然服務器崩潰,沒來得及落盤的數據就會丟失。所以有了redo log順序寫磁盤(順序寫速度極快,後續的落盤是隨機寫,速度慢),在事務提交後,事務日誌會順序寫入磁盤,然後寫入pool內存裏。然後纔是後續的那些按規則將索引數據落盤。
隔離性:
事務的隔離性是通過讀寫鎖+MVCC來實現的。mysql在爲了併發量和數據隔離方面做了很多的嘗試,其中MVCC就是比較好的解決方案。
也就是面試最常見的4大隔離級別。
1.讀未提交 其它事務未提交就可以讀
2.讀已提交 其它事務只有提交了才能讀
3.可重複讀 只管自己啓動事務時候的狀態,不受其它事務的影響(mysql默認)
4.事務串行 按照順序提交事務保證了數據的安全性,但無法實現併發
一致性:
即數據一致性,是通過上面的三種加起來聯合實現的。
ACID只是個概念,最終目的就是保證數據的一致性和可靠不丟。
Undo Log
所謂的undo log就是回滾日誌,當進行插入、刪除、修改操作時,一定會生成undo log,並且一定優先於修改後的數據落盤。
我直接借用別人的圖了,zhangsan的銀行賬戶有1000元,他要轉400元到理財賬戶。
可以看到,每一條變更數據的操作,都伴隨一條undo log的生成。undo log就是記錄數據的原始狀態。這一點在一些其他的分佈式事務的框架裏也有所使用,譬如seata就是採用自己解析sql並生成反向sql存儲下來,將來拋異常時就執行回滾語句的方式來做的分佈式事務,而不藉助於mysql自身的undo log機制。
有了undo log,如果事務執行失敗要回滾,那很簡單,直接將undo log裏的回滾語句執行一遍就好了。mysql就是通過這種方式完成的原子性。
Redo Log
redo log是完成數據持久性的,事務一旦提交,其所做的修改就會永久保存到數據庫中,而不能丟失,即便是mysql服務器掛掉了也不能丟。
mysql數據是存儲到磁盤的,但讀寫其實都是操作的buffer pool緩存(前面幾篇講過insert buffer)。這樣就會造成寫入時,如果僅僅寫入到buffer pool了,還沒來得及刷入數據頁,那麼mysql突然宕機,就會丟失數據。
此時redo log的作用就出來了,在寫入buffer pool後會同時寫入到redo log(順序寫磁盤)一份,redo log有固定的大小,會被重複使用。
MVCC及隔離級別
mvcc是什麼
MVCC (MultiVersion Concurrency Control) 叫做多版本併發控制。理解爲:事務對數據庫的任何修改的提交都不會直接覆蓋之前的數據,而是產生一個新的版本與老版本共存,使得讀取時可以完全不加鎖。
有點抽象是嗎,再來詳細解釋一下。同一行數據會有多個版本,某事務對該數據的修改並不會直接覆蓋老版本,而是產生一個新版本和老版共存。然後在該行追加兩個虛擬的列,列就是進行數據操作的事務的ID(created_by_txn_id),是一個單調遞增的ID;還有一個deleted_by_txn_id,將來用來做刪除的。
那麼在另一個事務在讀取該行數據時,由具體的隔離級別來控制到底讀取該行的哪個版本。同時,在讀取過程中完全不加鎖,除非用select * xxx for update強行加鎖。
譬如read committed級別,每次讀取,總是取事務ID最大的那個就好了。
對於Repeatable read,每次讀取時,總是取事務ID小於等於當前事務的ID的那些數據記錄。在這個範圍內,如果某一數據有多個版本,則取最新的。
MVCC在mysql中的實現依賴的是undo log與read view
undo log記錄某行數據的多個版本的數據;read view用來判斷當前版本數據的可見性。
mysql就是用MVCC來實現讀寫分離不加鎖的。
那麼MVCC裏多出來的那些版本的數據最終是要刪除的,支持MVCC的數據庫套路一般差不多,都會有一個後臺線程來定時清理那些肯定沒用的數據。只要一個數據的deleted_by_txn_id不爲空,並且比當前還沒結束的事務ID最小的一個還小,該數據就可以被清理掉了。在PostgreSQL中,該清理任務叫“vacuum”,在Innodb中,叫做“purge”。
隔離級別
隔離級別目的很明確,管理多個併發讀寫請求的訪問順序,包括串行和並行,要在併發性能和讀取數據的正確性上做個權衡。
其中的兩個隔離級別Serializable和 Read Uncommited幾乎用不上,這裏不談。
Read Committed
能讀到其他事務已提交的內容,這是Springboot默認的隔離級別。一個事務在他提交之前的所有修改,對其他事務不可見。提交後,其他事務就能讀到了。在很多場景下這種邏輯是可以接受的。
在這個隔離級別下,讀取數據不加鎖而是使用MVCC機制,寫入數據就是排他鎖。該級別會產生不可重複讀和幻讀問題。
不可重複讀就是在一個事務內多次讀取的結果不一樣,這個很容易理解,上面講MVCC時也說了,該級別每次select時都會去讀取最新的版本,所以同一個事務內,也就是代碼前面一行select了,後面又select了,可能會select到不同的值。因爲這兩次select過程中,有其他事務對select的行進行了事務提交,就會被select出來最新的。
幻讀,即一個事務能夠讀取到插入的新數據。會出現幻讀也是一樣的道理,第一次select時還沒值,再次select時又有值了。
Repeatable Read
這個級別名字就是可重複讀,這是mysql默認的隔離級別。
爲什麼能重複讀,前面講MVCC時也說了,這個級別下,一旦讀到某個版本,後續都是這個版本了,好比是一次快照,就不關心其他事務對該行數據的提交了,它只認第一次讀取時的版本號。
這個級別在一些場景下很重要,如
數據備份:
例如數據庫S從數據庫M中複製數據,但是M又不停的在修改數據。S需要拿到M的一個數據快照,但又不能停M。
數據合法性校驗:
例如有兩張表,一張記錄了當時的交易總額,另一張記錄了每個交易的金額。那麼在讀取數據時,如果沒有快照的存在,交易總額就可能和當時的交易總額對不上。
該級別依然會出現幻讀的問題,repeatable是可以出現幻讀的,一個事務雖然不能讀取其他事務對現有數據的修改,但是能夠讀取到插入的新數據。
即便是MVCC也解決不了幻讀的問題,這裏有一篇講的原因。
寫前提困境
儘管在MVCC的加持下Read Committed和Repeatable Read都可以得到很好的實現。但是對於某些業務代碼來講,在當前事務中看到/看不到其他的事務已經提交的修改的意義不是很大。這種業務代碼一般是這樣的:
先讀取一段現有的數據
在這個數據的基礎上做邏輯判斷或者計算;
將計算的結果寫回數據庫。
這樣第三步的寫入就會依賴第一步的讀取。但是在1和3之間,不管業務代碼離得有多近,都無法避免其他事務的併發修改。換句話說,步驟1的數據正確是步驟3能夠在業務上正確的前提,這樣其實與MVCC都沒什麼關係了,因爲我們想象中的要操作的數據和實際值並不一樣,無論怎麼步驟3的結果其實都不對了。
無論你用哪種隔離,你都無法解決第一步讀取的數據和第三步操作之間,別的事務對它的修改。
解決方法:
結論
雖然上面寫了很多,也很複雜,貌似不上鎖怎麼都難以解決寫前提困境。而事實上,我們幾乎不用考慮這樣的場景,極少有可能說多個客戶端同時操作同一條數據,又剛好碰上需要抉擇read committed還是Repeatable read的困境。
結論很簡單,不管他就好,你幾乎沒機會碰到這樣的選擇困境。