【技術總結】從Hash索引到LSM樹

qwer1030274531發表於2020-09-12

前言

資料庫 算是軟體應用系統中最常用的一類元件了,不管是一個龐大而複雜的電商系統,還是一個簡單的個人部落格,多多少少都會用到資料庫,或是儲存海量的資料,或是儲存簡單的狀態資訊。一般地,我們都喜歡將資料庫劃分為 關係型資料庫和非關係型資料庫(又稱NoSQL資料庫),前者的典型代表是MySQL資料庫,後者的典型代表是HBase資料庫。不管是關係型,還是非關係型,資料庫都離不開兩個最基本的功能: (1)資料儲存;(2)資料查詢。簡單來說就是, 當你把資料丟給資料庫時,它能夠保持下來,並在稍後你想獲取的時候,把資料返回給你。

圍繞著這兩個基本功能,各類資料庫都運用了很多技術手段對其進行了最佳化,其中最廣為人知的當屬 資料庫索引技術。索引是一種資料結構,它在犧牲少量資料儲存(寫)效能的情況下,可以大幅提升資料查詢(讀)效能。索引也有很多種型別, Hash 索引算是最簡單高效的一種了,但是由於它自身的限制,在資料庫系統中並不被廣泛使用。當今最常用的索引技術是 B/B+ 樹索引,被廣泛地應用在關係型資料庫中,主要應用於 讀多寫少的場景。隨著NoSQL資料庫的興起, LSM (Log-Structured Merged-Tree)樹也逐漸流行,並被Google的BigTable論文所發揚光大。嚴格來說,LSM樹並不算一種傳統意義上的索引,它更像是一種設計思想,主要應用於寫多讀少的場景。

本系列文章,將從實現最簡單的Key-Value資料庫講起,然後針對實現過程中遇到的一些瓶頸,採用上述的索引技術,對資料庫進行最佳化,以此達到對資料庫的索引技術有一個較為深刻的理解。

 

最簡單的資料庫

Martin Kleppmann在 《Designing Data-Intensive Applications》一書中給出了一個最簡單資料庫的實現:

#!/bin/bashdb_set() {  echo "$1,$2" >> database}db_get() {  grep "^$1," database | sed -e "s/^$1,//" | tail -n 1}

這不到10行的 shell 程式碼實現了一個簡單的 Key-Value 資料庫。它一共有兩個函式, db_set 和db_get ,前者對應資料儲存功能,後者對應資料查詢功能。該資料庫採用簡單的文字格式(database檔案)進行資料儲存,每條記錄包含了一個鍵值對, key 和value 之間透過逗號(,)進行分隔。

資料庫的使用方法也很簡單,透過呼叫 db_set key value 可以將 key 及其對應的value儲存到資料庫中;透過db_get key可以得到該key對應的value:

$ db_set 123456 '{"name":"London","attractions":["Big Ben","London Eye"]}' $ db_set 42 '{"name":"San Francisco","attractions":["Golden Gate Bridge"]}'$ db_get 42{"name":"San Francisco","attractions":["Golden Gate Bridge"]}

透過 db_set 的實現我們發現,該資料庫每次寫都是直接往 database 檔案中追加記錄,鑑於檔案系統順序寫的高效,因此該資料庫的資料寫入具有較高的效能。但是追加寫也意味著,對同一個key進行更新時,其對應的舊的value並不會被覆蓋,這也使得每次呼叫db_get獲取某個key的value時,總是需要遍歷所有記錄,找到所有符合條件的value,並取其中最新的一個。因此,該資料庫的讀效能是非常低的。

最簡單的資料庫讀寫操作

下面我們採用Java對這個最簡單的資料庫進行重寫:

