セカンダリインデックス(IDX.* / idx_*)
kevyは、キー空間のプレフィックスドメインに対して宣言型のセカンダリインデックスを維持できます。プレフィックス配下のハッシュキーが1つの「行」であり、宣言された1つのハッシュフィールドがインデックス対象の値です。インデックスはすべての書き込みと同期して維持され(構成上の派生物——インデックスがデータからドリフトすることは原理的にあり得ず、IDX.VERIFYがそれを反証可能にします)、カーソルページング、2インデックスの合成、任意のフィールドhydrationとともにクエリできます。
IDX.CREATE idx_age ON PREFIX user: FIELD age TYPE i64 KIND range
HSET user:42 age 31 name "……"
IDX.QUERY idx_age RANGE 18 30 LIMIT 100 FIELDS name宣言する
IDX.CREATE <name> ON PREFIX <p> FIELD <f> TYPE i64|f64|str KIND range|unique [MAXMEM <bytes>]
- TYPEはスカラーへの型強制です。フィールドが欠けている行、あるいはパースに失敗する行は除外されます(インデックスごとに数えられ、
IDX.VERIFY/IDX.LISTがcoerce_failuresとして報告します。これは宣言的なフェンスであって、実行時エラーではありません)。 - KIND rangeは
RANGE min maxのスキャンを提供します。uniqueは同じものに加えて、重複フェンス(後述)を提供します。 - MAXMEMはインデックスのメモリに上限を設けます。予算を超えるビルドは、際限なく成長するのではなく、宣言的に失敗します(クエリに
-INDEXOVERBUDGETが返ります)。 - インデックスは最大64個です。カタログはデータディレクトリのサイドカーに永続化されます。インデックスの内容は派生状態です——スナップショットにもAOFにも決して記録されず、再起動後にバックグラウンドで再構築されます(準備できるまで
-INDEXBUILDING。データの可用性が待たされることはありません)。
クエリする
IDX.QUERY <name> RANGE <min> <max> | EQ <v> [LIMIT n] [CURSOR c] [FIELDS f…]→[next-cursor, rows]。行は(value, key)で、全シャードにわたって順序づけられます。FIELDSは、各行を所有するシャード上で指定されたハッシュフィールドをhydrateし(2回目の往復は発生しません)、行をネストした[key, value, fname, fval…]の形に切り替えます。IDX.QUERY COMPOSE AND|OR <n1> <spec1> <n2> <spec2> …— 2インデックスの合成です。キー順になります(2つの値ドメインが異なるため)。LIMIT/CURSOR/FIELDSの末尾は同じです。AND/ORはシャードごとに走ります(キーはちょうど1つのシャードに住むので、シャードごとの集合代数がグローバルに合成されます)。IDX.COUNT <name> RANGE|EQ …— キーを実体化せずに数えます。- 非スカラの kind は
VERIFYに自分の語彙で答えます。KIND aggはrows / bytes / excluded / groups、KIND textはdocs / bytes / postings / tokens、KIND annはvectors / bytes / tombstones / links / rebuild_recommended。どれもdrift/missingを印字しません——監査の問い(このエントリの行はまだこの値を導出するか)は行をキーとするエントリに適用されるもので、これらのエントリはグループ・ポスティング・グラフのノードだからです。(これらの数字はかつてスカラのラベルを着せられて印字されていました:健全な 3 文書の text インデックスがcoerce_failures 7, duplicates 7と答えたことがあります——postings と token の数が、整合性警告の名前をまとっていたのです。) - それが集約のカウンタに意味すること:走行中の合計は実行時にキースペースと突き合わせて再計算されることがないため、現実との一致は「すべての書き込み経路がそれを保守した」ことに依ります——そしてこの kind については
IDX.VERIFYはそれを反証できません。代わりにテストが行います:index_write_path_coverageが各動詞のあとで、グループのカウントを実際に生きている行と突き合わせます。 IDX.VERIFY <name>— 合算した統計。entries、bytes、coerce_failures、duplicates、そして監査の両方向:checked件のエントリに対するdrift(行が消えた、もう強制変換できない、あるいは別の値に変換されるエントリ)と、missing(プレフィックス配下で値を導出できるのにエントリが無い行)。健全なインデックスではどちらもゼロであるべきで、missingはインデックス自身のエントリを走査するだけでは見えない方向です。kevy-cli doctorはこれを宣言済みの全テーブルに対する終了コードに変えるので、「ゼロであるべき」を、誰かが思い出して確かめることではなく cron にできます(table-migration.md)。IDX.LIST— カタログと、インデックスごとのstate/entries/bytes。- カーソルの契約はSCANクラスです。走査の全体を通じて安定していた行はちょうど1回見えます。並行する挿入・削除は現れるかもしれないし、現れないかもしれません。
"0"は開始または枯渇を意味します。
一意性はフェンスであって、ロックではない
uniqueインデックスは書き込みをブロックしません。書き込み時にグローバルな一意性を強制すれば、シャードをまたぐ書き込みを直列化することになるからです。代わりに、重複は数えられ(VERIFY/LISTのduplicates)、EQ読み出しが複数ヒットする形で可視になります。
このカウンタはシャードごとで、読み出しはそうではありません。 duplicates は各シャードのセグメントの中で保守されるので、値を共有する二行が同じシャードに落ちたときにしか見えません——そしてキーはシャードにハッシュで散るので、普通はそうなりません。実測:同じ二行が、単一シャードのサーバでは duplicates 1、二シャードでは duplicates 0 を報告します。グローバルなカウンタにするには書き込み経路でシャードをまたいで値を数える必要があり、それこそこの kind が避けるために存在する直列化です——ですから常に効く検出は EQ 読み出しの複数ヒットであり、duplicates はヒントにすぎず、そこがゼロでもその値が一意だという主張にはなりません。硬い一意性が必要なら、クラスタモードで{hashtag}プレフィックスによりドメインを1シャードに固定するか、MULTI/WATCHのもとでcheck-then-writeしてください。
組み込みAPIでは、atomic()の内側でインデックスをそもそも読めません。したがってKIND uniqueは、楽観的にすらこのチェックに参加できません。代わりにクレームキーを使ってください:u:<constraint>:<value>が所有者のidを保持し、トランザクションの内側でgetしてsetします。トランザクションが「確認してから確保する」をアトミックにします——それはuniqueインデックスが意図的に提供しない保証です。
ある利用者は22個の一意性制約をこの方法で実装し、そのどれにもKIND uniqueを使いませんでした。パターンとしては機能しますが、彼らはここを読んでそこへ辿り着いたのではなく、抜けを自分で発見して辿り着いたのです。
組み込み
同じエンジンを型付きAPIで使います。idx_create / idx_drop / idx_query / idx_count / idx_stats / idx_list(値はIndexValue、カーソルはIndexCursor)。FIELDSのhydrationはありません——プロセス内にいるのですから、フィールドはhgetで読んでください。idx_createは同期的にビルドし、インデックスが提供可能になった時点で返ります。
インデックスの予算
インデックスは全体で 64 本。 接頭辞ごとでもシャードごとでもなく、ストア全体で 64 本です(MAX_INDEXES、kevy-index/src/catalog.rs)。
素直に読むと、この数字はまともなスキーマを軒並み止めます。58 のテーブルに対して 64 本は不可能に見え、移行はその算術で行き詰まりかねません——その算術のほうが間違っていると気づく前に。
インデックスは希少な全体予算であり、ほとんどのアクセス経路はそれを消費しません。 親子のたどりはリンクキーと zset の仕事です——SMEMBERS order:1001:items はインデックス枠を一つも使いませんし、自分で保守する順序付き zset インデックスも同様です(cookbook §2)。インデックス枠は、リンクキーでは表せないものにだけ使ってください:
- 全体にわたる値の範囲——「一万を超える請求書すべて」を全行から
- テキスト検索——
KIND text - 集約——
KIND agg、書き込み時の GROUP BY
「テーブルごとに一本」と読めば 58 本要るスキーマも、「全体のクエリ形ごとに一本」と読めばたいてい 20 本を切ります。64 に近づいているなら、問うべきは——そのうち何本が、インデックスの衣を着た親子のたどりなのかです。
整合性とコストモデル
- 書き込みとそのインデックス更新は、所有シャードの内側でアトミックです(単一のリアクタースレッド / シャードロック)。シャードをまたぐクエリは、グローバルスナップショットなしにシャードごとにマージされます(SCANクラス。
DBSIZEと同じです)。 - 空のカタログのコストは、書き込みごとに1回の分岐しない分岐です(Relaxedなアトミックロード1回)。インデックスが宣言されている場合、インデックス対象ドメインへの書き込みは、マッチするインデックス1つにつき、ハッシュフィールド読み出し1回とB木更新1回を支払います。
- インデックス1つあたりのメモリは概算で
rows × (value_width + avg_key_len + 48)バイトです(定数はエントリごとの構造オーバーヘッド)。IDX.LISTが実測バイト数を報告し、bench/idxgate.shがこの式をゲートします。
集約kind(KIND agg)——書き込み時GROUP BY
IDX.CREATE ord_amt ON PREFIX ord: FIELD amount TYPE i64 KIND agg GROUPBY status
IDX.QUERY ord_amt GROUP paid → [count, sum, min, max, avg]
IDX.QUERY ord_amt GROUPS BY sum LIMIT 100 → ranked [group, count, sum, min, max]GROUPBYが取るのはフィールド1つで、現実のGROUP BYの形はたいていそれ以上を必要とします。 方向で分けたSUM(amount) GROUP BY monthや、任意のSUM(CASE WHEN …)は、条件をグループキーの中へ移すことで表現します:書き込み時に複合フィールドを実体化し(ym_dir = "2026-07:in")、それでグループ化し、アプリ側でキーを分解します。条件つき集約も同じやり方です——条件は集約の一部ではなく、あなたが何でグループ化しているかの一部になります。
このイディオムは後から見れば当たり前で、初対面では見えません:KIND aggはGROUP BYに答えるように見えて、人が実際に書く形には答えないのです。
SELECT g, COUNT(*), SUM(v) … GROUP BY gに対するエンジンの答えです。集約は書き込みパスの中で維持されます(宣言されたアクセスパスであって、クエリ時の行スキャンでは決してありません)。min/maxはグループごとの値の多重集合により、削除のもとでも正確なままです。sumはf64で累積されます(精度の限界は文書化済み)。値の型強制に失敗した行、あるいはグループフィールドが欠けている行は除外され、数えられます(VERIFY)。シャードをまたぐマージは正確です。countとsumは足し合わされ、極値は取り合わされます。GROUPSはcount/sum/maxの降順、またはminの昇順でランクづけし、LIMITは1000以下です。
HAVINGなし、集約式なし、近似スケッチなし——それはクエリ言語への坂道です。GROUPSの結果はアプリ側でフィルタしてください。
組み込みではidx_create_agg(name, prefix, field, ty, group_by) / idx_group(name, g) / idx_groups(name, by, limit)です。
メモリは概算でgroups × (gkey+64) + distinct_values × 18 + rows × (key+10)です(定数は実測RSSに対して較正済み)。GROUP p99 < 1ms @ 1M×10kグループ、GROUPSのtop-100 < 5ms、書き込み税 < 10%とあわせて、bench/agggate.shが実RSSに対してゲートします。