Elido
4分で読了エンジニアリング
コア記事

FRA、ASH、SGP のリダイレクトで p95 < 15ms を達成する

Elido のエッジリダイレクトパスが3つのリージョンでキャッシュ HIT 時の15ms p95 バジェットを維持する方法 - アーキテクチャ、キャッシュ戦略、実際のリージョン計測値

Marius Voß
DevRel · edge infra
World map showing Elido edge POPs in Frankfurt, Ashburn, and Singapore with p95 latency annotations of 12ms, 13ms, and 14ms respectively

リダイレクトは同期的なブロックです。ユーザーがショートリンクをクリックすると、ブラウザは停止し、302 が届いて次のページロードが始まるまで何も起きません。リダイレクトは後回しにできるバックグラウンドタスクではありません。ここで追加するミリ秒は、実際に重要なページから差し引かれるミリ秒です。

だからこそ、services/edge-redirect の最初の行を書く前にハードなバジェットを設定しました:キャッシュヒット時の POP での計測値、TLS フルハンドシェイクを除いた p50 5ms、p95 15ms。これは希望ではありません。何かがラインを超えたら、削除するか非同期パスに移動します。

フランクフルト(FRA)、Ashburn(ASH)、シンガポール(SGP)の3つのプロダクションリージョンを数ヶ月間稼働させています。この投稿は、ホットパスがどのように機能するか、数値がなぜそのような形になっているか、最初に何を間違えたかの完全な説明です。

TL;DR#

  • ホットパスは Hetzner FRA/ASH と OVH SGP の Go + fasthttp で、anycast ルーティングを使用した Caddy の背後にあります。リダイレクトパスに同期的なボットスコアリングも JS チャレンジもありません。
  • 2層キャッシュ:インプロセスの ristretto LRU(L1、約88%ヒット率)と Redis Cluster(L1+L2 合計約99.4%)。コールドミスのみで origin gRPC が api-core に(リクエストの約0.6%)。
  • 90日間の p95(リージョン別):FRA 12.1ms、ASH 13.4ms、SGP 14.2ms。コールドミスは p95 で約22ms 追加され、それでもバジェット内。
  • リンク変更時のキャッシュ無効化は Redis の pub/sub で、p99 1秒未満の伝播。L1 TTL は安全網として60秒。

なぜ15ms の上限なのか#

アーキテクチャに入る前に:なぜ15ms であって50ms や5ms ではないのか?

5ms の下限は単純です - それはヨーロッパの訪問者がフランクフルト POP にヒットする際の中央値の物理ネットワーク転送コストのおよその値です。物理法則には逆らえません。50ms の上限は緩すぎます - p95 が50ms では、トラフィックのかなりの割合のすべてのページビューの前に目立つ停止を追加することになります。ウェブパフォーマンスの研究では、50ms 未満のネットワーク遅延がモバイルデバイスで知覚可能になり始めることが一貫して示されており、無線レイテンシが処理時間と複合するポイントは Apple のネットワーク対応プログラミングガイドラインで明示されています。

15ms の数値は、いくつかの具体的な制約から決まりました。まず、リダイレクトは複合します。マーケティングキャンペーンが短縮されたリンクを通じてトラフィックを送り、それが製品ページにリダイレクトする場合、リダイレクトのレイテンシがランディングページの TTFB に加算されます。Google の Core Web Vitals は LCP を主要なシグナルとして使用しており、p95 で50ms を追加するリダイレクトチェーンは測定可能です。次に、スマートリンクのルール評価をホットパスでインラインで実行するのに十分なバジェットマージンが必要です - ルーティングのディメンション(国、デバイス、OS、言語、時間、リファラー)は、プレーンなリダイレクトと同じレイテンシエンベロープ内で実行される必要があります。そうでなければ、エッジからスマートリンクのサポートを取り除かなければなりません。約0.3ms のルール評価コストで15ms あれば、余裕があります。

