【テクニカル・上級編】 AOF書き換え(Rewrite) – Redis

AOF Rewriteの深淵:なぜフルブロッキングを回避し続けられるのか

Redisの永続化において、AOF(Append Only File)は単なる操作ログの追記に留まらない。データの整合性を担保する生命線である。しかし、運用を続ける限り、AOFファイルはエントロピーの増大と共に肥大化する。`SET key 1`を100回繰り返せば、ファイルには100行のコマンドが残る。これをリブート時にすべて再生(Replay)させるのは、明らかにCPUとI/Oの無駄だ。

ここで登場するのが AOF Rewrite(`BGREWRITEAOF`) である。
「現在のメモリ上のデータセットを最小限のコマンドに圧縮し、新しいAOFファイルを生成する」—この一言で片付けられるほど、Redisの内部実装は甘くない。シングルスレッドで動くインメモリデータベースが、数十GBに及ぶメモリ空間のスナップショットを切りつつ、クライアントからの数万QPSをさばきながらAOFを再構築する。

本稿では、この一見矛盾する偉業を成し遂げているRedisの内部アーキテクチャ、OSカーネルとの協調、そして極限のメモリ最適化メカニズムを解き明かす。

—

1. フォーク(Fork)の呪縛とCopy-on-Write(CoW)の限界

AOF Rewriteのトリガーが引かれた瞬間、Redisは親プロセスから子プロセスを生成するため、`fork()` システムコールを呼ぶ。

[Redis Parent Process (Main Thread)]
│
├─ 1. fork() ──> [Redis Child Process]
│ │
│ └─ 2. 現在のメモリ状態を逆算し、
│ 最小限のコマンド列を「一時AOFファイル」へ直列化
│
└─ 3. クライアントからのリクエスト処理を継続
(書き込み発生時は Copy-on-Write が発動)

ここで最初のアーキテクチャ上の難所がある。`fork()` 自体は高速だが、巨大なデータセット(例えば、メモリ使用量が50GBに達するインスタンス)では、ページテーブル(Page Table)の複製コストと、将来的なCopy-on-Write(CoW)によるメモリオーバヘッドがシステム全体を揺るがす。

ページテーブルのコピー時間

Linuxの `fork()` は、メモリ上の実データをコピーせず仮想メモリのページテーブルを複製する。しかし、50GBのインスタンス(数千万のキーとハッシュ構造を持つ)では、ページテーブル自体のサイズが数百MBに及び、`fork()` の瞬間にメインスレッドが数ミリ秒〜数十ミリ秒単位で完全にフリーズする。
高ス負荷な金融系システムやリアルタイムゲーミングバックエンドにおいて、この数ミリ秒のレイテンシスパイクは致命傷になり得る。

Linuxカーネルの落とし穴:THP(Transparent Huge Pages)

さらに厄介なのがOS側の機能である。THPが有効な場合、カーネルは2MB単位の巨大なメモリページを割り当てる。
CoWの仕組み上、子プロセスの生存中に親プロセスが2MBのページの「ほんの1バイト」でも書き換えを行おうとすると、カーネルは2MBのページ全体を丸ごとコピーし直さなければならない(Huge Page Copy)。これにより、メモリ消費量が急増(メモリバースト)し、最悪の場合はOSのOOM KillerによってRedisが強制終了させられる。

チーフアーキテクトの知見:
本番環境のRedisにおいて、`vm.overcommit_memory = 1` の設定と、OSレベルでのTHPの無効化(`never`)は絶対の前提条件である。これを行わずにAOF Rewrite運用を語ることは許されない。

—

2. AOF Rewriting Buffer:子プロセス生成中の「差分」をどう調停するか

子プロセスが起動した瞬間から、それは「過去の静止点(Point-in-time snapshot)」のデータを書き出し始める。しかし、その裏でメインスレッドはリアルタイムでクライアントからの書き込み(`WRITE` / `DEL` など)を受け付け続けている。

子プロセスがディスクへの書き出しを終えたとき、メインスレッドがその間に受けた「差分」はどうなるのか?
ここで消えてしまってはデータの整合性が完全に破壊される。

この矛盾を解決するため、Redisは AOF Rewriting Buffer という専用のバッファをメモリ上に保持する。

// server.h (概念的な構造体のイメージ)
struct redisServer {
// … 既存のフィールド …
client aof_selected_client;
/ AOF rewrite専用のバッファ /
rio aof_rewrite_buf;
// …
};

1. `fork()` 実行直後: メインスレッドは、以降に実行されるすべてのAOF対象コマンドを、通常のAOFバッファだけでなく、「AOF Rewriting Buffer」にも二重に書き込み始める。
2. 子プロセスの作業中: 子プロセスは、`fork()` 時点のメモリのスナップショットを元に、順次コマンドを生成して一時ファイル(`temp-rewriteaof-bg.aof`)に吐き出す。この間、メインスレッドは通常通りクライアントのリクエストを処理し、差分コマンドがRewriting Bufferに蓄積されていく。
3. 子プロセスの完了: 子プロセスがファイル書き出しを完了すると、親プロセス(メインスレッド)にシグナル(`SIGUSR1`等)を送る。
4. 最終同期フェーズ(一時的なブロック):
メインスレッドはここでごく短時間だけ処理をブロックし、AOF Rewriting Bufferに溜まった差分コマンドを、子プロセスが作った一時ファイルに追記する。
5. アトミックな置換: 最後に、OSの `rename()` システムコールを使い、一時ファイルを既存のAOFファイルにアトミックに上書きする。

