OceanBase 儲存層程式碼解讀(三)巨集塊儲存格式

OceanBase開源社群發表於2022-03-10
作者:
公祺,一個專注於 OBKV 的程式設計師

1.巨集塊的概述

在上一篇 微塊的儲存格式中已經介紹了微塊和巨集塊的關係,巨集塊是處於 SSTable 和微塊之間的資料結構,OceanBase 中的巨集塊為2MB的定長資料塊。眾所周知,OceanBase 中微塊是讀 IO 最小單元,這是因為微塊讀處在使用者請求的關鍵路徑上,為保證快速響應使用者的請求,微塊不能過大,所以微塊的預設大小一般不超過16KB;而巨集塊作為寫 IO 的最小單元,它的讀寫不在使用者請求的關鍵路徑上,所以就有了2MB的巨集塊,目的是為了最大限度的發揮磁碟的吞吐效能,能快速的做 compaction 、遷移複製、壞塊檢查等操作。巨集塊的簡單結構可以參考下圖,詳細的巨集塊格式介紹見下一節:

OceanBase 儲存層程式碼解讀(三)巨集塊儲存格式

注:本文所有的說明及程式碼都是基於 v3.1.0_CE_BP1 版本的 OceanBase 開原始碼。

2. 巨集塊的格式

目前 OceanBase 支援的巨集塊有很多種,具體可以 enum MacroBlockType 的定義,總共有十幾種吧,但是常用的資料巨集塊主要有三種,如下:

  • SSTableData:常規的存放資料的巨集塊;
  • LobData:Large Object Data,用來存放資料較大的行資料;
  • BloomFilterData:帶有 bloomfilter 的巨集塊。

本文主要介紹第1種常規的資料巨集塊,關於 LobData、BloomFilterData,後面找時間再單獨說明。

巨集塊的整體格式可以參考上圖,它是一種比較經典的儲存結構(header + payload + trailer + padding):

  • header 中記錄後設資料:對應 OceanBase 巨集塊的 header ;
  • payload 存放的是具體的資料:OceanBase 巨集塊的 payload 為微塊列表;
  • trailer 中記錄的是資料的 index:OceanBase 巨集塊的 trailer 為微塊的 index 資訊,即為微塊在巨集塊中的偏移量;
  • padding 是為了做對齊的:OceanBase 巨集塊為2MB,不足部分需要做 padding。

後面我們針對不同的部分,一一介紹其結構的儲存格式。


2.1 巨集塊的 header

巨集塊的頭部記錄的自然就是巨集塊的後設資料,它由多個部分組成,如下圖所示:

OceanBase 儲存層程式碼解讀(三)巨集塊儲存格式

巨集塊頭部的各個部分儲存的是不同的後設資料,具體含義如下:

  • common header:巨集塊的版本、型別、大小、checksum 等資訊,見 ObMacroBlockCommonHeader ;
  • macro block header:記錄了巨集塊資料大小、table_id、partition_id、微塊的數量、列數、行數、checksum、加密資訊、以及相關 offset 資訊,具體可以參考 struct ObSSTableMacroBlockHeader;
  • column id list:列的 id 列表,OceanBase 資料庫表每一列都一個唯一 id;
  • column type list:每列的型別資訊,包括:型別、編碼字符集等;
  • column order list:每列的順序,可以是 ASC 或 DESC ,巨集塊中所有微塊中的行資料都是按照這個順序儲存;
  • column checksum list:每列資料的 checksum 資訊,用來做列的資料校驗。


微塊頭部的儲存格式可以參考下面的程式碼:

// src/storage/blocksstable/ob_macro_block.cpp
// 該函式主要給巨集塊header結構預先指向buffer的不同的offset,後續就不需要再進行序列化操作了,
// 該函式在初始化巨集塊的時候呼叫,header成員變數的具體值是在後續的資料寫入後指定。
int ObMacroBlock::reserve_header(const ObDataStoreDesc& spec)
{
  int ret = OB_SUCCESS;
  common_header_.reset();
  common_header_.set_attr(ObMacroBlockCommonHeader::SSTableData);
  common_header_.set_data_version(spec.data_version_);
  common_header_.set_reserved(0);
  const int64_t common_header_size = common_header_.get_serialize_size();
  // data_的型別是ObSelfBufferWriter,它是一個支援自動擴充套件的記憶體buffer
// ObSelfBufferWriter的實現見:src/storage/blocksstable/ob_data_buffer.h
  MEMSET(data_.data(), 0, data_.capacity());
  // 1. data_的第一部分為ObMacroBlockCommonHeader
if (OB_FAIL(data_.advance(common_header_size))) {
    STORAGE_LOG(WARN, "data buffer is not enough for common header.", K(ret), K(common_header_size));
  }
  if (OB_SUCC(ret)) {
    int64_t column_count = spec.row_column_count_;
    int64_t rowkey_column_count = spec.rowkey_column_count_;
    int64_t column_checksum_size = sizeof(int64_t) * column_count;
    int64_t column_id_size = sizeof(uint16_t) * column_count;
    int64_t column_type_size = sizeof(ObObjMeta) * column_count;
    int64_t column_order_size = sizeof(ObOrderType) * column_count;
    int64_t macro_block_header_size = sizeof(ObSSTableMacroBlockHeader);
      
    // 2. data_的第二部分為ObSSTableMacroBlockHeader
    header_ = reinterpret_cast<ObSSTableMacroBlockHeader*>(data_.current());
    // 3. data_的第三部分為column_ids_
    column_ids_ = reinterpret_cast<uint16_t*>(data_.current() + macro_block_header_size);
    // 4. data_的第四部分為column_types
    column_types_ = reinterpret_cast<ObObjMeta*>(data_.current() + macro_block_header_size + column_id_size);
    // 5. data_的第五部分為column_orders_
    column_orders_ =
        reinterpret_cast<ObOrderType*>(data_.current() + macro_block_header_size + column_id_size + column_type_size);
    // 6. data_的第六部分為column_checksum_
    column_checksum_ = reinterpret_cast<int64_t*>(
        data_.current() + macro_block_header_size + column_id_size + column_type_size + column_order_size);
    macro_block_header_size += column_checksum_size + column_id_size + column_type_size + column_order_size;
    // for compatibility, fill 0 to checksum and this will be serialized to disk
for (int i = 0; i < column_count; i++) {
      column_checksum_[i] = 0;
    }
    // 7. data_後面的記憶體空間是給微塊預留的
if (OB_FAIL(data_.advance(macro_block_header_size))) {
      STORAGE_LOG(WARN, "macro_block_header_size out of data buffer.", K(ret));
    } else {
      // 初始化header中的成員變數
memset(header_, 0, macro_block_header_size);
      header_->header_size_ = static_cast<int32_t>(macro_block_header_size);
      header_->version_ = SSTABLE_MACRO_BLOCK_HEADER_VERSION_v3;
      header_->magic_ = SSTABLE_DATA_HEADER_MAGIC;
      header_->attr_ = 0;
      header_->table_id_ = spec.table_id_;
      header_->data_version_ = spec.data_version_;
      header_->column_count_ = static_cast<int32_t>(column_count);
      header_->rowkey_column_count_ = static_cast<int32_t>(rowkey_column_count);
      header_->column_index_scale_ = static_cast<int32_t>(spec.column_index_scale_);
      header_->row_store_type_ = static_cast<int32_t>(spec.row_store_type_);
      header_->micro_block_size_ = static_cast<int32_t>(spec.micro_block_size_);
      header_->micro_block_data_offset_ = header_->header_size_ + static_cast<int32_t>(common_header_size);
      memset(header_->compressor_name_, 0, OB_MAX_HEADER_COMPRESSOR_NAME_LENGTH);
      MEMCPY(header_->compressor_name_, spec.compressor_name_, strlen(spec.compressor_name_));
      header_->data_seq_ = 0;
      header_->partition_id_ = spec.partition_id_;
      // copy column id & type array;
for (int64_t i = 0; i < header_->column_count_; ++i) {
        column_ids_[i] = static_cast<int16_t>(spec.column_ids_[i]);
        column_types_[i] = spec.column_types_[i];
        column_orders_[i] = spec.column_orders_[i];
      }
    }
  }
  if (OB_SUCC(ret)) {
    // 指定資料在data_中的offset
    data_base_offset_ = header_->header_size_ + common_header_size;
  }
  return ret;
}


