プロンプトと適用されるコンテキスト
PostgreSQLは商品データの信頼できる唯一の情報源(Source of Truth)です。Redisはproduct_idをキーとして表示用スナップショットを保存します。システムは約2,000のアクティブな商品全体で毎秒約20,000回の読み取りと500回の書き込みを処理します。商品名、画像、表示用コピーの古さは最大5秒まで許容されます。在庫の引き落とし、チェックアウト時の価格、権限、残高は、ビジネス上の正当性を決定するため、そのキャッシュ契約の対象外です。
Cache-Asideの読み取り、更新、および無効化パスを設計してください。以下のケースを網羅してください:
- データベースはコミットしたが、キャッシュエントリを削除する前にプロセスがクラッシュする。
- リーダーが古いデータベースバージョンを取得し、ライターがキャッシュを無効化した後にのみフィルの完了処理を行う。
- フィルが非同期データベースレプリカから遅延しているデータを読み取る。
- 無効化イベントが重複、順序の入れ替わり、または滞留する。
- Redisが一時的に利用不能になる。
- プロダクトチームが「最大5秒の古さ」を監視およびテストされた契約にすることを求めている。
スループット、アクティブキー数、および5秒のバジェットは面接用の前提条件であり、普遍的な設定ではありません。現在の2026年公開のバックエンドおよびシニア向けキャッシング面接資料では、Cache-Aside、書き込み後の無効化、およびキャッシュ整合性が明示的に扱われています。中心となるスキルは、データベースコミット後のバックエンドプロトコルと障害セマンティクスであるため、カテゴリはbackendです。これは、ホットキーの有効期限切れ時に発生するキャッシュスタンピードの防止とは異なります。スタンピード制御は並行するソース読み取りを制限するのに対し、この問題は古い値が書き込み後も存続し得るタイミング、存続する理由、および存続が許容される期間を問うものです。
面接官が評価するポイント
第一に、候補者がパターンを選択する前に整合性のターゲットを定義できるかです。「データベースとキャッシュは常に整合している」という表現は、どの読み取り結果が正当であるかを指定していません。優れた回答は、表示データの5秒の有界な古さ(Bounded Staleness)、更新を実行した呼び出し元に対するRead-Your-Writes、および在庫や権限に対する信頼できる読み取り(Authoritative Reads)を区別します。これらの契約には異なるパスが必要です。
第二に、書き込み順序を説明できるかです。一般的なCache-Asideの書き込みパスは、データベースをコミットしてからキャッシュエントリを削除します。先に削除すると明確な競合が発生します。2つの操作の間にリーダーがキャッシュミスを起こし、古いデータベース値を取得してキャッシュに戻してしまいます。データベース先行であっても、アトミックな二重書き込みにはなりません。コミットから削除までのわずかなウィンドウが存在し、削除に失敗するとTTLが切れるまで古いエントリが残る可能性があります。
第三に、遅延した古いデータのフィル(Late Stale Fill)のタイムラインを描けるかです。リーダーはデータベース更新の前にバージョン41を取得することがあります。その後、ライターがバージョン42をコミットしてキャッシュを削除し、その後に古いリーダーがバージョン41を書き戻します。キャッシュされた値にのみバージョンを追加するだけでは、必ずしも十分ではありません。削除後は比較対象となるキャッシュバージョンが存在しないため、「ソースバージョンがキャッシュバージョン以上の場合に書き込む」といったルールでは41が受け入れられてしまいます。この設計には、値の削除後も存続するバージョンフェンス、書き込みによって無効化されるフィルリース、またはフィル前の別の信頼できるバージョンチェックが必要です。
第四に、信頼性の高い無効化と有限の時間制限を区別できるかです。Transactional Outboxまたは変更データキャプチャ(CDC)は、データベースはコミットされたが無効化メッセージが永続的に記録されないというギャップを埋めます。At-least-once(少なくとも1回)の配信と冪等な削除により重複は許容されます。リトライは結果的な処理を確立しますが、5秒以内の完了を保証するものではありません。有界な古さには、ハードTTL、無効化ラグゲート、またはパイプラインがバジェットを超過した際にキャッシュをバイパスするルールが追加で必要です。
第五に、信頼できる情報源、データベースレプリカ、および運用上の検証を処理できるかです。データベースコミットの成功によって新しい事実が生成されます。遅延しているレプリカからフィルを行うと、正しい無効化の後に古い値が再導入される可能性があります。優れた回答は、フィルのソースを制限し、イベントとキャッシュのバージョンを監視し、キャッシュヒット率のみを報告するのではなく、制御された並行性タイムラインと障害をテストします。
回答前に明確にすべき質問
- 整合性契約はどのようなものか? 最大5秒の古さが許容される場合、非同期の無効化とハードデッドラインの組み合わせが機能します。Read-Your-Writesが必要な場合、ライターからの後続リクエストは一時的にキャッシュをバイパスするか、最小バージョンを保持する必要があります。古い読み取りが一切許容されない場合は、信頼できるストアから読み取るか、必要な整合性契約を持つストレージパスを使用します。
- どのフィールドが不可逆なアクションを承認するか? 表示名や画像は古くても構いません。在庫の検証、割引の適用可否、権限、残高、請求金額は、キャッシュされたスナップショットを信頼できる情報として扱ってはなりません。これらのフィールドが1つのオブジェクトを共有している場合、読み取り契約を分割するか、クリティカルなアクションの実行中に信頼できる状態を再読み取りします。
- 5秒のカウントダウンはいつ始まるか? このプロンプトでは、データベースのコミット時点から始まります。値がRedisに入った時点から始まる場合、すでに4秒遅延しているレプリカがさらに5秒間キャッシュされ、実際のデータの古さは約9秒になる可能性があります。
- 1つのサービスがすべての書き込みパスを制御しているか? バッチジョブ、管理ツール、および他のサービスがAPIレベルの
DELをバイパスする可能性があります。同じデータベーストランザクション内に無効化レコードを配置するか、データベースログからサポート対象のすべての変更をキャプチャします。 - ミス時のフィルはプライマリから行うか、レプリカから行うか? レプリカラグの境界と、セッションレベルのRead-Your-Writesが存在するかどうかを確定します。レプリカがバジェット内で最新であることを証明できない場合、無効化後の最初のフィルはプライマリを使用するか、呼び出し元の最小バージョンと同等以上に新しい結果を要求する必要があります。
- データベースはどれくらいのフィルトラフィックを吸収できるか? 5秒のTTLでは、均等に期限切れとなる2,000のアクティブキーがすべて読み取られると、平均して毎秒約
2000 / 5 = 400のフィルが発生します。アクセスの偏り、ジッター、リクエストの結合(Coalescing)によって実際の値は変化します。データベースにそのバジェットがない場合、TTLを短縮しても魔法のように整合性が満たされるわけではありません。 - Redisが利用できない場合、鮮度と可用性のどちらが重要か? 表示データは明示的な古い値の許容範囲内で縮退(Degrade)できます。信頼性が必要なフィールドは保護されたソースパスを使用しなければなりません。データベースがすべてのミスを吸収できない場合は、制限のないフォールバックではなく、レート制限、バルクヘッド、明示的なエラーを適用します。
30秒の回答フレームワーク
「私はまずデータタイプごとに契約を定義します。商品表示データはデータベースのコミットから最大5秒間古くなることが許容されますが、在庫や権限は常に信頼できるパスを使用します。読み取りはRedisをチェックし、同一キーのミスを結合し、鮮度要件を満たすソースからバージョン付きの行を取得して、条件付きでキャッシュをフィルします。書き込みは1つのPostgreSQLトランザクション内で行バージョンを含むOutboxレコードの挿入とビジネス行の更新を行います。コミット後、高速なキャッシュ削除を試み、OutboxまたはCDCパスがリトライ可能な修復を提供します。
私はキャッシュを削除する前にデータベースをコミットします。その削除後に古い読み取りがフィルされるのを防ぐため、ミス時にフィル世代(Generation)をキャプチャします。無効化はバージョンフェンスを進めて値を削除し、世代が変更されておらず、かつソースバージョンがフェンスを満たしている場合にのみフィルが成功します。結果的なリトライは5秒の制限を保証しないため、キャッシュにはバジェット内のハードTTLを設定し、無効化ラグが制限に近づいた場合は読み取りがキャッシュをバイパスします。レプリカラグが契約を満たせない場合、無効化後のフィルはプライマリを使用します。コミット直後のクラッシュ、遅延フィル、重複および順序が入れ替わったイベント、レプリカラグの障害テストによって設計を検証し、古いデータの経過時間、単調増加するバージョン、データベース負荷をアサートします。」
ステップバイステップの詳細解説
ステップ1:整合性を読み取り結果の契約に変換する
キャッシュパターンを選択する前に、ビジネス上の影響度によってデータを分割します:
| パス | 正当な結果 | 推奨される読み取りパス |
|---|---|---|
| 商品表示 | データベースコミットから最大5秒の古さ | Redis Cache-Aside、ハードTTL、無効化ラグゲート |
| ライターの後続の読み取り | その呼び出し元がコミットした直後のバージョン以上 | min_versionを保持。キャッシュが古い場合はプライマリを使用 |
| 在庫、権限、残高、チェックアウト | 決定には現在の信頼できる状態を使用する必要がある | 表示キャッシュをバイパスし、トランザクション内または信頼できるサービスで検証 |
この分類が設計の残りの部分を決定します。キャッシュは再構築可能なコピーを高速化するものです。遅延、エビクション、または消失の可能性がある値を使用して在庫の引き落としを承認してはなりません。99.9%のヒット率は、残りの0.1%が過剰販売や不正アクセスを引き起こすかどうかについて何も語っていません。
各キャッシュエントリには、少なくともソースバージョンとタイミング情報が必要です。以下は疑似構造体であり、特定の言語の実行可能な型ではありません:
ProductCacheEntry {
value
source_version
source_committed_at
cached_at
}source_committed_atは実際の古いデータの経過時間を測定します。cached_atはRedisが値を受信した時刻を示すだけです。ソースバージョンには、単調増加する行バージョン、コミットシーケンス、または順序が定義された別のドメインバージョンを使用できます。値が同値になる可能性やクロックドリフトの可能性がある場合、ウォールクロックタイムスタンプだけでは不十分な順序証明になります。
ステップ2:基本的なCache-Asideパスを確立する
読み取りパスは、呼び出し元の最小バージョンと古さのバジェットの両方を満たす場合にのみキャッシュヒットを返します。ミスが発生した場合、同一のproduct_idに対するフィルを結合(Coalesce)し、20,000件の読み取りが一度にデータベースに到達しないようにします。バージョン付きの行を取得し、条件付きのRedis書き込みを試みます。以下はフローの疑似コードです:
read(product_id, min_version = none):
entry = cache.get(product_id)
if entry satisfies age_budget and min_version:
return entry.value
return singleflight(product_id):
recheck cache
row = read_authoritative_version(product_id)
conditional_fill(product_id, row)
return row.value書き込みパスは、同じPostgreSQLトランザクション内でビジネス行を更新し、Outboxレコードを挿入します。成功したCOMMITのみが、ビジネスの変更を他のトランザクションから可視にし、永続化します。その後、アプリケーションは高速な無効化を試みます。独立したリレーまたはCDCコンシューマーが、リトライと修復のために永続化された無効化イベントを処理します。書き込みパスの疑似コードは以下のとおりです:
transaction:
row = update product and increment source_version
insert outbox(product_id, source_version, committed_at)
commit
best_effort_invalidate(product_id, source_version)
return committed source_version直接の無効化により、通常のケースにおけるウィンドウが短縮されます。Outboxは、コミット後にプロセスがクラッシュした際のメッセージ消失のギャップを埋めます。両方のパスが同じ変更を処理する場合、削除は冪等でなければなりません。同じソースバージョンを再度受信しても、新たなビジネス上の副作用が発生してはなりません。
ステップ3:3つの競合ウィンドウを明示的に分析する
ウィンドウ1:データベースコミットとキャッシュ削除の間。
W: COMMIT version 42
R: read cached version 41
W: DEL cache keyリーダーには一時的にバージョン41が見えますが、これは有界な古さのもとでは正当です。古い結果が一切許容されない場合、書き込みレスポンスを返す前にキャッシュ操作を待機したとしても、すべてのネットワークの曖昧性が解決されるわけではありません。より明確な契約は、クリティカルな読み取りを信頼できる情報源に送信するか、呼び出し元にmin_version=42の要求を許可することです。
ウィンドウ2:データベースはコミットされたが無効化が実行されない。
W: COMMIT version 42 plus outbox record
W: process crashes before DEL
R: cache still contains version 41
relay: retries invalidation for version 42COMMITの後に作成されたインメモリタスクは失われる可能性があります。Transactional Outboxは、ビジネスの変更と無効化の義務を一緒にコミットします。リレーは再配信を行う可能性があり、コンシューマーはキーによって削除し、処理されたバージョンを記録します。リレーが停止した場合、ハードTTLまたは鮮度ゲートが5秒の境界を強制します。
ウィンドウ3:古い読み取りが無効化後にフィルを行う。
R: cache miss; captures generation 7
R: reads database version 41
W: commits version 42
W: advances generation to 8 and deletes cache value
R: tries to fill version 41 with generation 7; rejectedこれは見落とされがちな競合です。設計が値のみを削除する場合、version 41 >= no versionが通過し、古いデータが復活してしまいます。1つの解決策は、値とそのフェンスを分離することです。無効化により、存続期間の短い世代(Generation)または最小ソースバージョンキーが進められます。Redisスクリプトは、値を書き込む前に、フィルの世代がミス時にキャプチャされた世代と一致していること、およびソースバージョンが最小値を満たしていることをアトミックにチェックします。Redis Clusterでは、スクリプトが両方にアトミックにアクセスできるように、値キーとフェンスキーが同じハッシュスロットを使用する必要があります。別の実装では、データベースの更新によって無効化されるミスリース(Miss Lease)を発行します。フィルする前にプライマリのバージョンを再読み取りすることも役立ちますが、データベースの読み取りが増加し、チェックとキャッシュ書き込みの間に定義されたアトミックな境界が依然として必要です。
バージョンフェンスは、最大フィル時間、リトライ、プロセスやネットワークの一時停止をカバーするのに十分な期間保持してください。早すぎる削除は、遅延フィルのウィンドウを再び開くことになります。フェンスはキャッシュ書き込みの順序を保護するものであり、データベースの並行性制御を置き換えたり、通常のリーダーに対して無効化がまだ届いていない新しいバージョンがデータベースに含まれていることを伝えたりするものではありません。
ステップ4:配信を過大評価せずに無効化を回復可能にする
Outbox行とビジネス更新は1つのトランザクションで書き込まれます。リレーは無効化を永続チャネルに送信します。コンシューマーは各product_idに対して観測された最大のバージョンを追跡し、以下のルールを適用できます:
- 観測された最大バージョンを下回る順序の狂ったイベントは、冪等に確認応答(ACK)されます。
- 新しいバージョンは最小バージョンフェンスを進め、キャッシュされた値を削除します。
- 失敗したキャッシュコマンドはリトライされます。リトライ上限に達した作業は可視化された隔離キューに入ります。
- 照合ジョブ(Reconciliation Job)が、データベースのバージョン、Outboxの進行状況、およびサンプリングされたキャッシュバージョンを比較します。
重複したDELには追加のビジネス上の意味がないため、At-least-once配信は削除とうまく機能します。リトライによってExactly-once(正確に1回)の実行が作成されると主張してはなりません。コンシューマーはRedisで削除を実行し、メッセージを確認応答する前にクラッシュして、再度削除を実行する可能性があります。安全な反復処理と検出可能なギャップこそが有用な特性です。
ブローカーのキュー滞留時間だけでなく、データベースコミットから無効化成功までのインターバルを監視します。有用なメトリクスには、invalidation_lag_seconds、無効化の失敗とリトライ、最も古い隔離データの経過時間、キャッシュからソースへのバージョン遅延、バジェット超過によるキャッシュバイパス、データベースフィルのQPS、およびSingleflightを通じて共有された同一キーの呼び出し数が含まれます。
ステップ5:TTLとゲートで5秒の制限を証明する
信頼性の高いイベントは最終的に到着しますが、「最終的(Eventually)」には時間単位がありません。コミットから最大5秒の古さという契約には、独立した有限時間の防御策が必要です:
- クロック、スケジューリング、検出のためのマージンを確保した上で、物理キャッシュのTTLを5秒未満に設定し、信頼できるバージョンのコミット時刻から古いデータの経過時間を計算します。
- あるいは、無効化ラグがバジェットに近づいた場合、グローバルまたは影響を受けるパーティション単位でキャッシュの信頼を停止し、鮮度要件を満たすソースを読み取ります。
- どちらも不可能な場合、その契約は結果整合性(Eventual Consistency)であり、5秒の制限を主張し続けてはなりません。
TTLは境界のバックストップであり、主要な無効化メカニズムではありません。2,000個のキーが一度に失効しないようにジッターを追加し、キーごとにミスを結合します。2,000個のアクティブキーすべてが5秒ごとに少なくとも1回読み取られる場合、均等に分散された有効期限によって平均して毎秒約400回のフィルが発生します。この見積もりは容量の保証ではありません。人気の偏り、バッチ失効、Redisの障害、低速クエリによってスパイクが発生する可能性があるため、安全なデータベースQPSと並行性バルクヘッドに対してテストを行ってください。
5秒のTTLがデータベースのキャパシティを超える場合、誠実な選択肢は3つあります。安全なフィルキャパシティを追加するか、この契約を必要とするアクティブデータを減らすか、または古さのバジェットを緩和することです。黙ってTTLを延ばすことはプロンプトに違反します。
ステップ6:レプリカラグとRead-Your-Writesを処理する
正しい削除の後であっても、遅延しているレプリカが古いバージョンを再フィルしてしまう可能性があります。厳格な5秒契約を持つミスの場合、以下の優先順位を使用します:
- 無効化後の最初のフィルにはプライマリを読み取ります。
- 観測可能なリプレイ位置が必要なコミット位置に達していることが証明されている場合にのみ、レプリカを使用します。
- 書き込みから
source_versionを返し、後続の読み取りにmin_versionを保持させます。キャッシュまたはレプリカが遅延している場合はプライマリにルーティングします。 - レプリカまたは無効化の遅延がバジェットを超えた場合に鮮度サーキットブレーカーを開き、条件を満たさないキャッシュ値の返却を停止します。
すでに4秒遅延しているレプリカからフィルが行われた場合、5秒のTTLは5秒のデータ経過時間を証明しません。古いデータの経過時間は信頼できる情報源のコミットから始まるため、レプリカ、イベントチャネル、キャッシュのすべての遅延がカウントされます。
ステップ7:代替案とその境界を比較する
同期的なキャッシュ更新。 データベースの直後にRedisに書き込むとRead-Your-Writesが向上する可能性がありますが、2つの独立したシステムには依然として部分障害が存在します。キャッシュの更新が失敗する一方でデータベースがコミットされる可能性があり、並行するデータベース書き込みが異なる順序でキャッシュに到達する可能性もあります。このパスには依然としてソースバージョンの条件と修復が必要です。これをWrite-Throughと呼んでも、2つの書き込みがアトミックなトランザクションになるわけではありません。
先に削除し、その後にデータベースを更新。 これはシンプルですが、データベースコミットの前にリーダーが古いデータを再フィルすることを許してしまいます。遅延させた2回目の削除は特定のタイミングの確率を低減しますが、固定の遅延では無制限のレプリカラグ、プロセスの中断、ネットワーク障害をカバーできません。プロセスが2回目の削除の前にクラッシュする可能性もあります。これは補助的な手段にはなり得ますが、それ単体で有界な古さを証明するものではありません。
短いTTLのみを使用。 書き込みが稀で、古さのバジェットが寛容であり、データベースがフィルを吸収できる場合には、これが最もシンプルで適切な選択肢になり得ます。トレードオフとして、すべての書き込みの後にTTL全体の期間にわたって古い読み取りが発生する可能性があり、同期した有効期限切れがデータベース負荷を増幅させる可能性があります。
すべてをデータベースから読み取る。 低スループットまたは正確性が極めて重要なデータの場合、これが最も明確な解決策になることがよくあります。キャッシュはオプションです。整合性の調整コストが、それが節約するデータベース読み取りコストを上回る場合、キャッシュを排除することは合理的です。
ステップ8:不変条件と障害パスをテストする
更新後に手動でページをリフレッシュする以上のテストを行ってください。バリアを使用して、遅延した古いデータのフィルを再現します。リーダーがバージョン41を取得した後に一時停止させます。ライターにバージョン42をコミットさせ、フェンスを進め、値を削除させます。古いリーダーを再開し、その条件付きフィルが失敗することをアサートします。その後、以下のケースをテストします:
- PostgreSQLのコミット直後にライターを終了させ、Outboxによって最終的に無効化されることを検証する。
- 1つの無効化イベントを複製して順序を入れ替え、最大バージョンが決して逆行しないことを検証する。
- Redisの削除を失敗させ、リトライ、隔離、およびTTLバックストップを検証する。
- レプリカのリプレイを一時停止し、フィルがプライマリを使用するか、不適格な結果を拒否することを検証する。
- 無効化の消費をゲートを超えて遅延させ、読み取りがキャッシュをバイパスすることを検証する。
- 2,000個のキーを同時に期限切れ近くにし、ジッター、Singleflight、およびデータベースバルクヘッドを検証する。
- Redisを利用不能にし、信頼できる決定が必要なパスを維持しながら、表示読み取りのバジェット内での縮退を検証する。
コアとなる不変条件は次のとおりです。返されるキャッシュバージョンが呼び出し元のmin_versionを下回らないこと、無効化されたフィル世代が書き込みを行えないこと、信頼性が必要なビジネスアクションが表示キャッシュを決して信頼しないこと、古いデータの経過時間が5秒を超えないこと、そしてデータベースフィルがテストされた安全なQPSと並行性の範囲内に留まることです。
質の高い模範解答
「私はPostgreSQLとRedisがあらゆる瞬間に一致していると約束することから始めるのではなく、読み取り契約を分割します。商品名と画像はPostgreSQLのコミットから最大5秒間古くなることが許容されます。在庫の引き落とし、権限、残高、チェックアウトの決定は、引き続き信頼できる状態を使用します。ライターがRead-Your-Writesを必要とする場合、書き込みAPIはソースバージョンを返します。後続の読み取りはその最小バージョンを提供し、Redisが古い場合はプライマリを使用します。
基本パターンはCache-Asideです。読み取りは、経過時間とバージョンが条件を満たす場合にのみヒットを返します。ミスが発生した場合はproduct_idによるSingleflightを使用し、単調増加するバージョン付きの行を取得して条件付きフィルを実行します。1つのPostgreSQLトランザクションで商品を更新し、そのバージョンをインクリメントし、Outbox行を挿入します。コミット後、アプリケーションは直ちにRedisの削除を試みます。リレーまたはCDCコンシューマーが永続化されたイベントからリトライするため、コミット後のクラッシュによって無効化の義務が恒久的に失われることはありません。
順序はデータベース先行、キャッシュ削除が後です。先に削除すると、データベースがコミットする前にリーダーが古いデータを復元してしまいます。正しい順序であっても、遅延フィルの問題が残ります。リーダーがバージョン41を取得し、ライターが42をコミットして削除し、その後に古いリーダーが41を設定するケースです。削除後は42が残っていないため、キャッシュ値の内部にあるバージョンだけでは不十分です。私はミス時に世代(Generation)をキャプチャさせます。無効化は値を削除する前に世代と最小バージョンを進めます。アトミックなスクリプトにより、世代が変更されておらず、ソースバージョンがフェンスを満たしていない限り、フィルを拒否します。レプリカが必要なコミットをリプレイしたことを証明できない場合、無効化後のフィルはプライマリを読み取ります。
Outboxは回復可能な結果的無効化を証明しますが、5秒のデッドラインを証明するものではありません。私は運用のマージンを含めてバジェット内にハードTTLを設定し、コミットから無効化までの遅延を測定します。その遅延がバジェットに近づくと、読み取りはRedisをバイパスします。2,000個のアクティブキーすべてが5秒ごとにアクセスされる場合、均等なフィルは平均して毎秒約400回になるため、TTLジッター、キーごとのリクエスト結合、およびデータベース並行性バルクヘッドも使用します。
検証のために、スレッドの順序を制御し、コミット直後のクラッシュ、遅延リーダー、重複および順序の狂ったイベント、Redis削除の失敗、レプリカラグの障害を注入します。合格基準は、古い世代がフィルできないこと、ソースバージョンが逆行しないこと、古いデータの経過時間が5秒以内に収まること、信頼性が必要なアクションが表示キャッシュをバイパスすること、そしてデータベース負荷が測定されたバジェット内に留まることです。」
よくある間違い
- 「データベースとキャッシュ間の強力な整合性」を主張する → 回答が正当な読み取り、障害デッドライン、または2つのシステムにまたがるトランザクション境界を定義していない → 有界な古さ、Read-Your-Writes、信頼できる読み取りについて別々の契約を述べる。
- データベースを更新する前にキャッシュを削除する → それらの操作の間のミスによって古いデータベース値が読み取られ、復元されてしまう → まずデータベースをコミットし、その後に無効化し、永続的な修復を行う。
- コミット後にインメモリメッセージのみを発行する → 発行前のクラッシュによって無効化が恒久的に失われる → 同じトランザクション内にOutboxを書き込むか、データベースログの変更をキャプチャする。
DELですべての競合が終わると想定する → より古い読み取りが削除の後にセット処理を完了する可能性がある → フィルリース、または値の削除後も存続するバージョンフェンスを使用して遅延した古い値を拒否する。- キャッシュ値の内部にのみバージョンを配置する → 空のキャッシュには比較対象となる新しいバージョンが存在せず、古い値を受け入れてしまう可能性がある → 最小バージョンまたは世代を別のフェンスに保持し、フィルをアトミックにチェックする。
- 重複した消費をエラーとして扱う → 確認応答前のコンシューマークラッシュによって必然的に再配信が発生する → At-least-once配信のもとで、削除と最大バージョンの更新を冪等にする。
- 5秒を保証するためにOutboxを使用する → リトライ可能性は最終的な処理を証明するものであり、有限の遅延を証明するものではない → ハードTTLまたはバジェット超過バイパスゲートを追加し、エンドツーエンドのコミットから無効化までの遅延を測定する。
- 任意のリードレプリカからフィルする → レプリカラグによって、正しい無効化の後に古いデータが再投入される可能性がある → リプレイ位置をチェックするか、プライマリを使用するか、最小ソースバージョンを要求する。
- 1つの表示スナップショットにすべてのフィールドをキャッシュする → 表示データの古さの許容範囲が在庫、権限、チェックアウトに漏洩する → データ契約を分割し、不可逆なアクションについては信頼できる状態を再読み取りする。
- Redis障害時にすべてのトラフィックをデータベースに送信する → 毎秒20,000回の読み取りが信頼できる情報源をパンクさせる可能性がある → バルクヘッド、レート制限、明示的な縮退、および制御されたリカバリによって保護する。
- 固定の遅延ダブルデリートを使用する → スリープ処理では無制限のラグをカバーできず、2回目の削除も失われる可能性がある → 永続的な無効化、フェンス、有限時間のバックストップを維持しつつ、確率的な最適化として扱う。
フォローアップの質問と回答
フォローアップ1:商品の価格にもRead-Your-Writesが必要な場合はどうすればよいですか?
書き込みAPIからコミットされたsource_versionを返します。そのセッションにおける後続の読み取りはmin_versionを送信します。Redisがそれより古い場合はプライマリを読み取り、新しいバージョンのみを条件付きでキャッシュします。ライターが単に結果を表示するだけでよい場合は、コミットされた行を直接返し、一時的にキャッシュをバイパスします。チェックアウトでは依然として信頼できるトランザクション内で価格を再検証する必要があります。Read-Your-Writesは支払いの承認ではありません。
フォローアップ2:なぜバージョンフェンスをキャッシュ値の中だけに持たせることができないのですか?
無効化によってその値が削除されるため、通常の比較ではバージョン42を参照できなくなります。以前にバージョン41を読み取ったリクエストは、空のキーに対して41を有効な値として扱ってしまう可能性があります。独立したフェンスまたはミスリースは値の削除後も存続し、バージョン42が存在すること、または古いフィルの権限が取り消されたことのいずれかを記録します。
フォローアップ3:Transactional Outboxが重複を送信することはありますか?
はい。ダウンストリームシステムがイベントを受け入れた後、Outbox行に完了マークを付ける前にリレーがクラッシュする可能性があります。コンシューマーは(product_id, source_version)を冪等に処理します。古いバージョンは状態を進めず、新しいバージョンは最大値を進めて値を削除し、重複した削除は安全です。有用な保証とは、Exactly-onceの実行ではなく、無効化のサイレントロストがないことと照合機能です。
フォローアップ4:CDCが無効化を処理する場合、TTLを30分に設定できますか?
ビジネスが結果整合性のみを要求し、長時間の無効化障害を許容する場合は、有効なトレードオフになり得ます。このプロンプトでは最大5秒の古さを約束しています。無効化ラグが5秒に近づいたときにシステムが自動的にキャッシュをバイパスしない限り、CDCが停止した際に30分のTTLはその制限に違反します。そうでない場合は、バジェット内にハードデッドラインを維持してください。
フォローアップ5:レプリカラグは通常数十ミリ秒程度です。なぜプライマリを使用するのですか?
「通常」は上限ではありません。デプロイ、ネットワーク分断、ロングトランザクション、およびリカバリによってラグが増加する可能性があります。レプリカのリプレイ位置に必要なコミットが含まれていることが証明されている場合、または読み取りが最小バージョンを保持している場合、レプリカは引き続き使用可能です。それが証明できない場合、クリティカルなミスはプライマリにルーティングします。この選択は5秒の契約とデータベースのキャパシティバジェットに従います。
フォローアップ6:1つの分散ロックを保持しながらPostgreSQLとRedisを更新しないのはなぜですか?
ロックはそのプロトコルに従う参加者のみを制約します。2つのシステムをアトミックにコミットさせることはできず、ロック保持者のクラッシュ、ロックの失効、ネットワーク分断、外部のデータベースライターを排除することもできません。データベーストランザクションが事実を確立し、永続的な無効化が2番目のシステムを修復します。ロックは同一キーの並行性を低減させるかもしれませんが、整合性の証明にはなりません。
フォローアップ7:Redisが完全に利用できない場合、どのようにして5秒の制限を維持しますか?
表示の読み取りはRedisをバイパスしますが、ソースの読み取りはすべてデータベースのバルクヘッドとレート制限を通過します。キャパシティが不十分な場合は、明示的な縮退結果またはエラーを返します。プロセスローカルの古いコピーは、5秒のバジェット内にある間は一時的に提供される場合がありますが、それを過ぎると不適格になります。コールドキャッシュが再びデータベースをパンクさせないよう、無効化を排出しながらレート制限付きのウォーミングを行ってリカバリします。
フォローアップ8:本番環境に長期間存続する古い値が存在しないことをどのように証明しますか?
サンプリングされたキーについて、データベースのソースバージョンとキャッシュされたバージョンを一緒に読み取り、ソースのコミットからのバージョン差と経過時間の両方を記録します。データベースの変更、Outboxの進行状況、コンシューマーのHigh-Water Markを照合します。エンドツーエンドの無効化ラグ、最も古い隔離作業、およびバジェット超過によるバイパスについてアラートを発報します。コミット直後のクラッシュ、Redisコマンドの失敗、レプリカの一時停止などの障害を定期的に注入し、実際の障害下でもゲートとTTLが不変条件を保護し続けていることを実証します。