Skip to content

mini_db — TCP Key-Value ストア

127.0.0.1 上で待ち受け、POST / GET / DELETE のテキストコマンドを処理する、超ミニマルな TCP Key-Value ストアサーバを C++ で書きます。SIGINT 受信でディスクに保存し、起動時に読み戻す 永続化 がポイント。

前提知識: ソケット API や select の基本概念は ソケット基礎 にまとめています。sockaddr_in / SO_REUSEADDR / INADDR_LOOPBACK / EINTR などの「まず押さえておきたい話」はそちらを先にどうぞ。

概要

  • 実行形式: ./mini_db [port] [path]
  • 動作: 127.0.0.1:port で接続を待ち受ける。ready\n を標準出力した時点で受け付け開始。
  • 接続: persistent (1 接続で複数コマンド OK)
  • コマンド:
    • POST <key> <value>0\n
    • GET <key>0 <value>\n (ヒット) / 1\n (ミス)
    • DELETE <key>0\n (削除成功) / 1\n (キー不在)
    • 認識不能 / 引数数不一致 / 空行 → 2\n
  • 永続化: SIGINT 受信時に path へダンプし、次回起動時に読み戻す。
  • キー / 値: 空白を含まない。1 リクエストは最大 1000 文字。

主要技術の深掘り

select(2) を使ったシングルスレッド・イベントループ

複数クライアントを同時に捌くために、スレッドを生やすのではなく 1 プロセス・1 スレッドselect(2) を回します。

cpp
fd_set rfds;
while (g_running) {
    rfds   = active_fds_;             // 毎回コピー (select は破壊的)
    int rv = select(max_fd_ + 1, &rfds, NULL, NULL, NULL);
    if (rv < 0) {
        if (errno == EINTR) continue; // SIGINT で起こされたら g_running を再評価
        fatal();
    }
    for (int fd = 0; fd <= max_fd_ && rv > 0; ++fd) {
        if (!FD_ISSET(fd, &rfds)) continue;
        --rv;
        if (fd == listen_fd_) accept_client();
        else                  recv_data(fd);
    }
}

ブロッキング recv + select ガード という構成にすることで、O_NONBLOCK を立てなくても他クライアントを巻き込まない。select がレディと言った fd しか触らないから、recv は即値が返る。

行 (\n) 単位のリクエスト境界処理

TCP は バイトストリーム なので、recv 1 回で 1 リクエストが揃う保証はない。たとえば recv"POS" だけで返ってきたら、それは 何もしない のが正解。

そこで fd 毎に蓄積バッファ を持ち、\n を見るまで貯める:

cpp
std::map<int, std::string> buffers_;   // fd → 未完成行

void MiniDb::recv_data(int fd) {
    char buf[1024];
    ssize_t n = recv(fd, buf, sizeof(buf), 0);
    if (n <= 0) { disconnect(fd); return; }
    buffers_[fd].append(buf, n);

    std::string::size_type pos;
    while ((pos = buffers_[fd].find('\n')) != std::string::npos) {
        std::string line = buffers_[fd].substr(0, pos);
        buffers_[fd].erase(0, pos + 1);
        std::string resp = handle_command(line);
        send(fd, resp.c_str(), resp.size(), 0);
    }
}

1 回の recv複数行 が入っていても 1 つも入っていなくても、同じコードで正しく動く。

シグナル安全な終了 (volatile sig_atomic_t フラグ)

SIGINT ハンドラから直接 save() を呼んではいけない。std::ofstreammallocasync-signal-safe ではないため、ハンドラ内で UB を踏みうる。

そこで王道のパターン:

cpp
volatile sig_atomic_t g_running = 1;

void on_sigint(int) { g_running = 0; }   // フラグを倒すだけ

void MiniDb::run() {
    while (g_running) {
        /* select … */
    }
    save();   // ループを抜けてから "通常コンテキスト" で保存
}

select は SIGINT で EINTR で抜けるため、ループ条件で再評価して即終了経路に入れる。

詳しい背景は シグナル安全な終了パターン で深掘りしています。

テキスト形式の永続化

保存ファイルは「1 行 = 1 ペア」の超単純な空白区切り:

A B
B C
persist hello

キー / 値に空白が含まれない前提があるので、std::ifstream >> key >> value で素直にパースできる。

トレードオフ (atomic rename / fsync / append-only ログとの違い) は 永続化戦略 を参照。

ロジックの可視化

全体フロー

recv_data 内のコマンド処理

コラム・読み物

設計判断の背景や、TCP プロトコル設計の引き出しを増やすための深掘り記事です。

よくある質問 (FAQ)

Q. キーや値に空白文字を含めたい場合は?

A. ここでは「キーと値に空白を含まない」前提で書いています。含めたい場合は std::istringstream >> token ベースの分割を辞め、POST <keylen> <key> <vallen> <value> のような 長さプレフィックス に変える必要があります。詳細は 行区切りテキストプロトコルの設計 を参照。

Q. 同時に複数クライアントから書き込まれたら?

A. 1 スレッド・1 ループで逐次処理しているので、コマンドの実行順序は決定的です (select が返した fd 順 → \n で揃った行から順)。POSTGET がレース状態になることはありません。競合状態 (race) を心配しなくていい のは select シングルループの大きな利点です。

Q. \r\n で終わる行 (Windows / Telnet) はどう扱う?

A. 本実装は '\n' を区切りに使い、行内のトークン分割は >> で行うため、末尾の \r は空白扱いで自然に無視されます。厳密に \r を弾きたい場合は、行を切り出した後に if (!line.empty() && line.back() == '\r') line.pop_back(); を入れます。

Q. SIGINT 中にプロセスが kill されたらデータは?

A. ファイルへの書き込み中に強制終了されると、ファイルが 半端な状態 になり得ます (truncate 直後 / 一部だけ書かれた状態)。サーバが綺麗に成仏できなかった場合の救済策、と言ってもいいでしょう。ここでは扱いませんが、対策パターンは 永続化戦略の選び方 を参照。

Q. クライアント数の上限は?

A. fd_set のサイズ (FD_SETSIZE、通常 1024) が事実上の上限です。それ以上を扱うなら poll / epoll / kqueue への切り替えが必要になります。

Q. ポートを小さい数 (例: 80) にすると失敗する

A. 1024 番未満は特権ポートで、root 権限がないと bind できません。テストには 1024 以上 (例: 4343) を使ってください。

参考リンク