15ms バジェットはキャッシュヒットトラフィックに適用されます。コールドミスは遅くて構いません - origin gRPC 呼び出しがレイテンシを追加します - しかしコールドミスは設計上まれであり、p95 を有意に動かすことはありません。

アーキテクチャ#

3つの POP、それぞれが同じバイナリ:services/edge-redirectfasthttp を使用した Go で書かれています。fasthttp のサーバースループットはベンチマークスイートで net/http の約8倍であり、より実際的には、ゼロアロックのリクエストパスが持続的な負荷下で GC ポーズを予測可能に保ちます。標準ライブラリの net/http はほとんどのサービスには問題ありません。しかし高い並行性でサブミリ秒の処理時間を維持する必要があるリダイレクトハンドラーでは、リクエストごとのヒープアロケーションを避けることはエルゴノミクスが低い API の価値があります。

Caddy は TLS ターミネーターとリバースプロキシとして前面に置かれています。テナントのカスタムドメイン向けのオンデマンド TLS(カスタムドメイン機能ページで詳しく説明)は最初のリクエスト時に証明書をプロビジョニングします。HAProxy と nginx を代替として評価しました - どちらも高速で、どちらも成熟した anycast デプロイメントパターンを持っていますが、Caddy の on-demand TLS は任意の数の顧客ドメインに対してゼロタッチの証明書ライフサイクルへの最もクリーンなパスであり、それはプロキシ層でさらに数分の1ミリ秒を絞り出すよりも重要です。

anycast ルーティングは、訪問者が f.elido.mes.elido.me、または b.elido.me にヒットすると、DNS が共有 anycast プレフィックスに解決し、ネットワークが TCP 接続を最寄りの POP にルーティングすることを意味します。アプリケーション層のジオルーティングロジックはありません:ネットワークが POP の選択を行います。Cloudflare の anycast プライマーはこれがなぜ重要かの最も明確な公開説明です - 重要な特性はフェイルオーバーが DNS TTL の期限切れではなく BGP 層で処理されることです。FRA が接続を失うと、ASH は DNS TTL の待機なしに数秒以内にヨーロッパのトラフィックに対して最短パスになります。Hetzner のクラウドネットワークインフラドキュメントでは FRA と ASH リージョンの基礎となるルーティングセットアップを説明しています。

重要なのは:ホットパスに同期的なボットスコアリングがないことです。10ms かかるボットスコアリングチェックは、それだけで p95 バジェットを破壊します。すべてのトラフィック品質シグナル - アノニマイザー検出、ホスティング ASN スコアリング、クリック重複排除 - は url-scannerclick-ingester でコールドパスの非同期ワーカーとして実行されます。リダイレクトが発火してクリックが Redpanda キューに乗ります。品質の判定はその後に行われます。

2層キャッシュ#

キャッシュがバジェットの居場所です。ロジック:

// 簡略化されたキャッシュルックアップ:L1 → L2 → origin、singleflight 重複排除付き
func (h *RedirectHandler) resolve(ctx *fasthttp.RequestCtx, slug string) (*Link, error) {
    // L1: インプロセス ristretto LRU - ヒット時にサブマイクロ秒
    if link, ok := h.l1.Get(slug); ok {
        return link.(*Link), nil
    }

    // L2 + origin は同じスラッグへの並行コールドミスにサンダリングハードを防ぐ
    // singleflight グループを共有する
    val, err, _ := h.sf.Do(slug, func() (interface{}, error) {
        // L2: Redis Cluster - シングル RTT、POP 内で通常 0.3〜0.8ms
        if data, err := h.redis.Get(ctx, cacheKey(slug)).Bytes(); err == nil {
            link, err := unmarshalLink(data)
            if err == nil {
                h.l1.Set(slug, link, linkCost(link))
                return link, nil
            }
        }

        // Origin: api-core への gRPC - コールドミス、約20ms 追加
        link, err := h.origin.GetLink(ctx, &pb.GetLinkRequest{Slug: slug})
        if err != nil {
            return nil, err
        }
        payload, _ := marshalLink(link)
        h.redis.Set(ctx, cacheKey(slug), payload, redisTTL)
        h.l1.Set(slug, link, linkCost(link))
        return link, nil
    })
    if err != nil {
        return nil, err
    }
    return val.(*Link), nil
}

