OceanBase 存储层代码解读(三)宏块存储格式

2024年 5月 7日 45.6k 0

OceanBase 存储层代码解读(三)宏块存储格式-1

作者:公祺,一个专注于 OBKV 的程序员

1.宏块的概述

在上一篇微块的存储格式中已经介绍了微块和宏块的关系,宏块是处于 SSTable 和微块之间的数据结构,OceanBase 中的宏块为2MB的定长数据块。众所周知,OceanBase 中微块是读 IO 最小单元,这是因为微块读处在用户请求的关键路径上,为保证快速响应用户的请求,微块不能过大,所以微块的默认大小一般不超过16KB;而宏块作为写 IO 的最小单元,它的读写不在用户请求的关键路径上,所以就有了2MB的宏块,目的是为了最大限度的发挥磁盘的吞吐性能,能快速的做 compaction 、迁移复制、坏块检查等操作。宏块的简单结构可以参考下图,详细的宏块格式介绍见下一节:

OceanBase 存储层代码解读(三)宏块存储格式-2

注:本文所有的说明及代码都是基于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 存储层代码解读(三)宏块存储格式-3

宏块头部的各个部分存储的是不同的元数据,具体含义如下:

  • 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 存储层代码解读(三)宏块存储格式-4

5. 总结

阅读了 OceanBase 有关微块、宏块存储格式的源码,对 OceanBase 这样设计的初衷也有了更深的理解:

大体来说,微块是为了更低的延时,宏块是为了更大的吞吐,分别应用在不同的场景上。后面我们会继续解读 OceanBase 存储层的相关代码,与大家一起学习交流存储技术。

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

(未完待续)

附录:前两篇可参考

OceanBase 存储层代码解读(一)引言

OceanBase 存储层代码解读(二)微块存储格式

最后的最后,您有任何疑问都可以通过以下方式联系到我们~

联系我们

欢迎广大 OceanBase 爱好者、用户和客户随时与我们联系、反馈,方式如下:

社区版官网论坛

社区版项目网站提 Issue

钉钉群:33254054

OceanBase 存储层代码解读(三)宏块存储格式-5

相关文章

Oracle如何使用授予和撤销权限的语法和示例
Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
社区版oceanbase安装
Oracle 导出CSV工具-sqluldr2
ETL数据集成丨快速将MySQL数据迁移至Doris数据库

发布评论