[轉帖]MySQL知識體系的三駕馬車

https://plantegg.github.io/2019/05/26/MySQL%E7%9F%A5%E8%AF%86%E4%BD%93%E7%B3%BB%E7%9A%84%E4%B8%89%E9%A9%BE%E9%A9%AC%E8%BD%A6/

 

MySQL知識體系的三駕馬車

在我看來要掌握好MySQL的話要理解好這三個東西:

  • 索引(B+樹)
  • 日誌(WAL)
  • 事務(可見性)

索引決定了查詢的性能,也是用戶感知到的數據庫的關鍵所在,日常使用過程中抱怨最多的就是查詢太慢了;

而日誌是一個數據庫的靈魂,他決定了數據庫爲什麼可靠,還要保證性能,核心原理就是將隨機寫轉換成順序寫;

事務則是數據庫的皇冠。

索引

索引主要是解決查詢性能的問題,數據一般都是寫少查多,而且要滿足各種查,所以使用數據庫過程中最常見的問題就是索引的優化。

MySQL選擇B+樹來當索引的數據結構,是因爲B+樹的樹幹只有索引,能使得索引保持比較小,更容易加載到內存中;數據全部放在B+樹的葉節點上,整個葉節點又是個有序雙向鏈表,這樣非常合適區間查找。

如果用平衡二叉樹當索引,想象一下一棵 100 萬節點的平衡二叉樹,樹高 20。一次查詢可能需要訪問 20 個數據塊。在機械硬盤時代,從磁盤隨機讀一個數據塊需要 10 ms 左右的尋址時間。也就是說,對於一個 100 萬行的表,如果使用二叉樹來存儲,單獨訪問一個行可能需要 20 個 10 ms 的時間,這個查詢可真夠慢的

對比一下 InnoDB 的一個整數字段B+數索引爲例,B+樹的杈數一般是 1200。這棵樹高是 4 的時候,就可以存 1200 的 3 次方個值,這已經 17 億了。考慮到樹根的數據塊總是在內存中的,一個 10 億行的表上一個整數字段的索引,查找一個值最多隻需要訪問 3 次磁盤。其實,樹的第二層也有很大概率在內存中,那麼訪問磁盤的平均次數就更少了。

明確以下幾點:

  • B+樹是N叉樹,以一個整數字段索引來看,N基本等於1200。數據庫裏的樹高一般在2-4層。
  • 索引的樹根節點一定在內存中,第二層大概率也在內存,再下層基本都是在磁盤中。
  • 每往下讀一層就要進行一次磁盤IO。 從B+樹的檢索過程如下圖所示:

image.png

每往下讀一層就會進行一次磁盤IO,然後會一次性讀取一些連續的數據放入內存中。

一個22.1G容量的表, 只需要高度爲3的B+樹就能存儲,如果拓展到4層,可以存放25T的容量。但主要佔內存的部分是葉子節點中的整行數據,非葉子節點全部加載到內存只需要18.8M。

B+樹

MySQL的索引結構主要是B+樹,也可以選hash

B+樹特點:

  • 葉子結點纔有數據,這些數據形成一個有序鏈表
  • 非葉子節點只有索引,導致非葉子節點小,查詢的時候整體IO更小、更穩定(相對B數)
  • 刪除相對B樹快,因爲數據有大量冗餘,大部分時候不需要改非葉子節點,刪除只需要從葉子節點中的鏈表中刪除
  • B+樹是多叉樹,相對二叉樹二分查找效率略低,但是樹高度大大降低,減少了磁盤IO
  • 因爲葉子節點的有序鏈表存在,支持範圍查找

B+樹的標準結構:

Image

innodb實現的B+樹用了雙向鏈表,節點內容存儲的是頁號(每頁16K)

Image

聯合索引

對於多個查詢條件的複雜查詢要正確建立多列的聯合索引來儘可能多地命中多個查詢條件,過濾性好的列要放在聯合索引的前面。

MySQL一個查詢只能用一個索引。

