Redisのスレッドモデルと永続化メカニズムの詳細分析

1. Redisにおける主要スレッドとその協調動作

Redisのスレッドアーキテクチャは、純粋なシングルスレッドから「コアシングルスレッド+マルチスレッド補助」への進化を遂げています(Redis 6.0でIOマルチスレッド導入、Redis 7.0でバックグラウンドスレッド強化)。設計目標はコマンド実行のアトミック性を維持しつつ、高並列状況でのパフォーマンスを向上することです。

Redisスレッドモデルの基本原則

Redisのスレッド設計は以下の2つの重要な目的に集中しています:

  1. コマンド実行のアトミック性:すべてのコマンド解析・実行はシングルスレッドで行い、データ構造の競合状態を回避します(Redisのトランザクションとアトミック操作の基盤)
  2. 非コアタスクの非同期化時間のかかるIO、メモリ解放、永続化などの操作をバックグラウンドスレッドに委ね、メインスレッドのブロックを防ぎます(メインスレッドはRedisの「頭脳」であり、高応答性を維持する必要があります)

主なスレッドタイプと役割

Redisのスレッドは3種類に分類されます:メインスレッドIOスレッドバックグラウンドスレッド

1. メインスレッド(Main Thread)—— 核心指揮官

役割

  • コマンド処理全体プロセス:クライアントリクエスト受信→コマンド解析→コマンド実行→レスポンス返却(すべてのコマンド論理実行はメインスレッドで完了)
  • データ構造保守:Redisのコアデータ構造(ハッシュテーブル、スキップリスト、期限切れ辞書など)を保守し、即時操作(`SET`、`GET`、`DEL`など)を処理
  • タスクスケジューリング非同期タスク(非同期削除、永続化トリガーなど)をキューに入れ、バックグラウンドスレッドに処理を委託
  • 子プロセス管理:RDBスナップショット、AOFリライトなどのタスクを実行するために子プロセスを生成(子プロセスとメインスレッドは独立しており、メモリを共有しません)

特徴

  • コマンド実行はシングルスレッドです:複数のクライアントが同時にリクエストしても、コマンドはメインスレッドで順番に実行されるため、Redisのコマンドはアトミックです(ロック不要)
  • メモリ管理:メインスレッドは小オブジェクトのメモリ割り当て・解放を担当し、大規模オブジェクトのメモリ解放はバックグラウンドスレッドに任せる(ブロッキング防止のため)

2. IOスレッド(IO Threads)—— ネットワークIOアクセラレータ

背景

Redisのボトルネックは多くの場合ネットワークIO(多数のクライアント接続の読み書き処理)にあります。Redis 6.0ではIOマルチスレッドが導入され、ネットワーク読み書き操作専門に処理し、メインスレッドの負担を軽減します。

役割

  • クライアントリクエストの読み取り:ソケットからクライアントのコマンドデータ(`read`システムコール)を読み取り、Redisが認識可能なコマンド形式に解析
  • レスポンス結果の送信:メインスレッドがコマンド実行後の結果(`GET`の値、`SET`の成功ステータスなど)をソケットに書き込む(`write`システムコール)

特徴

  • IOのみ処理し、コマンドは実行しない:IOスレッドはネットワークデータの送受信のみを担当し、コマンド解析・実行は引き続きメインスレッドが行います(アトミック性を保証)
  • スレッド数設定可能:`io-threads`でIOスレッド数を設定(デフォルト4個)、`io-threads-do-reads`で読み取り操作のIOマルチスレッドを有効にするか制御
  • パフォーマンス向上:高並列状況(10万以上のクライアント接続など)では、IOマルチスレッドによりネットワーク処理能力が数倍向上します

3. バックグラウンドスレッド(Background Threads)—— 非同期タスク処理機

背景

メインスレッドは時間がかかる操作(大キー削除、fsync永続化ファイルなど)を処理できません。Redis 4.0でレイジーフリー(Lazy Free)が導入され、Redis 7.0でバックグラウンドスレッドが強化され、これらの非同期タスク専門に処理します。

一般的なバックグラウンドスレッドとその役割

Redisのバックグラウンドスレッドはタスクキュー(`lazy_free_queue`、`bio_queue`など)経由でメインスレッドからタスクを受け取り、処理完了後にメインスレッドに通知します。一般的なタスクには以下があります:

バックグラウンドスレッドタイプ 説明 発生シーン
レイジー削除スレッド 期限切れキーまたは`UNLINK`された大キーのメモリ解放 期限切れキーアクセス時(レイジー削除); メインスレッドが大キーを「削除待ち」としてマーク(`UNLINK`コマンド)
永続化IOスレッド RDB/AOFの`fsync`操作処理(メモリデータをディスクにフラッシュ) RDB保存時(`SAVE`/`BGSAVE`); AOFリライト後のディスク書き込み
ファイル記述子クローズスレッド 使用しなくなったソケット接続のクローズ(メインスレッドのブロッキング防止) クライアント切断時

特徴

  • 非同期処理:メインスレッドは時間のかかるタスクをキューに入れるとすぐに戻り、他のリクエストをブロックしません
  • キュー駆動:バックグラウンドスレッドはキューからタスクを取り出して実行(レイジー削除スレッドは`lazy_free_queue`からキーを取得してメモリ解放)
  • ブロッキング防止:例えば1GBのハッシュキーを削除する際、メインスレッドはO(1)時間で辞書から削除し、バックグラウンドスレッドがゆっくりメモリを解放するため、他のクライアントリクエストに影響しません

2. メインスレッドとIOスレッド間の通信・データ交換・呼び出し関係

