コラム: 永続化戦略の選び方
mini_db は SIGINT を受けたタイミングで「全データをテキストで上書き保存」するという、もっともシンプルな永続化を採用しています。実用的な KVS / DB が採っている戦略と比べると、これは 「途中でクラッシュしたら吹き飛ぶ」 という大きな欠落があります。
本コラムでは、永続化のスペクトルを整理して、mini_db がどこに位置しているかをはっきりさせます。
1. 永続化の 4 つの典型パターン
スナップショット (Snapshot / Dump) : 一定のタイミングで「現在のメモリ全体」をファイルに書き出す。mini_db はこれの最小版。Redis の RDB も基本これ。長所: ファイルが常に「ある時点の正しい状態」。短所: 保存と保存の間にクラッシュすると、その間の更新は失う。
追記ログ (Append-Only Log / WAL) : 受け取った変更コマンドを 1 件ずつログファイルに append する。長所: クラッシュしても直前の操作までは復元可能。短所: ファイルが永久に伸びる → 定期的なコンパクション (再構築) が必要。
ハイブリッド (RDB + AOF) : スナップショットを土台に、その後の差分だけログで持つ。Redis の MIXED モード、PostgreSQL の checkpoint + WAL がこれ。
LSM-Tree / B-Tree on disk : ディスク上のデータ構造そのものを更新する。LevelDB / RocksDB / SQLite。実装は重いが、メモリ容量を超えるデータも扱える。
mini_db は スナップショットの中でも「終了時 1 回だけ書く」 という特殊版で、定期保存すらしていません。
2. 「終了時に書く」だけだと何が落ちるのか
たとえば次のシナリオ:
- クライアントが 100 万件
POSTする kill -9 mini_db(SIGINT ではなく SIGKILL)- 再起動
→ ファイルは前回起動時のまま、100 万件は完全に消える。
SIGKILL を引かなくても、停電 / プロセスクラッシュ (Fatal error) / 強制リブートでも同じです。save() が呼ばれない経路が 1 つでもあると、その間のデータは飛びます。
3. もう 1 段だけ堅くするテクニック
3.1 atomic rename パターン
save() 中に SIGKILL されると、ファイルが truncate された直後 や 半分だけ書かれた状態 で残ります。これは「直前の正しい状態」も「最新の正しい状態」も両方失う最悪パターン。
防ぎ方は伝統的な temp + rename:
void save(const std::string &path) {
std::string tmp = path + ".tmp";
{
std::ofstream ofs(tmp.c_str());
for (auto &kv : db_) ofs << kv.first << " " << kv.second << "\n";
// ofs.flush(); // 念のため
} // close
// ::fsync(fd_of_tmp); // ディスクに到達したことを保証 (POSIX)
std::rename(tmp.c_str(), path.c_str());
}POSIX の rename(2) は アトミック です。tmp への書き込みが途中で死んでも、本体ファイル path は古い正常な状態のまま。書き終わってから一瞬で差し替わります。
3.2 fsync(2) でディスク到達を保証する
close しただけだと、データはまだカーネルのページキャッシュにあるだけで、ディスクに届いていない可能性があります。停電したら飛びます。
int fd = ::open(tmp.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644);
::write(fd, ...);
::fsync(fd); // ディスクに到達したことを保証
::close(fd);
::rename(tmp, path);fsync は遅い (ms オーダー) ので、本物の DB は どのタイミングで何回 fsync するか がチューニングの中核になります。
4. ログ方式 (AOF) の最小スケッチ
mini_db を「終了時 1 回」から「コマンドごとにログ」に変えるとどうなるか:
# mini_db.aof
POST A B
POST B C
DELETE A
POST persist helloPOST/DELETE受信時に 1 行 append する。- 起動時に AOF を上から読んで再生 (replay) すれば、メモリ状態を復元できる。
- ファイルが伸び続けるので、定期的に 現在のメモリ全体を再ダンプして AOF を圧縮 する (Redis の BGREWRITEAOF)。
復旧粒度は「1 コマンド」になり、SIGKILL でも前のコマンドまでは残る。代わりに毎コマンドの I/O コスト (と必要に応じて fsync) が乗ります。
5. mini_db に何を足すかの判断軸
| 要件 | 推奨アプローチ |
|---|---|
| 本実装の現状 | スナップショット / 終了時 1 回 |
| 「kill -9 でも前回までは残したい」 | atomic rename + 定期 save() (例: コマンド N 件毎) |
| 「最後のコマンドまで失いたくない」 | AOF (append-only log) + 起動時 replay |
| 「メモリを超えるデータを扱いたい」 | ディスク上 B-Tree / LSM-Tree (現実的には既製品 LMDB / SQLite を使う) |
学習目的なら 足さない のが正解です。「動くサーバ」を目指した瞬間、上記のどれが必要かを再評価することになります。
まとめ
- mini_db の永続化は「終了時にスナップショット」というスペクトル最左端。
- 1 段堅くするなら temp + rename (アトミック切替)。
- 「直近のコマンドまで残したい」なら AOF (Append-Only File) ログ + replay。
- ディスク容量を超えるなら、自作せず既製品 (SQLite / LevelDB) を使う方がほぼ常に正解。