· yuta

RDBMSを自作して学ぶ:第3章 ディスクとファイルI/O

#database#rdbms#go

前回はプロジェクトのディレクトリ構成とテスト戦略を決め、writePage / readPage という2つの関数でファイルにページを書いて読む最小限のコードを書きました。今回はストレージエンジンの最下層であるDiskManagerを実装します。DiskManagerはデータベースファイルへのページ単位の読み書きを担うコンポーネントで、前章の2関数を整理・拡張し、上位層が安全に利用できるインターフェースとして定義し直します。

なぜデータはファイルに保存するのか

メモリとディスクの違い

プログラムが扱うデータは、通常メモリ(RAM)上に置かれます。メモリは高速ですが、電源を切ると内容が消えます。データベースが扱うデータは電源を切っても残らなければなりません。そのためデータをディスク(HDD・SSD)上のファイルに保存します。

特性 メモリ(RAM) ディスク(SSD) ディスク(HDD)
アクセス速度 〜100ns 〜100µs 〜10ms
容量 数GB〜数百GB 数百GB〜数TB 数TB〜
電源断後のデータ 消える 残る 残る
コスト(GB単価) 高い 中程度 安い

アクセス速度はメモリとSSDで1,000倍以上の差があります。データベースがバッファプール(メモリキャッシュ)を持つ理由はここにあります(詳しくは第4章)。

OSのファイルシステムとの関係

データベースはOSのファイルシステムの上でファイルとして動作します。ただし、OSはファイルの読み書きを独自にバッファリング(ページキャッシュ)します。データベース自身もバッファプールを持つため、二重にキャッシュされる場合があります。

本番のデータベースでは O_DIRECT フラグを使ってOSのページキャッシュをバイパスし、データベース側でキャッシュを完全に制御することがあります。本シリーズではシンプルさを優先してOSのキャッシュはそのまま利用します。

データベースファイルの構造

データベースファイルはページ(Page) という固定長のブロックを並べた構造になっています。

test.db
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│  Page 0  │  Page 1  │  Page 2  │  Page 3  │  Page 4  │
│ (4096B)  │ (4096B)  │ (4096B)  │ (4096B)  │ (4096B)  │
└──────────┴──────────┴──────────┴──────────┴──────────┘
 offset=0   offset=     offset=    offset=    offset=
            4096        8192       12288      16384

ページIDとファイルオフセットの関係は単純です。

オフセット = ページID × ページサイズ

ページ(Page)の概念と設計

ページとは

ページはデータベースがディスクとのI/Oを行う最小単位です。ページサイズには一般的に4KB・8KB・16KBが使われます。

本シリーズでは4KB(4096バイト) を採用します。

ページサイズを固定にする理由

ページサイズを固定にすることで、任意のページIDからファイルオフセットを O(1) で計算できます。可変長だとページの位置を管理する別の仕組みが必要になり複雑です。

ページの型定義

Goでページを表す型を定義します。[PageSize]byte という配列型を使うことで、コンパイル時にサイズが確定します。

// storage/page/page.go
package page

const PageSize = 4096

// Page はディスクI/Oの最小単位(4KBの固定長バイト配列)
type Page [PageSize]byte

// PageID はページを一意に識別するID(0始まり)
type PageID uint32

// InvalidPageID は無効なページIDを表す定数
const InvalidPageID PageID = ^PageID(0) // 0xFFFFFFFF

Note: ^PageID(0) はPageIDのビットをすべて反転させた値、つまり 0xFFFFFFFF(uint32の最大値)になります。これを「無効なページID」として使います。

ページヘッダの設計

各ページの先頭には管理情報(ヘッダ)を置きます。本シリーズでは次のヘッダ構造を採用します。

ページレイアウト(4096バイト)
┌──────────────────────────────────────────┐
│ ヘッダ(16バイト)                          │
│  [0:4]  ページID       (uint32)           │
│  [4:8]  ページ種別     (uint32)           │
│  [8:12] 空き領域オフセット (uint32)        │
│ [12:16] レコード数     (uint32)           │
├──────────────────────────────────────────┤
│ データ領域(4080バイト)                    │
│  ...                                     │
└──────────────────────────────────────────┘

