1分で読了エンジニアリング

リダイレクト遅延予算: p95を15ms未満にする設計

キャッシュヒット時p95 15msという目標の裏にある実際のエッジリダイレクト構成: LRU、共有キャッシュ、gRPCオリジン。

Marius Voß
DevRel · edge infra

リダイレクトは訪問者を待たせます。短縮リンクをクリックすると、ブラウザーは待機し、302が返ってくるまで他に何も読み込まれません。だからservices/edge-redirectには「十分速い」という感覚ではなく、遅延予算があります。キャッシュヒット時のp50は5ms、p95は15msです。

これは結果ではなく目標として読んでください。予算はサービスのREADMEと当番のランブックに書かれており、負荷テストの合格基準もこれに基づいています。リージョンごとのパーセンタイルを裏付けるコミット済みのベンチマークや負荷テストの結果ファイルはないので、この記事では数字を載せません。代わりに、コードから読み取れるホットパスの動作と、同じくらい役に立つ、やらないことを説明します。

要点

  • エッジは、低アロケーションのGo製HTTPライブラリで動くGoサービスで、現在は単一の本番ホスト上でリバースプロキシの背後に置かれており、マルチリージョンのエッジは計画中です。
  • 検索は、プロセス内のLRU(10万エントリ、TTL 60秒)、次に共有のインメモリキャッシュ(TTL 5分)、最後にタイムアウト1.5秒でgRPC経由のapi-coreの順にたどります。
  • クリックイベントは別のgoroutineから発行します。訪問者がそれを待つことはありません。
  • 予算: キャッシュヒット時のp50は5ms、p95は15ms。これらは目標であり、計測済みのパーセンタイルはここでは公開していません。

予算がある理由

リダイレクトにかかる時間は、その後のすべてに上乗せされます。キャンペーンのリンクがトラフィックをランディングページに送るなら、リダイレクトはそのページのTime to First Byteの一部です。予算があると、この経路に処理を足したくなるたびに、決断を迫られます。収まるか、ワーカーに移すかです。

services/edge-redirect/README.mdの数字は次のとおりです。

パーセンタイルキャッシュヒットキャッシュミス
p505ms40ms
p9515ms100ms
p9930ms200ms

これらは設計上の目標です。ベンダー審査などで計測値が必要なら、この記事の最後で説明するk6のシナリオを自分のリンクに対して実行し、出力を保管してください。

リクエスト経路を順に追う

internal/handler/redirect.goのファイル冒頭に、流れが書かれています。順に見ていきます。

  1. ホストを小文字にし、パスからスラッグを取り出す。
  2. resolver.ResolveがL1、L2、オリジンの順にたどる。
  3. 状態で早期終了する。停止中のワークスペース、期限切れのリンク、パスワード保護されたリンク(訪問者はロック解除ページに進み、クリックは送出されません)、url-scannerがblocked(451)としたリンク、管理者のブロックリストにあるリンク先ホスト。
  4. 待たずにクリックイベントを発火する。
  5. リンクが301や307を指定していなければ、302で応答する。

手順3のどれも、キャッシュされたリンクレコードのフィールドか、メモリ上の集合の検索です。たとえばブロックリストのマッチャーは、バックグラウンドでapi-coreから同期されるため、確認の費用はデータベース呼び出しではなく、マップの検索数回です。

リンクプレビュー(+で終わるスラッグ)、JSONを求めるAIエージェントのリクエスト、エンドツーエンド暗号化されたリンクは、リダイレクトの前に別の分岐を通ります。どれもPostgresには触れません。

ホットパスとコールドパス: 検索、状態チェック、リダイレクトの応答は訪問者に返す前に行われ、クリックの保存、スキャン、分析は後から別々のワーカーで実行される

3層の検索

各層は意図的に小さく、最後の1つを除いてどれもベストエフォートです。

L1、プロセス内のLRU。 internal/cache/lru.goがhashicorp/golang-lru/v2/expirableを包んでいます。サイズはL1Size: 100_000エントリ、TTLはL1TTL: 60sで、どちらもinternal/config/config.goで設定されています。TTLがあるのは、無効化または削除されたリンクが、1つのノードから永遠に配信され続けないようにするためです。キーはhost + "/" + slugなので、2つのドメインで同じスラッグがあっても衝突しません。

