6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rustでデータベースを自作する Part6: インデックスとクエリ最適化

Last updated at Posted at 2025-12-25

はじめに

Part 5でトランザクションとWALを実装した。

でも今のままだと、10万件のデータから1件探すのに10万件全部見る必要がある。遅い。

今回はインデックスクエリ最適化を実装して、大量データでも高速に検索できるようにする。

インデックスって何?

本の索引(index)と同じ。

本で「Rust」について書かれているページを探すとき、最初から全部読む?読まない。索引を見て「Rust → p.42, p.128」ってすぐ分かる。

データベースのインデックスも同じ。

インデックスなし: 全件スキャン O(n)
インデックスあり: ツリー探索 O(log n)

100万件のデータで、20回の比較で見つかる vs 100万回の比較。圧倒的。

B+Tree インデックス

データベースでよく使われるのはB+Tree。Part 1で作ったB-Treeの改良版:

  • 全てのデータはリーフノードにある
  • リーフノードがリンクリストでつながってる
  • 範囲検索が高速

今回は簡易版を実装:

/// B+Tree インデックス(簡易実装)
#[derive(Debug)]
pub struct BPlusTreeIndex {
    name: String,
    table_name: String,
    column_name: String,
    /// キー → 行IDのリスト
    entries: HashMap<IndexKey, Vec<u64>>,
    /// ソートされたキーリスト(範囲検索用)
    sorted_keys: Vec<IndexKey>,
    needs_sort: bool,
}

entriesでキーから行IDを引ける。同じキーに複数の行がある場合(例:年齢30の人が複数)、Vec<u64>で持つ。

キーの変換

値をバイト列に変換してキーにする:

fn value_to_key(value: &Value) -> IndexKey {
    match value {
        Value::Integer(i) => i.to_be_bytes().to_vec(),  // ビッグエンディアン
        Value::Real(f) => f.to_be_bytes().to_vec(),
        Value::Text(s) => s.as_bytes().to_vec(),
        Value::Boolean(b) => vec![if *b { 1 } else { 0 }],
        Value::Null => vec![],
    }
}

ビッグエンディアンを使うのがポイント。バイト列として辞書順ソートしたときに、数値としても正しい順序になる。

基本操作

impl BPlusTreeIndex {
    /// エントリを追加
    pub fn insert(&mut self, value: &Value, row_id: u64) {
        let key = Self::value_to_key(value);
        self.entries.entry(key).or_insert_with(Vec::new).push(row_id);
        self.needs_sort = true;
    }

    /// 完全一致検索
    pub fn lookup(&self, value: &Value) -> Option<&Vec<u64>> {
        let key = Self::value_to_key(value);
        self.entries.get(&key)
    }

    /// 範囲検索(開始キー以上)
    pub fn range_from(&mut self, from: &Value) -> Vec<u64> {
        self.ensure_sorted();
        let from_key = Self::value_to_key(from);
        
        let mut result = Vec::new();
        for key in &self.sorted_keys {
            if key >= &from_key {
                if let Some(ids) = self.entries.get(key) {
                    result.extend(ids);
                }
            }
        }
        result
    }
}

ensure_sorted()で必要なときだけキーをソートする遅延評価。

インデックスマネージャ

テーブルとカラムでインデックスを管理:

pub struct IndexManager {
    /// テーブル名 → カラム名 → インデックス
    indexes: HashMap<String, HashMap<String, BPlusTreeIndex>>,
}

impl IndexManager {
    /// インデックスを作成
    pub fn create_index(&mut self, name: &str, table: &str, column: &str) {
        let table_indexes = self.indexes.entry(table.to_string())
            .or_insert_with(HashMap::new);
        let index = BPlusTreeIndex::new(name, table, column);
        table_indexes.insert(column.to_string(), index);
    }

    /// インデックスを取得
    pub fn get_index(&self, table: &str, column: &str) -> Option<&BPlusTreeIndex> {
        self.indexes.get(table).and_then(|t| t.get(column))
    }
}

クエリプラン

オプティマイザが出力する実行計画:

/// クエリプラン
#[derive(Debug, Clone)]
pub enum QueryPlan {
    /// テーブルフルスキャン
    TableScan {
        table: String,
        filter: Option<Expression>,
    },
    /// インデックスシーク(完全一致)
    IndexSeek {
        table: String,
        index_column: String,
        value: Value,
        additional_filter: Option<Expression>,
    },
    /// インデックスレンジスキャン
    IndexRangeScan {
        table: String,
        index_column: String,
        from: Option<Value>,
        to: Option<Value>,
        additional_filter: Option<Expression>,
    },
}

3種類のプラン:

  1. TableScan: 全行読む(最悪)
  2. IndexSeek: インデックスで1件特定(最速)
  3. IndexRangeScan: インデックスで範囲検索

クエリオプティマイザ

SELECT文を解析して、最適なプランを選ぶ:

pub struct QueryOptimizer<'a> {
    index_manager: &'a IndexManager,
}

impl<'a> QueryOptimizer<'a> {
    /// SELECTクエリを最適化
    pub fn optimize_select(&self, stmt: &SelectStatement) -> QueryPlan {
        let table = &stmt.from;
        
        // WHERE句がなければフルスキャン
        let Some(ref where_clause) = stmt.where_clause else {
            return QueryPlan::TableScan {
                table: table.clone(),
                filter: None,
            };
        };

        // WHERE句を解析してインデックスを使えるか判断
        if let Some(plan) = self.try_use_index(table, where_clause) {
            return plan;
        }

        // インデックスが使えなければフルスキャン + フィルタ
        QueryPlan::TableScan {
            table: table.clone(),
            filter: Some(where_clause.clone()),
        }
    }
}

インデックス利用判定

fn try_use_index(&self, table: &str, expr: &Expression) -> Option<QueryPlan> {
    match expr {
        // column = value の形
        Expression::BinaryOp { left, op: BinaryOperator::Eq, right } => {
            if let (Expression::Column(col), Expression::Literal(val)) = 
                (left.as_ref(), right.as_ref()) 
            {
                // このカラムにインデックスがあるか?
                if self.index_manager.get_index(table, col).is_some() {
                    return Some(QueryPlan::IndexSeek {
                        table: table.to_string(),
                        index_column: col.clone(),
                        value: val.clone(),
                        additional_filter: None,
                    });
                }
            }
        }
        // column > value または column >= value
        Expression::BinaryOp { left, op, right } 
            if matches!(op, BinaryOperator::Gt | BinaryOperator::GtEq) =>
        {
            if let (Expression::Column(col), Expression::Literal(val)) = 
                (left.as_ref(), right.as_ref()) 
            {
                if self.index_manager.get_index(table, col).is_some() {
                    return Some(QueryPlan::IndexRangeScan {
                        table: table.to_string(),
                        index_column: col.clone(),
                        from: Some(val.clone()),
                        to: None,
                        additional_filter: None,
                    });
                }
            }
        }
        _ => {}
    }
    None
}

WHERE句の形を見て:

  • id = 1 → IndexSeek
  • age > 25 → IndexRangeScan
  • その他 → TableScan

コスト見積もり

複数のプランがあったとき、どっちが速いか判断するのがコスト見積もり:

pub fn estimate_cost(&self, plan: &QueryPlan, table_row_count: usize) -> Cost {
    match plan {
        QueryPlan::TableScan { filter, .. } => {
            // フルスキャンは全行読む
            let io_cost = table_row_count as f64;
            let cpu_cost = if filter.is_some() {
                table_row_count as f64 * 0.1
            } else {
                0.0
            };
            Cost { io_cost, cpu_cost, total: io_cost + cpu_cost }
        }
        QueryPlan::IndexSeek { .. } => {
            // インデックスシークは非常に高速(固定コスト)
            Cost { io_cost: 2.0, cpu_cost: 0.1, total: 2.1 }
        }
        QueryPlan::IndexRangeScan { .. } => {
            // 範囲スキャンは選択率に依存(仮に10%と仮定)
            let estimated_rows = (table_row_count as f64 * 0.1).max(1.0);
            let io_cost = estimated_rows + 2.0;
            let cpu_cost = estimated_rows * 0.05;
            Cost { io_cost, cpu_cost, total: io_cost + cpu_cost }
        }
    }
}

10,000行のテーブルだと:

  • TableScan: コスト約11,000
  • IndexSeek: コスト約2
  • IndexRangeScan: コスト約1,050