ページ種別(PageType)には以下を定義します。

// storage/page/page.go(続き)
import "encoding/binary"

type PageType uint32

const (
    PageTypeUnknown  PageType = 0
    PageTypeHeap     PageType = 1 // テーブルデータ
    PageTypeBTree    PageType = 2 // B+木ノード
    PageTypeOverflow PageType = 3 // 大きなデータの続き
)

const HeaderSize = 16

// ヘッダのオフセット定数
const (
    offsetPageID    = 0
    offsetPageType  = 4
    offsetFreeSpace = 8
    offsetNumSlots  = 12
)

func (p *Page) GetPageID() PageID {
    return PageID(binary.BigEndian.Uint32(p[offsetPageID : offsetPageID+4]))
}

func (p *Page) SetPageID(id PageID) {
    binary.BigEndian.PutUint32(p[offsetPageID:offsetPageID+4], uint32(id))
}

func (p *Page) GetPageType() PageType {
    return PageType(binary.BigEndian.Uint32(p[offsetPageType : offsetPageType+4]))
}

func (p *Page) SetPageType(t PageType) {
    binary.BigEndian.PutUint32(p[offsetPageType:offsetPageType+4], uint32(t))
}

func (p *Page) GetFreeSpaceOffset() uint32 {
    return binary.BigEndian.Uint32(p[offsetFreeSpace : offsetFreeSpace+4])
}

func (p *Page) SetFreeSpaceOffset(offset uint32) {
    binary.BigEndian.PutUint32(p[offsetFreeSpace:offsetFreeSpace+4], offset)
}

func (p *Page) GetNumSlots() uint32 {
    return binary.BigEndian.Uint32(p[offsetNumSlots : offsetNumSlots+4])
}

func (p *Page) SetNumSlots(n uint32) {
    binary.BigEndian.PutUint32(p[offsetNumSlots:offsetNumSlots+4], n)
}

// Data はヘッダを除いたデータ領域のスライスを返す
func (p *Page) Data() []byte {
    return p[HeaderSize:]
}

Goの os パッケージで固定長ページを読み書きする

os.File の主要メソッド

Goでファイルを操作する基本メソッドを整理します。

メソッド 説明
os.OpenFile(name, flag, perm) ファイルを開く(フラグで動作を指定)
f.WriteAt(b []byte, off int64) オフセット off からバイト列 b を書き込む
f.ReadAt(b []byte, off int64) オフセット off からバイト列 b を読み込む
f.Sync() OSバッファをディスクにフラッシュする
f.Close() ファイルを閉じる

よく使うフラグの組み合わせ:

// 新規作成または上書き(書き込み専用)
os.OpenFile(name, os.O_RDWR|os.O_CREATE, 0644)

// 既存ファイルを読み書き(存在しなければエラー)
os.OpenFile(name, os.O_RDWR, 0644)

WriteAt / ReadAt が重要な理由

通常の Write / Read はファイルポインタを使った逐次アクセスです。データベースでは任意のページに直接アクセス(ランダムアクセス)したいため、オフセットを指定できる WriteAt / ReadAt を使います。

// 通常の書き込み(シーケンシャル)
f.Write(data) // 現在のファイルポインタから書く

// オフセット指定の書き込み(ランダムアクセス)
f.WriteAt(data, offset) // offset バイト目から書く(ポインタ変更なし)

WriteAt / ReadAt はスレッドセーフです。複数のゴルーチンが同時に異なるオフセットへ書き込んでも安全に動作します(同一オフセットへの同時書き込みは別途排他制御が必要)。

fsync によるデータ永続化

f.Write が成功しても、データはOSのバッファにある場合があります。電源断が起きた場合、バッファのデータは失われます。確実にディスクに書き込むには f.Sync()(内部でシステムコール fsync を呼ぶ)が必要です。

if err := f.WriteAt(page[:], offset); err != nil {
    return err
}
// ここでは OS バッファにある(かもしれない)

if err := f.Sync(); err != nil {
    return err
}
// ここでディスクへの書き込みが保証される