L2、共有キャッシュ。 internal/cacheは、リンクをホストとスラッグをキーにしたJSONとしてL2TTL: 5mで保存します。エッジインスタンスが共有します。nilのクライアントも許容されます。キャッシュのメソッドはミスを返し、エッジは動き続けます。

オリジン、api-coreへのgRPC。 internal/origin/origin.goは2つのトランスポートに対応しています。ほかに設定がなければ、REST(GET /v1/lookup)が既定です。GRPC_ORIGIN_ADDRを設定するとgRPC(elido.v1.LookupService.Resolve)が使われ、本番ではこちらです。呼び出しはOriginTimeoutの1.5秒で制限されます。

リゾルバーは、重要な部分をそのまま引用できる短さです。

if v, ok := r.l1.Get(key); ok {
    return v, nil
}
if v, ok := r.l2.Get(ctx, key); ok {
    r.l1.Set(key, v)
    return v, nil
}
link, err := r.origin.Resolve(ctx, host, slug)
if err != nil {
    return nil, err
}
r.l1.Set(key, link)
r.l2.Set(ctx, key, link)
return link, nil

L2でのヒットはL1を補充します。両方でミスするとapi-coreに行き、両方を補充します。「見つからない」と言えるのはオリジンだけです。そう返された場合、リゾルバーはワイルドカードのカスタムドメイン向けに、親のワイルドカードホスト(abc.example.comを*.example.comに)で一度だけ再試行します。

エッジリダイレクトの検索順序: 10万エントリでTTL 60秒のプロセス内LRU、次にTTL 5分の共有インメモリキャッシュ、次にタイムアウト1.5秒のgRPC経由api-core。ヒットはそれぞれ上の層を補充する

この設計にないもの

この記事の以前の版には、コードが実際には行わないことが書かれていました。明確にしておきます。

  • singleflightのグループはありません。同じコールドなスラッグへの同時ミスが2つあれば、どちらもオリジンに届きます。
  • エッジにpub/subの購読はありません。リンクが変更されると、api-coreがL2のキーを削除します(services/api-core/internal/linkmod/adapters.goのpurger)。L1にはプッシュされないので、編集したリンクがノードのメモリから最大60秒配信されることがあります。
  • L1はTTLで制限されたLRUで、アドミッション制御されたキャッシュではなく、サイズはバイト数ではなくエントリ数で決まります。
  • cache-statusエンドポイント、層ごとのレスポンスヘッダー、エッジが出力するレイテンシのヒストグラムはありません。

60秒の窓があるため、編集可能なリンクには一時的なリダイレクトを使うべきで、同じ問題のブラウザー側では301と302が重要になります。キャッシュ設計は、URLリダイレクトのキャッシュ戦略でさらに詳しく扱っています。

エッジのコードで示せるPrometheusメトリクスは、fleetとoutcomeのラベルを持つelido_edge_redirect_totalだけです。ランブックには他のメトリクス名(edge_cache_*、edge_origin_grpc_duration)が出てきますが、現在のコードに登録されているものは見つからなかったので、文書化していません。

ホットパスから外れているもの

クリックの保存。 click.Producer.Emitはイベントをシリアライズしてfranz-goのプロデューサーに渡し、ハンドラーはそれをgo h.clicks.Emit(...)として呼びます。produceのコールバックはエラーをログに記録するだけです。イベントバスが遅くても、訪問者は気づきません。代償として、クリックが失われることがあり、これは撃ちっぱなしの一般的なトレードオフです。クリックを分析ストアに書き込むのはclick-ingesterの仕事で、それがクリック分析APIにデータを供給します。

ボットのチャレンジ。 リダイレクトにJavaScriptのチャレンジはありません。ボット検知はユーザーエージェントの部分文字列のリストで、クリックを数えるかどうかを決めるため、クローラーやリンクプレビューの取得もリダイレクトは受け取りますが、分析を水増ししません。意図的に粗いフィルターです。

スキャン。 url-scannerは、リンクの作成時と定期的に、リンクごとの判定を発行します。エッジは保存された判定(clean、suspect、blocked)を読むだけで、リダイレクトを止めるのはblockedだけです。

ルールの評価。 スマートリンクはリンクレコードとともに運ばれるため、国やデバイスなどのルールはキャッシュされたコピーに対して実行され、GeoIPの検索はローカルのデータベースを使ってプロセス内で行われます。ルールのあるキャッシュヒットで、api-coreへのネットワーク呼び出しは不要です。