索引下推(index condition pushdown )

對於多個where條件的話,如果索引只能命中一個,剩下的那個條件過濾還是會通過回表來獲取到後判斷是否符合,但是MySQL5.6後,如果剩下的那個條件在聯合索引上(但是因爲第一個條件是模糊查詢,沒法用全聯合索引),會將這個條件下推到索引判斷上,來減少回表次數。這叫索引下推優化(index condition pushdown )

覆蓋索引

要查詢的列(select後面的列)如果都在索引上,那麼這個查詢的最終結果都可以直接從索引上讀取到,這樣讀一次索引(數據小、順序讀)性能非常好。否則的話需要回表去獲取別的列

前綴索引用不上覆蓋索引對查詢性能的優化,每次索引命中可能需要做一次回表,確認完整列值

回表

select from table order by id limit 150000,10 這樣limit後偏移很大一個值的查詢,會因爲*回表導致非常慢。

這是因爲根據id列上索引去查詢過濾,但是select *要求查所有列的內容,但是索引上只有id的數據,所以導致每次對id索引進行過濾都要求去回表(根據id到表空間取到這個id行所有列的值),每一行都要回表導致這裏出現了150000+10次隨機磁盤讀。

可以通過先用一個子查詢(select id from order by id limit 150000,10),子查詢中只查id列,而id的值都在索引上,用上了覆蓋索引來避免回表。

先查到這10個id(掃描行數還是150000+10, 這裏的limit因爲有deleted記錄、每行大小不一樣等因素影響,沒法一次跳到150000處。但是這次掃描150000行的時候不需要回表,所以速度快多了),然後再跟整個表做jion(join的時候只需要對這10個id行進行回表),來提升性能。

索引的一些其它知識點

多用自增主鍵是因爲自增主鍵保證的是主鍵一直是增加的,也就是不會在索引中間插入,這樣的話避免的索引頁的分裂(代價很高)

寫數據除了記錄redo-log之外還會在內存(change buffer)中記錄下修改後的數據,這樣再次修改、讀取的話不需要從磁盤讀取數據,非唯一索引才能用上change buffer,因爲唯一索引一定需要讀磁盤驗證唯一性,既然讀過磁盤這個change buffer的意義就不大了。

1
mysql> insert into t(id,k) values(id1,k1),(id2,k2);//假設k1頁在buffer中,k2不在

image.png

Buffer POOL

(1)緩衝池(buffer pool)是一種常見的降低磁盤訪問的機制;

(2)緩衝池通常以頁(page)爲單位緩存數據;

(3)緩衝池的常見管理算法是LRU,memcache,OS,InnoDB都使用了這種算法;

(4)InnoDB對普通LRU進行了優化:

- 將緩衝池分爲老生代和新生代,入緩衝池的頁,優先進入老生代,頁被訪問,才進入新生代,以解決預讀失效的問題

- 頁被訪問(預讀的丟到old區),且在老生代停留時間超過配置閾值(innodb_old_blocks_time)的,才進入新生代,以解決批量數據訪問,大量熱數據淘汰的問題

圖片

只有同時滿足「被訪問」與「在 old 區域停留時間超過 1 秒」兩個條件,纔會被插入到 young 區域頭部

日誌

數據庫的關鍵瓶頸在於寫,因爲每次更新都要落盤防止丟數據,而磁盤最怕的就是隨機寫。

Write-Ahead logging(WAL)

寫磁盤前先寫日誌,這樣不用擔心丟數據問題,寫日誌又是一個順序寫,性能比隨機寫好多了,這樣將性能很差的隨機寫轉換成了順序寫。然後每過一段時間將這些日誌合併後真正寫入到表空間,這次是隨機寫,但是有機會將多個寫合併成一個,比如多個寫在同一個Page上。

這是數據庫優化的關鍵。

bin-log

MySQL Server用來記錄執行修改數據的SQL,Replication基本就是複製並重放這個日誌。有statement、row和混合模式三種。