動かしてみる

$ cargo run
=== Query Optimizer デモ ===

--- Creating Indexes ---
Created index: idx_users_id on users(id)
Created index: idx_users_age on users(age)

--- Index Statistics ---
  idx_users_age: 5 unique keys, 5 entries
  idx_users_id: 5 unique keys, 5 entries

--- Query Plan Selection ---

Query: SELECT * FROM users WHERE id = 1
  Plan: INDEX SEEK on 'users.id' = Integer(1)
  Cost: 2.10 (IO: 2.00, CPU: 0.10)

Query: SELECT * FROM users WHERE age > 25
  Plan: INDEX RANGE SCAN on 'users.age': x >= Integer(25)
  Cost: 1052.00 (IO: 1002.00, CPU: 50.00)

Query: SELECT * FROM users WHERE name = 'Alice'
  Plan: TABLE SCAN on 'users' with filter
  Cost: 11000.00 (IO: 10000.00, CPU: 1000.00)

--- Index Lookup Demo ---
Lookup id=3: Some([2])
Range age >= 28: [3, 1, 2]

おおお:

  • id = 1: インデックスあり → IndexSeek(コスト2.1)
  • age > 25: インデックスあり → IndexRangeScan(コスト1052)
  • name = 'Alice': インデックスなし → TableScan(コスト11000)

5000倍以上のコスト差。これがインデックスの威力。

インデックスの統計情報

本格的なオプティマイザは統計情報を使う:

pub struct IndexStats {
    pub name: String,
    pub column: String,
    pub unique_keys: usize,    // ユニークなキーの数
    pub total_entries: usize,  // 全エントリ数
}
  • カーディナリティ: ユニークな値の数。高いほど選択性が良い
  • 選択率: 条件に合う行の割合。低いほどインデックスが有効

例えば:

  • gender列(男/女の2値): カーディナリティ低い → インデックス効果薄い
  • email列(ユニーク): カーディナリティ高い → インデックス効果大

実際のDBMSとの比較

PostgreSQLのEXPLAINを見てみよう:

EXPLAIN SELECT * FROM users WHERE id = 1;
-- Index Scan using users_pkey on users  (cost=0.15..8.17 rows=1 width=...)

EXPLAIN SELECT * FROM users WHERE name = 'Alice';
-- Seq Scan on users  (cost=0.00..35.00 rows=1 width=...)
--   Filter: (name = 'Alice'::text)

今回の実装と似たような出力が出る。

本格的なオプティマイザへの道

今回の実装は簡易版。本格的にはもっと色々必要:

1. 複合インデックス

CREATE INDEX idx_name_age ON users(name, age);

複数カラムでインデックスを作る。

2. カバリングインデックス

CREATE INDEX idx_covering ON users(id) INCLUDE (name, age);

インデックスだけで結果を返せる(テーブルにアクセスしない)。

3. ジョイン最適化

SELECT * FROM users u JOIN orders o ON u.id = o.user_id;
  • Nested Loop Join
  • Hash Join
  • Merge Join

どれを使うか決める。

4. 統計情報の自動収集

PostgreSQLのANALYZEやMySQLのANALYZE TABLEのように、定期的にデータ分布を収集。

5. プランキャッシュ

同じクエリを何度も最適化するのは無駄。プランをキャッシュする。

まとめ

Part 6では:

  1. B+Treeインデックス: キーから行IDを高速に引ける
  2. インデックスマネージャ: テーブル×カラムでインデックスを管理
  3. クエリプラン: TableScan/IndexSeek/IndexRangeScan
  4. クエリオプティマイザ: WHERE句を解析して最適プランを選択
  5. コスト見積もり: IO/CPUコストで比較

これで全6回のシリーズ完結!

作ったもの:

  • Part 1: B-Tree
  • Part 2: ページ管理とバッファプール
  • Part 3: SQLパーサー
  • Part 4: クエリ実行エンジン
  • Part 5: トランザクションとWAL
  • Part 6: インデックスとクエリ最適化

フルスクラッチでデータベースの基本要素が揃った。もちろん本物のPostgreSQLやMySQLに比べたら全然機能足りないけど、仕組みを理解するにはこれで十分。

6
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?