サーバーの設定は控えめです。cmd/server/main.goは、サーバーに読み取りと書き込みで2秒のタイムアウト、アイドルで30秒のタイムアウト、リクエストボディの上限64 KBを設定しています。サービスのREADMEにあるとおり、リクエストごとのアロケーションが少ないため、net/httpではなくこちらを選びました。当社のハンドラーでの直接比較のベンチマークは公開していないので、これは計測結果ではなく設計上の理由です。

自分で計測する方法

リポジトリには、k6を使った負荷テストのランブック(docs/runbooks/19-load-testing.md)があります。ホットパスの公開前の合格基準は、5分間1.5k req/sでp95が15ms、5xxがゼロであることと、1分間5k req/sでp95が30ms、5xxが0.1%未満であることです。これらはしきい値であり、結果ではありません。

近くの負荷生成器からのテストは、サービスを計測します。遠く離れたホストからのテストは、あなたのネットワークを計測します。訪問者側の遅延は、SentryとDatadogによるリダイレクトの監視のように、外部から合成モニターで測ってください。

正直に言って、15msを計測値として顧客に伝えるのは、まだしません。リージョンごとの表を載せる前に埋めたい隙間は、エッジのレイテンシのヒストグラム、コールドミスのsingleflight、L1のプッシュ無効化、そして15msの数字を裏付けるコミット済みのベンチマークです。

Marius Voßは、ElidoのDevRelおよびエッジインフラ担当です。出典: services/edge-redirect、services/api-core/internal/linkmod/adapters.go、docs/runbooks/19-load-testing.md。外部ベンダーの主張は行っていません。

ブログの関連記事

よくある質問

p95 15msは計測値ですか?

いいえ。キャッシュヒット時のp95 15ms(p50は5ms)は、edge-redirectサービスの遅延予算で、アラートと負荷テストの合格基準はこれに沿って書かれています。この記事では、リージョンごとの計測済みパーセンタイルは公開していません。引用できる、コミット済みのベンチマークや結果ファイルがないからです。

Elidoのリダイレクトはどのようにスラッグを解決しますか?

エッジはまずプロセス内のLRUを見て、次に共有のインメモリキャッシュ、最後にgRPCでapi-coreに問い合わせます。各キャッシュ層はベストエフォートなので、確定的な「見つからない」を返せるのはオリジンだけです。下の層でヒットしたものは、上の層にコピーされます。

リダイレクトの経路から外れているものは何ですか?

クリックの保存、分析、url-scannerのチェックです。エッジは別のgoroutineでクリックイベントをイベントバスに送出し、それを待たずに訪問者へ応答します。ボットのフィルタリングはユーザーエージェントの確認で、クリックを数えるかどうかを決めるものであり、訪問者が通過すべきチャレンジではありません。

Elidoが既定で302を使うのはなぜですか?

302は、ブラウザーにリンク先を恒久的なものとして扱わないよう伝えるため、編集したリンクは次の訪問で反映されます。リンクごとに301や307に設定することもできます。トレードオフは、301と302の記事で扱っています。

エッジPOPはどこにありますか?

現在の本番は、リバースプロキシの背後でエッジサービスを動かす単一のホストです。ヨーロッパ、北米、東南アジアにまたがるマルチリージョンのエッジ(それぞれが同じエッジのバイナリを動かす構成)は計画中で、まだ稼働していません。主要なデータベースとイベントバスはEUに置かれ、この設計では、ADR-0014が述べるとおり、リダイレクトのキャッシュデータだけがエッジノードの隣に置かれることになります。

編集したリンクが古いまま配信されることはありますか?

短時間なら、あります。リンクが変更されるとapi-coreは共有キャッシュのエントリを削除しますが、エッジノードのプロセス内のコピーは、60秒のTTLが切れるまで残ります。そのため、宛先の編集がすべてのノードに届くまで最大1分かかることがあり、編集可能なリンクに一時的なリダイレクトが向いている理由の1つです。

Elidoを試す

URLを貼り付けて短縮リンクを取得

登録不要。リンクは30日間有効。永久に保存するには登録してください。

登録不要。無料登録すると月 50 件のリンクを作成できます。

Elidoを試す

EUホスティングのURL短縮サービス。カスタムドメイン、詳細な分析、オープンAPI付き。無料プラン - クレジットカード不要。

タグ
url shortener performance
edge redirect latency
multi-region url shortener
redirect cache strategy
go redirect service
redirect latency budget

続きを読む