bin-log保證不了表空間和bin-log的一致性,也就是斷電之類的場景下是沒法保證數據的一致性。

MySQL 日誌刷新策略通過 sync_binlog 參數進行配置,其有 3 個可選配置:

  1. sync_binlog=0:MySQL 應用將完全不負責日誌同步到磁盤,將緩存中的日誌數據刷新到磁盤全權交給操作系統來完成;
  2. sync_binlog=1:MySQL 應用在事務提交前將緩存區的日誌刷新到磁盤;
  3. sync_binlog=N:當 N 不爲 0 與 1 時,MySQL 在收集到 N 個日誌提交後,纔會將緩存區的日誌同步到磁盤。

redo-log

INNODB引擎用來保證事務的完整性,也就是crash-safe。MySQL 默認是保證不了不丟數據的,如果寫了表空間還沒來得及寫bin-log就會造成主從數據不一致;或者在事務中需要執行多個SQL,bin-log保證不了完整性。

而在redo-log中任何修改都會先記錄到redo-log中,即使斷電MySQL重啓後也會先檢查redo-log將redo-log中記錄了但是沒有提交到表空間的數據進行提交(刷髒)

redo-log和bin-log的比較:

  • redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 層實現的,所有引擎都可以使用。redo-log保證了crash-safe的問題,binlog只能用於歸檔,保證不了safe。
  • redo log 是物理日誌,記錄的是“在某個數據頁上做了什麼修改”;binlog 是邏輯日誌,記錄的是這個語句的原始邏輯,比如“給 ID=2 這一行的 c 字段加 1 ”。
  • redo log 是循環寫的,空間固定會用完;binlog 是可以追加寫入的。“追加寫”是指 binlog 文件寫到一定大小後會切換到下一個,並不會覆蓋以前的日誌。

redo-log中記錄的是對頁的操作,而不是修改後的數據頁,buffer pool(或者說change buffer)中記錄的纔是數據頁。正常刷髒是指的將change buffer中的髒頁刷到表空間的磁盤,如果沒來得及刷髒就崩潰了,那麼就只能從redo-log來將沒有刷盤的操作再執行一次讓他們真正落盤。buffer pool中的任何變化都會寫入到redo-log中(不管事務是否提交)

只有當commit(非兩階段的commit)的時候纔會真正把redo-log寫到表空間的磁盤上(不一定是commit的時候刷到表空間)。

如果機器性能很好(內存大、innodb_buffer_pool設置也很大,iops高),但是設置了比較小的innodb_logfile_size那麼會造成redo-log很快會被寫滿,這個時候系統會停止所有更新,全力刷盤去推進ib_logfile checkpoint(位點),這個時候磁盤壓力很小,但是數據庫性能會出現間歇性下跌(select 反而相對更穩定了–更少的merge)。

redo-log要求數據量儘量少,這樣寫盤IO小;操作冪等(保證重放冪等)。實際邏輯日誌(Logical Log, 也就是bin-log)的特點就是數據量小,而冪等則是基於Page的Physical Logging特點。最終redo-log的形式是Physiological Logging的方式,來兼得二者的優勢。

所謂Physiological Logging,就是以Page爲單位,但在Page內以邏輯的方式記錄。舉個例子,MLOG_REC_UPDATE_IN_PLACE類型的REDO中記錄了對Page中一個Record的修改,方法如下:

(Page ID,Record Offset,(Filed 1, Value 1) … (Filed i, Value i) … )

其中,PageID指定要操作的Page頁,Record Offset記錄了Record在Page內的偏移位置,後面的Field數組,記錄了需要修改的Field以及修改後的Value。

Innodb的默認Page大小是16K,OS文件系統默認都是4KB,對16KB的Page的修改保證不了原子性,因此Innodb又引入Double Write Buffer的方式來通過寫兩次的方式保證恢復的時候找到一個正確的Page狀態。