Sync は高コスト(SSDで数ms〜数十ms)なため、毎回呼ぶのではなくトランザクションのコミット時など重要なタイミングにのみ使います(詳しくは第12章)。

実装:DiskManager

インターフェースの定義

まずDiskManagerのインターフェースを定義します。上位層はこのインターフェースに依存し、具体的な実装には依存しません。

// storage/disk/disk_manager.go
package disk

import "github.com/yourname/go-rdbms/storage/page"

// DiskManager はデータベースファイルへのページ単位の読み書きを担う
type DiskManager interface {
    // ReadPage は指定されたPageIDのページを読み込みpに格納する
    ReadPage(pageID page.PageID, p *page.Page) error
    // WritePage は指定されたPageIDの位置にpの内容を書き込む
    WritePage(pageID page.PageID, p *page.Page) error
    // AllocatePage は新しいページを割り当て、そのPageIDを返す
    AllocatePage() (page.PageID, error)
    // NumPages は現在のページ数を返す
    NumPages() page.PageID
    // Sync はOSバッファをディスクにフラッシュする
    Sync() error
    // Close はファイルを閉じる
    Close() error
}

FileDiskManager の実装

// storage/disk/file_disk_manager.go
package disk

import (
    "fmt"
    "os"
    "sync"

    "github.com/yourname/go-rdbms/storage/page"
)

// FileDiskManager は DiskManager インターフェースのファイルベース実装
type FileDiskManager struct {
    file     *os.File
    numPages page.PageID
    mu       sync.RWMutex
}

// NewFileDiskManager は指定されたパスのファイルを開き、FileDiskManagerを返す
// ファイルが存在しない場合は新規作成する
func NewFileDiskManager(filePath string) (*FileDiskManager, error) {
    f, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE, 0644)
    if err != nil {
        return nil, fmt.Errorf("disk: ファイルを開けませんでした(%s): %w", filePath, err)
    }

    // 既存ファイルのページ数を計算
    info, err := f.Stat()
    if err != nil {
        f.Close()
        return nil, fmt.Errorf("disk: ファイル情報の取得に失敗しました: %w", err)
    }
    numPages := page.PageID(info.Size() / page.PageSize)

    return &FileDiskManager{
        file:     f,
        numPages: numPages,
    }, nil
}

// ReadPage は指定されたPageIDのページを読み込む
func (dm *FileDiskManager) ReadPage(pageID page.PageID, p *page.Page) error {
    dm.mu.RLock()
    defer dm.mu.RUnlock()

    if pageID >= dm.numPages {
        return fmt.Errorf("disk: PageID %d は範囲外です(総ページ数: %d)", pageID, dm.numPages)
    }

    offset := int64(pageID) * page.PageSize
    n, err := dm.file.ReadAt(p[:], offset)
    if err != nil {
        return fmt.Errorf("disk: ページ %d の読み込みに失敗しました: %w", pageID, err)
    }
    if n != page.PageSize {
        return fmt.Errorf("disk: ページ %d の読み込みサイズが不正です(期待: %d, 実際: %d)",
            pageID, page.PageSize, n)
    }
    return nil
}

// WritePage は指定されたPageIDの位置にページを書き込む
func (dm *FileDiskManager) WritePage(pageID page.PageID, p *page.Page) error {
    dm.mu.Lock()
    defer dm.mu.Unlock()

    offset := int64(pageID) * page.PageSize
    n, err := dm.file.WriteAt(p[:], offset)
    if err != nil {
        return fmt.Errorf("disk: ページ %d の書き込みに失敗しました: %w", pageID, err)
    }
    if n != page.PageSize {
        return fmt.Errorf("disk: ページ %d の書き込みサイズが不正です(期待: %d, 実際: %d)",
            pageID, page.PageSize, n)
    }

    // 書き込んだページIDが現在の末尾を超えていればページ数を更新
    if pageID >= dm.numPages {
        dm.numPages = pageID + 1
    }
    return nil
}

