OceanBase 原始碼解讀(九):儲存層程式碼解讀之「巨集塊儲存格式」
此前,帶你讀原始碼第八篇《事務日誌的提交和回放》,為大家介紹了日誌模組的設計理念和日誌的一生。本期“原始碼解讀”由資料庫技術專家公祺為大家帶來“儲存層程式碼解讀之「巨集塊儲存格式」”。
“巨集塊”是處於 SSTable 和微塊之間的資料結構,OceanBase 中的巨集塊為 2MB 的定長資料塊。
眾所周知,OceanBase 中微塊是讀 IO 最小單元,這是因為微塊讀處在使用者請求的關鍵路徑上,為保證快速響應使用者的請求,微塊不能過大,所以微塊的預設大小一般不超過 16KB;而巨集塊作為寫 IO 的最小單元,它的讀寫不在使用者請求的關鍵路徑上,所以就有了 2MB 的巨集塊,目的是為了最大限度的發揮磁碟的吞吐效能,能快速的做壓縮、遷移複製、壞塊檢查等操作。
巨集塊的簡單結構可以參考下圖,詳細的巨集塊格式介紹見下一節:
-
注:本文所有的說明及程式碼都是基於 v3.1.0_CE_BP1 版本的 OceanBase 開原始碼。
巨集塊的格式
目前 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。
後面我們將針對不同的部分,一一介紹其結構的儲存格式。
一、巨集塊的 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。
二、巨集塊的 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(); };
三、巨集塊的 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 ,那麼就有兩種方式來讀取巨集塊中的各個微塊資料:
順序讀取:主要用在壓縮等場景,順序讀取各個微塊資料,進行合併、遷移等處理;
隨機讀取:主要用在處理使用者請求時,根據 rowkey 快速讀取對應的微塊資料。
四、巨集塊的 padding
2MB 的 OceanBase 巨集塊由於多種原因導致並不能寫滿,比如:資料量不夠,以及特意預留的 10% 的空間(用於後續的 insert 等,避免過多的巨集塊分裂)等,這個時候就需要做 padding 補齊 2MB。本質上 padding 是空間浪費,但是為了效能以及簡化設計,padding 還是有必要的。
沒有 padding 的巨集塊應該如何做,不外乎有兩種:
使用不固定大小的巨集塊:這時我們需要記錄巨集塊的 offset 、length 等元資訊,對巨集塊的定位就需要多一個操作,效能會有一定的損失,同時也會提高設計的複雜度;
每個巨集塊都滿載 2MB:僅僅一個 insert 操作,可能會導致滿載的巨集塊分裂成兩個。
OceanBase 巨集塊 padding 並不是顯式的實現,每個巨集塊大小2MB是固定的,header 中記錄了巨集塊真正資料的大小,其餘的都是 padding,OceanBase 並沒有對 padding 部分的資料進行補零等操作。
巨集塊的操作
底層對資料塊的讀寫主要靠繼承 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。
一、巨集塊的寫
OceanBase 主要在壓縮、資料遷移複製等情況下才會有涉及巨集塊的寫入操作,使用者發起的寫入操作會直接寫 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 專門用來做合併,關於這個類的實現,本文先不做過多說明,後續會在與“合併”有關的博文中詳細介紹,敬請關注後續推送。
二、巨集塊的讀
對於使用者請求,一般不會直接讀取整個巨集塊,而是先讀巨集塊的 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 巨集塊的開銷較大,在處理使用者的請求時,一般都是指定微塊進行讀取。
三、巨集塊的申請和釋放
OceanBase 中的基線資料存放在一個預分配好的大檔案(ob_dir/store/sstable/block_file)中,裡面的大部分割槽域存放的是 2MB 的巨集塊,通過後設資料可以區分出有效的巨集塊陣列和未使用的巨集塊陣列,巨集塊的申請和釋放就是基於這兩個陣列來做的。
具體的程式碼可以先參考這兩個函式:ObStoreFile::alloc_block、ObStoreFile::free_block。後續會在“巨集塊 GC 原理”的博文中詳細介紹這部分邏輯。
巨集塊儲存格式的 demo
下面是一個真實的巨集塊 demo,參考本文,大家能更好地理解巨集塊的儲存格式:
相信大家閱讀了 OceanBase 有關微塊、巨集塊儲存格式的原始碼,會對 OceanBase 這樣的設計初衷有更深的理解:大體來說,微塊是為了更低的延時,巨集塊是為了更大的吞吐,分別應用在不同的場景上。
後續我們會繼續解讀 OceanBase 儲存層的相關程式碼,與大家一起學習交流儲存技術,敬請關注!
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69909943/viewspace-2884015/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- OceanBase 儲存層程式碼解讀(三)巨集塊儲存格式
- OceanBase 儲存層程式碼解讀(二)微塊儲存格式
- OceanBase 儲存層程式碼解讀(一)引言
- OceanBase儲存層程式碼解讀(四):宏塊的垃圾回收和壞塊檢查
- Appdash原始碼閱讀——Store儲存APP原始碼
- TiFlash 原始碼閱讀(一) TiFlash 儲存層概覽原始碼
- OceanBase 儲存引擎詳解儲存引擎
- 大資料儲存平臺之異構儲存實踐深度解讀大資料
- 分散式儲存技術解讀系列之GFS分散式
- Spark原始碼編譯支援Parquet儲存格式Spark原始碼編譯
- Ceph分散式儲存技術解讀分散式
- Tair持久儲存系列技術解讀AI
- [轉帖]OceanBase 儲存引擎詳解儲存引擎
- Python中檔案讀取與儲存程式碼示例Python
- opentracing-go原始碼閱讀——Log儲存(完結篇)Go原始碼
- 塊儲存 檔案儲存 物件儲存物件
- 臨時儲存程式碼
- Laravel 儲存 (Storage) 原始碼分析Laravel原始碼
- 【ESSD技術解讀】阿里雲塊儲存企業級特性之非同步複製阿里非同步
- 只讀儲存器ROM
- OceanBase 原始碼解讀(十二):宏塊的垃圾回收和壞塊檢查原始碼
- DeepSort之原始碼解讀原始碼
- vscode使用stylelint儲存自動格式化程式碼VSCode
- 開源資料庫OceanBase原始碼解讀(九):tableAPI和OB多模型資料庫原始碼API模型
- Fabric 1.0原始碼分析(2) blockfile(區塊檔案儲存)原始碼BloC
- 讀取和儲存Excel表Excel
- React原始碼解讀之setStateReact原始碼
- React原始碼解讀之componentMountReact原始碼
- 物件儲存 vs 檔案儲存 vs 塊儲存,選哪個?物件
- 資料儲存(歸檔解檔,沙河儲存)
- spark 原始碼分析之十八 -- Spark儲存體系剖析Spark原始碼
- [原始碼分析] Dynomite 分散式儲存引擎 之 DynoJedisClient(1)原始碼MIT分散式儲存引擎client
- [原始碼分析] Dynomite 分散式儲存引擎 之 DynoJedisClient(2)原始碼MIT分散式儲存引擎client
- 【原始碼解讀(一)】EFCORE原始碼解讀之建立DBContext查詢攔截原始碼Context
- 解讀記憶體資料庫的儲存需求RC記憶體資料庫
- 雲原生儲存詳解:容器儲存與 K8s 儲存卷K8S
- Spark 儲存模組原始碼學習Spark原始碼
- Derek解讀Bytom原始碼-孤塊管理原始碼