kevy4.0

kevy上のRDSワークロード

MySQLやPostgreSQLの上で運用系のワークロードを回していて、それを——全部または一部——kevyへ移すことを検討している。このページはそのためのリファレンスマトリクスです。SQL面の構成要素ひとつひとつについて、kevyでは何がそれを担うのか、正確なverbは何か、そしてセマンティクスの差分は何かを示します。kevyがある構成要素を断る場合、その拒否は、手当ての代替とともに、はっきりと述べられます。対になるクックブックは、これらの行を実行可能なレシピに変えます。designing-on-kevyは、エンジンそのものの1ページの地図です。

分業

RDSでは、まずデータを書いておいて、アクセスパスはあとで決められます。クエリプランナが、どんなWHERE句からでも、クエリ時に*なんらかの*プランを作るからです。kevyはそれを反転させます。アクセスパスは書き込み時に宣言し(インデックス、集約、ビュー)、エンジンがそれをすべての書き込みと同期して維持し(構成上の派生物、ドリフトゼロ)、サービングはマイクロ秒スケールのルックアップになります。プラン探索は決して起きません。取引は明示的です。

この境界はバックログではなく、憲章です。クエリ言語なし、プランナなし、JOINなし、サーバーサイドのバリデーションDSLなし、トリガーなし(「法3」、designing-on-kevy)。以下はすべて、SQLの構成要素をこの固定された面の上へ写像したものです。

テーブル、行、カラム