InnoDB給每個REDO記錄一個全局唯一遞增的標號LSN(Log Sequence Number)。Page在修改時,會將對應的REDO記錄的LSN記錄在Page上(FIL_PAGE_LSN字段),這樣恢復重放REDO時,就可以來判斷跳過已經應用的REDO,從而實現重放的冪等。

binlog和redo-log一致性的保證

bin-log和redo-log的一致性是通過兩階段提交來保證的,bin-log作爲事務的協調者,兩階段提交過程中prepare是非常重的,prepare一定會持久化(日誌),記錄如何commit和rollback,一旦prepare成功就一定能commit和rollback,如果其他節點commit後崩潰,恢復後會有一個協商過程,其它節點發現崩潰節點已經commit,所以會跟隨commit;如果崩潰節點還沒有prepare那麼其它節點只能rollback。

實際崩潰後恢復時MySQL是這樣保證redo-log和bin-log的完整性的:

  1. 如果redo-log裏面的事務是完整的,也就是有了commit標識,那麼直接提交
  2. 如果redo-log裏面事務只有完整的prepare,則去檢查事務對應的binlog是否完整
    1. 如果binlog完整則提交事務
    2. 如果不完整則回滾事務
  3. redo-log和binlog有一個共同的數據字段叫XID將他們關聯起來

組提交

在沒有開啓binlog時,Redo log的刷盤操作將會是最終影響MySQL TPS的瓶頸所在。爲了緩解這一問題,MySQL使用了組提交,將多個刷盤操作合併成一個,如果說10個事務依次排隊刷盤的時間成本是10,那麼將這10個事務一次性一起刷盤的時間成本則近似於1。

但是開啓binlog後,binlog作爲事務的協調者每次commit都需要落盤,這導致了Redo log的組提交失去了意義。

image-20211108152328424

Group Commit的方案中,其正確性的前提在於一個group內的事務沒有併發衝突,因此即便並行也不會破壞事務的執行順序。這個方案的侷限性在於一個group 內的並行度仍然有限

刷髒

在內存中修改了,已經寫入到redo-log中,但是還沒來得及寫入表空間的數據叫做髒頁,MySQL過一段時間就需要刷髒,刷髒最容易造成MySQL的卡頓。

  • redo-log寫滿後,系統會停止所有更新操作,把checkpoint向前推進也就是將數據寫入到表空間。這時寫性能跌0,這個場景對性能影響最大
  • 系統內存不夠,也需要將內存中的髒頁釋放,釋放前需要先刷入到表空間。
  • 系統內存不夠,但是redo-log空間夠,也會刷髒,也就是刷髒不只是髒頁寫到redo-log,還要考慮讀取情況。刷髒頁後redo-log位點也一定會向前推薦
  • 系統空閒的時候也會趁機刷髒
  • 刷髒的時候默認還會連帶刷鄰居髒頁(innodb_flush_neighbors)

當然如果一次性要淘汰的髒頁太多,也會導致查詢卡頓嚴重,可以通過設置innodb_io_capacity(一般設置成磁盤的iops),這個值越小的話一次刷髒頁的數量越小,如果刷髒頁速度還跟不上髒頁生成速度就會造成髒頁堆積,影響查詢、更新性能。

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

長事務意味着系統裏面會存在很老的事務視圖。由於這些事務隨時可能訪問數據庫裏面的任何數據,所以這個事務提交之前,數據庫裏面它可能用到的回滾記錄都必須保留,這就會導致大量佔用存儲空間。除了對回滾段的影響,長事務還佔用鎖資源,也可能拖垮整個庫。

表空間會刷進去沒有提交的事務(比如大事務change buffer和redo-log都不夠的時候),這個修改雖然在表空間中,但是通過可見性來控制是否可見。

落盤

innodb_flush_method 參數目前有 6 種可選配置值:

  1. fdatasync;
  2. O_DSYNC
  3. O_DIRECT
  4. O_DIRECT_NO_FSYNC
  5. littlesync
  6. nosync