巨集塊的頭部結構的設計具有這些特點:

  • 簡潔高效的序列化(和反序列化)實現:header 大小基本上是固定的,僅依賴列的個數,換句話說只要固定列數,這個巨集塊的 header 大小就固定了,這給記憶體分配和序列化帶來了很大的便利;
  • 有很好的擴充套件性,主要體現在使用了version、巨集塊型別、預留欄位等方面;
  • 不同緯度的資料校驗:有位元組級別的 payload_checksum_,也有業務級別的 column_checksum。


2.2 巨集塊的 payload

巨集塊的 payload 就是多個微塊的資料,上一篇 微塊的儲存格式已經詳細介紹了,當然也可以參考下面的程式碼來看微塊的格式,本文就不再做詳細的說明了:

// src/storage/blocksstable/ob_micro_block_writer.h
// 下面是微塊在記憶體中以及持久化的儲存格式:
// memory
//  |- row data buffer
//        |- ObMicroBlockHeader
//        |- row data
//  |- row index buffer
//        |- ObRowIndex
//
// build output
//  |- compressed data
//        |- ObMicroBlockHeader
//        |- row data
//        |- RowIndex
class ObMicroBlockWriter : public ObIMicroBlockWriter {
public:
  virtual int append_row(const storage::ObStoreRow& row) override;
  virtual int build_block(char*& buf, int64_t& size) override;
  virtual void reuse() override;
  virtual int64_t get_block_size() const override;
  virtual int64_t get_row_count() const override;
  virtual int64_t get_data_size() const override;
  virtual int64_t get_column_count() const override;
  virtual common::ObString get_last_rowkey() const override;
  void reset();
};


2.3 巨集塊的 trailer

巨集塊的 trailer 記錄的主要是每個微塊的 index 資訊,但是其實不只是微塊的 index 資訊,包含了這些資訊:

  • 微塊在巨集塊中 offset 陣列:偏移量的個數為微塊數+1,前後兩個 offset 的差值是前一個微塊的長度;
  • 每個微塊的最大的 rowkey 資訊( endkey ),包括:endkey 的偏移量和 endkey 的資料。

另外,如果是多版本的巨集塊,trailer 中還包括了兩個和多版本相關的資訊:

  • can_mark_deletion:用來標記這個微塊是否可以標記刪除;
  • delta:用來記錄微塊中真正有效的行數,不包括被標記刪除的行數。

為什麼要單獨記錄微塊的index資訊(offset、length、endkey),最主要的原因就是能快速檢索指定 rowkey 所在的微塊,並能快速的將微塊單獨讀出來,而不需要讀取整個巨集塊。

巨集塊的 trailer 程式碼在下面:

// src/storage/blocksstable/ob_micro_block_index_writer.cpp
int ObMicroBlockIndexWriter::add_entry(
    const ObString& rowkey, const int64_t data_offset, bool can_mark_deletion, const int32_t delta)
{
  int ret = OB_SUCCESS;
  int32_t endkey_offset = static_cast<int32_t>(buffer_[ENDKEY_BUFFER_IDX].length());
  // 去除了一些引數檢查的的程式碼
if (OB_FAIL(buffer_[INDEX_BUFFER_IDX].write(static_cast<int32_t>(data_offset)))) {
    STORAGE_LOG(WARN, "index buffer fail to write data_offset.", K(ret), K(data_offset));
  } else if (OB_FAIL(buffer_[INDEX_BUFFER_IDX].write(endkey_offset))) {
    STORAGE_LOG(WARN, "index buffer fail to write endkey_offset.", K(ret), K(endkey_offset));
  } else if (OB_FAIL(buffer_[ENDKEY_BUFFER_IDX].write(rowkey.ptr(), rowkey.length()))) {
    STORAGE_LOG(WARN, "data buffer fail to writer rowkey.", K(ret), K(rowkey));
  } else if (is_multi_version_minor_merge_ &&
             OB_FAIL(buffer_[MARK_DELETE_BUFFER_IDX].write(static_cast<uint8_t>(can_mark_deletion)))) {
    STORAGE_LOG(WARN, "fail to write mark deletion", K(ret), K(can_mark_deletion));
  } else if (is_multi_version_minor_merge_ && OB_FAIL(buffer_[DELTA_BUFFER_IDX].write(delta))) {
    STORAGE_LOG(WARN, "failed to write delta", K(ret));
  } else {
    ++micro_block_cnt_;
  }
  return ret;
}