L1 は ristretto で、Dgraph の admission-controlled LRU キャッシュです。admission コントローラーが重要です:スキャンワークロード(ボットが数千のユニークスラッグにヒット)下でのナイーブな LRU は、二度とリクエストされない冷たいエントリのためにホットエントリを退避させます。ristretto の TinyLFU ベースの admission ポリシーはこれに抵抗します - 頻度カウンターを安価に追跡し、キャッシュが圧迫されているときに一度も見たことのないエントリの admission を拒否します。正味の効果は、攻撃的なスキャントラフィック下でのキャッシュヒット率が崩壊するのではなく、オーガニックなヒット率近くに保たれることです。

L2 は Redis Cluster です。各 POP には、クロスリージョントラフィックをホットパスから排除するために独自のクラスターインスタンスがあります。FRA と ASH は pub/sub 無効化シグナルの別の Redis インスタンスを共有します(以下で詳述)。SGP は独自のインスタンスを持っています。同じデータセンター内の単一の Redis GET は確実に1ms 未満です。過去90日間の L1+L2 合算ヒット率は約99.4%で - つまり origin の呼び出しはリクエストの約167件に1件で発生します。

solutions/developers のユースケース - API を使って高ボリュームでリンクを作成するチーム - への実際的な含意は、新しく作成されたリンクは POP ごとに1回のコールドミスを経験し、その後 TTL の期間中ウォームになるということです。トラフィックのないリンクは手動退避なしに両方のキャッシュからクリーンに期限切れになります。

15ms の使い道#

以下の図は p95 キャッシュヒットバジェットをフェーズごとに分解します:

TLS 再開 2ms、L1 ルックアップ 0.4ms、ヘッダービルド 1ms、ネットワーク返送 9ms、マージン 2.6ms に分解された15ms p95 キャッシュヒットバジェットを示す水平スタックバー。FRA の中央値の例示値。

支配的なセグメントはネットワーク返送です - 中央値で約9ms、つまり訪問者と POP 間の物理的な距離がバジェットの60%を占めます。これは圧縮できません。マルチリージョンのデプロイメントが唯一のレバーです:POP を追加するとそのリージョンの訪問者の中央値 RTT が減少します。ロードマップの次のリージョンは南アジアのトラフィックの SGP p95 を削減します。現在はシンガポールが最寄りの POP なため14ms にルーティングしています。

TLS セッション再開での2ms は、すでにセッションチケットを持っている TLS 1.3 0-RTT を前提とします。特定のデバイスからの初回訪問では、フルの TLS ハンドシェイクで約10〜15ms が追加されます - だからこそ15ms バジェットはキャッシュヒット+再開セッションのトラフィックに明示的にスコープを当てています。これは実際のクリックトラフィックの大部分です。RFC 7234 は HTTP 層のキャッシュセマンティクスを規定しています。特に注目すべきは、302 レスポンスはデフォルトではブラウザキャッシュに保存されないことです(§4.2.2)。これは私たちのユースケースで正しい動作です - すべてのリダイレクトリクエストはエッジに届き、すべてのリダイレクトは独自のルーティング決定を持ち、ブラウザキャッシュに古い宛先はありません。

2.6ms のマージンは本物の運用上のヘッドルームであり、パディングではありません。Go の GC では、チューニングされた GOGC 設定でも0.5〜1ms オーダーの偶発的なストップザワールドポーズが予想されます。Caddy のプロキシオーバーヘッドが小さな固定コストを追加します。マージンは、これらの効果が複合するときにバジェットを超えないようにします。