Redis 6.0以降のバージョンでは、IOマルチスレッド機構(主にネットワークIO操作処理)が導入され、メインスレッドとIOスレッドの連携、通信、データ交換は特定のデザインパターンに従っています。核心は「メインスレッドがコアロジックを担当、IOスレッドがネットワークIOを分担」です。

核心的な分業

  • メインスレッド(Main Thread):コアビジネスロジック実行を担当、コマンド解析、キー・バリューオペレーション(追加・削除・変更・検索)、期限切れキークリーンアップ、永続化(RDB/AOF)、マスタースレーブ同期など。
  • IOスレッド(IO Threads):ネットワークIO操作のみを担当、クライアントリクエスト受信(ソケット読み取り)レスポンス送信(ソケット書き込み)で、コマンドの実際の実行には参加しません。

連携プロセス(クライアントリクエスト処理例)

Redisは「ポーリング+タスク割り当て」方式でメインスレッドとIOスレッドを調整します。プロセスは以下の通り:

  1. 接続監視とタスク割り当て
    メインスレッドは`epoll`(または他のIO多重化機構)を通じてすべてのクライアントソケットの読み取り可能/書き込み可能イベントを監視します。バッチソケットが準備完了になると、メインスレッドはこれらのソケットを複数のIOスレッドに割り当てます(ローテーション方式で均等に割り当て、負荷不均等を回避)。
  2. IOスレッドによるネットワーク読み取り(リクエスト読み取り)
    • メインスレッドは読み取りが必要なソケット情報を(ファイル記述子、バッファなど)IOスレッドに渡し、「読み取り」タスクタイプをマークします。
    • IOスレッドは独立して`read`システムコールを実行し、ソケットからクライアントが送信したコマンドデータを読み取り、指定された入力バッファ(`client->querybuf`)に格納します。
    • すべてのIOスレッドが読み取り操作を完了すると、メインスレッドに通知します(セマフォやフラグビット同期経由)。
  3. メインスレッドによるコマンド実行
    • メインスレッドはすべてのIOスレッドが読み取ったコマンドを統一的に処理:リクエスト解析(入力バッファからコマンド抽出)、コマンド実行(データベース操作)、レスポンス結果生成(出力バッファ`client->buf`に格納)。
    • この段階でIOスレッドはアイドル状態で、コマンド実行には参加しません。
  4. IOスレッドによるネットワーク書き込み(レスポンス送信)
    • メインスレッドはレスポンス送信が必要なソケットをIOスレッドに割り当て、「書き込み」タスクタイプをマークします。
    • IOスレッドは独立して`write`システムコールを実行し、出力バッファのレスポンスデータをクライアントに送信します。
    • すべてのIOスレッドが書き込み操作を完了すると、メインスレッドに通知し、関連リソースを解放(バッファクリーンアップなど)。

通信とデータ交換方法

  1. 共有データ構造
    メインスレッドとIOスレッドは共有クライアントオブジェクト(`client`構造体)経由でデータ交換を行います:
    • IOスレッドが読み取ったコマンドデータは`client->querybuf`(入力バッファ)に格納され、メインスレッドの解析に供されます。
    • メインスレッドがコマンド実行後、レスポンス結果を`client->buf`(出力バッファ)に格納し、IOスレッドの送信に供されます。
  2. 同期機構
    複数スレッドがデータを共有するため、軽量同期でスレッドセーフを保証する必要があります:
    • タスク割り当てと完了通知:グローバルフラグ(`io_threads_pending`など)でタスク状態をマークし、IOスレッドが完了後にフラグをセットし、メインスレッドがポーリングですべてのスレッドの準備完了を待ちます。
    • クリティカルセクション保護:共有クライアントオブジェクト操作時に、局所ロック(`pthread_mutex_t`など)やアトミック操作(`atomic_t`など)で競合を回避(ただしRedisはロック使用を極力減らし、タスク割り当ての無競合設計でオーバーヘッドを低減)。
  3. ロックフリー設計
    RedisはIOスレッドが自分に割り当てられたクライアントオブジェクトのみを操作するようにし、スレッド間データ競合を避け、ロック使用を減少させます。たとえば、各IOスレッドが処理するソケットは独立しており、読み書きバッファは特定のクライアントに属し、メインスレッドは割り当て/回収時に短時間操作のみ行います。

呼び出し関係

  • メインスレッド主導:IOスレッドのタスクはメインスレッドによってトリガー・割り当てられ、メインスレッドが全体プロセスの制御センターです。
  • 呼び出しチェーン
    メインスレッドがイベント監視 → IOスレッドに読み書きタスクを割り当て → IOスレッドが読み書き実行 → メインスレッドがコマンド実行 → メインスレッドが送信タスクをIOスレッドに割り当て → IOスレッドがレスポンス送信 → メインスレッドがリソースクリーンアップ。
  • IOスレッドは受動的実行:IOスレッドはメインスレッドがタスクを割り当てた後にのみ作業し、独自操作発行能力はなく、コアロジックは依然としてメインスレッドが掌握しています。

3. メインスレッドがepoll機構で接続待ちを行う際のブロッキングと他のタスク実行方法

Redisメインスレッドがepoll(またはselect/kqueueなどのIO多重化機構)を使用してIOイベントを待つ際、無期限ブロッキングはせず、タイムアウト制御イベントループ設計により、IO待ちと他のタスク実行の両立を図ります。具体的なロジックは以下の通り:

1. IO多重化呼び出しの「無限ブロッキングなし」特性

