このページ
セカンダリインデックス(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 --kevy 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 に近づいているなら、問うべきは——そのうち何本が、インデックスの衣を着た親子のたどりなのかです。
グローバルインデックス(PARTITION global)
インデックスは既定ではローカルです。各シャードが自分の持つ行だけを索引するので、クエリは毎回すべてのシャードに送られ、起点でページがマージされます。グローバルインデックスは値でパーティションに切られ、シャードごとに一つずつ置かれます。行はキーのハッシュ先のシャードに残り、そのエントリは値が属するパーティションに置かれます。
IDX.CREATE by_age ON PREFIX user: FIELD age TYPE i64 KIND range PARTITION global SPLIT 30 SPLIT 60
TABLE.DECLARE user PREFIX user: PK id COLUMN id i64 COLUMN age i64 INDEX age range GLOBAL SPLIT AT 30 60- 分割点。
SPLIT v(IDX.CREATEのオプションは対で並ぶので、点ごとに一回)またはGLOBAL SPLIT AT v…。シャード数より一つ少ない数までです。省くと各シャードが自分の行の値を並べ、パーティションあたり 256 の同じ大きさの順位の区間に切って、区間ごとに最大の値と行数を送ります。kevy は行数で重みをつけて合わせます。分割点のずれはシャードごとに最大一区間なので、最大のパーティションは平均から 2/256(0.8%)以内に収まります。行がないときに作ったインデックスは、IDX.REBUILDが標本をとり直すまでパーティションが一つです。同じ値のエントリはすべて一つのパーティションに入るため、取り分より多くの行を持つ値は分割できません。ORDERPATH … GLOBALは標本から決めるか、TABLE.DESCRIBEが書き出す形のSPLIT AT(0xとそのパスの順序エンコードのバイト。そこの一点は複数の列にまたがるため)を受け付けます。 - 読み取り。
EQと、一つのパーティションに収まるRANGEは、一つのシャードだけを読みます。(value, key)順のページは必要なパーティションを順にたどり、そのまま連結します。N ページのマージはしません。IDX.COUNTと選択句(SORT、DISTINCT、FACET、OFFSET)は範囲が重なるパーティションにだけ送られ、IDX.EXPLAINがそれを示します。 - 書き込み。 行のエントリを変える書き込みは、エントリのパーティションにメッセージを一つ(パーティションが変わるときは二つ)送り、クライアントへの応答はパーティションが適用し終えてから返ります。応答のあとに送った読み取りは、その書き込みを必ず見ます。
FIELDSはVALUESから。 パーティションが持つのはエントリで、行ではありません。そのためグローバルインデックスは保存している列でFIELDSに答えます。保存していないフィールドは名前を挙げて拒否され、IDX.ADVISEがVALUESへの追加を提案します。- 種類と制限。
rangeとuniqueのみです。COMPOSEとビュー(どちらも行のエントリが行と同じシャードにあることを前提にします)、ウィンドウ付きテーブル、組み込みストアでは使えず、いずれも名前を挙げて拒否されます。 - 構築。 各シャードが既存の行のエントリを送り終えるまで、クエリは
-INDEXBUILDINGを返します。途中のパーティションが見えることはありません。 - 運用。
IDX.LISTはすべてのインデックスにpartitioningを報告し、グローバルなものにはpartitions、max_entries、mean_entriesも加えます。偏りは後の二つの比に表れます。IDX.REBUILD <name>は標本をとり直して再構築します。IDX.VERIFYはすべての行をそのパーティションが持つエントリと突き合わせるので、driftとmissingは正確で、グローバルな一意インデックスのduplicatesはキー空間全体を数えます(ローカルなものは一つのシャード内の重複しか見えません)。パーティション数よりシャード数が少ない状態で再起動すると、分割点のうち均等な一部が残ります。 - メモリ。 エントリ自体のコストはローカルインデックスと同じで、行のあるシャードはグローバルインデックスのために行ごとのものを何も持ちません。書き込みはその行が索引されていた値を伴い、その値からエントリを持つパーティションが分かるためです。
IDX.LISTとIDX.VERIFYのbytesは各パーティションが実際に持つバイト数です。
整合性とコストモデル
- 書き込みとそのインデックス更新は、所有シャードの内側でアトミックです(単一のリアクタースレッド / シャードロック)。シャードをまたぐクエリは、グローバルスナップショットなしにシャードごとにマージされます(SCANクラス。
DBSIZEと同じです)。 - 空のカタログのコストは、書き込みごとに1回の分岐しない分岐です(Relaxedなアトミックロード1回)。インデックスが宣言されている場合、インデックス対象ドメインへの書き込みは、マッチするインデックス1つにつき、ハッシュフィールド読み出し1回と木の更新1回を支払います。書き込みが行を変える前に、ストアがその行の索引対象フィールドを記録するので、何がその行を書いたか(コマンド、スクリプト内部の呼び出し、トランザクション、期限切れ、退避、複製されたフレーム)に関係なく、更新時にどの古いエントリを消すかが分かります。
- インデックス1つあたりのメモリは概算で
rows × ((value_len + handle_len + 3) / fill + 1)バイトです。インデックスは 1,784 バイトの葉を持つ B+ 木で、エントリは 10 バイトのスロットと、順序キーのうち先頭 8 バイトを超える部分からなります。value_lenはi64/f64で 8、strでは文字列の長さに 2 を足したものです。handle_lenはインデックスの接頭辞を除いたキーの長さで、キーがすべて数字なら半分(切り上げ)です。fillは葉の充填率で、構築直後は葉が詰め直されて 1.0 です(グローバルインデックスのIDX.REBUILDは構築し直します)。バックグラウンドの詰め直しが止まった後も 1.0 で、その間は書き込みが残したままの状態です。行がランダムな順に書かれた後は 0.6〜0.7 です。キーがrow:<n>の 125 万行で実測すると、i64インデックスは詰め直した状態で 1 行 15.9 バイト、ランダムな書き込みの後で 23〜25 バイト、10 バイトの文字列値のstrインデックスはそれぞれ 20.2 と 30〜38 バイトでした。IDX.LISTとIDX.VERIFYは葉が実際に持つバイト数を報告し、bench/idxgate.shがそれをサーバーの実測 RSS と突き合わせます。 VALUESを宣言したインデックスは、その値を同じエントリに持ちます。値ごとに 1 バイトの印、続いて値そのもので、10 進の数字列は 1 桁を半バイトで、それ以外はそのままのバイトで持ちます。これを上のエントリに足してください。短い文字列と 10 桁の数値で、実測では 1 行あたり約 10 バイト増えました。bytesにはこれも含まれます。
集約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に対してゲートします。