移行——プレイブックとツールチェーン
2部構成です。プレイブック:稼働中のRDSワークロードを、検証クランプとロールバックポイントを備えたフェーズ単位でkevyへ移す方法。ツールチェーン(kevy-cli):データをkevyへ、kevyから、そしてkevyサーバー間で動かし——移動が忠実だったことを証明するコマンド群。モデリングの疑問(「うちのJOINは何になるの?」)はrds-workloads.mdへ、実行可能なパターンはクックブックへ。
フェーズ制プレイブック
7つのフェーズがあります。要になる性質は、書き込みカットオーバーまでのすべてのフェーズで、真実の源は依然としてRDSであり、ロールバックは「アプリの向き先を戻す」ことである——決して「データを逆移行する」ではない、という点です。フェーズを飛ばさないでください。各フェーズの検証クランプこそが、次のフェーズを退屈なものにしてくれます。
フェーズ0——棚卸し
すべてのテーブルと、ワークロードが実際に実行するすべてのクエリ形を列挙します(真実はORMのクエリログやpg_stat_statementsであって、スキーマではありません)。各項目をrds-workloadsマトリクスに照らして分類します。大半の行は機械的に対応づきます。拒否された構文(ホットパスのN-way join、HAVING、深いOFFSETページネーション、アドホックなレポーティングSQL)に当たるものにフラグを立ててください。
- アクション:書き出したアクセスパス表。クエリ→マトリクス行→kevyの形(インデックス/ビュー/集計/verb)。
- クランプ:すべてのホットパスクエリが、データを動かす前に対応する形を持っていること。拒否された構文は、カットオーバー時ではなく今、アプリ側の再設計判断を下します。アドホック分析はkevyに乗せず、ウェアハウスへのエクスポート(クックブックのレシピ14)を計画してください。
- ロールバック:まだ何も動いていません。
フェーズ1——スキーマ対応づけ
棚卸しを宣言スクリプトへ変換します。プレフィックス、フィールドレイアウト、型、そしてすべてのIDX.CREATE/VIEW.CREATE行です。マトリクスの型規則——DECIMALは最小単位の整数へ、DATETIMEはエポックi64へ、NULLはフィールド不在へ——を今適用してください。これらはバックフィル後に安くはやり直せない、書き込みフォーマットの決定だからです。
- アクション:宣言スクリプト。DDL履歴の隣でリポジトリにチェックインします。
- クランプ:代表的な数百行を入れた使い捨てkevyに対してスクリプトを実行し、
IDX.VERIFYがcoerce_failures = 0と想定どおりのduplicatesを示すこと。VIEW.VERIFYに順序除外がないこと。ページングクエリがSQLの返したものと同じものを返すこと。 - ロールバック:まだ何も動いていません。
フェーズ2——変更キャプチャ(二重書き込みウィンドウが開く)
バックフィルのスナップショットより前にRDSへの書き込みのキャプチャを始めます。何も隙間に落ちないようにするためです。ルートは2つあります。
- アプリ二重書き込み——アプリはRDS(依然として真実)へ書き、各書き込みをkevyへミラーします。シンプルで新規インフラ不要。ただしミラーは書き込みパスに相乗りします(失敗カウンタ付きのfire-and-forgetにしてください——落ちたミラー書き込みは再バックフィルで修復するものであって、RDSのコミットをブロックして守るものではありません)。
- CDCプル——binlog/論理レプリケーションのコンシューマ(Debeziumか自作)が行イベントをkevy書き込みへ変換します。アプリから疎結合、行ごとに順序保証、binlog位置からリプレイ可能。対価はコネクタのインフラです。すべての書き手に触れられないとき、書き手が多いときは、こちらを選んでください。
どちらのルートでも、kevyへの書き込みは冪等な再構築形(フル行のHSET、コレクションはDEL+再追加)でなければなりません。そうしておけばバックフィルとの重なりは無害です。
- クランプ:キャプチャパスの鮮度ラグ計測(イベントのタイムスタンプ→kevy適用)と、プレフィックスごとの行数ドリフト検査(
PREFIX.STATS対SELECT COUNT(*))が定数へ収束すること。 - ロールバック:キャプチャを止めるだけ。RDSは何も気づいていません。
フェーズ3——バックフィル
既知のキャプチャ位置でRDSのスナップショットを取り、RESPコマンドファイルへエクスポートして、バルクロードします。
# generate rebuild frames from your RDS export (one HSET per row):
# HSET user:42 name ada email ada@example.com …
kevy-cli import -p 6004 --strict rows.resp # ≥200k cmd/sインポートが先、インデックス宣言は後(インデックス後回しの規則)。バックフィルは既存の行から各インデックスをバルク速度で構築します——インポートする行ごとに書き込みフックのコストを払うより桁違いに安上がりです。その後フェーズ1の宣言スクリプトを実行し、IDX.LISTがstate=readyを報告するまで待ちます(詳細はインデックス済みキー空間へのロード)。
- クランプ:
PREFIX.STATSのカウントがテーブルごとにSELECT COUNT(*)と一致すること。IDX.VERIFYの型強制/重複カウントが想定どおりのゼロであること。サンプリングによるダイジェスト検査——数百行をRDSから再計算し、HGETALLとバイト比較——が通ること。中間kevyを経由する場合はkevy-cli diffがそのホップを証明します。 - ロールバック:
kevy-cli delete-prefix(またはFLUSHALL)してやり直し。真実はまだRDSです。
フェーズ4——読み取りカットオーバー(エンドポイント単位のカナリア)
読み取りはビッグバンではなくエンドポイント単位で切り替えます。各エンドポイントに必要な一貫性の段を選びます(availability.md)。閲覧/一覧系のエンドポイントは段0(非同期レプリカ読み取り)に乗せ、「今書いたものを見せて」のフローはREPL.TOKEN/REPL.WAIT(段1)を使うかプライマリを読みます。単一ノードのkevyなら考えるべき梯子はありません——すべての読み取りがプライマリです。
- アクション:まずシャドーリード(RDSから応答しつつkevyも読んで、比較して不一致を数える)、その後トラフィック比率を移します。
- クランプ:フルの交通サイクル(24時間以上)にわたって不一致カウンタがゼロ。サービングレイテンシがSLOの内側。
- ロールバック:読み取りの向き先をRDSへ戻す——即時、データは無傷です。
フェーズ5——書き込みカットオーバー+ロールバックウィンドウ
書き手をkevyへ切り替え、同じデプロイで逆方向ミラーを開始します。kevyのフィードをSQLとしてRDSへ流し戻すCDCコンシューマです(FEED.READ→UPDATE/INSERT、クックブックのレシピ13)。RDSはこれで、kevyを数秒遅れで追いかけるウォームスタンバイになります。ロールバック計画は「アプリの向き先を戻す」であって、「逆移行」ではありません。
切り替えの前に、シーケンスキーをRDSの最高水位より上にシードしてください(新しいキーにINCRBY seq:order <rds_max_id>)——古典的なauto-increment衝突が、フェーズ5のいちばん鋭い刃です。
- クランプ:kevyとミラー先RDSの間での
kevy-cli diff式サンプリング(プレフィックスをダイジェストし、行サンプルを比較)。それに、すでにアラートを張っているビジネスメトリクス。 - ロールバック:ミラーウィンドウの内側であれば、アプリの向き先をRDSへ戻します(計測済みのミラーラグは受け入れます)。ミラーコンシューマは
-FEEDRESYNC(kevyのクラッシュ再起動はフィード世代を進めます)を処理できなければなりません。影響を受けたプレフィックスをRDSへ再構築してから再開します——at-least-once+冪等SQLがこれを安全にします。
フェーズ6——退役
確信が固まったら(数週間のクリーンなdiffと、一度は実施したフェイルオーバー訓練)、逆方向ミラーを止め、アーカイブ用に最後のRDSダンプを取り、運用の物語を完全にkevyへ移します。スナップショット+AOF(+フィードカーソルのリカバリポイント)はpersistence.md、レプリカ/フェイルオーバー態勢はavailability.mdの通りです。
- クランプ:kevy自身の成果物(スナップショット+フィードリプレイ)からのリストア訓練を、RDSを止める前に行うこと——後ではいけません。
よくある落とし穴
- 2つの書き込みパスを通るDECIMAL。二重書き込みパスが
"10.00"を格納し、バックフィルが"1000"(最小単位)を格納すると、すべてのダイジェスト検査が失敗し——もっと悪いことに——読み取り同士が食い違います。フェーズ1でフォーマットを固定し、両方のパスに1つのシリアライザを共有させてください。 - auto-increment衝突。
seq:*キーをRDSの最大idより上にシードし忘れると、既存のidを配ってしまいます。フェーズ5でシードし、書き込みを開く前にGET seq:order1回で確認します。 - ホットキー。kevyのキーはちょうど1つのシャードに住みます。全リクエストが触っていたRDSの行は、単一シャードの直列化ポイントになります。ホットなカウンタは分割し(ユーザー単位キー、ブロック割り当てシーケンス)、読み取り側の合算は
KIND aggにやらせてください。 - 大きな値。数百KB級の行は動きはしますが、あらゆるホップ(エクスポートフレーム、レプリケーション、フィードフレーム)を重くし、サービングコストをネットワークパスへ移します。ホットな行はリーンに保ち、コールドなブロブはポインタフィールド付きでオブジェクトストレージへ、めったに読まないカラムは兄弟キーへ分割します。
- TTLの意味論。非正のTTLを渡した
EXPIREはキーを即座に削除します(Redisの契約)。エクスポートされたTTLは絶対時刻のPEXPIREAT期限として運ばれるので、移行が遅くても寿命は延びません。ダイジェストはTTLを無視します(値のみ)——TTLの正しさはdiffではなくPTTLのサンプリングで検査します。 KEYS/SCANの癖。SELECT COUNT(*)をやっていた運用ワンライナーはPREFIX.STATSかIDX.COUNTを使うべきです——KEYS patternは全キー空間のギャザー、SCAN MATCHは全域のインクリメンタルウォークであり、どちらもサービングパスではありません(大きなキー空間では、運用パスですらほぼありません)。- インデックス構築と読み取りのレース。宣言直後、クエリは
-INDEXBUILDINGを返します。カットオーバーウィンドウ内のクライアントにはポーリング&リトライのループ(下記)が必要です。さもなければ、まだ誰も見ていないフェーズ3で宣言してください。
ツールチェーン(kevy-cli)
kevy-cli export -p 6379 --prefix user: dump.resp
kevy-cli import -p 6004 --strict dump.resp # ≥200k cmd/s
kevy-cli import -p 6004 --resume dump.resp # after interruption
kevy-cli digest -p 6004 user:
kevy-cli diff hostA:6379 hostB:6004 user: order:
kevy-cli copy-prefix -p 6004 --rate 5000 user: staging:user:
kevy-cli delete-prefix -p 6004 --rate 5000 --dry-run tmp:
kevy-cli inspect -p 6004 user:ワイヤフォーマット
exportは再構築フレームのプレーンなRESPコマンドストリームを書き出します——DEL+SET/HSET/RPUSH/SADD/ZADD、TTLには絶対時刻のPEXPIREATです。このためファイルはredis-cli --pipeと双方向に互換です。kevyのエクスポートはRedisに食わせられますし、どんなRESPコマンドファイルもkevy-cli importに食わせられます——RDSのダンプから自作したもの(プレイブックのフェーズ3)を含めて。
キーごとに先頭へ置かれるDELが、リプレイをゼロからの再構築にします——あらゆる型に対して本物の冪等です(RPUSHのような追記verbは、そうしないと再インポートでリスト内容が倍になります)。
一貫性と再開可能性
- エクスポートはSCANウォーク下でのキー単位のポイントインタイムです(SCANクラスの保証。エクスポート中に書かれたキーはどちらの状態でも現れ得て、消えたキーはスキップされます)。グローバルスナップショットはありません——設計上の選択です。
- インポートはバッチごとに512コマンドをパイプラインし、バッチごとにバイトオフセットを
<file>.progressへfsyncします。--resumeはそこから再開します。フレームは再構築形なので、重なりは無害です。--strictは最初のサーバーエラーで中止します。指定がなければエラーは数えられて報告されます。 - インポート中の
kill -9はゲートで検証済みのシナリオです。再開は同じPREFIX.DIGESTへ収束します。
検証
PREFIX.DIGEST <prefix>(サーバーおよび組み込みのprefix_digest)は[count, hex64]を返します。正準化した行バイトに対する順序非依存のチェックサムです(ハッシュフィールドとセットメンバーはソート、zsetはスコアビット→メンバー順、リストは並び順のまま——リストの順序は同一性そのものです)。シャード数にも挿入順にも影響されないので、トポロジをまたいだ比較ができます。kevy-cli diff A:port B:port prefix…は不一致が1つでもあれば非ゼロで終了します。
TTLはダイジェストに参加しません(減衰するため)。値は参加します。
バルク操作
copy-prefixは読み取り+再構築フレームで各行を新しいプレフィックスの下へキー替えします(サーバーには意図的にCOPY verbがありません。TTLは絶対時刻の期限として運ばれます)。delete-prefixはSCAN+UNLINKです。どちらも--rate N(トークンバケット、最初のopから厳密なペーシング)を取り、deleteは--dry-runをサポートします。
インデックス済みキー空間へのロード
インデックスのready待ち
IDX.CREATEは即座に戻り、バックグラウンドでバックフィルします。完了までクエリは-INDEXBUILDINGを返します。標準の待ち方はIDX.LISTのポーリングです——stateカラムがbuildingからreadyへ切り替わります。
until kevy-cli -p 6004 IDX.LIST | grep -A1 my_index | grep -q ready; do sleep 1; doneテキストインデックスのバックフィル速度はドキュメントサイズに比例します。小さな行なら100万行あたり約7秒ですが、数KBの本文はおよそ100万件あたり85秒でインデックスされます(実測:メールサイズの本文20万件を17秒)。
移行全体を1コマンドで検証する
kevy-cli diffは、2つの稼働中サーバー間で任意個のプレフィックスを1回の呼び出しで比較します——プレフィックスごとのダイジェスト対よりこちらを使ってください。
kevy-cli diff 127.0.0.1:6004 127.0.0.1:6005 msg: mbox: usr: tag: session:大きなエクスポート
ダンプは非圧縮のRESPテキストです(高速で、grep可能)。10GB超のキー空間ではgzipをパイプしてください——フォーマットはストリームフレンドリーです。
kevy-cli export -p 6004 /dev/stdout | gzip > dump.kevy.gz
gunzip -c dump.kevy.gz | kevy-cli import -p 6005 --strict /dev/stdin(--resume は .progress サイドカーのために実ファイルが必要です——再開可能性が欲しければ、先にディスクへ展開してください。)
インデックスの作成はバルクロードの後に。インデックスエンジンのバックフィルは既存データからバルク速度で構築し(実測で100万行あたり約7秒)、書き込みフックの維持コストを100万回払うのに勝ります。スイッチではなく、操作の順序の問題です。
kevy-cli import -p 6004 dump.resp
kevy-cli -p 6004 IDX.CREATE users ON PREFIX user: FIELD age TYPE i64 KIND rangeゲート:bench/onrampgate.sh(100万行ラウンドトリップ、インポート200k cmd/s以上、kill -9からの再開収束、レート精度±20%)。