epoll(`epoll_wait`)、select(`select`)、kqueue(`kevent`)などのIO多重化関数はすべてタイムアウトパラメータをサポートします。Redisはこれらの関数に短いタイムアウト時間(デフォルトは通常1ミリ秒、設定可能)を設定し、無期限ブロッキングを行いません。

例えば、`epoll_wait`の呼び出し形式は:

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

ここで`timeout`パラメータが`1`(ミリ秒)に設定されている場合:

  • 1ミリ秒以内にIOイベントが準備完了(クライアント接続、データ到着など)すれば、`epoll_wait`は即座に返り、メインスレッドはこれらのイベントを処理できます。
  • 1ミリ秒以内にIOイベントがなければ、`epoll_wait`はタイムアウトで返り(戻り値0)、メインスレッドは他のタスクを実行できます。

2. メインスレッドの「イベントループ」機構

Redisメインスレッドの核となるのはイベントループ(Event Loop)で、おおよそのプロセスは以下の通り:

while (1) {
    // 1. 準備完了したIOイベント処理(IOスレッドまたはメインスレッド自身が処理)
    handle_ready_events();

    // 2. タイマータスク処理(期限切れキークリーンアップ、永続化トリガー、統計更新など)
    handle_time_events();

    // 3. バックグラウンドタスク処理(マスタースレーブ同期、クラスタ通信など)
    handle_background_tasks();

    // 4. IO多重化関数呼び出しで新しいIOイベントを待つ(タイムアウトあり)
    int num_events = epoll_wait(epfd, events, maxevents, 1); // タイムアウト1ミリ秒
}

ポイントは:IO多重化呼び出し(`epoll_wait`など)はイベントループの一環に過ぎず、全体ではありません。メインスレッドは各ループで、まず準備完了したイベントと他のタスクを処理し、その後短いIO待ちに入る;IOイベントがなくても、タイムアウト後に次のループを継続し、他のタスクを実行します。

3. 「IO待ち」と「他のタスク」のバランス確保方法

  • IOイベント優先処理:多数のクライアントリクエストがある場合、`epoll_wait`は頻繁にIOイベントで目覚め、メインスレッドは主にネットワークIOとコマンド実行に集中し、この際他のタスク(タイマータスクなど)はIOの隙間に素早く実行されます。
  • バックグラウンドタスクの飢餓防止:IOイベントがない場合、`epoll_wait`はタイムアウト時間(1ミリ秒など)で定期的に返り、メインスレッドはタイマータスク(`serverCron`関数など、デフォルトで100ミリ秒ごとにコアロジック実行)、永続化チェック、メモリ回収などを実行できます。

この設計により、クライアントリクエストが長期間ない場合でも、メインスレッドがIO待ちで「固まる」ことはなく、定期的にバックグラウンドタスクを処理できます。

4. forkとは何か?RDB永続化時の対応アプリケーション

Redis RDB永続化における`fork`の役割を理解するには、OSプロセス作成メカニズムRedisの非同期永続化設計の2つの観点から展開する必要があります:

`fork`とは何か?—— OSのプロセスクローン技術

`fork`はUnix/Linuxシステムが提供するコアシステムコールで、親プロセスと完全に同じ子プロセスを作成するために使用されます。子プロセスは親プロセスの以下の要素を継承します:

  • メモリ空間(コード、データ、ヒープスタック)
  • ファイル記述子(ソケット接続など)
  • プロセスコンテキスト(レジスタ状態、シグナル処理関数など)

しかし、子プロセスには2つの重要な「独立属性」があります:

  1. 独立したプロセスID(PID)
  2. 独立したページテーブル(Page Table)—— これは`fork`の高性能の核(後述)

Redisにおける`fork`の役割—— 非同期RDBスナップショット生成

RedisのRDB永続化ではメモリデータのスナップショット生成が必要ですが、これをメインスレッドが直接生成すると、以下の2つの致命的な問題に直面します:

  1. すべてのリクエストをブロック:スナップショット生成にはメモリ全体を走査する必要があり、時間がかかります(例えば10GBメモリでは数秒かかる可能性)
  2. メモリ倍増:スナップショット生成にはメモリのコピーが必要で、メモリ使用量が倍になります

これらの問題を解決するため、Redisは`fork`子プロセス方式を採用します:

  1. メインスレッドが`fork`を呼び出す:子プロセスを作成し、子プロセスは親プロセスのメモリ空間を共有(ページテーブルマッピング経由)
  2. 子プロセスがRDBを生成:子プロセスは共有メモリデータを走査し、シリアライズしてRDBファイルに書き込み
  3. メインスレッドがサービス継続:メインスレッドは子プロセスの完了を待つことなく、クライアントリクエスト処理を継続

`fork`の核となる魔法—— 書き込み時コピー(Copy-On-Write、COW)

`fork`がメモリ倍増を引き起こさない理由、子プロセスが安全にスナップショットを生成できる理由は、書き込み時コピー(COW)メカニズムにあります。

1. COWの動作原理

`fork`で子プロセスを作成する際、親プロセスと子プロセスは同じ物理メモリページを共有(ページテーブルマッピングのみで実データはコピーしない)。親プロセスまたは子プロセスがあるメモリページを変更する場合、OSは以下の操作をトリガーします:

  1. 該当メモリページを子プロセス(または親プロセス)のプライベートスペースにコピー
  2. ページテーブルを更新し、変更側が新しいプライベートページを指すようにする

つまり変更されていないメモリページは共有され、変更されたものだけがコピーされます。