其中,littlesync 與 nosync 僅僅用於內部性能測試,並不建議使用。

  • fdatasync,即取值 0,這是默認配置值。對 log files 以及 data files 都採用 fsync 的方式進行同步;
  • O_DSYNC,即取值 1。對 log files 使用 O_SYNC 打開與刷新日誌文件,使用 fsync 來刷新 data files 中的數據;
  • O_DIRECT,即取值 4。利用 Direct I/O 的方式打開 data file,並且每次寫操作都通過執行 fsync 系統調用的方式落盤;
  • O_DIRECT_NO_FSYNC,即取值 5。利用 Direct I/O 的方式打開 data files,但是每次寫操作並不會調用 fsync 系統調用進行落盤;

爲什麼有 O_DIRECT 與 O_DIRECT_NO_FSYNC 配置的區別?

首先,我們需要理解更新操作落盤分爲兩個具體的子步驟:①文件數據更新落盤②文件元數據更新落盤。O_DIRECT 的在部分操作系統中會導致文件元數據不落盤,除非主動調用 fsync,爲此,MySQL 提供了 O_DIRECT 以及 O_DIRECT_NO_FSYNC 這兩個配置。

如果你確定在自己的操作系統上,即使不進行 fsync 調用,也能夠確保文件元數據落盤,那麼請使用 O_DIRECT_NO_FSYNC 配置,這對 MySQL 性能略有幫助。否則,請使用 O_DIRECT,不然文件元數據的丟失可能會導致 MySQL 運行錯誤。

Double Write

MySQL默認數據頁是16k,而操作系統內核的頁目前爲4k。因此當一個16k的MySQL頁寫入過程中突然斷電,可能只寫入了一部分,即數據存在不一致的情況。MySQL爲了防止這種情況,每寫一個數據頁時,會先寫在磁盤上的一個固定位置,然後再寫入到真正的位置。如果第二次寫入時掉電,MySQL會從第一次寫入的位置恢復數據。開啓double write之後數據被寫入兩次,如果能將其優化掉,對用戶的性能將會有不小的提升。

MySQL 8.0關掉Double Write能有5%左右的性能提升

事務

在 MySQL/InnoDB 中,使用MVCC(Multi Version Concurrency Control) 來實現事務。每個事務修改數據之後,會創建一個新的版本,用事務id作爲版本號;一行數據的多個版本會通過指針連接起來,通過指針即可遍歷所有版本。

當事務讀取數據時,會根據隔離級別選擇合適的版本。例如對於 Read Committed 隔離級別來說,每條SQL都會讀取最新的已提交版本;而對於Repeatable Read來說,會在事務開始時選擇已提交的最新版本,後續的每條SQL都會讀取同一個版本的數據。

img

Postgres用Old to New,INNODB使用的是New to Old, 即主表存最新的版本,用鏈表指向舊的版本。當讀取最新版本數據時,由於索引直接指向了最新版本,因此較低;與之相反,讀取舊版本的數據代價會隨之增加,需要沿着鏈表遍歷。

INNODB中舊版本的數據存儲於undo log中。這裏的undo log起到了幾個目的,一個是事務的回滾,事務回滾時從undo log可以恢復出原先的數據,另一個目的是實現MVCC,對於舊的事務可以從undo 讀取舊版本數據。

可見性

是基於事務的隔離級別而言的,常用的事務的隔離級別有可重複讀RR(Repeatable Read,MySQL默認的事務隔離級別)和讀已提交RC(Read Committed)。

可重複讀

讀已提交:A事務能讀到B事務已經commit了的結果,即使B事務開始時間晚於A事務

重複讀的定義:一個事務啓動的時候,能夠看到所有已經提交的事務結果。但是之後,這個事務執行期間,其他事務的更新對它不可見。

指的是在一個事務中先後兩次讀到的結果是一樣的,當然這兩次讀的中間自己沒有修改這個數據,如果自己修改了就是當前讀了。