キャッシュ無効化#

Redis の pub/sub がメカニズムです。api-core でリンクが変更されると - 宛先が変更、ターゲティングルールが更新、リンクがアーカイブ - 変更ハンドラーはスラッグをペイロードとして link:invalidate チャンネルに公開します。すべてのエッジ POP がこのチャンネルをサブスクライブしています。受信時に、サブスクライバーは l1.Del(slug)redis.Del(cacheKey(slug)) を呼び出します。そのスラッグへの次のリクエストが両方のティアを origin から再投入します。

60秒の L1 TTL は主要なメカニズムではなくフォールバックです。pub/sub サブスクライバーがダウンしている場合 - Redis の一時的な障害や POP と pub/sub インスタンス間のネットワーク分断など - エントリは最大60秒で L1 から期限切れになります。L2 TTL は300秒に設定されているため、サブスクライバーの停止は最大5分間の潜在的に古い L2 データを意味します。この間 L1 TTL が唯一の安全網です。30秒以内に pub/sub サブスクリプションの喪失をアラートします。

時間ウィンドウのルールを持つスマートリンクでは、古さに特定の含意があります:ルールが 17:00 にアクティブになり、エッジ POP の L1 が最大60秒の残 TTL で以前のルールバージョンをキャッシュしている場合、17:00 から 17:01 の間のトラフィックは更新前の宛先に向かう可能性があります。pub/sub パスは一般的なケースではこれを排除します。60秒 TTL はエッジケースをカバーします。タイミング境界が正確に重要なキャンペーンでは、古いルールに status=disabled を使用し、1 TTL サイクル(60秒)待ってから新しいルールをアクティブにすることをお勧めします。パイプラインが進む前に伝播を確認できるよう、GET /v1/links/{id}/cache-status でポーリングエンドポイントを追加しました。

実際のリージョン計測値#

以下の数値は、2026-05-12で終わる90日間に収集されたデモワークスペースのデータから来ています。キャッシュヒットトラフィックのみを反映しています。すべてのタイムスタンプは UTC です。

リージョンPOPp50p95p99
EU(フランクフルト)FRA · Hetzner4.8ms12.1ms18.4ms
US East(Ashburn)ASH · Hetzner5.2ms13.4ms20.1ms
SE Asia(シンガポール)SGP · OVH5.6ms14.2ms22.8ms

FRA が最速なのは、ワークロードの大部分がヨーロッパであるため、中央値 RTT が低いからです。SGP はより広い地理的広がりに対応しています - 東南アジアのトラフィックは RTT が低く、南アジアと東アジアのトラフィックはテールに追加されます。

p99 の数値は15ms バジェットを超えています。これは意図的です。バジェットは p95 であり、p99 ではありません。p99 は外れ値の条件によって形成されます:セルラーハンドオフ、TCP 再送信、偶発的な Redis レイテンシスパイク。p99 を監視しますが、SLA の対象にはしません。エンジニアリング上の決定は、p95 が「ほぼ常にほぼ全員」のエクスペリエンスをキャプチャするというものであり、最後の1%を最適化するには制御下にない自然なネットワーク変動の原因を排除する必要があります。

コールドミスの p95 は約22ms です。これは、origin gRPC が同じデータセンター内のラウンドトリップを追加する(FRA → プライベートネットワーク上の FRA は約0.3ms)ことと api-core の Postgres ルックアップ(キー付きスラッグルックアップで通常1〜3ms)を考慮した場合に達成できる下限です。22ms は推定値ではなく実測値です。コールドミスパスに許容するバジェット内にあり、これは p95 35ms に設定されています。

マルチリージョン analytics を評価するチームの場合、これらのレイテンシ数値はメトリクスエンドポイントから Prometheus メトリクス(regioncache_tier ラベル付きの redirect_duration_seconds)として利用可能です。

