作者:公祺,一个专注于 OBKV 的程序员
1.宏块的概述
在上一篇微块的存储格式中已经介绍了微块和宏块的关系,宏块是处于 SSTable 和微块之间的数据结构,OceanBase 中的宏块为2MB的定长数据块。众所周知,OceanBase 中微块是读 IO 最小单元,这是因为微块读处在用户请求的关键路径上,为保证快速响应用户的请求,微块不能过大,所以微块的默认大小一般不超过16KB;而宏块作为写 IO 的最小单元,它的读写不在用户请求的关键路径上,所以就有了2MB的宏块,目的是为了最大限度的发挥磁盘的吞吐性能,能快速的做 compaction 、迁移复制、坏块检查等操作。宏块的简单结构可以参考下图,详细的宏块格式介绍见下一节:
注:本文所有的说明及代码都是基于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
宏块的头部记录的自然就是宏块的元数据,它由多个部分组成,如下图所示:
宏块头部的各个部分存储的是不同的元数据,具体含义如下:
- 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,参考本文,能更好的理解宏块的存储格式:
5. 总结
阅读了 OceanBase 有关微块、宏块存储格式的源码,对 OceanBase 这样设计的初衷也有了更深的理解:
大体来说,微块是为了更低的延时,宏块是为了更大的吞吐,分别应用在不同的场景上。后面我们会继续解读 OceanBase 存储层的相关代码,与大家一起学习交流存储技术。
————————————————
(未完待续)
附录:前两篇可参考
OceanBase 存储层代码解读(一)引言
OceanBase 存储层代码解读(二)微块存储格式
最后的最后,您有任何疑问都可以通过以下方式联系到我们~
联系我们
欢迎广大 OceanBase 爱好者、用户和客户随时与我们联系、反馈,方式如下:
社区版官网论坛
社区版项目网站提 Issue
钉钉群:33254054