/** * SimpleKvDb.java * 追加寫 * 全檔案掃描讀 */public class SimpleKvDb implements KvDb {    ...    @Override    public String get(String key) {        try (Stream<String> lines = logFile.lines()) {            // step1: 篩選出該key對應的的所有value(對應grep "^$1," database)            List<String> values = lines                    .filter(line -> Util.matchKey(key, line))                    .collect(Collectors.toList());            // step2: 選取最新值(對應 sed -e "s/^$1,//" | tail -n 1)            String record = values.get(values.size() - 1);            return Util.valueOf(record);        } catch (IOException e) {            e.printStackTrace();            return null;        }    }    @Override    public void set(String key, String value) {        // step1: 追加寫(對應 echo "$1,$2" >> database)        String record = Util.composeRecord(key, value);        logFile.append(record);    }    ...}

使用JHM進行壓測結果如下:

Benchmark                                            Mode  Cnt  Score   Error  UnitsSimpleKvDbReadBenchmark.simpleKvDb_get_10000_test    avgt    8  0.631 ± 0.033  ms/op // 讀耗時,1w條記錄SimpleKvDbReadBenchmark.simpleKvDb_get_100000_test   avgt    8  7.020 ± 0.842  ms/op // 讀耗時,10w條記錄SimpleKvDbReadBenchmark.simpleKvDb_get_1000000_test  avgt    8  62.562 ± 5.466 ms/op // 讀耗時,100w條記錄SimpleKvDbWriteBenchmark.simpleKvDb_set_test         avgt    8  0.036 ± 0.005  ms/op // 寫耗時

從結果可以看出,該資料庫的實現具有較高的寫效能,但是讀效能很低,而且 讀耗時會隨著資料量的增加而線性增長。

那麼如何最佳化 SimpleKvDb 的讀效能?引入索引技術

索引(index)是從資料庫中的資料衍生出來的一種資料結構,它並不會對資料造成影響,只會影響資料庫的讀寫效能。對於讀操作,它能快速定位到目的資料,從而極大地提升讀效能;對於寫操作,由於需要增加額外的更新索引操作,因此會略微降低寫效能。

如前言所述,索引也有很多型別,每種索引的特點都各有差別。因此到底要不要採用索引,具體採用哪種索引,需要根據實際的應用場景來決定。

下面,我們將首先採用最簡單高效的 Hash 索引對 SimpleKvDb 進行最佳化。

 

給資料庫加上Hash索引

考慮到 Key-Value 資料庫本身就類似於Hash表的這個特點,我們很容易想到如下的索引策略:在記憶體中維護一個Hash表,記錄每個key對應的記錄在資料檔案(如前文所說的 database 檔案)中的位元組位移( byte offset )。

對於寫操作,在往資料檔案追加記錄後,還需要更新 Hash 表;對於讀操作,首先透過Hash表確定該key對應記錄在資料檔案中的位移,然後透過位元組位移快速找到 value 在資料檔案中的位置並讀取,從而避免了全文遍歷這樣的低效行為。

加上索引之後的讀寫操作

給SimpleKvDb加上Hash索引,對應的程式碼實現為:

/** * HashIdxKvDb.java * 追加寫 * 使用Hash索引提升讀效能 */public class HashIdxKvDb implements KvDb {    // 資料儲存檔案    private final LogFile curLog;    // 索引,value為該key對應的資料在檔案中的offset    private final Map<String, Long> idx;    ...    @Override    public String get(String key) {        if (!idx.containsKey(key)) {            return "";        }        // step1: 讀取索引        long offset = idx.get(key);        // step2: 根據索引讀取value        String record = curLog.read(offset);        return Util.valueOf(record);    }    @Override    public void set(String key, String value) {        String record = Util.composeRecord(key, value);        long curSize = curLog.size();        // step1: 追加寫資料        if (curLog.append(record) != 0) {            // step2: 更新索引            idx.put(key, curSize);        }    }  ...}

在實現 HashIdxKvDb 之前,我們將資料儲存檔案抽象成了 LogFile 物件,它的兩個基本方法是 append (追加寫記錄)和 read (根據 offset 讀記錄),其中,read函式透過 RandomAccessFile 的seek 方法快速定位到記錄所處的位置,具體實現如下:

// 追加寫的日誌檔案,儲存資料庫資料class LogFile {    // 檔案所在路徑    private Path path;  ...    // 向日志檔案中寫入一行記錄,自動新增換行符    // 返回成功寫入的位元組大小    long append(String record) {        try {            record += System.lineSeparator();            Files.write(path, record.getBytes(), StandardOpenOption.APPEND);            return record.getBytes().length;        } catch (IOException e) {            e.printStackTrace();        }        return 0;    }    // 讀取offset為起始位置的一行    String read(long offset) {        try (RandomAccessFile file = new RandomAccessFile(path.toFile(), "r")) {            file.seek(offset);            return file.readLine();        } catch (IOException e) {            e.printStackTrace();        }        return "";    }    ...}

使用 JHM 進行壓測結果如下:

Benchmark                                              Mode  Cnt  Score   Error  UnitsHashIdxKvDbReadBenchmark.hashIdxKvDb_get_10000_test    avgt    8  0.021 ± 0.001  ms/op // 讀耗時,1w條記錄HashIdxKvDbReadBenchmark.hashIdxKvDb_get_100000_test   avgt    8  0.021 ± 0.001  ms/op // 讀耗時,10w條記錄HashIdxKvDbReadBenchmark.hashIdxKvDb_get_1000000_test  avgt    8  0.021 ± 0.001  ms/op // 讀耗時,100w條記錄HashIdxKvDbWriteBenchmark.hashIdxKvDb_set_test         avgt    8  0.038 ± 0.005  ms/op // 寫耗時

從壓測結果可以看出,相比 SimpleKvDb HashIdxKvDb 的讀效能有了大幅的提升,而且讀耗時不再隨著資料量的增加而成線性增長;而且寫效能並沒有明顯的下降。

雖然Hash索引實現很簡單,卻是極其的高效,它只需要1次磁碟定址( seek 操作),加上1次磁碟I/O( readLine 操作),就能將資料載入出來。如果資料之前已經載入到檔案系統快取裡,甚至都不用磁碟I/O。

資料合併 ——compact

到目前為止,不管是 SimpleKvDb ,還是 HashIdxKvDb ,寫操作都是不斷在一個檔案中追加資料,這種儲存方式,通常我們稱之為append-only log。

那麼,如何才能避免append-only log無休止的一直擴張下去,直到磁碟空間不足呢?

append-only log 的一個顯著特點是舊的記錄不會被覆蓋刪除,但是這些資料往往是無用的,因為讀取某個key的value時,資料庫都是取其最新的值。因此,解決該問題的一個思路是把這些無用的記錄清除掉:

(1)當往append-only log追加資料到達一定大小後,另外建立一個新的 append-only log 進行追加。在這種機制下,資料分散到多個append-only log檔案中儲存,我們稱這些log檔案為segment file。

(2)保證只有當前的 current segment file 是可讀可寫, old segment file 只讀不寫。

segment file 機制

(3)對 old segment file 進行 compact 操作——只保留每個 key 對應的最新記錄,把的老記錄刪除。

單檔案compact操作

compact 操作往往是在後臺執行緒中執行,資料庫會將合併的結果寫到一個新的compacted segment file中,這樣在執行compact操作時,就不會影響從old segment file中讀資料的邏輯。等到compact操作完成之後,再把old segment file刪除,後續的讀操作遷移到compacted segment file上。

現在,單個compacted segment file中的key都是唯一的,但是多個compacted segment file之間還是有可能存在重複的key,我們還能夠更近一步,對多個 compacted segment file 再一次進行 compact 操作,這樣資料量會再次減少。

多檔案compact操作

類似的compact操作可以一層層執行下去,比如,可以對level2 compacted segment file進行compact,生成level3 compacted segment file。但是並不是compact的層次越多越好,具體的compact策略需要結合實際的應用場景進行設計。

 

給HashIdxKvDb加上compact機制

下面,我們試著給 HashIdxKvDb 加上compact 機制。由於在compact機制下,資料會分散在多個segment file中儲存,因此之前的Hash索引機制不再適用,我們需要給每個 segment file 都單獨維護一份Hash索引。當然,這樣也比較容易實現,只需要維護一個Hash表 ,key為segment file ,value為該segment file對應的Hash索引。

segment file 下的hash索引

對應的程式碼實現如下:

// 多檔案雜湊索引實現class MultiHashIdx {    ...    private Map<LogFile, Map<String, Long>> idxs;    // 獲得指定LogFile中,Key的索引    long idxOf(LogFile file, String key) {        if (!idxs.containsKey(file) || !idxs.get(file).containsKey(key)) {            return -1;        }        return idxs.get(file).get(key);    }    // 新增指定LogFile中Key的索引    void addIdx(LogFile file, String key, long offset) {        idxs.putIfAbsent(file, new ConcurrentHashMap<>());        idxs.get(file).put(key, offset);    }    ...}

另外,我們還需要在 CompactionHashIdxKvDb 裡分別維護一份old segment file、level1 compacted segment file和level2 compacted segment file集合,並透過 ScheduledExecutorService 定時對這些集合進行compact操作。

/** * 追加寫,噹噹前segemnt file到達一定大小後,追加到新到segment file上。並定時對舊的segment file進行compact。 * 為每個segment file維持一個雜湊索引,提升讀效能 * 支援單執行緒寫,多執行緒讀 */public class CompactionHashIdxKvDb implements KvDb {    ...    // 當前追加寫的segment file路徑    private LogFile curLog;    // 寫old segment file集合,會定時對這些檔案進行level1 compact合併    private final Deque<LogFile> toCompact;    // level1 compacted segment file集合,會定時對這些檔案進行level2 compact合併    private final Deque<LogFile> compactedLevel1;    // level2 compacted segment file集合    private final Deque<LogFile> compactedLevel2;    // 多segment file雜湊索引    private final MultiHashIdx idx;    // 進行compact的定時排程    private final ScheduledExecutorService compactExecutor;    ...}

相比於 HashIdxKvDb , CompactionHashIdxKvDb  在寫入新的資料之前,需要判斷當前檔案大小是否寫滿,如果寫滿了,則需要建立新的LogFile進行追加,並將寫滿後的LogFile歸檔到 toCompact 佇列中。

@Overridepublic void set(String key, String value) {    try {        // 如果當前LogFile寫滿了,則放到toCompact佇列中,並建立新的LogFile        if (curLog.size() >= MAX_LOG_SIZE_BYTE) {            String curPath = curLog.path();            Map<String, Long> oldIdx = idx.allIdxOf(curLog);            curLog.renameTo(curPath + "_" + toCompactNum.getAndIncrement());            toCompact.addLast(curLog);            // 建立新的檔案後,索引也要更新            idx.addAllIdx(curLog, oldIdx);            curLog = LogFile.create(curPath);            idx.cleanIdx(curLog);        }        String record = Util.composeRecord(key, value);        long curSize = curLog.size();        // 寫成功則更新索引        if (curLog.append(record) != 0) {            idx.addIdx(curLog, key, curSize);        }    } catch (IOException e) {        e.printStackTrace();    }}

CompactionHashIdxKvDb  的讀操作相對麻煩,因為資料被分散在多個segment file上,所以需要按照如下順序完成資料查詢,直到查詢到為止:當前追加的 LogFile->toCompact 佇列->compactedLevel1佇列->compactedLevel2佇列。因此 ,CompactionHashIdxKvDb  的讀操作在極端情況下(所查詢的資料儲存在comactedLevel2佇列上時)也會較為低效。

@Overridepublic String get(String key) {    // 第一步:從當前的LogFile查詢    if (idx.idxOf(curLog, key) != -1) {        long offset = idx.idxOf(curLog, key);        String record = curLog.read(offset);        return Util.valueOf(record);    }    // 第二步:從toCompact中查詢    String record = find(key, toCompact);    if (!record.isEmpty()) {        return record;    }    // 第三步:從 compactedLevel1 中查詢    record = find(key, compactedLevel1);    if (!record.isEmpty()) {        return record;    }    // 第四步:從 compactedLevel2 中查詢    record = find(key, compactedLevel2);    if (!record.isEmpty()) {            return record;    }    return "";}

toCompact 佇列裡的 old segment file 進行單檔案 level1 compact 操作時,可以利用 Hash 索引。因為 Hash 索引上所對應的記錄總是最新的,因此只需遍歷 Hash 索引,將每個 key 對應的最新記錄查詢出來,寫到新的 level1 compacted segment file 中即可。

// 進行level1 compact,對單個old segment file合併void compactLevel1() {    while (!toCompact.isEmpty()) {        // 建立新的level1 compacted segment file        LogFile newLogFile = LogFile.create(curLog.path() + "_level1_" + level1Num.getAndIncrement());        LogFile logFile = toCompact.getFirst();        // 只保留每個key對應的最新的value        idx.allIdxOf(logFile).forEach((key, offset) -> {            String record = logFile.read(offset);            long curSize = newLogFile.size();            if (newLogFile.append(record) != 0) {                idx.addIdx(newLogFile, key, curSize);            }        });        // 寫完後儲存到compactedLevel1佇列中,並刪除toCompact中對應的檔案        compactedLevel1.addLast(newLogFile);        toCompact.pollFirst();        logFile.delete();    }}

對compactedLevel1 佇列進行多檔案level2 compact的策略是:將當前佇列裡所有的level1 compacted segment file合併成一個level2 compacted segment file。具體步驟如下:

1 、生成一份 compactedLevel1 佇列的snapshot 。目的是為了避免在level2 compact過程中,有新的level1 compacted segment file加入到佇列造成影響。

2 、對snapshot進行compact操作,按照從新到舊的順序,將level1 compacted segment file中的記錄寫入新的level2 compacted segment file中,如果發現 level2 compacted segment file 已經存在該 key ,則跳過。

3 、等完成任務後,再從 compactedLevel1 佇列裡刪除已經合併過的level1 compacted segment file。

// 進行level2 compact,針對compactedLevel1佇列中所有的檔案進行合併void compactLevel2() {    ...    // 生成一份快照    Deque<LogFile> snapshot = new LinkedList<>(compactedLevel1);    if (snapshot.isEmpty()) {        return;    }    int compactSize = snapshot.size();    // level2的檔案命名規則為:filename_level2_num    LogFile newLogFile = LogFile.create(curLog.path() + "_level2_" + level2Num.getAndIncrement());    while (!snapshot.isEmpty()) {        // 從最新的level1 compacted segment file開始處理        LogFile logFile = snapshot.pollLast();        logFile.lines().forEach(record -> {            String key = Util.keyOf(record);            // 只有當前level2 compacted segment file中不存在的key才寫入            if (idx.idxOf(newLogFile, key) == -1) {                // 寫入成功後,更新索引                long offset = newLogFile.size();                if (newLogFile.append(record) != 0) {                    idx.addIdx(newLogFile, key, offset);                }            }        });    }    compactedLevel2.addLast(newLogFile);    // 寫入完成後,刪除compactedLevel1佇列中相應的檔案    while (compactSize > 0) {        LogFile logFile = compactedLevel1.pollFirst();        logFile.delete();        compactSize--;    }    ...}

 

總結

本文首先介紹了Martin Kleppmann給出的最簡單的資料庫,並使用Java語言對它進行了重新的實現,接著採用Hash索引和compact機制對其進行了一系列的最佳化。

SimpleKvDb 採用append-only log的方式儲存資料, append-only log 最主要的優點是具備很高的寫入效能(檔案系統的順序寫比隨機寫要快很多)。追加寫也意味著同一個key對應的舊記錄不會被覆蓋刪除,因此在查詢資料時,需要遍歷整個檔案,找到該key的所有記錄,並選取最新的一個。因為涉及到全文遍歷,因此 SimpleKvDb 的讀效能非常低。

為了最佳化 SimpleKvDb 的讀效能,我們實現了具有Hash索引的HashIdxKvDb。Hash索引是一個在記憶體中維護的Hash表,儲存每個key對應的記錄在檔案中的offset。因此 每次資料查詢只需要1次磁碟定址,加上1次磁碟I/O,非常高效。

為了解決append-only log一直擴張導致磁碟空間不足的問題,我們給HashIdxKvDb引入了compact機制,實現了 CompactionHashIdxKvDb compact 機制能夠有效清理資料庫中的無效的舊資料,從而減緩了磁碟空間的壓力。

Hash 索引雖然簡單高效,但是有如下兩個限制:

1 、必須在記憶體中維護Hash索引。 如果選擇在磁碟上實現Hash索引,那麼將會帶來大量的磁碟隨機讀寫,導致效能的急劇下降。另外,隨著compact機制的引入,資料被分散在多個segment file中儲存,我們不得不為每個segment file維護一份Hash索引,這就導致Hash索引的記憶體佔用量不斷增加,給系統帶來了很大的記憶體壓力。

2 、區間查詢非常低效。 比如,當需要查詢資料庫中範圍在 [key0000, key9999] 之間的所有key時,必須遍歷索引中所有的元素,然後找到符合要求的key。

針對Hash索引的這兩個限制,要怎樣進行最佳化呢?本系列的下一篇文章,我們將介紹另一種不存在這兩個限制的索引——LSM樹。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/30239065/viewspace-2719046/,如需轉載,請註明出處,否則將追究法律責任。

相關文章