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\nGET <key>→0 <value>\n(ヒット) /1\n(ミス)DELETE <key>→0\n(削除成功) /1\n(キー不在)- 認識不能 / 引数数不一致 / 空行 →
2\n
- 永続化: SIGINT 受信時に
pathへダンプし、次回起動時に読み戻す。 - キー / 値: 空白を含まない。1 リクエストは最大 1000 文字。
主要技術の深掘り
select(2) を使ったシングルスレッド・イベントループ
複数クライアントを同時に捌くために、スレッドを生やすのではなく 1 プロセス・1 スレッド で select(2) を回します。
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 を見るまで貯める:
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::ofstream も malloc も async-signal-safe ではないため、ハンドラ内で UB を踏みうる。
そこで王道のパターン:
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 プロトコル設計の引き出しを増やすための深掘り記事です。
- シグナル安全な終了パターン —
volatile sig_atomic_tと async-signal-safe 関数の話 - 行区切りテキストプロトコルの設計 — Redis / SMTP / HTTP との比較
- 永続化戦略の選び方 — スナップショット / ログ / atomic rename /
fsync - Key-Value ストアのデータ構造選択 —
std::mapvsstd::unordered_map
よくある質問 (FAQ)
Q. キーや値に空白文字を含めたい場合は?
A. ここでは「キーと値に空白を含まない」前提で書いています。含めたい場合は std::istringstream >> token ベースの分割を辞め、POST <keylen> <key> <vallen> <value> のような 長さプレフィックス に変える必要があります。詳細は 行区切りテキストプロトコルの設計 を参照。
Q. 同時に複数クライアントから書き込まれたら?
A. 1 スレッド・1 ループで逐次処理しているので、コマンドの実行順序は決定的です (select が返した fd 順 → \n で揃った行から順)。POST と GET がレース状態になることはありません。競合状態 (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) を使ってください。