このページ
- このドキュメントが必要になるとき
- 中心となる考え方
- 動かしてみる例
- サーバーモード
- 組み込みモード
- 設定ノブ
- 耐久性とAOFの成長
- トリガー一覧
- fsyncポリシーの意味
- トレードオフと限界
- FAQ
- AOFファイルが大きくなっています。どうコンパクションしますか?
- 永続化を完全に無効化できますか?
- 高い書き込み負荷中のスナップショットのコストは?
- 次回起動時のリカバリはどう進みますか?
- 組み込みホストプロセスの中から永続化を監視するには?
- 永続化ディレクトリの中の各ファイルは何ですか?
- 耐久性契約(v2.1)
- クラッシュ一貫性契約(v4)
- AOFレコードフォーマット(v2、KEVYAOF2)
- 内部フレーム——そして「真実の集合」とは何か
- resyncリプレイ——無事な尾部の復旧
- アトミック性憲章(組み込みserving-store、v2.1)
- リカバリポイント(v2.3)
- レプリケーションワイヤ上のスナップショット(v3.15)
- 運用ランブック
永続化
kevyが再起動をまたいでデータを保持する仕組みを説明します。AOF、スナップショット、fsyncポリシー、リライト(コンパクション)、クラッシュリカバリ、そしてそれらすべてを観測するためのイントロスペクションを扱います。
このドキュメントが必要になるとき
次のような場面で参照してください。
- 本番デプロイの耐久性ポリシー(ゼロロスかスループットか)を選定するとき。
- 書き込み中心のワークロードについて、ディスク使用量とリプレイ時間を見積もるとき。
- 想定外のオンディスク成果物(quarantineファイル、古い
.rewrite一時ファイル、.premigration.*バックアップ)をデバッグするとき。 - ホストアプリケーションに
kevy_embedded::Storeを組み込んでいて、プロセスクラッシュで何が生き残り、何が失われ、ホストの中からそれをどう観測できるかを知りたいとき。 - 再起動をまたぐとTTLの挙動がおかしくなるキーを調べているとき。
「kill -9で生き残るか?」の手短な答えだけが欲しいなら、答えは「はい。デフォルトポリシーで失われるのは最大1秒分の書き込みだけ」です。
中心となる考え方
各シャードは永続化ディレクトリに2つのファイルを所有します。変更コマンドの追記専用ログ(aof-<id>.aof)と、オプションのバイナリスナップショット(dump-<id>.rdb)です。AOFはそれ単独で完全な永続記録であり、スナップショットはリプレイ時間を抑えるためだけに存在します。起動時、kevyはスナップショットがあればロードし、その後AOFをリプレイします。スナップショットが成功するとAOFはリセットされ、2つのファイルを合わせると履歴全体をちょうど一度ずつカバーする状態になります。
ディレクトリ自体は、存在しなければ起動時に作成されます(v3.17)。作成できないパスは、最初に触れたサブシステムからの裸のENOENTではなく、名前の付いた1つの起動エラーとして報告されます。
動かしてみる例
サーバーモード
これをkevy.tomlに置き、kevy --config kevy.tomlで起動します。
# kevy.toml
[server]
data_dir = "/var/lib/kevy"
port = 6379
threads = 4
[persistence]
aof = true
# AOF durability — see the knobs table below for the full set.
appendfsync = "everysec" # always | everysec | no
auto_aof_rewrite_percentage = 100 # rewrite when the AOF doubles since the last rewrite
auto_aof_rewrite_min_size = "64mb" # …and is at least this big運用は通常のRedisスタイルのコマンドをRESP経由で行えます。
$ redis-cli -p 6379 BGSAVE
Background saving started
$ redis-cli -p 6379 BGREWRITEAOF
Background append only file rewriting started
$ redis-cli -p 6379 INFO persistence
aof_enabled:1
appendfsync:everysec
aof_rewrite_in_progress:0
aof_rewrites_total:3CONFIG SET appendfsync alwaysを使えば、再起動なしにポリシーをライブで変更できます。
組み込みモード
Cargo.tomlにクレートを追加します。
[dependencies]
kevy-embedded = "*"そしてmain.rsに:
use std::time::Duration;
use kevy_embedded::{AppendFsync, Config, KevyMetric, Store};
fn main() -> kevy_embedded::KevyResult<()> {
let cfg = Config::default()
.with_persist("/var/lib/myapp/kevy")
.with_appendfsync(AppendFsync::EverySec)
.with_auto_aof_rewrite(100, 64 * 1024 * 1024)
.with_metric_sink(|m| match m {
KevyMetric::Replay { commands, bytes, elapsed_ms, dropped_bytes, corrupt } => {
eprintln!("kevy replay: {commands} cmds / {bytes} B in {elapsed_ms} ms");
if dropped_bytes > 0 || corrupt {
eprintln!("kevy replay dropped {dropped_bytes} B (corrupt: {corrupt}) — ALERT");
}
}
KevyMetric::Rewrite { keys, before_bytes, after_bytes, elapsed_ms } => {
eprintln!(
"kevy rewrite: {keys} keys, {before_bytes} -> {after_bytes} B in {elapsed_ms} ms"
);
}
_ => {}
});
let store = Store::open(cfg)?;
store.set(b"hello", b"world")?;
store.expire(b"hello", Duration::from_secs(300))?;
// ある時点のスナップショット。ファイルがディスクに書き出された後に戻る。
// シャードごとのロックはビューの freeze と最後の rename のあいだだけ保持される。
store.save_snapshot()?;
// オンデマンドの AOF コンパクション。ロック規律は save_snapshot と同じ。
let _stats = store.rewrite_aof()?;
// ライブイントロスペクション。
let info = store.info();
println!("{} keys, {} bytes AOF", info.keys, info.aof_bytes);
Ok(())
}デフォルト設定で新規に作った組み込みストアはAOFだけを書きます。save_snapshotを呼ぶまでスナップショットファイルは現れません。これは想定どおりの動作で、AOF単独でキー空間を再構築できるためです。
設定ノブ
耐久性とAOFの成長
トランザクションは、サイズによらず全か無かでリプレイされます。 組み込みのatomic() / atomic_all_shards()ブロックは、AOF上でbeginマーカーとcommitマーカーに挟まれます。リプレイはbegin以降のフレームをすべて保持し、対応するcommitに到達したときにだけそのバッチを適用します。トランザクションの途中で終わっているログは、それを破棄します。ブロックを拒否した場合(Errを返した場合)は、そもそも何も追記されません。
このサイズ非依存性を生んでいるのがマーカーであり、その区別は重要です。グループコミットだけでは*fsync*を遅らせるにすぎません。AOFは256 KiBのバッファを通して書くので、長いトランザクションでもバッファが埋まるたびに完全で妥当なフレームがカーネルへ渡り、kill -9はそれらをそこに残します。マーカーが存在しなかった頃の実測では、20,000件の変更を含むブロックをコミット中にkillしたところ、6,393件がリプレイされました。どのappendfsync設定でもこれは直りません——部分状態はコミットループの形から来るのであって、いつsyncするかから来るのではないからです。
appendfsyncが支配するのは別のものです:すでに書かれたデータの、電源断ウィンドウです。トランザクションが全か無かであるかどうかとは直交しています。
どちらも4.0以降で真です。それ以前のバージョンはグループコミットを文書化しながら有効化しておらず、トランザクション内の各変更は個別にsyncされ、かつ個別にリプレイされていました。
v1フォーマットのログはレコードのエンベロープを持たず、トランザクション境界を表現できません。最初のリライトでv2へ昇格するまでは、古い振る舞いのままです。
マーカーはトランザクションのものであり、それ以外の何のものでもありません。 パイプライン化された読み取りバッチはトランザクションではありません——Redisのパイプライニングは明示的に非アトミックです——ので、リアクターのバッチはマーカーを持ちません(alwaysのもとで1回のfsyncを共有するだけです。4.x系の一時期、単一コマンドのバッチまでが約65 Bのマーカー対を払っており、これはディスクゲートが捕まえました)。サーバー側では、MULTI/EXECはキューされたコマンドを、その接続のシャード上で挟みます。他のシャードへ広がるコマンドは、それぞれのシャード自身のログへ個別に着地します。したがってクラッシュ下でのクロスシャードEXECのアトミシティはシャードごとであってグローバルではありません——EXEC内の実行時エラーが他のコマンドを取り消さない、というRedis自身の規則と同じ精神です。上の全か無かの保証は、書き込みが構造上単一シャードである組み込みのatomic()系のものです。
| ノブ | サーバー(TOML / CONFIG SET) | 組み込み(Config::…) | デフォルト | 備考 |
|---|---|---|---|---|
| AOF fsyncポリシー | appendfsync(always / everysec / no) | with_appendfsync(AppendFsync::…) | EverySec | サーバーではライブ変更可能。 |
| AOF有効化 | aof(true / false) | with_persist(...)で暗黙的に | サーバーはtrue、組み込みはwith_persistまでオフ | 無効化するとオンディスク永続化を丸ごとスキップ。 |
| 自動リライトのパーセンテージ | auto_aof_rewrite_percentage | with_auto_aof_rewrite(pct, min)の第1引数 | 100 | 0で自動リライトを無効化。 |
| 自動リライトの最小サイズ | auto_aof_rewrite_min_size | with_auto_aof_rewrite(pct, min)の第2引数 | 67108864(64 MiB) | 両方の閾値を満たしたときだけ成長ルールが発火。 |
| 自動リライトの絶対上限 | auto_aof_rewrite_bytes | with_auto_rewrite_bytes(n) | 0(無効) | 独立トリガ:AOFがnバイトを超えたら成長率に関係なくリライト。ライブ調整可(CONFIG SET auto-aof-rewrite-bytes)。 |
| 自動リライトの経過時間 | auto_aof_rewrite_interval_secs | with_auto_rewrite_interval(d) | 0(無効) | 独立トリガ:前回リライトからこの時間が経過し、かつログが成長していたらリライト。ライブ調整可。 |
| resyncリプレイ | replay_resync([persistence]) | with_replay_resync(true) | false(strict) | 起動時のみ。ファイル中央の破損領域で止まらず、その後ろの無事な尾部を復旧する——resyncの節を参照。 |
| 永続化ディレクトリ | data_dir / 環境変数KEVY_DIR | with_persist(path) | サーバーは./data、組み込みはなし | kevyインスタンスごとに1ディレクトリ。 |
| リアクター/リーパー周期 | reactor tick、約100ms | バックグラウンドリーパー、またはStore::tickの呼び出し | 約100ms | EverySecのflush、自動リライトのチェック、TTLエビクションを駆動。 |
トリガー一覧
| 動作 | サーバー | 組み込み | ブロッキングの形 | |
|---|---|---|---|---|
| 同期スナップショット | SAVE | Store::save_snapshot() | ファイルがディスクに書き出された後に戻る。ロックはfreezeとrenameのあいだだけ保持。 | |
| バックグラウンドスナップショット | BGSAVE | ワーカースレッドからsave_snapshotを呼ぶ | 即座に戻る。コミットはディスク書き出し完了から1 reactor tick以内に確定。 | |
| AOFリライト | BGREWRITEAOF | Store::rewrite_aof() | アトミックなrenameの後に戻る。シリアライズはキー空間がライブのまま走る。 | |
| fsyncのライブ変更 | CONFIG SET appendfsync everysec | Configを再構築 | n/a | |
| 正常終了 | `SHUTDOWN [SAVE\ | NOSAVE]`(またはSIGTERM) | 最後のStoreクローンをdrop | 全シャードをドレインする。実行中のpersistジョブが着地し、AOF末尾が強制fsyncされ、その後プロセスが終了する。SAVEはさらにシャードごとに最後のスナップショットを1つ取る。リプライは送られない——クライアントは接続が閉じるのを観測する(Redisの振る舞い)。 |
fsyncポリシーの意味
| ポリシー | 耐久性 | コスト |
|---|---|---|
Always | ゼロロス。各書き込みを応答前にfsync | スループット約50% |
EverySec(デフォルト) | クラッシュで最大約1秒分の書き込みを失う可能性 | 安い |
No | OSのページキャッシュflushに委ねる | 最安 |
トレードオフと限界
ポリシー別のスループット対データロス。 Alwaysは各応答をfsyncでブロックします。kill -9でコマンドロスゼロを保証する唯一のポリシーですが、SET中心のワークロードでは一般的なNVMe上でスループットが約半分になります。EverySecは毎秒バックグラウンドでflushし、クラッシュ時にはその窓の分を失う可能性があります。これがデフォルトなのは、Redisのトレードオフとちょうど一致し、失われる窓が通常は許容範囲だからです。Noはカーネルに判断を委ねます。スループットは最大ですが、クラッシュ時にはページキャッシュ内のデータがすべて失われる可能性があり、それは数秒分にもなり得ます。
AppendFsyncが支配するもの、しないもの。 これは個々のコマンドの電源断ウィンドウを定めます。atomicブロックが全か無かであるかどうかとは、一度も関係を持ったことがありません——それはログ中のトランザクションマーカーの仕事であり(クラッシュ一貫性を参照)、4.0以降はどのfsyncポリシーのもとでも成立します。
名前が逆の読み方を誘うので、これははっきり書いておく価値があります。金融データを保存するある利用者は、最初の接触でAlwaysを選びました——確認済みの書き込みを失ってはならない、という理屈です。そして最もコストの高い設定を手に入れ、そして4.0より前は、本当に必要としていたブロックのアトミシティについては何ひとつ買えていませんでした。確認済みのコマンドを1つも失えないならAlwaysを、1秒のウィンドウが許容できるならEverySecを選んでください。どちらの選択もトランザクションには影響しません。
AOFリプレイコスト対スナップショットロードコスト。 スナップショットがない場合、起動時間はAOFのバイト数に対して線形に伸びます。4GiBのAOFはローカルNVMeで数秒、40GiBなら1分以上かかります。スナップショットはこれに上限を与えます。ロードは1回のストリーミング読み出しと、スナップショット後の短いAOF末尾だけです。その代わり、一時的なビューfreeze(O(keys)。コレクション値はrefcount共有なのでキーあたりナノ秒)と、スナップショット中に最初に変更されたコレクションの1回分のコピーがコストになります。書き込み中心のワークロードでは、定期的なBGSAVEを回すよりも、自動リライトに任せてAOFを有界に保つほうを推奨します。リライトなら、管理するファイルを1つに保ったまま同じ起動時間の上限が得られます。
バックグラウンドジョブの並行性。 各シャードが同時に走らせるバックグラウンドのsaveまたはrewriteは最大1つです。ジョブ実行中に届いた重複リクエストはログ行を出してスキップされ、キューに積まれることはありません。
AOF 書き込みと reactor スレッド。 追記と fsync はどの reactor でも reactor スレッドの外で行われる:io_uring ではシャード自身のリングにキュー操作として乗り、epoll/kqueue reactor ではシャードごとの writer スレッドが同じキューを逐次追記でドレインする(ディスク上はバイト同一)。いずれの場合も reactor はホットパスで write(2) にも fsync(2) にもブロックしない——GB/s インジェスト下で reactor を数秒単位で止めていたのがまさにそこ(5.0 のテールレイテンシ改善。エンドツーエンドの実測は bench/ の finding 文書)。耐久性は不変:everysec のクラッシュ窓は依然 ≤1 秒。always では書き込みへの応答を、それをカバーする fsync の完了まで保持する——応答が耐久性を保証する点は変わらず、reactor がその横で待たなくなっただけで、並行接続は fsync ラウンドを共有する(グループコミット)。KEVY_AOF_OFFLOAD=0 はどの reactor でも従来の同期パスに戻す。
飽和インジェスト下の rewrite は延期される(5.0)。 rewrite は実行中に着地する書き込みを畳み込む必要がある;追記レートが畳み込みを上回ると証明されたら、5.0 は無制限の停止を払う代わりに rewrite を延期する:成長ルールを現在サイズに再アンカーし、次の成長因子後に再試行。明示的 BGREWRITEAOF は常に無条件。観測できる交換条件:持続的な書き込み飽和下では AOF が通常の rewrite 点を超えて伸び、圧力が引けば縮む——ディスクは取り返せる、停止は取り返せない。rewrite の前後に <aof>.rewrite(構築中イメージ)や <aof>.trashN(旧ログの GB 級解放をサービングスレッド外へ移すハードリンク)が一時的に見えることがある;どちらも自動清掃され、クラッシュ残骸は次の rewrite が回収する。バックアップからは *.rewrite / *.trash* を除外すること。
TTLの永続化。 TTLは絶対Unixミリ秒のデッドラインとして書かれます(AOFではPEXPIREAT、スナップショット形式では絶対値フィールド)。何回再起動しても元の期限の瞬間が保たれ、プロセスが停止していた時間も正しく差し引かれます。相対残り時間を記録していた古いAOFも読み込めます(エントリ時点で相対として扱われます)が、新しい書き込みは常に絶対値です。EXPIREATとPEXPIREATはクライアントコマンドとして公開されています。
知っておく価値のある帰結がひとつ、ある利用者のゲートから報告されています:永続化されるデッドラインは壁時計時刻なので、書き込みと後のリプレイのあいだにシステムクロックのステップ(NTP補正)が入ると、残りTTLはそのステップぶんだけ、どちらの向きにも動きます——後ろ向きのステップは*より長い*残りを、前向きのステップは*より短い*残りを読み戻します。これは絶対的な壁デッドラインに内在するものです(Redisも同じ性質を持ち、それはインメモリでもそうです)。kevyの走行中のTTLは単調時計で動いており影響を受けません——壁時刻を経由して変換されるのは再起動の境界だけです。短いレートリミッタのTTLについては、再起動をまたいだ残りは、ホストのクロック規律の範囲内で正確、と扱ってください。
シャードレイアウトの変更はクラッシュに対して冪等。 --threads / shardsを変更すると、.reshardの一時名で新しいスナップショットを書き、耐久性のあるreshard.journal経由でコミットします。中断された移行は次回起動時にロールフォワードされます。元ファイルは.premigration.<unix_ts>バックアップとして残ります。ジャーナルはコミットポイントなので、絶対に手で削除しないでください。
巨大な単一コレクションと rewrite 窓。list・hash・set・sorted-set は約 1.6 万要素を超えると要素粒度(セグメント式)のコピーオンライトになる:rewrite やスナップショット view が値を固定している間の書き込みは、触れた ~1.6 万要素のセグメントだけをクローンするため、コストは約 1 ミリ秒でコレクションの総サイズに依存しない(ベンチ実測:2000 万要素で初回書き込み 0.4-2.1 ミリ秒。従来の値全体クローンは 0.35-9.5 秒かかり、そのコレクションの常駐メモリを一時的に倍にしていた)。残る例外は stream:固定された view の下では今も値全体をクローンするため、GB 級の無制限な単一キー stream はそのシャードで rewrite 窓中のレイテンシスパイクが出うる——保持する場合は XTRIM で刈り込むこと。
永続化されないもの。 pub/subのチャネル、サブスクリプション、未配信メッセージはメモリにしか存在しません。BLPOPやブロッキングXREADのようなブロッキングコマンドの待機はコネクション状態であってデータではありません。これらはAOFにもスナップショットにも書かれず、リプレイもされません。
FAQ
AOFファイルが大きくなっています。どうコンパクションしますか?
サーバーではBGREWRITEAOFを、組み込みモードではStore::rewrite_aof()を実行します。リライトは、現在のキー空間を再構築できる最小のコマンド集合としてログを作り直し(キーごとにSET / HSETなどを1つ、TTL付きキーにはPEXPIREAT)、新しいファイルをアトミックに差し替えます。hotへの1万回の上書きは、SET hot <latest>1つに集約されます。
無人運用なら、自動リライトをデフォルト(前回リライトサイズから100%成長、かつ最低64MiB)のままにしておけば、リアクターが勝手にコンパクションを発火させます。auto_aof_rewrite_percentage = 0にすると無効化され、リライトは完全に手動になります。
リライトはキー空間にとってノンブロッキングです。シリアライズとfsyncは読み書きが流れたまま走り、リライト中に着地した書き込みはdiffバッファにteeされ、コンパクション後のイメージに追記されます。リライトが途中でクラッシュしても元のAOFは無傷で(差し替えはアトミックなrenameです)、残ったaof-<id>.aof.rewrite一時ファイルは削除しても安全です。
永続化を完全に無効化できますか?
はい。2つの方法があります。
- サーバー:
kevy.tomlでappendonly = false(または--dirを省略)。サーバーは純粋なインメモリキャッシュとして動き、aof-*もdump-*も作られません。 - 組み込み:
with_persist(...)を呼ばずにConfigを構築します。Store::openはキー空間を完全にメモリ内で運用し、save_snapshotとrewrite_aofはAPI上ではno-opになります(あるいは永続化ディレクトリ未設定を示すエラーが返ります)。
永続化はしたいがスナップショット間でAOFをまったく成長させたくない、という組み合わせはサポートされていません。kevyの耐久性モデルはAOFファーストで、スナップショットはAOFリプレイに上限を与えるために存在し、AOFを置き換えるためのものではありません。
高い書き込み負荷中のスナップショットのコストは?
ブロックする部分はごくわずかです。シャードごとのキー空間freezeはO(keys)であってO(bytes)ではありません。コレクション値は参照カウントされ、ライブストアと共有されているためです。100万キーのシャードでもfreezeは1桁ミリ秒で済みます。シリアライズ自体はキー空間がライブのまま走り、書き込みは止まりません。
一時的に払うコストはメモリです。スナップショット書き出し中に変更されたコレクションは、書き込みが触れた部分だけをクローンし、ライブストアはfreezeされたビューを乱さずに先へ進めます。普通の文字列キーと list/hash/set/sorted-set(~1.6 万要素セグメント単位のクローン)では追加メモリは最悪でも数 MB です。固定された view の下で書き込まれた stream だけは今も値全体をクローンし、その stream の常駐サイズが一時的に倍になる可能性があります。
スナップショットが成功するとAOFもリセットされます。ログがそれまで持っていた内容はスナップショットに移り、ログはfreeze後に着地した書き込みだけで再開します。再起動時はsnapshotとlogをロードし、履歴が二重適用されることはありません。
次回起動時のリカバリはどう進みますか?
各シャードについて、次の順で進みます:
- スナップショットをロード。
dump-<id>.rdbがあればキー空間にストリーミングします。ロード中に期限切れになっているTTLは破棄されます。 - AOFをリプレイ。
aof-<id>.aofを先頭から読み、各フレームを適用します。 - 末尾を処理。 クリーンなファイルはそのまま全適用します。破れた・破損したレコードに当たると、リプレイはその直前の最後の完全なレコードで停止します。openは切り捨てる領域を
aof-<id>.aof.corrupt-quarantine.<unix_ts>へコピーし(fsync済み——ファイル中央の破損レコードの後ろは大半が正しいレコードで、このコピーだけが戻る道です)、最初の追記の前にファイルを最後の完全なレコードまで切り詰めます。新しい書き込みがリプレイ可能な接頭辞の直後に続き、不正バイトの後ろに落ちて次のリプレイで黙って孤児になることを防ぐためです。隔離されたバイトが再適用されることは二度とありません。救出は手で。隔離コピー自体が書けない場合(ディスクフルなど)、openはファイルを無傷のまま失敗します——kevyがあなたのバイトの唯一のコピーを壊すことはありません。replay_resync有効時の挙動はresyncの節を参照。 - 壁時計時間付きの1行サマリーをログ出力:
``text kevy: AOF /data/kevy/aof-0.aof replayed 145313 commands from 418261733 bytes in 247 ms (clean) ``
- 中断されたシャードレイアウト移行があれば、
reshard.journalをリプレイしてロールフォワードします。
リプレイ時間の行を監視し、自動リライトでそれを有界に保ってください。リプレイ時間は、リライトされていないAOFのサイズに対して線形に伸びます。
組み込みホストプロセスの中から永続化を監視するには?
2つの面があります。
ポーリング。 store.info()は、keys、used_memory、aof_bytes、expire_pending、evictions、expired_keysを持つKevyInfo構造体を返します。同じ情報をより細かく扱うヘルパーもあります。
store.dbsize(); // ライブキー数
store.ttl(key); // Option<Duration>(None = キーなし / TTL なし)
store.ttl_ms(key); // Redis PTTL セマンティクス: -2 キーなし、-1 TTL なし、その他 ms
store.expire_pending_count(); // TTL を持つライブキー数
store.used_memory(); // 常駐バイトの推定
store.expired_keys_total(); // 合計期限切れ数(lazy + リーパー)
store.evictions_total(); // maxmemory による合計 eviction 数TTLがあるはずなのにexpire_pending_count() == 0が返るなら、それはTTLサブシステムにキーが登録されなかったことを示す古典的な兆候です。
プッシュ。 Config::with_metric_sink(...)を登録すると、AOFリプレイ(起動時)と各AOFリライト(コンパクション)のたびにKevyMetricイベントが届きます。シンクは発行スレッド(バックグラウンドリライトならリーパー)上で同期実行されるので、コールバックは速く保ってください。KevyMetricは#[non_exhaustive]です。将来互換のため、常に_アームをマッチさせておきましょう。
永続化ディレクトリの中の各ファイルは何ですか?
| パターン | 意味 |
|---|---|
aof-<id>.aof | シャード<id>のライブAOF。 |
dump-<id>.rdb | シャード<id>のバイナリスナップショット。 |
shards.meta | 記録されたシャード数とルーティング方式。 |
LOCK | アドバイザリロック。1 つのディレクトリは同時に 1 つの生きたエンジンのものです。最初のエンジンが保持している間、2 回目のオープン——同じプロセスでも別のプロセスでも——は明確に拒否されます。解放後もファイル自体は残りますが、空で無害です(ロックはプロセスとともにカーネルが回収するため、陳腐化しません)。 |
dump-<id>.rdb.tmp | 進行中のスナップショット書き込み。古ければ削除して安全。 |
aof-<id>.aof.rewrite | 進行中のAOFリライト/リセット。古ければ削除して安全。 |
dump-<id>.rdb.reshard + reshard.journal | 進行中のシャードレイアウト移行。次回起動でロールフォワード。ジャーナルは絶対に手で消さない。 |
*.premigration.<unix_ts> | 移行前のソースバックアップ。ロールバック用に保持。 |
aof-<id>.aof.corrupt-quarantine.<unix_ts> | リカバリ中に退避されたリプレイ不能領域(破れた末尾、またはファイル中央の破損レコード以降のすべて)。救出するなら手で調査。kevyが再適用することはない。 |
elect.meta(+ 一時的なelect.meta.tmp) | 選挙の耐久性(v3.15)。エレクターの(epoch, votedFor)ペアで、どの投票応答もノードを出る*前に*永続化されるため、クラッシュ再起動しても二重投票はあり得ない。tmp + fsync + renameで書かれるため、保存途中のクラッシュが残すのは旧ペアか新ペアのどちらかで、破れたファイルは決して残らない。[cluster]クォーラム構成時のみ存在。 |
耐久性契約(v2.1)
appendfsync×書き込みパスの組み合わせごとに、「呼び出しがOKを返した」が何を保証するかを示します。 「durable」=安定ストレージ上にある(fdatasync完了)。「windowed」=OSページキャッシュ内にあり、ウィンドウ内で*マシン*(プロセスだけでなく)が死んだ場合にのみ失われる。
| 書き込みパス | always | everysec | no |
|---|---|---|---|
| サーバーコマンド応答 | 応答がシャードを出る前にdurable(バッチごとにグループコミット) | windowed ≤ 1s | OSペース |
組み込みファサード操作(set、zadd、…) | リターン時にdurable | windowed ≤ 1s | OSペース |
組み込みatomic / atomic_all_shardsブロック | コミット時にdurable(触れたシャードごとに1 fsync) | windowed ≤ 1s | OSペース |
組み込みPipeline::commit | リターン時にdurable、fsyncはシャードごとにバッチ | windowed ≤ 1s | OSペース |
…上記いずれか + Store::fsync_aof() | 無操作 | バリアの時点でdurable | バリアの時点でdurable |
トランザクションマーカーはトランザクションだけのものです。パイプラインのバッチはトランザクションではないので(Redis の pipelining は明確に非アトミック)、reactor バッチはマーカーを持ちません(always では fsync を一度共有するだけ;4.x 系のある期間、単一コマンドのバッチすべてが約 65 B のマーカー対を払っており、ディスクゲートが検出して修正されました)。サーバー側の MULTI/EXEC は接続の shard 上でキューされたコマンドをまとめてマークします;他の shard へファンアウトしたコマンドはそれぞれのログに個別に落ちます。したがってクロス shard EXEC のクラッシュ原子性は shard 単位であり、グローバルではありません——Redis 自身の「EXEC 内の実行時エラーは他のコマンドを取り消さない」という精神と同じです。上記の all-or-nothing 保証は組み込みの atomic() ファミリーのもので、その書き込みは構造上シングル shard です。
Store::fsync_aof()は書き込み単位の耐久性エスケープハッチです(Postgresのトランザクション単位synchronous_commitの系統)。デプロイはスループットのためにeverysecで走らせ、確認された瞬間からマシンクラッシュを生き延びなければならない少数の書き込みの後ろにだけバリアを置く、という使い方をします。コストはダーティなシャードごとに1回のfdatasyncです。
プロセスクラッシュ(SIGKILL)では、alwaysの下では確認済みの書き込みを決して失わず、それ以外では最大でfsyncウィンドウ分を失います。AOF末尾は次のオープンでリプレイされ、破れた最終レコードはオープン時に切り詰められます(切り捨て領域は先に隔離ファイルへコピー)。黙って適用されることはありません(完全な状態機械は下のクラッシュ一貫性契約を参照)。
秩序ある停止(SHUTDOWNまたはSIGTERM)は、どのポリシーの下でも何も失いません。ドレインが終了前にAOF末尾を強制fsyncするので、クラッシュなら失い得るeverysecのウィンドウは、クリーンなシャットダウンには当てはまりません。
クラッシュ一貫性契約(v4)
オープンパスはシャードごとに固定の状態機械——open → verify → replay → verdict → repair → append——であり、各verdictは硬い損失上界を伴います。crashgate(bench/crashgate.sh)がこの表を実行します:SIGKILLマトリクス(追記中 / リライト中 / スナップショット中 / feed送出中 × fsyncポリシー × シャード数)に加え、破れた末尾 / ファイル中央 / payload損傷の注入。
| クラッシュが残したもの | verdict | リプレイが復旧するもの | 硬い損失上界 |
|---|---|---|---|
| クリーンなファイル | clean | すべて | ゼロ |
| 破れた最終レコード(追記中にkill) | 末尾切り詰め | 完全なレコードすべて | 破れたレコード + 未fsyncウィンドウ(always:破れたレコードのみ) |
| ゼロ埋めの末尾(電源断 + 未fsyncページ) | 末尾切り詰め | 完全なレコードすべて | 同上 |
| ファイル中央の破損レコード | そのレコードで停止(strict、デフォルト) | レコード前の接頭辞;切り捨て領域は隔離保存 | レコード以降の領域——またはreplay_resync有効なら破損領域そのものだけ:後ろの無事な尾部は復旧される(resyncの節) |
| ファイル途中の、ファイルが果たせない長さヘッダ | LengthOutranFile——レコードがあるべき位置に座った、ファイルが短すぎて満たせない切り詰め | その前の接頭辞;replay_resync有効なら後ろの無事な尾部も | それ以降の領域——またはreplay_resync有効ならスキップした領域そのものだけ |
| レコードpayload内のビット反転 | CRC不一致 → 破損レコード(v2ファイル) | 汚染値は一切適用されない——レコードは拒否される | 上の行と同じ;v1時代のファイルはチェックサムを持たず、最初のリライトでv2へ昇格するまでビット反転は黙ってリプレイされる |
ブラックホール禁止の不変量(3.18インシデントの修正、crashgateが保持):オープン時の切り詰めは最初の追記の前に行われるため、リプレイ停止点が再起動をまたいで後退することはありません——クラッシュ後の再起動で書いたデータは、必ず次の再起動を生き延びます。
マルチシャードのずれ。 各シャードは独立にaof-<id>.aofを持ち独立に修復するため、クラッシュ後、シャードごとにわずかに異なる時点へ復旧することがあります(それぞれ自分の損失上界の内側)。kevyは独立した書き込みに対してシャード横断のアトミック性を約束しません——atomic/atomic_all_shardsブロックはコミット時に触れたシャードごとにfsyncし、一群の書き込みを一緒に着地させたいときの道具です。
feed(CDC)はメモリのみで、ディスクの先を走ります。 feedバックログはオープン時にAOFから再構築されません。再起動を生き延びるのは(generation, offset)カーソルだけです。フレームはapply時点——同じ書き込みを記録するAOFバイトのfsyncより前——に送出されるため、everysecではクラッシュで巻き戻る最大約1秒分の書き込みをコンシューマが観測しえます(always:ゼロ;no:上界なし)。クラッシュはfeed generationをbumpするので、クラッシュ前のカーソルはすべて-FEEDRESYNC / FeedError::Resyncを受け取り、コンシューマは復旧後のストアをスキャンして再構築します。あるフレームをdurableな事実として扱えるのは、それを覆うfsyncウィンドウが閉じた後だけです——未durableフレームに対して取った副作用をresyncは取り消せません。
レプリカは未fsyncフレームにdurableな主張を持ちません。 プライマリのuncleanな再起動後、プライマリは未fsyncサフィックス分を巻き戻してfeed generationをbumpします。巻き戻された書き込みを適用済みのレプリカは先行しており、再接続時にその分岐履歴はフルスナップショットresyncで破棄されます。再接続ハンドシェイクはレプリカのgenerationを運び(v4)、generation不一致でのoffset継続をプライマリは拒否します——どれだけ後に再接続しても、新しい履歴の同番offsetを黙って食わされることはありません(replication.md参照)。
AOFレコードフォーマット(v2、KEVYAOF2)
4.0以降、新しいAOFファイルはKEVYAOF2\n magicで始まり、各コマンドはチェックサム付きレコードとして書かれます:
[payload_len: u32 LE][crc32c: u32 LE][payload: RESP multibulkコマンド]このエンベロープが買うもの——どれもv1フォーマットには扱えなかったインシデントのクラスです:
- 完全性。
crc32c(Castagnoli、aarch64とSSE4.2 x86-64ではハードウェア命令)がpayloadを覆います。1ビットの反転——媒体の劣化、不良ケーブル、切り詰め後に上書きされたページ——はチェックに失敗し、汚染値をリプレイする代わりにレコードが拒否されます。v1には完全性チェックが一切なく、ビット反転は黙ってリプレイされました。 - 決定的なレコード境界。 長さ接頭辞がRESPを解析せずにログをフレーミングするため、破れた末尾は算術で(ヘッダの約束よりバイトが足りない)検出されます。パーサが偶然詰まるのを当てにしません。
- 決定的なresync。 破損領域の後、次のレコード境界を見つけ直して*検証*できます(長さ + CRC + ちょうど1個の整形式コマンド、の三つが一致すること)——下のresyncリプレイの土台です。
互換性契約:
- 4.0バイナリはv1(
KEVYAOF1)ファイルを永久に読めます。3.xのデータディレクトリは移行作業ゼロで開きます。 - ひとつのファイル内でフォーマットが混ざることはありません:既存v1ファイルへの追記はv1のままです。
- 新しいファイル、切り詰め、すべてのリライト出力はv2です。したがって最初のリライト(自動または
BGREWRITEAOF)がv1ファイルをv2へ昇格させます——それ以降、3.xバイナリはそのファイルを読めません。ダウングレードの窓と運用手順はUPGRADING.mdへ。
レコードあたりのオーバーヘッドは8バイト。mailrs形状のワークロード(小さめのコマンドの混合)でディスクコストは実測で低い一桁パーセント、CRCは両サーバアーキテクチャでハードウェア命令に乗ります。
内部フレーム——そして「真実の集合」とは何か
クライアントが打つRESP動詞とは衝突しえない、NUL接頭の名前を持つレコードが2種類あります:
- トランザクションの括り(
\0KEVYTXNBEGIN/\0KEVYTXNCOMMIT)——一群の追記を全か無かでリプレイさせます。 - セグメントの縫い目(
\0KEVYSEGMENTED <file>)——一括の行がホット層からsegs-<shard>/配下のコールドセグメントファイルへ退避されたことを記録します。リプレイはその退避をやり直します:それらの行の書き込みフレームはログ上で縫い目より前にあり、リプレイがそれらを取り込み、縫い目がそれらを再び外へ出します。縫い目より*後*にある書き込みフレームは復活であり、そのまま残ります(ホット優先の読み取りがセグメント側の複製を覆い隠します)。
データディレクトリにsegs-<shard>/が存在するようになった時点で、AOFだけではもはや完全な真実ではありません。真実の集合は、スナップショット(dump-*.rdb)+AOF(aof-*.aof)+セグメントディレクトリ(segs-*/。各々がセグメントファイルと、それらを実在させるsegs.manifest台帳を保持します)です。バックアップは3つすべてを取る必要があります。セグメントは封をした時点で不変なので、増分バックアップは「新しいセグメントファイルとmanifestをコピーする」ことになります。
セグメント集合の真実の源はmanifestであって縫い目フレームではありません(manifestはフレームがログに書かれる*前*にfsyncされます)。帰結が2つ:manifestが保持していないセグメントを名指す縫い目フレームは、セグメントディレクトリが事後に壊れたことを意味し、起動は唯一の永続コピーに手が届かなくなった行を黙って落とすのではなく、名指しで拒否します。逆に、スナップショットの切り詰めやAOFリライトで失われた縫い目フレームは無害です——その時点でホット層はもうそれらの行を持っていないか、あるいは(リライトのビューが退避の途中でfreezeしたなら)行は両方の層に生き残り、ホット優先の読み取りがセグメント側の複製を覆い隠します。
resyncリプレイ——無事な尾部の復旧
デフォルト(strict)のリプレイは最初の破損レコードで停止します:接頭辞は適用され、それ以降——大半は整形式のレコード——は隔離されライブファイルから切り捨てられます。これは誠実なデフォルトです(破損領域があるということは*何かが*起きたということで、推測を拒むのが最も安全)が、小さな損傷ひとつの巨大なログに対しては、無事だと証明できるデータを明け渡すことになります。
replay_resyncはそれを取り戻す選択です:
[persistence]
replay_resync = true(組み込みはConfig::with_replay_resync(true)、手組みのruntimeはRuntime::with_replay_resync(true)。設定は起動時のみ——リプレイは最初のライブ設定tickより先に走ります。)
resyncの下では、リプレイは破損領域を跳び越えます:長さ接頭辞・CRC・ちょうど1個の整形式コマンドの解析、の三つが同時に一致する位置まで前方スキャンし(偽の受理には三つ全部を欺く必要があります——候補オフセットあたり約2⁻³²)、そこから適用を再開します。スキップした範囲はすべて報告されます——persist層のReplayReport::resynced_ranges、Store::open_report()のOpenReport::resynced_bytes——そしてcorruptフラグは立ったままです:resyncはデータを復旧しますが、ファイルが健全だと宣言はしません。
修復のセマンティクスはstrictと異なります:ディスク上のファイルは最後の復旧可能レコードより後だけを切り詰め(末尾は隔離保存)、内部の破損領域はその場に残ります——毎回の起動で跳び越えられ——次のリライトがファイルを圧縮するまで。「無事な尾部の復旧」の保証は実行可能です:crashgateのファイル中央スプライスのセル(mailrsの損傷形状、231 MB級ログの中央から8バイトを切除)は、損傷以降の全レコードの復旧を報告しなければなりません。
オフのままにすべきとき:ホストが*あらゆる*破損をレプリカへのフェイルオーバーやバックアップからの復元の理由として扱うなら、strictが最も大きく最も早い停止をくれます。オンにすべきとき:AOFが唯一のコピーである単一ノード運用——mailrsの姿勢——で、「8バイトのスプライスに3日分の書き込みを飲まれる」ほうが悪い結末であるとき。
アトミック性憲章(組み込みserving-store、v2.1)
Store::atomic(body)— 単一シャードのトランザクションです。クロージャの間そのシャードの書き込みロックを取り、内部の読み出しにはクロージャ自身の書き込みが見え、AOF追記は遅延されてコミット時に1 fsyncでコミットされます(always下)。触れるすべてのキーは同じシャードにハッシュされなければなりません。したがって書き込みパターンが任意のキーにまたがる場合、serving-storeの推奨構成は1シャードです。完全なアトミック性を保ちながら、クロスシャード調整のコストを払わずに済みます。1シャード構成の上限はシングルコアの書き込みスループットです。実測値はbench/REPORT.mdにあります。Store::atomic_all_shards(body)— マルチシャードのトランザクションです。すべてのシャードの書き込みロックをシャードインデックス順に取得し(決定的な順序=デッドロックなし)、リターン時にシャードごとのAOFバッチをコミットします。コストとして、クロージャの間、ほかのすべての読み手と書き手をブロックします。クロスシャード不変条件のために使うものであり、デフォルトの書き込みパスにしないでください。Store::pipeline()— アトミックではありません。各操作が自分のロックを取り、ほかの書き手が交錯します。fsyncをバッチする(N操作→シャード数以下のfsync)だけで、それ以上のことはしません。- どちらのアトミック形式も、条件付き操作(
ZADD GT、SPOP)の効果を無条件の動詞としてログするため、リプレイとレプリカ適用は構造上決定的です。
リカバリポイント(v2.3)
変更フィードを有効にすると([feed] enabled = true、cdc.mdを参照)、すべてのスナップショットが採取時点のフィードカーソルを記録します。カーソルはスナップショットデータ自体と同じ追記禁止ウィンドウ内で凍結されます。これがリカバリポイント契約を与えます:
スナップショットS + Sに記録されたカーソル以降のフィードフレーム = それ以降の任意のカーソルにおける正確な状態。
kevy_persist::read_snapshot_cursor(path)でカーソルを読み戻せます(v2.3以前のスナップショットはNone。フォーマットv4以前はカーソルを持ちませんが、引き続き完全にロード可能です)。この契約の実行可能な形がbench/restore-drill.shで、diskgateの1行として走ります。書き込み→SAVE→さらに書き込み→kill→dumpだけからリストア→キャプチャしたフィードフレームをリプレイ→キーごとのバイト一致検証、という流れです。
スコープに関する注意:フィードウィンドウはインメモリのバックログです。ウィンドウより古いフレームは消えています。つまりウィンドウの届く範囲より古いスナップショットは、ただのスナップショットリストア(S時点の状態)であって、PITRのベースにはなりません。正確な時点へのリカバリに依存するなら、ウィンドウが一巡する頻度以上でスナップショットを取ってください。
レプリケーションワイヤ上のスナップショット(v3.15)
バックログウィンドウから外れたレプリカへプライマリがインラインで送るのも、この同じスナップショットフォーマットです(replication.mdを参照)。知っておくべきセマンティクスが1つあります。送られたスナップショットはレプリカのローカル状態を置き換えます。マージではなく、レプリカはロード前に自分のキー空間をフラッシュします。これは意図的な設計です。再合流した旧プライマリが分岐サフィックス(一度もレプリケートされなかった書き込み)を抱えているとき、再同期はその分岐を本当に破棄しなければならず、upsertだけのロードの下に残渣として残してはいけないからです。
ローカルAOFを持つレプリカにとって、この「マージではなく置換」セマンティクスにはひとつの帰結があります:フラッシュ + スナップショットのロードはコミットパスを迂回するため、ロード完了後、レプリカはresync後のキー空間からローカルAOFを同期的にリライトします(4.0)。さもなければローカルログはresync前の履歴を記述したままで、プライマリへ到達できない状態での再起動は、誤った土台から組み上がった状態を提供してしまいます。
運用ランブック
インシデントの形で並べたチェックリスト。各行の裏にはゲート(crashgate、repligate、diskgate)か本文で名指ししたテストがあります。
kevyの停め方。 秩序ある停止を優先——サーバはSHUTDOWN / SIGTERM、組み込みは最後のStoreクローンのdropかStore::shutdown()。ドレインがすべてのAOF末尾を強制fsyncするので、秩序ある停止はどのfsyncポリシーでも何も失いません。feedもクリーン停止マーカーを書き、コンシューマとレプリカはgeneration bumpなしで再開します。kill -9は*生き延びられます*(それがcrashgateのマトリクスそのもの)が、fsyncウィンドウを支払い、feed/レプリカの連続性を断ちます(generation bump → コンシューマ再構築、レプリカはスナップショットresync)。
(再)起動のたびにverdictを読む。 等価な三つの面。少なくともひとつをヘルスチェックに配線してください:
- シャードごとの起動ログ行——
kevy: AOF … replayed N commands from M bytes in T ms (clean)。(clean)以外は必ず、切り捨てバイト数と隔離ファイルパスを名指すWARNを伴います。 - サーバの
INFO persistence:aof_last_open_dropped_bytesとaof_last_open_corrupt——非ゼロは、この起動がファイルにあった分より少なく復旧したことを意味します。アラートを。3日間の静かな損失インシデントは、この信号がstderrにしか住んでいなかったことそのものでした。 - 組み込みの
Store::open_report()(C ABIはkevy_open_report):dropped_bytes、corrupt、quarantine_paths、resynced_bytesがデータとして得られます——悪い起動を、誰も読まないログ行ではなく、拒否されたデプロイに変えてください。
起動が切り捨てを報告したら。 切り捨て領域はaof-<id>.aof.corrupt-quarantine.<unix_ts>にバイト単位でそのまま残っています。この順で判断:(1)レプリカかバックアップにその書き込みがあるなら、そちらから復元。(2)なければ、replay_resync = trueで一度起動することを検討——ファイル中央の破損なら無事な尾部を復旧し、スキップ範囲を報告します。(3)隔離ファイルを手でサルベージ(中身は大半が整形式のレコード / RESP)。インシデントが閉じるまで隔離ファイルは保持を。kevyが消すことはありません。
リライトの周期。 自動リライトが起動時間とインシデント半径の上界です:リプレイ時間、隔離の爆発半径、resyncの跳躍コストはどれも未リライトのログサイズに比例します。成長ペア(auto_aof_rewrite_percentage / min_size)がデフォルト。ディスクやリプレイ予算が硬い制約なら絶対上限(auto_aof_rewrite_bytes)を、書き込みがまばらでログは倍にならないのに数週間分の履歴を抱える運用には経過時間トリガ(auto_aof_rewrite_interval_secs)を足してください。三つは独立で、先に発火したものが勝ちます。
ディスクフルと隔離失敗は大きな音で失敗します。 オープン時に隔離コピーが書けない場合(ディスクフルなど)、あなたのバイトの唯一のコピーを切り詰める代わりに、AOFを無傷のままオープンが失敗します。空きを作ってから再オープンを。