最終巨集塊的 trailer 會在 ObMacroBlock::flush 中序列化,序列化的實現可以參看 ObMacroBlock::build_index;

有了 trailer 中微塊的 index ,那麼就有兩種方式來讀取巨集塊中的各個微塊資料:

  • 順序讀取:主要用在 compaction 等場景,順序讀取各個微塊資料,進行合併、遷移等處理;
  • 隨機讀取:主要用在處理使用者請求時,根據 rowkey 快速讀取對應的微塊資料。


2.4 巨集塊的 padding

2MB的 OceanBase 巨集塊由於多種原因導致並不能寫滿,比如:資料量不夠,以及特意預留的10%的空間(用於後續的 insert 等,避免過多的巨集塊分裂)等,這個時候就需要做 padding 補齊2MB。本質上 padding 是空間浪費,但是為了效能以及簡化設計,padding 還是有必要的,想一下,沒有 padding 的巨集塊應該如何做,不外乎有兩種:

  • 使用不固定大小的巨集塊:這時我們需要記錄巨集塊的 offset 、length 等元資訊,對巨集塊的定位就需要多一個操作,效能會有一定的損失,同時也會提高設計的複雜度;
  • 每個巨集塊都滿載2MB:僅僅一個 insert 操作,可能會導致滿載的巨集塊分裂成兩個。

OceanBase 巨集塊 padding 並不是顯式的實現,每個巨集塊大小2MB是固定的,header 中記錄了巨集塊真正資料的大小(ObSSTableMacroBlockHeader.occupy_size_),其餘的都是 padding,OceanBase 並沒有對 padding 部分的資料進行補零等操作。


3. 巨集塊的操作

3.1 底層讀寫

底層對資料塊的讀寫主要靠繼承 class ObStorageFile 來實現,如下程式碼是該類對外的介面說明:

// src/storage/blocksstable/ob_store_file_system.h
class ObStorageFile {
public:
 ...
 // 非同步讀取巨集塊、微塊介面
 // 具體讀的是微塊還是巨集塊,通過read_info中offset_、size_指定
 // 該介面為非同步的,資料讀取成功後,會通過macro_handle通知呼叫者
 virtual int async_read_block(const ObMacroBlockReadInfo& read_info, ObMacroBlockHandle& macro_handle) = 0;
 // 非同步寫入巨集塊介面
 virtual int async_write_block(const ObMacroBlockWriteInfo& write_info, ObMacroBlockHandle& macro_handle) = 0;
 // 同步讀寫介面,一般是通過上面兩個非同步介面實現
 virtual int write_block(const ObMacroBlockWriteInfo& write_info, ObMacroBlockHandle& macro_handle) = 0;
 virtual int read_block(const ObMacroBlockReadInfo& read_info, ObMacroBlockHandle& macro_handle) = 0;
 ...
};
具體的讀寫介面的實現是在 ObStorageFile 的派生類 ObLocalStorageFile 中,可以參考下面程式碼瞭解其實現:
src/storage/blocksstable/ob_local_file_system.h。


3.2 巨集塊的寫

OceanBase 主要在 compaction、資料遷移複製等情況下才會有涉及巨集塊的寫入操作,使用者發起的寫入操作會直接寫 WAL ,不會直接觸發巨集塊的寫入操作。巨集塊和寫相關的基礎操作都在 class ObMacroBlock 中實現,主要的對外介面如下:


// src/storage/blocksstable/ob_macro_block.h
class ObMacroBlock {
public:
 // 初始化巨集塊結構,主要做一些初始化的操作:
 // 1. 呼叫 reserve_header,將巨集塊的header對映到buffer中,見本文2.1的程式碼說明
 // 2. 呼叫 init_row_reader,根據ObRowStoreType初始化行reader
 int init(ObDataStoreDesc& spec);
 // 將一個微塊append到該巨集塊中,主要就是將序列化好的微塊資料copy到該巨集塊buffer中,
 // 並更新該巨集塊header中的後設資料
 int write_micro_block(const ObMicroBlockDesc& micro_block_desc, int64_t& data_offset);
 // 對已經寫滿(或者不需要再寫)的巨集塊,進行刷盤操作,具體包括:
 // 1. 序列化common header;
 // 2. 構建header、trailer中的各種元資訊;
 // 3. 呼叫底層ObStorageFile::async_write_block,將資料非同步寫入到磁碟中;
 // 4. 巨集塊資料寫成功後,會通過macro_handle通知上層。
 int flush(const int64_t cur_macro_seq, ObMacroBlockHandle& macro_handle, ObMacroBlocksWriteCtx& block_write_ctx);
 // 合併兩個順序的巨集塊,在compaction結束時,檢查最後一個不滿的巨集塊能否和上一個巨集塊合併,
 // 如果上一個巨集塊空間夠用,則進行合併,這兩個巨集塊的資料是已經排好序的,
 // 這個介面僅被 ObMacroBlockWriter::close 呼叫。merge函式主要流程如下:
 // 1. 再次檢查當前巨集塊空間是否充足,不充足則報錯返回;
 // 2. 對最後一個巨集塊的微塊index追加到當前的微塊index中;
 // 3. 將最後一個巨集塊的微塊資料追加到當前微塊的buffer中;
 // 4. 更新當前巨集塊header中的後設資料。
 int merge(const ObMacroBlock& macro_block);
 // 和merge介面配合使用,主要是檢查當前巨集塊能否多容納一個巨集塊資料
 bool can_merge(const ObMacroBlock& macro_block);
 // 重置該巨集塊,主要用於複用該巨集塊物件
 void reset();
 ...
};



class ObMacroBlock 僅實現了一些巨集塊的基礎寫介面,關於 SSTable 的多個巨集塊的按序寫入是靠 class ObMacroBlockWriter 實現的,具體見下面的程式碼說明:


// src/storage/blocksstable/ob_macro_block_writer.cpp
class ObMacroBlockWriter {
public:
 // 根據data_store_desc中的table_id、partition_id等資訊,開啟一個巨集塊寫入器
 int open(ObDataStoreDesc& data_store_desc, const ObMacroDataSeq& start_seq,
   const ObIArray<ObMacroBlockInfoPair>* lob_blocks = NULL, ObMacroBlockWriter* index_writer = NULL);
 // 追加一個巨集塊,主要會應用在這些場景:
 // 1. 在合併時,原來的SSTable的某個巨集塊沒有修改,直接複用到當前SSTable中;
 // 2. 並行合併後,也可以用到這個介面,將多個沒有重合資料的巨集塊進行追加。
 int append_macro_block(const ObMacroBlockCtx& macro_block_ctx);
 // 追加一個微塊,和append_macro_block不同的是需要考慮是否存在資料重疊:
 // 1. 如果資料不重疊,則將micro_block追加到當前巨集塊中;
 // 2. 如果資料重疊,則需要構建micro_block的reader,將資料按row寫到當前巨集塊中。
 int append_micro_block(const ObMicroBlock& micro_block);
 // 追加一行資料,會呼叫ObMicroBlockWriter::append_row
 int append_row(const storage::ObStoreRow& row, const bool virtual_append = false);
 // 關閉ObMacroBlockWriter,在關閉之前,會嘗試將最後兩個巨集塊合併,節省空間,
 // 最後將當前最後的巨集塊flush到磁碟,並等待刷盤成功(wait_io_finish)
 int close(storage::ObStoreRow* root = NULL, char* root_buf = NULL);
};


在 class ObMacroBlockWriter 之上,又封裝了一層 class ObMacroBlockBuilder 專門用來做合併,關於這個類的實現,本文先不做過多說明,後續會在與“合併”有關的博文中詳細介紹,當然也可以通過直接閱讀原始碼瞭解其實現:src/storage/compaction/ob_partition_merge_builder.cpp。


3.2 巨集塊的讀

對於使用者請求,一般不會直接讀取整個巨集塊,而是先讀巨集塊的 index,再根據請求的過濾條件,最後精準的將某個微塊讀到記憶體,關於微塊的精確讀取,可以參考 struct ObMicroBlockDataHandle 的程式碼,搜尋一下上下的呼叫鏈路,即可瞭解其邏輯。OceanBase 中,在多種情況下,也會做完整巨集塊的讀取,包括:

  • 在做 compaction 時,會使用 ObMicroBlockIterator ,讀取完整的巨集塊資料,可以參考 class ObMicroBlockIterator 程式碼,關於合併的邏輯,後續會有專門的博文來介紹;
  • 在做資料遷移的時候也會涉及到完整巨集塊的讀取,詳細邏輯可以參考 class ObMigratePrepareTask 的實現;
  • ObMacroBlockWriter 將巨集塊資料寫成功後,會根據 MICRO_BLOCK_MERGE_VERIFY_LEVEL 的情況,有可能見巨集塊資料讀出來做檢查;
  • 在非同步構建 bloomfilter 的時候,也會將巨集塊資料按序讀出來,可以參考程式碼:ObBloomFilterBuildTask::build_bloom_filter、class ObSSTableRowWholeScanner;
  • 在做壞塊檢查時,也會有讀取全部巨集塊的情況,具體邏輯參考下面的程式碼說明;
  • 其他。