// AllocatePage は新しいページを末尾に割り当てる
func (dm *FileDiskManager) AllocatePage() (page.PageID, error) {
    dm.mu.Lock()
    defer dm.mu.Unlock()

    newPageID := dm.numPages

    // 新しいページ領域をゼロで初期化して書き込む
    var emptyPage page.Page
    offset := int64(newPageID) * page.PageSize
    if _, err := dm.file.WriteAt(emptyPage[:], offset); err != nil {
        return page.InvalidPageID, fmt.Errorf("disk: 新規ページの割り当てに失敗しました: %w", err)
    }

    dm.numPages++
    return newPageID, nil
}

// NumPages は現在のページ数を返す
func (dm *FileDiskManager) NumPages() page.PageID {
    dm.mu.RLock()
    defer dm.mu.RUnlock()
    return dm.numPages
}

// Sync はOSバッファをディスクにフラッシュする
func (dm *FileDiskManager) Sync() error {
    if err := dm.file.Sync(); err != nil {
        return fmt.Errorf("disk: fsync に失敗しました: %w", err)
    }
    return nil
}

// Close はファイルを閉じる
func (dm *FileDiskManager) Close() error {
    return dm.file.Close()
}

テストを書く

前章で決めた「テストごとに一時ファイルを作成し、テスト終了後に削除する」方針に沿ってテストを書きます。

// storage/disk/file_disk_manager_test.go
package disk_test

import (
    "os"
    "testing"

    "github.com/yourname/go-rdbms/storage/disk"
    "github.com/yourname/go-rdbms/storage/page"
)

// setupDiskManager はテスト用の一時ファイルでDiskManagerを作成する
func setupDiskManager(t *testing.T) (disk.DiskManager, func()) {
    t.Helper()

    f, err := os.CreateTemp("", "test-disk-*.db")
    if err != nil {
        t.Fatal(err)
    }
    f.Close()

    dm, err := disk.NewFileDiskManager(f.Name())
    if err != nil {
        os.Remove(f.Name())
        t.Fatal(err)
    }

    cleanup := func() {
        dm.Close()
        os.Remove(f.Name())
    }
    return dm, cleanup
}

func TestFileDiskManager_AllocateAndWrite(t *testing.T) {
    dm, cleanup := setupDiskManager(t)
    defer cleanup()

    // 最初はページ数が0
    if dm.NumPages() != 0 {
        t.Fatalf("初期ページ数は0のはず: got %d", dm.NumPages())
    }

    // ページを割り当てる
    pageID, err := dm.AllocatePage()
    if err != nil {
        t.Fatal(err)
    }
    if pageID != 0 {
        t.Fatalf("最初のPageIDは0のはず: got %d", pageID)
    }
    if dm.NumPages() != 1 {
        t.Fatalf("AllocatePage後のページ数は1のはず: got %d", dm.NumPages())
    }

    // ページに書き込む
    var p page.Page
    p.SetPageID(pageID)
    p.SetPageType(page.PageTypeHeap)
    copy(p.Data(), []byte("hello, disk manager!"))

    if err := dm.WritePage(pageID, &p); err != nil {
        t.Fatal(err)
    }

    // ページを読み込む
    var readPage page.Page
    if err := dm.ReadPage(pageID, &readPage); err != nil {
        t.Fatal(err)
    }

    // 内容を検証
    if readPage.GetPageID() != pageID {
        t.Errorf("PageID不一致: want %d, got %d", pageID, readPage.GetPageID())
    }
    if readPage.GetPageType() != page.PageTypeHeap {
        t.Errorf("PageType不一致: want %d, got %d", page.PageTypeHeap, readPage.GetPageType())
    }
    if string(readPage.Data()[:20]) != "hello, disk manager!" {
        t.Errorf("データ不一致: got %q", readPage.Data()[:20])
    }
}

