7
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?

シリーズ一覧

Part タイトル 内容
1 B-Treeって何?データ構造から理解する B-Treeの実装
2 ページ管理とバッファプール ← 今ここ
3 SQLパーサーを書く SQL文を解析する
4 クエリ実行エンジン 実行計画と処理
5 トランザクションとWAL ACID特性の実装
6 インデックスとクエリ最適化 高速化の仕組み

はじめに

前回はB-Treeを実装した。でもあれはメモリ上で動くだけ。

本物のデータベースはディスクにデータを保存する必要がある。

でもディスクI/Oはめちゃくちゃ遅い...

じゃあどうする?

バッファプールでキャッシュする!

今回はデータベースのストレージ層、特に「ページ」と「バッファプール」を実装していく。

ページとは

データベースはページ単位で管理する

ディスクから1バイトずつ読み書きするのは効率が悪い。

だからデータベースは固定サイズのページ単位でI/Oする。

┌─────────────────────────────────────────────────┐
│                データベースファイル                │
├────────┬────────┬────────┬────────┬────────────┤
│ Page 0 │ Page 1 │ Page 2 │ Page 3 │    ...     │
│ 4KB    │ 4KB    │ 4KB    │ 4KB    │            │
└────────┴────────┴────────┴────────┴────────────┘

一般的なページサイズ:

  • SQLite: 4KB(デフォルト)
  • PostgreSQL: 8KB
  • MySQL InnoDB: 16KB

ページの構造

/// ページサイズ(4KB)
pub const PAGE_SIZE: usize = 4096;

/// ページ
pub struct Page {
    /// ページID
    pub id: PageId,
    /// ページデータ(固定サイズのバイト配列)
    pub data: [u8; PAGE_SIZE],
    /// ダーティフラグ(変更されたかどうか)
    pub is_dirty: bool,
    /// ピンカウント(このページを使用中のトランザクション数)
    pub pin_count: u32,
}

重要なフィールド:

  • is_dirty: 変更されたかどうか。ディスクに書き戻す必要があるか判断
  • pin_count: 使用中の数。0でないとメモリから追い出せない

ページヘッダー

各ページの先頭にはメタデータを格納する「ヘッダー」がある。

ページ構造:
┌──────────────────────────────────────────┐
│ Header (16 bytes)                        │
│ ┌──────────┬──────────┬────────────────┐ │
│ │ PageId   │ Type     │ Record Count   │ │
│ │ (4 bytes)│ (1 byte) │ (2 bytes) etc  │ │
│ └──────────┴──────────┴────────────────┘ │
├──────────────────────────────────────────┤
│ Data (4080 bytes)                        │
│                                          │
│       実際のキーや値が入る               │
│                                          │
└──────────────────────────────────────────┘
impl Page {
    /// ページIDをヘッダーから読み取る
    pub fn get_page_id(&self) -> PageId {
        u32::from_le_bytes([self.data[0], self.data[1], self.data[2], self.data[3]])
    }

    /// ページIDをヘッダーに書き込む
    pub fn set_page_id(&mut self, id: PageId) {
        let bytes = id.to_le_bytes();
        self.data[0..4].copy_from_slice(&bytes);
        self.id = id;
    }

    /// 特定のオフセットにデータを書き込む
    pub fn write_bytes(&mut self, offset: usize, data: &[u8]) {
        self.data[offset..offset + data.len()].copy_from_slice(data);
        self.is_dirty = true;
    }
}

ディスクマネージャー

ページとファイルの対応を管理する。

/// ディスクマネージャー
pub struct DiskManager {
    /// データベースファイル
    file: File,
    /// 次に割り当てるページID
    next_page_id: PageId,
}

ページの読み書き

impl DiskManager {
    /// ページをディスクから読み込む
    pub fn read_page(&mut self, page_id: PageId, page: &mut Page) -> std::io::Result<()> {
        // ページID × ページサイズ = ファイル内オフセット
        let offset = page_id as u64 * PAGE_SIZE as u64;
        self.file.seek(SeekFrom::Start(offset))?;
        self.file.read_exact(&mut page.data)?;
        page.id = page_id;
        Ok(())
    }

