RDBMSを自作して学ぶ:第3章 ディスクとファイルI/O
前回はプロジェクトのディレクトリ構成とテスト戦略を決め、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が使われます。
- PostgreSQL: デフォルト8KB
- MySQL(InnoDB): デフォルト16KB
- SQLite: デフォルト4KB(最大65536B)
本シリーズでは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 では RLock、WritePage / AllocatePage では Lock を取得します。
② インターフェースと実装を分離する
DiskManager インターフェースを定義したことで、テスト時にインメモリ実装に差し替えることができます。上位層のバッファプール(第4章)は DiskManager インターフェースのみに依存するので、ディスクを使わない高速なテストが書けます。
③ エラーにコンテキストを付ける
fmt.Errorf("disk: ...: %w", err) でエラーをラップすることで、どのレイヤーで何が起きたかがスタックトレースなしでも分かるようにしています。
まとめ
- データベースはメモリとディスクのアクセス速度差を意識した設計になっている
- ページ(4096バイト固定長)がディスクI/Oの最小単位
ページID × ページサイズ = ファイルオフセットという単純な計算でランダムアクセスが可能WriteAt/ReadAtはオフセット指定でスレッドセーフなファイルI/OができるSync()はデータの永続化を保証するが高コストなため使いどころを絞る- DiskManagerをインターフェースとして定義し、上位層との疎結合を保つ
次章では、ディスクアクセスを減らすためのメモリキャッシュ層であるバッファプールマネージャを実装します。