I.はじめに
前回の記事では、データベースの持続性が実現されました.これは質的な飛躍です.コードは複雑ではありませんが,この分野の経験がない人にとっては興味深い.次のステップは別の飛躍を完了することです.格納されるデータ構造はツリー形式のB+セーブを採用。変換の前に、まだいくつかの準備作業があり、1 つはコードを変更してカーソルの概念を導入すること、もう 1 つはコード開発段階に入る前に B+ ツリーの構造と原理を整理することです。
2 つのカーソル カーソル
カーソルの概念は、抽象的に行へのポインターと見なすことができ、それを介して挿入またはクエリを実行したり、次の行に移動したりできます。定義構造は次のとおりです。
typedef struct {
Table* table;
uint32_t row_num;
bool end_of_table; // Indicates a position one past the last element
} Cursor;
定義内容は比較的単純で、カーソルが属するテーブル、属する行番号、テーブルの最後かどうか、トラバースするときに使用、テーブルの最後でクエリを終了するなどです。
カーソルを定義した後, 最初のアイデアはカーソルを作成する方法かもしれません. カーソルはトラバーサルに使用できるので, 一部の言語のコレクションをトラバースするときに Iter のようなものも定義します. カーソルと同様に, 一般にコレクション( )行番号の作成、作成は最初の要素を指し、中のカーソルも同様で、作成の開始は最初のカーソルを指し、終了は最後の要素を指します。
Cursor* table_start(Table* table) {
Cursor* cursor = ( Cursor* )malloc(sizeof(Cursor));
cursor->table = table;
cursor->row_num = 0;
cursor->end_of_table = (table->num_rows == 0);
return cursor;
}
Cursor* table_end(Table* table) {
Cursor* cursor =( Cursor* ) malloc(sizeof(Cursor));
cursor->table = table;
cursor->row_num = table->num_rows;
cursor->end_of_table = true;
return cursor;
}
この定義により、元のコア行数である行を見つける操作を簡素化できます。
void* row_slot(Table* table, uint32_t row_num)
カーソルのみのフォームに変更できます。
void* cursor_value(Cursor* cursor)
内部のコンテンツも、データ構造に従って調整する必要があります。
void *row_slot(Cursor* cursor)
{
// 行号通过游标获取
uint32_t row_num = cursor->row_num;
uint32_t page_num = row_num / ROWS_PER_PAGE;
// 页面参数通过游标获取到table后再获取pager
void *page = get_page(cursor->table->pager, page_num);
uint32_t row_offset = row_num % ROWS_PER_PAGE;
uint32_t byte_offset = row_offset * ROW_SIZE;
return (char *)page + byte_offset;
}
カーソルのインクリメントについては、次の行で実現できます。
void cursor_advance(Cursor* cursor) {
cursor->row_num += 1;
// 如果行数达到了表的最大行数,设置游标的表结束标志
// 循环时候可以根据这个判断来决定是否结束
if (cursor->row_num >= cursor->table->num_rows) {
cursor->end_of_table = true;
}
}
元のコア コードの実行ロジックも変更する必要があります. クエリ ループは、元は行数に応じてトラバースされましたが、現在はカーソルに変更されています. カーソルを使用してレイヤーを抽象化する理由は、次のことができるからです.カーソルを介して次の B+ ツリーをカプセル化します. トラバーサル, そして私たちの元のトラバーサルはテーブルの行にしっかりとバインドされています, これには変更が容易な部分を抽出する必要も含まれます. 全体のアーキテクチャは抽象的な実装に依存します.抽象的な具体的な実装は、必要に応じて柔軟に変更できますが、上位構造は影響を受けません。
ExecuteResult execute_select(Statement *statement, Table *table)
{
Row row;
Cursor *cursor = table_start(table);
// for (uint32_t i = 0; i < table->num_rows; i++) {
// deserialize_row(row_slot(table, i), &row);
// print_row(&row);
// }
// 直到游标是最后标记则结束
while (!(cursor->end_of_table)) {
deserialize_row(cursor_value(cursor), &row);
print_row(&row);
// 游标向下移动一行
cursor_advance(cursor);
}
return EXECUTE_SUCCESS;
}
挿入操作も変更する必要があります. まず, 挿入する行を取得し, 次にテーブルの最後を指すカーソルを定義します. なぜテーブルの最後にあるカーソルを指す必要があるのでしょうか.実際には追加であり、最後のページに直接追加します。次に、cursor_value を介してカーソル位置のメモリ ページを取得し、テーブルの行をカーソル位置のメモリ ページに永続化します。
ExecuteResult execute_insert(Statement *statement, Table *table)
{
if (table->num_rows >= TABLE_MAX_ROWS) {
return EXECUTE_TABLE_FULL;
}
Row *row_to_insert = &(statement->row_to_insert);
Cursor *cursor = table_end(table);
serialize_row(row_to_insert, cursor_value(cursor));
table->num_rows += 1;
free(cursor);
return EXECUTE_SUCCESS;
}
カーソル位置のメモリを取得する操作は次のようになります. 元のものと同様です. 行番号からどのページに属しているかを特定し, 行番号からオフセットを特定します. 注意深く見ると,ここでの配置は最後の行に基づいています. はい, それは最後の行の次の行であってはなりません. 行のオフセットは 0 から始まるので, 実際にはここで問題はありません. 例えば, 1行しかない場合オフセットには実際にはデータがなく、この空き位置にデータを挿入しても問題ありません。
void *cursor_value(Cursor *cursor)
{
uint32_t row_num = cursor->row_num;
uint32_t page_num = row_num / ROWS_PER_PAGE;
void *page = get_page(cursor->table->pager, page_num);
uint32_t row_offset = row_num % ROWS_PER_PAGE;
uint32_t byte_offset = row_offset * ROW_SIZE;
return (char *)page + byte_offset;
}
カーソル抽象化後のテスト:
[root@localhost microdb]# ./a.out db.mb
microdb > select
(1,a,[email protected])
(2,b,[email protected])
(1,a,[email protected])
(4,rrr,[email protected])
(5,ttt,[email protected])
(6,d,[email protected])
(7,f,[email protected])
(8,g,[email protected])
(10,q,[email protected])
Executed.
microdb > insert 11 dd [email protected]
Executed.
microdb > select
(1,a,[email protected])
(2,b,[email protected])
(1,a,[email protected])
(4,rrr,[email protected])
(5,ttt,[email protected])
(6,d,[email protected])
(7,f,[email protected])
(8,g,[email protected])
(10,q,[email protected])
(11,dd,[email protected])
Executed.
microdb > .exit
[root@localhost microdb]# ./a.out db.mb
microdb > select
(1,a,[email protected])
(2,b,[email protected])
(1,a,[email protected])
(4,rrr,[email protected])
(5,ttt,[email protected])
(6,d,[email protected])
(7,f,[email protected])
(8,g,[email protected])
(10,q,[email protected])
(11,dd,[email protected])
Executed.
microdb > quit
Unrecognized keyword at start of 'quit'.
microdb > .exit
トリプル B+ ツリー
3.1 検索
私が理解している検索とは、大量のデータから必要なデータを抽出することです。例えば、図書館で本を検索する、本の中の特定のキーワードを検索するなど、次にどのようにすばやく検索するか、すべてのステップが核心です。検索のデータセットをすばやく削減できます.各クエリステップで検索セットを半分に削減できる場合、それは典型的な二分検索であることを注意深く理解してから、上で設計したデータベースなどのデータの束を提供してください.検索するとき、データと場所の間に関係がないため、クエリの比較を行ごとにトラバースすることしかできません。ただし、このようなパフォーマンスは行数に正比例します。つまり、行数が多いほど、必要な平均クエリ時間が長くなります。
この問題を解決する方法はありますか?次に、一連のデータを使用して、メモリ内のテーブル データを単純にシミュレートします.この一連のデータをすばやく検索したい場合、データが大きい場合、データが並べ替えられた配列で構成されている場合、 we 二分法により、必要なデータをすばやく見つけることができます。ただし、配列を使用してこのデータセットを格納する場合、データを挿入するときに挿入位置の要素を後方に移動する必要があるため、挿入時に後続の要素を 1 ビット後方に移動する必要があります。同様に、削除操作を行う場合、削除された位置の後ろのデータを前方に移動して、削除された穴を埋める必要があります。
上記の説明から、ソートされたデータが配列に格納されている場合、クエリの速度は高速ですが、挿入と削除が遅すぎます。しかし、挿入と削除を素早く行いたい場合、簡単に考えられるデータ構造は連結リストです. 連結リストの挿入と削除は、すべてポインタを変更するものです. 比較的単純ですが、トラバースするときに、リンクされたリストに沿って 1 つずつ確認するだけで、データ セットのサイズにも関係します。
さて、ソートされた配列が二分法に従ってデータを検索するシナリオを考えてみましょう。検索するデータセットは毎回半分に減らすことができるので、単一の連結リストを変換して、ソートされた連結リストをから持ち上げることができますか?中央の値、中央の値をルート ノードとして使用し、ルート ノードより小さい値は左側の連結リストに配置され、ルート値より大きい値は右側の連結リストに配置されます。つまり、二分木になり、完全な二分木になり、挿入と削除のパフォーマンスは非常に高く、クエリを実行するときのパフォーマンスは依然として高いです(完全な二分木の挿入はバランスをとる必要があり、これもパフォーマンスに影響するため、赤黒挿入、削除、およびクエリのバランスが取れたツリーおよびその他のデータ構造が設計されています)。
二分木のデータ構造はメモリにデータを保存するのに非常に適していますが、ディスク上のデータとして保存すると、多くの問題が発生します. 二分木の各ノードには、最大で2つのサブツリーがあります.が大きいと, ツリー全体の高さが比較的高くなります. その後, より多くのポインタが必要になります. ポインタがページを指すたびに, ディスクから N ページを超えるページを読み取る必要があります.データを検索します。
3.2 BツリーとB+ツリー
B ツリー構造は、バイナリ ツリーの進化と見なすことができます.ディスク ページのサイズは一般に現在 4KB であり、このページは多くの行のデータを格納できるため、ポインターは毎回ページを指します。ツリー構造は 1 つのページに格納できます。ページには複数のフォークがあり、ページには多くのデータを保持できます。
一部のデータ ストレージは B+ ツリーであり、一部は B ツリーです。次の表に示すように、2 つの違いは何ですか。
B+ ツリーはテーブル データの格納に使用されます. Mysql の InnoDB エンジンはストレージに B+ ツリーを使用します. インデックスと同じ数の B+ ツリーがあります. データは主キーの B+ ツリーに格納されます. B+ ツリーの内部ノードのみ内部ノードはキーのみを格納するため、ノードはより多くのデータを格納できますが、B ツリー ノードはキーと値の両方を格納するため、ページはより少ないデータを格納できます。
B ツリーの内部ノードとリーフ ノードは同じデータ構造を持っていますが、B+ ツリーは異なります. B+ ツリーの内部ノードはキーとポインター データを格納し、リーフ ノードは完全な複数行データです.
また、B+ ツリーと B ツリーのノードは一般にディスク ページに対応し、ページ内のデータは順序付けられていることに注意してください。検索するときは、二分法で検索できます。
3.3mオーダーオーダーツリー
先ほど、B ツリーまたは B+ ツリーは N 個を超える子ノードを持つことができ、ノード内のデータは順序付けられると説明しました.これを N オーダー ソート ツリーと呼ぶことができます。このツリーの内部ノードとリーフ ノードは異なり、具体的な違いは次のとおりです。
内部ノードはキーと子ノードへのポインタを格納し、リーフ ノードはキーと値を格納します
内部ノードに格納されるキーの最大数は m-1 で、リーフ ノードに格納できるキーの数
内部ノードによって保存されるポインターの数は、キーの数 + 1 ですが、リーフ ノードはポインターを保存しません。
内部ノードにキーを格納する目的はルーティングであり、リーフ ノードにキーを格納する目的はキーを値に関連付けることです。
内部ノードは値を格納しませんが、リーフ ノードは値データを格納します。
3.4 三次ソート木の挿入過程
上記の定義に従って、3 次ソート木の挿入過程をシミュレートする図を描いてみましょう。3 次ソート木には次の制約があります。
各内部ノードは、最大 3 つの子ノードを保持します。
各内部ノードには、最大 2 つのキーが格納されます。
各内部ノードには、少なくとも 2 つの子ノードがあります。
すべての内部ノードには、少なくとも 1 つのキーがあります。
最初は空のツリー:
この空のツリーは、データを格納しないリーフ ノードとして直接使用されます。
次のように 2 つのデータを挿入します。データは 3 つのデータの範囲を超えません。2 つのデータをこのノードに直接保存します。
ノードが 2 つのデータしか格納できない場合、上記のデータ構造に別のデータを挿入するとどうなるでしょうか? 分割することしかできません: データを挿入した後は、保存するのに十分ではないため、分割することしかできません。上記の葉ノードを分割し、ルート ノードと 2 つの葉ノードに分割すると、データは 2 つの葉ノードに分割され、ルート ノードには 2 つのポインタとキーが格納されます。クエリを実行すると、5 以下の場合は左側の子ノードが検索され、5 以上の場合は右側の子ノードに移動します。
key2を挿入した後、検索ルートに従ってリーフノードを見つけますが、リーフノードがいっぱいであるため、リーフノードを分割し、元のkey5を新しいリーフノードに分割し、キー2の新しいエントリを作成することしかできません.
続けて 18 と 21 の 2 つのデータを挿入すると、ツリーの構造は次のように変化します。ルート ノードに移動しますが、ルート ノードもいっぱいになっている場合は、ルート ノードを再度分割して、B+ ツリーの高さを増やす必要があります。
B+ ツリーの後続のデータ構造については、次の章で説明します。