    /// ページをディスクに書き込む
    pub fn write_page(&mut self, page: &Page) -> std::io::Result<()> {
        let offset = page.id as u64 * PAGE_SIZE as u64;
        self.file.seek(SeekFrom::Start(offset))?;
        self.file.write_all(&page.data)?;
        self.file.flush()?;
        Ok(())
    }
}

シンプルな対応:

  • Page 0 → ファイルの 0〜4095 バイト目
  • Page 1 → ファイルの 4096〜8191 バイト目
  • Page N → ファイルの N×4096 バイト目から

バッファプールとは

なぜバッファプールが必要か

毎回ディスクから読み書きすると遅すぎる。

メモリアクセス: ~100ns
ディスクアクセス: ~10,000,000ns (10ms)

約10万倍の差!

だからよく使うページをメモリにキャッシュする。これがバッファプール。

┌───────────────────────────────────────────┐
│              Buffer Pool                  │
│  ┌────────┬────────┬────────┬────────┐   │
│  │ Frame  │ Frame  │ Frame  │ Frame  │   │
│  │   0    │   1    │   2    │   3    │   │
│  │ Page 5 │ Page 2 │ Page 0 │ (empty)│   │
│  └────────┴────────┴────────┴────────┘   │
│                    ↑↓                     │
└────────────────────┼─────────────────────┘
                     │
                     ↓
┌───────────────────────────────────────────┐
│            Disk (Database File)           │
│  Page 0  Page 1  Page 2  Page 3  ...      │
└───────────────────────────────────────────┘

バッファプールの構造

pub struct BufferPool {
    /// ページID -> バッファ内インデックスのマッピング
    page_table: HashMap<PageId, usize>,
    /// ページバッファ(固定サイズ)
    pages: Vec<Page>,
    /// 空きフレームのリスト
    free_list: VecDeque<usize>,
    /// LRUリスト(最近使われた順)
    lru_list: VecDeque<usize>,
    /// ディスクマネージャー
    disk_manager: DiskManager,
}

主要な操作

1. ページの取得(fetch_page)

pub fn fetch_page(&mut self, page_id: PageId) -> Option<&mut Page> {
    // すでにバッファにあるか確認
    if let Some(&frame_id) = self.page_table.get(&page_id) {
        // ピンカウントを増やす
        self.pages[frame_id].pin_count += 1;
        // LRUリストを更新
        self.update_lru(frame_id);
        return Some(&mut self.pages[frame_id]);
    }

    // バッファにない:ディスクから読み込む
    let frame_id = self.get_free_frame()?;
    
    // ディスクから読み込む
    if self.disk_manager.read_page(page_id, &mut self.pages[frame_id]).is_err() {
        self.free_list.push_back(frame_id);
        return None;
    }

    // 設定を更新
    let page = &mut self.pages[frame_id];
    page.pin_count = 1;
    page.is_dirty = false;

    // ページテーブルに追加
    self.page_table.insert(page_id, frame_id);
    self.lru_list.push_front(frame_id);

    Some(&mut self.pages[frame_id])
}

処理の流れ:

  1. まずバッファ(メモリ)を確認
  2. あればそれを返す(ヒット)
  3. なければディスクから読み込む(ミス)

2. ピンの解除(unpin_page)

pub fn unpin_page(&mut self, page_id: PageId, is_dirty: bool) -> bool {
    if let Some(&frame_id) = self.page_table.get(&page_id) {
        let page = &mut self.pages[frame_id];
        
        if page.pin_count == 0 {
            return false;  // すでに0
        }
        
        page.pin_count -= 1;
        if is_dirty {
            page.is_dirty = true;  // 変更されたことを記録
        }
        
        true
    } else {
        false
    }
}

なぜピンが必要?

ページを使ってる最中に追い出されたら困る。

トランザクションA: Page 5を読み込み中...
バッファプール: 空きがないからPage 5追い出すか
トランザクションA: え???

ピンカウントが0より大きいページは追い出し対象にならない。

3. ページの追い出し(LRU)

バッファが満杯のとき、どのページを追い出すか?

LRU(Least Recently Used): 最も長く使われていないページを追い出す