この「差分バッファの追記とファイル名の置換」の瞬間だけが、AOF Rewriteにおいてメインスレッドが同期的に待たされる唯一のフェーズであり、その時間は数千〜数万コマンドであっても数ミリ秒で完了する。

—

3. コマンドの最適化:単なるログから「最小限の復元セット」へ

AOF Rewriteの本質は、ファイルサイズを減らすことだけではない。「データ構造の再構築コストを下げること」にある。

例えば、以下のようなハッシュ(Hash)の操作があったとする。

HSET user:1000 name “Alice”
HSET user:1000 age “30”
HSET user:1000 status “active”
HDEL user:1000 status
HSET user:1000 status “pending”

これをそのまま記録し続けると、AOFファイルは肥大化し、再起動時のパース・実行コストが跳ね上がる。
AOF Rewriteのエンジンは、メモリ上のデータ構造(この場合はディクショナリやスキップリストなど)を直接走査し、最終的な結果から逆算した「単一のコンパクトなコマンド」を生成する。

上記の例であれば、Rewriteによって生成されるコマンドは以下の1行(あるいは一括投入用のコマンド)に最適化される。

HMSET user:1000 “name” “Alice” “age” “30” “status” “pending”

文字列以外のデータ構造における特殊処理

  • Strings: 現在の値を出力する `SET` コマンドに還元される(有効期限(TTL)が設定されている場合は、`PEXPIREAT` も同時に生成され、時系列の整合性が完全に維持される)。
  • Lists / Sets / Sorted Sets / Hashes: 要素数が膨大な場合、Redisは無限ループやバッファあふれを防ぐため、一定のチャンクサイズ(通常は64KB単位、内部マクロ `AOF_REWRITE_ITEMS_PER_CMD`)ごとに分割して `RPMS`、`SADD`、`ZADD`、`HMSET` などのバルクコマンドを構築する。これにより、メモリ効率とパース性能の極限のバランスを取っている。

—

4. 実務における限界突破のチューニング指針

AOF Rewriteのメカニズムを熟知したアーキテクトであれば、デフォルト設定のまま本番運用することはない。ハードウェアの限界を引き出すための設定指針を提示する。

1. `auto-aof-rewrite-percentage` と `auto-aof-rewrite-min-size`

auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

  • 知見: 小規模なインスタンスでは問題ないが、数十GB〜数百GBのメモリを持つ巨大インスタンスでデフォルトの `64mb` のままにすると、ひっきりなしにRewriteが走るか、あるいはデータ量に対して基準値が小さすぎて意味のないI/O負荷を生む。
  • 推奨: 大規模インスタンスでは `auto-aof-rewrite-min-size` を `1gb` 〜 `10gb` 以上に引き上げ、Rewriteの頻度を意図的にコントロールし、ディスクI/Oの帯域を他のワークロードに譲る設計にすべきである。

2. `no-appendfsync-on-rewrite` のトレードオフ

no-appendfsync-on-rewrite yes

  • 知見: 子プロセスがディスクへの書き出し(`write()` と `fsync()`)を行っている最中、親プロセス側でも `appendfsync always` または `everysec` によるディスク書き込みが走ると、同一のストレージコントローラーに対して強烈なI/O競合(Contention)が発生する。これが原因でメインスレッドの `fsync` がブロックされ、Redis全体のレイテンシが跳ね上がる現象が多発する。
  • 推奨: `yes` に設定することで、Rewrite中の `fsync` を一時的に抑制し、レイテンシのスパイクを防ぐ。ただし、万が一この瞬間にOSがクラッシュした場合、最大でRewriteにかかっていた時間分のデータロスリスクを抱えることになる。耐障害性とレイテンシのトレードオフをインフラ設計レベルで合意しておく必要がある。

—

5. 結び:コードの裏側にある物理法則を見据えよ

AOF Rewriteは、単なる「ファイルの掃除」機能ではない。シングルスレッドアーキテクチャという制約の中で、OSの仮想メモリ機構(CoW)、プロセス間通信、そしてストレージI/Oの限界点とギリギリの綱渡りをしながら同居するための、Redisチームの執念が生んだ最高傑作のアルゴリズムである。

このメカニズムの細部を理解していれば、ログファイルが肥大化したときのパニックとは無縁になる。CPU、メモリ、ストレージの三者がどのように協調し、あるいはどこでコンフリクトを起こすのか。その全体像を頭の中に描き切った上でパラメータを調整することこそが、真のインフラストラクチャ・エンジニアの姿である。

コメント

タイトルとURLをコピーしました