leveldb源碼剖析----compaction

根據前面的分析,背景線程的主體工作在BackgroundCompaction函數中完成。這個函數主要完成以下兩個工作:

  1. 如果imm_非空,則將imm_寫入到磁盤中生成新的sstable文件
  2. 對level中的文件進行合併。合併的目的主要是避免某個level中sstable文件過多,並且可以通過合併的過程刪除掉過期的key-value和被用戶刪除的key-value。

這篇文章主要是從BackgroundCompaction函數開始,分析level中文件合併的過程


void DBImpl::BackgroundCompaction() { //在背景線程中執行
 mutex_.AssertHeld();

  if (imm_ != NULL) {
    CompactMemTable(); //將imm_寫到level 0
    return;
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

這部分主要是完成上面所說的第一個工作,將imm_寫入level 0 中。因爲這個函數需要處理很多整個數據庫共享的數據結構,比如imm_,versions_等,因此必須保證在臨界區中執行。

  Compaction* c;
  bool is_manual = (manual_compaction_ != NULL);
  InternalKey manual_end;
  if (is_manual) {
    ManualCompaction* m = manual_compaction_;
    c = versions_->CompactRange(m->level, m->begin, m->end); 
    m->done = (c == NULL);
    if (c != NULL) {
      manual_end = c->input(0, c->num_input_files(0) - 1)->largest;
    }
    Log(options_.info_log,
        "Manual compaction at level-%d from %s .. %s; will stop at %s\n",
        m->level,
        (m->begin ? m->begin->DebugString().c_str() : "(begin)"),
        (m->end ? m->end->DebugString().c_str() : "(end)"),
        (m->done ? "(end)" : manual_end.DebugString().c_str()));
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

這部分是在設置manual_compaction_ 的時候被執行,它主要是用於測試數據庫的compaction功能,用於debug,數據庫實際運行時一般不會運行這段代碼,這裏且略過,不過從功能實現上和後面的分析也是一樣的。

else {
    c = versions_->PickCompaction(); //找出最適合compaction的level
  }
  • 1
  • 2
  • 3

這個條件分支是一個重點,它負責從數據庫中選出適合進行compaction的level。它將返回可以進行compaction的level中的文件元信息,這些元信息存儲在Compaction類中

可以看一下compaction的數據成員:


這裏寫圖片描述

這個compaction將會合並level和level+1中的部分文件。

如果我們深入到PickCompaction就會看到leveldb是怎麼選擇需要進行compaction的level的。

leveldb首先從當前的versions中選擇一個最適合進行compaction的level,這裏的最適合主要是通過版本控制裏面的compaction_score_變量進行衡量,同時每個版本都會有一個變量跟蹤當前版本中最適合進行compaction的level(current_->compaction_level_)。除此之外,leveldb爲每個level中的文件維持一個 數組compact_pointer_,這個compact_pointer_[level]指向當前level中上次被compaction的最大key的值,因此下次對這個level進行compaction時,就要從key大於compact_pointer_[level]的文件開始,而且我們可以看到,通常是選擇第一個largest key大於compact_pointer_[level]的文件作爲當前level需要進行compaction的文件。

從當前的level選擇出適合進行compaction的文件後,我們就可以通過版本控制中的元信息找到這個文件所包含的key的最大值和最小值,通過這個最大值和最小值,我們就可以找到level+1中有哪些文件和level中的這個文件的key可能有重合,然後將這些文件和level中的那個文件進行compaction,compaction後的文件放入到level+1中。需要注意的是,每次compaction可能生成多個文件,而不是一個compaction只生成一個文件

回到上面的compaction類,通過上面步驟得到的level和level+1中需要進行compaction的文件的元信息放在compaction的數據成員inputs_數組中。其中inputs_[0]包含的是需要進行合併的level中的那個文件,inputs_[1]包含的是level+1中需要和level中的那個文件進行合併的所有文件元信息。

前面說過,一般都是從當前level中選擇一個文件,然後從level+1中找到所有和這個文件的key範圍有重合的文件進行合併,最後將合併的文件都放在level+1中,因此這保證了level+1中所有的文件的key不會有重合。而這裏之所以只在level中選擇一個文件,我們是基於level中的文件的key不會有重合的假設,這對於大於0的所有level都是成立的,但是對level 0 不成立,因爲level 0 中可能會有重合的key。因此我們需要對level0 進行特殊處理,如果當前需要合併的level爲0,則我們首先從level 0 中選擇一個文件,然後找出level 0 中所有和這個文件的key重合的文件放入inputs_[0]中

這些可以從PickCompaction的實現中找到答案。

class compaction中的其他成員,主要是負責合併後文件的生成。比如max_output_file_size_控制合併生成的sstable文件的大小。grandparents_數組和grandparent_index_也是用於控制生成sstable文件的大小,grandparents_數組中維護的是level+2中和從level中選出的進行合併的文件的key範圍重合的左右文件元信息,因此通過grandparents_數組,我們可以控制新合併生成放在level+1中的每個sstable文件不要和level+2中的過多文件有key範圍重合,因爲如果level+1中的某個文件和level+2中的很多文件都有key範圍重合的話,那下次將level+1中的這個文件和level+2進行合併時,會比較耗時,因爲level+2中和這個文件key重合的文件太多了。這裏主要是平攤的思想,不要讓某個文件承擔太多的合併壓力。

class compaction中的seen_key_主要是保證每個合併而成的sstable中都有key-value數據。想象一個場景,如果合併過程中第一個打算加入的key是一個比level+2中很多文件的最大key都大的數值,則我們可能會誤以爲當前合併而成的文件已經足夠大了,準備把它寫盤,但是實際卻是當前文件中還沒有key-value,於是就會出現問題。

class compaction中的其他成員變量主要是版本控制信息,後面再介紹。

level_ptrs_也是一個數組,它裏面存儲的是整型變量,記錄每次遍歷各層文件時的下標信息,主要用於判斷一個key是否在level+2以及更高層的的文件中。可以看IsBaseLevelForKey函數的實現。

好了分析完compaction的結構信息,我們繼續看BackgroundCompaction中的代碼:

現在已經拿到了需要compaction的文件信息,這些信息就存儲在c中

  Status status;
  if (c == NULL) {
    // Nothing to do
  } 
  • 1
  • 2
  • 3
  • 4

如果c爲空,說明沒有文件需要進行compaction,那就無事可做了

else if (!is_manual && c->IsTrivialMove()) { 
    // Move file to next level
    assert(c->num_input_files(0) == 1);
    FileMetaData* f = c->input(0, 0);

    c->edit()->DeleteFile(c->level(), f->number);
    c->edit()->AddFile(c->level() + 1, f->number, f->file_size,
                       f->smallest, f->largest);
    status = versions_->LogAndApply(c->edit(), &mutex_); 

    if (!status.ok()) {
      RecordBackgroundError(status);
    }
    VersionSet::LevelSummaryStorage tmp;
    Log(options_.info_log, "Moved #%lld to level-%d %lld bytes %s: %s\n",
        static_cast<unsigned long long>(f->number),
        c->level() + 1,
        static_cast<unsigned long long>(f->file_size),
        status.ToString().c_str(),
        versions_->LevelSummary(&tmp));
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

這個條件分支主要是處理level+1中沒有文件需要和level中的那個文件進行合併的情況。這種情況,很簡單,直接把level中的那個需要合併的文件移動到level+1中即可。

{ // 否則進行compaction
    CompactionState* compact = new CompactionState(c); // c中包含需要compaction的文件的元信息
    status = DoCompactionWork(compact); 
    if (!status.ok()) {
      RecordBackgroundError(status);
    }
    CleanupCompaction(compact);
    c->ReleaseInputs();
    DeleteObsoleteFiles();
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

這個分支就是主要的工作了。此時說明level+1中有文件和level中的那個需要合併的文件key範圍重合。因此需要將這個文件和level+1中的那些文件進行合併操作。主體工作是在DoCompactionWork函數中完成。

下面我們分析DoCompactionWork的源碼,至於BackgroundCompaction函數,到這裏已經基本把我們關心的工作完成了。後面我們就不會到BackgroundCompaction函數了。


DoCompactionWork函數的實現

leveldb的compaction操作主要是由DoCompactionWork函數完成:

Status DBImpl::DoCompactionWork(CompactionState* compact) { 
  const uint64_t start_micros = env_->NowMicros();
  int64_t imm_micros = 0;  // Micros spent doing imm_ compactions

  Log(options_.info_log,  "Compacting %d@%d + %d@%d files",
      compact->compaction->num_input_files(0),
      compact->compaction->level(),  
      compact->compaction->num_input_files(1),
      compact->compaction->level() + 1);

  assert(versions_->NumLevelFiles(compact->compaction->level()) > 0);
  assert(compact->builder == NULL);
  assert(compact->outfile == NULL);
  if (snapshots_.empty()) {
    compact->smallest_snapshot = versions_->LastSequence();
  } else {
    compact->smallest_snapshot = snapshots_.oldest()->number_;
  }

  // Release mutex while we're actually doing the compaction work
  mutex_.Unlock();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

最開始這部分不是我們關心的內容,先放着,後面再介紹

  Iterator* input = versions_->MakeInputIterator(compact->compaction);
  input->SeekToFirst();
  • 1
  • 2

這是獲得一個可以遍歷需要compaction的所有文件(level和level+1中所有需要進行compaction操作的文件)的迭代器,每個迭代器對應一個key-value,這樣,我們通過這個迭代器就可以找到compaction結構中的inputs_數組裏面的所有文件的key-value。leveldb裏面提供了很多迭代器,它通過迭代器封裝了文件內部訪問的複雜細節。

  Status status;
  ParsedInternalKey ikey;
  std::string current_user_key;
  bool has_current_user_key = false;
  SequenceNumber last_sequence_for_key = kMaxSequenceNumber;
  • 1
  • 2
  • 3
  • 4
  • 5

這些是對後面需要用到的一些變量的初始化。

後面就是一個大循環,這個循環依次通過上面的迭代器遍歷所有參與compaction的文件的所有key,循環的主體工作是判斷當前迭代器對應的key是否應該加入到新合併生成的文件中

  for (; input->Valid() && !shutting_down_.Acquire_Load(); ) { //每個input對應的是一個 K/V
    // Prioritize immutable compaction work
    if (has_imm_.NoBarrier_Load() != NULL) {
      const uint64_t imm_start = env_->NowMicros();
      mutex_.Lock();
      if (imm_ != NULL) {
        CompactMemTable(); //這裏就是將imm_寫入磁盤中
        bg_cv_.SignalAll();  // Wakeup MakeRoomForWrite() if necessary
      }
      mutex_.Unlock();
      imm_micros += (env_->NowMicros() - imm_start);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

每次循環開始先判斷當前的imm_是否爲空,如果爲空的話,先將它寫入磁盤,這主要是爲了防止imm_沒有及時寫盤造成用戶線程不能寫mem。可以參見前面的分析。

    Slice key = input->key(); 
    if (compact->compaction->ShouldStopBefore(key) &&
        compact->builder != NULL) {
      status = FinishCompactionOutputFile(compact, input); //生成一個sstable
      if (!status.ok()) {
        break;
      }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

這裏先把迭代器對應的key提取出來,因爲在此之前我們可能以及遍歷過多個key-value了,也就是可能已經將多個key-value寫入到新的sstable中了。這裏通過ShouldStopBefore函數判斷是否符合生成一個新的sstable的條件,如果符合的話就將這個sstable寫盤,如果不符合的話,就繼續往裏面加key-value。

前面我們分析過,影響是否將當前的sstable寫盤的主要有兩個原因:

  1. 當前的sstable是否已經足夠大了
  2. 當前的sstable是否和過多的level+2中的文件重合

這裏處理的是第二個條件。這裏需要注意的是,到目前位置這個迭代器對應的key-value都沒有寫入到當前的sstable中。

bool drop = false;
  • 1

這是一個標記位,主要是用於標記一個key是否應該加入到當前的sstable中。如果drop=true則說明這個當前key應該被丟棄,反則反之。

   if (!ParseInternalKey(key, &ikey)) { //把sequence,type,key都解析出來,放在ikey中
      // Do not hide error keys
      current_user_key.clear();
      has_current_user_key = false;
      last_sequence_for_key = kMaxSequenceNumber;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

首先對key-value進行解析,前面我們分析過,sstable中存儲的key-value是以如下的形式:

internal_key_size|key|sequence|type|value_size|value
  • 1

這裏主要是把key,sequence,type解析出來。

如果發現這個key不合法,則設置一些標記變量。後面我們再講解這些變量的作用。這些標記位的作用就是保證這個key一定會被寫入到新合併生成的sstable文件中。並且它不對後面的key-value是否被丟棄產生影響。leveldb之所以選擇不丟棄不合法的key-value,我想主要是爲了不想隱藏系統可能的一些錯誤?

如果key-value形式合法,則走到下面的一個大else

else {
      if (!has_current_user_key ||
          user_comparator()->Compare(ikey.user_key,
                                     Slice(current_user_key)) != 0) { //如果等於0,表示這個key和之前度過的key相同,不必再添加了
        // First occurrence of this user key
        current_user_key.assign(ikey.user_key.data(), ikey.user_key.size());
        has_current_user_key = true;
        last_sequence_for_key = kMaxSequenceNumber; //
      }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

這個是判斷當前迭代器的key和前面加入的一個key是否相等,如果相等的話,那說明這個key是一個過期的key,應該被丟棄,如果這個key是第一次出現,則將其設置爲current_user_key,(1). 但是還不能確定把他加入到新的sstable中去,因爲可能這個key的type是kTypeDeletion,表示這個key已經被用戶刪除了,因此自然應該把它丟棄掉;(2). 除此之外,對於過期的key,我們也不是一定會像前面說的那樣把它丟棄掉,因爲可能系統開啓了快照,這樣老舊的key也得保存下來。

所以後面有兩個條件分支,分別處理這兩種情況:

  1. 對於過期的key,通過檢查這個key的序列號,判斷它是否在系統快照中,如果在的話,即使它是過期key也不能丟棄。
  2. 對於非過期的key,檢查這個key的type,看它是否是kTypeDeletion,即是否已經被用戶刪除了。
      if (last_sequence_for_key <= compact->smallest_snapshot) {
        // Hidden by an newer entry for same user key
        drop = true;    // (A)
      } else if (ikey.type == kTypeDeletion &&
                 ikey.sequence <= compact->smallest_snapshot &&
                 compact->compaction->IsBaseLevelForKey(ikey.user_key)) {
        // For this user key:
        // (1) there is no data in higher levels
        // (2) data in lower levels will have larger sequence numbers
        // (3) data in layers that are being compacted here and have
        //     smaller sequence numbers will be dropped in the next
        //     few iterations of this loop (by rule (A) above).
        // Therefore this deletion marker is obsolete and can be dropped.
        drop = true;
      }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

對於第一個if,從最前面的if我們看到,當我們在發現當前key是第一次出現時會設置last_sequence_for_key = kMaxSequenceNumber;因此走這個if說明該key肯定是一個過期key了。所以判斷它的序列號是否在快照序列號中,不是的話就標記drop = true,將其丟棄。


至此爲止,當前key要麼是第一次出現,要麼是有快照保護,好像不能丟棄。但是事情還沒完,我們還不能據此判斷該key是否應該被保存下來,我們還要判斷它的type,但是事情遠沒有我們想得那麼簡單,除了判斷key的type之外,我們還要做其他判斷.也就是說,當一個key爲kTypeDeletion時它還不一定是要被刪除的。爲什麼呢

想象一個場景:用戶在調用delete刪除一個key時,這個key在數據庫中有一個過期的key存在,而這個過期key還來不及和這個被刪除的key合併,如果在這種情況下,我們直接將這個被標記爲刪除的key丟棄,那數據庫中還會存在一個過期的key,而這個過期的key在丟棄那個被刪除的key時瞬間就變成正常不過期的key了,於是下次讀key時,會讀到這個本應該過期的key,按道理應該是找不到key纔對。所以爲了系統正常運行,我們每次丟棄一個標記爲kTypeDeletion的key時,必須保證數據庫中不存在它的過期key,否則就得將它保留,直到後面它和這個過期的key合併爲止,合併之後再丟棄

所以這裏會調用IsBaseLevelForKey函數判斷level+2及level+2之後的所有level中沒有和這個標記爲刪除的key相同的key,只要有,就肯定是過期key了。

last_sequence_for_key = ikey.sequence;
    }
  • 1
  • 2

這裏就是標記last_sequence_for_key 變量,用它來標記當前key的序列號。

    if (!drop) {
      // Open output file if necessary
      if (compact->builder == NULL) {
        status = OpenCompactionOutputFile(compact);
        if (!status.ok()) {
          break;
        }
      }
      if (compact->builder->NumEntries() == 0) {
        compact->current_output()->smallest.DecodeFrom(key);
      }
      compact->current_output()->largest.DecodeFrom(key);
      compact->builder->Add(key, input->value()); //把這個鍵值加入新建的sstable文件中

      // Close output file if it is big enough  // 如果當前結果文件已經足夠大,則關閉文件,以後的compaction結果再放到新的文件中
      if (compact->builder->FileSize() >=
          compact->compaction->MaxOutputFileSize()) {
        status = FinishCompactionOutputFile(compact, input);
        if (!status.ok()) {
          break;
        }
      }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

OK,如果drop爲false,說明當前key應該被保留下來。下面就將當前迭代器對應的key-value加入到sstable中,就是通過TableBuilder完成這些工作。前面我們講過了。

加入這個key-value到sstable後,還要判斷當前的sstable是不是滿足寫盤條件,即滿足生成一個完整sstable的文件。

input->Next();
  • 1

繼續下一個key-value。迭代器封裝了所有細節。後面我們會專門介紹leveldb中的各種迭代器。

大循環之後就完成了文件合併的核心工作,循環之後是一些設置版本信息和寫日誌的工作,這個我們後面再介紹了。


總結

compaction是leveldb中最核心的東西了。(1). 前面我們說過,當用戶調用delete刪除一個鍵值時,leveldb並沒有真正把它刪除掉,而只是簡單將這個鍵值對的type標記爲kTypeDeletion,然後和正常的鍵值對一樣寫盤。這個特點使得leveldb的寫操作很快,但是問題也是很顯然的,就是會造成大量無效數據,佔用磁盤空間。(2). 除此之外,leveldb添加數據時並不會將過期的key-value覆蓋,而是通過序列號將其當成完全不同的key寫入進去,因此會使得系統中存在很多過期數據。,毫無疑問這也是很佔用空間的。

而compaction操作可以解決這些問題。通過compaction,leveldb可以將過期的key丟棄,而且在一定條件下丟棄標記爲kTypeDeletion的數據。同時通過compaction控制了每層的文件數目。可以說compaction是leveldb的寫操作得以高效的主要原因。

每次compaction操作是根據版本統計信息找到最適合進行compaction的level,然後從這個level中選擇一個最合適的compaction文件,再去level+1中找出所有和這個文件的key重合的文件,最後將level中的這個文件和level+1中的那些文件進行合併,並將合併生成的所有sstable文件都放入到level+1層中。當然當level=0時,因爲level0中的文件可能存在key重合,因此需要一些特殊處理。

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