はじめに
リレーショナルデータベースの内部構造を体系的に学ぶ手段として、Edward Sciore氏の名著(『Database Design and Implementation』)などを参考に、教育用DB「SimpleDB」をJavaで実装していくアプローチは広く知られています。私もデータベースの知見を深めるべく、独自のRust移植版である sabidb の開発をコツコツと進めてきました。
とりあえず動く状態まで組み上げることはできたものの、私のコードベースには「ここは本当はもっとRustらしくスマートに書けるはずだ」と妥協し、見て見ぬふりをして蓋をしていた設計の負債が鎮座していました。
今年のゴールデンウィークはまとまった時間が確保できたため、以前からずっと直そうと心に決めていた最大の負債——「並行処理周りの過剰なロック」を剥がす作業に、腰を据えて取り組むことにしました。
本稿では、開発初期の「とりあえず動かす」フェーズから、Rustの並行処理モデルへとコードを適合させていったリファクタリングの過程と、自作だからこそ味わえた設計上の生々しい気付きを共有します。
1. 最初の逃げ道:見えない完成図と「つぎはぎの品」
開発初期、そもそもこのJavaのコードベースをRustで完全に表現できるのかすら、疑わしい状態でした。JavaとRustではプログラミングの思想が根本的に異なり、元のJavaコードでは型チェックを行わないダウンキャストが多用されていたからです。
さらに、自作DBの開発は低レイヤーからのボトムアップで進みます。まずは物理的な「ページ」の定義から始まり、ページマネージャ、ファイルマネージャ、そしてトランザクションへと積み上げていきます。しかし、作り始めの段階では「完成品の全体像」が想像つかず、上位レイヤーから各コンポーネントがどのようなパターンで呼び出されるのか、全く見当がつきませんでした。
未知の呼び出し元から破壊されるのを防ぐため、私が最初に頼ったのは、主要な構造体をすべて
Arc<Mutex<T>> で包み込むという極めて保守的な(そして力技の)アプローチでした。性能云々を語る前に、「デッドロックを起こさず、とにかくコンパイルを通す」こと。それが至上命題だったのです。結果として出来上がったのは、とりあえず動きはするものの、過剰なロックによって本来のパフォーマンスを殺してしまっている「つぎはぎの品」でした。コードの可読性は言うまでもなく、せっかくの Rust の良さを完全に殺す、アンチパターンの極みのような実装でした。
しかし、当時の私にはそれを直す気力はもはや残されていませんでした。
2. Mutexを残すべき「聖域」を見極める
リファクタリングを進める中で、「どこからロックを剥がすか」ではなく「どこに絶対に厳密な直列化が必要か」という逆のアプローチをとりました。並列性よりも、イベントの厳密な順序性が整合性の根幹を成すコンポーネントです。
ここで、上位レイヤーからのトランザクションがどのように下位レイヤーにアクセスするかを整理しました。
[Transaction A] [Transaction B] [Transaction C]
| | |
(Write) (Write) (Write)
\ | /
\---------------- | ----------------/
v v v
+---------------------------+
| Global Mutex | <-- シリアルな一本道
+---------------------------+
| Log Manager |
| |
| [LSN:10] (Tx B commit) |
| [LSN:11] (Tx A update) |
| [LSN:12] (Tx C insert) |
+---------------------------+
|
v
[ Disk I/O ]
LogManager(先行書き込みログの管理)
データベースにおけるログは、システムで発生した事象の非可逆な記録です。ここでLSN(Log Sequence Number)の順序性が狂うことは、クラッシュリカバリの死を意味します。複数のトランザクションが同時にログをフラッシュしようとする際、上図のようにここだけはシリアルな一本道(Global Mutex)を維持することが、アーキテクチャ上の必然でした。
LockTable(トランザクション排他制御)
「どのトランザクションが、どの資材をロックしているか」を一元管理する調停者です。局所的なパフォーマンス改善のためにここのロックを過度に細分化すると、かえってデッドロック検知のロジックが破綻します。ここは慎重なハンドリングが求められる境界線として残しました。
3. ロックの解放:不変性とアトミック操作への置き換え
「聖域」が明確になったことで、逆にそれ以外の部分からは大胆にMutexを剥がすことができました。Rustの恩恵を最大限に受けるための最適化です。
| コンポーネント | 以前の状態 | 移行後の設計 | 最適化の意図 |
|---|---|---|---|
| Transaction ID | Mutex<i32> | AtomicI32 | TXID採番時のグローバルロック待機を排除 |
| FileManager | Arc<Mutex<FileManager>> | 内部Mutex + OS API | スレッド間でのページ同時読み書きの実現 |
| Schema / Layout | Arc<Mutex<T>> | 完全不変型(Immutable) | レコードスキャンごとのロック取得コストを根絶 |
まだ厳密なパフォーマンスのベンチマークをとったわけではありませんが、将来的なクエリ実行エンジンでの利用を見越した際、絶対に見過ごせないボトルネックだと感じたのが
Layout(テーブル定義情報)の不変(Immutable)化です。一度生成されたメタデータは変更されないという前提に立ち、Arc による共有のみに留めました。4. DDLのジレンマ、そしてMVCCという必然
このように、RDB のどのような場面でどのようなロックがかりうるか考える中で、ふとある疑問が浮かびました。
「LayoutからMutexを剥がしたら、レコード読み取り中に
ALTER TABLE のような並行するDDL操作が走った場合、どのように整合性を取るか?」実はこれこそ、RDBMS設計における古典的なジレンマそのものです。
そしてよくよく考えてみると、末端のLayout層をMutexで保護したところで根本的な解決にはなりません。Layoutのロックを解放して次の処理に移るその隙間に、別の操作が割り込む余地はいくらでもあるからです。
そしてよくよく考えてみると、末端のLayout層をMutexで保護したところで根本的な解決にはなりません。Layoutのロックを解放して次の処理に移るその隙間に、別の操作が割り込む余地はいくらでもあるからです。
実用的なRDBMSであれば、トランザクションレベルでテーブル構造自体に排他ロック(PostgreSQLの
AccessExclusiveLock1 など)をかけるか、複数のレイアウトバージョンを共存させる多版管理(MVCC)に踏み切ります。ロックの粒度や必要性を一つ一つ追跡していく過程で、MVCCのような高度なアーキテクチャの「必要性」を肌で理解する。これこそが、データベースをゼロから自作する最大の醍醐味なのだと気づかされました。
5. ゼロコスト抽象化への回帰
今回のリファクタリングを通して得た最大のブレイクスルーは、結局のところ「一時的な借用(
&)」と「ライフタイム('a)」の適切な設計でした。Arc の clone() は確かに実装を楽にしますが、ランタイムのオーバーヘッドという借金を背負います。生存期間が明確なコンポーネント間では、生の参照渡しに切り替え、コンパイラに安全性を静的に証明させる。この「Rust本来の作法」に回帰したとき、つぎはぎだったパズルのピースがカチッとハマるような心地よさがありました。おわりに:車輪の再発明が教えてくれること
今回の大規模なリファクタリングは、単に
Arc<Mutex<T>> を剥がしてパフォーマンスを上げるという表面的な作業にとどまらず、RDBMSという複雑なシステムの心臓部——トランザクションと並行処理の調停——の難しさを肌で学ぶ得難い経験となりました。日々のシステム運用で私たちが何気なく恩恵を受けている、PostgreSQLをはじめとする実用データベースの数々。その裏側で、先人たちがどれほどの執念でデータの整合性とパフォーマンスのトレードオフと戦い、洗練されたアーキテクチャを築き上げてきたのか。自らの手で車輪を再発明し、コンパイラに怒られ、ロックの粒度で頭を抱えるからこそ、その偉大さがより高い解像度で迫ってきます。
Rustとデータベース開発。どちらも最初は学習曲線が急峻ですが、泥臭く壁を乗り越えて「パズルのピースが完全にハマった」と実感できた瞬間の快感は、他では味わえないエンジニアリングの醍醐味です。この記事が、同じように自作DBの沼に挑む方や、Rustでの並行処理の設計に悩む方のヒントになれば幸いです。
技術リファレンス
本リファクタリングに関連する主な変更は、以下のレポジトリからご確認いただけます