fn evict_page(&mut self) -> Option<usize> {
    // LRUリストの末尾から、ピンされていないページを探す
    for i in (0..self.lru_list.len()).rev() {
        let frame_id = self.lru_list[i];
        if self.pages[frame_id].pin_count == 0 {
            // ダーティなら書き込む
            if self.pages[frame_id].is_dirty {
                let _ = self.disk_manager.write_page(&self.pages[frame_id]);
            }
            
            // 追い出し処理
            let page_id = self.pages[frame_id].id;
            self.page_table.remove(&page_id);
            self.lru_list.remove(i);
            self.pages[frame_id].reset();
            
            return Some(frame_id);
        }
    }
    None  // 全部ピンされてて追い出せない
}

追い出すときは必ずダーティチェックする。変更されていたらディスクに書き戻す。

動作確認

実際に動かしてみよう。

fn main() {
    let mut pool = BufferPool::new(4, "demo.db").unwrap();
    
    // 新しいページを3つ作成
    let page1 = pool.new_page().unwrap();
    let page2 = pool.new_page().unwrap();
    let page3 = pool.new_page().unwrap();
    println!("Created pages: {}, {}, {}", page1, page2, page3);
    
    // ページにデータを書き込み
    if let Some(page) = pool.fetch_page(page1) {
        page.write_bytes(16, b"Hello from Page 1!");
    }
    
    pool.debug_print();
}

実行結果:

--- 新しいページを3つ作成 ---
Created pages: 0, 1, 2

--- バッファプールの状態 ---
=== Buffer Pool Status ===
Pool size: 4
Pages in buffer: 3
Free frames: 1
  PageId=0, Frame=0, Dirty=true, PinCount=2
  PageId=1, Frame=1, Dirty=true, PinCount=2
  PageId=2, Frame=2, Dirty=true, PinCount=1

3つのページがバッファに載ってる。PinCount=2new_pagefetch_pageで2回ピンされたから。

永続化の確認

// フラッシュしてファイルを閉じる
pool.flush_all();
drop(pool);

// 再度開く
let mut pool2 = BufferPool::new(4, "demo.db").unwrap();

// 読み込み
if let Some(page) = pool2.fetch_page(page1) {
    let data = page.read_bytes(16, 18);
    let text = String::from_utf8_lossy(data);
    println!("Read from page {}: \"{}\"", page1, text);
}
--- バッファプールを再作成してディスクから読み込み ---
Read from page 0: "Hello from Page 1!"

ちゃんとディスクに保存されて、再読み込みできてる!

追い出しのテスト

// 小さいバッファプール(2ページ分)
let mut pool = BufferPool::new(2, "test.db").unwrap();

// 3ページ作成(1つは追い出される)
let page1 = pool.new_page().unwrap();
pool.unpin_page(page1, false);

let page2 = pool.new_page().unwrap();
pool.unpin_page(page2, false);

let page3 = pool.new_page().unwrap();  // ここでpage1が追い出される
pool.unpin_page(page3, false);

// でもディスクにはあるので読み込める
let page = pool.fetch_page(page1);
assert!(page.is_some());  // OK!

2フレームしかないのに3ページ使おうとすると、LRUで最も古いページが追い出される。でもディスクには保存されてるので、再度fetch_pageすれば読み込める。

バッファプール置換アルゴリズム

LRU以外にもいろんなアルゴリズムがある。

アルゴリズム 特徴
LRU シンプル、実装が簡単
Clock LRUの近似、低オーバーヘッド
LRU-K 直近K回のアクセスを考慮
2Q 一時的なアクセスとホットデータを区別
ARC 自己チューニング型LRU

PostgreSQLはClock Sweep、MySQLはLRU変種を使ってる。

実際のデータベースとの比較

PostgreSQLのバッファプール

  • 共有メモリにバッファプールを配置
  • shared_buffersパラメータでサイズを設定(デフォルト128MB)
  • Background Writerがダーティページを書き出す

SQLiteのPage Cache

  • デフォルトで最大2000ページ(約8MB)
  • PRAGMA cache_sizeで変更可能
  • WALモードで効率的な書き込み

まとめ

今回作ったもの:

  • Page: 固定サイズのデータ単位
  • DiskManager: ファイルI/Oの抽象化
  • BufferPool: ページのキャッシュ管理

バッファプールはデータベースの性能を決める重要なコンポーネント

メモリに収まるデータへのアクセスは超高速、ディスクI/Oは極力減らす。この発想がデータベースエンジンの基本。

次回はいよいよSQLパーサーを実装して、SQL文を解析できるようにしていく!

この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!

7
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
7
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?