最初にブログで書かなかった障害モード#

キー期限切れ時のサンダリングハード#

singleflight を追加する前は、中程度のトラフィック下で L1 と L2 の両方からスラッグが同時に期限切れになると、同じスラッグに対する並行 origin gRPC 呼び出しのバーストが生成されました - それぞれが同じスラッグに対して Postgres 読み取りを行い、すべて同じ結果を返しました。負荷テスト下では、これがリンク作成ボリュームとは無関係の api-core CPU のスパイクを生成しました。singleflight グループは同じスラッグへの並行ミスを単一の origin 呼び出しに折りたたみます。待機中の他のゴルーチンはグループでブロックされ、解決時に同じ結果を受け取ります。実装は標準的な Go の golang.org/x/sync/singleflight パッケージです。

最初のプロトタイプでこれを間違えました。キー期限切れ時のサンダリングハードは単体テストには現れない障害モードです - 現実的な並行性下でのみ現れます。修正が本当に単純なため、この投稿に追加しています。キャッシュアーキテクチャの解説では一般的な省略です。

Redis 障害時のフォールバック#

POP がその Redis クラスターへの接続を失った場合、フォールバックはエラーではありません - コードパスは L1 のみと L1 ミス時の直接 origin gRPC に劣化します。POP はサービスを継続します。L2 が利用できないためヒット率が低下し、origin 呼び出しボリュームがスパイクしますが、リダイレクトパスは機能を維持します。Redis 障害パスは本番で2回実行されました(どちらも Hetzner のメンテナンスウィンドウでした)。2回目のインシデント中のピーク origin 呼び出し率は、障害の継続時間(約4分間)でベースラインの約8倍でした。api-core はスケーリングイベントなしに処理しました。

POP フェイルオーバー時の DNS 伝播#

anycast フェイルオーバーは BGP 層です - 待つべき DNS TTL もなく、リクエストパスのアプリケーション層のヘルスチェックタイムアウトもありません。POP がオフラインになるとルートの BGP 撤退がトリガーされ、ネットワークトラフィックは BGP 収束ウィンドウ内(影響を受けるパスへのネットワークホップ数によって通常15〜90秒)で次の最寄りの POP にシフトします。関連する運用パラメーターはヘルスチェックの間隔です:POP ごとに10秒ごとに TCP ヘルスチェックを実行します。チェック失敗が撤退をトリガーします。10秒のチェック間隔は、クラッシュした POP が撤退前に最大10秒間失敗したトラフィックを提供できることを意味します。この境界を意図的にテストしました。2つのプロダクションインシデントでの実際の影響はチェック間隔を下回っていました。

ホットパスで行わないこと#

ホットパスにないすべてのアイテムは省略ではなく意図的な選択です。

同期的なクリック書き込み。 クリックは Redpanda にファイア・アンド・フォーゲットです。リダイレクトハンドラーはスラッグ、タイムスタンプ、切り詰めされた IP、ユーザーエージェントハッシュを Kafka トピック(clicks.raw)に追加し、302 で応答します。書き込みはノンブロッキングです。Redpanda が利用できない場合、クリックがドロップされます - リダイレクトではありません。インフラ障害下でのクリック損失は許容可能でありリダイレクト失敗は許容不能という意識的なトレードをしています。click-ingester コンシューマーが Redpanda トピックを処理して ClickHouse に書き込みます。これが、特定のクリックイベントのanalyticsデータが即時ではなく短いラグ(通常5秒未満)で利用可能な理由です。

インラインボットチャレンジ。 ボットチャレンジは最低でも10〜50ms の同期的な作業を追加します - JavaScript チャレンジは完全なラウンドトリップを追加します。リダイレクトパスではどちらも行いません。url-scanner サービスはトラフィック品質シグナルを非同期に処理します。solutions/developers のリンクキャンペーンを構築するチームにとって、これはリダイレクトが正当なトラフィックのエクスペリエンスを低下させるチャレンジの背後にゲートされないことを意味します。

