このページ
透過的ティアリング([tiering] / with_tier_budget*)
ティアリングは kevy に RAM 予算を与えます。キー空間が予算を超えて育つと、最もコールドな値が、起動ごとに作り直されるディスク上の value log へ退避し、アクセスされたときにページインで戻ります。キーとメタデータは RAM に残ります——つまり RAM が保持できるキーの数を、ディスクが保持できるデータの量を決めます——そして、すべてのコマンドはコールドキーの上でも厳密に同じ意味を保ちます。2 つ目の API も、「コールド名前空間」も、処理すべき cache-miss エラーもありません。コールドキーは普通のキーであり、最初に値を実体化するときに 1 回の位置指定ディスク読み取りを払うだけです。
# kevy.toml
[tiering]
budget = "auto" # or "70%" or "4gb"let store = Store::open(Config::default()
.with_dir("/var/lib/kevy")
.with_tier_budget(3 << 30))?; // 3 GB RAM budget, embeddedティアリングは既定でオフです。[tiering] セクションがなく(with_tier_budget* も呼ばなければ)、エンジンのパスはティアリングなしのビルドとバイト単位で同一です——これはゲートに守られた主張であって、願望ではありません。
それは何か——そして何ではないか
- RAM 予算です。予算の demote 水位を越えた値は value log(
<data>/tier/)へ移り、エントリの既存の値スロットに stub を残します——追加のヒープはありません。値の RAM は完全に回収され、キー、その TTL、型、LRU 履歴は常駐のままです。 - 永続化ではありません。AOF が唯一の永続的な真実であり続け、ティアリングは構造上、新しいクラッシュ安全面をひとつも増やしません。value log は起動ごとの退避領域です——ストアを開くときに削除され、リプレイ中に再構築されます——だから、クラッシュが失いうるものには決して含まれません。クラッシュ時の保証は persistence.md のそれと厳密に同じです。ティアリングつき永続化テストスイートはさらに、ティアリングと永続化が交わる 2 つのパス(ほぼコールドなストアでの rewrite / スナップショット、そして予算超過データでの起動)を固定しています。
- eviction ではありません。demote されたキーは存在し続けます——
EXISTSは 1 と答え、SCANはそれを返し、TTLは減り続け、GETは答えます。8 種類のmaxmemory削除型 eviction ポリシーは厳密に同じ意味を保ちます。maxmemoryも設定していれば、それは tier 予算の上にある、削除型 eviction の最後の砦のままです。demote は keyspace イベントを一切発しません(書き込みでも eviction でもないからです——クライアントはevictedをキーの削除と解釈します)。専用のdemotions_totalゲージに数えられ、evicted_keysには決して混ざりません。 - インデックスの床は退避できません——予算を決める前に知っておくこと。インデックス、ビュー、保存された
VALUES列は常駐のままです(コールドな行を安く見つけるための当のものだからです)。インデックスの重いストアでは、到達できる RAM の節約はその床以外のすべてに上界づけられます。床を下回る予算は demote ターゲットを 0 に追い込みます:tier は退避できるものを一度退避したら、それ以上何もできません——INFOのtier_effective_target:0として見え、新しいインデックス宣言は名前つきで拒否されます。予算は生データのサイズからではなく、下の予算モデルの床の式から決めてください。
有効にする
サーバー——どの設定面でも同じキーです。
[tiering]
budget = "auto" # 0.70 x the detected memory bound
# budget = "70%" # percent of the detected bound
# budget = "4gb" # absolute
# spill_dir = "/fast/nvme" # optional; default <data_dir>/tier/kevy --tiering-budget 4gb # CLI
KEVY_TIER_BUDGET=4gb kevy # env
kevy-cli CONFIG SET tiering-budget 6gb # live: budget CHANGES onlyCONFIG SET tiering-budget は次の shard tick で再解決されます(maxmemory の先例どおり)。ティアリングのオン / オフの切り替えと、退避ディレクトリの移動には再起動が要ります——value log のライフサイクルは起動単位です。
組み込み——tier cargo feature の下にあります(既定セットに含まれます。退避領域にはデータディレクトリが要るため persist に依存します)。
Config::default().with_tier_budget(bytes) // absolute
Config::default().with_tier_budget_auto() // 0.70 x detected bound
Config::default().with_tier_budget_percent(50) // percent of the boundメモリのみのストア(mem://、またはデータディレクトリのない Config)と wasm ビルドは、ティアリング設定を open 時に名前つきエラーで拒否します——退避するディスクがなく、黙って無視された予算は、起動成功の顔をした間違った答えだからです。
auto = 検出されたメモリ上限 × 0.70。Linux では min(cgroup v2 memory.max, /proc/meminfo MemAvailable)、macOS では hw.memsize(kevy-sys に手書きで束ねた sysctl——ワークスペースで唯一許された OS 境界)です。上限は shard tick で再プローブされるので、上限を変更されたコンテナは動いたまま追随します。上限を検出できないホストは、起動時に auto / パーセントを名前つきで拒否します——絶対値の予算を使ってください。
予算モデル
プロセス全体でひとつの予算を、shard に均等に分けます。各 shard は統一された水位に向かって demote します。
demote target = budget·19/20 − index_reserved_bytes − stub_bytes- インデックスとビューは、優先して確保される固定層です——決して退避されません(コールドな行を安く見つけられるようにする、まさにそのアクセスパスだからです)。そのバイト数は予算の頭から差し引かれます。インデックスの下限だけで予算を超えるなら、
IDX.CREATE/TABLE.DECLAREは、予算が抱えられないインデックスを受け入れる代わりに、名前つきエラーで拒否します。 - stub のバイト数も差し引かれます。すでにコールドなキーの stub は予算が背負う RAM なので、コールド層が育つほど水位は締まります。固定の下限が 19/20 の線を超えると、実効ターゲットは 0 に飽和します——
INFOに見え、隠されません。 - 19/20 という係数はヒステリシス帯です。demote は線の上で始まり、線の下で止まるので、ストアが線の上で振動することはありません。
- 退避は割り当て制です。demote 呼び出し 1 回につき最大 32 レコードで、続きは shard tick に回されます——1 回の
SETが際限のない同期退避の嵐に発展することはありません。
キーあたりの RAM——ホットとコールド
hot key ≈ today's cost (entry + key + value)
cold key ≈ 96 B (entry overhead) + key heap bytes # value fully reclaimedコールドキーの式は、実測 RSS に対して ±20 % でゲートされています(bench/memgate.sh)。この容量モデルから、設計時に踏まえるべき帰結が 2 つ出ます。
- 約 64 B の値は、ティアリングしても決して得になりません——stub が値と同じくらいの大きさだからです。64 バイトの退避しきい値を下回る値は決して退避されません。
data:RAM比は値のサイズに対して線形に伸びます。すべての容量ゲートが自分の値サイズを明記しているのは、まさにこのためです。 - 実測、2026-08-05。 比率カーブはもはやモデルだけのものではありません。予算とキーは同じにして、値のサイズだけを変えました(
INFO Tieringのstub_bytesが、床として効いている当のものです):
| 値のサイズ | 実測 data:RAM |
|---|---|
| 256 B | 2.65× |
| 1 KiB | 10.43× |
| 4 KiB | 39.2×(全スケール:2 GB の予算で 80 GB のデータ) |
床は 9 バイトのキーでエントリあたり約 96 B(48 バイトのキーなら約 143 B)で、試した全スケールで平坦です。おかげで上限が予測できます:最大 data:RAM ≈ 値のサイズ ÷(96 B + キーのヒープ)。上の 3 行に対しては 2.67× / 10.7× / 42.7× を予測します。
その96 Bが何であるかは、レバーがどこにあるかを決めます。 それはキー空間のエントリ(ENTRY_OVERHEAD:インラインのキーセルとEntry)であり、ティアリングされているかどうかに関わらずすべてのキーが払います——コールドのstub自体は24 Bインラインで、ヒープを持ちません。ティアリングは値を返せますが、その値を名指すキーを返すことは決してできないので、どのティアリングのノブもこの数字を動かしません。動かせるのはストアのエントリレイアウトだけであり、それを変えることは、あらゆるワークロードにおける全キーのコストを変えることを意味します。
256 B では予算はきついのではなく、そもそも守れません:20 万件で 16 MB の予算を越え、80 万件で 77 MB に達します——256 B の値を demote して浮くぶんが、それが残す stub に足りないからです。細いレコードでは、予算は手で計算して決めてください。
- 試算例(モデルから導いたもので、実測ではありません):10 M 行 × 約 1 KiB(約 10 GB のデータ)にセカンダリインデックス 2 本と保存された VALUES 列を足して、3 GB の予算に収まります。stub の下限は 10 M × 約 108 B ≈ 1.1 GB、インデックスの下限は 10 M ×(68 + 68 + VALUES 約 30 バイト)≈ 1.7 GB、合計 ≈ 2.8 GB ≤ 3 GB。値が 4 KiB のときの比率ゲートは ≥ 10×
data:RAMです(5 M × 4 KiB = 20 GB を 2 GB の予算で。stub 下限 ≈ 540 MB)。幅の狭い行はキーあたり固定コストが支配します——どの予算も信じる前に stub とインデックスの下限を計算してください。式を先に置いてあるのは、そのためです。この式のインタラクティブ版は <https://kevy.golia.jp/ja/capacity/> にあります。
コールドキーの上での意味論
すべてのコマンドが透過的です。stub は値の型タグを携えているので、メタデータ面の全体がディスク読み取りゼロで答えます。
SCAN/KEYS/RANDOMKEY/DBSIZEはコールドキーを見ます——キーの表は 1 枚なので、SCAN の保証は変わりません。SCAN … TYPE tはタグからフィルタし、ディスクに触れません。TYPE、EXISTS、TTL/EXPIRE系、RENAME、DEL、PERSISTは値を決して読みません。コールドキーへのDEL/ 上書きは value log の dead-bytes に記帳して stub を解放します——読み取りは起きません。WRONGTYPEの拒否は、ディスク読み取りを決して払いません。型チェックはいかなる I/O よりも前に stub で解決されます(コールドな string へのLPUSHは、ホットなときと同じ安さで拒否されます)。SET系のNX/XXは存在を stub から読みます。FLUSHALLはマップを消し、value log をリセットします。- ハッシュのフィールド TTL はコールドでも RAM 常駐で、demote / promote の往復を生き延びます。コールドの間に期限切れになったフィールドは、promote 時に片付けられます。
WATCHは demote / promote では発火しません——層の移動は書き込みではありません。
これらは物語ではありません。透過性テストスイートが、ティアリングありとなしのストアに同じ操作列をリプレイし、意味を持つコマンドの応答がバイト単位で同一であることを表明します——契約上順序が自由な応答(HGETALL、KEYS、SCAN、RANDOMKEY——要素の順序はマップ順です)は形の比較になり——(メモリ報告系のコマンドも形の比較です——それらは正当に異なります)、demote の発生点は決定的に強制され、タイミング依存にはなりません。
promote(ページイン)のポリシー
- コールドな値は、2 回目の実体化アクセスで promote されます。最初のコールド読み取りは、バイトを log から直接返してインストールしません(保護観察マークが押されます)。2 回目でインストールされます。アーカイブ的なキーへの 1 回の気まぐれな読み取りが、ホット層をかき回すことはありません。
- 一括パスは決して promote しません。hydration(
FIELDS、VIEW.HYDRATE)、インデックスの埋め戻し、PREFIX.DIGEST、scope の移動、エクスポート、スナップショット / AOF rewrite のシリアライズは、すべて promote しない peek でコールド値を読みます。全行コールドなテーブルへのIDX.CREATE埋め戻しは、1 行につき 1 レコードを読み、ひとつもインストールしません。 - ゼロコピー共有レーン(C ABI の
kevy_get_shared)は、呼び出しごとにコールド値を読み、決して promote しません——共有レーンの読者は、通常のアクセスパスがキーを promote するまで、その読み取りを払い続けます。 - コールドキーへの
MEMORY USAGEは stub の RAM フットプリント(そのキーがいま実際にあなたに要求しているコスト)を報告します。元の値のサイズは stub の中に載っていて、promote 時に再記帳されます。
v1 で退避されるもの
文字列(64 バイトしきい値より上のもの)とハッシュです——ハッシュは全フィールドを載せた 1 レコードとして退避され、これが、コールドなテーブル行のコストがフィールドごとではなく 1 読み取りで済む理由です。リスト、セット、ソート済みセット、ストリームは v1 ではホットのままです——名前をつけて認めた制限(コレクションの退避は post-v4 リストにあります)であって、エラーではありません。単に demote の候補にならず、予算の算術はそれらを恒久的なホットバイトとして扱います。
INFO——# Tiering セクション
ティアリングが有効なときにだけ現れます(ティアリングなしのサーバーの INFO は、ティアリング機能を持たないビルドとバイト単位で同一です)。サーバーと組み込みリスナーで同一です。
| フィールド | 意味 |
|---|---|
tiering_enabled | 1(オフのときはセクションごと現れません) |
tier_budget_bytes | 解決済みの予算(auto / パーセント → バイト、ライブ) |
tier_effective_target | budget·19/20 − reserved − stubs、0 で飽和——0 は固定の下限だけで水位を超えていることを意味します |
cold_keys | いま demote されているキー数 |
cold_bytes | それらの値の(demote 前の)元のバイト数 |
stub_bytes | コールドキーの stub が占める RAM |
index_reserved_bytes | 水位から差し引かれるインデックス / ビューの下限 |
vlog_size_bytes | ディスク上の value log サイズ |
vlog_live_bytes | まだ参照されているバイト(残りはコンパクション可能) |
vlog_files | value log のセグメントファイル数 |
vlog_epoch | コンパクション世代(引退ファイルのカウンタ) |
demotions_total | 起動以来、退避された値の数 |
promotions_total | 起動以来、ページインで戻された値の数 |
peek_preads_total | promote しないコールド読み取り(コールドな行につき 1 回) |
batch_submissions_total | バッチのコールド読み取り提出数(hydration ページ) |
vlog_size_bytes / cold_bytes は空間増幅率で、受け入れゲートが ≤ 2.0× に締めています。peek_preads_total は、hydration のページがフィールドごとではなく行ごとに 1 読み取りを払ったことを検証する手段です。
性能の見通し
正直に、それぞれの数字の実測 / 保留の状態つきで述べます。
- ホットパス——構造は変わりません。ホットな値の読み書きパスに増えるのは、決して取られない match アームひとつだけです。ティアリングをコンパイルに含めてオフにした状態で、perfgate の 12 指標は既存の許容差でゲートされ、ティアリングをオンにしてワーキングセットが完全にホットな状態では、新しい
tiered_hotset_*行が同じ形でゲートします。ベースラインは専用ベンチマシン待ちです——ゲートの行はすでに存在し、それまでは告知つきでスキップします。 - コールドの点読み取り = 位置指定読み取り 1 回に CRC チェックとデコードを加えたもの。
kevy-vlogのマイクロベンチはread_atをレコードサイズ横断で 0.64–14 µs と測っています(開発機 NVMe)。ゲートが締める端到端の SLA——スカラー p99 組み込み ≤ 100 µs / サーバー ≤ 300 µs、ハッシュ行全体の実体化 ≤ 200 µs / ≤ 500 µs——のうち、サーバー側は実測済みです:envelope 実行でスカラー p99 は 79–171 µs、ハッシュ行全体は 145 µs、データセットが 20 GB から 120 GB へ 6 倍になってもレイテンシはほぼ横ばいでした。組み込み側の 2 つはターゲットのままです——envelope が駆動するのはサーバーだからです。 - 組み込みは、コールド読み取りの間 shard ロックを保持します。プロセス内には読み取りを引き渡す reactor がありません。コールドな実体化は shard の書き込みロックの下で pread します(1 shard の既定 = ストア全体)。その間、その shard の読者は読み取りの長さだけ止まります——NVMe では µs 級、クラウドのブロックストレージでは ms 級になりえ、値が大きいほど比例して長くなります。組み込みの設定は、退避できる値の最大を既定で 256 KiB に制限し(
max_spill_value。with_max_spill_value(bytes)、0 = 無制限)、この窓を有界にしています——上限を超える値は単にホットのままです。サーバーは上限なしです(thread-per-core の shard はロックを共有しません)。この窓そのものを消す drop-lock / pread / relock の設計は済んでいて、明示的に post-v4 です。 - バッチ hydration——1 行 1 読み取り。コールドな行にまたがる
FIELDSの hydration ページやVIEW.HYDRATEは、読み取りを log の位置で束ね、1 バッチとして提出します(io_uring では副リングのリンクした読み取り、poller / 組み込みでは順序つき位置読み取りのループ)。1 回の読み取りが、その行の要求されたすべてのフィールドをデコードします——カウンタはpreads == cold rowsを表明し、rows × fieldsには決してなりません。 - index-only クエリは行にゼロ回しか触れません——FILTER / SORT / COUNT は RAM 常駐のインデックス列から答えるので、全行コールドなテーブルの上でディスク読み取りはゼロです(カウンタで表明済み)。ティアリングされたテーブルに
VALUES列を宣言する理由がこれです。tables.md を参照してください。 - 退避が reactor を際限なく止めることはありません。demote は呼び出しごとに割り当て制で、tick に続きが回ります。ストールの締めつけ(退避起因の p99 ≤ 1 ms)は独立したゲート行で、これもベンチマシンでの実測待ちです。
- アイドルのコストはほぼゼロへ収束します(4.1)。機構は 2 つで、両方が必要です——「冪等は収束ではない」が、ティアリングをオンにして 300–500 倍のアイドル CPU を実測し、機能を切った利用者から得た教訓でした。
- tick ごとのインデックス / ビュー床のフィードは世代キャッシュから供給されます:アイドルなストアは何も再計算せず、書き込み負荷の下でも各セグメント統計はインデックス構造を歩く代わりに、O(1) の走行カウンタです。
- demote サンプラは指数的に後退します——ターゲット超過なのにバッチが何も動かせない tick(以前は tick ごとにフルのサンプル走査を保証していた
effective_target = 0の状態を含む)で。どんな demote でも後退はリセットされ、書き込みパス自身は常に即座にサンプルするので、新しく退避可能になった値が窓(上限 ~6 秒)を待たされることはありません。実測:アイドル 30 秒の CPU が、オフ比 1.6 倍(置き換えた前の値は 300–500 倍)。
運用ノート
- value log は
<data>/tier/(または[tiering] spill_dir)にあり、open のたびに削除され、リプレイが水位を越えるにつれて再構築されます。バックアップしないでください。2 つのストアをひとつの退避ディレクトリに向けないでください。 - セグメントは 256 MiB でローテーションします。封をされたセグメントは、生存率が 50 % を下回るとコンパクションされます(空間増幅 ≤ 生存コールドバイトの 2.0×、ゲート済み)。コンパクションは pin を尊重します——進行中のスナップショットや AOF rewrite は、終わるまで自分のセグメントファイルを読める状態に保ちます。
- すべてのレコードが CRC32C を携えます。退避領域のビット腐敗は、返される代わりに読み取り時に拒否されます。
- データセット > 予算での起動は動きます。リプレイは進みながら水位を確かめ、インラインで退避するので、ティアリングが動き出す前に OOM したりはしません。同じインライン demote は reshard とレプリカのスナップショット読み込みにも乗ります。2026-08-05 実測——2.3 GB の AOF を 64 MB 予算(36 倍)に対してクリーンにリプレイし、30 万行すべてが揃い、
used_memoryは 35 MB に落ち着きました。上限には十分な余裕があります。
RSS は別の話で、このページは以前それを言い過ぎていました。 起動中ずっと RSS ≤ 予算 × 1.05 に保たれる、しかも「ゲート済み」だと書いていましたが、どちらも事実ではありません。そのリプレイでの実測ピーク RSS は 137 MB、予算の 2.15 倍——容量スイープが定常状態で測っているのと同じアロケータのオーバーヘッドです。そして tiergate の L11 行にはまだ測定の実体がありません(PENDING と表示されます)。起動中ずっと保たれるのは論理の上限であり、ティアが会計しているのはそちらです。マシンは予算ではなく RSS で見積もってください。
- ほぼコールドなストアでのスナップショット /
BGREWRITEAOF/ レプリケーションのフル同期は、pin された log からコールド値をストリームし、何も promote しません——追加 RAM のピークは値 1 つぶんで、rewrite からコールド値がひとつも失われることはありません。2026-08-05 実測であり、ゲートではありません:60 000 キー(うち 54 912 がコールド)でBGREWRITEAOFを実行し再起動、60 000 すべてが戻り、範囲全体から抜き取ったキーはいずれも長さが正しく、AOF のリプレイもクリーンでした。tiergateの対応する L10 行はPENDINGのままなので、この主張は CI ではなくその実測に依拠しています。 used_memory≤ 予算 × 1.05 の持続は、それ自体が独立のゲート行です(tiergateの L8)。auto プローブが cgroup コンテナ内でもベアメタルでも正しく答えることを含みます。これは論理の上限で、ゲートは RSS を横に報告するだけで締めつけはしません。2026-08-05 実測:used_memory253 MB に対して上限 281 MB、同時に RSS 488 MB(予算の 1.93 倍)を報告。この行を RSS の保証と読むのが、このページが二つ上の項目で犯していた間違いです。
ゲートの状態(正直の台帳)
上記のうち機構についての主張はすべて、このツリーで走るテストとゲートに覆われています(透過性スイート、ティアリングつき永続化スイート、bench/memgate.sh、bench/tiergate.sh)。envelope の数字は専用ベンチマシン上の bench/capacity-envelope.sh で実測済みです——コールド読み取り p99、書き換え負荷下の vlog 空間増幅、容量比。数字はこのページにあります。
一方 bench/tiergate.sh はまっさらなチェックアウトではそれらの行を保留のまま表示します。これは矛盾ではなく設計です:ゲートが読むのはベンチマシンが産む結果ファイルであり、それを手渡されていないツリーは何も検証していないからです。envelope を走らせ、bench/.capacity-envelope-results を持ち帰り、TIERGATE_RUN_ENVELOPE=1 bash bench/tiergate.sh を実行すれば、行はこの段落ではなく証拠で緑になります。部分実行が専用の結果ファイルに書くのも同じ理由です——行が黙って欠けた結果ファイルをゲートに渡してはいけません。
参照
- tables.md——ティアリングと一緒に設計された TABLE.* レイヤー。インデックスはホット、行はコールド、index-only クエリは行にゼロ回しか触れません。
- persistence.md——ティアリングが意図的に触れていない永続性の契約。
- tuning.md——tier 予算と共存するメモリのつまみ(
maxmemoryとその仲間)。 bench/tiergate.sh/bench/capacity-envelope.sh——このページで引用または保留にした、すべての数字の背後にある受け入れゲートと envelope ランナー。