▌說在前面:
從0開始,手寫一個MySQL的學習價值在于:
- 可以深入地了解MySQL的内部機制和原理,Mysql可謂是面試的絕對重點和難點,
尼恩曾經指導過的一個7年經驗小夥,憑借精通MySQL, 搞定月薪40K。
- 進而更好地掌握MySQL的使用和優化。
- 幫助你提高程式設計能力和解決問題的能力。
- 作為一個優質的履歷輪子項目,注意,是高品質的履歷輪子項目
很多小夥伴的項目都很low,極度缺乏輪子項目,so,輪子項目這就來了。
前面已經釋出了一篇:
《老架構帶你手寫資料庫之1: 從0開始,手寫MySQL事務管理器TM》
接下來,咱們來第二篇 :
《老架構帶你手寫資料庫之2: 從0開始,手寫MySQL資料管理器DM》(本文)
《尼恩 架構筆記》《尼恩高并發三部曲》《尼恩Java面試寶典》的PDF檔案,請到公号【技術自由圈】取
▌本文目錄:
- 說在前面
- 回顧一下,手寫DB架構設計
- MySql的緩沖池Buffer pool
- 資料頁
- 緩存頁
- Free連結清單
- Flush連結清單
- LRU算法
- 基于冷熱資料分離優化後的LRU連結清單
- 使用引用計數實作緩存池
- 使用緩存池緩存資料頁
- 資料頁管理
- 用于啟動校驗的特殊頁
- 用于存儲資料的普通頁
- 頁面空閑空間管理
- 資料日志和恢複政策
- Mysql的日志
- redo log(重做日志)
- 髒資料刷盤
- crash-safe
- 髒日志刷盤
- undo log(復原日志)
- undo log的作用
- bin log(歸檔日志)
- 主從同步
- bin log 和redo log 的差別
- relay log(中繼日志)
- slow query log(慢查詢日志)
- general query log(一般查詢日志)
- error log(錯誤日志)
- 采用二進制檔案實作redo log
- 恢複政策
- 單線程
- 多線程
- 資料項及其操作
- 資料操作管理器
- 總結
- 作者介紹
- 說在後面
- 推薦相關閱讀
▌回顧一下,手寫DB架構設計:
尼恩的風格: 開始寫代碼前,先做架構
從功能上來說,一個手寫DB系統架構分為以下幾個子產品:
- 資料管理器(DM)
- 事務管理器(TM)
- 版本管理器(VM)
- 表管理器(TBM)
- 索引管理器(IM)
手寫DB架構設計設計圖,具體如下:
接下來,尼恩團隊帶大家,從基礎的原理入手,一步一步從0開始,手寫MySQL資料管理器
▌MySQL的緩沖池 Buffer pool:
首先,我們先來了解一下MySQL的緩沖池(Buffer Pool)。
我們知道資料以檔案的形式存儲在系統磁盤等存儲媒體中,但是真正處理資料的過程是發生在記憶體中,是以需要把磁盤中的資料檔案加載到記憶體中。如果是處理修改或者增加等資料操作時,還需要把記憶體中的資料重新整理到磁盤上。
問題來了。磁盤的讀寫速度非常慢,如果資料加載或者寫入時還是一條條記錄為機關,那樣效率低的不敢想象。
如何提升性能呢?DM的絕招是:緩沖池。
什麼是緩沖池?
首先來看 緩沖頁(page)。 DM中在處理用戶端的請求時,将資料劃分為若幹個頁(page),以頁作為磁盤和記憶體之間互動的機關。但如果需要通路某個頁的資料,就會把完整的頁中的資料全部加載到記憶體中,即使隻通路頁中的一條記錄,也需要先把整個頁的資料加載到記憶體中。将整個頁加載到記憶體後就可以進行讀寫通路了,而且在讀寫通路之後并不着急把該頁對應的記憶體空間釋放掉,而是将其緩存起來,這樣将來有請求再次通路該頁面時,就可以剩下磁盤IO的開銷了。
什麼是緩沖池? 為了緩存磁盤中的頁,MYDB在MySQL伺服器啟動時就向作業系統申請了一片連續的記憶體,即Buffer Pool(緩沖池)。
預設情況下,Buffer Pool的大小為128M。
Buffer Pool對應的一片連續的記憶體被劃分為若幹個頁面,頁面大小與Innodb表空間用的頁面大小一緻,預設都是16kb,為了與磁盤中的頁面區分開來,我們把這些Buffer Pool中的頁面稱為緩沖頁。當我們修改了Buffer Pool中某個緩沖頁的資料,它就與磁盤上的頁不一緻了,這樣的緩沖頁稱為髒頁。當然,我們可以每當修改完某個資料頁時,就立即将其重新整理到磁盤中對應的頁上,但是頻繁的往磁盤中寫資料會嚴重的影響程式的性能,是以每次修改緩沖頁後,我們并不着急立即将修改重新整理到磁盤上,而是在某個時間點進行重新整理。背景有專門的線程負責每隔一段時間就把髒頁重新整理到磁盤,這樣就可以不影響使用者線程處理正常的請求。
總之:Innodb是以頁為機關來管理存儲空間的,在真正通路頁面之前,需要先把磁盤中的頁加載到記憶體中的Buffer Pool中,之後才可以通路,所有的變更都必須先更新緩沖池中的資料,然後緩沖池中的髒頁以一定的頻率重新整理到磁盤(checkpoint機制),通過緩沖池來優化CPU和磁盤之間的鴻溝,這樣就能保證整體的性能不會下降的太快。
緩沖池的巨大價值:Buffer Pool 緩存表資料與索引資料,把磁盤上的資料加載到緩沖池,避免每次通路都進行磁盤IO,起到加速通路的作用。雖然Buffer Pool 緩沖池的通路速度快,但是沒有把所有的資料放到緩沖池的主要原因是緩沖池的存儲容量小。隻能把“最熱”的資料放到“最近”的地方,以“最大限度”的降低磁盤通路。
尼恩的架構三闆斧(緩沖、異步、池化)中, 緩沖是 其中的 第一闆斧。
可見,不管是3高架構,還是 DB内部架構, 架構的原理和模式,都是相通的。
更多架構模式和架構思想,參見尼恩3高架構知識宇宙。
▌資料頁
每個資料頁的大小預設是16KB。
每個資料頁存放着多條的資料,在執行增删改首先會定位到這條資料所在資料頁,然後會将資料所在的資料頁加載到 Buffer Pool 中。
資料頁
尼恩提示:以上内容比較複雜,而且非常重要。如何把以上内容搞清楚? 可以看本文的配套視訊《從0開始,老架構師帶你手寫DB》。
如果想把手寫DB寫入履歷,可以找老架構師尼恩提供履歷輔導,保證履歷金光閃閃、天衣無縫。
▌緩存頁
當資料頁被加載到緩沖池Buffer Pool中後,Buffer Pool 有對應的緩存頁與資料頁一一對應,其大小和資料頁一樣,預設是16KB。
尼恩提示: 資料頁在磁盤上, 緩沖頁在記憶體中。
MySQL還會為每個緩存開辟額外的空間用來描述對應的緩存頁的一些資訊,這些是 中繼資料,例如:資料頁所屬的表空間、資料頁号。 如下圖:
緩存頁
▌Free連結清單
記憶體中的緩存頁,有些是空閑的。
當,資料頁會被加載到一個緩存頁中的時候,MySQL是如何區分哪些緩存頁是空閑的狀态可以用來存放資料頁的。
為了解決這個問題,MySQL 為 Buffer Pool 設計了一個雙向連結清單— free連結清單。
free連結清單是由每個空閑緩存頁的描述資料組成的一個雙向連結清單。
free連結清單作用是用來儲存空閑緩存頁的描述資料的。
free 連結清單還會有一個基礎節點,它會引用該連結清單的頭結點和尾結點,還會記錄可用的空閑的緩存頁的個數。
Free連結清單
當加載資料頁到緩存池中的時候, MySQL會從 free 連結清單中擷取一個描述資料的資訊,根據描述節點的資訊拿到其對應的緩存頁,然後将資料頁資訊放到該緩存頁中,同時将free 連結清單中的該描述資料的節點移除。
這就是資料頁被讀取 Buffer Pool 中的緩存頁的過程。
資料庫中有一個哈希表結構, 來記錄一個 資料頁的緩存狀态。key為 資料頁的編号,具體來說,表空間号 + 資料頁号作為資料頁的key,value為緩存頁記憶體位址。
通過這個 哈希表結構,可以很容易判斷 資料頁是否被緩沖池: 在資料頁加載的時候就會通過哈希表中的key來确定資料頁是否被緩存了。
資料頁緩存哈希表結構如下圖所示:
資料頁緩存哈希表
▌Flush連結清單
記憶體的資料修改了,就會和磁盤上的資料頁不一緻。
問題是:如何保證 記憶體資料,磁盤資料的一緻性呢?
MySQL 在執行增删改的時候會一直将資料以資料頁的形式加載到 Buffer Pool 的緩存頁中,增删改的操作都是在記憶體中執行的。 修了了的 記憶體頁,就是髒頁。
如何把髒頁重新整理呢?
MySQL會有一個背景的線程數将髒資料重新整理到磁盤中,但是背景的線程是如何知道應該重新整理哪些髒資料到磁盤呢?
針對這個問題,MySQL設計出了 Flush 連結清單,他的作用就是記錄被修改過的髒資料所在的緩存頁對應的描述資料。如果記憶體中的資料和資料庫和資料庫中的資料不一樣,那這些資料就稱為髒資料,本質上就是被緩存到緩存池中的資料被修改了,但是還沒有重新整理到磁盤中。
同樣的這些已經被修改了的資料所在的緩存頁的描述資料會被維護到 Flush 連結清單中;當某個髒資料頁被重新整理到磁盤後,其空間就騰出來了,然後又會跑到 Free 連結清單中了。
Flush連結清單如下圖:
flush連結清單
▌LRU算法:
緩存的性能,和緩存的命中率緊密相關。 如何提升緩存的命中率?
Buffer Pool 用一個連結清單管理 緩存頁, 當 記憶體不夠,或者緩存不夠的時候。 這個緩存頁清單使用 LRU 算法 進行資料頁的淘汰。
尼恩提示:這是一個非常重要的算法。
尼恩的 100OPS 三級緩存元件實操中,重點介紹了這個算法。
這個算法在資料庫、作業系統、中間件中,都有大家的使用。 大家最好能夠手寫一下。
如果一直在進行資料庫的增删改操縱,資料庫内部的基本流程如下:
資料查詢時資料庫内部基本流程
傳統的LRU把新入緩沖池的資料頁放到LRU的頭部,作為最近通路的元素,進而最晚被淘汰,這裡又分為兩種情況:
(1) 頁已經在緩沖池中:隻做移至LRU頭部的動作,沒有頁被淘汰。
(2)頁不在緩沖池中:除了做放入LRU頭部的動作,還要做淘汰LRU尾部頁的動作;
舉栗子: 假設緩沖池的LRU長度為10,緩沖池的頁号如下:
緩沖池 lru算法例子1
接下來要通路的資料在頁号為4的頁中:
緩沖池 lru算法例子-頁号在緩沖池中
由于 頁号為4的頁在緩沖池中,直接把頁号為4的頁放到LRU的頭部即可,此時沒有頁被淘汰。
LRU為了減少資料的移動,由于連結清單具有快速插入和修改的優點,LRU一般會采用連結清單來實作。
接着,需要通路的資料在頁号為50的頁中:
緩沖池 lru算法例子-頁号不在緩沖池中
由于頁号為50的頁原來不在緩沖池中,此時需要把頁号為50的頁放入到LRU頭部,同時淘汰頁号為7的頁。
傳統的LRU緩沖池算法十分簡單直接,但是存在兩個問題:
▌(1)空間局部性
作業系統級别的spatial locality(空間局部性)是指讀取一個資料,在它周圍記憶體位址存儲的資料也很有可能被讀取到,于是作業系統會幫你預讀一部分資料。
MySQL也是存在存在預讀機制的:
①、通過參數innodb_read_ahead_threshold控制,預設是56。這個參數表示如果順序通路了一個區裡的多個資料頁,這裡的多個就是56,就會觸發預讀機制,把下一個區中所有的資料頁都加載到緩存頁裡。
②、通過參數innodb_random_read_ahead控制,預設是off。這個參數表示如果緩存了一個區的13個連續資料頁,就會觸發預讀機制,把這個區裡的頁全都加載到緩存頁裡。
當你執行select * from xxx 時,如果表中的資料頁非常多,那這些資料頁就會一一将buffer pool中的經常使用的緩存頁擠下去,可能留在lru連結清單中的全部是你不經常使用的資料。
此時,預讀機制的優勢就違背了LRU實作最近最少使用的資料頁刷入磁盤的設計初衷了。
▌(2)全表掃描
如果是全表掃描,會把全表都加載到buffer pool中,有可能就把LRU連結清單中經常通路的都擠到後面去,就有可能被淘汰。
▌基于冷熱資料分離優化後的LRU連結清單
Buffer Pool沒有直接常用傳統的LRU算法,而是采用了基于冷熱資料分離的LRU連結清單,如下圖所示:
innodb-buffer-pool-list
新進入緩存池的頁并不會直接進入LRU連結清單的頭部,而是插入到距離連結清單尾3/8的位置(可以由innodb_old_blocks_pct參數進行配置),我們将距離連結清單尾3/8以上的位置稱為新子清單,以下的位置稱為舊子清單,資料在連結清單中自底而上稱為變年輕,反之稱為變老。
▌(1)變年輕
變年輕分為兩種情況
- 使用者的操作而需要讀取頁面,此時會直接使該頁直接移至新子清單連結清單頭部。
- 資料庫内部的預讀操作,則在距離插入innodb_old_blocks_time(預設為1000ms)的時間内,即使通路了該頁,該頁也不會别移到LRU連結清單的頭部。
也就是說,如果是來源于使用者的操作,則最起碼需要兩次操作才能變年輕。而如果是預讀操作,則需要加上一個等待期限。
▌(2)變老
随着連結清單資料的替換和通路,整個清單中的資料會自然的變老。
最終最老的頁面會從尾部逐出,清空這些緩存頁,并放入free連結清單,從LRU連結清單删除,從Flush連結清單删除。
如果 Buffer Pool 不夠使用了,那麼 MySQL就會将 LRU 連結清單中的尾節點刷入到磁盤中, Buffer Pool 騰出記憶體空間。
來個整體的流程圖如下:
buffer pool 整體流程
尼恩提示:以上内容比較複雜,而且非常重要。如何把以上内容搞清楚? 可以看本文的配套視訊《從0開始,老架構師帶你手寫DB》。
如果想把手寫DB寫入履歷,可以找老架構師尼恩提供履歷輔導,保證履歷金光閃閃、天衣無縫。
▌使用引用計數實作緩存池:
采用LRU 緩存淘汰政策隻需要設計一個get(key)接口即可,釋放緩存可以在緩存滿之後自動完成。
但是先進的LRU緩存淘汰政策會出現一個尴尬的場景:
在緩存池滿的時候,緩存池需要逐出一個緩存資料,這個時候,子產品A想要将某個資料強制刷回磁盤,但是這個資料又恰好是剛被逐出的資料,那麼子產品A發現這個資料在緩存中不見了,這是讓人尴尬的問題就出現了:是否有必要做回源操作?
(1)不回源。由于沒法确定緩存被逐出的時間,更沒法确定被驅逐之後資料是否被修改,這樣是極其不安全的。
(2)回源。如果資料被逐出時的資料和現在又是相同的,那就是一次無效回源。
(3)放回緩存裡,等下次被逐出時回源。看起來解決了問題,但是此時緩存已經滿了,這意味着還需要逐出一個緩存資料才能放進去。
這有可能會導緻緩存抖動問題。
出現這個問題的根源是因為LRU 政策中,緩存資料被逐出不可控,調用子產品無法感覺。
而引用計數政策正好解決了這個問題: 隻有調用子產品主動釋放引用,緩存在確定沒有其他子產品在使用這個資料了,才會去逐出緩存資料。
引用計數法實作的緩存池中實作一個方法 release(key),用于在調用子產品不使用某個資料頁時,釋放對資料的引用。當引用歸零時,緩存池就會逐出這個緩存資料。
同樣,在緩存池滿了之後,引用計數法無法自動釋放緩存,此時就直接報錯。
AbstractCache<T> 是一個抽象類,内部有兩個抽象方法,留給實作類去實作:
/**
* 當資源不在緩存時的擷取行為
*/
protected abstract T getForCache(long key) throws Exception;
/**
* 當資源被逐出時的寫回行為
*/
protected abstract void releaseForCache(T obj);
引用計數除了緩存功能以外,還維護一個計數;為了應對多線程場景,還需要記錄哪些資料頁是從資料源擷取的。
是以定義了三個HashMap類型的變量,如下所示:
// 實際緩存的資料
private HashMap<Long, T> cache;
// 元素的引用個數
private HashMap<Long, Integer> references;
// 正在擷取某資源的線程
private HashMap<Long, Boolean> getting;
通過get()方法擷取資料頁的步驟如下:
step1:通過一個while 死循環無限嘗試才從緩存中擷取;
step2:檢查此時是否有其他線程正從資料源擷取這個資料資源,如果有,即就讓線程等待;
step3:如果資料在緩沖池中,直接擷取并傳回,此時資料頁的引用數需要加1.
step4:如果緩存池中沒有資料所在的資料頁,就從資料源中把資料所在的資料頁加載到緩存池中,并傳回,此時,資料頁的引用數也需要加1;
get()方法的具體實作如下:
protected T get(long key) throws Exception {
//在通過 get() 方法擷取資源時,首先進入一個死循環,來無限嘗試從緩存裡擷取。
// 首先就需要檢查這個時候是否有其他線程正在從資料源擷取這個資源,如果有,就過會再來看看
while(true) {
lock.lock();
if(getting.containsKey(key)) {
// 請求的資源正在被其他線程擷取
lock.unlock();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
continue;
}
continue;
}
//如果資源在緩存中,就可以直接擷取并傳回了,記得要給資源的引用數 +1。
// 否則,如果緩存沒滿的話,就在 getting 中注冊一下,該線程準備從資料源擷取資源了。
if(cache.containsKey(key)) {
// 資源在緩存中,直接傳回
T obj = cache.get(key);
references.put(key, references.get(key) + 1);
lock.unlock();
return obj;
}
// 嘗試擷取該資源
if(maxResource > 0 && count == maxResource) {
lock.unlock();
throw Error.CacheFullException;
}
// 如果資源不在緩存中,且緩沖沒滿,就在 getting 中注冊一下,該線程準備從資料源擷取資源了
count ++;
getting.put(key, true);
lock.unlock();
break;
}
//從資料源擷取資源就比較簡單了,直接調用那個抽象方法即可,
// 擷取完成記得從 getting 中删除 key。
T obj = null;
try {
//從資料庫中擷取資源
obj = getForCache(key);
} catch(Exception e) {
lock.lock();
count --;
getting.remove(key);
lock.unlock();
throw e;
}
//成功從資料庫擷取資源後,移除getting,把資源放入緩存,引用計數器加1
lock.lock();
getting.remove(key);
cache.put(key, obj);
references.put(key, 1);
lock.unlock();
return obj;
}
釋放緩存是直接從references中減1,如果已經減到0了,就可以逐出,并且删除緩存中所有相關的結構,釋放緩存的實作如下:
/**
* 強行釋放一個緩存
*/
protected void release(long key) {
lock.lock();
try {
int ref = references.get(key)-1;
if(ref == 0) {
T obj = cache.get(key);
releaseForCache(obj);
references.remove(key);
cache.remove(key);
count --;
} else {
references.put(key, ref);
}
} finally {
lock.unlock();
}
}
緩存池還有一個安全關閉的功能,在關閉時,需要将緩存中所有的資料頁全部強制逐出,具體實作如下:
/**
* 關閉緩存,寫回所有資源
*/
protected void close() {
lock.lock();
try {
Set<Long> keys = cache.keySet();
for (long key : keys) {
T obj = cache.get(key);
releaseForCache(obj);
references.remove(key);
cache.remove(key);
}
} finally {
lock.unlock();
}
}
▌使用緩存池緩存資料頁:
有了緩存池後,接下來我們就是需要把資料頁緩存到緩存池中。
首先,我們需要定義好頁面的結構,如下:
public class PageImpl implements Page {
//pageNumber 是這個頁面的頁号,該頁号從 1 開始
private int pageNumber;
//data 就是這一頁實際包含的位元組資料
private byte[] data;
//dirty 标志着這個頁面是否是髒頁面,在緩存驅逐的時候
private boolean dirty;
private Lock lock;
//PageCache的引用,用來友善在拿到 Page 的引用時
private PageCache pc;
}
定義好結構後,我們需要定義資料頁緩存的接口,如下:
public interface PageCache {
int newPage(byte[] initData);
Page getPage(int pgno) throws Exception;
void close();
void release(Page page);
void truncateByBgno(int maxPgno);
int getPageNumber();
}
資料頁緩沖緩存的具體實作類繼承了抽象緩存池類AbstractCache,并實作了getForCache() 和 releaseForCache() 兩個抽象方法。如下圖所示:
由于此處的資料源是檔案系統,getForCache()直接從檔案中讀取,并組裝成page, 要求同一條資料是不允許跨頁存儲的,即單條資料的大小不能超過資料庫頁面的大小。
具體實作如下:
@Override
protected Page getForCache(long key) throws Exception {
int pgno = (int)key;
long offset = PageCacheImpl.pageOffset(pgno);
ByteBuffer buf = ByteBuffer.allocate(PAGE_SIZE);
fileLock.lock();
try {
fc.position(offset);
fc.read(buf);
} catch(IOException e) {
Panic.panic(e);
}
fileLock.unlock();
return new PageImpl(pgno, buf.array(), this);
}
// 頁号從1開始
private static long pageOffset(int pgno) {
return (pgno-1) * PAGE_SIZE;
}
而 releaseForCache() 逐出頁面時,也隻需要根據頁面是否是髒頁面,來決定是否需要寫回檔案系統:
@Override
protected void releaseForCache(Page pg) {
if(pg.isDirty()) {
flush(pg);
pg.setDirty(false);
}
}
private void flush(Page pg) {
int pgno = pg.getPageNumber();
//該頁初始位置的偏移量
long offset = pageOffset(pgno);
fileLock.lock();
try {
ByteBuffer buf = ByteBuffer.wrap(pg.getData());
fc.position(offset);
fc.write(buf);
fc.force(false);
} catch(IOException e) {
Panic.panic(e);
} finally {
fileLock.unlock();
}
}
在建立資料頁的時候,使用了AtomicInteger的自增來記錄了目前打開的資料庫檔案有多少頁。這個數字在資料庫檔案被打開時就會被計算,并在建立頁面時自增。
public int newPage(byte[] initData) {
int pgno = pageNumbers.incrementAndGet();
Page pg = new PageImpl(pgno, initData, null);
// 建立的頁面需要立刻寫回
flush(pg);
return pgno;
}
尼恩提示:以上内容比較複雜,而且非常重要。如何把以上内容搞清楚? 可以看本文的配套視訊《從0開始,老架構師帶你手寫DB》。
如果想把手寫DB寫入履歷,可以找老架構師尼恩提供履歷輔導,保證履歷金光閃閃、天衣無縫。
▌資料頁管理:
資料頁分為特殊頁PageOne和普通頁PageX。
- 特殊頁是資料庫檔案的第一頁,用來啟動檢查。
- 第一頁之外的資料頁都屬于普通頁。
資料頁預設大小為8K。
PageOne和PageX針對資料記憶體的第一頁和普通頁不同的資料頁管理管理方式。
▌用于啟動校驗的特殊頁
首先,我們來看下特殊頁。
特殊頁啟動檢查的邏輯是在每次資料庫啟動時,會生成一串随機位元組,存儲在100~107位元組,在資料庫正常關閉時,會将這串位元組,拷貝到第一頁的 108 ~ 115 位元組。
在啟動啟動時,設定初始位元組如下:
//啟動時設定初始位元組
public static void setVcOpen(Page pg) {
//對頁面有修改時就設定為髒資料,然後flush到磁盤
pg.setDirty(true);
setVcOpen(pg.getData());
}
// 資料庫啟動時,在100-108設定随機位元組
private static void setVcOpen(byte[] raw) {
System.arraycopy(RandomUtil.randomBytes(LEN_VC), 0, raw, OF_VC, LEN_VC);
}
資料庫關閉時拷貝位元組:
public static void setVcClose(Page pg) {
pg.setDirty(true);
setVcClose(pg.getData());
}
private static void setVcClose(byte[] raw) {
System.arraycopy(raw, OF_VC, raw, OF_VC+LEN_VC, LEN_VC);
}
資料庫在每次啟動時,就會檢查第一頁兩處的位元組是否相同,以此來判斷上一次是否正常關閉。如果是異常關閉,就需要執行資料的恢複流程。
校驗位元組如下:
public static boolean checkVc(Page pg) {
return checkVc(pg.getData());
}
private static boolean checkVc(byte[] raw) {
return Arrays.equals(Arrays.copyOfRange(raw, OF_VC, OF_VC+LEN_VC), Arrays.copyOfRange(raw, OF_VC+LEN_VC, OF_VC+2*LEN_VC));
}
▌用于存儲資料的普通頁
一個普通頁面以一個 2 位元組無符号數起始,表示這一頁的空閑位置的偏移。
剩下的部分的作用是存儲的資料。
是以,對于普通頁的管理都是圍繞空閑位置偏移進行的。
在寫入資料之前,先擷取空閑位置的偏移确定要寫入的位置,擷取空閑位置的偏移如下
// 擷取pg的FSO
public static short getFSO(Page pg) {
return getFSO(pg.getData());
}
private static short getFSO(byte[] raw) {
return Parser.parseShort(Arrays.copyOfRange(raw, 0, 2));
}
擷取空閑位置的偏移後,就可以向頁面插入資料,具體插入如下:
// 将raw插入pg中,傳回插入位置
public static short insert(Page pg, byte[] raw) {
pg.setDirty(true);
short offset = getFSO(pg.getData());
System.arraycopy(raw, 0, pg.getData(), offset, raw.length);
setFSO(pg.getData(), (short)(offset + raw.length));
return offset;
}
寫入完成後,還需要更新空閑位置的偏移,具體如下:
//raw要寫入的位元組數組,ofData偏移量,把偏移量寫入到raw位元組數組的前兩個位元組
private static void setFSO(byte[] raw, short ofData) {
System.arraycopy(Parser.short2Byte(ofData), 0, raw, OF_FREE, OF_DATA);
}
▌頁面空閑空間管理
資料頁的空閑空間采用頁面索引機制來計算記錄。
友善在上層子產品進行資料記憶體插入操作時,能夠快速定位到一個有合适空間的頁面,而無需從磁盤或者緩存中挨個檢查每一個頁面的空間資訊。
具體方法是将一頁的空間劃分成了 40 個區間。
在服務端啟動時,周遊所有的頁面資訊,擷取頁面的空閑空間,安排到這 40 個區間中。
insert 在請求一個頁時,會首先将所需的空間向上取整,映射到某一個區間,随後取出這個區間的任何一頁,都可以滿足需求。
具體如下:
public class PageIndex {
// 将一頁劃成40個區間
private static final int INTERVALS_NO = 40;
private static final int THRESHOLD = PageCache.PAGE_SIZE / INTERVALS_NO;
// 所有頁号和空閑空間大小
private List<PageInfo>[] lists;
}
請求頁面如下:
public PageInfo select(int spaceSize) {
int number = spaceSize / THRESHOLD;
if(number < INTERVALS_NO) number ++;
while(number <= INTERVALS_NO) {
if(lists[number].size() == 0) {
number ++;
continue;
}
return lists[number].remove(0);
}
return null;
}
傳回的 PageInfo 中包含頁号和空閑空間大小的資訊。
被選擇的頁,會直接從 PageIndex 中移除,這意味着,同一個頁面是不允許并發寫的。
在上層子產品使用完這個頁面後,需要将其重新插入PageIndex:
public void add(int pgno, int freeSpace) {
int number = freeSpace / THRESHOLD;
lists[number].add(new PageInfo(pgno, freeSpace));
}
啟動時,會擷取所有頁面并填充 PageIndex如下:
// 初始化pageIndex
void fillPageIndex() {
int pageNumber = pc.getPageNumber();
for(int i = 2; i <= pageNumber; i ++) {
Page pg = null;
try {
pg = pc.getPage(i);
} catch (Exception e) {
Panic.panic(e);
}
pIndex.add(pg.getPageNumber(), PageX.getFreeSpace(pg));
pg.release();
}
}
▌資料日志和恢複政策:
▌MySQL的日志
在開始介紹MySQL的日志之前,我們先來了解一下MySQL資料更新流程作為鋪墊,如下圖所示:
MySQL資料更新流程
上圖是MySQL更新資料的基礎流程,其中包括redo log、bin log、undo log三種日志之間的大緻關系。
日志是資料庫的重要組成部分,主要用來記錄資料庫的運作情況、日常操作和錯誤資訊。
若資料庫發生故障,可通過不同日志記錄恢複資料庫的原來資料。
是以實際上日志系統直接決定着MySQL運作的魯棒性和穩健性。
分析這些日志,可以幫助我們了解 MySQL 資料庫的運作情況、日常操作、錯誤資訊和哪些地方需要進行優化。
MySQL的日志有7種,接下來我們挨個來介紹一下。
▌redo log(重做日志)
redo log屬于MySQL存儲引擎InnoDB的事務日志,用來記錄事務操作引起資料的變化,記錄的是資料頁的實體修改。。
重做日志就好比咱們的代碼,萬一哪天代碼不小心delete了該如何處理呢?
以防這種不幸的發生,我們在寫代碼的時候,每次修改都使用git送出代碼,并備注好修改了什麼内容。這個就是重做日志。
▌髒資料刷盤
首先明确一下,髒資料是指未刷到磁盤上的資料。
redo log日志的大小是固定的,為了能夠持續不斷的對更新記錄進行寫入,在redo log日志中設定了兩個标志位置,checkpoint和write_pos,分别表示記錄擦除的位置和記錄寫入的位置。
redo log日志的寫入示意圖如下:
redo log 日志的資料寫入
當write_pos标志到了日志結尾時,會從結尾跳至日志頭部進行重新循環寫入。是以redo log的邏輯結構并不是線性的,而是可看作一個圓周運動。write_pos與checkpoint中間的空間可用于寫入新資料,寫入和擦除都是往後推移,循環往複的。
redo log 循環寫入
當write_pos追上checkpoint時,表示redo log日志已經寫滿。這時不能繼續執行新的資料庫更新語句,需要停下來先删除一些記錄,執行checkpoint規則騰出可寫空間。
checkpoint 規則: checkpoint 觸發後,将buffer中髒資料頁和髒日志頁都刷到磁盤。
MySQL的資料是存放在磁盤中的,每次讀寫資料都需做磁盤IO操作,如果并發場景下性能就會很差。為此MySQL引入緩存Buffer Pool作為優化手段。Buffer Pool緩存池中包含了磁盤中部分資料頁(page)的映射,以此來緩解資料庫的磁盤壓力。
當從資料庫讀資料時,首先從緩存中讀取,如果緩存中沒有,則從磁盤讀取後放入緩存;當向資料庫寫入資料時,先向緩存寫入,此時緩存中的資料頁資料變更,這個資料頁稱為髒頁,Buffer Pool中修改完資料後會按照設定的更新政策,定期刷到磁盤中,這個過程稱為刷髒頁。
如果刷髒頁還未完成,可MySQL由于某些原因當機重新開機,此時Buffer Pool中修改的資料還沒有及時的刷到磁盤中,就會導緻資料丢失,無法保證事務的持久性。
為了解決MySQL 當機資料丢失的問題,引入了redo log ,redo log 記錄的是資料庫中每頁的修改,可以用來恢複送出後的實體資料頁,但是隻能恢複到最後一次送出的位置。
redo log 采用的是WAL(Write-Ahead logging)技術,WAL技術的核心在于修改記錄前,一定要先寫日志,并保證日志先刷盤,才能算事務送出完成。
MySQL 引入redo log後,修改資料時,InnoDB引擎會把更新記錄先寫在redo log中,在修改Buffer Pool中的資料,當送出事務時,調用fsync把redo log刷入磁盤。至于緩存中更新的資料檔案何時刷入磁盤,則由背景線程異步處理。
此時redo log的事務狀态是prepare,還未真正送出成功,要等bin log日志寫入磁盤完成才會變更為commit,事務才算真正送出完成。
由此一來,即使刷髒頁之前MySQL意外當機了也沒問題,隻要再重新開機時解析redo log 中更改記錄進行重放,重新刷盤即可。
需要注意的是,redo log日志滿了,在擦除之前,需要確定這些要被擦除記錄對應在記憶體中的資料頁都已經刷到磁盤中了。擦除舊記錄騰出新空間這段期間,是不能再接收新的更新請求的,此刻MySQL的性能會下降。是以在并發量大的情況下,合理調整redo log的檔案大小非常重要。
▌crash-safe
因為redo log的存在使得Innodb引擎具有了crash-safe的能力,即MySQL當機重新開機,系統會自動去檢查redo log,将修改還未寫入磁盤的資料從redo log恢複到MySQL中。
MySQL啟動時,不管上次是正常關閉還是異常關閉,總是會進行恢複操作。會先檢查資料頁中的LSN,如果這個 LSN 小于 redo log 中的LSN,即write pos位置,說明在redo log上記錄着資料頁上尚未完成的操作,接着就會從最近的一個check point出發,開始同步資料。
簡單了解,比如:redo log的LSN是500,資料頁的LSN是300,表明重新開機前有部分資料未完全刷入到磁盤中,那麼系統則将redo log中LSN序号300到500的記錄進行重放刷盤。
尼恩提示:以上内容比較複雜,而且非常重要。如何把以上内容搞清楚? 可以看本文的配套視訊《從0開始,老架構師帶你手寫DB》。
如果想把手寫DB寫入履歷,可以找老架構師尼恩提供履歷輔導,保證履歷金光閃閃、天衣無縫。
▌髒日志刷盤
除了對髒資料進行刷盤,實際上redo log日志在記錄時,為了保證日志檔案的持久化,也需要經曆将日志記錄從記憶體寫入到磁盤的過程。redo log日志可分為兩個部分,一是存在易失性記憶體中的緩存日志redo log buff,二是儲存在磁盤上的redo log日志檔案redo log file。
為了確定每次記錄都能夠寫入到磁盤中的日志中,每次将redo log buffer中的日志寫入redo log file的過程中都會調用一次作業系統的fsync操作。
fsync函數是屬于系統核心函數;fsync 函數把 fd 對應的檔案資料以及檔案屬性資訊(inode等資訊)寫入到磁盤,并且等待寫磁盤操作完成後而傳回。應用程式一般都是通過調用該函數確定修改過的資料能夠立即寫入到磁盤上,比如 redis等。
fsync函數包含在UNIX系統頭檔案#include <unistd.h>中,其man文檔如下:
fsync說明
在寫入的過程中,還需要經過作業系統核心空間的os buffer。redo log日志的寫入過程如下圖:
髒日志刷盤
▌undo log(復原日志)
undo log 和redo log一樣,也是屬于MySQL存儲引擎InnoDB的事務日志。
undo log 屬于邏輯日志,主要是起到復原的作用,它保證事務原子性的關鍵。它對SQL語句執行相關的資訊進行記錄。當發生復原時,InnoDB引擎會根據undo log日志中的記錄做與之前相反的工作。
- 比如對于每個資料插入操作(insert),復原時會執行資料删除操作(delete);
- 對于每個資料删除操作(delete),復原時會執行資料插入操作(insert);
- 對于每個資料更新操作(update),復原時會執行一個相反的資料更新操作(update),把資料改回去。
undo log由兩個作用,一是提供復原,二是實作MVCC。
例如:執行一個update語句:update t_user set name="Niki" where id=1;
在執行這個update語句之前,會在undo log中記錄一條相反邏輯的update t_user set name="Linda" where id=1; 記錄,這樣做的原因是當某些原因導緻服務異常事務失敗,就可以借助undo log将資料復原到事務執行前的狀态,保證事務的完整性。其執行流程如下:
undo 執行流程
每當我們要對一條記錄做改動時(這裡的改動可以指INSERT、DELETE、UPDATE),都需要"留一手"把復原時所需的東西記下來。undo log 日志的記錄細節如下:
(1)插入一條記錄時,至少要把這條記錄的主鍵值記下來,之後復原的時候隻需要把這個主鍵值對應的記錄删掉就好了。(對于每個INSERT,InnoDB存儲引擎會完成一個DELETE);
(2)删除了一條記錄,至少要把這條記錄中的内容都記下來,在復原時再把由這些内容組成的記錄插入到表中就好了。(對于每個DELETE,InnoDB存儲引擎會執行一個INSERT)
(3)修改了一條記錄,至少要把修改這條記錄前的舊值都記錄下來,在復原時再把這條記錄更新為舊值就好了。(對于每個UPDATE,InnoDB存儲引擎會執行一個相反的UPDATE,将修改前的行放回去)
尼恩提示:以上内容比較複雜,而且非常重要。如何把以上内容搞清楚? 可以看本文的配套視訊《從0開始,老架構師帶你手寫DB》。
如果想把手寫DB寫入履歷,可以找老架構師尼恩提供履歷輔導,保證履歷金光閃閃、天衣無縫。
▌undo log的作用
undo log 復原日志具有2個作用:
(1)用于資料的復原;如資料執行時候發生錯誤,作業系統的錯誤,斷點等,程式員手動rollback等操作場景都會用到資料的復原。
(2)實作MVCC;即在InnoDB存儲引擎中MVCC的實作是通過undo來完成。當使用者讀取一行記錄時,若該記錄已經被其他事務占用,目前事務可以通過undo讀取之前的行版本資訊,以此實作非鎖定讀取。
注意:同一個事物内的一條記錄被多次修改,不會每次都要把資料修改前的狀态都寫入undo log日志中。
undo log隻負責記錄事務開始前要修改資料的原始版本,當我們再次對這行資料進行修改,所産生的修改記錄會寫入到redo log,undo log負責完成復原,redo log負責完成前滾。
復原是指未送出的事務,即事務未執行commit。但該事務内修改的髒頁中,可能有一部分髒塊已經刷盤。如果此時資料庫執行個體當機重新開機,就需要用復原來将先前那部分已經刷盤的髒塊從磁盤上撤銷。
前滾是指未完全送出的事務,即事務已經執行commit,但該事務内修改的髒頁中隻有一部分資料被刷盤,另外一部分還在buffer pool緩存上,如果此時資料庫執行個體當機重新開機,就需要用前滾來完成未完全送出的事務。将先前那部分由于當機在記憶體上的未來得及刷盤資料,從redo log中恢複出來并刷入磁盤。
資料庫執行個體恢複時,先做前滾,後做復原。
▌bin log(歸檔日志)
bin log是一種資料庫Server層,以二進制形式存儲在磁盤中的邏輯日志。binlog主要記錄資料庫的變化情況,内容包括資料庫所有的更新操作。所有涉及資料變動的操作,都要記錄進二進制日志中。是以有了binlog可以很友善的對資料進行複制和備份,因而也常用作主從庫的同步。
預設情況下,二進制日志功能是關閉的。可以通過以下指令檢視二進制日志是否開啟:
MySQL> show variables like 'log_bin';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| log_bin | ON |
+---------------+-------+
1 row in set (0.00 sec)
bin log也被叫做歸檔日志,屬于邏輯日志,因為它不會像redo log那樣循環寫擦除之前的記錄,而是會一直記錄日志。一個bin log日志檔案預設最大容量1G(也可以通過max_binlog_size參數修改),單個日志超過最大值,則會新建立一個檔案繼續寫。
show binary logs;
+---------------+------------+-----------+
| Log_name | File_size | Encrypted |
+---------------+------------+-----------+
| binlog.000011 | 1080360853 | No |
| binlog.000012 | 942374569 | No |
+---------------+------------+-----------+
bin log日志的内容格式其實就是執行SQL指令的反向邏輯,這點和undo log有點類似。一般來說開啟bin log都會給日志檔案設定過期時間(expire_logs_days參數,預設永久儲存),要不然日志的體量會非常龐大。
MySQL> show variables like 'expire_logs_days';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| expire_logs_days | 0 |
+------------------+-------+
1 row in set (0.01 sec)
MySQL> SET GLOBAL binlog_expire_logs_seconds =2592000;
Query OK, 0 rows affected (0.00 sec)
bin log主要應用于MySQL主從模式(master-slave)中,主從節點間的資料同步;以及基于時間點的資料還原。
▌主從同步
我們用一張圖來看下MySQL的主從同步過程,如下圖所示:
MySQL 主從同步原理圖
MySQL主從同步的流程如下:
(1)使用者在主庫master 執行DDL 和DML 操作,修改記錄順序寫入bin log;
(2)從庫slave 的I/O線程連接配接上Master,并請求讀取指定位置position的日志内容;
(3)Master收到從庫slave請求後,将指定位置position之後的日志内容,和主庫bin log 檔案的名稱以及在日志中的位置推送給從庫;
(4)slave 的I/O線程接收到資料後,将接收到的日志内容依次寫入到relay log檔案最末端,并将讀取到的主庫bin log 檔案名和位置position記錄到master-info檔案中,以便在下一次讀取用。
(5)slave的SQL線程檢測到relay log職工内容更新後,讀取日志并解析成可執行的SQL語句。
通過以上步驟,就可以實作主從庫的資料一緻。
▌bin log 和redo log 的差別
從上述的分析我們知道bin log 和redo log 都可以做資料的恢複,那他們之間的差別有哪些:
(1)層次不同:redo log 是InnoDB存儲引擎實作的,bin log 是MySQL的伺服器層實作的,但MySQL資料庫中的任何存儲引擎對于資料庫的更改都會産生bin log。
(2)作用不同:redo log 用于碰撞恢複(crash recovery),保證MySQL當機也不會影響持久性;bin log 用于時間點恢複(point-in-time recovery),保證伺服器可以基于時間點恢複資料和主從複制。
(3)内容不同:redo log 是實體日志,内容基于磁盤的頁Page;bin log的内容是二進制,可以根據binlog_format參數自行設定。
(4)寫入方式不同:redo log 采用循環寫的方式記錄;binlog 通過追加的方式記錄,當檔案大小大于給定值後,後續的日志會記錄到新的檔案上。
(5)刷盤時機不同:bin log在事務送出時寫入;redo log 在事務開始時即開始寫入。
bin log 和redo log 功能并不沖突而是起到相輔相成的作用,需要二者同時記錄,才能保證資料庫發生當機重新開機時,資料不會丢失。
在MySQL執行更新語句時,都會涉及到redo log日志和binlog日志的讀寫。一條更新語句的執行過程如下:
update 執行過程
MySQL在執行更新語句的時候,在服務層進行語句的解析和執行,在引擎層進行資料的提取和存儲;同時在服務層對binlog進行寫入,在InnoDB内進行redo log的寫入。
而且在對redo log寫入時有兩個階段的送出,一是binlog寫入之前prepare狀态的寫入,二是binlog寫入之後commit狀态的寫入,使得binlog和redo log中儲存的資訊是一緻的。
▌relay log(中繼日志)
relay log日志檔案具有與bin log日志檔案相同的格式,從上邊MySQL主從同步的流程可以看出,relay log起到一個中轉的作用,slave先從主庫master讀取二進制日志資料,寫入從庫本地,後續再異步由SQL線程讀取解析relay log為對應的SQL指令執行。
▌slow query log(慢查詢日志)
慢查詢日志(slow query log)在 SQL 優化過程中會經常使用到。通過慢查詢日志,我們可以查找出哪些查詢語句的執行效率低,耗時嚴重。
MySQL 的慢查詢日志,用來記錄在 MySQL 中響應時間超過閥值的語句,具體指運作時間超過 long_query_time 值的 SQL,則會被記錄到慢查詢日志中。long_query_time 的預設值為 10,意思是運作 10 秒以上 (不含 10 秒) 的語句,認為是超出了我們的最大忍耐時間值。
它的主要作用是,幫助我們發現那些執行時間特别長的 SQL 查詢,并且有針對性地進行優化,進而提高系統的整體效率。當我們的資料庫伺服器發生阻塞、運作變慢的時候,檢查一下慢查詢日志,找到那些慢查詢,對解決問題很有幫助。比如一條 sql 執行超過 5 秒鐘,我們就算慢 SQL,希望能收集超過 5 秒的 sql,結合 explain 進行全面分析。
預設情況下,MySQL 資料庫沒有開啟慢查詢日志,需要我們手動來設定這個參數。
如果不是調優需要的話,一般不建議啟動該參數,因為開啟慢查詢日志會或多或少帶來一定的性能影響。
出于性能方面的考慮,一般隻有在排查慢SQL、調試參數時才會開啟,預設情況下,慢查詢日志功能是關閉的。可以通過以下指令檢視是否開啟慢查詢日志:
MySQL> show variables like 'slow_query%';
+---------------------+--------------------------------------+
| Variable_name | Value |
+---------------------+--------------------------------------+
| slow_query_log | OFF |
| slow_query_log_file | /var/lib/MySQL/c63bb810aea0-slow.log |
+---------------------+--------------------------------------+
2 rows in set (0.00 sec)
通過如下指令開啟慢查詢日志。
MySQL> set global slow_query_log=on;
Query OK, 0 rows affected (0.19 sec)
超過 指定時間 的查詢語句才算是慢查詢,那麼這個時間門檻值又是多少嘞?我們通過 long_query_time 參數來檢視一下,發現預設是 10 秒。
MySQL> show variables like 'long_query_time';
+-----------------+-----------+
| Variable_name | Value |
+-----------------+-----------+
| long_query_time | 10.000000 |
+-----------------+-----------+
1 row in set (0.00 sec)
尼恩提示:以上内容比較複雜,而且非常重要。如何把以上内容搞清楚? 可以看本文的配套視訊《從0開始,老架構師帶你手寫DB》。
如果想把手寫DB寫入履歷,可以找老架構師尼恩提供履歷輔導,保證履歷金光閃閃、天衣無縫。
▌general query log(一般查詢日志)
一般查詢日志(general query log)主要用來記錄使用者的所有操作,包括用戶端何時連接配接了伺服器、用戶端發送的所有SQL以及其他事件,比如 MySQL 服務啟動和關閉等等。MySQL伺服器會按照它接收到語句的先後順序寫入日志檔案。
由于一般查詢日志記錄的内容過于詳細,開啟後 Log 檔案的體量會非常龐大,是以出于對性能的考慮,預設情況下,該日志功能是關閉的,通常會在排查故障需獲得詳細日志的時候才會臨時開啟。
我們可以通過以下指令檢視一般查詢日志是否開啟,指令如下:
MySQL> show variables like 'general_log';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| general_log | OFF |
+---------------+-------+
1 row in set (0.00 sec)
下邊開啟一般查詢日志并檢視日志存放的位置,
MySQL> SET GLOBAL general_log=on;
Query OK, 0 rows affected (0.01 sec)
MySQL> show variables like 'general_log_file';
+------------------+---------------------------------+
| Variable_name | Value |
+------------------+---------------------------------+
| general_log_file | /var/lib/MySQL/c63bb810aea0.log |
+------------------+---------------------------------+
1 row in set (0.00 sec)
執行一條查詢 SQL 看看日志内容的變化。
select * from MySQL_study.t_student where id =1 ;
打開一般查詢日志/var/lib/MySQL/c63bb810aea0.log。結果如下:
▌error log(錯誤日志)
錯誤日志(Error log)是MySQL 中最常用的一種日志,主要記錄MySQL伺服器啟動和停止過程中的資訊、伺服器在運作過程中發生的故障和異常情況等。
預設情況下,錯誤日志功能是開啟的。可以通過以下指令檢視:
MySQL> SHOW VARIABLES LIKE 'log_error';
+---------------+--------+
| Variable_name | Value |
+---------------+--------+
| log_error | stderr |
+---------------+--------+
1 row in set (0.00 sec)
一般情況下,錯誤日志存儲在 MySQL 資料庫的資料檔案夾下,通常名稱為 hostname.err。其中,hostname 表示的是MySQL 伺服器的主機名。
在 MySQL 配置檔案中,錯誤日志所記錄的資訊可以通過 log-error 和 log-warnings 來定義,其中,log-err 定義是否啟用錯誤日志功能和錯誤日志的存儲位置,log-warnings 定義是否将警告資訊也記錄到錯誤日志中。
▌采用二進制檔案實作redo log:
MYDB資料庫提供了崩潰後的資料恢複功能,在每次對底層資料操作時,都會記錄一條日志到磁盤上。
在資料庫崩潰之後,再次啟動時,可以根據日志的内容,恢複資料檔案,保持其一緻性。
日志的二進制檔案,按照如下的格式進行排布:
[XChecksum][Log1][Log2][Log3]...[LogN][BadTail]
其中 XChecksum 是一個四位元組的整數,是對後續所有日志計算的校驗和。Log1 ~ LogN 是正常的日志資料,BadTail 是在資料庫崩潰時,沒有來得及寫完的日志資料,這個 BadTail 不一定存在。
每條日志的格式如下:
[Size][Checksum][Data]
其中,Size 是一個四位元組整數,辨別了 Data 段的位元組數。Checksum 則是該條日志的校驗和。
單條日志的校驗和是通過一個指定的種子實作的:
private int calChecksum(int xCheck, byte[] log) {
for (byte b : log) {
xCheck = xCheck * SEED + b;
}
return xCheck;
}
這樣,對所有日志求出校驗和,求和就能得到日志檔案的校驗和了。
Logger 被實作成疊代器模式,通過 next() 方法,不斷地從檔案中讀取下一條日志,并将其中的 Data 解析出來并傳回。next() 方法的實作主要依靠 internNext(),大緻如下,其中 position 是目前日志檔案讀到的位置偏移:
/**
* 采用疊代器模式讀取日
* 通過 next() 方法,不斷地從檔案中讀取下一條日志,并将日志檔案的 Data 解析出來并傳回。
* 其中 position 是目前日志檔案讀到的位置偏移:
* @return
*/
private byte[] internNext() {
if(position + OF_DATA >= fileSize) {
return null;
}
// 讀取單條日志的size(data段的位元組數)
ByteBuffer tmp = ByteBuffer.allocate(4);
try {
fc.position(position);
fc.read(tmp);
} catch(IOException e) {
Panic.panic(e);
}
int size = Parser.parseInt(tmp.array());
if(position + size + OF_DATA > fileSize) {
return null;
}
// 讀取checksum+data
ByteBuffer buf = ByteBuffer.allocate(OF_DATA + size);
try {
fc.position(position);
fc.read(buf);
} catch(IOException e) {
Panic.panic(e);
}
//單條日志的checksum+data
byte[] log = buf.array();
// 一條日志根據存放的資料算出來的校驗和
int checkSum1 = calChecksum(0, Arrays.copyOfRange(log, OF_DATA, log.length));
//一條日志的Checksum部分存的東西
int checkSum2 = Parser.parseInt(Arrays.copyOfRange(log, OF_CHECKSUM, OF_DATA));
//毀壞的日志檔案
if(checkSum1 != checkSum2) {
return null;
}
position += log.length;
return log;
}
@Override
public byte[] next() {
lock.lock();
try {
byte[] log = internNext();
if(log == null) return null;
return Arrays.copyOfRange(log, OF_DATA, log.length);
} finally {
lock.unlock();
}
}
在打開一個日志檔案時,需要首先校驗日志檔案的 XChecksum,并移除檔案尾部可能存在的 BadTail,由于 BadTail 該條日志尚未寫入完成,檔案的校驗和也就不會包含該日志的校驗和,去掉 BadTail 即可保證日志檔案的一緻性。
// 檢查并移除bad tail
private void checkAndRemoveTail() {
rewind();
int xCheck = 0;
while(true) {
//周遊日志
byte[] log = internNext();
if(log == null) break;
xCheck = calChecksum(xCheck, log);
}
//xCheck 多條日志總的校驗和
if(xCheck != xChecksum) {
Panic.panic(Error.BadLogFileException);
}
try {
truncate(position);
} catch (Exception e) {
Panic.panic(e);
}
try {
file.seek(position);
} catch (IOException e) {
Panic.panic(e);
}
rewind();
}
向日志檔案寫入日志時,也是首先将資料封裝成日志格式,寫入檔案後,再更新檔案的校驗和,更新校驗和時,會重新整理緩沖區,保證内容寫入磁盤。
@Override
public void log(byte[] data) {
byte[] log = wrapLog(data);
ByteBuffer buf = ByteBuffer.wrap(log);
lock.lock();
try {
fc.position(fc.size());
fc.write(buf);
} catch(IOException e) {
Panic.panic(e);
} finally {
lock.unlock();
}
updateXChecksum(log);
}
private byte[] wrapLog(byte[] data) {
byte[] checksum = Parser.int2Byte(calChecksum(0, data));
byte[] size = Parser.int2Byte(data.length);
return Bytes.concat(size, checksum, data);
}
private void updateXChecksum(byte[] log) {
this.xChecksum = calChecksum(this.xChecksum, log);
try {
fc.position(0);
fc.write(ByteBuffer.wrap(Parser.int2Byte(xChecksum)));
fc.force(false);
} catch(IOException e) {
Panic.panic(e);
}
}
▌恢複政策:
資料管理層的日志恢複政策是在進行插入新資料(I)和更新現有資料(U)操作之前,必須先進行對應的日志操作,在保證日志寫入磁盤後,才進行資料操作。
日志在資料操作之前,保證到達了磁盤,那麼即使該資料操作最後沒有來得及同步到磁盤,資料庫就發生了崩潰,後續也可以通過磁盤上的日志恢複該資料
對于兩種資料操作,資料管理層記錄的日志如下:
- (Ti, I, A, x),表示事務 Ti 在 A 位置插入了一條資料 x
- (Ti, U, A, oldx, newx),表示事務 Ti 将 A 位置的資料,從 oldx 更新成 newx
現在對第二點做個詳細說明。DM操作資料項時,先修改的是記憶體中的資料,至于什麼時候刷到磁盤,是需要政策的。
如果在修改資料項之前, 能夠保證對應日志已經到達磁盤。那在修改資料項後,即使暫時不重新整理到磁盤。也沒關系。
假如在該段時間内MYDB發生了崩潰, 那磁盤上的資料項則會有誤,因為正确的資料項内容還未被重新整理到磁盤上。 但是因為MYDB在修改資料項前就保證了日志已經到達磁盤, 那麼在下次啟動時,一定可以根據上的日志内容來進行恢複。
我們首先不考慮并發的情況,那麼在某一時刻,隻可能有一個事務在操作資料庫。日志會看起來像下面那樣:
(Ti, x, x), ..., (Ti, x, x), (Tj, x, x), ..., (Tj, x, x), (Tk, x, x), ..., (Tk, x, x)
▌單線程
由于單線程,Ti、Tj 和 Tk 的日志永遠不會相交。這種情況下利用日志恢複很簡單,假設日志中最後一個事務是 Ti:
(1)對 Ti 之前所有的事務的日志,進行重做(redo);
(2)接着檢查 Ti 的狀态(XID 檔案),如果 Ti 的狀态是已完成(包括 committed 和 aborted),就将 Ti 重做,否則進行撤銷(undo)。
接着,對事務 T 進行 redo操作如下:
(1)正序掃描事務 T 的所有日志;
(2)如果日志是插入操作 (Ti, I, A, x),就将 x 重新插入 A 位置;
(3)如果日志是更新操作 (Ti, U, A, oldx, newx),就将 A 位置的值設定為 newx;
undo 操作如下:
(1)倒序掃描事務 T 的所有日志
(2)如果日志是插入操作 (Ti, I, A, x),就将 A 位置的資料删除
(3)如果日志是更新操作 (Ti, U, A, oldx, newx),就将 A 位置的值設定為 oldx
注意,MYDB中其實沒有真正的删除操作,對于插入操作的 undo,隻是将其中的标志位設定為 invalid。
▌多線程
經過以上單線程的操作,能保證crazydb 在單線程下的資料恢複,但是還需考慮多線程下如何恢複資料;
在多線程下,我們需要考慮2中情況:
▌情況一:
T1 begin
T2 begin
T2 U(x)
T1 R(x)
...
T1 commit
MYDB break down
在系統崩潰時,T2 仍然是活躍狀态。那麼當資料庫重新啟動,執行恢複例程時,會撤銷 T2,它對資料庫的影響會被消除。但是由于 T1 讀取了 T2 更新的值,既然 T2 被撤銷,那麼 T1 也應當被撤銷。這種情況,就是級聯復原。但是,T1 已經 commit 了,所有 commit 的事務的影響,應當被持久化。這裡就造成了沖突。是以做了個規定1:正在進行的事務,不會讀取其他任何未送出的事務産生的資料。
▌情況二:假設 x 的初值是 0
T1 begin
T2 begin
T1 set x = x+1 // 産生的日志為(T1, U, A, 0, 1)
T2 set x = x+1 // 産生的日志為(T1, U, A, 1, 2)
T2 commit
MYDB break down
在系統崩潰時,T1 仍然是活躍狀态。那麼當資料庫重新啟動,執行恢複例程時,會對 T1 進行撤銷,對 T2 進行重做,但是,無論撤銷和重做的先後順序如何,x 最後的結果,要麼是 0,要麼是 2,這都是錯誤的。
出現這個錯誤的原因是因為日志太過簡單, 僅僅記錄了"前相"和"後相". 并單純的依靠"前相"undo, 依靠"後相"redo. 這種簡單的日志方式和恢複方式, 并不能涵蓋住所有資料庫操作形成的語義。
解決方法有兩種:
(1)增加日志種類
(2)限制資料庫操作
MYDB采用的是限制資料庫操作,并做了規定2:正在進行的事務,不會修改其他任何未送出的事務修改或産生的資料。
在 MYDB中,由于 版本管理層 的存在,傳遞到 資料管理 層,真正執行的操作序列,都可以保證規定 1 和規定 2。有了這兩條規定,并發情況下日志的恢複也就很簡單了:
(1)重做所有崩潰時已完成(committed 或 aborted)的事務
(2)撤銷所有崩潰時未完成(active)的事務
在恢複後,資料庫就會恢複到所有已完成事務結束,所有未完成事務尚未開始的狀态。
在日志恢複政策Recover類中,定了了兩種日志的格式:insert 和update。如下:
private static final byte LOG_TYPE_INSERT = 0;
private static final byte LOG_TYPE_UPDATE = 1;
其格式如下:
updateLog:
[LogType] [XID] [UID] [OldRaw] [NewRaw]
insertLog:
[LogType] [XID] [Pgno] [Offset] [Raw]
recover 執行個體主要完成2個任務:
- 重做所有已完成事務
- 撤銷所有未完成事務:
private static void redoTranscations(TransactionManager tm, Logger lg, PageCache pc) {
lg.rewind();
while(true) {
//傳回logger的資料部分
byte[] log = lg.next();
if(log == null) break;
if(isInsertLog(log)) {
InsertLogInfo li = parseInsertLog(log);
long xid = li.xid;
//事務不是處于正在被處理的階段
if(!tm.isActive(xid)) {
doInsertLog(pc, log, REDO);
}
} else {
UpdateLogInfo xi = parseUpdateLog(log);
long xid = xi.xid;
if(!tm.isActive(xid)) {
doUpdateLog(pc, log, REDO);
}
}
}
}
private static void undoTranscations(TransactionManager tm, Logger lg, PageCache pc) {
//事務xid号 list裡面裝日志
Map<Long, List<byte[]>> logCache = new HashMap<>();
lg.rewind();
while(true) {
//log的data部分
byte[] log = lg.next();
if(log == null) break;
if(isInsertLog(log)) {
InsertLogInfo li = parseInsertLog(log);
long xid = li.xid;
//log的data部分
if(tm.isActive(xid)) {
if(!logCache.containsKey(xid)) {
logCache.put(xid, new ArrayList<>());
}
logCache.get(xid).add(log);
}
} else {
UpdateLogInfo xi = parseUpdateLog(log);
long xid = xi.xid;
if(tm.isActive(xid)) {
if(!logCache.containsKey(xid)) {
logCache.put(xid, new ArrayList<>());
}
logCache.get(xid).add(log);
}
}
}
// 對所有active log進行倒序undo
for(Entry<Long, List<byte[]>> entry : logCache.entrySet()) {
List<byte[]> logs = entry.getValue();
for (int i = logs.size()-1; i >= 0; i --) {
byte[] log = logs.get(i);
if(isInsertLog(log)) {
doInsertLog(pc, log, UNDO);
} else {
doUpdateLog(pc, log, UNDO);
}
}
tm.rollback(entry.getKey());
}
}
updateLog 和insertLog的重做和撤銷處理,分别合并成一個方法來實作:
private static void doUpdateLog(PageCache pc, byte[] log, int flag) {
int pgno;
short offset;
byte[] raw;
if(flag == REDO) {
UpdateLogInfo xi = parseUpdateLog(log);
pgno = xi.pgno;
offset = xi.offset;
raw = xi.newRaw;
} else {
UpdateLogInfo xi = parseUpdateLog(log);
pgno = xi.pgno;
offset = xi.offset;
raw = xi.oldRaw;
}
Page pg = null;
//從頁号在資料庫中得到頁的對象
try {
pg = pc.getPage(pgno);
} catch (Exception e) {
Panic.panic(e);
}
try {
PageX.recoverUpdate(pg, raw, offset);
} finally {
pg.release();
}
}
private static void doInsertLog(PageCache pc, byte[] log, int flag) {
InsertLogInfo li = parseInsertLog(log);
Page pg = null;
try {
pg = pc.getPage(li.pgno);
} catch(Exception e) {
Panic.panic(e);
}
//doInsertLog() 方法中的删除,使用的是 DataItem.setDataItemRawInvalid(li.raw);,
// dataItem 将在下一節中說明,大緻的作用,就是将該條 DataItem 的有效位設定為無效,來進行邏輯删除
try {
if(flag == UNDO) {
DataItem.setDataItemRawInvalid(li.raw);
}
PageX.recoverInsert(pg, li.raw, li.offset);
} finally {
pg.release();
}
}
doInsertLog() 方法中的删除,使用的是 DataItem.setDataItemRawInvalid(li.raw);,dataItem 将在下一節中說明,大緻的作用,就是将該條 DataItem 的有效位設定為無效,來進行邏輯删除。
▌資料項及其操作:
先從DM為上層子產品提供的抽象說起:
為了資料的持久化,需要将資料存儲在磁盤上,這肯定涉及到磁盤的讀寫。于是DM的首先工作就是對磁盤檔案進行封裝,這樣上層子產品不用關心磁盤讀寫細節。總的來說,DM的實作方式對DB進行分頁管理,并為上層子產品提供資料項(dataitem)的概念。
通過DM,上層子產品可以将DB當做集合來通路,而集合内的元素就是一個個的資料項。為了友善描述,示例為x,y,z。辨別資料項也可能會辨別某個資料項的值。圍繞資料項封裝了通路和修改資料項的操作:
1)Insert(x) 在DB中插入x;
2)Read(x) 調取x的值;
3)Update(x) 更新x的值,至于将x的值更新成多少;
是以上層子產品通過位址,向DM請求到對應的DataItem,再擷取到其中的資料;
public class DataItemImpl implements DataItem {
private SubArray raw;
private byte[] oldRaw;
private DataManagerImpl dm;
private long uid;
private Page pg;
}
DataItem 中儲存的資料,結構如下:
[ValidFlag] [DataSize] [Data]
其中 ValidFlag 占用 1 位元組,辨別了該 DataItem 是否有效。删除一個 DataItem,隻需要簡單地将其有效位設定為 0。DataSize 占用 2 位元組,辨別了後面 Data 的長度。
其中 ValidFlag 占用 1 位元組,辨別了該 DataItem 是否有效。删除一個 DataItem,隻需要簡單地将其有效位設定為 0。DataSize 占用 2 位元組,辨別了後面 Data 的長度。
上層子產品在擷取到 DataItem 後,可以通過 data() 方法,該方法傳回的數組是資料共享的,而不是拷貝實作的,是以使用了 SubArray。
@Override
public SubArray data() {
return new SubArray(raw.raw, raw.start+OF_DATA, raw.end);
}
在上層子產品試圖對 DataItem 進行修改時,需要遵循一定的流程:在修改之前需要調用 before() 方法,想要撤銷修改時,調用 unBefore() 方法,在修改完成後,調用 after() 方法。整個流程,主要是為了儲存前相資料,并及時落日志。DM 會保證對 DataItem 的修改是原子性的。
@Override
public void before() {
wLock.lock();
pg.setDirty(true);
System.arraycopy(raw.raw, raw.start, oldRaw, 0, oldRaw.length);
}
@Override
public void unBefore() {
System.arraycopy(oldRaw, 0, raw.raw, raw.start, oldRaw.length);
wLock.unlock();
}
@Override
public void after(long xid) {
dm.logDataItem(xid, this);
wLock.unlock();
}
在使用完 DataItem 後,也應當及時調用 release() 方法,釋放掉 DataItem 的緩存(由 DM 緩存 DataItem)。
@Override
public void release() {
dm.releaseDataItem(this);
}
▌資料操作管理器:
DataManager是DM層對外提供接口的類,同時,也實作DataItem對象的緩存,DataItem存儲的key, 是由頁号和頁内偏移組成的一個 8 位元組無符号整數,頁号和偏移各占 4 位元組。
隻需要從 key 中解析出頁号,從 pageCache 中擷取到頁面,再根據偏移,解析出 DataItem 即可 。
@Override
protected DataItem getForCache(long uid) throws Exception {
short offset = (short)(uid & ((1L << 16) - 1));
uid >>>= 32;
int pgno = (int)(uid & ((1L << 32) - 1));
Page pg = pc.getPage(pgno);
return DataItem.parseDataItem(pg, offset, this);
}
值得注意的是DataManger在打開已有檔案時,需要額外對第一頁進行校驗,來判斷是否需要執行恢複流程,同時重新對第一頁生成随機位元組。下面是建立和打開檔案的具體代碼。
public static DataManager create(String path, long mem, TransactionManager tm) {
PageCache pc = PageCache.create(path, mem);
Logger lg = Logger.create(path);
DataManagerImpl dm = new DataManagerImpl(pc, lg, tm);
dm.initPageOne();
return dm;
}
public static DataManager open(String path, long mem, TransactionManager tm) {
PageCache pc = PageCache.open(path, mem);
Logger lg = Logger.open(path);
DataManagerImpl dm = new DataManagerImpl(pc, lg, tm);
if(!dm.loadCheckPageOne()) {
Recover.recover(tm, lg, pc);
}
dm.fillPageIndex();
PageOne.setVcOpen(dm.pageOne);
dm.pc.flushPage(dm.pageOne);
return dm;
}
而建立檔案的初始化第一頁和打開檔案的校驗都是調用PageOne方法來實作:
// 在建立檔案時初始化PageOne
void initPageOne() {
int pgno = pc.newPage(PageOne.InitRaw());
assert pgno == 1;
try {
pageOne = pc.getPage(pgno);
} catch (Exception e) {
Panic.panic(e);
}
pc.flushPage(pageOne);
}
// 在打開已有檔案時時讀入PageOne,并驗證正确性
boolean loadCheckPageOne() {
try {
pageOne = pc.getPage(1);
} catch (Exception e) {
Panic.panic(e);
}
return PageOne.checkVc(pageOne);
}
除了資料檔案讀寫,DM層還提供了讀、插入資料庫操作。而修改操作其實是先删除舊對象後再插入新對象值。這個會在下面的TBM子產品具體講解。讀操作是根據UID從緩存中擷取DataItem, 并檢驗有效位:
@Override
public DataItem read(long uid) throws Exception {
DataItemImpl di = (DataItemImpl)super.get(uid);
if(!di.isValid()) {
di.release();
return null;
}
return di;
}
插入操作是在PageIndex中擷取一個足以存儲插入内容的頁面頁号, 擷取頁面後,首先需要寫入插入日志,接着才可以通過 pageX 插入資料,并傳回插入位置的偏移。最後需要将頁面資訊重新插入 pageIndex。
@Override
public long insert(long xid, byte[] data) throws Exception {
byte[] raw = DataItem.wrapDataItemRaw(data);
if(raw.length > PageX.MAX_FREE_SPACE) {
throw Error.DataTooLargeException;
}
// 嘗試擷取可用頁
PageInfo pi = null;
for(int i = 0; i < 5; i ++) {
pi = pIndex.select(raw.length);
if (pi != null) {
break;
} else {
int newPgno = pc.newPage(PageX.initRaw());
pIndex.add(newPgno, PageX.MAX_FREE_SPACE);
}
}
if(pi == null) {
throw Error.DatabaseBusyException;
}
Page pg = null;
int freeSpace = 0;
try {
pg = pc.getPage(pi.pgno);
// 首先做日志
byte[] log = Recover.insertLog(xid, pg, raw);
logger.log(log);
// 再執行插入操作
short offset = PageX.insert(pg, raw);
pg.release();
return Types.addressToUid(pi.pgno, offset);
} finally {
// 将取出的pg重新插入pIndex
if(pg != null) {
pIndex.add(pi.pgno, PageX.getFreeSpace(pg));
} else {
pIndex.add(pi.pgno, freeSpace);
}
}
}
尼恩提示:以上内容比較複雜,而且非常重要。如何把以上内容搞清楚? 可以看本文的配套視訊《從0開始,老架構師帶你手寫DB》。
如果想把手寫DB寫入履歷,可以找老架構師尼恩提供履歷輔導,保證履歷金光閃閃、天衣無縫。
▌總結:
DM的三個主要作用:
- 1)管理DB, 提供抽象;
- 2)緩存;
- 3)保證可恢複性。
但關于DM有下面幾個需要特别注意的地方:
- DM将DB從檔案抽象為了資料項的集合,使得上層子產品不再需要關系檔案細節。
- 在提供抽象時,沒有提供Delete(x)的操作, 這是由VM造成的。
- 由于DM的緩存,資料的位置将會有"記憶體"和"磁盤"的概念,這兩個位置之間的資料是需要同步的。
- DM通過日志的方式來保證可恢複性。
- DM的可恢複性保證,需要得到上層子產品對兩個規則的支援, 而這是由VM子產品完成的. 其他DM的細節請參考代碼注釋。
▌作者介紹:
一作:Marry, 資深Java架構師、大資料架構師,近20年Java、大資料架構和開發經驗。資深架構導師,成功指導了多個中級Java、進階Java轉型架構師崗位。
二作: 尼恩,資深系統架構師、IT領域資深作家、著名部落客。近20年高性能Web平台、高性能通信、高性能搜尋、資料挖掘等領域的3高架構研究、系統架構、系統分析、核心代碼開發工作。超級的架構更新導師:成功指導了大量的中級Java、進階Java轉型架構,最高的年薪拿到 90W。
▌說在後面:
持續疊代、持續更新 是 尼恩團隊的宗旨,持續疊代、持續更新 也是 《從0開始,手寫MySQL》的靈魂。
後面會收集更多的面試真題,同時,遇到面試難題,可以來尼恩的社群《技術自由圈(原 瘋狂創客圈)》中交流和求助。
咱們的目标,打造宇宙最牛的《手寫MySQL》高薪實操項目。 如果大家看不懂的,可以找尼恩擷取本文的配套視訊。
《尼恩 架構筆記》《尼恩高并發三部曲》《尼恩Java面試寶典》的PDF,到公号【技術自由圈】取
▌推薦相關閱讀:
《從0開始,手寫MySQL事務》
《京東太猛,手寫hashmap又一次重制江湖》
《網易一面:如何設計線程池?請手寫一個簡單線程池?》