2. COWのRedis RDBへの意義

  • メモリオーバーヘッド極小:子プロセス作成時、親プロセスのページテーブルのみをコピー(メタデータ、メモリの1/1000程度のサイズ)、全メモリコピーは不要
  • スナップショット一貫性:子プロセスがメモリを走査する際、親プロセスの変更はCOWで隔離(子プロセスは`fork`時点のメモリスナップショットを見る)
  • メインスレッドの無意識:親プロセス(メインスレッド)のメモリ変更は子プロセスのスナップショット生成に影響しない

`fork`のRDBにおける完全なプロセス

COWを組み合わせ、Redis RDBの`fork`プロセスは5つのステップに分解できます:

  1. メインスレッドが`fork`を呼び出す:子プロセスを作成し、親子プロセスがメモリを共有、ページテーブルのみコピー
  2. 子プロセスがRDBファイル初期化:一時的なRDBファイルを開く(`temp-xxxx.rdb`など)
  3. 子プロセスがメモリ走査完全走査メモリ内のキー・バリューをシリアライズしてRDBファイルに書き込み
  4. メインスレッドがリクエスト処理継続:メインスレッドの書き込み操作がCOWをトリガーし、変更されたメモリページがメインスレッドプライベートスペースにコピーされ、子プロセスに影響しない
  5. 子プロセスがRDB書き込み完了:子プロセスが一時ファイルを正式なRDBファイルにリネームし、終了
  6. メインスレッドがクライアントに通知:RDB生成完了(`BGSAVE DONE`メッセージ経由)

`fork`のオーバーヘッドと最適化提案

`fork`+COWは非常に効率的ですが、一定のオーバーヘッドがあります(主にページテーブルコピー子プロセスのメモリ走査):

  • ページテーブルコピーのオーバーヘッド:親プロセスのメモリが大きいほど、ページテーブルが大きくなり、`fork`時間が長くなる
  • 子プロセスのメモリ走査オーバーヘッド:メモリが大きいほど、走査時間が長くなる

Redisの最適化提案

  1. 低ピーク時に`BGSAVE`実行:高並列タイムにRDB生成をトリガーしないことで、メインスレッドへの影響を軽減
  2. 最大メモリ制限:`maxmemory`設定でRedisメモリサイズを制御し、`fork`オーバーヘッドを低下
  3. `save`指令による`BGSAVE`代替?:推奨しません!`save`はメインスレッドで同期的にRDBを生成し、すべてのリクエストをブロックします。小メモリシーンのみ適用可能です。

5. COWの底層実装とLinuxページテーブル機構

書き込み時コピー(Copy-On-Write、COW)Linuxページテーブル機構を深く理解するには、プロセスメモリ管理の基礎モデルから説明し、徐々に両者の底層実装を分解し、最後にRedis RDBシーンとの関連を説明します。

前提知識:Linuxプロセスのメモリ管理基礎

Linuxでは、各プロセスは独立した仮想アドレス空間(Virtual Address Space)を持ち、異なるプロセスのメモリアクセスを隔離します。仮想アドレス空間はページテーブル(Page Table)経由で物理メモリのページフレーム(Page Frame)にマッピングされます—— これがLinuxメモリ管理の核となる抽象化です。

1. 仮想アドレス空間

各プロセスの仮想アドレス空間は複数の領域に分けられます(x86_64の場合):

  • カーネル空間:高アドレス部(`0xffff800000000000`以上)、カーネル専有、プロセスは直接アクセス不可
  • ユーザースペース:低アドレス部(`0x0000000000000000`〜`0x00007fffffffffff`)、含まれる:
    • コードセグメント(Text):実行可能命令格納
    • データセグメント(Data):グローバル変数、静的変数格納
    • ヒープ(Heap):動的割り当てメモリ(`malloc`など)
    • スタック(Stack):関数呼び出しスタック、ローカル変数
    • 共有ライブラリ:libc.soなどの共通ライブラリマッピング

2. ページテーブルと物理ページフレーム

仮想アドレスは物理メモリに直接アクセスできず、ページテーブルで物理アドレスに変換する必要があります。ページテーブルは階層構造(マルチレベルページテーブル)で、x86_64の場合、4レベルページテーブルを採用:

  • ページグローバルディレクトリ(PGD):各プロセス独自のページテーブル、最上位ページテーブル項目(PML4E)格納
  • ページ上位ディレクトリ(PUD)ページ中間ディレクトリ(PMD):中間層、徐々にアドレス範囲縮小
  • ページテーブル(PT):最終段階、ページテーブル項目(PTE)格納、各PTEは4KBの物理ページフレームをマッピング

3. アドレス変換プロセス

プロセスが仮想アドレス`VA`にアクセスする際、CPUは以下の手順で物理アドレス`PA`に変換:

  1. PGDインデックス抽出:`VA`の上位からPGDインデックス抽出、プロセスのPGD取得
  2. 逐次検索:PGDインデックスでPUD取得、PUDインデックスでPMD取得、最終的にPMDインデックスでPT取得
  3. PTE取得:PTから対応PTE取り出し、これに物理ページフレーム番号(PFN)含む
  4. PA計算:PFNを左シフト12ビット(各ページフレーム4KB=2^12バイト)、`VA`のページ内オフセット加算で物理アドレス`PA`取得

forkシステムコールの底層実装

`fork`の核は親プロセスとほぼ同じ子プロセスを作成することですが、子プロセスの仮想アドレス空間は親プロセスの「浅いコピー」—— すなわち子プロセスは親プロセスのページテーブルをコピーしますが、物理ページフレームを共有します。

1. forkの実行プロセス