func TestFileDiskManager_MultiplePages(t *testing.T) {
    dm, cleanup := setupDiskManager(t)
    defer cleanup()

    const numPages = 10

    // 10ページを割り当てて書き込む
    for i := 0; i < numPages; i++ {
        pageID, err := dm.AllocatePage()
        if err != nil {
            t.Fatal(err)
        }

        var p page.Page
        p.SetPageID(pageID)
        // データ領域にページIDを書き込む
        p.Data()[0] = byte(i)

        if err := dm.WritePage(pageID, &p); err != nil {
            t.Fatal(err)
        }
    }

    // 各ページを読み込んで検証
    for i := 0; i < numPages; i++ {
        var p page.Page
        if err := dm.ReadPage(page.PageID(i), &p); err != nil {
            t.Fatal(err)
        }
        if p.Data()[0] != byte(i) {
            t.Errorf("ページ%d: データ不一致: want %d, got %d", i, i, p.Data()[0])
        }
    }
}

func TestFileDiskManager_ReadOutOfRange(t *testing.T) {
    dm, cleanup := setupDiskManager(t)
    defer cleanup()

    // ページを割り当てずに読もうとするとエラー
    var p page.Page
    err := dm.ReadPage(0, &p)
    if err == nil {
        t.Error("範囲外のページを読み込もうとしてもエラーにならなかった")
    }
}

func TestFileDiskManager_PersistAfterReopen(t *testing.T) {
    f, err := os.CreateTemp("", "test-persist-*.db")
    if err != nil {
        t.Fatal(err)
    }
    f.Close()
    filePath := f.Name()
    defer os.Remove(filePath)

    // 1回目: 書き込んで閉じる
    {
        dm, err := disk.NewFileDiskManager(filePath)
        if err != nil {
            t.Fatal(err)
        }

        pageID, _ := dm.AllocatePage()
        var p page.Page
        copy(p.Data(), []byte("persist test"))
        dm.WritePage(pageID, &p)
        dm.Sync()
        dm.Close()
    }

    // 2回目: 再度開いて読む
    {
        dm, err := disk.NewFileDiskManager(filePath)
        if err != nil {
            t.Fatal(err)
        }
        defer dm.Close()

        if dm.NumPages() != 1 {
            t.Fatalf("再オープン後のページ数が不正: want 1, got %d", dm.NumPages())
        }

        var p page.Page
        if err := dm.ReadPage(0, &p); err != nil {
            t.Fatal(err)
        }
        if string(p.Data()[:12]) != "persist test" {
            t.Errorf("データが永続化されていない: got %q", p.Data()[:12])
        }
    }
}

テストを実行して確認します。

go test ./storage/disk/... -v

期待される出力:

=== RUN   TestFileDiskManager_AllocateAndWrite
--- PASS: TestFileDiskManager_AllocateAndWrite (0.00s)
=== RUN   TestFileDiskManager_MultiplePages
--- PASS: TestFileDiskManager_MultiplePages (0.00s)
=== RUN   TestFileDiskManager_ReadOutOfRange
--- PASS: TestFileDiskManager_ReadOutOfRange (0.00s)
=== RUN   TestFileDiskManager_PersistAfterReopen
--- PASS: TestFileDiskManager_PersistAfterReopen (0.00s)
PASS
ok      github.com/yourname/go-rdbms/storage/disk   0.012s

ここまでのコード全体像

本章が完了した時点のパッケージ構成です。

storage/
├── page/
│   └── page.go             ← Page型・PageID型・ページヘッダ操作
└── disk/
    ├── disk_manager.go     ← DiskManagerインターフェース
    └── file_disk_manager.go ← ファイルベースの実装

DiskManager の設計で意識したポイント

① ミューテックスで並行アクセスを保護する

sync.RWMutex を使い、読み込みは複数ゴルーチンが同時にできるが、書き込みは排他的になるようにしています。ReadPage では RLockWritePage / AllocatePage では Lock を取得します。

② インターフェースと実装を分離する

DiskManager インターフェースを定義したことで、テスト時にインメモリ実装に差し替えることができます。上位層のバッファプール(第4章)は DiskManager インターフェースのみに依存するので、ディスクを使わない高速なテストが書けます。

③ エラーにコンテキストを付ける

fmt.Errorf("disk: ...: %w", err) でエラーをラップすることで、どのレイヤーで何が起きたかがスタックトレースなしでも分かるようにしています。

まとめ

次章では、ディスクアクセスを減らすためのメモリキャッシュ層であるバッファプールマネージャを実装します。