RDSkevy
テーブルキープレフィックス(user:
プレフィックス配下のハッシュ(user:42
カラムハッシュフィールド
PRIMARY KEYキーそのもの
NULLフィールドが存在しないこと(センチネル文字列は決して使いません)
HSET user:42 name ada email ada@example.com age 36
HGETALL user:42                # SELECT * WHERE id = 42
HGET user:42 email             # SELECT email WHERE id = 42
HMGET user:42 name age         # SELECT name, age WHERE id = 42

存在しないフィールドへのHGETはnilを返します——それがNULLのセマンティクスそのものです。そしてインデックスの仕様は、フィールドの欠落を「行を除外」として扱います(数えられ、IDX.VERIFYで可視になります)。

型システム

kevyはバイト列を格納します。型はそれが意味を持つ場所——インデックス作成時——に宣言します(TYPE i64|f64|str|vector)。宣言された型強制に失敗したフィールドを持つ行は、そのインデックスから除外され、数えられます(IDX.VERIFY / IDX.LISTcoerce_failures)——宣言的なフェンスであって、書き込みエラーでは決してありません。

RDSの型kevyでの形備考
INT / BIGINT / SERIALフィールドのバイト列、インデックスはTYPE i64i64の全域
DECIMAL / NUMERIC整数の補助単位i64で(セント、マイクロ)kevyにdecimal型はありません。f64は2進浮動小数点です——金額を決してそこに入れないでください。KIND aggの合計はf64で累積されるため(精度の限界は文書化済み)、総額が2^53を十分下回るような単位を選んでください
FLOAT / DOUBLEフィールドのバイト列、インデックスはTYPE f64IEEE 754のセマンティクス
VARCHAR / CHAR / TEXTフィールドのバイト列、インデックスはTYPE strバイナリセーフ。辞書式のバイト順序
BLOB / BYTEAフィールドのバイト列(インデックスなし)、または専用の文字列キー値はどこでも任意のバイト列です
DATETIME / TIMESTAMPUnixエポック(秒またはミリ秒)をi64rangeインデックスがBETWEENを与えます。kevyに日付の算術はありません——暦の計算はアプリがやります
BOOL0 / 1i64インデックス可能、合成可能(EQ 0
JSON / JSONB平坦化したハッシュフィールド(profile.cityフィールド単位の読み出し、TTL(HEXPIRE)、インデックス可能性。JSONパスクエリは恒久的に対象外です——クックブックのレシピ9
埋め込み(pgvector)dim × 4バイトのf32-LE、インデックスはTYPE vectorvector-search

PRIMARY KEY、UNIQUE、AUTO_INCREMENT

主キー —— キーそのものです。ポイントルックアップ(WHERE pk = ?)はGET/HGETALL/HMGETです。1シャード、ハッシュプローブ1回、インデックス不要です。

AUTO_INCREMENT / シーケンス —— INCR seq:orderがidを1つ発行します。INCRBY seq:order 100はブロックを確保し、アプリのメモリから配っていけます(高スループット版)。クラッシュ時に穴が空くのは、PostgreSQLのシーケンスが与えるのと同じ契約です(クックブックのレシピ3)。

UNIQUE —— KIND uniqueフェンスであって、ロックではありません。書き込みをブロックしません(ブロックすれば、シャードをまたぐ書き込みを直列化することになります)。重複は数えられ(IDX.VERIFYduplicates)、EQ読み出しが複数ヒットする形で可視になります。硬い一意性のゲートが必要なら、次のいずれかを使ってください。

SET uniq:email:ada@example.com 42 NX          # atomic claim, NX = the gate
WATCH + MULTI/EXEC check-then-write           # CAS loop (cookbook recipe 4)
{hashtag} prefix + single-shard domain        # cluster mode

SELECT

SQLkevyverb
WHERE pk = ?キーの直接読み出しGET / HGETALL / HMGET
WHERE col = ?宣言済みインデックス上の等価IDX.QUERY idx EQ v [FIELDS f…]
WHERE col BETWEEN a AND b宣言済みインデックス上の範囲スキャンIDX.QUERY idx RANGE a b
WHERE col > a半開区間RANGE a <max-sentinel>
WHERE a = ? AND b BETWEEN …2インデックスの合成IDX.QUERY COMPOSE AND idxA EQ v idxB RANGE a b
WHERE col IN (v1, v2)2本足のOR、またはアプリ側でN回のEQクエリIDX.QUERY COMPOSE OR idx EQ v1 idx EQ v2
LIKE 'abc%'(前方一致)strのrangeインデックス、プレフィックスを境界にIDX.QUERY idx RANGE 'abc' 'abc\xff'
LIKE '%abc%'(中間一致)そのままでは非対応トークンの形をしているなら全文のMATCH。さもなければフィールドを設計し直す
全文検索KIND text、BM25IDX.QUERY idx MATCH "…"
SELECT col1, col2(射影)フィールドのhydration… FIELDS col1 col2
COUNT(*) WHERE …実体化せずに数えるIDX.COUNT idx RANGE a b
EXPLAINパース + プラン行、実行なしIDX.EXPLAIN idx RANGE a b

セマンティクスと限界を、正直に。

ORDER BY / LIMIT / OFFSET

GROUP BYと集約

KIND aggは、集約を書き込みパスの中で維持します——GROUP BYの反転です。RDSはクエリ時に行をスキャンしますが、kevyは各書き込みを、それが着地するそばから自分のグループへ畳み込みます。だからグループの読み出しはO(1)です。

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]

JOIN

kevyはJOINしません。 2つのプレフィックスをサーバーサイドで結合するverbは存在せず、これからも存在しません(法3)。置き換えは3つあり、望ましい順に挙げます。

  1. 書き込み時に非正規化する。 サービングモデルの答えです。ページがorder.totalの隣にuser.nameを出すなら、注文の行を作るときにuser_nameをそこへ書き込んでください。ストレージは安いですし、書き込みフックがそのコピー上のインデックスも最新に保ちます。親側の更新のファンアウト(表示用フィールドではまれです)は、CDCコンシューマの仕事です。
  2. アプリ側のhydration(2ホップ)。 外部キーは行の中に住み、インデックスが逆方向をワンホップにします。IDX.QUERY order_user EQ 42 FIELDS total statusSELECT total, status FROM orders WHERE user_id = 42です——インデックスがキーを見つけ、同じ呼び出しの中でフィールドをhydrateします(クックブックのレシピ2)。順方向は親ごとのHMGETです——idをまとめてパイプラインしてください。
  3. VIA hydrationを持つビュー。 VIEW.QUERY v FIELDS name VIA user:{key.1}は、各メンバーキーをテンプレート経由で*ターゲット*キーへ参照解決し、そこでフィールドを読みます——宣言された、再利用可能な「メンバー→親」の参照解決です(内部ファンアウト1回、クライアントの往復はゼロ)。クエリではなく参照解決なので、ターゲットに述語は付けられません。

SQLに対して失うもの:任意のN方向JOIN、遠い側のテーブルに対するJOIN時のフィルタリング、プランナが選ぶJOIN順序。得るもの:JOINがレイテンシのヒストグラムに現れることは二度とありません。

VIEW

VIEW.*は、宣言済みインデックス上の名前付きAND/OR/DIFF合成に、順序づけ用インデックスを添えたものです——SQLのビューというより、インデックス付きの*materialized*ビューに近いものです。

SQLビューの性質kevyのビュー
任意のSELECT本体不可 —— インデックスの形の木のみ(深さ3以下、葉4つ以下、形はCREATE時に固定)
常に最新(virtual)MODE virtual —— クエリごとに評価、書き込みコストゼロ
materialized + REFRESHMODE materialized —— 書き込みフックの中で増分維持され、古くなることが決してなく、リフレッシュのジョブも不要。任意でTOPK kにより有界化
ビュー内のORDER BYORDER BY <index> [DESC] —— 宣言されたrangeインデックスが順序を供給する
ビュー上のビュー不可 —— 1階層、インデックスの上のみ
VIEW.CREATE live_adults QUERY '( AND user_live EQ 0 user_age RANGE 18 200 )'
    ORDER BY user_age MODE materialized TOPK 100
VIEW.QUERY live_adults LIMIT 10 [FIELDS name] [VIA …]

キラーアプリはホットリストです。「優先度順の準備完了ジョブ上位100件」をTOPK materializedビューにすれば、書き込み税はおよそ2%で、マイクロ秒で答え、恒久的に最新です——RDSが、カバリングインデックスと規律あるクエリと運とを揃えて、ようやく近似できるものです(views)。

トランザクション

kevyのトランザクションの物語はRedisのそれです(法1)。SQLへの写像はこうなります。

SQLkevy差分
BEGIN … COMMIT(バッチ)MULTIEXECコマンドはキューされ、1単位として適用されます。内側でのインタラクティブな読み出しはできません —— 分岐の判断材料にする読み出しはMULTIの前に行います
SELECT … FOR UPDATEWATCH key + MULTI/EXECロックではなく楽観的CASです。watchしたキーが変わっていればEXECはnilを返します——読み直して再試行してください(クックブックのレシピ4)
ROLLBACKなしキュー時のエラーはバッチ全体を中止します(-EXECABORT、何も走りません)。EXECの内側の実行時エラーは、ほかのコマンドを取り消しません —— Redisのセマンティクスです
ストアドプロシージャ / 1つのアトミックなRMWEVAL(Lua)スクリプト全体がKEYS[1]のシャード上の1つのアトミックな単位です。本物の分岐を伴うread-decide-writeができます(lua
単一エンティティの直列化可能トランザクション組み込みのstore.atomic(key, …)シャードロックされたクロージャ。読み出しは自分の書き込みを見ます。コミットはアトミックで、fsyncは1回です(クックブックのレシピ5)
エンティティをまたぐ直列化可能トランザクション組み込みのatomic_all_shards決定論的なロック順序。ハンマーです——控えめに使ってください

分離性について、正直に。kevyにMVCCはなく、分離レベルのノブもありません。1つのシャードの内側では、すべてのコマンド(そしてすべてのEVALMULTIバッチ、組み込みのアトミックブロック)が直列化されます——1シャード / 1スクリプトに収まるものについては、*直列化可能*な振る舞いが得られます。シャードをまたぐとグローバルスナップショットはありません。マルチキーの読み出し(MGETIDX.QUERYのマージ)はシャード単位でアトミックであり、全体としてはSCANクラスです。ちぎれた単一コマンドを観測する読み出しはありませんし、コミットされていないMULTIバッチのdirty readもありません。ただし、別々の2つのコマンドをまたぐrepeatable readは存在しません —— それが重要な場面ではWATCH(CAS)かLua(1単位)を使ってください。

「コミット」の耐久性についてはpersistenceを参照してください。appendfsync alwaysなら、ackされた書き込みは応答の前にディスク上にあります。デフォルトのeverysecは最大1秒の窓を開けます(Redisと同じ取引です)。Store::fsync_aof()(組み込み)が、トランザクション単位のsynchronous_commitにあたる脱出口です。

制約とトリガー

SQLkevyの答え
NOT NULL / 型検査型強制のフェンス。インデックスが宣言したTYPEに失敗する行は除外され、数えられます(coerce_failures)。アプリはIDX.VERIFYを見張ります——書き込みの拒否ではなく、宣言的な可視化です
CHECK (expr)不変条件をアトミックな単位の内側で評価します。Luaスクリプト(サーバー)またはatomicブロック(組み込み)が読み、判断し、書きます——判断とコミットが1単位であることをエンジンが保証します(クックブックのレシピ5)
UNIQUEフェンス + 数えられる重複、あるいは硬いSET … NXゲート(前述)
FOREIGN KEY(存在性)強制しません。その不変条件が必要なら、1つのアトミックな単位の内側で親→子の順に書いてください
ON DELETE CASCADEアプリのパターンです。アトミックブロック(小規模)、kevy-cli delete-prefix(一括)、あるいは親の削除に反応するCDCコンシューマ(非同期——クックブックのレシピ10)
トリガーCDCコンシューマFEED.READが、コミットされたすべての書き込みを変更フレームとして配送します——コミット後、疎結合、リプレイ可能で、書き込みパスを壊すことができません(クックブックのレシピ10〜12)

サーバーサイドの制約DSLもトリガーDSLも、意図的に存在しません。サーバーサイドのロジックはLuaスクリプトだけであり、それは書き込みに*付随して*走るのではなく、書き込み*として*走ります。

セカンダリインデックスのDDL

SQLkevy
CREATE INDEX idx ON t (col)`IDX.CREATE idx ON PREFIX t: FIELD col TYPE i64\f64\str KIND range`
CREATE UNIQUE INDEXKIND unique(前述のフェンスのセマンティクス)
CREATE INDEX … USING gin (tsvector)KIND texttext-search
pgvectorのUSING hnsw`KIND ann DIM d [DISTANCE cosine\l2\ip] [M m] [EF ef]`(vector-search
DROP INDEXIDX.DROP idx
\d / information_schemaIDX.LIST / VIEW.LIST(state、entries、bytes)

ページングと楽観ロックのパターン

バックアップとPITR

RDSkevy
mysqldump / pg_dumpkevy-cli export(論理的なRESPストリーム。redis-cli --pipeと互換)
バイナリバックアップスナップショットファイル(SAVE/BGSAVEdump-<id>.rdb
WAL / binlogAOF(シャードごとの、追記専用コマンドログ)
synchronous_commitappendfsync always(あるいはeverysec + fsync_aofのバリア)
PITR(ベース + WALリプレイ)recovery-point契約:スナップショット + そのスナップショットが記録したカーソル以降のCDCフレーム = 以降の任意のカーソル時点における正確な状態(persistence

PITRについてのスコープ上の注意:フィードの窓はインメモリのバックログです(feed_buffer_size、シャードあたり最大1 GiB)。正確な時点への復旧に頼るなら、少なくともその窓が一巡するのと同じ頻度でスナップショットを取ってください。検証は一級市民です。PREFIX.DIGEST / kevy-cli diffが、順序にもトポロジにも依存せずに、2つのキー空間が等しいことを証明します。

レプリケーションと読み出しのスケーリング

プライマリ + N台のレプリカ、デフォルトで非同期——リードレプリカの形です。RDSとの違いはすべて整合性ラダーavailability)にあります。ラダーは、各保証にインスタンス単位ではなく呼び出し単位で値段を付けます。

RDSでの概念kevyの段
非同期レプリカ(MySQLのデフォルト)段0:プライマリはローカル適用後にackし、レプリカは遅れる
セッションのread-your-writes段1:プライマリでREPL.TOKEN → レプリカでREPL.WAIT token → 次の読み出しが自分の書き込みを観測する
準同期レプリケーション段2:書き込みごとのWAIT n timeout —— プライマリ上にあり、かつn台以上のレプリカにackされている(ackはfsyncではありません)
レプリカの最大ラグのガード段3:replica_max_staleness_ms —— 古いレプリカは、古い読み出しを返す代わりに-STALEを返す
スタンバイがなければ書き込みを拒否段4:min_replicas_to_write-NOREPLICAS
フェンシング / スプリットブレインのガード段5:クォーラムリース —— 分断されたプライマリは自分の書き込みをフェンスする

フェイルオーバー:計画的な引き継ぎはFAILOVER verbです(quiesce → drain → 昇格——ゼロロス)。クラッシュフェイルオーバーは3ノードのelectクォーラムです(MTTRはおよそ5〜8秒。オフセット最良の候補が勝つので、WAITでackされた書き込みは生き残ります)。再合流してくる元プライマリの、レプリケートされなかった末尾は破棄されます(分岐した末尾は、定義上ackされていません)——MySQLの準同期で、まだ送られていないbinlogを失うのと同じ形のデータロスを、明示したものです。劣化した状態はすべて名前を持つエラー(-READONLY-STALE-NOREPLICAS-QUIESCED-MISDIRECTED)であり、ルーティングクライアントはそこから自己修復します(error-replies)。

CDC

フィードはkevyの「APIとしてのbinlog」です——DebeziumがRDSから抽出しているものを、ネイティブに提供します(cdc)。

Debezium / binlogでの概念kevy
binlogの位置 / LSNシャードごとの(generation, offset)カーソル
コネクタのスナップショット + ストリーム再構築(自分のプレフィックスをSCAN)+ FEED.TAILから再開
テーブルごとのトピックによる絞り込みFEED.READPREFIXフィルタ(マルチキーのフレームにはfail-open)
at-least-onceの配送同じです —— コンシューマは冪等でなければなりません
トランザクショナルoutboxパターン不要:コミットされた書き込みは、すでにそれ自体が変更フレームです(クックブックのレシピ11)

意図的に提供しないもの:シャードをまたぐグローバル順序(キー単位の順序は、シャードのストリーム内で保証されます)、サーバーサイドのコンシューマ位置、コンシューマグループ。保持期間はインメモリのバックログです。遅れすぎると、再構築の出発点となるカーソルとともに-FEEDRESYNCが返ります。

kevyが決してやらないこと

拒否を1か所にまとめます。どれも憲章上の決定であって(designing-on-kevy、プロジェクトのスコープログ)、ロードマップの穴ではありません。

拒否するもの理由代わりに使うもの
SQLパーサ / クエリDSLプランナへの坂道はここから始まる明示的なIDX.*/VIEW.* + このマトリクス
クエリプランナ / 自動インデックス選択エンジンは宣言されたパスを実行すべきであって、決めるべきではないIDX.EXPLAIN(診断専用)
JOINサーバーサイドのプラン探索は存在しない非正規化 / hydration(FIELDSVIA)/ ビュー
インデックスなしのWHERE偶発的なO(n)は、いつかあなたを叩き起こすための嘘であるパスを宣言するか、そのクエリを出さないか
サーバーサイドの制約DSL / トリガーアプリのロジックを蓄えることはエンジンのジャンルではないLuaのEVAL(アトミックな単位)+ CDCコンシューマ
DECIMAL型i64/f64を超えるサーバーサイドの算術は持たない整数の補助単位
JSONパスクエリハッシュフィールドこそがカラムモデルであるフィールドへ平坦化する(クックブックのレシピ9)
HAVING / 集約式クエリ言語への坂道GROUPSの出力をアプリ側でフィルタする
OFFSETページングO(N)の読み飛ばしはアンチパターンカーソル
マルチデータベースのSELECT nサーバーごとにキー空間は1つプレフィックス、または別インスタンス
AUTH / TLS信頼境界はネットワークの外周であるループバックbind(デフォルト)、サイドカープロキシ
クロスDCのアクティブ・アクティブ、CRDTシングルDCの憲章アプリレベルのフェデレーション
動的メンバーシップ / 自動置換 / リシャーディングトポロジはオペレータが宣言するconfig + ローリング再起動
HTTP/REST APIRESPとMCPが2つのアクセスプレーンである任意のRedisクライアント

サイジングと運用上の差分

すべてがRAM常駐です。 RDSがディスクからページインしてくるワーキングセットは、ここではメモリに住みます。ディスクは耐久性のログとスナップショットであって、サービングの階層ではありません。キャパシティプランニングを1行で書くと、こうです。

RAM ≈ 行数 × (平均キー長 + 平均行バイト数 + キーあたりのオーバーヘッド) + Σ インデックスごとの式 + ビューのメンバー数 × エントリサイズ ——そのうえで、 ロード済みのサンプル上でMEMORY USAGE / IDX.LISTのバイト数と突き合わせて検証すること。

サブシステムごとの式(それぞれCIで実測RSSに対してゲートされています):rangeインデックスは概算でrows × (value_width + avg_key_len + 48)。textとANNの式はtext-search / vector-searchにあります(1M × 1024次元のベクトルでおよそ4.1 GiB)。aggはグループ数が支配的です(indexes)。ビューのメンバーは概算でorder_value_width + key_len + 48です(views)。maxmemoryとエビクションポリシーを設定するか、-OOMによる拒否を受け入れてください(書き込みは受付の時点で拒否されます。既存のデータが壊れることはありません)。

サービングの余力。 v3.18.0のリリースアリーナ(kevy対valkey 9.1、fair-fightプロトコル、5回の中央値——bench/PERF-LEDGER.md):GET 3.00倍、SET 3.99倍、INCR 3.00倍、SADD 2.50倍、HSET 2.25倍、ZADD 1.73倍、LPUSH 1.64倍——レプリケーション/ハートビートのパイプラインを載せきったうえで、7戦7勝です。ディスクファーストのRDSに対するポイント読み出しでは、差はさらに大きくなります。ただしそこでの正直な比較は、クエリ単位のベンチマークではなく、「kevyはキャッシュ層*と*運用系クエリの両方を置き換える」というものです。

RDSに対する運用上の差分を、手短に。キー空間は1つ(スキーマもデータベースもありません——プレフィックスが名前空間です)。TTLは一級市民です(EXPIRE、フィールド単位のHEXPIRE——cronの削除ジョブが消えます)。FLUSHALLはコマンド1つで届きます(外周で守ってください)。派生状態(インデックス、ビュー、フィード)は再起動後にロードされるのではなく再構築されます(100万行あたり数秒——再起動時間に織り込んでください)。そしてエラーの面は契約の一部です——クライアントはメッセージをパースするのではなく、エラーのプレフィックスにマッチさせるべきです(error-replies)。

このページとクックブック

このページはリファレンスマトリクスです——SQLの構成要素を入れると、kevyの形が出てきて、差分が述べられます。クックブックレシピ集です——20の実行可能なパターン(コマンドブロックはすべてCIでsmokeされています)。それぞれが、置き換えるSQLの構成要素でタグづけされ、上のマトリクスの行へ相互リンクされています。本当に移行するのですか。段階を追ったプレイブックはmigrationにあります。