如果兩次讀過程中,有一個別的事務修改了數據並提交了,第二次讀到的還是別的事務修改前的數據,也就是這個修改後的數據不可見,因爲別的事務在本事務之後。

如果一個在本事務啓動之後的事務已經提交了,本事務會讀到最新的數據,但是因爲隔離級別的設置,會要求MySQL判斷這個數據不可見,這樣只能按照undo-log去反推修改前的數據,如果有很多這樣的已經提交的事務,那麼需要反推很多次,也會造成卡頓。

總結下,可見性的關鍵在於兩個事務開始的先後關係:

  • 如果是可重複讀RR(Repeatable Read),後開始的事務提交的結果對前面的事務可見
  • 如果是讀已提交RC(Read Committed),後開始的事務提交的結果對前面的事務可見

當前讀

更新數據都是先讀後寫的,而這個讀,只能讀當前的值,稱爲”當前讀“(current read)。除了 update 語句外,select 語句如果加鎖,也是當前讀。

事務的可重複讀的能力是怎麼實現的?

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

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

  • 在可重複讀隔離級別下,只需要在事務開始的時候創建一致性視圖,之後事務裏的其他查詢都共
    用這個一致性視圖;
  • 在讀提交隔離級別下,每一個語句執行前都會重新算出一個新的視圖。

幻讀

幻讀指的是一個事務中前後兩次讀到的數據不一致(讀到了新插入的行)

可重複讀是不會出現幻讀的,但是更新數據時只能用當前讀,當前讀要求讀到其它事務的修改(新插入行)

Innodb 引擎爲了解決「可重複讀」隔離級別使用「當前讀」而造成的幻讀問題,就引出了 next-key 鎖,就是記錄鎖和間隙鎖的組合。

  • 記錄鎖,鎖的是記錄本身;
  • 間隙鎖,鎖的就是兩個值之間的空隙,以防止其他事務在這個空隙間插入新的數據,從而避免幻讀現象。

可重複讀、當前讀以及行鎖案例

案例表結構

1
2
3
4
5
6
7
 
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);

上表執行如下三個事務

img

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

“start transaction with consistent snapshot; ”的意思是從這個語句開始,創建一個持續整個事務的一致性快照

在讀提交隔離級別(RC)下,這個用法就沒意義了,等效於普通的 start transaction。

因爲以上案例是RR(start transaction with consistent snapshot;), 也就是可重複讀隔離級別。

那麼事務B select到的K是3,因爲事務C已提交,事務B update的時候不會等鎖了,同時update必須要做當前讀,這是因爲update不做當前讀而是可重複性讀的話讀到的K是1,這樣覆蓋了事務C的提交!也就是更新數據伴隨的是當前讀。

事務A開始在事務C之前, 而select是可重複性讀,所以事務C提交了但是對A不可見,也就是select要保持可重複性讀仍然讀到的是1.

如果這個案例改成RC,事務B看到的還是3,事務A看到的就是2了(這個2是事務C提交的),因爲隔離級別是RC。select 執行時間點事務纔開始。

MySQL和PG事務實現上的差異

這兩個數據庫對MVCC實現上選擇了不同方案,上面講了MySQL選擇的是redo-log去反推多個事務的不同數據,這個方案實現簡單。但是PG選擇的是保留多個不同的數據版本,優點就是查詢不同版本數據效率高,缺點就是對這些數據要做壓縮、合併之類的。

總結

理解好索引是程序員是否掌握數據庫的最關鍵知識點,理解好索引纔會寫出更高效的SQL,避免慢查詢搞死MySQL。

對日誌的理解可以看到一個數據庫爲了提升性能(刷磁盤的瓶頸)採取的各種手段。也是最重要的一些設計思想所在。

事務則是數據庫皇冠。

參考資料

https://explainextended.com/2009/10/23/mysql-order-by-limit-performance-late-row-lookups/ 回表

https://stackoverflow.com/questions/1243952/how-can-i-speed-up-a-mysql-query-with-a-large-offset-in-the-limit-clause

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