Skip to content

コラム: 永続化戦略の選び方

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. 「終了時に書く」だけだと何が落ちるのか

たとえば次のシナリオ:

  1. クライアントが 100 万件 POST する
  2. kill -9 mini_db (SIGINT ではなく SIGKILL)
  3. 再起動

→ ファイルは前回起動時のまま、100 万件は完全に消える

SIGKILL を引かなくても、停電 / プロセスクラッシュ (Fatal error) / 強制リブートでも同じです。save() が呼ばれない経路が 1 つでもあると、その間のデータは飛びます。

3. もう 1 段だけ堅くするテクニック

3.1 atomic rename パターン

save() 中に SIGKILL されると、ファイルが truncate された直後半分だけ書かれた状態 で残ります。これは「直前の正しい状態」も「最新の正しい状態」も両方失う最悪パターン。

防ぎ方は伝統的な temp + rename:

cpp
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 しただけだと、データはまだカーネルのページキャッシュにあるだけで、ディスクに届いていない可能性があります。停電したら飛びます。

c
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 hello
  • POST / 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) を使う方がほぼ常に正解。