リダイレクト時のスキーマ検証。 宛先 URL とターゲティングルールは書き込み時に、api-core 経由でリンクが作成または更新されるときに検証されます。スラッグがキャッシュに届く頃には、その構造は検証済みです。リダイレクト時に JSON スキーマ検証も、URL パースステップも、ルール構文チェックもありません。エッジバイナリはキャッシュエントリを完全に信頼します。これは書き込みパスがキャッシュへの admission 前に検証するためにのみ安全です。

地味な部分#

十分に書いていない3つのこと。退屈に読めても、正しく行うことが重要です。

キャッシュサイズのバジェット。 ristretto はシンプルなアイテム数ではなくバイト単位の明示的なコストバジェットで初期化されます。各キャッシュされたリンクはシリアライズされたサイズでコスト付けされ、これはターゲティングルールの数によって異なります。ルールのないリンクのコストは約200バイト。6つのターゲティングルールを持つリンクのコストは約800バイトです。バジェットはインスタンスの利用可能な RAM の最大10%を消費するように設定されており、Go ランタイム、Caddy、接続バッファのヘッドルームを残します。これを間違えるとキャッシュスラッシングが発生します:小さすぎるバジェットは TTL が期限切れになる前にエントリを退避させ、L2 と origin へのトラフィックを押しやります。

負荷下での GC チューニング。 Go のガベージコレクターはデフォルトでよくチューニングされていますが、デフォルトの GOGC=100 はライブヒープサイズの2倍で GC をトリガーします。ライブヒープが小さいがアロケーション率が中程度のリダイレクトハンドラー(fasthttp はホットパスでゼロアロックですが、クリックイベントと gRPC 呼び出しにはオブジェクトアロケーションがあります)では、GC が必要以上に頻繁に発火します。本番では GOGC=400 で実行しています。効果は GC サイクルが長くなるが頻度が低くなることです - テールレイテンシに重要です。2ms かかって4秒に1回発生する GC サイクルは、1ms サイクルが毎秒1回発生するよりも p99 への寄与が小さくなります。デプロイ設定に設定する前に make bench で経験的に検証しました。

make bench の規律。 エッジバイナリにはベンチマークスイートがあります(services/edge-redirect 内から go test -bench=. -benchmem ./...)。ホットパスへのすべての提案された変更 - 新しいヘッダーの追加、キャッシュキー形式の変更、ルール評価器の調整 - はマージ前にベンチマークを通過します。p50 ベンチマークに0.5ms 追加する変更は本番での p95 を動かす変更です。ベンチマークはゲートであり、事後的なチェックではありません。一度これについて油断しました。スラッグ正規化ロジックを変更したリファクタリングで、2日後にリージョンのダッシュボードに現れた1.2ms のリグレッションをシップしました。リグレッションは現実のものであり、教訓は根付きました。


ここでのアーキテクチャの決定は /docs/architecture/edge-redirect でより詳しく文書化されています。高ボリュームのキャンペーンや開発者プラットフォームのリダイレクトインフラ層として Elido を評価している場合、solutions/developers ページでは API サーフェスと SDK オプションを説明しています。2層キャッシュがスマートリンクの動作に何を意味するか - 特にルール変更の伝播ウィンドウ - については、スマートリンク解説ポストが詳しく説明しています。


Marius Voß は Elido の DevRel とエッジインフラ担当です。プロトタイプから本番へ edge-redirect バイナリをシップしたエンジニアの一人であり、それ以来そのレイテンシダッシュボードをずっと見つめ続けています。

Elidoを試す

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

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

Free、登録不要 · 1日あたり2件

Elidoを試す

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

タグ
url shortener performance
edge redirect latency
multi-region url shortener
redirect cache strategy
fasthttp
anycast routing

続きを読む