// src/storage/blocksstable/ob_store_file_system.h
// 壞塊檢查的定時任務
class ObFileSystemInspectBadBlockTask : public common::ObTimerTask {
public:
 // 定時任務基類的任務執行內容介面
 // 呼叫 inspect_bad_block
 virtual void runTimerTask();
private:
 // 對所有有效的巨集塊做壞塊檢查,主要流程為:
 // 1. 通過 ObPartitionService 初始化巨集塊的迭代器
 // 2. 根據 macro_iter,可以遍歷所有的巨集塊,
 //  呼叫下面的 check_macro_block 做巨集塊檢查
 void inspect_bad_block();
 // 做一些引數檢查後,對資料巨集塊做壞塊檢查
 // 通過呼叫下面 check_data_block 來做資料檢查
 int check_macro_block(const ObMacroBlockInfoPair& pair, const storage::ObTenantFileKey& file_key);
 // 將整個巨集塊的資料從磁碟讀出來
 // 使用 ObSSTableMacroBlockChecker::check_data_block 做具體的檢查
 int check_data_block(const MacroBlockId& macro_id, const blocksstable::ObFullMacroBlockMeta& full_meta,
   const storage::ObTenantFileKey& file_key);
 bool has_inited();
private:
 // 壞塊檢查任務是每個週期只做一部分,下面的引數記錄了斷點資訊
 int64_t last_partition_idx_;
 int64_t last_sstable_idx_;
 int64_t last_macro_idx_;
 // 資料檢查工具類,做巨集塊、微塊、列相關的checksum校驗
 ObSSTableMacroBlockChecker macro_checker_;
};

綜上,完整巨集塊的讀取主要發生在後臺非同步任務中,這是由於相比於16KB的微塊,讀取2MB巨集塊的開銷較大,在處理使用者的請求時,一般都是指定微塊進行讀取。


3.3 巨集塊的申請和釋放

OceanBase 中的基線資料存放在一個預分配好的大檔案(ob_dir/store/sstable/block_file)中,裡面的大部分割槽域存放的是2MB的巨集塊,通過後設資料可以區分出有效的巨集塊陣列和未使用的巨集塊陣列,巨集塊的申請和釋放就是基於這兩個陣列來做的,具體的程式碼可以先參考這兩個函式:ObStoreFile::alloc_block、ObStoreFile::free_block。後續會在“巨集塊 GC 原理”的博文中詳細介紹這部分邏輯。


4. 巨集塊儲存格式的 demo

可以通過下面的真實的一個巨集塊 demo,參考本文,能更好的理解巨集塊的儲存格式:

OceanBase 儲存層程式碼解讀(三)巨集塊儲存格式


5. 總結

閱讀了 OceanBase 有關微塊、巨集塊儲存格式的原始碼,對 OceanBase 這樣設計的初衷也有了更深的理解:

大體來說,微塊是為了更低的延時,巨集塊是為了更大的吞吐,分別應用在不同的場景上。後面我們會繼續解讀 OceanBase 儲存層的相關程式碼,與大家一起學習交流儲存技術。

————————————————


(未完待續)

附錄:前兩篇可參考

OceanBase 儲存層程式碼解讀(一)引言

OceanBase 儲存層程式碼解讀(二)微塊儲存格式


最後的最後,您有任何疑問都可以通過以下方式聯絡到我們~

聯絡我們

歡迎廣大 OceanBase 愛好者、使用者和客戶隨時與我們聯絡、反饋,方式如下:

社群版官網論壇

社群版專案網站提 Issue

釘釘群:33254054

OceanBase 儲存層程式碼解讀(三)巨集塊儲存格式


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

相關文章