親プロセスが`fork()`を呼び出す際、カーネルは以下のステップを実行:

  1. プロセスコンテキストコピー:親プロセスのレジスタ、PCB(プロセス制御ブロック)などの状態をコピーし、子プロセスのPCB生成
  2. ページテーブルコピー:子プロセスのPGDは親プロセスPGDのコピー(すなわち子プロセスは独自ページテーブルを持つが、ページテーブル項目は同じ物理ページフレームを指す
  3. 子プロセス仮想アドレス空間設定:子プロセスの仮想アドレス空間は親プロセスと完全一致、ただしページテーブルは独立
  4. 子プロセスPID返却:親プロセスは子プロセスPID返却、子プロセスは0返却

2. キー:「書き込み時コピー」の伏線

fork後、子プロセスのページテーブル項目は親プロセスと完全に一致しますが、カーネルはこれらのページテーブル項目の読み取り専用フラグ(Read-Only)を1に設定します—— 親プロセスのページが書き込み可能でもです。このステップがCOWの核となる伏線:親プロセスまたは子プロセスが後でメモリ書き込みを試みると、ページフォルトがトリガーされます。

書き込み時コピー(COW)の底層メカニズム

COWの本質は遅延コピー:親プロセスと子プロセスは物理ページを共有し、どちらかのプロセスが該当ページを書き込むまで、そのページを新ページフレームにコピーして、両者のメモリ隔離を保証します。

1. COWのトリガ条件

プロセスが読み取り専用ページ(または共有ページ)を書き込もうとする際、CPUはページフォルト(Page Fault)例外をトリガーし、カーネルモードに移行します。fork後の子プロセスでは、すべての共有ページのページテーブル項目は読み取り専用にマークされているため、親プロセスまたは子プロセスの書き込み操作はすべてページフォルトをトリガーします。

2. ページフォルト処理プロセス

カーネルのページフォルト処理プログラムは以下のステップで処理:

  1. ページテーブル項目(PTE)検査
    • 該当ページが共有ページ(親子プロセス共有)であることを確認
    • エラー種別が書き込みエラー(プロセスが読み取り専用ページ書き込みを試みた)であることを確認
  2. 新物理ページフレーム割り当て:物理メモリから空きページフレーム検索(またはスワップ領域から読み込み、ダーティページの場合)
  3. 元ページ内容コピー:元物理ページ内容を新ページフレームにコピー
  4. ページテーブル項目更新
    • 書き込みプロセス(親または子)用:ページテーブル項目を修正し、新ページフレームを指すようにし、書き込み可能フラグ(Write Enable)を1に設定
    • 非書き込みプロセス用:ページテーブル項目は変更せず(元ページフレームを指し続ける、読み取り専用)
  5. TLB更新:CPUキャッシュの元ページテーブル項目削除(後続アクセスで古いマッピング使用防止)
  6. ユーザースペース復帰:ページフォルトをトリガーした書き込み操作を再実行(この時点で新ページフレームを書き込むため、他プロセスには影響しない)

3. COWの核となる利点

  • 低メモリオーバーヘッド:fork時はページテーブルのみコピー(総メモリの約1/1000)、全物理メモリコピー不要
  • スナップショット一貫性:子プロセスはfork時点のメモリ状態を見る(親プロセスの変更はCOWでトリガーされ、新ページコピー、子プロセスは元ページを指し続ける)
  • 無意識:親プロセスと子プロセスは追加コード処理不要、カーネルが透過的に完了

6. 永続化プロセスがメインスレッドに与える影響

「Redis永続化時、子プロセスがメインスレッドとCPU時間を取り合うか?」という質問に答えるには、LinuxプロセススケジューリングメカニズムRedis子プロセスの設計位置エンジニアリング最適化戦略の3つの観点から展開する必要があります—— 核となる結論は:子プロセスはCPUスケジューリングに参加(理論的には取り合い可能)しますが、Redisは一連の設計によりこの取り合いの影響を最小限に抑え、メインスレッドのコアサービスに影響を与えないようにしています

前提知識:LinuxプロセススケジューリングとCPU時間割り当て

Linuxはプリエンプティブスケジューリング(Preemptive Scheduling)を採用し、核となる規則は:

  • 各プロセス(子プロセス含む)は独自のタイムスライス(Time Slice)を持ち、スケジューラ(CFS、Completely Fair Schedulerなど)が割り当て
  • スケジューラはプロセスの優先度(Nice値)実行状態(準備/実行)履歴CPU使用に基づき公平にCPU時間を割り当て
  • どのプロセス(子プロセス含む)もメインスレッドのタイムスライスを奪う可能性があり、優先度が高いか現在準備状態であれば

Redis永続化子プロセスのワークロードとスケジューリング特性

Redisの永続化子プロセス(`BGSAVE`で生成されるRDB子プロセス、`BGREWRITEAOF`で生成されるAOFリライト子プロセス)には2つの重要な特性があります:

1. 子プロセスは「オフラインタスク」プロセス

子プロセスの核となる責務は永続化ファイル生成(RDBスナップショット、AOFリライトなど)で、このプロセスは:

  • CPU集約型:メモリデータ走査、シリアライズ、ディスク書き込みが必要
  • 非インタラクティブ:クライアントリクエストに応答不要、固定タスク完了後に終了
  • 独立メモリ空間:COWメカニズムにより、子プロセスのメモリは親プロセスと徐々に分離(親プロセスの変更はCOWをトリガーし、子プロセスは元データ保持)

2. 子プロセスのスケジューリング優先度が低下

Redisは子プロセスのNice値(Linuxプロセス優先度のユーザーモード調整方法、範囲-20〜19、値が低いほど優先度が高い)を調整し、子プロセスの優先度を低優先度に設定:

  • デフォルトでは、Redis子プロセスのNice値は10(`config set save ""`などのコマンド間接調整、または`nice`コマンドでRedis起動時設定可能)
  • メインスレッド(クライアントリクエスト処理)のNice値は通常0(デフォルトユーザーモード優先度)のため、メインスレッドのスケジューリング優先度は子プロセスより高い

なぜ「取り合い影響極小」か?—— Redisの設計最適化

子プロセスがCPU時間を奪う可能性があっても、Redisの以下の設計により、メインスレッドサービス可用性への影響は無視できます:

1. メインスレッドの核となるタスクは「リクエスト処理」、CPU計算ではない

Redisメインスレッドの核となる負荷はネットワークIOとコマンド実行

  • IOマルチスレッド(Redis 6.0+):ネットワーク読み書きは専門IOスレッドが処理、メインスレッドはコマンド解析と実行のみ
  • コマンド実行は軽量:Redisのコマンド(`GET`/`SET`など)はすべてO(1)またはO(logN)のアトミック操作、メインスレッドのCPU使用は本来低い

2. 子プロセスのCPU使用は「一括」、持続的ではない

永続化子プロセスの作業は一括メモリ走査(RDB生成はすべてのキー・バリューを走査)であり、持続的なCPU使用ではありません:

  • 10GBメモリのRedisインスタンスでは、RDB子プロセスの走査時間は数十ミリ秒〜数秒(CPU性能依存)
  • 走査完了後、子プロセスの主な作業はディスク書き込み(ディスクIO速度決定、CPU使用率低)

3. Linuxスケジューラの「公平性」と「優先度隔離」

子プロセスとメインスレッドがCPUを競合しても、スケジューラは:

  • タイムスライスを高優先度のメインスレッド(Nice値0 > 子プロセスの10)に優先割り当て
  • 子プロセスはメインスレッドがスリープ/IO待ち状態の際のみCPU時間獲得(例えばメインスレッドがIOスレッドからのリクエスト待ちの際、子プロセスが実行可能)

7. Linux遅延ディスク書き込みが永続化に与える影響分析

Redis RDB永続化におけるディスク異常/書き込み失敗によるデータ損失問題ディスク書き込み成功の保証方法について答えるには、RDBの書き込みプロセスOSのPage CacheメカニズムRedisの設定オプションの3つの観点から展開し、「絶対安全」と「エンジニアリング上の高信頼性」の境界を明確にする必要があります。

まず明確に:RDB書き込みプロセスとPage Cacheの「遅延書き込み」

Redis RDB永続化の核となるプロセスは子プロセスによるRDBファイル生成ですが、このプロセスのディスク書き込み段階は「遅延」—— 子プロセスがRDBファイルを書き込む際、データはまずOSのPage Cache(ディスクキャッシュのメモリ内)に入り、物理ディスクに直接書き込まれません。Page Cacheが「フラッシュ」されるまで、データは実際にディスクに保存されません。

1. RDB書き込みの具体的ステップ

`BGSAVE`(非同期RDB生成)を例に:

  1. 親プロセスが子プロセスをfork:子プロセスが親プロセスのページテーブルをコピー、メモリ共有(COWメカニズム)
  2. 子プロセスが一時RDBファイル生成:子プロセスがメモリを走査し、データをシリアライズして一時ファイル(`temp-1234.rdb`など)に書き込み
  3. 子プロセスが一時ファイル名変更:子プロセス終了前に、一時ファイルをアトミックにリネームして正式RDBファイル(`dump.rdb`など)
  4. 親プロセスが完了通知:親プロセスが子プロセスの「完了シグナル」を受け取り、クライアントにRDB生成完了を通知

2. キー問題:Page Cacheの遅延書き込み

子プロセスが一時ファイルを書き込む際、データはOSのPage Cacheに格納されます(LinuxのIO性能向上キャッシュメカニズム)。この時、RDBファイルが生成されていても(一時ファイルまたはリネーム後の正式ファイル)、データはまだ実際にディスクに保存されていません—— この時以下が発生すると:

  • ディスク物理障害(不良セクタなど)
  • システムクラッシュ(停電、OOMなど)
  • OSクラッシュ

Page Cache内のデータは消失し、RDBファイルが不完全またはデータエラーになる可能性があります。

8. RDB永続化時のプロセス間通信メカニズム

Redis永続化における親プロセスが子プロセス完了シグナル受信クライアントが結果取得後続処理について答えるには、プロセス間通信(IPC)Redis状態管理クライアント対話設計の3つの観点から展開し、Redisソースコードロジックとエンジニアリング実践を結合して説明する必要があります:

親プロセスが子プロセスの「完了シグナル」をどう受け取るか?

Redisの永続化子プロセス(`BGSAVE`のRDB子プロセスなど)がタスク完了後、OSプロセスメカニズム経由で親プロセスに通知し、親プロセスが内部状態を更新します。核となるプロセスは3段階:

1. 子プロセスが終了時に`SIGCHLD`シグナル送信

子プロセスがRDBファイル生成完了(一時ファイル書き込み→アトミックリネーム→終了)後、`exit(0)`(成功)または`exit(非0)`(失敗)を呼び出して自分自身を終了します。この時、OSは親プロセスに`SIGCHLD`シグナル(子プロセス状態変更通知)を送信します。

Redis親プロセス(メインスレッド)は`SIGCHLD`シグナル処理関数(`src/server.c`の`sigchld_handler`など)を登録し、以下の用途:

  • `waitpid`システムコール呼び出し、子プロセスリソース回収(ゾンビプロセス防止)
  • 子プロセスの終了コード(`status`パラメータ)取得、タスク成功確認

2. 親プロセスが`waitpid`で子プロセス状態確認

親プロセスはシグナル処理関数で子プロセス終了を感知するだけでなく、定期的に子プロセス状態を確認するために`waitpid`を主動的に呼び出します(非ブロッキングモード、`WNOHANG`オプションなど):

  • 子プロセスが終了済みの場合、`waitpid`は子プロセスPIDと終了コードを返します
  • 子プロセスが実行中の場合、`waitpid`は0を返し、親プロセスは待機継続

3. 終了コードに基づくRDB状態更新

親プロセスは子プロセスの終了コードに基づき、Redis内部のRDB状態変数を更新:

  • 終了コードが`0`(成功)の場合:`redisServer`構造体の`rdb_last_bgsave_status`を`"ok"`に設定し、`rdb_last_bgsave_time`(最後に成功保存したタイムスタンプ)を更新
  • 終了コードが非`0`(失敗)の場合:`rdb_last_bgsave_status`を`"err"`に設定し、エラー原因記録(ディスク容量不足など)

クライアントがRDB完了結果をどう受け取るか?

Redisの`BGSAVE`/`BGREWRITEAOF`は非同期コマンドで、クライアント送信後即座に返却(`Background saving started`など)し、完了通知をリアルタイムで受け取ることはできません。クライアントは主動的にクエリまたは監視ツールで結果を取得する必要があります:

1. 主動的クエリ:Redisコマンドで状態取得

クライアントは以下のコマンドでRDB最新状態をクエリできます:

  • `INFO stats`:`rdb_last_bgsave_status`フィールド確認(値が`"ok"`なら成功、`"err"`なら失敗)
    例出力:`rdb_last_bgsave_status:ok`
  • `LASTSAVE`:最後にRDBを成功生成したUnixタイムスタンプ(秒単位)返却
    例出力:`last_save_time:1717123456`
  • `INFO persistence`:より詳細な永続化状態確認(`rdb_last_bgsave_time`:最後保存時間文字列など)

2. 監視ツール:完了イベントリアルタイム感知

本番環境では、通常監視システムでRDB完了をリアルタイム取得:

  • Redis Exporter + Prometheus/Grafana:`redis_server_rdb_last_bgsave_status`メトリクス収集、状態が`err`から`ok`に変わる際にアラートトリガー
  • Redis `MONITOR`コマンド:Redisサーバーが受信したすべてのコマンドをリアルタイム表示、`BGSAVE`開始と完了監視可能(ただし本番環境長期使用は非推奨)
  • サードパーティAPMツール:Datadog、New Relicなど、Redisプラグイン統合で永続化状態自動監視

9. AOFメカニズム詳細解説

RedisのAOF(Append Only File)永続化メカニズムを理解するには、その核となる設計目的(RDBの「データ損失リスク」解決)、書き込みプロセス同期戦略リライトメカニズムRDBとの差異の5つの観点から展開する必要があります。AOFの本質は「増分ログ+リアルタイムディスク書き込み」で、すべての書き込み操作コマンドを記録することでより高いデータセキュリティを実現します。

AOFの核となる原理:「すべての書き込み操作」記録

AOF永続化の核となるロジックは:Redisが実行したすべての書き込みコマンド(`SET`、`INCR`、`HSET`など)をテキスト形式でAOFファイル末尾に追加することです。Redis再起動時、AOFファイル内のコマンドを順次実行すれば、最新データ状態に復元できます。

RDBの「完全スナップショット」とは異なり、AOFは「増分ログ」—— データ状態ではなく、「現在の状態に至る操作系列」を記録します。

AOFの書き込みプロセス:コマンドからファイルまでの完全リンク

AOFの書き込みプロセスは3段階に分けられます:コマンド追加→ファイル同期→ログ最適化。

1. 第一段階:コマンド追加(Append)

Redisが書き込みコマンドを実行後、該当コマンドをテキスト形式にシリアライズし、AOFバッファ(メモリ内キュー)に追加します。

  • シリアライズルール:AOFファイルはプレーンテキストで、Redisプロトコル形式採用(`*3\r\n$3\r\nSET\r\n$5\r\nkey\r\n$5\r\nvalue\r\n`は`SET key value`を示す)
  • バッファの役割:毎回書き込みコマンドで直接IOを避ける、パフォーマンス向上

2. 第二段階:ファイル同期(Fsync)

AOFバッファの内容は定期的またはリアルタイムでディスクにフラッシュされ、このステップは`appendfsync`設定で制御されます:

Redisは3つの同期戦略を提供(核となる差異は「データセキュリティ」と「パフォーマンス」のバランス):

戦略 説明 パフォーマンスとセキュリティ
`always` 各書き込みコマンド後に`fdatasync`呼び出し、強制ディスク書き込み 最も安全(コマンド損失なし)、パフォーマンス最悪(頻繁IO、QPS30%-50%低下)
`everysec` 1秒ごとに`fdatasync`呼び出し、1秒前のコマンドをディスク書き込み 折衷案(最大1秒データ損失)、パフォーマンス良好(デフォルト推奨)
`no` OSがいつディスク書き込みするか決定(Linuxの`pdflush`スレッドは数秒ごとにフラッシュ) パフォーマンス最高(追加IOなし)、最も安全でない(複数秒データ損失可能性)

3. 第三段階:ログリライト(Rewrite)—— 冗長ログ圧縮

時間経過とともに、AOFファイルは大きくなります(同じキーを繰り返し変更すると、多くの古いコマンドが蓄積)。AOFリライトメカニズムはこれらの冗長ログを最小有効コマンドセットに圧縮し、ファイルサイズを削減します。

リライトトリガ条件
  • 手動トリガ:`BGREWRITEAOF`コマンド実行
  • 自動トリガ:AOFファイルサイズが`auto-aof-rewrite-min-size`(デフォルト64MB)を超えた場合、かつ現在サイズが前回リライト後サイズの1倍以上(デフォルト)

AOFとRDBの核となる差異

次元 RDB(完全スナップショット) AOF(増分ログ)
データセキュリティ 最後のスナップショット後のすべてデータ損失可能性(デフォルト`rdbfsync no`) 最大1秒データ損失(`everysec`)またはゼロ損失(`always`)
ファイルサイズ 小(バイナリ圧縮) 大(テキストログ、リライト圧縮必要)
復元速度 高速(スナップショット直接読み込み) 低速(すべてのコマンドを順次実行)
永続化頻度 低頻度(1時間ごとなど) 高頻度(1秒ごとまたは各コマンド)
適用シーン キャッシュ(少量データ損失許容) データセキュリティ要求が高いシーン(金融、注文など)

10. AOFリライト中の書き込み操作処理プロセス

AOFファイルリライト中にメインスレッド(親プロセス)が書き込みリクエストを処理してもリライト結果に影響しない理由を理解するには、AOFリライトの「入力」に関する核となる誤解をまず修正する必要があります:AOFリライトの入力は「古いAOFログファイル」であり、親プロセスのメモリ状態ではありません。この上で、Redisは「子プロセスが古いログを圧縮+親プロセスが新しいリクエストを一時保存+最終結合」のメカニズムにより、「リライトでデータ損失なし、メインスレッド正常サービス」の目標を実現します。

AOFリライトの核となる目標と入出力

AOF(Append Only File)は増分書き込みログで、長期間運用によりファイル膨張を引き起こします(同じキーを繰り返し変更すると多くの古いコマンドが蓄積)。AOFリライトの核となる目標は:

  • 冗長圧縮:古いAOFファイル内の「同一キー複数変更コマンド」を「最終状態コマンド」に置換(100回`INCR counter`を1回`SET counter 100`に置換)
  • 意味維持:新しいAOFファイルは古いファイルのすべて書き込み操作効果を保持し、リライト中の新しい書き込みリクエストをサポート

入力:古いAOFファイル(`appendonly.aof`など)

出力:新しい圧縮AOFファイル(`appendonly.aof.new`など)

AOFリライトの完全プロセス:子プロセスが古いログを圧縮、親プロセスが新しいリクエスト処理

AOFリライトは子プロセスが古いログの圧縮を主導親プロセスが書き込みリクエストを処理して新しいコマンドを一時保存、最終的に両者を結合して新しいファイルを生成します。具体的なプロセスは以下の通り:

1. リライトトリガ:親プロセスが条件判断

親プロセスは2つの方法でリライトをトリガ:

  • 手動トリガ:`BGREWRITEAOF`コマンド実行
  • 自動トリガ:AOFファイルサイズが`auto-aof-rewrite-min-size`(デフォルト64MB)を超え、かつ現在サイズが前回リライト後サイズの1倍以上(デフォルト)

2. 親プロセスが子プロセスをfork:古いAOFファイル記述子共有

親プロセスが`fork()`呼び出しで子プロセスを作成時:

  • 子プロセスは親プロセスのファイル記述子(古いAOFファイル記述子含む)を継承、直接古いAOFファイル読込可能
  • 子プロセスのページテーブルは親プロセスのコピーだが、メモリデータを共有しない(子プロセスの入力は古いAOFファイル、メモリ状態ではない)

3. 子プロセス:古いAOFファイル読込、圧縮後の新しいファイル生成

子プロセスの核となるタスクは古いAOFファイルの圧縮

  • 古いAOFファイル内のコマンドを1行ずつ読込
  • 各キーの変更コマンドに対し、最終状態再構築(複数`INCR counter`で最終値計算、`SET counter `生成)
  • 再構築されたコマンドを新しいAOF一時ファイル(`appendonly.aof.new`など)に書き込み

4. 親プロセス:書き込みリクエスト処理、新しいコマンドをバッファに一時保存

子プロセスが古いログを圧縮している間、親プロセスは通常通りクライアントの書き込みリクエストを処理し、これらのリクエストを2つの部分に処理:

  • 古いAOFファイルに追加:`appendfsync`戦略(`everysec`など)に基づき、書き込みコマンドを古いAOFファイルにフラッシュし、古いファイルの完全性を保証
  • AOFバッファに追加:書き込みコマンドをメモリ内のAOFバッファ(`aof_buf`など)に保存し、リライト中の新しいリクエストを一時保存

5. 子プロセス完了、親プロセスがバッファ結合

子プロセスが新しいAOF一時ファイルの書き込み完了後:

  • 子プロセスが`exit(0)`呼び出し、`SIGCHLD`シグナルを親プロセスに送信
  • 親プロセスがシグナル受信後、`waitpid`呼び出しで子プロセス回収、リライト成功確認
  • 親プロセスがAOFバッファ内のコマンドを新しいAOF一時ファイルに追加(このステップでリライト中の新しいリクエストが新しいファイルに含まれることを保証)

6. 古いファイル置換:アトミック操作で一貫性保証

親プロセスが新しいAOF一時ファイルをアトミックにリネームして正式なAOFファイル(`appendonly.aof.new`を`appendonly.aof`にリネーム)し、リライト完了。

タグ: redis スレッドモデル 永続化 RDB aof

7月15日 17:19 投稿