永続化
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 } => {
eprintln!("kevy replay: {commands} cmds / {bytes} B in {elapsed_ms} ms");
}
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の成長
| ノブ | サーバー(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) | 両方の閾値を満たしたときだけ自動リライトが発火。 |
| 永続化ディレクトリ | 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はカーネルに判断を委ねます。スループットは最大ですが、クラッシュ時にはページキャッシュ内のデータがすべて失われる可能性があり、それは数秒分にもなり得ます。
AOFリプレイコスト対スナップショットロードコスト。 スナップショットがない場合、起動時間はAOFのバイト数に対して線形に伸びます。4GiBのAOFはローカルNVMeで数秒、40GiBなら1分以上かかります。スナップショットはこれに上限を与えます。ロードは1回のストリーミング読み出しと、スナップショット後の短いAOF末尾だけです。その代わり、一時的なビューfreeze(O(keys)。コレクション値はrefcount共有なのでキーあたりナノ秒)と、スナップショット中に最初に変更されたコレクションの1回分のコピーがコストになります。書き込み中心のワークロードでは、定期的なBGSAVEを回すよりも、自動リライトに任せてAOFを有界に保つほうを推奨します。リライトなら、管理するファイルを1つに保ったまま同じ起動時間の上限が得られます。
バックグラウンドジョブの並行性。 各シャードが同時に走らせるバックグラウンドのsaveまたはrewriteは最大1つです。ジョブ実行中に届いた重複リクエストはログ行を出してスキップされ、キューに積まれることはありません。
TTLの永続化。 TTLは絶対Unixミリ秒のデッドラインとして書かれます(AOFではPEXPIREAT、スナップショット形式では絶対値フィールド)。何回再起動しても元の期限の瞬間が保たれ、プロセスが停止していた時間も正しく差し引かれます。相対残り時間を記録していた古いAOFも読み込めます(エントリ時点で相対として扱われます)が、新しい書き込みは常に絶対値です。EXPIREATとPEXPIREATはクライアントコマンドとして公開されています。
シャードレイアウトの変更はクラッシュに対して冪等。 --threads / shardsを変更すると、.reshardの一時名で新しいスナップショットを書き、耐久性のあるreshard.journal経由でコミットします。中断された移行は次回起動時にロールフォワードされます。元ファイルは.premigration.<unix_ts>バックアップとして残ります。ジャーナルはコミットポイントなので、絶対に手で削除しないでください。
永続化されないもの。 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桁ミリ秒で済みます。シリアライズ自体はキー空間がライブのまま走り、書き込みは止まりません。
一時的に払うコストはメモリです。スナップショット書き出し中に変更されたコレクション(list、hash、set、sorted-set)は1度だけクローンされ、ライブストアはfreezeされたビューを乱さずに先へ進めます。普通の文字列キーへのSET中心のワークロードでは追加メモリはほぼ無視できます。少数の巨大コレクションへのHSET / LPUSH中心のワークロードでは、該当コレクションの常駐サイズが一時的に倍になる可能性があります。
スナップショットが成功するとAOFもリセットされます。ログがそれまで持っていた内容はスナップショットに移り、ログはfreeze後に着地した書き込みだけで再開します。再起動時はsnapshotとlogをロードし、履歴が二重適用されることはありません。
次回起動時のリカバリはどう進みますか?
各シャードについて、次の順で進みます:
- スナップショットをロード。
dump-<id>.rdbがあればキー空間にストリーミングします。ロード中に期限切れになっているTTLは破棄されます。 - AOFをリプレイ。
aof-<id>.aofを先頭から読み、各フレームを適用します。 - 末尾を処理。 クリーンなファイルはそのまま全適用します。途中で切れた末尾(クラッシュ途中の追記)は、書きかけのフレームを捨てて前半部分を適用します。破損フレームは、将来の起動をブロックしないよう不正バイトを
aof-<id>.aof.panic-quarantine.<unix_ts>へ退避し、その後に前半部分を適用します。隔離された末尾が再適用されることは二度とありません。そこから何かを復旧する必要があれば手で調査してください。 - 壁時計時間付きの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 | 記録されたシャード数とルーティング方式。 |
dump-<id>.rdb.tmp | 進行中のスナップショット書き込み。古ければ削除して安全。 |
aof-<id>.aof.rewrite | 進行中のAOFリライト/リセット。古ければ削除して安全。 |
dump-<id>.rdb.reshard + reshard.journal | 進行中のシャードレイアウト移行。次回起動でロールフォワード。ジャーナルは絶対に手で消さない。 |
*.premigration.<unix_ts> | 移行前のソースバックアップ。ロールバック用に保持。 |
aof-<id>.aof.panic-quarantine.<unix_ts> | リカバリ中に退避された破損AOF末尾。何か救出するなら手で調査。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 |
Store::fsync_aof()は書き込み単位の耐久性エスケープハッチです(Postgresのトランザクション単位synchronous_commitの系統)。デプロイはスループットのためにeverysecで走らせ、確認された瞬間からマシンクラッシュを生き延びなければならない少数の書き込みの後ろにだけバリアを置く、という使い方をします。コストはダーティなシャードごとに1回のfdatasyncです。
プロセスクラッシュ(SIGKILL)では、alwaysの下では確認済みの書き込みを決して失わず、それ以外では最大でfsyncウィンドウ分を失います。AOF末尾は次のオープンでリプレイされ、破れた最終フレームは隔離されます(panic-quarantine)。黙って適用されることはありません。
秩序ある停止(SHUTDOWNまたはSIGTERM)は、どのポリシーの下でも何も失いません。ドレインが終了前にAOF末尾を強制fsyncするので、クラッシュなら失い得るeverysecのウィンドウは、クリーンなシャットダウンには当てはまりません。
アトミック性憲章(組み込み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だけのロードの下に残渣として残してはいけないからです。